Compare commits

...

93 Commits

Author SHA1 Message Date
Matt Aitken 70f9bd0d70 Latest lockfile 2024-02-12 13:11:14 +00:00
github-actions[bot] a1860dbaae chore: Update version for release (#899)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-02-12 13:00:14 +00:00
Matt Aitken d69e4e712d OpenAI integration: retry 400 status code because OpenAI returns this sometimes for no reason (#898) 2024-02-12 12:48:48 +00:00
Matt Aitken 7fae67c47d Fix/autoyield canceled run (#890)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 16s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Todo for the fix

* If the run has been canceled during execution, don’t do any further processing of the run
2024-02-09 11:06:19 +00:00
Matt Aitken 9ae0ca64af Fix edge cases with deleting last org/project (#892)
* If you have an org with no projects, it displays in the project dropdown with a “New project” button

* When creating a new org disable the button whilst it’s doing the request

* If an org already had any deleted projects it couldn’t be deleted…

* When selecting the best project, factor in deleted ones

* Don’t show the cancel button when creating a new org if there are no non-deleted projects

* If a project has already been deleted just return
2024-02-09 11:05:28 +00:00
Matt Aitken da69fa0613 Fixes #886. Removed “public” schema from the JobListPresenter query
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-02-07 16:48:18 +00:00
Matt Aitken 336029b842 Organization renaming, deleting and one-click email unsubscribe (#885)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Added the org settings page to the sidebar

* Added loading states when renaming/deleting projects

* Don’t show deleted orgs in the app

* The actual db migration file

* The Org settings page with the actions working

* Don’t remove org members, just leave them

* Don’t show invites from orgs that are deleted

* Allow disabling IntegrationConnections

* Don’t refresh IntegrationConnections that are disabled

* Set all the integrations as disabled

* Updated the unsubscribe checkbox text

* Unsubscribe route

* Use the magic link secret, not the encryption key. Also make the error message more vague

* Only members of the org or project can rename them

* Don’t throw an error if the connection can’t be refreshed, return undefined instead
2024-02-07 10:52:50 +00:00
Matt Aitken 29edcd3df9 Rename and delete projects (#880)
🚀 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 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Side menu: selector title changed to project. Changed section headers and made it Slack + Discord

* Added project settings page. Renaming is working

* Added project.deletedAt column

* Deleting projects WIP

* Allow clearing the project from the session

* Don’t load projects that are deleted

* Improved the project selection logic

* Clear the session project id when deleting a project

* Deleting the last project in an org now works. The new project page doesn’t have the sidebar anymore.

* Removed false code comment

* Added the JobRun index back in, using Prisma. Otherwise it tries to remove it on each migration

* Only return environments where the project isn’t deleted. This will block API calls

* Do the project deletedAt check in code, not SQL

* Made it clearer what the code does with a comment and applied the same logic to the public api key
2024-02-05 13:14:33 +00:00
Matt Aitken a9ff32418e Fix for when there are no jobs…
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-30 17:35:04 +00:00
Matt Aitken 7358fcb891 Latest lockfile 2024-01-30 17:07:24 +00:00
Matt Aitken 28052daad9 Faster jobs page (#879)
* Optional next/previous with the list pagination

* Defer the loading of the runs table

* Only return the latest run in a separate query, then do a join in code

* Added a composite index to speed up getting the latest run from a job id
2024-01-30 17:02:04 +00:00
github-actions[bot] 364c8c5f7f chore: Update version for release (#876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-30 13:36:36 +00:00
Matt Aitken 4b3b418abb Set endpoint URLs to null, instead of deleting them (#878)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Added Endpoint deletedAt column

* Only show endpoints where they’re not deleted

* Don’t delete Endpoints, set the deletedAt and change their slug name

* Only perform indexing if the endpoint isn’t deleted

* Have a nullable URL for endpoints

* Deal with null URLs throughout the app

* Re-running and retrying behaves properly when there’s no endpoint URL

* Remove console.log

* Better error message when doing a run
2024-01-30 10:45:12 +00:00
Matt Aitken 2e354d342c Deleting endpoint working (#875) 2024-01-29 11:57:25 +00:00
Matt Aitken dd879c8e4a Fix api run statuses (#874)
* Added a subtask for testing

* Make it easier to run the CLI from the nextjs reference project

* Copies of the run and statuses endpoints, but without simplifying the run statuses

* Use the new v2 endpoints that give the full run statuses

* Removed unused import

* v2 events endpoint with the full run status info

* Changeset
2024-01-29 11:42:19 +00:00
Matt Aitken af485b9180 Fix custom oauth client (#872)
* A checkbox can now be readonly and not be checkable, with correct styling

* Use readOnly for the hasCustomClient checkbox, not disabled

* Better styling of the disabled state

* The update oauth form too
2024-01-26 17:25:15 +00:00
Matt Aitken a739ebaa88 Tweaked the layout of the usage runs charts so the daily runs are in the box with other run charts 2024-01-26 15:05:52 +00:00
Kritik Jiyaviya 3ebc2578e0 feat: Add daily runs graph (#865)
* feat: Add daily runs graph

* Merge branch 'main' into feat/daily-runs-graph

* update DayRunsChart

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-26 15:00:30 +00:00
Kritik Jiyaviya 0b657b33f9 Allow retrying a job run with an invalid payload (#869) 2024-01-26 12:39:23 +00:00
Erin Allison 7ac942dc0e Address unintended type coercion in PageInfoPropertyComponent (#861)
Fixes triggerdotdev/trigger.dev#858

Signed-off-by: Erin Allison <erin@eallison.us>
2024-01-26 09:44:55 +00:00
Matt Aitken 9b12016428 Improved SQL reads for some dashboard pages (#868)
* Pass subscription status into the usage bar

* Page navigation spinner is now blue (was a bit subtle before)

* Better logging of db queries, this will be commented out before the PR is merged

* Select only the required fields

* We don’t need the member count for each org

* WIP redirecting with projectId in session

* Switching projects is now working, without duplicating the project query

* Removed logs in revalidate function

* Removes some unused imports

* ProjectPresenter: removed lots of unused db selects

* Root use defaultShouldRevalidate, not just true

* Simplified the job list query and separated the deleting job modal query

* Use requireUserId instead of requireUser wherever possible

* EventListPresenter query simplified

* Simplified the RunListPresenter query

* Disable query logging

* Use the latest updated version for the job list table

* We need to use the org presenter on the select plan page
2024-01-26 09:41:11 +00:00
Matt Aitken d6b44de4ba Defer the loading of the usage data (#866)
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Updated to the latest remix-typedjson package

* Throw an error if the org isn’t found

* Use defer. Doesn’t work because Date isn’t deserialized correctly

* Use regular defer, typeddefer isn’t working properly…

* Don’t update remix-typedjson, wasn’t needed

* Organize imports
2024-01-22 10:26:33 +00:00
Matt Aitken 17df4839d7 Latest lockfile
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-21 17:30:21 +00:00
github-actions[bot] e3f78178f7 chore: Update version for release (#864)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-21 17:29:23 +00:00
Kritik Jiyaviya da90ee13c3 fix: Resend integration example comment (#860) 2024-01-21 17:16:26 +00:00
Matt Aitken 583da458ec Changed the minimum interval time period from 60s to 20s (with changeset) 2024-01-21 17:06:09 +00:00
Matt Aitken dcf95c4eb2 Revert "Changeset: Changed the minimum interval time from 60s to 20s"
This reverts commit c272e38de2.
2024-01-21 17:04:11 +00:00
Matt Aitken 32cf5790cb Revert "Changed the minimum interval time from 60s to 20s"
This reverts commit ca78ddc2c2.
2024-01-21 17:04:11 +00:00
Matt Aitken c272e38de2 Changeset: Changed the minimum interval time from 60s to 20s 2024-01-21 16:29:54 +00:00
Matt Aitken ca78ddc2c2 Changed the minimum interval time from 60s to 20s 2024-01-21 16:28:28 +00:00
Matt Aitken 07ed8c346a Fix for enabled env var in docs 2024-01-20 14:01:56 +00:00
Matt Aitken d4391f2e2d Fixed link in io.logger to the run log 2024-01-20 12:54:11 +00:00
Eric Allam 2c328ba93d Fix pnmp lock file again 2024-01-17 14:50:04 +00:00
github-actions[bot] 68dbc07ee8 chore: Update version for release (#854)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:49:19 +00:00
Eric Allam 58448243af Fix pnpm i 2024-01-17 14:41:51 +00:00
Eric Allam 6c4047cf21 Fix an issue where runs were stuck executing when a child task failed and the parent task retried 2024-01-17 14:39:06 +00:00
github-actions[bot] 831860eace chore: Update version for release (#853)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 14:13:34 +00:00
Matt Aitken db46f2a69a Add explicit return type to deprecated sendEmail function because tsup was complaining 2024-01-17 14:07:45 +00:00
Kritik Jiyaviya 4dd6cf18dc feat: Add support for the new Resend audience API endpoints (#850)
* feat: Add support for the new Resend audience API endpoints

* update pnpm-lock.yaml

* update pnpm-lock.yaml

* Merge branch 'main' into feat/support-for-resend-audience-api

* latest pnpm-lock.yaml

* Create lovely-items-end.md

* Update pnpm-lock.yaml

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-17 14:06:45 +00:00
Eric Allam adf66d23b8 Update pnpm lock file
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 15s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-17 11:15:30 +00:00
github-actions[bot] 5c42831a60 chore: Update version for release (#846)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-17 11:14:52 +00:00
Eric Allam 26f5e7774d Fix yalt.dev connection timeouts by increasing timeout from 1s to 5s (#851)
* Increase the yalt tunnel connection timeout to 5 seconds

* Add changeset
2024-01-17 10:27:57 +00:00
Matt Aitken f209a3b364 Ability to disable organization runs + more obvious if you're over the free limit (#848)
* Fix for typo in error when a Job invoke fails

* Can disable runs for an Organization

* Don’t perform runs if runsEnabled === false for an Org

* Exceeded runs messages made into errors, they were very subtle

* Return the runsEnabled property for each org

* More obvious message if you’re over the free run limit

* Added a red border to the free usage panel if you’re over your run limit

* Added missing property to Storybook for the FreePlanUsage component
2024-01-17 09:42:58 +00:00
Son Yen Nguyen a93b554f8b Feat: Update cron parser human readable string (#847)
* Update cron parser human readable string

* Create hot-cobras-flow.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-16 17:22:35 +00:00
Matt Aitken 0f342cd1be Don't show duplicate Job warning if it's an internal job 2024-01-16 15:54:20 +00:00
Matt Aitken 7df2f85a1f Latest lockfile 2024-01-16 15:22:21 +00:00
Matt Aitken f14180d13c Also hide "dev.trigger.scheduled" events from the event list 2024-01-16 15:21:45 +00:00
github-actions[bot] ab6b9514cd chore: Update version for release (#844)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-16 15:00:28 +00:00
Matt Aitken 98345d67d9 Allow unchecked array access in the CLI tsconfig. This matches the other packages… 2024-01-16 14:46:31 +00:00
Matt Aitken 38f5a90399 Auto-yielding is skipped for tasks that are no-ops and subtasks 2024-01-16 14:01:05 +00:00
Matt Aitken 1b2635ae4a Don’t show “trigger.scheduled” events in the list 2024-01-16 12:14:08 +00:00
Matt Aitken 129f023d11 EventTrigger source wasn’t getting passed through to the class 2024-01-16 12:07:11 +00:00
Matt Aitken 795e637dec caniuse-lite updated, it was logging out an annoying message 2024-01-16 11:52:08 +00:00
Matt Aitken 5238c424fc Absolute date filtering (#845)
* WIP on absolute date filtering for runs

* Use the date hook instead

* Reworked the date field again so the state behaves nicely

* Way better date filtering

* Setting the absolute date is working well, also clearing filters

* Reverse date format

* Turn off the guide

* Added time filtering and clearing to the events page
2024-01-16 11:44:15 +00:00
Matt Aitken 1bbd7e6dc3 V3 CLI started, with Personal Access Tokens and Who Am I (#841)
* Initial CLI commit

* Hooked up the CLI so it actually shows stuff…

* Totally reworked the v3 CLI to be based on our existing CLI (Commander)

* WIP on PersonalAccessTokens and AuthorizationCodes

* WIP creating a Personal Access Tokens page. Created a new sidenav for account pages

* Creating tokens is working but the form is broken

* Tokens are created in the UI

* Creating and revoking access tokens from the UI is working

* Improved the create form and copy

* Tokens are a bit shorter and only lowercase

* API endpoint for creating AuthorizationCodes and the web page users hit to create PATs from them

* V3_ENABLED env var and hook that can be used in the UI to show/hide things

* API route to get a PAT (within 10 mins of creating an auth code). Moved some code to core

* Start to build the login command

* Nicer banner when starting the CLI

* Nicer update checking

* Update command style improved

* Removed the template step

* Login command options are now working

* The new CLI is logging in using the Personal Access TOken. But I need to save it still

* Logging in and saving the token is working

* Deal with already being logged in

* Who Am I working with PAT

* Deleted old account side menu header

* Improved the copy on the Auth code page

* Added docs for the login and whoami commands

* Added readme instructions for the update command

* Removed some unused things

* Remove fetchUseProxy for now
2024-01-15 14:45:41 +00:00
Kritik Jiyaviya 3bb82ed9a5 feat: runs filtering by relative timeframe (#832)
* feat: runs filtering by relative timeframe

* improve logic

* Used formatDateTime

* fix: logic of  determineTimeFrame() function

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:13:20 +00:00
Abhimanyu Yadav ff4ff869ab feat(logger): accepting Error objects as parameters in io.logger.error() (#797)
* feat(logger): accepting Error objects as parameters in io.logger.error()

* Allow errors for any log level

* Create silent-ties-vanish.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 14:02:41 +00:00
Matt Aitken 1dcee2b338 Changed docs analytics to PostHog EU 2024-01-15 13:51:02 +00:00
Aditya Tripathi 0a798446c8 feat: managed cloud check for search indexing (#825)
* feat: managed cloud check for search indexing

* fix: vice versa on the robots meta tag

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-15 09:35:43 +00:00
Kritik Jiyaviya 209942a63d fix: integrations list scrollbar (#829) 2024-01-15 09:29:42 +00:00
Manthan Mallikarjun 57a9b35870 Fix typo in deployment-automatic docs (#842)
Env var is wrong
2024-01-15 09:28:37 +00:00
Fabian B a16b65f666 docs: fix syntax error (#839) 2024-01-14 16:55:01 +00:00
Matt Aitken 5871745a92 Latest lockfile 2024-01-12 14:13:32 +00:00
github-actions[bot] 87b5dfbf11 chore: Update version for release (#837)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-12 14:10:42 +00:00
Michelle Rosenberger 5af2003516 Fix Stripe integration webhook validation (#836)
The SubtleCryptoProvider cannot be used in a synchronous context. This
is a problem for non-node environments where the Stripe client falls
back to SubtleCryptoProvider.

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-12 14:05:33 +00:00
Matt Aitken 85ce729bf2 Latest lockfile 2024-01-12 13:55:03 +00:00
Matt Aitken 5701d1da42 Reference the —https flag in the instructions if your endpoint isn’t found 2024-01-12 13:54:36 +00:00
Chigala bc61d83764 feat: updates CLI to use https flag (#814)
* feat: updates CLI to use https flag

* added changeset

* Added yalt to the changeset

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-12 13:53:43 +00:00
nicktrn 5bf125be0d Fix: helm chart and docs (#827)
* Improve helm chart and docs

* Minor docs changes

* Add alternative commands to specify values
2024-01-11 13:54:22 +00:00
Eric Allam babe1c0e54 Fix pnpm lock file 2024-01-10 10:58:07 +00:00
github-actions[bot] 1224fceb18 chore: Update version for release (#831)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-10 10:57:32 +00:00
Eric Allam 8277f4d249 Use correct overload param when invoking a job outside of a run (fixes #802) 2024-01-10 10:50:12 +00:00
Eric Allam 73cb8839a5 Fixed invoke inferred payload types (fix #830) 2024-01-10 10:44:35 +00:00
Matt Aitken 5d0a731cf6 Event page tweaks (#826)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Don’t show internal events

* Show the cancelled time

* Made the Pagination component accept any compatible list type

* Changed some wording on the event detail page
2024-01-05 12:39:27 +00:00
Kritik Jiyaviya 25a152517e feat: Events list page (#824)
* feat: add events list

* Changed the Events icon in the sidemenu

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-05 12:04:31 +00:00
Matt Aitken d1092fcd2c Run filtering now works on the job runs page too (#823)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Run filtering is now shared: runs page and job runs page

* Use optimistic location

* Improved the type in the run status filter dropdown

* Organize imports
2024-01-03 15:50:45 +00:00
Matt Aitken 61d33ed1b8 Latest lockfile 2024-01-03 14:23:21 +00:00
github-actions[bot] 7a51612fc8 chore: Update version for release (#822)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-01-03 13:48:31 +00:00
Abhimanyu Yadav 4d380173c8 feat:runs filtering by status and environment (#800)
* feat: view teammates' job runs on dev env

* fix typechecks

* feat: add filter functionality on task run page

* feat:adding filter functionality on run task page

* changes filter from csr to ssr

* changing filter functionality from csr to ssr

* adding url.string() for navigation purpose

* improving state management in filtering

* When setting a filter, delete the cursor and direction

* Some design improvements for the "All" options

---------

Co-authored-by: hmacr <hmac.devo@gmail.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-03 13:37:50 +00:00
Hemachandar 8880d8afe2 feat: view teammates' dev env job runs (#708)
* feat: view teammates' job runs on dev env

* fix typechecks

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-03 13:11:22 +00:00
Jan Henning ef037af457 Add a comment explaining that JSON parse middlewares need to come after the Trigger middleware (#773)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 3s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2024-01-03 11:19:34 +00:00
Dan 390ac6101e Added new http-endpoint examples, changed examples links in the docs (#775)
* updated companyicons package

* Changed custom help so it doesn’t show code examples

* Added Asana / Instagram / Salesforce / Segment / Square / Svix / Snyk / Todoist & Zapier http endpoint examples to the integration page

* Commented out some of the apis we dont have code examples for

* Uncommented raycast and reddit

* Updated the blank state copy

* Changed the copy so it works better on examples with and without code

* Added Brex / Discoed / Google Sheets, Docs, Drive / Gmail / PagerDuty / YouTube endpoint examples

* Added more examples

* Change the top tab to ‘Examples’ and updated /examples links to point to trigger.dev/apis

* Update package.json

* Updated pnpm-lock file

* Updated pnpm-lock file

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-03 11:18:44 +00:00
Dan aa97bf4a52 Changed posthog to eu (#772)
* Updated posthog url and key

* Create clean-plums-happen.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-03 10:43:14 +00:00
Matt Aitken 86d6e102a4 Added documentation for cancelRunsForJob 2024-01-03 10:38:23 +00:00
Matt Aitken adf497bc3e Example in the job-catalog of canceling all runs for a Job 2024-01-03 10:31:56 +00:00
Kritik Jiyaviya f7bf25f03f feat: Add ability to cancel all runs for job from SDK (#819)
* feat: Add ability to cancel all runs for job from SDK

* Create nine-trainers-vanish.md

* Cancel runs that are executing and waiting

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-03 10:30:19 +00:00
GuyBorderless f8cab96fa2 use --env cli flag for deno (#820)
* --env cli flag

* Update hono.mdx

* Update hono.mdx
2024-01-03 10:27:39 +00:00
nicktrn 225614fea3 Feature: ARM builds (#821)
* Add native ARM runner

* Switch to arch-agnostic manifest digest

* Set correct latest tag
2024-01-02 15:48:53 +00:00
Yash Khokhaneshiya 740b7b2385 feat: Add $not to eventFilters (#815)
* feat: Add  to eventFilters

* not filter test case added for false evaluation

* Added a patch changeset for @trigger.dev/core

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-01-02 15:43:02 +00:00
Matt Aitken 26d223ab36 Latest lockfile 2023-12-22 15:50:13 +00:00
github-actions[bot] 0c4fd9a631 chore: Update version for release (#813)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-22 15:43:15 +00:00
Matt Aitken 5060eec01b Update Octokit to account for CVE-2023-50728 (#812) 2023-12-22 15:35:47 +00:00
Matt Aitken aaea064208 Added “graphile” to the logs when they zodWorker starts, to make it easier for self hosters to debug 2023-12-22 10:46:05 +00:00
299 changed files with 13546 additions and 1968 deletions
+15 -3
View File
@@ -3,7 +3,12 @@ on:
workflow_call:
jobs:
publish:
runs-on: buildjet-4vcpu-ubuntu-2204
strategy:
fail-fast: true # when a job fails, all remaining ones will be cancelled
matrix:
runs-on: [buildjet-4vcpu-ubuntu-2204, buildjet-4vcpu-ubuntu-2204-arm]
name: ${{matrix.runs-on}}
runs-on: ${{matrix.runs-on}}
outputs:
version: ${{ steps.get_version.outputs.version }}
short_sha: ${{ steps.get_commit.outputs.sha_short }}
@@ -36,6 +41,9 @@ jobs:
echo "Invalid reference: ${GITHUB_REF}"
exit 1
fi
if [[ ${{matrix.runs-on}} == *-arm ]]; then
IMAGE_TAG="${IMAGE_TAG}-arm"
fi
echo "::set-output name=version::${IMAGE_TAG}"
- name: 🔢 Get the commit hash
id: get_commit
@@ -65,8 +73,12 @@ jobs:
- 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
LATEST=latest
if [[ ${{matrix.runs-on}} == *-arm ]]; then
LATEST="${LATEST}-arm"
fi
docker tag release_build_image $REGISTRY/$REPOSITORY:$LATEST
docker push $REGISTRY/$REPOSITORY:$LATEST
env:
REGISTRY: ghcr.io/triggerdotdev
REPOSITORY: trigger.dev
@@ -22,7 +22,7 @@ const tooltipStyle = {
color: "#E2E8F0",
};
type DataItem = { date: Date; maxConcurrentRuns: number };
type DataItem = { date: string; maxConcurrentRuns: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
@@ -62,25 +62,34 @@ export function ConcurrentRunsChart({
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (item.date.getDate() === 1) {
return dateFormatter.format(item.date);
if (!item.date) return "";
const date = new Date(item.date);
if (date.getDate() === 1) {
return dateFormatter.format(date);
}
return `${item.date.getDate()}`;
return `${date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const date = data.at(0)?.payload.date;
if (!date) {
const dateString = data.at(0)?.payload.date;
if (!dateString) {
return "";
}
return dateFormatter.format(date);
return dateFormatter.format(new Date(dateString));
}}
/>
{concurrentRunsLimit && (
@@ -0,0 +1,89 @@
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Paragraph } from "../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
border: "1px solid #1A2434",
backgroundColor: "#0B1018",
padding: "0.3rem 0.5rem",
fontSize: "0.75rem",
color: "#E2E8F0",
};
type DataItem = { date: string; runs: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
export function DailyRunsChart({
data,
hasDailyRunsData,
}: {
data: DataItem[];
hasDailyRunsData: boolean;
}) {
return (
<div className="relative">
{!hasDailyRunsData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No daily Runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
<LineChart
data={data}
margin={{
top: 20,
right: 0,
left: 0,
bottom: 10,
}}
className="-ml-8"
>
<XAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (!item.date) return "";
const date = new Date(item.date);
if (date.getDate() === 1) {
return dateFormatter.format(date);
}
return `${date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const dateString = data.at(0)?.payload.date;
if (!dateString) {
return "";
}
return dateFormatter.format(new Date(dateString));
}}
/>
<Line dataKey="runs" name="Runs" stroke="#16A34A" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -13,8 +13,15 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
);
const hasHitLimit = cappedPercentage >= 1;
return (
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
<div
className={cn(
"rounded border border-slate-900 bg-[#101722] p-2.5",
hasHitLimit && "border-rose-800/60"
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
@@ -5,6 +5,7 @@ import { formatNumberCompact } from "~/utils/numberFormatter";
import { plansPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import { Callout } from "../primitives/Callout";
type UpgradePromptProps = {
organization: MatchedOrganization;
@@ -18,19 +19,25 @@ export function UpgradePrompt({ organization }: UpgradePromptProps) {
}
return (
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
<Paragraph variant="extra-small" className="text-rose-500">
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
limit
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
<Callout variant="error" className="flex h-full items-center rounded-none px-1 py-0">
<div className="flex items-center justify-between gap-3">
<Paragraph variant="extra-small" className="text-white">
{organization.runsEnabled
? `You have exceeded the monthly ${formatNumberCompact(
currentPlan.usage.runCountCap
)} runs
limit`
: `No runs are executing because you have exceeded the free limit`}
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
</Callout>
);
}
@@ -5,9 +5,11 @@ type Environment = Pick<RuntimeEnvironment, "type">;
export function EnvironmentLabel({
environment,
userName,
className,
}: {
environment: Environment;
userName?: string;
className?: string;
}) {
return (
@@ -18,19 +20,19 @@ export function EnvironmentLabel({
className
)}
>
{environmentTitle(environment)}
{environmentTitle(environment, userName)}
</span>
);
}
export function environmentTitle(environment: Environment) {
export function environmentTitle(environment: Environment, username?: string) {
switch (environment.type) {
case "PRODUCTION":
return "Prod";
case "STAGING":
return "Staging";
case "DEVELOPMENT":
return "Dev";
return username ? `Dev: ${username}` : "Dev";
case "PREVIEW":
return "Preview";
}
@@ -0,0 +1,45 @@
import { CodeBlock } from "../code/CodeBlock";
import { DateTime } from "../primitives/DateTime";
import { Header3 } from "../primitives/Headers";
import {
RunPanel,
RunPanelBody,
RunPanelDivider,
RunPanelIconProperty,
RunPanelIconSection,
} from "~/components/run/RunCard";
import { Event } from "~/presenters/EventPresenter.server";
export function EventDetail({ event }: { event: Event }) {
const { id, name, payload, context, timestamp, deliveredAt } = event;
return (
<RunPanel selected={false}>
<RunPanelBody>
<RunPanelIconSection>
<RunPanelIconProperty
icon="calendar"
label="Created"
value={<DateTime date={timestamp} />}
/>
{deliveredAt && (
<RunPanelIconProperty
icon="flag"
label="Delivered"
value={<DateTime date={deliveredAt} />}
/>
)}
<RunPanelIconProperty icon="id" label="Event name" value={name} />
<RunPanelIconProperty icon="account" label="Event ID" value={id} />
</RunPanelIconSection>
<RunPanelDivider />
<div className="mt-4 flex flex-col gap-2">
<Header3>Payload</Header3>
<CodeBlock code={payload} />
<Header3>Context</Header3>
<CodeBlock code={context} />
</div>
</RunPanelBody>
</RunPanel>
);
}
@@ -0,0 +1,16 @@
import { z } from "zod";
import { DirectionSchema, FilterableEnvironment } from "~/components/runs/RunStatuses";
export const EventListSearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
@@ -0,0 +1,104 @@
import { useNavigate } from "@remix-run/react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { Paragraph } from "../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
import { EventListSearchSchema } from "./EventStatuses";
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
import { useCallback } from "react";
import { Button } from "../primitives/Buttons";
export function EventsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment, from, to } = EventListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
};
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
<SelectGroup>
<Select
name="environment"
value={environment ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="secondary/small" width="full">
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph variant="extra-small" className="pl-0.5">
All environments
</Paragraph>
</SelectItem>
{environmentKeys.map((env) => (
<SelectItem key={env} value={env}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={{ type: env }} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</div>
);
}
@@ -0,0 +1,134 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { RuntimeEnvironmentType, User } from "@trigger.dev/database";
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,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "../primitives/Table";
type EventTableItem = {
id: string;
name: string | null;
environment: {
type: RuntimeEnvironmentType;
userId?: string;
userName?: string;
};
createdAt: Date | null;
isTest: boolean;
deliverAt: Date | null;
deliveredAt: Date | null;
cancelledAt: Date | null;
runs: number;
};
type EventsTableProps = {
total: number;
hasFilters: boolean;
events: EventTableItem[];
isLoading?: boolean;
eventsParentPath: string;
currentUser: User;
};
export function EventsTable({
total,
hasFilters,
events,
isLoading = false,
eventsParentPath,
currentUser,
}: EventsTableProps) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Event</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Received Time</TableHeaderCell>
<TableHeaderCell>Delivery Time</TableHeaderCell>
<TableHeaderCell>Delivered</TableHeaderCell>
<TableHeaderCell>Canceled Time</TableHeaderCell>
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Go to page</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={9}>
<NoEvents title="No events found" />
</TableBlankRow>
) : events.length === 0 ? (
<TableBlankRow colSpan={9}>
<NoEvents title="No events match your filters" />
</TableBlankRow>
) : (
events.map((event) => {
const path = `${eventsParentPath}/events/${event.id}`;
const usernameForEnv =
currentUser.id !== event.environment.userId ? event.environment.userName : undefined;
return (
<TableRow key={event.id}>
<TableCell to={path}>{typeof event.name === "string" ? event.name : "-"}</TableCell>
<TableCell to={path}>
<EnvironmentLabel environment={event.environment} userName={usernameForEnv} />
</TableCell>
<TableCell to={path}>
{event.createdAt ? <DateTime date={event.createdAt} /> : ""}
</TableCell>
<TableCell to={path}>
{event.deliverAt ? <DateTime date={event.deliverAt} /> : ""}
</TableCell>
<TableCell to={path}>
{event.deliveredAt ? <DateTime date={event.deliveredAt} /> : ""}
</TableCell>
<TableCell to={path}>
{event.cancelledAt ? <DateTime date={event.cancelledAt} /> : ""}
</TableCell>
<TableCell to={path}>
{event.isTest ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
) : (
<StopIcon className="h-4 w-4 text-slate-850" />
)}
</TableCell>
<TableCell to={path}>{event.runs}</TableCell>
<TableCellChevron to={path} isSticky />
</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 NoEvents({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}
@@ -127,7 +127,7 @@ export function ConnectToOAuthForm({
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
disabled={requiresCustomOAuthApp}
readOnly={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
@@ -135,8 +135,9 @@ export function ConnectToOAuthForm({
{useMyOAuthApp && (
<div className="ml-6 mt-2">
<Paragraph variant="small" className="mb-2">
Set the callback url to <CodeBlock code={callbackUrl} showLineNumbers={false} />
Set the callback url to
</Paragraph>
<CodeBlock code={callbackUrl} showLineNumbers={false} />
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<InputGroup fullWidth>
@@ -3,37 +3,10 @@ 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 { Header1, Header2, Header3 } 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);
@@ -43,7 +16,7 @@ export function CustomHelp({ api }: { api: Api }) {
return (
<div className="mt-4">
<Header1 className="mb-2">Using an API with an SDK or requests</Header1>
<Header1 className="mb-2">Using {api.name} with an SDK or requests</Header1>
<Paragraph spacing>
You can use Trigger.dev with any existing Node SDK or even just using fetch. You can
subscribe to any API with{" "}
@@ -87,9 +60,13 @@ export function CustomHelp({ api }: { api: Api }) {
</>
) : (
<>
<Header2 className="mb-2">Example code using fetch / an existing SDK</Header2>
<Header2 className="mb-2">Getting started with {api.name}</Header2>
<Paragraph spacing className="mb-4">
We recommend searching for the official {api.name} Node SDK. If they have one, you can
install it and then use their API documentation to get started and create tasks. If they
don't, there are often third party SDKs you can use instead.
</Paragraph>
<Paragraph spacing className="mb-4">
You can use one of our examples below as a starting point / reference for your projects.
Please{" "}
<Feedback
button={
@@ -99,24 +76,9 @@ export function CustomHelp({ api }: { api: Api }) {
}
defaultValue="help"
/>{" "}
if you're having any issues.
if you're having any issues connecting to {api.name}, we'll help you get set up as
quickly as possible.
</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>
@@ -117,7 +117,7 @@ export function UpdateOAuthForm({
id="hasCustomClient"
label="Use my OAuth App"
variant="simple/small"
disabled={requiresCustomOAuthApp}
readOnly={requiresCustomOAuthApp}
onChange={(checked) => setUseMyOAuthApp(checked)}
{...conform.input(hasCustomClient, { type: "checkbox" })}
defaultChecked={requiresCustomOAuthApp}
@@ -1,20 +1,45 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useFetcher } from "@remix-run/react";
import { useEffect } from "react";
import { loader } from "~/routes/resources.jobs.$jobId";
import { cn } from "~/utils/cn";
import { JobStatusTable } from "../JobsStatusTable";
import { JobEnvironment, JobStatusTable } from "../JobsStatusTable";
import { Button } from "../primitives/Buttons";
import { Header1, Header2 } from "../primitives/Headers";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { useFetcher } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
import { TextLink } from "../primitives/TextLink";
import { useTypedFetcher } from "remix-typedjson";
type JobEnvironment = {
type: RuntimeEnvironmentType;
lastRun?: Date;
version: string;
enabled: boolean;
};
export function DeleteJobDialog({ id, title, slug }: { id: string; title: string; slug: string }) {
const fetcher = useTypedFetcher<typeof loader>();
useEffect(() => {
fetcher.load(`/resources/jobs/${id}`);
}, [id]);
const isLoading = fetcher.state === "loading" || fetcher.state === "submitting";
if (isLoading || !fetcher.data) {
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
<Header1>{title}</Header1>
<Paragraph variant="small">ID: {slug}</Paragraph>
</div>
<Spinner />
</div>
);
} else {
return (
<DeleteJobDialogContent
id={id}
title={title}
slug={slug}
environments={fetcher.data.environments}
/>
);
}
}
type DeleteJobDialogContentProps = {
id: string;
@@ -22,7 +22,7 @@ import {
} from "../primitives/Table";
import { SimpleTooltip } from "../primitives/Tooltip";
import { runStatusTitle } from "../runs/RunStatuses";
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
import { DeleteJobDialog, DeleteJobDialogContent } from "./DeleteJobModalContent";
import { JobStatusBadge } from "./JobStatusBadge";
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
@@ -99,7 +99,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
{job.properties && (
<SimpleTooltip
button={
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
<div className="flex max-w-[300px] items-start justify-start gap-5 truncate">
{job.properties.map((property, index) => (
<LabelValueStack
key={index}
@@ -165,12 +165,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
</DialogTrigger>
<DialogContent>
<DialogHeader>Delete Job</DialogHeader>
<DeleteJobDialogContent
id={job.id}
title={job.title}
slug={job.slug}
environments={job.environments}
/>
<DeleteJobDialog id={job.id} title={job.title} slug={job.slug} />
</DialogContent>
</Dialog>
</TableCellMenu>
@@ -0,0 +1,104 @@
import { Link } from "@remix-run/react";
import { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
import { Header3 } from "../primitives/Headers";
import { ArrowLeftIcon, ChevronLeftIcon } from "@heroicons/react/24/solid";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { useV3Enabled } from "~/root";
export function AccountSideMenu({ user }: { user: User }) {
const v3Enabled = useV3Enabled();
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
)}
>
<div className="flex h-full flex-col">
<div
className={cn("flex items-center justify-between border-b bg-background p-px transition")}
>
<LinkButton
variant="tertiary/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
Account
</LinkButton>
</div>
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
<SideMenuItem
name="Your profile"
icon="account"
iconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
</div>
{v3Enabled && (
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title="Security" />
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
iconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
data-action="help & feedback"
fullWidth
textAlignLeft
>
Help & Feedback
</Button>
}
/>
</div>
</div>
</div>
);
}
@@ -5,6 +5,6 @@ import { cn } from "~/utils/cn";
export function PageNavigationIndicator({ className }: { className?: string }) {
const navigation = useNavigation();
if (navigation.state === "loading") {
return <Spinner color="muted" className={cn("h-4 w-4", className)} />;
return <Spinner color="blue" className={cn("h-4 w-4", className)} />;
}
}
@@ -3,18 +3,18 @@ import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
ChartBarIcon,
EllipsisHorizontalIcon,
CursorArrowRaysIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useV3Enabled } from "~/root";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import {
@@ -26,11 +26,15 @@ import {
organizationBillingPath,
organizationIntegrationsPath,
organizationPath,
organizationSettingsPath,
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
projectEventsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectSettingsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
@@ -40,11 +44,10 @@ import { LogoIcon } from "../LogoIcon";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, LinkButton } from "../primitives/Buttons";
import { Button } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
@@ -55,7 +58,8 @@ import {
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { SideMenuHeader } from "./SideMenuHeader";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -103,11 +107,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
showHeaderDivider ? " border-border" : "border-transparent"
)}
>
<ProjectSelector
organization={organization}
organizations={organizations}
project={project}
/>
<ProjectSelector organizations={organizations} project={project} />
<UserMenu user={user} />
</div>
<div
@@ -115,7 +115,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={project.name || "No project found"}>
<SideMenuHeader title={"Project"}>
<PopoverMenuItem
to={projectSetupPath(organization, project)}
title="Framework setup"
@@ -144,6 +144,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
data-action="triggers"
hasWarning={project.hasInactiveExternalTriggers}
/>
<SideMenuItem
name="Events"
icon={CursorArrowRaysIcon}
iconColor="text-sky-500"
to={projectEventsPath(organization, project)}
/>
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
@@ -159,9 +165,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
to={projectEnvironmentsPath(organization, project)}
data-action="environments & api keys"
/>
<SideMenuItem
name="Project settings"
icon="settings"
iconColor="text-teal-500"
to={projectSettingsPath(organization, project)}
data-action="project-settings"
/>
</div>
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title={organization.title}>
<SideMenuHeader title={"Organization"}>
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
<PopoverMenuItem
to={inviteTeamMemberPath(organization)}
@@ -197,10 +210,17 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
iconColor="text-green-600"
data-action="usage & billing"
/>
<SideMenuItem
name="Organization settings"
icon="settings"
iconColor="text-teal-500"
to={organizationSettingsPath(organization)}
data-action="organization-settings"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
{currentPlan?.subscription?.isPaying === true ? (
{currentPlan?.subscription?.isPaying === true && (
<Dialog>
<DialogTrigger asChild>
<Button
@@ -256,16 +276,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
</DialogContent>
</Dialog>
) : (
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
)}
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Documentation"
icon="docs"
@@ -308,11 +326,9 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
function ProjectSelector({
project,
organization,
organizations,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
}) {
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
@@ -330,7 +346,7 @@ function ProjectSelector({
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
>
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
<span className="truncate">{organization.title ?? "Select an organization"}</span>
<span className="truncate">{project.name ?? "Select a project"}</span>
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
@@ -341,23 +357,31 @@ function ProjectSelector({
<Fragment key={organization.id}>
<PopoverSectionHeader title={organization.title} />
<div className="flex flex-col gap-1 p-1">
{organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={projectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-bright">
<span className="grow truncate text-left">{p.name}</span>
<MenuCount count={p.jobCount} />
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})}
{organization.projects.length > 0 ? (
organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={projectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-bright">
<span className="grow truncate text-left">{p.name}</span>
<MenuCount count={p.jobCount} />
</div>
}
isSelected={isSelected}
icon="folder"
/>
);
})
) : (
<PopoverMenuItem
to={newProjectPath(organization)}
title="New project"
icon="plus"
/>
)}
</div>
</Fragment>
))}
@@ -372,6 +396,7 @@ function ProjectSelector({
function UserMenu({ user }: { user: SideMenuUser }) {
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
const navigation = useNavigation();
const v3Enabled = useV3Enabled();
useEffect(() => {
setProfileMenuOpen(false);
@@ -409,6 +434,14 @@ function UserMenu({ user }: { user: SideMenuUser }) {
icon={UserProfilePhoto}
leadingIconClassName="text-indigo-500"
/>
{v3Enabled && (
<PopoverMenuItem
to={personalAccessTokensPath()}
title="Personal Access Tokens"
icon={ShieldCheckIcon}
leadingIconClassName="text-emerald-500"
/>
)}
<PopoverMenuItem
to={logoutPath()}
title="Log out"
@@ -421,99 +454,3 @@ function UserMenu({ user }: { user: SideMenuUser }) {
</Popover>
);
}
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</div>
);
}
function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -0,0 +1,38 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
useEffect(() => {
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-slate-500"
>
{title}
</Paragraph>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
</PopoverCustomTrigger>
<PopoverContent
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="start"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
) : null}
</div>
);
}
@@ -0,0 +1,73 @@
import { AnchorHTMLAttributes } from "react";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { IconNames } from "../primitives/NamedIcon";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { Icon } from "../primitives/Icon";
import { IconExclamationCircle } from "@tabler/icons-react";
export function SideMenuItem({
icon,
iconColor,
name,
to,
hasWarning,
count,
target,
subItem = false,
}: {
icon?: IconNames | React.ComponentType<any>;
iconColor?: string;
name: string;
to: string;
hasWarning?: string | boolean;
count?: number;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
to={to}
target={target}
className={cn(
"text-bright group-hover:bg-slate-850",
subItem ? "text-dimmed" : "",
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
{hasWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
)}
</div>
</div>
</LinkButton>
);
}
export function MenuCount({ count }: { count: number | string }) {
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
}
@@ -109,14 +109,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
return (
<div
className={cn(
"group flex cursor-pointer items-start gap-x-2 transition",
"group flex items-start gap-x-2 transition ",
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
buttonClassName,
isChecked && isCheckedClassName,
isDisabled && isDisabledClassName,
(isDisabled || props.readOnly) && isDisabledClassName,
className
)}
onClick={(e) => {
if (isDisabled) return;
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked((c) => !c);
}}
>
@@ -127,12 +129,15 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
value={value}
checked={isChecked}
onChange={(e) => {
//returning false is not setting the state to false, it stops the event from bubbling up
if (isDisabled || props.readOnly === true) return false;
setIsChecked(!isChecked);
}}
disabled={isDisabled}
className={cn(
inputPositionClasses,
"cursor-pointer rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
"rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 read-only:border-slate-650 read-only:!bg-slate-700 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
)}
id={id}
ref={ref}
@@ -141,7 +146,10 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
<div className="flex items-center gap-x-2">
<label
htmlFor={id}
className={cn("cursor-pointer", labelClassName)}
className={cn(
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
labelClassName
)}
onClick={(e) => e.preventDefault()}
>
{label}
@@ -3,6 +3,7 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
const ClientTabs = TabsPrimitive.Root;
@@ -48,4 +49,47 @@ const ClientTabsContent = React.forwardRef<
));
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
export type TabsProps = {
tabs: {
label: string;
value: string;
}[];
currentValue: string;
className?: string;
layoutId: string;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-slate-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-slate-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
@@ -0,0 +1,243 @@
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
import { useDateFieldState } from "@react-stately/datepicker";
import { Granularity } from "@react-types/datepicker";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { useLocales } from "./LocaleProvider";
import { Button } from "./Buttons";
type DateFieldProps = {
label?: string;
defaultValue?: Date;
minValue?: Date;
maxValue?: Date;
className?: string;
fieldClassName?: string;
granularity: Granularity;
showGuide?: boolean;
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
};
export function DateField({
label,
defaultValue,
onValueChange,
minValue,
maxValue,
granularity,
className,
fieldClassName,
showGuide = false,
showNowButton = false,
showClearButton = false,
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
value: value,
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate("utc"));
}
},
minValue: utcDateToCalendarDate(minValue),
maxValue: utcDateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
createCalendar: (name: string) => {
return createCalendar(name);
},
});
//if the passed in value changes, we should update the date
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utcDateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
}
setValue(calendarDate);
}, [defaultValue]);
const ref = useRef<null | HTMLDivElement>(null);
const { labelProps, fieldProps } = useDateField(
{
label,
},
state,
ref
);
//render if reverse date order
const yearSegment = state.segments.find((s) => s.type === "year")!;
const monthSegment = state.segments.find((s) => s.type === "month")!;
const daySegment = state.segments.find((s) => s.type === "day")!;
const hourSegment = state.segments.find((s) => s.type === "hour")!;
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
const secondSegment = state.segments.find((s) => s.type === "second")!;
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
return (
<div className={`flex flex-col items-start ${className || ""}`}>
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-slate-300">
{label}
</span>
<div className="flex flex-row items-center gap-1">
<div
{...fieldProps}
ref={ref}
className={cn(
"flex rounded-sm border border-slate-800 bg-midnight-900 p-0.5 px-1.5 transition-colors focus-within:border-slate-500 hover:border-slate-700 focus-within:hover:border-slate-500",
fieldClassName
)}
>
<DateSegment segment={yearSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={monthSegment} state={state} />
<DateSegment segment={literalSegment("/")} state={state} />
<DateSegment segment={daySegment} state={state} />
<DateSegment segment={literalSegment(", ")} state={state} />
<DateSegment segment={hourSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={minuteSegment} state={state} />
<DateSegment segment={literalSegment(":")} state={state} />
<DateSegment segment={secondSegment} state={state} />
<DateSegment segment={literalSegment(" ")} state={state} />
<DateSegment segment={dayPeriodSegment} state={state} />
</div>
{showNowButton && (
<Button
variant="secondary/small"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
onValueChange?.(now);
}}
>
Now
</Button>
)}
{showClearButton && (
<Button
variant="secondary/small"
LeadingIcon={"close"}
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
state.clearSegment("year");
state.clearSegment("month");
state.clearSegment("day");
state.clearSegment("hour");
state.clearSegment("minute");
state.clearSegment("second");
}}
/>
)}
</div>
{showGuide && (
<div className="mt-1 flex px-2">
{state.segments.map((segment, i) => (
<DateSegmentGuide key={i} segment={segment} />
))}
</div>
)}
</div>
);
}
function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
};
function DateSegment({ segment, state }: DateSegmentProps) {
const ref = useRef<null | HTMLDivElement>(null);
const { segmentProps } = useDateSegment(segment, state, ref);
return (
<div
{...segmentProps}
ref={ref}
style={{
...segmentProps.style,
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
<span
aria-hidden="true"
className="block text-center italic text-slate-500 group-focus:text-white"
style={{
visibility: segment.isPlaceholder ? undefined : "hidden",
height: segment.isPlaceholder ? "" : 0,
pointerEvents: "none",
}}
>
{segment.placeholder}
</span>
{segment.isPlaceholder ? "" : segment.text}
</div>
);
}
function literalSegment(text: string): DateSegment {
return {
type: "literal",
text,
isPlaceholder: false,
isEditable: false,
placeholder: "",
};
}
function minWidthForSegment(segment: DateSegment) {
if (segment.type === "literal") {
return undefined;
}
return String(`${segment.maxValue}`).length + "ch";
}
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
return (
<div
style={{
minWidth: minWidthForSegment(segment),
}}
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
!segment.isEditable ? "text-slate-500" : "text-bright"
}`}
>
<span className="block text-center italic text-slate-500">
{segment.type !== "literal" ? segment.placeholder : segment.text}
</span>
</div>
);
}
@@ -2,6 +2,7 @@ import * as React from "react";
import { cn } from "~/utils/cn";
import type { IconNamesOrString } from "./NamedIcon";
import { NamedIcon } from "./NamedIcon";
import { Icon, RenderIcon } from "./Icon";
const variants = {
large: {
@@ -44,7 +45,7 @@ const variants = {
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
variant?: keyof typeof variants;
icon?: IconNamesOrString;
icon?: RenderIcon;
shortcut?: string;
fullWidth?: boolean;
};
@@ -59,7 +60,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
{icon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
<NamedIcon name={icon} className={cn(iconClassName, "text-dimmed")} />
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
</div>
)}
<input
@@ -129,10 +129,10 @@ function PageInfoPropertyContent({
{label && (
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
{label}
{value && ":"}
{value !== undefined && ":"}
</Paragraph>
)}
{value && <Paragraph variant="small">{value}</Paragraph>}
{value !== undefined && <Paragraph variant="small">{value}</Paragraph>}
</div>
);
}
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
avoidCollisions={true}
className={cn(
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-max rounded-md border border-slate-700 bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
style={{
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md bg-popover text-bright shadow-md animate-in fade-in-40",
"relative z-50 min-w-max overflow-hidden rounded-md border border-slate-700 bg-popover text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
>
<SelectPrimitive.Viewport
className={cn(
"border border-slate-800 px-1 py-0",
"px-1 py-0",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
@@ -122,10 +122,26 @@ type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
hasAction?: boolean;
isSticky?: boolean;
};
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
to,
onClick,
hasAction = false,
isSticky = false,
},
ref
) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -154,6 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
? "cursor-pointer group-hover:bg-slate-900"
: "px-4 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
className
)}
colSpan={colSpan}
@@ -174,9 +191,6 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
);
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
@@ -189,7 +203,8 @@ export const TableCellChevron = forwardRef<
>(({ className, to, children, isSticky, onClick }, ref) => {
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
to={to}
onClick={onClick}
ref={ref}
@@ -213,7 +228,8 @@ export const TableCellMenu = forwardRef<
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
+12 -4
View File
@@ -10,7 +10,7 @@ import {
useNavigate,
useNavigation,
} from "@remix-run/react";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { RuntimeEnvironmentType, User } from "@trigger.dev/database";
import { useMemo } from "react";
import { usePathName } from "~/hooks/usePathName";
import type { RunBasicStatus } from "~/models/jobRun.server";
@@ -66,11 +66,12 @@ type RunOverviewProps = {
run: string;
runsPath: string;
};
currentUser: User;
};
const taskPattern = /\/tasks\/(.*)/;
export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps) {
export function RunOverview({ run, trigger, showRerun, paths, currentUser }: RunOverviewProps) {
const navigate = useNavigate();
const pathName = usePathName();
@@ -90,6 +91,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
}
}, [pathName]);
const usernameForEnv =
currentUser.id !== run.environment.userId ? run.environment.userName : undefined;
return (
<PageContainer>
<PageHeader>
@@ -113,6 +117,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
{showRerun && run.isFinished && (
<RerunPopover
runId={run.id}
runPath={paths.run}
runsPath={paths.runsPath}
environmentType={run.environment.type}
status={run.basicStatus}
@@ -136,7 +141,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
<PageInfoProperty icon={"property"} label={"Version"} value={`v${run.version}`} />
<PageInfoProperty
label={"Env"}
value={<EnvironmentLabel environment={run.environment} />}
value={<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />}
/>
<PageInfoProperty
icon={"clock"}
@@ -313,18 +318,20 @@ function BlankTasks({ status }: { status: RunBasicStatus }) {
function RerunPopover({
runId,
runPath,
runsPath,
environmentType,
status,
}: {
runId: string;
runPath: string;
runsPath: string;
environmentType: RuntimeEnvironmentType;
status: RunBasicStatus;
}) {
const lastSubmission = useActionData();
const [form, { successRedirect }] = useForm({
const [form, { successRedirect, failureRedirect }] = useForm({
id: "rerun",
// TODO: type this
lastSubmission: lastSubmission as any,
@@ -343,6 +350,7 @@ function RerunPopover({
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
<input {...conform.input(failureRedirect, { type: "hidden" })} defaultValue={runPath} />
{environmentType === "PRODUCTION" && (
<div className="px-4 pt-4">
<Callout variant="warning">
@@ -0,0 +1,230 @@
import {
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
PauseCircleIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { cn } from "~/utils/cn";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { Paragraph } from "../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
import { Spinner } from "../primitives/Spinner";
import {
FilterableEnvironment,
FilterableStatus,
RunListSearchSchema,
environmentKeys,
statusKeys,
} from "./RunStatuses";
import { TimeFrameFilter } from "./TimeFrameFilter";
import { Button } from "../primitives/Buttons";
import { useCallback } from "react";
export function RunsFilters() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environment, status, from, to } = RunListSearchSchema.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
handleFilterChange("status", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: FilterableEnvironment | "ALL") => {
handleFilterChange("environment", value === "ALL" ? undefined : value);
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const clearFilters = useCallback(() => {
searchParams.delete("status");
searchParams.delete("environment");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
return (
<div className="flex flex-row justify-between gap-x-2">
<SelectGroup>
<Select
name="environment"
value={environment ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="secondary/small" width="full">
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph variant="extra-small" className="pl-0.5">
All environments
</Paragraph>
</SelectItem>
{environmentKeys.map((env) => (
<SelectItem key={env} value={env}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={{ type: env }} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="status" value={status ?? "ALL"} onValueChange={handleStatusChange}>
<SelectTrigger size="secondary/small" width="full">
<SelectValue placeholder="Select status" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph variant="extra-small" className="pl-0.5">
All statuses
</Paragraph>
</SelectItem>
{statusKeys.map((status) => (
<SelectItem key={status} value={status}>
{
<span className="flex items-center gap-1 text-xs">
<FilterStatusIcon status={status} className="h-4 w-4" />
<FilterStatusLabel status={status} />
</span>
}
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
Clear
</Button>
</div>
);
}
export function FilterStatusLabel({ status }: { status: FilterableStatus }) {
return <span className={filterStatusClassNameColor(status)}>{filterStatusTitle(status)}</span>;
}
export function FilterStatusIcon({
status,
className,
}: {
status: FilterableStatus;
className: string;
}) {
switch (status) {
case "COMPLETED":
return <CheckCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
case "WAITING":
return <ClockIcon className={cn(filterStatusClassNameColor(status), className)} />;
case "QUEUED":
return <PauseCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
case "IN_PROGRESS":
return <Spinner className={cn(filterStatusClassNameColor(status), className)} />;
case "TIMEDOUT":
return (
<ExclamationTriangleIcon className={cn(filterStatusClassNameColor(status), className)} />
);
case "CANCELED":
return <NoSymbolIcon className={cn(filterStatusClassNameColor(status), className)} />;
case "FAILED":
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function filterStatusTitle(status: FilterableStatus): string {
switch (status) {
case "QUEUED":
return "Queued";
case "IN_PROGRESS":
return "In progress";
case "WAITING":
return "Waiting";
case "COMPLETED":
return "Completed";
case "FAILED":
return "Failed";
case "CANCELED":
return "Canceled";
case "TIMEDOUT":
return "Timed out";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function filterStatusClassNameColor(status: FilterableStatus): string {
switch (status) {
case "QUEUED":
return "text-slate-500";
case "IN_PROGRESS":
return "text-blue-500";
case "WAITING":
return "text-blue-500";
case "COMPLETED":
return "text-green-500";
case "FAILED":
return "text-rose-500";
case "CANCELED":
return "text-slate-500";
case "TIMEDOUT":
return "text-amber-300";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
@@ -10,6 +10,7 @@ import {
import type { JobRunStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { Spinner } from "../primitives/Spinner";
import { z } from "zod";
export function RunStatus({ status }: { status: JobRunStatus }) {
return (
@@ -127,3 +128,52 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
}
}
}
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
export type Direction = z.infer<typeof DirectionSchema>;
export const FilterableStatus = z.union([
z.literal("QUEUED"),
z.literal("IN_PROGRESS"),
z.literal("WAITING"),
z.literal("COMPLETED"),
z.literal("FAILED"),
z.literal("TIMEDOUT"),
z.literal("CANCELED"),
]);
export type FilterableStatus = z.infer<typeof FilterableStatus>;
export const FilterableEnvironment = z.union([
z.literal("DEVELOPMENT"),
z.literal("STAGING"),
z.literal("PRODUCTION"),
]);
export type FilterableEnvironment = z.infer<typeof FilterableEnvironment>;
export const environmentKeys: FilterableEnvironment[] = ["DEVELOPMENT", "STAGING", "PRODUCTION"];
export const RunListSearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
status: FilterableStatus.optional(),
environment: FilterableEnvironment.optional(),
from: z
.string()
.transform((value) => parseInt(value))
.optional(),
to: z
.string()
.transform((value) => parseInt(value))
.optional(),
});
export const filterableStatuses: Record<FilterableStatus, JobRunStatus[]> = {
QUEUED: ["QUEUED", "WAITING_TO_EXECUTE", "PENDING", "WAITING_ON_CONNECTIONS"],
IN_PROGRESS: ["STARTED", "EXECUTING", "PREPROCESSING"],
WAITING: ["WAITING_TO_CONTINUE"],
COMPLETED: ["SUCCESS"],
FAILED: ["FAILURE", "UNRESOLVED_AUTH", "INVALID_PAYLOAD", "ABORTED"],
TIMEDOUT: ["TIMED_OUT"],
CANCELED: ["CANCELED"],
};
export const statusKeys: FilterableStatus[] = Object.keys(filterableStatuses) as FilterableStatus[];
+10 -4
View File
@@ -1,6 +1,6 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { JobRunStatus, RuntimeEnvironmentType, User } from "@trigger.dev/database";
import { formatDuration, formatDurationMilliseconds } from "~/utils";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { DateTime } from "../primitives/DateTime";
@@ -23,6 +23,8 @@ type RunTableItem = {
number: number | null;
environment: {
type: RuntimeEnvironmentType;
userId?: string;
userName?: string;
};
job: { title: string; slug: string };
status: JobRunStatus;
@@ -41,6 +43,7 @@ type RunsTableProps = {
runs: RunTableItem[];
isLoading?: boolean;
runsParentPath: string;
currentUser: User;
};
export function RunsTable({
@@ -50,6 +53,7 @@ export function RunsTable({
isLoading = false,
showJob = false,
runsParentPath,
currentUser,
}: RunsTableProps) {
return (
<Table>
@@ -73,17 +77,19 @@ export function RunsTable({
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs found" />
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs match your filters" />
{!isLoading && <NoRuns title="No runs match your filters" />}
</TableBlankRow>
) : (
runs.map((run) => {
const path = showJob
? `${runsParentPath}/jobs/${run.job.slug}/runs/${run.id}/trigger`
: `${runsParentPath}/${run.id}/trigger`;
const usernameForEnv =
currentUser.id !== run.environment.userId ? run.environment.userName : undefined;
return (
<TableRow key={run.id}>
<TableCell to={path}>
@@ -91,7 +97,7 @@ export function RunsTable({
</TableCell>
{showJob && <TableCell to={path}>{run.job.slug}</TableCell>}
<TableCell to={path}>
<EnvironmentLabel environment={run.environment} />
<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />
</TableCell>
<TableCell to={path}>
<RunStatus status={run.status} />
@@ -0,0 +1,243 @@
import { ChevronDownIcon } from "lucide-react";
import { useCallback, useState } from "react";
import {
Calendar,
CalendarDateTime,
DateValue,
getLocalTimeZone,
today,
} from "@internationalized/date";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { ClientTabs, ClientTabsContent, ClientTabsWithUnderline } from "../primitives/ClientTabs";
import { formatDateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { DateField } from "../primitives/DateField";
import { useLocales } from "../primitives/LocaleProvider";
import { createCalendar } from "@internationalized/date";
type RunTimeFrameFilterProps = {
from?: number;
to?: number;
onRangeChanged: (range: { from?: number; to?: number }) => void;
};
type Mode = "absolute" | "relative";
export function TimeFrameFilter({ from, to, onRangeChanged }: RunTimeFrameFilterProps) {
const [activeTab, setActiveTab] = useState<Mode>("absolute");
const [isOpen, setIsOpen] = useState(false);
const [relativeTimeSeconds, setRelativeTimeSeconds] = useState<number | undefined>();
const fromDate = from ? new Date(from) : undefined;
const toDate = to ? new Date(to) : undefined;
const relativeTimeFrameChanged = useCallback((value: number) => {
const to = new Date().getTime();
const from = to - value;
onRangeChanged({ from, to });
setRelativeTimeSeconds(value);
}, []);
const absoluteTimeFrameChanged = useCallback(({ from, to }: { from?: Date; to?: Date }) => {
setRelativeTimeSeconds(undefined);
const fromTime = from?.getTime();
const toTime = to?.getTime();
onRangeChanged({ from: fromTime, to: toTime });
}, []);
return (
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen} modal>
<PopoverTrigger asChild>
<Button
variant="secondary/small"
className="bg-slate-800 group-hover:bg-tertiary-foreground"
>
<Paragraph variant="extra-small" className="mr-2">
{title(from, to, relativeTimeSeconds)}
</Paragraph>
<ChevronDownIcon className="h-4 w-4 text-bright" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="bg-popover p-2">
<ClientTabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as Mode)}
className="p-1"
>
<ClientTabsWithUnderline
tabs={[
{ label: "Absolute", value: "absolute" },
{ label: "Relative", value: "relative" },
]}
currentValue={activeTab}
layoutId={"time-tabs"}
/>
<ClientTabsContent value={"absolute"}>
<AbsoluteTimeFrame
from={fromDate}
to={toDate}
onValueChange={absoluteTimeFrameChanged}
/>
</ClientTabsContent>
<ClientTabsContent value={"relative"}>
<RelativeTimeFrame
value={relativeTimeSeconds}
onValueChange={relativeTimeFrameChanged}
/>
</ClientTabsContent>
</ClientTabs>
</PopoverContent>
</Popover>
);
}
function title(
from: number | undefined,
to: number | undefined,
relativeTimeSeconds: number | undefined
): string {
if (!from && !to) {
return "All time periods";
}
if (relativeTimeSeconds !== undefined) {
return timeFrameValues.find((t) => t.value === relativeTimeSeconds)?.label ?? "Timeframe";
}
let fromString = from ? formatDateTime(new Date(from), "UTC", ["en-US"], false, true) : undefined;
let toString = to ? formatDateTime(new Date(to), "UTC", ["en-US"], false, true) : undefined;
if (from && !to) {
return `From ${fromString} (UTC)`;
}
if (!from && to) {
return `To ${toString} (UTC)`;
}
return `${fromString} - ${toString} (UTC)`;
}
function RelativeTimeFrame({
value,
onValueChange,
}: {
value?: number;
onValueChange: (value: number) => void;
}) {
return (
<div className="grid grid-cols-3 gap-1 pt-2">
{timeFrameValues.map((timeframe) => (
<Button
key={timeframe.value}
variant={value === timeframe.value ? "primary/small" : "tertiary/small"}
className={cn(
"w-full",
value !== timeframe.value && "border border-slate-700 group-hover:bg-slate-700"
)}
onClick={() => {
onValueChange(timeframe.value);
}}
>
<Paragraph variant="extra-small">{timeframe.label}</Paragraph>
</Button>
))}
</div>
);
}
const timeFrameValues = [
{
label: "5 mins",
value: 5 * 60 * 1000,
},
{
label: "15 mins",
value: 15 * 60 * 1000,
},
{
label: "30 mins",
value: 30 * 60 * 1000,
},
{
label: "1 hour",
value: 60 * 60 * 1000,
},
{
label: "3 hours",
value: 3 * 60 * 60 * 1000,
},
{
label: "6 hours",
value: 6 * 60 * 60 * 1000,
},
{
label: "1 day",
value: 24 * 60 * 60 * 1000,
},
{
label: "3 days",
value: 3 * 24 * 60 * 60 * 1000,
},
{
label: "7 days",
value: 7 * 24 * 60 * 60 * 1000,
},
{
label: "10 days",
value: 10 * 24 * 60 * 60 * 1000,
},
{
label: "14 days",
value: 14 * 24 * 60 * 60 * 1000,
},
{
label: "30 days",
value: 30 * 24 * 60 * 60 * 1000,
},
];
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
function AbsoluteTimeFrame({
from,
to,
onValueChange,
}: {
from?: Date;
to?: Date;
onValueChange: (value: { from?: Date; to?: Date }) => void;
}) {
return (
<div className="flex flex-col gap-2 pt-2">
<div className="flex flex-col justify-start gap-2">
<div className="flex flex-row items-center gap-1">
<DateField
label="From (UTC)"
defaultValue={from}
onValueChange={(value) => {
onValueChange({ from: value, to: to });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
<div className="flex flex-row items-center gap-1">
<DateField
label="To (UTC)"
defaultValue={to}
onValueChange={(value) => {
onValueChange({ from: from, to: value });
}}
granularity="second"
showNowButton
showClearButton
/>
</div>
</div>
</div>
);
}
@@ -21,7 +21,7 @@ const mockOrganization: MatchedOrganization = {
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
runsEnabled: true,
};
export const ProgressBar: Story = {
+9 -3
View File
@@ -110,13 +110,19 @@ function getClient() {
// emit: "stdout",
// level: "query",
// },
// {
// emit: "event",
// level: "query",
// },
],
});
// client.$on("query", (e) => {
// console.log("Query: " + e.query);
// console.log("Params: " + e.params);
// console.log("Duration: " + e.duration + "ms");
// console.log(`Query tooks ${e.duration}ms`, {
// query: e.query,
// params: e.params,
// duration: e.duration,
// });
// });
// connect eagerly
+3
View File
@@ -67,6 +67,9 @@ const EnvironmentSchema = z.object({
TUNNEL_HOST: z.string().optional(),
TUNNEL_SECRET_KEY: z.string().optional(),
//v3
V3_ENABLED: z.string().default("false"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,12 @@
import { useLocation, useNavigation } from "@remix-run/react";
export function useOptimisticLocation() {
const navigation = useNavigation();
const location = useLocation();
if (navigation.state === "idle" || !navigation.location) {
return location;
}
return navigation.location;
}
+1 -1
View File
@@ -17,7 +17,7 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
if (postHogInitialized.current === true) return;
if (logging) console.log("Initializing PostHog");
posthog.init(apiKey, {
api_host: "https://app.posthog.com",
api_host: "https://eu.posthog.com",
opt_in_site_apps: true,
debug,
loaded: function (posthog) {
+5 -6
View File
@@ -1,17 +1,16 @@
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
import { organizationMatchId } from "./useOrganizations";
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
export type MatchedProject = UseDataFunctionReturn<typeof orgLoader>["project"];
export function useOptionalProject(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: projectMatchId,
const routeMatch = useTypedMatchesData<typeof orgLoader>({
id: organizationMatchId,
matches,
});
+3 -4
View File
@@ -1,9 +1,8 @@
import type { User } from "~/models/user.server";
import { useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
import type { User } from "~/models/user.server";
import { loader } from "~/root";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData<typeof loader>({
+3
View File
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
return await prisma.orgMemberInvite.findMany({
where: {
email,
organization: {
deletedAt: null,
},
},
include: {
organization: true,
@@ -14,6 +14,11 @@ export async function findEnvironmentByApiKey(apiKey: string) {
},
});
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
return null;
}
return environment;
}
@@ -28,24 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
},
});
return environment;
}
export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) {
const organization = await prisma.organization.findUnique({
where: {
slug: organizationSlug,
},
include: {
environments: true,
},
});
if (!organization) {
return;
//don't return deleted projects
if (environment?.project.deletedAt !== null) {
return null;
}
const environment = organization.environments.find((environment) => environment.slug === slug);
return environment;
}
+2 -2
View File
@@ -148,7 +148,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return true;
}
this.#logDebug("Initializing worker queue with options", {
this.#logDebug("Initializing graphile worker queue with options", {
runnerOptions: this.#runnerOptions,
});
@@ -163,7 +163,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
});
if (!this.#runner) {
throw new Error("Failed to initialize worker queue");
throw new Error("Failed to initialize graphile worker queue");
}
this.#runner?.events.on("pool:create", ({ workerPool }) => {
@@ -38,7 +38,7 @@ export type ClientEndpoint =
state: "configured";
id: string;
slug: string;
url: string;
url: string | null;
indexWebhookPath: string;
latestIndex?: {
status: EndpointIndexStatus;
@@ -102,6 +102,11 @@ export class EnvironmentsPresenter {
},
},
},
where: {
url: {
not: null,
},
},
},
},
where: {
@@ -0,0 +1,169 @@
import { PrismaClient, prisma } from "~/db.server";
import { Direction, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { getUsername } from "~/utils/username";
type EventListOptions = {
userId: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
export type EventList = Awaited<ReturnType<EventListPresenter["call"]>>;
export class EventListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
organizationSlug,
projectSlug,
filterEnvironment,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: EventListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
const events = await this.#prismaClient.eventRecord.findMany({
select: {
id: true,
name: true,
deliverAt: true,
deliveredAt: true,
isTest: true,
createdAt: true,
cancelledAt: true,
environment: {
select: {
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
runs: {
select: {
id: true,
},
},
},
where: {
internal: false,
name: {
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
},
projectId: project.id,
organizationId: organization.id,
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
createdAt: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//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,
}
: undefined,
});
const hasMore = events.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? events.at(0)?.id : undefined;
if (hasMore) {
next = events[pageSize - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = events[1]?.id;
next = events[pageSize]?.id;
} else {
next = events[pageSize - 1]?.id;
}
break;
}
const eventsToReturn =
direction === "backward" && hasMore
? events.slice(1, pageSize + 1)
: events.slice(0, pageSize);
return {
events: eventsToReturn.map((event) => ({
id: event.id,
name: event.name,
deliverAt: event.deliverAt,
deliveredAt: event.deliveredAt,
createdAt: event.createdAt,
cancelledAt: event.cancelledAt,
isTest: event.isTest,
environment: {
type: event.environment.type,
slug: event.environment.slug,
userId: event.environment.orgMember?.user.id,
userName: getUsername(event.environment.orgMember?.user),
},
runs: event.runs.length,
})),
pagination: {
next,
previous,
},
};
}
}
@@ -0,0 +1,68 @@
import { PrismaClient, prisma } from "~/db.server";
export type Event = NonNullable<Awaited<ReturnType<EventPresenter["call"]>>>;
export class EventPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
eventId,
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
eventId: string;
}) {
// 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,
},
});
const event = await this.#prismaClient.eventRecord.findFirst({
select: {
id: true,
name: true,
payload: true,
context: true,
timestamp: true,
deliveredAt: true,
},
where: {
id: eventId,
projectId: project.id,
organizationId: organization.id,
},
});
if (!event) {
throw new Error("Could not find Event");
}
return {
id: event.id,
name: event.name,
timestamp: event.timestamp,
payload: JSON.stringify(event.payload, null, 2),
context: JSON.stringify(event.context, null, 2),
deliveredAt: event.deliveredAt,
};
}
}
@@ -9,6 +9,7 @@ import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { z } from "zod";
import { projectPath } from "~/utils/pathBuilder";
import { JobRunStatus } from "@trigger.dev/database";
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
@@ -43,52 +44,34 @@ export class JobListPresenter {
id: true,
slug: true,
title: true,
aliases: {
integrations: {
select: {
version: {
key: true,
integration: {
select: {
version: true,
eventSpecification: true,
properties: true,
status: true,
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
integrations: {
select: {
key: true,
integration: {
select: {
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
triggerLink: true,
triggerHelp: true,
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
versions: {
select: {
version: true,
eventSpecification: true,
properties: true,
status: true,
triggerLink: true,
triggerHelp: true,
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
orderBy: [{ updatedAt: "desc" }],
take: 1,
},
dynamicTriggers: {
select: {
@@ -115,50 +98,47 @@ export class JobListPresenter {
orderBy: [{ title: "asc" }],
});
let latestRuns = [] as {
createdAt: Date;
status: JobRunStatus;
jobId: string;
rn: BigInt;
}[];
if (jobs.length > 0) {
latestRuns = await this.#prismaClient.$queryRaw<
{
createdAt: Date;
status: JobRunStatus;
jobId: string;
rn: BigInt;
}[]
>`
SELECT * FROM (
SELECT
"id",
"createdAt",
"status",
"jobId",
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
FROM
"JobRun"
WHERE
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
) t
WHERE rn = 1;`;
}
return jobs
.map((job) => {
//the best alias to select:
// 1. Logged-in user dev
// 2. Prod
// 3. Any other user's dev
const sortedAliases = job.aliases.sort((a, b) => {
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
return -1;
}
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
return 1;
}
if (a.environment.type === "PRODUCTION") {
return -1;
}
if (b.environment.type === "PRODUCTION") {
return 1;
}
return 0;
});
const alias = sortedAliases.at(0);
if (!alias) {
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
.flatMap((job) => {
const version = job.versions.at(0);
if (!version) {
return [];
}
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
const eventSpecification = EventSpecificationSchema.parse(version.eventSpecification);
const lastRuns = job.aliases
.map((alias) => alias.version.runs.at(0))
.filter(Boolean)
.sort((a, b) => {
return b.createdAt.getTime() - a.createdAt.getTime();
});
const lastRun = lastRuns.at(0);
const integrations = alias.version.integrations.map((integration) => ({
const integrations = job.integrations.map((integration) => ({
key: integration.key,
title: integration.integration.slug,
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
@@ -171,44 +151,41 @@ export class JobListPresenter {
properties = [...properties, ...eventSpecification.properties];
}
if (alias.version.properties) {
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
if (version.properties) {
const versionProperties = z.array(DisplayPropertySchema).parse(version.properties);
properties = [...properties, ...versionProperties];
}
const environments = job.aliases.map((alias) => ({
type: alias.environment.type,
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
}));
const latestRun = latestRuns.find((r) => r.jobId === job.id);
return {
id: job.id,
slug: job.slug,
title: job.title,
version: alias.version.version,
status: alias.version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: projectSlug
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
alias.version.triggerLink
}`
: undefined,
return [
{
id: job.id,
slug: job.slug,
title: job.title,
version: version.version,
status: version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
icon: eventSpecification.icon,
source: eventSpecification.source,
link: projectSlug
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
version.triggerLink
}`
: undefined,
},
integrations,
hasIntegrationsRequiringAction: integrations.some(
(i) => i.setupStatus === "MISSING_FIELDS"
),
environment: version.environment,
lastRun: latestRun,
properties,
projectSlug: job.project.slug,
},
integrations,
hasIntegrationsRequiringAction: integrations.some(
(i) => i.setupStatus === "MISSING_FIELDS"
),
lastRun,
properties,
environments,
projectSlug: job.project.slug,
};
];
})
.filter(Boolean);
}
@@ -10,11 +10,16 @@ export class NewOrganizationPresenter {
public async call({ userId }: { userId: User["id"] }) {
const organizations = await this.#prismaClient.organization.findMany({
select: {
projects: {
where: { deletedAt: null },
},
},
where: { members: { some: { userId } } },
});
return {
hasOrganizations: organizations.length > 0,
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
};
}
}
@@ -1,9 +1,7 @@
import { estimate } from "@trigger.dev/billing";
import { formatDateTime } from "~/components/primitives/DateTime";
import { PrismaClient, prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
export class OrgUsagePresenter {
#prismaClient: PrismaClient;
@@ -25,7 +23,7 @@ export class OrgUsagePresenter {
});
if (!organization) {
return;
throw new Error("Organization not found");
}
// Get count of runs since the start of the current month
@@ -108,7 +106,7 @@ export class OrgUsagePresenter {
const ThirtyDaysAgo = new Date();
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
ThirtyDaysAgo.setHours(0, 0, 0, 0);
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
const hasConcurrencyData = concurrencyChartRawData.length > 0;
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
@@ -117,6 +115,13 @@ export class OrgUsagePresenter {
concurrencyChartRawData
);
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
{ day: Date; runs: BigInt }[]
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
const hasDailyRunsData = dailyRunsRawData.length > 0;
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
endOfMonth.setDate(endOfMonth.getDate() - 1);
const projectedRunsCount = Math.round(
@@ -146,12 +151,12 @@ export class OrgUsagePresenter {
const periodStart = new Date();
periodStart.setDate(1);
periodStart.setHours(0, 0, 0, 0);
periodStart.setUTCHours(0, 0, 0, 0);
const periodEnd = new Date();
periodEnd.setDate(1);
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setHours(0, 0, 0, 0);
periodEnd.setUTCHours(0, 0, 0, 0);
return {
id: organization.id,
@@ -161,6 +166,8 @@ export class OrgUsagePresenter {
hasMonthlyRunData,
concurrencyData: concurrencyChartRawDataFilledIn,
hasConcurrencyData,
dailyRunsData: dailyRunsDataFilledIn,
hasDailyRunsData,
runCostEstimation,
projectedRunCostEstimation,
periodStart,
@@ -224,6 +231,33 @@ function fillInMissingConcurrencyDays(
return outputData;
}
function fillInMissingDailyRuns(
startDate: Date,
days: number,
data: Array<{ day: Date; runs: BigInt }>
) {
const outputData: Array<{ date: Date; runs: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
runs: 0,
});
} else {
outputData.push({
date,
runs: Number(foundData.runs),
});
}
}
return outputData;
}
// Start month will be like 2023-03 and endMonth will be like 2023-10
// The result should be an array of months between these two months, including the start and end month
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
@@ -1,10 +1,17 @@
import { PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { ProjectPresenter } from "./ProjectPresenter.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
getCurrentProjectId,
setCurrentProjectId,
} from "~/services/currentProject.server";
import { logger } from "~/services/logger.server";
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
import { newProjectPath } from "~/utils/pathBuilder";
import { ProjectPresenter } from "./ProjectPresenter.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { match } from "assert";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -16,40 +23,167 @@ export class OrganizationsPresenter {
public async call({
userId,
organizationSlug,
request,
projectSlug,
request,
}: {
userId: string;
organizationSlug: string;
projectSlug: string | undefined;
request: Request;
projectSlug?: string;
}) {
const organizations = await this.getOrganizations(userId);
//first get the project id, this redirects if there's no session
const projectId = await this.#getProjectId({
request,
projectSlug,
organizationSlug,
userId,
});
const organizations = await this.#getOrganizations(userId);
const organization = organizations.find((o) => o.slug === organizationSlug);
if (!organization) {
logger.info("Not Found: organization", {
organizationSlug,
projectSlug,
request,
organization,
});
throw new Response("Not Found", { status: 404 });
}
const project = await this.getProject(organization, projectSlug, request, userId);
const projectPresenter = new ProjectPresenter(this.#prismaClient);
const project = await projectPresenter.call({
id: projectId,
userId,
});
if (!project) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"No projects found in organization"
);
}
return { organizations, organization, project };
}
async getOrganizations(userId: string) {
async #getProjectId({
request,
projectSlug,
organizationSlug,
userId,
}: {
request: Request;
projectSlug: string | undefined;
organizationSlug: string;
userId: string;
}): Promise<string> {
const sessionProjectId = await getCurrentProjectId(request);
//no project in session, let's set one
if (!sessionProjectId) {
//no session id and no project slug so we need to select the best project
if (!projectSlug) {
const bestProject = await this.#selectBestProjectForOrganization(
organizationSlug,
userId,
request
);
const session = await setCurrentProjectId(bestProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
//get all the projects
const projects = await prisma.project.findMany({
select: {
id: true,
slug: true,
},
where: {
organization: {
slug: organizationSlug,
},
deletedAt: null,
slug: projectSlug,
},
orderBy: {
updatedAt: "desc",
},
});
if (projects.length === 0) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"No projects in this organization"
);
}
//try get the project which matches the URL
let matchingProject = projects.find((p) => p.slug === projectSlug);
//if there's no matching project, just use the most recently updated one
if (!matchingProject) {
matchingProject = projects[0];
}
//set the session
const session = await setCurrentProjectId(matchingProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
if (!projectSlug) {
return sessionProjectId;
}
//check session id matches the project slug
const project = await prisma.project.findFirst({
select: {
id: true,
slug: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
},
deletedAt: null,
},
});
if (!project) {
throw new Response("Project not found in organization", { status: 404 });
}
if (project.id !== sessionProjectId) {
const session = await setCurrentProjectId(project.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
return project.id;
}
async #getOrganizations(userId: string) {
const orgs = await this.#prismaClient.organization.findMany({
where: { members: { some: { userId } } },
where: { members: { some: { userId } }, deletedAt: null },
orderBy: { createdAt: "desc" },
include: {
select: {
id: true,
slug: true,
title: true,
runsEnabled: true,
projects: {
orderBy: { name: "asc" },
include: {
where: { deletedAt: null },
select: {
id: true,
slug: true,
name: true,
_count: {
select: {
jobs: {
@@ -67,10 +201,10 @@ export class OrganizationsPresenter {
},
},
},
orderBy: { name: "asc" },
},
_count: {
select: {
members: true,
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
@@ -93,38 +227,41 @@ export class OrganizationsPresenter {
jobCount: project._count.jobs,
})),
hasUnconfiguredIntegrations: org._count.integrations > 0,
memberCount: org._count.members,
runsEnabled: org.runsEnabled,
};
});
}
async getProject(
organization: Org,
projectSlug: string | undefined,
request: Request,
userId: string
async #selectBestProjectForOrganization(
organizationSlug: string,
userId: string,
request: Request
) {
const projectPresenter = new ProjectPresenter();
const projects = await this.#prismaClient.project.findMany({
select: {
id: true,
slug: true,
},
where: {
deletedAt: null,
organization: {
deletedAt: null,
slug: organizationSlug,
members: { some: { userId } },
},
},
orderBy: {
jobs: {
_count: "desc",
},
},
take: 1,
});
if (!projectSlug) {
const projectId = await getCurrentProjectId(request);
const orgProject = organization.projects.find((p) => p.id === projectId);
if (!orgProject) {
logger.info("Not Found: proj 1", {
projectId,
organization,
projectSlug: projectSlug ?? null,
});
throw new Response("Not Found", { status: 404 });
}
projectSlug = orgProject.slug;
if (projects.length === 0) {
throw redirect(newProjectPath({ slug: organizationSlug }), request);
}
const project = await projectPresenter.call({ userId, slug: projectSlug });
if (!project) {
logger.info("Not Found: proj 2", { projectSlug, organization, project });
throw new Response("Not Found", { status: 404 });
}
return project;
return projects[0];
}
}
@@ -11,8 +11,8 @@ export class ProjectPresenter {
public async call({
userId,
slug,
}: Pick<Project, "slug"> & {
id,
}: Pick<Project, "id"> & {
userId: User["id"];
}) {
const project = await this.#prismaClient.project.findFirst({
@@ -23,67 +23,7 @@ export class ProjectPresenter {
organizationId: true,
createdAt: true,
updatedAt: true,
jobs: {
select: {
id: true,
slug: true,
title: true,
aliases: {
select: {
version: {
select: {
version: true,
eventSpecification: true,
properties: true,
runs: {
select: {
createdAt: true,
status: true,
},
take: 1,
orderBy: [{ createdAt: "desc" }],
},
integrations: {
select: {
key: true,
integration: {
select: {
slug: true,
definition: true,
setupStatus: true,
},
},
},
},
},
},
environment: {
select: {
type: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
name: "latest",
},
},
dynamicTriggers: {
select: {
type: true,
},
},
},
where: {
internal: false,
deletedAt: null,
},
orderBy: [{ title: "asc" }],
},
deletedAt: true,
_count: {
select: {
sources: {
@@ -100,19 +40,6 @@ export class ProjectPresenter {
httpEndpoints: true,
},
},
organization: {
select: {
_count: {
select: {
integrations: {
where: {
setupStatus: "MISSING_FIELDS",
},
},
},
},
},
},
environments: {
select: {
id: true,
@@ -127,7 +54,7 @@ export class ProjectPresenter {
},
},
},
where: { slug, organization: { members: { some: { userId } } } },
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
});
if (!project) {
@@ -141,6 +68,7 @@ export class ProjectPresenter {
organizationId: project.organizationId,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
deletedAt: project.deletedAt,
hasInactiveExternalTriggers: project._count.sources > 0,
jobCount: project._count.jobs,
httpEndpointCount: project._count.httpEndpoints,
@@ -1,17 +1,26 @@
import { z } from "zod";
import {
Direction,
FilterableEnvironment,
FilterableStatus,
filterableStatuses,
} from "~/components/runs/RunStatuses";
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>;
import { getUsername } from "~/utils/username";
type RunListOptions = {
userId: string;
eventId?: string;
jobSlug?: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
filterStatus?: FilterableStatus;
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
@@ -27,49 +36,56 @@ export class RunListPresenter {
public async call({
userId,
eventId,
jobSlug,
organizationSlug,
projectSlug,
filterEnvironment,
filterStatus,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: RunListOptions) {
const filterStatuses = filterStatus ? filterableStatuses[filterStatus] : undefined;
const directionMultiplier = direction === "forward" ? 1 : -1;
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
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 job = jobSlug ? await this.#prismaClient.job.findFirstOrThrow({
where: {
slug: jobSlug,
projectId: project.id,
},
}) : undefined;
const event = eventId
? await this.#prismaClient.eventRecord.findUnique({ where: { id: eventId } })
: undefined;
const runs = await this.#prismaClient.jobRun.findMany({
select: {
@@ -87,7 +103,13 @@ export class RunListPresenter {
slug: true,
orgMember: {
select: {
userId: true,
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
@@ -105,11 +127,15 @@ export class RunListPresenter {
},
},
where: {
eventId: event?.id,
jobId: job?.id,
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
status: filterStatuses ? { in: filterStatuses } : undefined,
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
startedAt: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
@@ -119,8 +145,8 @@ export class RunListPresenter {
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
id: cursor,
}
: undefined,
});
@@ -163,7 +189,8 @@ export class RunListPresenter {
environment: {
type: run.environment.type,
slug: run.environment.slug,
userId: run.environment.orgMember?.userId,
userId: run.environment.orgMember?.user.id,
userName: getUsername(run.environment.orgMember?.user),
},
job: run.job,
})),
@@ -8,6 +8,7 @@ import { PrismaClient, prisma } from "~/db.server";
import { isRunCompleted, runBasicStatus } from "~/models/jobRun.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
import { taskListToTree } from "~/utils/taskListToTree";
import { getUsername } from "~/utils/username";
type RunOptions = {
id: string;
@@ -79,6 +80,8 @@ export class RunPresenter {
environment: {
type: run.environment.type,
slug: run.environment.slug,
userId: run.environment.orgMember?.user.id,
userName: getUsername(run.environment.orgMember?.user),
},
event: this.#prepareEventData(run.event),
tasks,
@@ -130,6 +133,17 @@ export class RunPresenter {
select: {
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
event: {
@@ -14,7 +14,7 @@ export class SelectBestProjectPresenter {
const projectId = await getCurrentProjectId(request);
if (projectId) {
const project = await this.#prismaClient.project.findUnique({
where: { id: projectId, organization: { members: { some: { userId } } } },
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
include: { organization: true },
});
if (project) {
@@ -28,6 +28,7 @@ export class SelectBestProjectPresenter {
organization: true,
},
where: {
deletedAt: null,
organization: {
members: { some: { userId } },
},
@@ -2,7 +2,8 @@ import { TriggerSource, User } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { Direction, RunList, RunListPresenter } from "./RunListPresenter.server";
import { RunList, RunListPresenter } from "./RunListPresenter.server";
import { Direction } from "~/components/runs/RunStatuses";
export class TriggerSourcePresenter {
#prismaClient: PrismaClient;
@@ -1,5 +1,5 @@
import { Direction } from "~/components/runs/RunStatuses";
import { PrismaClient, prisma } from "~/db.server";
import { Direction } from "./RunListPresenter.server";
type RunListOptions = {
userId: string;
@@ -2,9 +2,9 @@ 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";
import { Direction } from "~/components/runs/RunStatuses";
export class WebhookDeliveryPresenter {
#prismaClient: PrismaClient;
@@ -2,8 +2,9 @@ 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 { RunListPresenter } from "./RunListPresenter.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
import { Direction } from "~/components/runs/RunStatuses";
export class WebhookSourcePresenter {
#prismaClient: PrismaClient;
+22 -2
View File
@@ -1,7 +1,15 @@
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useMatches,
} from "@remix-run/react";
import { metaV1 } from "@remix-run/v1-meta";
import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExternalScripts } from "remix-utils/external-scripts";
@@ -16,6 +24,7 @@ import { env } from "./env.server";
import { featuresForRequest } from "./features.server";
import { useHighlight } from "./hooks/useHighlight";
import { usePostHog } from "./hooks/usePostHog";
import { useTypedMatchesData } from "./hooks/useTypedMatchData";
import { getUser } from "./services/session.server";
import { appEnvTitleTag } from "./utils";
@@ -28,14 +37,24 @@ export const meta: TypedMetaFunction<typeof loader> = (args) => {
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
robots: args.data.features.isManagedCloud ? "index, follow" : "noindex, nofollow",
});
};
export function useV3Enabled() {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
});
return routeMatch?.v3Enabled ?? false;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const session = await getSession(request.headers.get("cookie"));
const toastMessage = session.get("toastMessage") as ToastMessage;
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
const highlightProjectId = env.HIGHLIGHT_PROJECT_ID;
const v3Enabled = env.V3_ENABLED === "true";
const features = featuresForRequest(request);
return typedjson(
@@ -47,6 +66,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
features,
appEnv: env.APP_ENV,
appOrigin: env.APP_ORIGIN,
v3Enabled,
},
{ headers: { "Set-Cookie": await commitSession(session) } }
);
@@ -59,7 +79,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
return false;
}
return true;
return options.defaultShouldRevalidate;
};
export function ErrorBoundary() {
@@ -1,35 +1,32 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Await, useLoaderData } from "@remix-run/react";
import { DataFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/UsageBar";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DailyRunsChart } from "~/components/billing/DailyRunsChat";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, plansPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
export async function loader({ params, request }: LoaderFunctionArgs) {
export async function loader({ request, params }: DataFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug, request });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
const usageData = presenter.call({ userId, slug: organizationSlug, request });
return defer({ usageData });
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
@@ -47,146 +44,194 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const { usageData } = useLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? loaderData.concurrencyData.some(
(c) => c.maxConcurrentRuns >= (currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
const hitsRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={loaderData.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={loaderData.hasConcurrencyData}
/>
</div>
</div>
<Suspense
fallback={
<>
<LoadingElement title="Concurrent runs" />
<LoadingElement title="Runs" />
</>
}
>
<Await
resolve={usageData}
errorElement={<Paragraph>There was a problem loading your usage data.</Paragraph>}
>
{(data) => {
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? data.concurrencyData.some(
(c) =>
c.maxConcurrentRuns >=
(currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
Upgrade to a paid plan before{" "}
<DateTime date={loaderData.periodEnd} includeSeconds={false} includeTime={false} />.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{loaderData.runCostEstimation !== undefined &&
loaderData.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(loaderData.runCostEstimation, false)}
</p>
return (
<>
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={data.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={data.hasConcurrencyData}
/>
</div>
</div>
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"error"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small" className="text-white">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs
limit. Upgrade to a paid plan before{" "}
<DateTime
date={data.periodEnd}
includeSeconds={false}
includeTime={false}
/>
.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{data.runCostEstimation !== undefined &&
data.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(data.runCostEstimation, false)}
</p>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(data.projectedRunCostEstimation, false)}
</p>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={data.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={data.projectedRunsCount}
subscribedToPaidTier={
(currentPlan && currentPlan.subscription?.isPaying) ?? false
}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!data.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={data.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(loaderData.projectedRunCostEstimation, false)}
</p>
<div>
<Header3 className="mb-4">Daily runs</Header3>
<DailyRunsChart
data={data.dailyRunsData}
hasDailyRunsData={data.hasDailyRunsData}
/>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={loaderData.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={loaderData.projectedRunsCount}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!loaderData.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={loaderData.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</>
);
}}
</Await>
</Suspense>
</div>
);
}
function LoadingElement({ title }: { title: string }) {
return (
<div>
<Header2 spacing>{title}</Header2>
<div className="flex h-96 w-full items-center justify-center gap-5 rounded border border-border p-6">
<Spinner />
</div>
</div>
);
@@ -65,7 +65,7 @@ export default function Page() {
</Callout>
)}
{hitRunLimit && (
<Callout variant={"pricing"}>
<Callout variant={"error"}>
{`You have exceeded the monthly
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
can continue to perform runs.`}
@@ -38,31 +38,23 @@ import {
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Project } from "~/models/project.server";
import {
Client,
IntegrationOrApi,
IntegrationsPresenter,
} from "~/presenters/IntegrationsPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
OrganizationParamsSchema,
ProjectParamSchema,
docsCreateIntegration,
docsPath,
integrationClientPath,
} from "~/utils/pathBuilder";
import { OrganizationParamsSchema, docsPath, integrationClientPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new IntegrationsPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
});
@@ -99,7 +91,7 @@ export default function Integrations() {
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] gap-4 divide-x divide-slate-900 overflow-hidden">
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] divide-x divide-slate-900 overflow-hidden">
<PossibleIntegrationsList
options={options}
organizationId={organization.id}
@@ -142,7 +134,7 @@ function PossibleIntegrationsList({
return (
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="py-4 pl-4">
<div className="p-4">
<div className="flex items-center justify-between">
<Header2 className="mb-2">Connect an API</Header2>
<Switch
@@ -18,7 +18,7 @@ import {
import { useOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { IntegrationClientPresenter } from "~/presenters/IntegrationClientPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
IntegrationClientParamSchema,
@@ -29,12 +29,12 @@ import {
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
const presenter = new IntegrationClientPresenter();
const client = await presenter.call({
userId: user.id,
userId,
organizationSlug,
clientSlug: clientParam,
});
@@ -47,6 +47,9 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
const refreshEndpointFetcher = useFetcher();
const refreshingEndpoint = refreshEndpointFetcher.state !== "idle";
const deleteEndpointFetcher = useFetcher();
const deletingEndpoint = deleteEndpointFetcher.state !== "idle";
const revalidator = useRevalidator();
const events = useEventSource(endpointStreamingPath({ id: endpoint.environment.id }), {
event: "message",
@@ -70,12 +73,30 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
>
<SheetContent size="lg">
<SheetHeader>
<Header1>
<div className="flex items-center gap-2">
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
<Header1>Configure endpoint</Header1>
</div>
</Header1>
<div className="flex w-full items-center justify-between">
<Header1>
<div className="flex items-center gap-2">
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
<Header1>Configure endpoint</Header1>
</div>
</Header1>
{endpoint.state === "configured" && (
<deleteEndpointFetcher.Form
method="post"
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
>
<input type="hidden" name="action" value="delete" />
<Button
variant="danger/small"
type="submit"
disabled={deletingEndpoint}
LeadingIcon={deletingEndpoint ? "spinner-white" : undefined}
>
{deletingEndpoint ? "Deleting" : "Delete"}
</Button>
</deleteEndpointFetcher.Form>
)}
</div>
</SheetHeader>
<SheetBody>
<setEndpointUrlFetcher.Form
@@ -90,7 +111,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
<Input
className="rounded-r-none"
{...conform.input(url, { type: "url" })}
defaultValue={"url" in endpoint ? endpoint.url : ""}
defaultValue={"url" in endpoint ? endpoint.url ?? "" : ""}
placeholder="URL for your Trigger API route"
/>
<Button
@@ -123,6 +144,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
method="post"
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
>
<input type="hidden" name="action" value="refresh" />
<Callout
variant="info"
icon={
@@ -0,0 +1,131 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { requireUserId } from "~/services/session.server";
import { EventParamSchema, projectEventsPath, projectPath } from "~/utils/pathBuilder";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
import { EventDetail } from "~/components/event/EventDetail";
import { EventPresenter } from "~/presenters/EventPresenter.server";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { Fragment } from "react";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { RunsTable } from "~/components/runs/RunsTable";
import { RunsFilters } from "~/components/runs/RunFilters";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { useUser } from "~/hooks/useUser";
import { useNavigation } from "@remix-run/react";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { eventParam, projectParam, organizationSlug } = EventParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = RunListSearchSchema.parse(s);
const presenter = new EventPresenter();
try {
const event = await presenter.call({
userId,
projectSlug: projectParam,
organizationSlug,
eventId: eventParam,
});
if (!event) {
throw new Response("Not Found", { status: 404 });
}
const runsPresenter = new RunListPresenter();
const list = await runsPresenter.call({
userId,
filterEnvironment: searchParams.environment,
filterStatus: searchParams.status,
eventId: event.id,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({ event, list });
} catch (e) {
console.log(e);
throw new Response(e instanceof Error ? e.message : JSON.stringify(e), { status: 404 });
}
};
export const handle: Handle = {
breadcrumb: (match) => {
const eventData = useTypedMatchData<typeof loader>(match);
return (
<Fragment>
{eventData && eventData.event && (
<BreadcrumbLink to={match.pathname} title={eventData.event.name} />
)}
</Fragment>
);
},
};
export default function Page() {
const { event, list } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
const user = useUser();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle
title={event.name}
backButton={{
to: projectEventsPath(organization, project),
text: "Events",
}}
/>
</PageTitleRow>
</PageHeader>
<PageBody scrollable={false}>
<div className="grid h-full grid-cols-2">
<div className="overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<EventDetail event={event} />
</div>
<div className="overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<RunsTable
total={list.runs.length}
hasFilters={false}
runs={list.runs}
isLoading={isLoading}
showJob={true}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,95 @@
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 { EventsTable } from "~/components/events/EventsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { EventListPresenter } from "~/presenters/EventListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, projectPath, trimTrailingSlash } from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { EventListSearchSchema } from "~/components/events/EventStatuses";
import { useUser } from "~/hooks/useUser";
import { EventsFilters } from "~/components/events/EventsFilters";
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 = EventListSearchSchema.parse(s);
const presenter = new EventListPresenter();
const list = await presenter.call({
userId,
filterEnvironment: searchParams.environment,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
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();
const user = useUser();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} events`} />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("documentation/concepts/triggers/events")}
variant="secondary/small"
>
Event documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>All events 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-between gap-x-2">
<EventsFilters />
<ListPagination list={list} />
</div>
<EventsTable
total={list.events.length}
hasFilters={false}
events={list.events}
isLoading={isLoading}
eventsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,11 @@
import { Outlet } from "@remix-run/react";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Handle } from "~/utils/handle";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Events" />,
};
export default function Page() {
return <Outlet />;
}
@@ -1,16 +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 { Direction } from "~/components/runs/RunStatuses";
import { cn } from "~/utils/cn";
export function ListPagination({
list,
className,
}: {
list: RunList | WebhookDeliveryList;
className?: string;
}) {
type List = {
pagination: {
next?: string | undefined;
previous?: string | undefined;
};
};
export function ListPagination({ list, className }: { list: List; className?: string }) {
return (
<div className={cn("flex items-center gap-1", className)}>
<PreviousButton cursor={list.pagination.previous} />
@@ -10,6 +10,7 @@ import { RunsTable } from "~/components/runs/RunsTable";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
@@ -19,13 +20,8 @@ import {
organizationIntegrationsPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "./ListPagination";
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
export const RunListSearchSchema = z.object({
cursor: z.string().optional(),
direction: DirectionSchema.optional(),
});
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunsFilters } from "~/components/runs/RunFilters";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -38,11 +34,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new RunListPresenter();
const list = await presenter.call({
userId,
filterEnvironment: searchParams.environment,
filterStatus: searchParams.status,
jobSlug: jobParam,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({
@@ -57,6 +57,7 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const job = useJob();
const user = useUser();
return (
<>
@@ -71,16 +72,21 @@ export default function Page() {
{(open) => (
<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">
<HelpTrigger title="How do I run my Job?" />
<ListPagination list={list} />
<div className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters />
<div className="flex items-center justify-end gap-x-2">
<HelpTrigger title="How do I run my Job?" />
<ListPagination list={list} />
</div>
</div>
<RunsTable
total={list.runs.length}
hasFilters={false}
runs={list.runs}
isLoading={isLoading}
runsParentPath={jobRunsParentPath(organization, project, job)}
currentUser={user}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
@@ -10,6 +10,7 @@ import { jobMatchId, useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
@@ -63,6 +64,7 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const job = useJob();
const user = useUser();
const revalidator = useRevalidator();
const events = useEventSource(runStreamingPath(organization, project, job, run), {
@@ -86,6 +88,7 @@ export default function Page() {
run: runPath(organization, project, job, run),
runsPath: jobRunsParentPath(organization, project, job),
}}
currentUser={user}
/>
);
}
@@ -1,12 +1,9 @@
import { Outlet, useLocation } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson } from "remix-typedjson";
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
@@ -22,11 +19,9 @@ import {
import { Paragraph } from "~/components/primitives/Paragraph";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { projectMatchId, useProject } from "~/hooks/useProject";
import { useProject } from "~/hooks/useProject";
import { useOptionalRun } from "~/hooks/useRun";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { findJobByParams } from "~/models/job.server";
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
import { JobPresenter } from "~/presenters/JobPresenter.server";
import { requireUserId } from "~/services/session.server";
import { titleCase } from "~/utils";
@@ -36,7 +31,6 @@ import {
jobPath,
jobSettingsPath,
jobTestPath,
jobTriggerPath,
trimTrailingSlash,
} from "~/utils/pathBuilder";
@@ -1,5 +1,5 @@
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Await, useLoaderData, useLocation, useNavigate, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -13,11 +13,15 @@ import {
import { RunsTable } from "~/components/runs/RunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
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";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunsFilters } from "~/components/runs/RunFilters";
import { Suspense } from "react";
import { Spinner } from "~/components/primitives/Spinner";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -28,26 +32,32 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const searchParams = RunListSearchSchema.parse(s);
const presenter = new RunListPresenter();
const list = await presenter.call({
const list = presenter.call({
userId,
filterEnvironment: searchParams.environment,
filterStatus: searchParams.status,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
pageSize: 25,
from: searchParams.from,
to: searchParams.to,
});
return typedjson({
return defer({
list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const { list } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
const user = useUser();
return (
<PageContainer>
@@ -69,18 +79,51 @@ export default function Page() {
<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 className="mb-2 flex items-center justify-between gap-x-2">
<RunsFilters />
<Suspense fallback={<></>}>
<Await resolve={list}>{(data) => <ListPagination list={data} />}</Await>
</Suspense>
</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" />
<Suspense
fallback={
<RunsTable
total={0}
hasFilters={false}
showJob={true}
runs={[]}
isLoading={true}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
}
>
<Await resolve={list}>
{(data) => {
const runs = data.runs.map((run) => ({
...run,
startedAt: run.startedAt ? new Date(run.startedAt) : null,
completedAt: run.completedAt ? new Date(run.completedAt) : null,
createdAt: new Date(run.createdAt),
}));
return (
<>
<RunsTable
total={data.runs.length}
hasFilters={false}
showJob={true}
runs={runs}
isLoading={isLoading}
runsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={data} className="mt-2 justify-end" />
</>
);
}}
</Await>
</Suspense>
</div>
</PageBody>
</PageContainer>
@@ -0,0 +1,264 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
} from "~/services/currentProject.server";
import { DeleteProjectService } from "~/services/deleteProject.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
export function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("rename"),
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
}),
z.object({
action: z.literal("delete"),
projectSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${projectSlug}`,
});
}
}),
}),
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
switch (submission.value.action) {
case "rename": {
await prisma.project.update({
where: {
slug: projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
data: {
name: submission.value.projectName,
},
});
return redirectWithSuccessMessage(
projectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const deleteProjectService = new DeleteProjectService();
try {
await deleteProjectService.call({ projectSlug: projectParam, userId });
//we need to clear the project from the session
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(organizationPath({ slug: organizationSlug }), {
headers: { "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession) },
});
} catch (error: unknown) {
logger.error("Project could not be deleted", {
error: error instanceof Error ? error.message : JSON.stringify(error),
});
return redirectWithErrorMessage(
organizationPath({ slug: organizationSlug }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const project = useProject();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [renameForm, { projectName }] = useForm({
id: "rename-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const [deleteForm, { projectSlug }] = useForm({
id: "delete-project",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
}),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} project settings`} />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<Form method="post" {...renameForm.props} className="max-w-md">
<input type="hidden" name="action" value="rename" />
<Fieldset>
<InputGroup>
<Label htmlFor={projectName.id}>Rename your project</Label>
<Input
{...conform.input(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Your project name"
icon="folder"
autoFocus
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename project
</Button>
}
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="max-w-md rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...conform.input(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon="warning"
autoFocus
/>
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete project
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -19,17 +19,17 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { TriggersPresenter } from "~/presenters/TriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, externalTriggerPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const presenter = new TriggersPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
});
@@ -21,17 +21,17 @@ import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { ScheduledTriggersPresenter } from "~/presenters/ScheduledTriggersPresenter.server";
import { requireUser } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { ProjectParamSchema, docsPath, trimTrailingSlash } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const presenter = new ScheduledTriggersPresenter();
const data = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
});
@@ -1,11 +1,16 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
import { Button } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
@@ -18,35 +23,29 @@ import {
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunsTable } from "~/components/runs/RunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TriggerSourcePresenter } from "~/presenters/TriggerSourcePresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { requireUserId } from "~/services/session.server";
import { ActivateSourceService } from "~/services/sources/activateSource.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
rootPath,
projectTriggersPath,
externalTriggerPath,
externalTriggerRunsParentPath,
projectTriggersPath,
trimTrailingSlash,
} 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 { nanoid } from "nanoid";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -55,7 +54,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TriggerSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
triggerSourceId: triggerParam,
@@ -130,6 +129,7 @@ export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const navigation = useNavigation();
const lastSubmission = useActionData();
@@ -234,6 +234,7 @@ export default function Page() {
total={trigger.runList.runs.length}
hasFilters={false}
runsParentPath={externalTriggerRunsParentPath(organization, project, trigger)}
currentUser={user}
/>
<ListPagination list={trigger.runList} className="mt-2 justify-end" />
</>
@@ -10,6 +10,7 @@ import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
@@ -97,6 +98,7 @@ export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const revalidator = useRevalidator();
const events = useEventSource(
@@ -124,6 +126,7 @@ export default function Page() {
id: trigger.id,
}),
}}
currentUser={user}
/>
);
}
@@ -1,39 +1,35 @@
import { json } from "@remix-run/node";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
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 { RunListSearchSchema } from "~/components/runs/RunStatuses";
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 { useUser } from "~/hooks/useUser";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
projectTriggersPath,
externalTriggerPath,
projectWebhookTriggersPath,
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 userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -42,7 +38,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -105,10 +101,7 @@ export const handle: Handle = {
<BreadcrumbIcon />
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
<BreadcrumbIcon />
<BreadcrumbLink
to={trimTrailingSlash(match.pathname)}
title={data.trigger.key}
/>
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title={data.trigger.key} />
</Fragment>
);
},
@@ -118,6 +111,7 @@ export default function Page() {
const { trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const navigation = useNavigation();
const lastSubmission = useActionData();
@@ -135,17 +129,17 @@ export default function Page() {
return (
<>
<Paragraph variant="small" spacing>
Webhook Triggers need to be registered with the external service. You can see the list
of attempted registrations below.
Webhook Triggers need to be registered with the external service. You can see the list of
attempted registrations below.
</Paragraph>
{!trigger.active &&
{!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
<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}
/>
@@ -159,8 +153,9 @@ export default function Page() {
>
{isLoading ? "Retrying…" : "Retry now"}
</Button> */}
</Callout>
</Form>}
</Callout>
</Form>
)}
{trigger.runList ? (
<>
@@ -170,6 +165,7 @@ export default function Page() {
total={trigger.runList.runs.length}
hasFilters={false}
runsParentPath={webhookTriggerRunsParentPath(organization, project, trigger)}
currentUser={user}
/>
<ListPagination list={trigger.runList} className="mt-2 justify-end" />
</>
@@ -5,10 +5,13 @@ 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 { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { requireUser } from "~/services/session.server";
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import {
TriggerSourceParamSchema,
@@ -19,12 +22,9 @@ import {
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 userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookDeliveryPresenter();
const { webhook } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -2,7 +2,6 @@ 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,
@@ -12,20 +11,20 @@ import {
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { requireUser } from "~/services/session.server";
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
import { requireUserId } 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 userId = await requireUserId(request);
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
const url = new URL(request.url);
@@ -34,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new WebhookSourcePresenter();
const { trigger } = await presenter.call({
userId: user.id,
userId,
organizationSlug,
projectSlug: projectParam,
webhookId: triggerParam,
@@ -77,11 +76,7 @@ export default function Page() {
value={trigger.integration.slug}
to={trigger.integrationLink}
/>
<PageInfoProperty
icon="webhook"
label="HTTP Endpoint"
to={trigger.httpEndpointLink}
/>
<PageInfoProperty icon="webhook" label="HTTP Endpoint" to={trigger.httpEndpointLink} />
</PageInfoGroup>
</PageInfoRow>
<PageTabs
@@ -10,6 +10,7 @@ import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
@@ -103,6 +104,7 @@ export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const revalidator = useRevalidator();
const events = useEventSource(
@@ -130,6 +132,7 @@ export default function Page() {
id: trigger.id,
}),
}}
currentUser={user}
/>
);
}
@@ -10,6 +10,7 @@ import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { RunPresenter } from "~/presenters/RunPresenter.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
@@ -83,7 +84,10 @@ export const handle: Handle = {
title={`${data.trigger.integration.title}: ${data.trigger.integration.slug}`}
/>
<BreadcrumbIcon />
<BreadcrumbLink to={webhookDeliveryPath(org, project, { id: data.trigger.id })} title="Deliveries" />
<BreadcrumbLink
to={webhookDeliveryPath(org, project, { id: data.trigger.id })}
title="Deliveries"
/>
<BreadcrumbIcon />
{data && data.run && (
<BreadcrumbLink
@@ -100,6 +104,7 @@ export default function Page() {
const { run, trigger } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const user = useUser();
const revalidator = useRevalidator();
const events = useEventSource(
@@ -127,6 +132,7 @@ export default function Page() {
id: trigger.id,
}),
}}
currentUser={user}
/>
);
}
@@ -1,67 +1,17 @@
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import invariant from "tiny-invariant";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { useOrganization } from "~/hooks/useOrganizations";
import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { Handle } from "~/utils/handle";
import { projectPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = params;
invariant(projectParam, "projectParam not found");
try {
const presenter = new ProjectPresenter();
const project = await presenter.call({
userId,
slug: projectParam,
});
if (!project) {
throw new Response("Not Found", {
status: 404,
statusText: `Project ${projectParam} not found in your Organization.`,
});
}
telemetry.project.identify({ project });
const session = await setCurrentProjectId(project.id, request);
return typedjson(
{
project,
},
{
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
}
);
} catch (error) {
if (error instanceof Response) {
throw error;
}
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
import { loader as orgLoader } from "../_app.orgs.$organizationSlug/route";
export const handle: Handle = {
breadcrumb: (match) => {
const data = useTypedMatchData<typeof loader>(match);
breadcrumb: (match, matches) => {
const orgMatch = matches.find((m) => m.id === organizationMatchId);
const data = useTypedMatchData<typeof orgLoader>(orgMatch);
return <BreadcrumbLink to={match.pathname} title={data?.project.name ?? "Project"} />;
},
scripts: (match) => [
@@ -0,0 +1,270 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { r } from "tar";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
clearCurrentProjectId,
commitCurrentProjectSession,
} from "~/services/currentProject.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
export function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("rename"),
organizationName: z
.string()
.min(3, "Organization name must have at least 3 characters")
.max(50),
}),
z.object({
action: z.literal("delete"),
organizationSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${organizationSlug}`,
});
}
}),
}),
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug } = params;
if (!organizationSlug) {
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === organizationSlug, organizationSlug };
},
});
const submission = parse(formData, { schema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
switch (submission.value.action) {
case "rename": {
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
data: {
title: submission.value.organizationName,
},
});
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
`Organization renamed to ${submission.value.organizationName}`
);
}
case "delete": {
const deleteOrganizationService = new DeleteOrganizationService();
try {
await deleteOrganizationService.call({ organizationSlug, userId, request });
//we need to clear the project from the session
const removeProjectIdSession = await clearCurrentProjectId(request);
return redirect(rootPath(), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
},
});
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
logger.error("Organization could not be deleted", {
error: errorMessage,
});
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
errorMessage
);
}
}
}
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export default function Page() {
const organization = useOrganization();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [renameForm, { organizationName }] = useForm({
id: "rename-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema(),
});
},
});
const [deleteForm, { organizationSlug }] = useForm({
id: "delete-organization",
// TODO: type this
lastSubmission: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({
isMatch: slug === organization.slug,
organizationSlug: organization.slug,
}),
}),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${organization.title} organization settings`} />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<Form method="post" {...renameForm.props} className="max-w-md">
<input type="hidden" name="action" value="rename" />
<Fieldset>
<InputGroup>
<Label htmlFor={organizationName.id}>Rename your organization</Label>
<Input
{...conform.input(organizationName, { type: "text" })}
defaultValue={organization.title}
placeholder="Your organization name"
icon="folder"
autoFocus
/>
<FormError id={organizationName.errorId}>{organizationName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="max-w-md rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
<Input
{...conform.input(organizationSlug, { type: "text" })}
placeholder="Your organization slug"
icon="warning"
autoFocus
/>
<FormError id={organizationSlug.errorId}>{organizationSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Organization slug
<InlineCode variant="extra-small">{organization.slug}</InlineCode> and then
press Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -1,4 +1,4 @@
import { Outlet, UIMatch } from "@remix-run/react";
import { Outlet, ShouldRevalidateFunction, UIMatch } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -9,11 +9,10 @@ import { PageNavigationIndicator } from "~/components/navigation/PageNavigationI
import { SideMenu } from "~/components/navigation/SideMenu";
import { featuresForRequest } from "~/features.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { BillingService } from "~/services/billing.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { BillingService } from "~/services/billing.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -48,6 +47,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
telemetry.organization.identify({ organization });
telemetry.project.identify({ project });
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
@@ -56,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
organizations,
organization,
currentProject: project,
project,
isImpersonating: !!impersonationId,
currentPlan,
});
@@ -72,13 +72,10 @@ export const handle: Handle = {
};
export default function Organization() {
const { organization, currentProject, organizations, isImpersonating } =
const { organization, project, organizations, isImpersonating } =
useTypedLoaderData<typeof loader>();
const user = useUser();
//the side menu won't change projects when using the switcher unless we use the hook (on project pages)
const project = useOptionalProject() ?? currentProject;
return (
<>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
@@ -111,3 +108,23 @@ export function ErrorBoundary() {
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
);
}
export const shouldRevalidate: ShouldRevalidateFunction = ({
defaultShouldRevalidate,
currentParams,
nextParams,
}) => {
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
return defaultShouldRevalidate;
};
@@ -1,12 +1,14 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction } from "@remix-run/node";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
@@ -14,11 +16,44 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { useOrganization } from "~/hooks/useOrganizations";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, projectPath } from "~/utils/pathBuilder";
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug, members: { some: { userId } } },
select: {
id: true,
title: true,
_count: {
select: {
projects: {
where: { deletedAt: null },
},
},
},
},
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
return typedjson({
organization: {
id: organization.id,
title: organization.title,
slug: organizationSlug,
projectsCount: organization._count.projects,
},
});
}
const schema = z.object({
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
@@ -54,7 +89,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function NewOrganizationPage() {
const organization = useOrganization();
const { organization } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const [form, { projectName }] = useForm({
@@ -71,10 +106,15 @@ export default function NewOrganizationPage() {
<div>
<FormTitle
LeadingIcon="folder"
title="Create a new Project"
description="Create a new Project to help you organize the Jobs you create."
title="Create a new project"
description={`This will create a new project in your "${organization.title}" organization. `}
/>
<Form method="post" {...form.props}>
{organization.projectsCount === 0 && (
<Callout variant="info" className="mb-4">
Organizations require at least one project, please create one to continue.
</Callout>
)}
<Fieldset>
<InputGroup>
<Label htmlFor={projectName.id}>Project name</Label>
@@ -93,9 +133,11 @@ export default function NewOrganizationPage() {
</Button>
}
cancelButton={
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
Cancel
</LinkButton>
organization.projectsCount > 0 ? (
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
Cancel
</LinkButton>
) : undefined
}
/>
</Fieldset>
@@ -41,10 +41,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
}
const orgsPresenter = new OrganizationsPresenter();
const { organizations, organization, project } = await orgsPresenter.call({
const { project } = await orgsPresenter.call({
userId,
request,
organizationSlug,
projectSlug: undefined,
});
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
@@ -52,7 +53,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
export default function ChoosePlanPage() {
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
const project = useOptionalProject();
return (
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
@@ -63,7 +63,6 @@ export default function ChoosePlanPage() {
showActionText={false}
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
@@ -26,6 +26,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
request,
organizationSlug,
projectSlug: undefined,
});
const { isManagedCloud } = featuresForRequest(request);
+12 -3
View File
@@ -3,7 +3,7 @@ import { parse } from "@conform-to/zod";
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 { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
@@ -23,7 +23,7 @@ import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
@@ -86,6 +86,7 @@ export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
const [form, { orgName, projectName }] = useForm({
id: "create-organization",
@@ -95,8 +96,11 @@ export default function NewOrganizationPage() {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
shouldValidate: "onSubmit",
});
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
return (
<MainCenteredContainer className="max-w-[22rem]">
<FormTitle LeadingIcon="organization" title="Create an Organization" />
@@ -161,7 +165,12 @@ export default function NewOrganizationPage() {
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
<Button
type="submit"
variant={"primary/small"}
TrailingIcon="arrow-right"
disabled={isLoading}
>
Create
</Button>
}
@@ -0,0 +1,193 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
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 { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { updateUser } from "~/models/user.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { accountPath } from "~/utils/pathBuilder";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Profile"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Your profile" />
</PageTitleRow>
</PageHeader>
<PageBody>
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive onboarding emails"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</Form>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,93 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { title } from "process";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { Icon } from "~/components/primitives/Icon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { logger } from "~/services/logger.server";
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
authorizationCode: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
throw new Response(undefined, {
status: 400,
statusText: "Invalid params",
});
}
try {
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
parsedParams.data.authorizationCode,
userId
);
return typedjson({
success: true as const,
});
} catch (error) {
if (error instanceof Response) {
throw error;
}
if (error instanceof Error) {
return typedjson({
success: false as const,
error: error.message,
});
}
logger.error(JSON.stringify(error));
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
return (
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
<div className="flex flex-col items-center space-y-4">
{result.success ? (
<div>
<Header1 className="mb-2 flex items-center gap-1">
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>Return to your terminal to continue.</Paragraph>
</div>
) : (
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
{result.error}
</Callout>
<Paragraph spacing>
There was a problem authenticating you, please try logging in with your CLI again.
</Paragraph>
</div>
)}
</div>
</MainCenteredContainer>
</AppContainer>
);
}
@@ -0,0 +1,312 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useFetcher } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
CreatedPersonalAccessToken,
ObfuscatedPersonalAccessToken,
createPersonalAccessToken,
getValidPersonalAccessTokens,
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { personalAccessTokensPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
return typedjson({
personalAccessTokens,
});
} catch (error) {
if (error instanceof Response) {
throw error;
}
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
const CreateTokenSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
tokenName: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
}),
z.object({
action: z.literal("revoke"),
tokenId: z.string(),
}),
]);
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = parse(formData, { schema: CreateTokenSchema });
if (!submission.value) {
return json(submission);
}
switch (submission.value.action) {
case "create": {
try {
const tokenResult = await createPersonalAccessToken({
name: submission.value.tokenName,
userId,
});
return json({ ...submission, payload: { token: tokenResult } });
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
case "revoke": {
try {
await revokePersonalAccessToken(submission.value.tokenId);
return redirectWithSuccessMessage(
personalAccessTokensPath(),
request,
"Personal Access Token revoked"
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
default: {
return json({ errors: { body: "Invalid action" } }, { status: 400 });
}
}
};
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Personal Access Tokens"} />;
},
};
export default function Page() {
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Personal Access Tokens" />
<PageButtons>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small">Create new token</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Create a Personal Access Token</DialogHeader>
<CreatePersonalAccessToken />
</DialogContent>
</Dialog>
</PageButtons>
</PageTitleRow>
<PageDescription>Personal Access Tokens can be used with our CLI and API.</PageDescription>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Token</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Last accessed</TableHeaderCell>
<TableHeaderCell hiddenLabel>Delete</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{personalAccessTokens.length > 0 ? (
personalAccessTokens.map((personalAccessToken) => {
return (
<TableRow key={personalAccessToken.id} className="group">
<TableCell>{personalAccessToken.name}</TableCell>
<TableCell>{personalAccessToken.obfuscatedToken}</TableCell>
<TableCell>
<DateTime date={personalAccessToken.createdAt} />
</TableCell>
<TableCell>
{personalAccessToken.lastAccessedAt ? (
<DateTime date={personalAccessToken.lastAccessedAt} />
) : (
"Never"
)}
</TableCell>
<TableCell alignment="right">
<RevokePersonalAccessToken token={personalAccessToken} />
</TableCell>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={5}>
<Paragraph variant="small" className="flex items-center justify-center">
You have no Personal Access Tokens (that haven't been revoked).
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</PageBody>
</PageContainer>
);
}
function CreatePersonalAccessToken() {
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data as any;
const [form, { tokenName }] = useForm({
id: "create-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
const token = lastSubmission?.payload?.token
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
: undefined;
return (
<div className="max-w-full overflow-x-hidden">
{token ? (
<div className="flex flex-col gap-2 p-2">
<Header2>Successfully generated a new token</Header2>
<Callout variant="success">
Copy this access token and store it in a secure place - you will not be able to see it
again.
</Callout>
<ClipboardField
secure
value={token.token}
variant={"secondary/medium"}
icon={<ShieldExclamationIcon className="h-5 w-5 text-emerald-500" />}
/>
</div>
) : (
<fetcher.Form method="post" {...form.props}>
<input type="hidden" name="action" value="create" />
<Fieldset>
<InputGroup>
<Label htmlFor={tokenName.id}>Name</Label>
<Input
{...conform.input(tokenName, { type: "text" })}
placeholder="The name of your Personal Access Token"
defaultValue=""
icon={ShieldCheckIcon}
autoComplete="off"
data-1p-ignore
/>
<Hint>
This will help you to identify your token. Tokens called "cli" are automatically
generated when you login with our CLI.
</Hint>
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
/>
</Fieldset>
</fetcher.Form>
)}
</div>
);
}
function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessToken }) {
const lastSubmission = useActionData();
const [form, { tokenId }] = useForm({
id: "revoke-personal-access-token",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateTokenSchema });
},
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs" />
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Revoke Personal Access Token</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Are you sure you want to revoke "{token.name}"? This can't be reversed.
</Paragraph>
<Form method="post" {...form.props}>
<input type="hidden" name="action" value="revoke" />
<input type="hidden" name="tokenId" value={token.id} />
<Button type="submit" variant="danger/medium" fullWidth>
Revoke token
</Button>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
+22 -172
View File
@@ -1,185 +1,35 @@
import { Form, useActionData } from "@remix-run/react";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
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 { Outlet } from "@remix-run/react";
import { AppContainer } from "~/components/layout/AppLayout";
import { AccountSideMenu } from "~/components/navigation/AccountSideMenu";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { useUser } from "~/hooks/useUser";
import { z } from "zod";
import { ActionFunction, json, redirect } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { parse } from "@conform-to/zod";
import { accountPath, rootPath } from "~/utils/pathBuilder";
import { conform, useForm } from "@conform-to/react";
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
import { Checkbox } from "~/components/primitives/Checkbox";
import { updateUser } from "~/models/user.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { prisma } from "~/db.server";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Handle } from "~/utils/handle";
function createSchema(
constraints: {
isEmailUnique?: (email: string) => Promise<boolean>;
} = {}
) {
return z.object({
name: z
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
email: z
.string()
.email()
.superRefine((email, ctx) => {
if (constraints.isEmailUnique === undefined) {
//client-side validation skips this
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conform.VALIDATION_UNDEFINED,
});
} else {
// Tell zod this is an async validation by returning the promise
return constraints.isEmailUnique(email).then((isUnique) => {
if (isUnique) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email is already being used by a different account",
});
});
}
}),
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
});
}
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const formData = await request.formData();
const formSchema = createSchema({
isEmailUnique: async (email) => {
const existingUser = await prisma.user.findFirst({
where: {
email,
},
});
if (!existingUser) {
return true;
}
if (existingUser.id === userId) {
return true;
}
return false;
},
});
const submission = await parse(formData, { schema: formSchema, async: true });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const user = await updateUser({
id: userId,
name: submission.value.name,
email: submission.value.email,
marketingEmails: submission.value.marketingEmails,
});
return redirectWithSuccessMessage(
accountPath(),
request,
"Your account profile has been updated."
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
export const handle: Handle = {
breadcrumb: (match) => {
return <BreadcrumbLink to={match.pathname} title={"Account"} />;
},
};
export default function Page() {
const user = useUser();
const lastSubmission = useActionData();
const [form, { name, email, marketingEmails }] = useForm({
id: "account",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: createSchema() });
},
});
return (
<AppContainer>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
<InputGroup className="mb-4">
<Label htmlFor={name.id}>Profile picture</Label>
<UserProfilePhoto className="h-24 w-24" />
</InputGroup>
<Fieldset>
<InputGroup>
<Label htmlFor={name.id}>Full name</Label>
<Input
{...conform.input(name, { type: "text" })}
placeholder="Your full name"
defaultValue={user?.name ?? ""}
icon="account"
/>
<Hint>Your teammates will see this</Hint>
<FormError id={name.errorId}>{name.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={email.id}>Email address</Label>
<Input
{...conform.input(email, { type: "text" })}
placeholder="Your email"
defaultValue={user?.email ?? ""}
icon="envelope"
/>
<FormError id={email.errorId}>{email.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Notifications</Label>
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
</InputGroup>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<AccountSideMenu user={user} />
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
Update
</Button>
}
cancelButton={
<LinkButton to={rootPath()} variant={"secondary/small"}>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</MainCenteredContainer>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border">
<Breadcrumb />
<div className="flex h-full items-center gap-4">
<PageNavigationIndicator className="mr-2" />
</div>
</div>
<Outlet />
</div>
</div>
</AppContainer>
);
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { HomeIcon } from "@heroicons/react/24/outline";
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { redirect, typedjson } from "remix-typedjson";
import { getUser, requireUserId } from "~/services/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
@@ -19,8 +18,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Page() {
const data = useTypedLoaderData<typeof loader>();
return (
<div className="h-full w-full">
<Outlet />
@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
/** Used to create an AuthorizationCode, that can then be used to obtain a Personal Access Token by logging in with the provided URL */
export async function action({ request }: ActionFunctionArgs) {
logger.info("Creating AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//there is no authentication on this endpoint, anyone can create an AuthorizationCode.
//they're only used to allow a user to login, when they'll then receive a Personal Access Token
try {
const authorizationCode = await createAuthorizationCode();
const responseJson: CreateAuthorizationCodeResponse = {
authorizationCode: authorizationCode.code,
url: `${env.APP_ORIGIN}/account/authorization-code/${authorizationCode.code}`,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error creating AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -0,0 +1,48 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CancelRunsForJobService } from "~/services/jobs/cancelRunsForJob.server";
const ParamsSchema = z.object({
jobSlug: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return json({ error: "Invalid or Missing jobSlug" }, { status: 400 });
}
const { jobSlug } = parsed.data;
const service = new CancelRunsForJobService();
try {
const res = await service.call(authenticatedEnv, jobSlug);
if (!res) {
return json({ error: "Job not found" }, { status: 404 });
}
return json(res);
} catch (err) {
logger.error("CancelRunsForJobService.call() error", { error: err });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -72,7 +72,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
if (!run) {
return json({ error: "Job count not be invoked" }, { status: 500 });
return json({ error: "Job could not be invoked" }, { status: 500 });
}
return json({ id: run.id });
+50
View File
@@ -0,0 +1,50 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
GetPersonalAccessTokenRequestSchema,
GetPersonalAccessTokenResponse,
} from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { logger } from "~/services/logger.server";
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
export async function action({ request }: ActionFunctionArgs) {
logger.info("Getting PersonalAccessToken from AuthorizationCode", { url: request.url });
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
//There is no authentication on this endpoint, anyone can create an AuthorizationCode.
//But only a logged in user can create a PersonalAccessToken, so for a user who can't login to the app this will always fail.
// Now parse the request body
const anyBody = await request.json();
const body = GetPersonalAccessTokenRequestSchema.safeParse(anyBody);
if (!body.success) {
return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 });
}
try {
const personalAccessToken = await getPersonalAccessTokenFromAuthorizationCode(
body.data.authorizationCode
);
const responseJson: GetPersonalAccessTokenResponse = {
token: personalAccessToken.token,
};
return json(responseJson);
} catch (error) {
if (error instanceof Error) {
logger.error("Error getting PersonalAccessToken from AuthorizationCode", {
url: request.url,
error: error.message,
});
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
@@ -0,0 +1,84 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { GetEvent } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
const ParamsSchema = z.object({
eventId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: true,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return apiCors(request, json({ error: "Invalid or Missing eventId" }, { status: 400 }));
}
const { eventId } = parsed.data;
const event = await findEventRecord(eventId, authenticatedEnv.id);
if (!event) {
return apiCors(request, json({ error: "Event not found" }, { status: 404 }));
}
return apiCors(request, json(toJSON(event)));
}
function toJSON(eventRecord: FoundEventRecord): GetEvent {
return {
id: eventRecord.eventId,
name: eventRecord.name,
createdAt: eventRecord.createdAt,
updatedAt: eventRecord.updatedAt,
runs: eventRecord.runs.map((run) => ({
id: run.id,
status: run.status,
startedAt: run.startedAt,
completedAt: run.completedAt,
})),
};
}
type FoundEventRecord = NonNullable<Awaited<ReturnType<typeof findEventRecord>>>;
async function findEventRecord(eventId: string, environmentId: string) {
return await prisma.eventRecord.findUnique({
select: {
eventId: true,
name: true,
createdAt: true,
updatedAt: true,
runs: {
select: {
id: true,
status: true,
startedAt: true,
completedAt: true,
},
},
},
where: {
eventId_environmentId: {
eventId,
environmentId,
},
},
});
}
@@ -0,0 +1,82 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { apiCors } from "~/utils/apiCors";
const ParamsSchema = z.object({
runId: z.string(),
});
const RecordsSchema = z.array(JobRunStatusRecordSchema);
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request, { allowPublicKey: true });
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const { runId } = ParamsSchema.parse(params);
logger.debug("Get run statuses", {
runId,
});
try {
const run = await prisma.jobRun.findUnique({
where: {
id: runId,
},
select: {
id: true,
status: true,
output: true,
statuses: {
orderBy: {
createdAt: "asc",
},
},
},
});
if (!run) {
return apiCors(request, json({ error: `No run found for id ${runId}` }, { status: 404 }));
}
const parsedStatuses = RecordsSchema.parse(
run.statuses.map((s) => ({
...s,
state: s.state ?? undefined,
data: s.data ?? undefined,
history: s.history ?? undefined,
}))
);
return apiCors(
request,
json({
run: {
id: run.id,
status: run.status,
output: run.output,
},
statuses: parsedStatuses,
})
);
} catch (error) {
if (error instanceof Error) {
return apiCors(request, json({ error: error.message }, { status: 400 }));
}
return apiCors(request, json({ error: "Something went wrong" }, { status: 500 }));
}
}

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