Compare commits

...

83 Commits

Author SHA1 Message Date
Eric Allam 91afa5ebbf Release 3.3.5 (#1535)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-03 18:13:54 +00:00
github-actions[bot] 65262dc3d7 chore: Update version for release (#1534)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-03 18:12:28 +00:00
Eric Allam 9105701ae0 Fix cancelled runs breaking realtime subscriptions (#1533) 2024-12-03 16:55:27 +00:00
nicktrn 9b35cc484b Release 3.3.4
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-03 12:16:56 +00:00
github-actions[bot] 30a04a5a06 chore: Update version for release (#1531)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-03 12:16:08 +00:00
nicktrn 493315af48 Hide batch actions for dev runs 2024-12-03 11:18:40 +00:00
Eric Allam 8db1da69e9 Triggering docs and adding options to all trigger hooks (#1528) 2024-12-03 10:15:49 +00:00
nicktrn cd7a45101e Fix: Extract the correct SDK version during build (#1530)
* fix build manifest sdk version

* new cli version prop in deployment tab

* changeset
2024-12-03 10:00:55 +00:00
nicktrn 29d107dc0a Feat: Add button to try and resume pending batches (#1529)
* add try and resume action for in-progress batches

* don't reinvent the wheel
2024-12-03 09:50:55 +00:00
Eric Allam cf7dc8d719 Release 3.3.3 (#1527)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-02 20:02:34 +00:00
github-actions[bot] 9ced599b19 chore: Update version for release (#1526)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-02 20:01:09 +00:00
Eric Allam 67592ec2b4 Multiple streams can now be consumed at the same time (#1522)
* Mutliple streams can be now consumed simultaneously

* Update prerelease script

* Add changeset

* Make it core

* Handle API error responses when streaming
2024-12-02 17:58:10 +00:00
Eric Allam f7bf7bc268 Release 3.3.2 (#1521)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-02 14:14:14 +00:00
github-actions[bot] bb57426a0d chore: Update version for release (#1516)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-02 14:12:27 +00:00
Eric Allam 3ab7eb9c7a Add unique indexes for the oneTimeUseToken columns (#1517) 2024-12-02 13:45:38 +00:00
Eric Allam 2892efad04 Fix: actually wait for realtime streams to finished (#1520)
* Realtime streams fix waiting for streams to finished

* Add changeset
2024-12-02 13:40:18 +00:00
nicktrn 979ba51d2f Feat: configurable deploy timeouts (#1519)
* make deploy timeout configurable

* increase default deploy timeout to 8 minutes
2024-12-02 11:02:16 +00:00
Eric Allam 364ea565ed Use one-time use tokens when triggering or batch triggering from the frontend (#1515)
* Use one-time use tokens when triggering or batch triggering from the frontend

* Add changeset
2024-11-29 20:16:37 +00:00
Eric Allam 58252728f6 Release 3.3.1 (#1513)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-29 13:00:10 +00:00
github-actions[bot] 5eaad0577e chore: Update version for release (#1508)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-29 12:56:50 +00:00
Matt Aitken 00f1103deb Show "root" runs by default, save the preference to a cookie (#1512)
* “Root only” toggle, off by default. Disabled when filtering by tasks

* Remove text transition on Switch because it didn’t match the container
2024-11-29 12:40:15 +00:00
Eric Allam c37622e7b6 Realtime: improve scope access to runs with tags and batches (#1511)
* JWT scopes for tags and batches can now access runs that have the tag or are in the batch

- useTaskTrigger can now submit options
- auto-generated batch trigger public access tokens no longer need each individual run ID scope

* Add changeset

* Added task scopes to work like tags and batches

Also removed scopes for tags when auto-generating a public access token as that could be dangerous.
2024-11-29 11:37:11 +00:00
Eric Allam d67023aa8f Fixed the missing icons in trigger spans (#1506) 2024-11-28 19:09:33 +00:00
Matt Aitken d9bfe55a8c Run filters: fix for “Batch ID” that should say “Schedule ID” 2024-11-28 17:00:17 +00:00
Eric Allam f4a18feca0 Docs: Batch Trigger upgrades (#1505)
* WIP batch docs

* More batch trigger v2 docs
2024-11-28 15:55:45 +00:00
Matt Aitken 0acd052061 Release 3.3.0
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-28 15:27:20 +00:00
github-actions[bot] 6bf3bbcfd7 chore: Update version for release (#1504)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-28 15:26:11 +00:00
James Ritchie 1077709e15 Side help panel (#1488)
* WIP adding a side help panel to the tasks page

* Optionally display the shortcut before the trailing icon in the button

* Updated the close icon

* WIP adding a new side help panel

* WIP adding content to the side help panel

* WIP new side help panel content

* Removed images as not needed any more

* Added content to the side help menu

* Help panel open/closed state stored as cookie

* Removed the icons from the docs and examples links
2024-11-28 11:49:52 +00:00
Willow (GHOST) 54017cbffa feat: always show alerts tab and lock email alerts when keys are missing (#1478) 2024-11-28 11:43:34 +00:00
James Ritchie 39ef733a34 Deploy page table rows stay selected when the side menu is open (#1492) 2024-11-28 11:35:08 +00:00
Fernando Coelho 736f577c25 Update Infisical instructions to new SDK (#1493)
* Update Infisical instructions to new SDK

Current documentation is using old Infisical SDK implementation.

New SDK has a different name, and authenticates on a method, not in the class constructor.

* Fix import and client secret env
2024-11-28 11:34:27 +00:00
James Ritchie b7de02ec14 Improved the text selection style (#1497) 2024-11-28 11:33:27 +00:00
James Ritchie 90db0f6e01 Adds realtime limits to the billing page pricing tiers (#1500) 2024-11-28 11:32:46 +00:00
Eric Allam 6df8069c0e Batch Trigger upgrades (#1502)
* WIP batch trigger v2

* Fix for the DateField being one month out… getUTCMonth() is zero indexed 🤦‍♂️

* Added a custom date range filter

* Deal with closing the custom date range

* Child runs filter

* Fix for the clear button untoggling the child runs

* WIP batchTriggerV2

* Finished removing rate limit from the webapp

* Added an index TaskRun to make useRealtimeBatch performant

* Renamed the period filter labels to be “Last X mins”

* Denormalize background worker columns into TaskRun

* Use the runTags column on TaskRun

* Add TaskRun ("projectId", "id" DESC) index

* Improved the v2 batch trigger endpoint to process items in parallel and also added a threshold, below which the processing of items is async

* Added a runId filter, and WIP for batchId filter

* WIP triggerAll

* Add new batch methods for triggering multiple different tasks in a single batch

* Disabled switch styling

* Batch filtering, force child runs to show if filtering by batch/run

* Added schedule ID filtering

* Force child runs to show when filtering by scheduleId, for consistency

* realtime: allow setting enabled: false on useApiClient

* Batches page

* Always complete batches, not only batchTriggerAndWait in deployed tasks

* Add batch.retrieve and allow filtering by batch in runs.list

* Renamed pending to “In progress”

* Tidied up the table a bit

* Deal with old batches: “Legacy batch”

* Added the Batch to the run inspector

* Fixed the migration that created the new idempotency key index on BatchTaskRun

* Fixed the name of the idempotencyKeyExpiresAt option and now default idempotency key TTL is 30 days, not 24 hours

* Timezone fix: wrong month in Usage page dropdown

* The DateField now defaults to local time, but can be overriden to use utc with an option

* Don’t allow the task icon to get squished

* BatchFilters removed unused imports

* In the batch filtering, use `id` instead of `batchId` in the URL

* BatchFilters: we don’t need a child tasks hidden input field

* Creates some common filter components/functions

* Fix for batchVersion check when filtering by batch status

* Add additional logging around telemetry and more attributes for trigger spans

* Show clear button for specific id filters

* Batch list: only allow environments that are part of this project

* Unnecessary optional chain

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Add JSDocs

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-28 11:25:00 +00:00
nicktrn b714d6fabf Release 3.2.2
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-27 16:31:01 +00:00
github-actions[bot] 576715374e chore: Update version for release (#1503)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-27 16:29:30 +00:00
nicktrn 9046ea4f23 Fix: Prevent hung worker process on flush exceptions (#1501)
* deprecate cli logger warn and error methods

* replace broken error logger in deployed workers

* changeset
2024-11-27 16:22:26 +00:00
Eric Allam 7e209e0771 Stop using Response errors when validating API Keys (#1498)
* Stop using Response errors when validating API Keys, instead introduce a new "Result" type that has success and failure conditions. Adding in a way to progressively adopt because this touches everything.

* Make sure authenticateApiRequestWithFailure always returns a result
2024-11-25 15:19:05 +00:00
Eric Allam 3792394e5c Always await wrapResponse since it returns a Promise, to protect against UnhandledRejection errors (#1494) 2024-11-22 22:16:50 +00:00
Eric Allam 4305a23668 Fix: trigger queue updates (#1491)
* Fixed task trigger queue update logic to only update an existing queue if the concurrency limit changes, instead of on every single trigger task call

* Fix bad update from another branch
2024-11-22 10:39:34 +00:00
James Ritchie e7fc592cd9 Reinstate pagination on schedules page 2024-11-21 09:53:39 +00:00
James Ritchie cb12a73db6 Fix for the last task in a long list wasn’t on screen 2024-11-20 18:10:05 +00:00
James Ritchie 31fc9e12b7 Show the delete alerts button in the popover 2024-11-20 17:40:37 +00:00
James Ritchie 2bdbedcdd8 Fixed layout bug in the alerts page if you don’t have any alerts set 2024-11-20 14:55:22 +00:00
Eric Allam 332166a098 Release 3.2.1
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-20 14:14:56 +00:00
github-actions[bot] 14f19066b5 chore: Update version for release (#1481)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-20 14:13:53 +00:00
Eric Allam af7a7681cf Update realtime docs for streaming and a couple tweaks to the SDK (#1486) 2024-11-20 14:10:38 +00:00
James Ritchie c044cb125e Fixed the scrolling on the JSON editor on the test page 2024-11-19 16:06:51 +00:00
Eric Allam 8ba226b99a Add TRIGGER_STREAMS_URL to auto-populated dev env vars (#1485) 2024-11-19 16:06:41 +00:00
Eric Allam 7d4708839f Upgrade zod to 3.23.8 (#1484)
* Upgrade to zod 3.23.8

* Add changeset
2024-11-19 15:51:37 +00:00
James Ritchie 532fdcad47 Fix to keep the Alerts progress bar at the bottom of the screen when you have reached the max 2024-11-19 13:34:29 +00:00
Eric Allam 186942de5d minor update should be a patch (#1483) 2024-11-19 13:16:13 +00:00
Eric Allam 23b43be952 feat/realtime-streams (#1470)
* WIP realtime streams

* Handle realtime with large payloads or outputs #1451

* feat: optimize Redis stream handling with batching

Add STREAM_ORIGIN to environment schema. Improve performance in
RealtimeStreams by using TextDecoderStream for simpler text
decoding and implementing batching of XADD commands for Redis
streams. Limit stream size using MAXLEN option. Update
environment variable repository with new variable type. Adjust
import statements for Redis key and value types.

* 🔧 chore: add dev dependencies for bundle analysis

* add metadata tests and a few more utilties

* Add stream tests and improve streaming

* Added AI tool tasks, descriptions to tasks

* Use the config file path to determine the workingDir, then the package.json path

* Remove stream test files

* useTaskTrigger react hook that allows triggering a task from the client

* Add streaming support for the realtime react hooks

* Add ability to stream results after useTaskTrigger

* Improve the stream throttling

* Use the runId as the ID key to bust the cache after triggering

* Upgrade to to the latest electric sql client and server

* Make realtime server backwards compat with 3.1.2 release

* Pass the runId into useRealtimeRun

* Fix scopes when specifiying reading all runs

* WIP @trigger.dev/rsc package

* Various fixes and accepted recommendations by CodeRabbit

* Regenerate pnpm lock file

* A couple tweaks to rsc and give up on rendering react in tasks for now

* Add changeset

* Remove triggerRequest from the useEffect deps

* Improve realtime & frontend authentication errors

* Fixed authorization tests

* Remove unnecessary log

* Add metadata.stream limits and improve the metadata streams structure

* Streams can now have up to 2500 entries

* Various coderabbit fixes

* additional react-hooks jsdocs
2024-11-19 13:14:25 +00:00
James Ritchie ea0956464b Added an ellipsis to the tooltip text 2024-11-19 13:07:11 +00:00
James Ritchie eca79afced Added some padding around the Setup Your Dev callout on Task page 2024-11-19 13:03:20 +00:00
James Ritchie 58861a0983 Added the new Help & Feedback popover to the account side menu (#1482) 2024-11-19 10:08:12 +00:00
James Ritchie 1b3a2f2e5b Full width tables (#1474)
* Updated styling

* Updated the main Table component styles to the new design

* Run page table layout style updates

* Updated row styling and new scrolling behaviour

* Fixed bulk action bar no appearing

* Improved table component styles

* Pagination arrows now chevrons

* Added link to Runs docs page

* Small popover tweak

* Moved the extra Test button out of Tables and into the RunsTable

* WIP refactoring the sticky table cell

* Support for the existing way we have menus in the sticky table cells

* Created a specific Docs style button

* Improved dialog trigger button styles

* Improved docs button styles

* Updated Docs buttons to the new style in the tooltips

* Adding a Test button on hover

* Removed the page padding and made table scroll

* Fixed table scrolling

* cleaned up imports

* WIP adding a test button that links to the test page

* added a docs button to the header

* Much nicer display bar for showing how many schedules you have (and it works with the updated table)

* Fixed the button types in the schedules info panel

* Fixed button icon colour

* Removed the pagination as it’s not being used

* Delete modal button position updated

* Fixed padding on recent payloads panel

* Fixed button icon colours

* Better style for active table row

* Table scrolling now behaves properly

* environment buttons now have a hover state

* Bigger Run test bar to match the Run page

* Small layout fixes to the schedule side panel

* Test page now has link to docs

* fixed text alignment with table headers

* API keys page now has full width tables

* Fix typo and modal layout

* Table uses new sticky button type

* Tidy imports

* Env var table now full width

* Form buttons have a border top

* Updated button styles

* Updated blank state message style to be consistent

* Removed unnecessary form button border

* Alerts page tables now full width

* Allowing the Detail cell to be styled

* Updated the page structure to be the same as the scheduled page – with better progress indicator

* Concurrency limits page now full width

* Fixed function name label extending too far

* Improved the button sizes on the replay modal

* Added a minimal style to the info panels

* Make sure you can always see the info and unlock staging info when the table scrolls

* Info panel style updated to match the others

* Removed the duplicate header

* Usage page tables now full width

* Personal access token page using full width tables

* Small CSS fixes

* Deployments page table now full width

* Deploy page scrolls properly and pagination stays on screen if table is scrollable

* TableCellMenu using the correct popover

* Added missing button variant

* Button group is now named so it doesn’t cause conflicts

* Added docs link to the Run page

* Typo

* Added a link to the test page if you have no runs

* Make the table sticky cell buttons more obvious

* Env var page now using the new popover menu

* Improved the menu items

* Copy tweak

* Made the icons bright on hover

* Updated the cancel run dialog

* Improved the API keys sticky menu behaviour

* Alerts table menu using the new popover

* Correct sized buttons in the Env Var page table menu

* Fixed weird Dialog text styling

* Schedules page blank state using correct docs button style

* Copy tweak

* Menu buttons styled correctly

* Added close buttons to the deploy dialogs

* Made the Dialog footer match the Form footer style

* Fixed feedback form buttons

* Fixed cancel run dialog

* Test page uses the taskIdentifier in the path

* Test task button working

* Added Spline component for 404 logo to remove spline logo

* Removed todo comment

* Added a docs button to the PAT page

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-11-18 17:11:06 +00:00
Willow (GHOST) 653d974da7 feat: exit if docker buildx can't be found for self-hosted builds (#1475)
* feat: exit if docker buildx can't be found for self-hosted builds

* chore: tweak debug log message

* chore: improve error message based on rabbit's suggestion
2024-11-18 12:17:48 +00:00
nicktrn 332854bbcb Release 3.1.3
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
2024-11-15 11:40:00 +00:00
github-actions[bot] 911447304c chore: Update version for release (#1469)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-15 11:39:24 +00:00
amuwal 2be1062f1e fix: unintended project initialization at absolute path /trigger during project initialization (#1412)
* Fix parsing of project path for cli init command

* Update development instructions for cli-v3

* Create gentle-mails-sip.md

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2024-11-15 11:34:51 +00:00
James Ritchie 64be7b04e8 Linking to more examples in the Next.js guide 2024-11-14 10:04:48 +00:00
Dan 925c463fe5 Added libreoffice example (#1472)
* Added Libreoffice example docs

* typo
2024-11-13 15:03:23 +00:00
James Ritchie e1bd6b3355 Fixed broken link 2024-11-13 13:55:06 +00:00
James Ritchie fd90f79ede Added realtime example link to Introduction page 2024-11-13 13:51:40 +00:00
James Ritchie 5d303ce6f2 New Realtime example added to docs 2024-11-13 13:45:47 +00:00
James Ritchie d98221ba7f Added the new quickstart video to the docs 2024-11-13 11:31:28 +00:00
Diego Valenzuela 29026a379b Fix: Typo on overview.mdx (#1460)
* Fix: Typo on overview.mdx

the import helloWorldTask wasn't called correctly at an example

* Fix: Typo update on overview.mdx

Now the import is called `helloWorld` instead of `helloWorldTask` to be consistent with the filename as requested
2024-11-12 18:29:33 +00:00
D-K-P 0706482232 Fix vercelSynEnvVars export 2024-11-12 12:37:21 +00:00
D-K-P 2e273e8fe2 Updated vercel env vars instructions and naming 2024-11-12 11:28:17 +00:00
Nico a5d59de39e Fix: vercelSyncEnvVars teamId (#1463)
* Update vercelSyncEnvVars.ts

* Update vercelSyncEnvVars.ts

* Update vercelSyncEnvVars.ts

* Update vercelSyncEnvVars.ts

* Update docs

* Add changeset
2024-11-12 11:19:25 +00:00
D-K-P 71808fafd1 Improved maxDuration comments in the templates 2024-11-07 14:02:56 +00:00
Matt Aitken 82ebfc7908 Made it clearer that tasks need to be exported 2024-11-06 15:17:12 +00:00
Matt Aitken f0996686e9 Added a db index to make the schedule list page faster
There was a slow query to get the latest run for a list of schedules
2024-11-06 11:56:28 +00:00
Matt Aitken e422fb5e4d Added the ability to switch database volumes locally without losing data, with docs 2024-11-06 10:55:19 +00:00
nicktrn ceac10b098 Release 3.1.2
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-05 15:25:20 +00:00
github-actions[bot] 365fabf9a0 chore: Update version for release (#1461)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-05 15:24:01 +00:00
nicktrn 21e1375760 Better logs for payload retrieval issues 2024-11-05 12:19:49 +00:00
nicktrn d7656314d6 Prevent stuck runs after duplicate dependency resume messages (#1459)
* ignore duplicate dependency resume messages

* add changeset
2024-11-05 12:00:02 +00:00
nicktrn 5368bcf6bd Improve resume reliability of restored runs (#1458)
* replay ready for resume

* clear replay state between attempts

* add changeset
2024-11-05 11:59:44 +00:00
D-K-P adf5970245 Shortened sidebar title to Vercel sync env vars 2024-11-04 14:58:01 +00:00
Dan aca2f54cb7 Docs - sync vercel env vars (#1426)
* Added sync env vars docs example

* Added links in the config + deploy files

* Updated copy

* Added vercel docs cards and added them to all of the relevant docs pages

* Added sync env vars to the intro page

* Added automatically sync env vars section to nextjs guide

* Added ‘Manually’

* Removed code block and updated title

* updated formatting and added vercelsyncenvvars to the config file

* Added missing comment

* Updated imports to /core

* Added sync env var docs link and note

* Improved the VERCEL_ACCESS_TOKEN note

* Added link to vercel

* Updated docs and improved formatting

* Copy tweaks

* Added space

* Updated deploy docs
2024-11-04 14:55:10 +00:00
Chibuike Chijioke 6313db698a fix(docs): Correct typo in '/idempotency' documentation (#1456) 2024-11-02 23:56:56 +00:00
293 changed files with 19385 additions and 4213 deletions
+1
View File
@@ -0,0 +1 @@
This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations.
+17
View File
@@ -13,6 +13,15 @@
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug realtimeStreams.test.ts",
"command": "pnpm run test -t RealtimeStreams",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
@@ -36,6 +45,14 @@
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug Dev Next.js Realtime",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/nextjs-realtime",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
+1 -1
View File
@@ -16,7 +16,7 @@
"@aws-sdk/client-sqs": "^3.445.0",
"@trigger.dev/core": "workspace:*",
"ulidx": "^2.2.1",
"zod": "3.22.3",
"zod": "3.23.8",
"zod-error": "1.5.0"
}
}
@@ -0,0 +1,15 @@
export function SideMenuRightClosedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="12" y="4" width="1" height="12" fill="currentColor" />
<rect x="2.5" y="3.5" width="15" height="13" rx="2.5" stroke="currentColor" />
</svg>
);
}
+2 -6
View File
@@ -5,6 +5,7 @@ import { friendlyErrorDisplay } from "~/utils/httpErrors";
import { LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import Spline from "@splinetool/react-spline";
type ErrorDisplayOptions = {
button?: {
@@ -55,18 +56,13 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
{button ? button.title : "Go to homepage"}
</LinkButton>
</div>
<div className="pointer-events-none absolute bottom-4 right-4 z-10 h-[70px] w-[200px] bg-[rgb(24,26,30)]" />
<motion.div
className="pointer-events-none absolute inset-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
>
<iframe
src="https://my.spline.design/untitled-a6f70b5ebc46bdb2dcc0f21d5397e8ac/"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
style={{ border: "none" }}
/>
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
</motion.div>
</div>
);
+13 -10
View File
@@ -18,6 +18,7 @@ import { Paragraph } from "./primitives/Paragraph";
import { Select, SelectItem } from "./primitives/Select";
import { TextArea } from "./primitives/TextArea";
import { TextLink } from "./primitives/TextLink";
import { DialogClose } from "@radix-ui/react-dialog";
type FeedbackProps = {
button: ReactNode;
@@ -120,16 +121,18 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="flex w-full justify-end">
<FormButtons
className="m-0 w-max"
confirmButton={
<Button type="submit" variant="tertiary/medium">
Send message
</Button>
}
/>
</div>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium">
Send message
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
@@ -26,7 +26,7 @@ function NextButton({ cursor }: { cursor?: string }) {
<LinkButton
to={path ?? "#"}
variant={"minimal/small"}
TrailingIcon="arrow-right"
TrailingIcon="chevron-right"
trailingIconClassName="text-text-dimmed"
className={cn(
"flex items-center",
@@ -47,7 +47,7 @@ function PreviousButton({ cursor }: { cursor?: string }) {
<LinkButton
to={path ?? "#"}
variant={"minimal/small"}
LeadingIcon="arrow-left"
LeadingIcon="chevron-left"
leadingIconClassName="text-text-dimmed"
className={cn(
"flex items-center",
@@ -1,18 +1,16 @@
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/20/solid";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { generateTwoRandomWords } from "~/utils/randomWords";
import { Button } from "../primitives/Buttons";
import { Header1 } from "../primitives/Headers";
import { Input } from "../primitives/Input";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { Callout } from "../primitives/Callout";
import { Fieldset } from "../primitives/Fieldset";
import { InputGroup } from "../primitives/InputGroup";
import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
type ModalProps = {
id: string;
@@ -31,15 +29,17 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="minimal/small"
leadingIconClassName="text-text-dimmed"
variant="small-menu-item"
fullWidth
textAlignLeft
leadingIconClassName="text-success"
LeadingIcon={ArrowPathIcon}
>
Regenerate
Regenerate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
<DialogHeader>{`Regenerate ${title.toUpperCase()} environment key`}</DialogHeader>
<RegenerateApiKeyModalContent
id={id}
title={title}
@@ -62,10 +62,10 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
}
return (
<div className="flex flex-col items-center gap-y-5 py-4">
<div className="flex flex-col items-center gap-y-4 pt-4">
<Callout variant="warning">
{`Regenerating the keys for this environment will temporarily break any live Jobs in the
${title} Environmentuntil the new API keys are set in the relevant environment variables.`}
{`Regenerating the keys for this environment will temporarily break any live tasks in the
${title} environment until the new API keys are set in the relevant environment variables.`}
</Callout>
<fetcher.Form
method="post"
@@ -73,7 +73,7 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
className="mt-2 w-full"
>
<Fieldset className="w-full">
<InputGroup>
<InputGroup className="max-w-full">
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
<Paragraph
variant="small"
@@ -93,14 +93,14 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
confirmButton={
<Button
type="submit"
variant={"primary/small"}
variant={"primary/medium"}
LeadingIcon={isSubmitting ? Spinner : undefined}
disabled={confirmationText !== randomWord}
>
Regenerate
</Button>
}
cancelButton={<Button variant={"tertiary/small"}>Cancel</Button>}
cancelButton={<Button variant={"tertiary/medium"}>Cancel</Button>}
/>
</Fieldset>
</fetcher.Form>
@@ -1,14 +1,13 @@
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { User } from "@trigger.dev/database";
import { useFeatures } from "~/hooks/useFeatures";
import { cn } from "~/utils/cn";
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import { LinkButton } from "../primitives/Buttons";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
export function AccountSideMenu({ user }: { user: User }) {
const { v3Enabled } = useFeatures();
@@ -20,7 +19,7 @@ export function AccountSideMenu({ user }: { user: User }) {
)}
>
<div className="flex h-full flex-col">
<div className={cn("flex items-center justify-between border-b p-px transition")}>
<div className={cn("flex h-10 items-center justify-between border-b p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
@@ -57,42 +56,7 @@ export function AccountSideMenu({ user }: { user: User }) {
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright 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>
}
/>
<HelpAndFeedback />
</div>
</div>
</div>
@@ -0,0 +1,182 @@
import {
ArrowUpRightIcon,
CalendarDaysIcon,
ChatBubbleLeftEllipsisIcon,
EnvelopeIcon,
LightBulbIcon,
SignalIcon,
} from "@heroicons/react/20/solid";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "../Feedback";
import { StepContentContainer } from "../StepContentContainer";
import { Button } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
export function HelpAndFeedback() {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger isOpen={isHelpMenuOpen} shortcut={{ key: "h" }}>
<div className="flex items-center gap-1.5">
<ChatBubbleLeftEllipsisIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<Fragment>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon="docs"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={SignalIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={LightBulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join-our-slack"
fullWidth
textAlignLeft
>
<div className="flex w-full items-center justify-between">
<span className="text-text-bright">Join our Slack</span>
<MenuCount count="PRO" />
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="secondary/medium"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
<SideMenuItem
name="Ask in our Discord"
icon={DiscordIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Book a 15 min call"
icon={CalendarDaysIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-rose-500"
activeIconColor="text-rose-500"
to="https://cal.com/team/triggerdotdev/founders-call"
data-action="book-a-call"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
data-action="contact-us"
fullWidth
textAlignLeft
>
Contact us
</Button>
}
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
);
}
@@ -1,27 +1,21 @@
import {
AcademicCapIcon,
ArrowRightOnRectangleIcon,
ArrowUpRightIcon,
BeakerIcon,
BellAlertIcon,
CalendarDaysIcon,
ChartBarIcon,
ChatBubbleLeftEllipsisIcon,
ClockIcon,
CreditCardIcon,
CursorArrowRaysIcon,
EnvelopeIcon,
IdentificationIcon,
KeyIcon,
LightBulbIcon,
RectangleStackIcon,
ServerStackIcon,
ShieldCheckIcon,
SignalIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { useFeatures } from "~/hooks/useFeatures";
@@ -52,6 +46,7 @@ import {
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3ConcurrencyPath,
v3DeploymentsPath,
@@ -64,17 +59,12 @@ import {
v3TestPath,
v3UsagePath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/v2/FreePlanUsage";
import { Badge } from "../primitives/Badge";
import { Button, LinkButton } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
@@ -83,12 +73,11 @@ import {
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverSideMenuTrigger,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { TextLink } from "../primitives/TextLink";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
import { SideMenuItem } from "./SideMenuItem";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
@@ -261,167 +250,6 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
);
}
function HelpAndFeedback() {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger isOpen={isHelpMenuOpen} shortcut={{ key: "h" }}>
<div className="flex items-center gap-1.5">
<ChatBubbleLeftEllipsisIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<Fragment>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Documentation"
icon="docs"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<SideMenuItem
name="Status"
icon={SignalIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-green-500"
activeIconColor="text-green-500"
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<SideMenuItem
name="Suggest a feature"
icon={LightBulbIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://feedback.trigger.dev/"
data-action="suggest-a-feature"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-sun-500"
activeIconColor="text-sun-500"
to="https://trigger.dev/changelog"
data-action="changelog"
target="_blank"
/>
</div>
<div className="flex flex-col gap-1 p-1">
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<div>
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join-our-slack"
fullWidth
textAlignLeft
>
<div className="flex w-full items-center justify-between">
<span className="text-text-bright">Join our Slack</span>
<MenuCount count="PRO" />
</div>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-charcoal-800" />
<div>
<StepNumber stepNumber="1" title="Email us" />
<StepContentContainer>
<Paragraph>
Send us an email to this address from your Trigger.dev account email
address:
<ClipboardField
variant="secondary/medium"
value="priority-support@trigger.dev"
className="my-2"
/>
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
<StepContentContainer>
<Paragraph>
As soon as we can, we'll setup a Slack Connect channel and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
<SideMenuItem
name="Ask in our Discord"
icon={DiscordIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
<SideMenuItem
name="Book a 15 min call"
icon={CalendarDaysIcon}
trailingIcon={ArrowUpRightIcon}
trailingIconClassName="text-text-dimmed"
inactiveIconColor="text-rose-500"
activeIconColor="text-rose-500"
to="https://cal.com/team/triggerdotdev/founders-call"
data-action="book-a-call"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
data-action="contact-us"
fullWidth
textAlignLeft
>
Contact us
</Button>
}
/>
</div>
</Fragment>
</PopoverContent>
</Popover>
);
}
function ProjectSelector({
project,
organizations,
@@ -631,8 +459,6 @@ function V3ProjectSideMenu({
project: SideMenuProject;
organization: MatchedOrganization;
}) {
const { alertsEnabled } = useFeatures();
return (
<>
<SideMenuHeader title={"Project"} />
@@ -649,6 +475,13 @@ function V3ProjectSideMenu({
activeIconColor="text-teal-500"
to={v3RunsPath(organization, project)}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-blue-500"
to={v3BatchesPath(organization, project)}
data-action="batches"
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
@@ -685,15 +518,13 @@ function V3ProjectSideMenu({
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
{alertsEnabled && (
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
)}
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
<SideMenuItem
name="Concurrency limits"
icon={RectangleStackIcon}
@@ -0,0 +1,177 @@
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const variants = {
small: {
size: "size-[1rem]",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadLeft: "group-hover:translate-x-[3px]",
arrowLineLeft: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
medium: {
size: "size-[1.1rem]",
arrowHeadRight: "group-hover:translate-x-[3px]",
arrowLineRight: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-[-3px]",
arrowLineLeft: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
large: {
size: "size-6",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
"extra-large": {
size: "size-8",
arrowHeadRight: "group-hover:translate-x-1",
arrowLineRight: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadLeft: "group-hover:translate-x-1",
arrowLineLeft: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
arrowHeadTopRight:
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
},
};
export const themes = {
dark: {
textStyle: "text-background-bright",
arrowLine: "bg-background-bright",
},
dimmed: {
textStyle: "text-text-dimmed",
arrowLine: "bg-text-dimmed",
},
bright: {
textStyle: "text-text-bright",
arrowLine: "bg-text-bright",
},
primary: {
textStyle: "text-text-dimmed group-hover:text-primary",
arrowLine: "bg-text-dimmed group-hover:bg-primary",
},
blue: {
textStyle: "text-text-dimmed group-hover:text-blue-500",
arrowLine: "bg-text-dimmed group-hover:bg-blue-500",
},
rose: {
textStyle: "text-text-dimmed group-hover:text-rose-500",
arrowLine: "bg-text-dimmed group-hover:bg-rose-500",
},
amber: {
textStyle: "text-text-dimmed group-hover:text-amber-500",
arrowLine: "bg-text-dimmed group-hover:bg-amber-500",
},
apple: {
textStyle: "text-text-dimmed group-hover:text-apple-500",
arrowLine: "bg-text-dimmed group-hover:bg-apple-500",
},
lavender: {
textStyle: "text-text-dimmed group-hover:text-lavender-500",
arrowLine: "bg-text-dimmed group-hover:bg-lavender-500",
},
};
type Variants = keyof typeof variants;
type Theme = keyof typeof themes;
type AnimatingArrowProps = {
className?: string;
variant?: Variants;
theme?: Theme;
direction?: "right" | "left" | "topRight";
};
export function AnimatingArrow({
className,
variant = "medium",
theme = "dimmed",
direction = "right",
}: AnimatingArrowProps) {
const variantStyles = variants[variant];
const themeStyles = themes[theme];
return (
<span className={cn("relative -mr-1 ml-1 flex", variantStyles.size, className)}>
{direction === "topRight" && (
<>
<svg
className={cn(
"absolute top-[5px] transition duration-200 ease-in-out",
themeStyles.textStyle
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<svg
className={cn(
"absolute top-[5px] transition duration-300 ease-in-out",
themeStyles.textStyle,
variantStyles.arrowHeadTopRight
)}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1 1H7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M7.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M1 7.5L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</>
)}
{direction === "right" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineRight,
themeStyles.arrowLine
)}
/>
<ChevronRightIcon
className={cn(
"absolute -translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadRight,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
{direction === "left" && (
<>
<span
className={cn(
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
variantStyles.arrowLineLeft,
themeStyles.arrowLine
)}
/>
<ChevronLeftIcon
className={cn(
"absolute translate-x-0.5 transition duration-300 ease-in-out",
variantStyles.arrowHeadLeft,
variantStyles.size,
themeStyles.textStyle
)}
/>
</>
)}
</span>
);
}
@@ -41,40 +41,53 @@ type Size = keyof typeof sizes;
const theme = {
primary: {
textColor:
"text-charcoal-900 group-hover:text-charcoal-900 transition group-disabled:text-charcoal-900",
"text-charcoal-900 group-hover/button:text-charcoal-900 transition group-disabled/button:text-charcoal-900",
button:
"bg-primary group-hover:bg-apple-200 group-disabled:opacity-50 group-disabled:bg-primary group-disabled:pointer-events-none",
"bg-primary group-hover/button:bg-apple-200 group-disabled/button:opacity-50 group-disabled/button:bg-primary group-disabled/button:pointer-events-none",
shortcut:
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
"border-black/40 text-charcoal-900 group-hover/button:border-black/60 group-hover/button:text-charcoal-900",
icon: "text-charcoal-900",
},
secondary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
button:
"bg-secondary group-hover:bg-charcoal-600 group-hover:border-charcoal-650 border border-charcoal-600 group-disabled:bg-secondary group-disabled:opacity-60 group-disabled:pointer-events-none",
"bg-secondary group-hover/button:bg-charcoal-600 group-hover/button:border-charcoal-650 border border-charcoal-600 group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-text-bright",
},
tertiary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
button:
"bg-tertiary group-hover:bg-charcoal-600 group-disabled:bg-tertiary group-disabled:opacity-60 group-disabled:pointer-events-none",
"bg-tertiary group-hover/button:bg-charcoal-600 group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-text-bright",
},
minimal: {
textColor:
"text-text-dimmed group-hover:text-text-bright transition group-disabled:text-text-dimmed/80",
"text-text-dimmed group-hover/button:text-text-bright transition group-disabled/button:text-text-dimmed/80",
button:
"bg-transparent group-hover:bg-tertiary disabled:opacity-50 group-disabled:bg-transparent group-disabled:pointer-events-none",
"bg-transparent group-hover/button:bg-tertiary disabled:opacity-50 group-disabled/button:bg-transparent group-disabled/button:pointer-events-none",
shortcut:
"border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60",
"border-dimmed/40 text-text-dimmed group-hover/button:text-text-bright/80 group-hover/button:border-dimmed/60",
icon: "text-text-dimmed",
},
danger: {
textColor:
"text-text-bright group-hover:text-white transition group-disabled:text-text-bright/80",
"text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-bright/80",
button:
"bg-error group-hover:bg-rose-500 disabled:opacity-50 group-disabled:bg-error group-disabled:pointer-events-none",
shortcut: "border-text-bright text-text-bright group-hover:border-bright/60",
"bg-error group-hover/button:bg-rose-500 disabled:opacity-50 group-disabled/button:bg-error group-disabled/button:pointer-events-none",
shortcut: "border-text-bright text-text-bright group-hover/button:border-bright/60",
icon: "text-text-bright",
},
docs: {
textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80",
button:
"bg-charcoal-700 border border-charcoal-600/50 shadow group-hover/button:bg-charcoal-650 group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
shortcut:
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
icon: "text-blue-500",
},
};
@@ -84,7 +97,7 @@ function createVariant(sizeName: Size, themeName: Theme) {
return {
textColor: theme[themeName].textColor,
button: cn(sizes[sizeName].button, theme[themeName].button),
icon: sizes[sizeName].icon,
icon: cn(sizes[sizeName].icon, theme[themeName].icon),
iconSpacing: sizes[sizeName].iconSpacing,
shortcutVariant: sizes[sizeName].shortcutVariant,
shortcut: cn(sizes[sizeName].shortcut, theme[themeName].shortcut),
@@ -112,9 +125,14 @@ const variant = {
"danger/medium": createVariant("medium", "danger"),
"danger/large": createVariant("large", "danger"),
"danger/extra-large": createVariant("extra-large", "danger"),
"docs/small": createVariant("small", "docs"),
"docs/medium": createVariant("medium", "docs"),
"docs/large": createVariant("large", "docs"),
"docs/extra-large": createVariant("extra-large", "docs"),
"menu-item": {
textColor: "text-text-bright px-1",
button: "h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-750",
button:
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover/button:bg-charcoal-750",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
@@ -123,7 +141,7 @@ const variant = {
"small-menu-item": {
textColor: "text-text-bright",
button:
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750",
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-charcoal-750",
icon: "h-4",
iconSpacing: "gap-x-1.5",
shortcutVariant: undefined,
@@ -132,7 +150,7 @@ const variant = {
"small-menu-sub-item": {
textColor: "text-text-dimmed",
button:
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750 focus-custom",
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-charcoal-750 focus-custom",
icon: undefined,
iconSpacing: undefined,
shortcutVariant: undefined,
@@ -141,7 +159,7 @@ const variant = {
};
const allVariants = {
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus:outline-none group-disabled:opacity-75 group-disabled:pointer-events-none focus-custom",
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-none group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom",
variant: variant,
};
@@ -156,6 +174,7 @@ export type ButtonContentPropsType = {
className?: string;
shortcut?: ShortcutDefinition;
variant: keyof typeof variant;
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
};
export function ButtonContent(props: ButtonContentPropsType) {
@@ -192,13 +211,18 @@ export function ButtonContent(props: ButtonContentPropsType) {
(typeof LeadingIcon === "string" ? (
<NamedIcon
name={LeadingIcon}
className={cn(iconClassName, leadingIconClassName, "shrink-0 justify-start")}
className={cn(
iconClassName,
leadingIconClassName,
"shrink-0 justify-start",
variation.icon
)}
/>
) : (
<LeadingIcon
className={cn(
iconClassName,
textColorClassName,
variation.icon,
leadingIconClassName,
"shrink-0 justify-start"
)}
@@ -214,29 +238,44 @@ export function ButtonContent(props: ButtonContentPropsType) {
<>{text}</>
))}
{TrailingIcon &&
(typeof TrailingIcon === "string" ? (
<NamedIcon
name={TrailingIcon}
className={cn(iconClassName, trailingIconClassName, "shrink-0 justify-end")}
/>
) : (
<TrailingIcon
className={cn(
iconClassName,
textColorClassName,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
))}
{shortcut && (
{shortcut && props.shortcutPosition === "before-trailing-icon" && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
{TrailingIcon &&
(typeof TrailingIcon === "string" ? (
<NamedIcon
name={TrailingIcon}
className={cn(
iconClassName,
trailingIconClassName,
"shrink-0 justify-end",
variation.icon
)}
/>
) : (
<TrailingIcon
className={cn(
iconClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
))}
{shortcut &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
</div>
</div>
);
@@ -267,7 +306,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
return (
<button
className={cn("group outline-none focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none focus-custom", props.fullWidth ? "w-full" : "")}
type={type}
disabled={disabled}
onClick={onClick}
@@ -313,7 +352,7 @@ export const LinkButton = ({
return (
<div
className={cn(
"group pointer-events-none cursor-default opacity-40 outline-none",
"group/button pointer-events-none cursor-default opacity-40 outline-none",
props.fullWidth ? "w-full" : ""
)}
>
@@ -327,7 +366,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -342,7 +381,7 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -363,7 +402,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -1,4 +1,4 @@
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CalendarDateTime, createCalendar } from "@internationalized/date";
import { useDateField, useDateSegment } from "@react-aria/datepicker";
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
@@ -12,7 +12,7 @@ const variants = {
small: {
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
nowButtonVariant: "tertiary/small" as const,
clearButtonVariant: "minimal/small" as const,
clearButtonVariant: "tertiary/small" as const,
},
medium: {
fieldStyles: "h-7 text-base rounded px-1",
@@ -35,9 +35,12 @@ type DateFieldProps = {
showNowButton?: boolean;
showClearButton?: boolean;
onValueChange?: (value: Date | undefined) => void;
utc?: boolean;
variant?: Variant;
};
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
export function DateField({
label,
defaultValue,
@@ -50,10 +53,11 @@ export function DateField({
showGuide = false,
showNowButton = false,
showClearButton = false,
utc = false,
variant = "small",
}: DateFieldProps) {
const [value, setValue] = useState<undefined | CalendarDateTime>(
utcDateToCalendarDate(defaultValue)
utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue)
);
const state = useDateFieldState({
@@ -61,11 +65,11 @@ export function DateField({
onChange: (value) => {
if (value) {
setValue(value);
onValueChange?.(value.toDate("utc"));
onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone));
}
},
minValue: utcDateToCalendarDate(minValue),
maxValue: utcDateToCalendarDate(maxValue),
minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue),
maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue),
shouldForceLeadingZeros: true,
granularity,
locale: "en-US",
@@ -78,7 +82,9 @@ export function DateField({
useEffect(() => {
if (state.value === undefined && defaultValue === undefined) return;
const calendarDate = utcDateToCalendarDate(defaultValue);
const calendarDate = utc
? utcDateToCalendarDate(defaultValue)
: dateToCalendarDate(defaultValue);
//unchanged
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
return;
@@ -134,23 +140,19 @@ export function DateField({
<Button
type="button"
variant={variants[variant].nowButtonVariant}
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
onClick={() => {
const now = new Date();
setValue(utcDateToCalendarDate(new Date()));
setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now));
onValueChange?.(now);
}}
>
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
Now
</Button>
)}
{showClearButton && (
<Button
type="button"
variant={variants[variant].clearButtonVariant}
LeadingIcon={"close"}
leadingIconClassName="-mr-2"
onClick={() => {
setValue(undefined);
onValueChange?.(undefined);
@@ -181,7 +183,7 @@ function utcDateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCMonth() + 1,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
@@ -190,6 +192,19 @@ function utcDateToCalendarDate(date?: Date) {
: undefined;
}
function dateToCalendarDate(date?: Date) {
return date
? new CalendarDateTime(
date.getFullYear(),
date.getMonth() + 1,
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds()
)
: undefined;
}
type DateSegmentProps = {
segment: DateSegment;
state: DateFieldState;
@@ -34,6 +34,7 @@ type DetailCellProps = {
description?: string | React.ReactNode;
className?: string;
variant?: keyof typeof variations;
boxClassName?: string;
};
export function DetailCell({
@@ -45,6 +46,7 @@ export function DetailCell({
description,
className,
variant = "small",
boxClassName,
}: DetailCellProps) {
const variation = variations[variant];
@@ -53,6 +55,7 @@ export function DetailCell({
<IconInBox
icon={leadingIcon}
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
boxClassName={boxClassName}
/>
<div className="flex flex-1 flex-col">
<Paragraph
@@ -82,7 +82,7 @@ DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2", className)}
className={cn("flex justify-between border-t border-grid-bright pt-4", className)}
{...props}
/>
);
@@ -106,7 +106,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground pt-4 text-sm", className)}
className={cn("pt-2 text-base text-text-dimmed", className)}
{...props}
/>
));
@@ -10,7 +10,12 @@ export function FormButtons({
className?: string;
}) {
return (
<div className={cn("flex w-full items-center justify-between", className)}>
<div
className={cn(
"flex w-full items-center justify-between border-t border-grid-bright pt-4",
className
)}
>
{cancelButton ? cancelButton : <div />} {confirmButton}
</div>
);
@@ -5,10 +5,13 @@ import { Paragraph } from "./Paragraph";
const variants = {
info: {
panelStyle: "border-grid-bright bg-background-bright",
panelStyle: "border-grid-bright bg-background-bright rounded-md border p-4 gap-3",
},
upgrade: {
panelStyle: "border-indigo-400/20 bg-indigo-800/10",
panelStyle: "border-indigo-400/20 bg-indigo-800/10 rounded-md border p-4 gap-3",
},
minimal: {
panelStyle: "max-w-full w-full py-3 px-3 gap-2",
},
};
@@ -43,7 +46,7 @@ export function InfoPanel({
className={cn(
variantStyle.panelStyle,
title ? "flex-col" : "",
"flex h-fit items-start gap-3 rounded-md border p-4",
"flex h-fit items-start",
panelClassName
)}
>
@@ -20,15 +20,15 @@ export function PaginationControls({
}
return (
<nav className="flex items-center gap-1" aria-label="Pagination">
<nav className="flex items-center gap-0.5" aria-label="Pagination">
<LinkDisabled
to={pageUrl(location, currentPage - 1)}
className={currentPage > 1 ? "group" : ""}
disabled={currentPage === 1}
disabledClassName="opacity-30 cursor-default"
>
<ButtonContent variant="minimal/medium" LeadingIcon={ChevronLeftIcon}>
Previous
<ButtonContent variant="minimal/small" LeadingIcon={ChevronLeftIcon}>
Prev
</ButtonContent>
</LinkDisabled>
@@ -44,7 +44,7 @@ export function PaginationControls({
disabled={currentPage === totalPages}
disabledClassName="opacity-30 cursor-default"
>
<ButtonContent variant="minimal/medium" TrailingIcon={ChevronRightIcon}>
<ButtonContent variant="minimal/small" TrailingIcon={ChevronRightIcon}>
Next
</ButtonContent>
</LinkDisabled>
@@ -61,9 +61,9 @@ function pageUrl(location: ReturnType<typeof useLocation>, page: number): string
}
const baseClass =
"flex items-center justify-center border border-transparent h-8 w-8 text-xs font-medium transition text-text-dimmed rounded-sm";
"flex items-center justify-center border border-transparent min-w-6 h-6 px-1 text-xs font-medium transition text-text-dimmed rounded-sm";
const unselectedClass = "hover:bg-tertiary hover:text-text-bright";
const selectedClass = "border-text-dimmed text-text-bright hover:bg-tertiary";
const selectedClass = "border-charcoal-600 bg-tertiary text-text-bright hover:bg-charcoal-600/50";
function PageLinkComponent({
page,
@@ -190,11 +190,11 @@ function PopoverVerticalEllipseTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-text-dimmed transition focus-custom hover:bg-charcoal-750 hover:text-text-bright",
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
className
)}
>
<EllipsisVerticalIcon className={cn("h-5 w-5 transition group-hover:text-text-bright")} />
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
</PopoverTrigger>
);
}
@@ -440,7 +440,7 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
export function SelectItem({
icon,
@@ -613,7 +613,7 @@ export function SelectPopover({
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(520px,var(--popover-available-height))]",
"max-h-[min(600px,var(--popover-available-height))]",
"origin-[var(--popover-transform-origin)]",
className
)}
@@ -13,10 +13,10 @@ const variations = {
},
small: {
container:
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom",
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
root: "h-3 w-6",
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition",
text: "text-xs",
},
};
+84 -38
View File
@@ -17,7 +17,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps>(
return (
<div
className={cn(
"overflow-x-auto whitespace-nowrap rounded-md border border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
"overflow-x-auto whitespace-nowrap border-t border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
containerClassName,
fullWidth && "w-full"
)}
@@ -41,7 +41,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
<thead
ref={ref}
className={cn(
"sticky top-0 z-10 divide-y divide-grid-dimmed rounded-t-md bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-dimmed",
"sticky top-0 z-10 bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
className
)}
>
@@ -59,10 +59,7 @@ type TableBodyProps = {
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className, children }, ref) => {
return (
<tbody
ref={ref}
className={cn("relative divide-y divide-grid-dimmed overflow-y-auto", className)}
>
<tbody ref={ref} className={cn("relative overflow-y-auto", className)}>
{children}
</tbody>
);
@@ -78,7 +75,14 @@ type TableRowProps = {
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, disabled, children }, ref) => {
return (
<tr ref={ref} className={cn(disabled && "opacity-50", "group/table-row w-full", className)}>
<tr
ref={ref}
className={cn(
disabled && "opacity-50",
"group/table-row relative w-full after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
className
)}
>
{children}
</tr>
);
@@ -114,7 +118,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
ref={ref}
scope="col"
className={cn(
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
"px-3 py-2.5 pb-3 align-middle text-sm font-medium text-text-bright",
alignmentClassName,
className
)}
@@ -145,13 +149,16 @@ type TableCellProps = TableCellBasicProps & {
};
const rowHoverStyles = {
default: "group-hover/table-row:bg-charcoal-800",
dimmed: "group-hover/table-row:bg-charcoal-850",
bright: "group-hover/table-row:bg-charcoal-750",
default:
"group-hover/table-row:bg-charcoal-800 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-750 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-750 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
dimmed:
"group-hover/table-row:bg-charcoal-850 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-800 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-800 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
bright:
"group-hover/table-row:bg-charcoal-750 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-700 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-700 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
};
const stickyStyles =
"sticky right-0 w-[2.8rem] min-w-[2.8rem] bg-background-dimmed before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem]";
"sticky right-0 bg-background-dimmed group-hover/table-row:bg-charcoal-750 w-[--sticky-width] [&:has(.group-hover\\/table-row\\:block)]:w-auto";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
(
@@ -192,7 +199,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
<td
ref={ref}
className={cn(
"text-xs text-charcoal-400 transition-colors",
"text-xs text-charcoal-400",
to || onClick || hasAction ? "cursor-pointer" : "px-3 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
@@ -237,7 +244,7 @@ export const TableCellChevron = forwardRef<
alignment="right"
>
{children}
<ChevronRightIcon className="h-4 w-4 text-text-dimmed transition group-hover:text-text-bright" />
<ChevronRightIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
</TableCell>
);
});
@@ -246,33 +253,72 @@ export const TableCellMenu = forwardRef<
HTMLTableCellElement,
{
className?: string;
children?: ReactNode;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
visibleButtons?: ReactNode;
hiddenButtons?: ReactNode;
popoverContent?: ReactNode;
children?: ReactNode;
}
>(({ className, children, isSticky, onClick }, ref) => {
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
hasAction={true}
>
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
<PopoverContent
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="end"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</TableCell>
);
});
>(
(
{ className, isSticky, onClick, visibleButtons, hiddenButtons, popoverContent, children },
ref
) => {
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
hasAction={true}
>
<div className="relative p-1">
<div className="absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 bg-background-dimmed p-0.5 group-hover/table-row:rounded-[0.25rem] group-hover/table-row:bg-background-bright group-hover/table-row:ring-1 group-hover/table-row:ring-grid-bright">
{/* Hidden buttons that show on hover */}
{hiddenButtons && (
<div className="hidden pr-0.5 group-hover/table-row:block group-hover/table-row:border-r group-hover/table-row:border-grid-dimmed">
{hiddenButtons}
</div>
)}
{/* Always visible buttons */}
{visibleButtons}
{/* Always visible popover with ellipsis trigger */}
{popoverContent && (
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger
isOpen={isOpen}
className="duration-0 group-hover/table-row:text-text-bright"
/>
<PopoverContent
className="min-w-[10rem] max-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="end"
>
<div className="flex flex-col gap-1 p-1">{popoverContent}</div>
</PopoverContent>
</Popover>
)}
{/* Optionally pass in children to render in a popover */}
{!visibleButtons && !hiddenButtons && !popoverContent && (
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
<PopoverContent
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="end"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</TableCell>
);
}
);
type TableBlankRowProps = {
className?: string;
@@ -214,6 +214,7 @@ export function AbsoluteTimeFrame({
granularity="second"
showNowButton
showClearButton
utc
/>
</div>
<div className="space-y-1">
@@ -227,6 +228,7 @@ export function AbsoluteTimeFrame({
granularity="second"
showNowButton
showClearButton
utc
/>
</div>
</div>
@@ -0,0 +1,446 @@
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
import {
allBatchStatuses,
BatchStatusCombo,
batchStatusTitle,
descriptionForBatchStatus,
} from "./BatchStatus";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
export const BatchStatus = z.enum(allBatchStatuses);
export const BatchListFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
environments: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
BatchStatus.array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
id: z.string().optional(),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type BatchListFilters = z.infer<typeof BatchListFilters>;
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type BatchFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
hasFilters: boolean;
};
export function BatchFilters(props: BatchFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("id") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<AppliedFilters {...props} />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: BatchFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedBatchIdFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & BatchFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) => {
if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allBatchStatuses.map((status) => ({
title: batchStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<BatchStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForBatchStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
value={appliedSummary(
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
)}
onRemove={() => del(["statuses", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("id");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
id: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27) {
error = "Batch IDs are 27 characters long";
}
}
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedBatchIdFilter() {
const { value, del } = useSearchParams();
if (value("id") === undefined) {
return null;
}
const batchId = value("id");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
value={batchId}
onRemove={() => del(["id", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,81 @@
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allBatchStatuses = ["PENDING", "COMPLETED"] as const satisfies Readonly<
Array<BatchTaskRunStatus>
>;
const descriptions: Record<BatchTaskRunStatus, string> = {
PENDING: "The batch has child runs that have not yet completed.",
COMPLETED: "All the batch child runs have finished.",
};
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
return descriptions[status];
}
export function BatchStatusCombo({
status,
className,
iconClassName,
}: {
status: BatchTaskRunStatus;
className?: string;
iconClassName?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<BatchStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
<BatchStatusLabel status={status} />
</span>
);
}
export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) {
return <span className={batchStatusColor(status)}>{batchStatusTitle(status)}</span>;
}
export function BatchStatusIcon({
status,
className,
}: {
status: BatchTaskRunStatus;
className: string;
}) {
switch (status) {
case "PENDING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
default: {
assertNever(status);
}
}
}
export function batchStatusColor(status: BatchTaskRunStatus): string {
switch (status) {
case "PENDING":
return "text-pending";
case "COMPLETED":
return "text-success";
default: {
assertNever(status);
}
}
}
export function batchStatusTitle(status: BatchTaskRunStatus): string {
switch (status) {
case "PENDING":
return "In progress";
case "COMPLETED":
return "Completed";
default: {
assertNever(status);
}
}
}
@@ -1,4 +1,6 @@
import { StopCircleIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useFetcher, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
@@ -7,6 +9,8 @@ import {
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Paragraph } from "~/components/primitives/Paragraph";
type CancelRunDialogProps = {
runFriendlyId: string;
@@ -22,24 +26,33 @@ export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialog
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this run?</DialogHeader>
<DialogDescription>
Canceling a run will stop execution, along with any executing subtasks.
</DialogDescription>
<DialogFooter>
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/small"
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel run"}
</Button>
</Form>
</DialogFooter>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
Canceling a run will stop execution, along with any executing subtasks.
</Paragraph>
<FormButtons
confirmButton={
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? "spinner-white" : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel run"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -0,0 +1,57 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Paragraph } from "~/components/primitives/Paragraph";
type CheckBatchCompletionDialogProps = {
batchId: string;
redirectPath: string;
};
export function CheckBatchCompletionDialog({
batchId,
redirectPath,
}: CheckBatchCompletionDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/batches/${batchId}/check-completion`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="check-completion">
<DialogHeader>Try and resume batch</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
In rare cases, parent runs don't continue after child runs have completed.
</Paragraph>
<Paragraph>
If this doesn't help, please get in touch. We are working on a permanent fix for this.
</Paragraph>
<FormButtons
confirmButton={
<Form action={`/resources/batches/${batchId}/check-completion`} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? "spinner-white" : undefined}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
>
{isLoading ? "Attempting resume..." : "Attempt resume"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -129,12 +129,12 @@ function ReplayForm({
defaultValue={environment.id}
items={environments}
dropdownIcon
variant="tertiary/small"
variant="tertiary/medium"
className="w-fit pl-1"
text={(value) => {
const env = environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pr-2">
<div className="flex items-center pl-1 pr-2">
<EnvironmentLabel environment={env} userName={env.userName} />
</div>
);
@@ -152,11 +152,11 @@ function ReplayForm({
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<div className="mt-3 flex items-center justify-between gap-2 border-t border-grid-dimmed pt-3.5">
<DialogClose asChild>
<Button variant="tertiary/small">Cancel</Button>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button
type="submit"
variant="primary/small"
variant="primary/medium"
LeadingIcon={isSubmitting ? ButtonSpinner : undefined}
disabled={isSubmitting}
shortcut={{ modifiers: ["meta"], key: "enter", enabledOnInputElements: true }}
@@ -1,4 +1,5 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
@@ -33,6 +34,9 @@ export function RetryDeploymentIndexingDialog({
any errors and re-deploy.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/retry-indexing`}
method="post"
@@ -41,7 +45,7 @@ export function RetryDeploymentIndexingDialog({
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/small"
variant="primary/medium"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
@@ -1,4 +1,5 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
@@ -33,6 +34,9 @@ export function RollbackDeploymentDialog({
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
@@ -41,7 +45,7 @@ export function RollbackDeploymentDialog({
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/small"
variant="primary/medium"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["meta"], key: "enter" }}
+392 -239
View File
@@ -1,30 +1,34 @@
import * as Ariakit from "@ariakit/react";
import {
ArrowPathIcon,
CalendarIcon,
ClockIcon,
CpuChipIcon,
InboxStackIcon,
FingerPrintIcon,
Squares2X2Icon,
TagIcon,
XMarkIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import type {
RuntimeEnvironment,
TaskTriggerSource,
TaskRunStatus,
BulkActionType,
RuntimeEnvironment,
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { ListChecks, ListFilterIcon } from "lucide-react";
import { matchSorter } from "match-sorter";
import type { ReactNode } from "react";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { DateTime } from "~/components/primitives/DateTime";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
ComboboxProvider,
SelectButtonItem,
SelectItem,
SelectList,
@@ -33,6 +37,8 @@ import {
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import {
Tooltip,
TooltipContent,
@@ -40,22 +46,29 @@ import {
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionStatusCombo } from "./BulkAction";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
import {
TaskRunStatusCombo,
allTaskRunStatuses,
filterableTaskRunStatuses,
descriptionForTaskRunStatus,
filterableTaskRunStatuses,
runStatusTitle,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
import { DateTime } from "~/components/primitives/DateTime";
import { BulkActionStatusCombo } from "./BulkAction";
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { useProject } from "~/hooks/useProject";
import { Spinner } from "~/components/primitives/Spinner";
import { matchSorter } from "match-sorter";
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
@@ -86,6 +99,10 @@ export const TaskRunListSearchFilters = z.object({
bulkId: z.string().optional(),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
rootOnly: z.coerce.boolean().optional(),
batchId: z.string().optional(),
runId: z.string().optional(),
scheduleId: z.string().optional(),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
@@ -102,6 +119,7 @@ type RunFiltersProps = {
type: BulkActionType;
createdAt: Date;
}[];
rootOnlyDefault: boolean;
hasFilters: boolean;
};
@@ -114,15 +132,24 @@ export function RunsFilters(props: RunFiltersProps) {
searchParams.has("tasks") ||
searchParams.has("period") ||
searchParams.has("bulkId") ||
searchParams.has("tags");
searchParams.has("tags") ||
searchParams.has("from") ||
searchParams.has("to") ||
searchParams.has("batchId") ||
searchParams.has("runId") ||
searchParams.has("scheduleId");
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<RootOnlyToggle defaultValue={props.rootOnlyDefault} />
<AppliedFilters {...props} />
{hasFilters && (
<Form>
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
<Form className="h-6">
{searchParams.has("rootOnly") && (
<input type="hidden" name="rootOnly" value={searchParams.get("rootOnly") as string} />
)}
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
Clear all
</Button>
</Form>
@@ -145,7 +172,11 @@ const filterTypes = [
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
{ name: "bulk", title: "Bulk action", icon: <ListChecks className="size-4" /> },
] as const;
type FilterType = (typeof filterTypes)[number]["name"];
@@ -186,34 +217,6 @@ function FilterMenu(props: RunFiltersProps) {
);
}
function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
return (
<>
@@ -222,6 +225,10 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedRunIdFilter />
<AppliedBatchIdFilter />
<AppliedScheduleIdFilter />
<AppliedBulkActionsFilter bulkActions={bulkActions} />
</>
);
@@ -246,19 +253,28 @@ function Menu(props: MenuProps) {
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "bulk":
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tags":
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "run":
return <RunIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "batch":
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "schedule":
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
return filterTypes.filter((item) => {
if (item.name === "daterange") return false;
return item.title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue]);
return (
@@ -384,100 +400,6 @@ function AppliedStatusFilter() {
);
}
function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedEnvironmentFilter({
possibleEnvironments,
}: Pick<RunFiltersProps, "possibleEnvironments">) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
@@ -524,7 +446,9 @@ function TasksDropdown({
<SelectItem
key={item.slug}
value={item.slug}
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
{item.slug}
</SelectItem>
@@ -685,7 +609,7 @@ function TagsDropdown({
});
};
const fetcher = useFetcher<typeof loader>();
const fetcher = useFetcher<typeof tagsLoader>();
useEffect(() => {
const searchParams = new URLSearchParams();
@@ -780,62 +704,34 @@ function AppliedTagsFilter() {
);
}
const timePeriods = [
{
label: "All periods",
value: "all",
},
{
label: "5 mins ago",
value: "5m",
},
{
label: "15 mins ago",
value: "15m",
},
{
label: "30 mins ago",
value: "30m",
},
{
label: "1 hour ago",
value: "1h",
},
{
label: "3 hours ago",
value: "3h",
},
{
label: "6 hours ago",
value: "6h",
},
{
label: "1 day ago",
value: "1d",
},
{
label: "3 days ago",
value: "3d",
},
{
label: "7 days ago",
value: "7d",
},
{
label: "10 days ago",
value: "10d",
},
{
label: "14 days ago",
value: "14d",
},
{
label: "30 days ago",
value: "30d",
},
];
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
const { value, values, replace } = useSearchParams();
const searchValue = value("rootOnly");
const rootOnly = searchValue !== undefined ? searchValue === "true" : defaultValue;
function CreatedDropdown({
const batchId = value("batchId");
const runId = value("runId");
const scheduleId = value("scheduleId");
const tasks = values("tasks");
const disabled = !!batchId || !!runId || !!scheduleId || tasks.length > 0;
return (
<Switch
disabled={disabled}
variant="small"
label="Root only"
checked={disabled ? false : rootOnly}
onCheckedChange={(checked) => {
replace({
rootOnly: checked ? "true" : "false",
});
}}
/>
);
}
function RunIdDropdown({
trigger,
clearSearchValue,
searchValue,
@@ -846,25 +742,34 @@ function CreatedDropdown({
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const runIdValue = value("runId");
const handleChange = (newValue: string) => {
const [runId, setRunId] = useState(runIdValue);
const apply = useCallback(() => {
clearSearchValue();
if (newValue === "all") {
if (!value) return;
replace({
cursor: undefined,
direction: undefined,
runId: runId === "" ? undefined : runId?.toString(),
});
setOpen(false);
}, [runId, replace]);
let error: string | undefined = undefined;
if (runId) {
if (!runId.startsWith("run_")) {
error = "Run IDs start with 'run_'";
} else if (runId.length !== 25) {
error = "Run IDs are 25 characters long";
}
replace({ period: newValue, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
}
return (
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
@@ -876,39 +781,63 @@ function CreatedDropdown({
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
</SelectList>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Run ID</Label>
<Input
placeholder="run_"
value={runId ?? ""}
onChange={(e) => setRunId(e.target.value)}
variant="small"
className="w-[27ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !runId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedPeriodFilter() {
function AppliedRunIdFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
if (value("runId") === undefined) {
return null;
}
const runId = value("runId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedDropdown
<RunIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
label="Run ID"
value={runId}
onRemove={() => del(["runId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
@@ -920,14 +849,238 @@ function AppliedPeriodFilter() {
);
}
function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
function BatchIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const batchIdValue = value("batchId");
const [batchId, setBatchId] = useState(batchIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
batchId: batchId === "" ? undefined : batchId?.toString(),
});
setOpen(false);
}, [batchId, replace]);
let error: string | undefined = undefined;
if (batchId) {
if (!batchId.startsWith("batch_")) {
error = "Batch IDs start with 'batch_'";
} else if (batchId.length !== 27) {
error = "Batch IDs are 27 characters long";
}
}
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Batch ID</Label>
<Input
placeholder="batch_"
value={batchId ?? ""}
onChange={(e) => setBatchId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !batchId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedBatchIdFilter() {
const { value, del } = useSearchParams();
if (value("batchId") === undefined) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
const batchId = value("batchId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<BatchIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Batch ID"
value={batchId}
onRemove={() => del(["batchId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function ScheduleIdDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const scheduleIdValue = value("scheduleId");
const [scheduleId, setScheduleId] = useState(scheduleIdValue);
const apply = useCallback(() => {
clearSearchValue();
replace({
cursor: undefined,
direction: undefined,
scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(),
});
setOpen(false);
}, [scheduleId, replace]);
let error: string | undefined = undefined;
if (scheduleId) {
if (!scheduleId.startsWith("sched")) {
error = "Schedule IDs start with 'sched_'";
} else if (scheduleId.length !== 27) {
error = "Schedule IDs are 27 characters long";
}
}
return values.join(", ");
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
className="max-w-[min(32ch,var(--popover-available-width))]"
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>Schedule ID</Label>
<Input
placeholder="sched_"
value={scheduleId ?? ""}
onChange={(e) => setScheduleId(e.target.value)}
variant="small"
className="w-[29ch] font-mono"
spellCheck={false}
/>
{error ? <FormError>{error}</FormError> : null}
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={error !== undefined || !scheduleId}
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
function AppliedScheduleIdFilter() {
const { value, del } = useSearchParams();
if (value("scheduleId") === undefined) {
return null;
}
const scheduleId = value("scheduleId");
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ScheduleIdDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Schedule ID"
value={scheduleId}
onRemove={() => del(["scheduleId", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
@@ -0,0 +1,482 @@
import * as Ariakit from "@ariakit/react";
import type { RuntimeEnvironment } from "@trigger.dev/database";
import type { ReactNode } from "react";
import { startTransition, useCallback, useMemo, useState } from "react";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { DateField } from "~/components/primitives/DateField";
import { DateTime } from "~/components/primitives/DateTime";
import { Label } from "~/components/primitives/Label";
import {
ComboBox,
ComboboxProvider,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
shortcutFromIndex,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
export function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
export function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedEnvironmentFilter({
possibleEnvironments,
}: {
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [
{
label: "Last 5 mins",
value: "5m",
},
{
label: "Last 30 mins",
value: "30m",
},
{
label: "Last 1 hour",
value: "1h",
},
{
label: "Last 6 hours",
value: "6h",
},
{
label: "Last 1 day",
value: "1d",
},
{
label: "Last 3 days",
value: "3d",
},
{
label: "Last 7 days",
value: "7d",
},
{
label: "Last 14 days",
value: "14d",
},
{
label: "Last 30 days",
value: "30d",
},
{
label: "All periods",
value: "all",
},
];
export function CreatedAtDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
setFilterType,
hideCustomRange,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
setFilterType?: (type: "daterange" | undefined) => void;
hideCustomRange?: boolean;
}) {
const { value, replace } = useSearchParams();
const from = value("from");
const to = value("to");
const period = value("period");
const handleChange = (newValue: string) => {
clearSearchValue();
if (newValue === "all") {
if (!period && !from && !to) return;
replace({
period: undefined,
from: undefined,
to: undefined,
cursor: undefined,
direction: undefined,
});
return;
}
if (newValue === "custom") {
setFilterType?.("daterange");
return;
}
replace({
period: newValue,
from: undefined,
to: undefined,
cursor: undefined,
direction: undefined,
});
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider
value={from || to ? "custom" : period ?? "all"}
setValue={handleChange}
virtualFocus={true}
>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
{!hideCustomRange ? (
<SelectItem value="custom" hideOnClick={false}>
Custom date range
</SelectItem>
) : null}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedPeriodFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedAtDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
hideCustomRange
/>
)}
</FilterMenuProvider>
);
}
export function CustomDateRangeDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const [open, setOpen] = useState<boolean | undefined>();
const { value, replace } = useSearchParams();
const fromSearch = dateFromString(value("from"));
const toSearch = dateFromString(value("to"));
const [from, setFrom] = useState(fromSearch);
const [to, setTo] = useState(toSearch);
const apply = useCallback(() => {
clearSearchValue();
replace({
period: undefined,
cursor: undefined,
direction: undefined,
from: from?.getTime().toString(),
to: to?.getTime().toString(),
});
setOpen(false);
}, [from, to, replace]);
return (
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<div className="flex flex-col gap-4 p-3">
<div className="flex flex-col gap-1">
<Label>From (local time)</Label>
<DateField
label="From time"
defaultValue={from}
onValueChange={setFrom}
granularity="second"
showNowButton
showClearButton
variant="small"
/>
</div>
<div className="flex flex-col gap-1">
<Label>To (local time)</Label>
<DateField
label="To time"
defaultValue={to}
onValueChange={setTo}
granularity="second"
showNowButton
showClearButton
variant="small"
/>
</div>
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="secondary/small"
shortcut={{
modifiers: ["meta"],
key: "Enter",
enabledOnInputElements: true,
}}
onClick={() => apply()}
>
Apply
</Button>
</div>
</div>
</SelectPopover>
</SelectProvider>
);
}
export function AppliedCustomDateRangeFilter() {
const { value, del } = useSearchParams();
if (value("from") === undefined && value("to") === undefined) {
return null;
}
const fromDate = dateFromString(value("from"));
const toDate = dateFromString(value("to"));
const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to";
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CustomDateRangeDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label={
rangeType === "range"
? "Created"
: rangeType === "from"
? "Created after"
: "Created before"
}
value={
<>
{rangeType === "range" ? (
<span>
<DateTime date={fromDate!} includeTime includeSeconds /> {" "}
<DateTime date={toDate!} includeTime includeSeconds />
</span>
) : rangeType === "from" ? (
<DateTime date={fromDate!} includeTime includeSeconds />
) : (
<DateTime date={toDate!} includeTime includeSeconds />
)}
</>
}
onRemove={() => del(["period", "from", "to", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
export function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
}
return values.join(", ");
}
function dateFromString(value: string | undefined | null): Date | undefined {
if (!value) return;
//is it an int?
const int = parseInt(value);
if (!isNaN(int)) {
return new Date(int);
}
return new Date(value);
}
@@ -1,9 +1,10 @@
import {
ArrowPathIcon,
ArrowRightIcon,
ClockIcon,
CpuChipIcon,
NoSymbolIcon,
RectangleStackIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { useLocation } from "@remix-run/react";
@@ -14,6 +15,7 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironments } from "~/hooks/useEnvironments";
@@ -102,11 +104,11 @@ export function TaskRunsTable({
);
return (
<Table>
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
{allowSelection && (
<TableHeaderCell className="pl-2 pr-0">
<TableHeaderCell className="pl-3 pr-0">
{runs.length > 0 && (
<Checkbox
checked={hasAll(runs.map((r) => r.id))}
@@ -210,8 +212,9 @@ export function TaskRunsTable({
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="tertiary/small"
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
@@ -233,8 +236,9 @@ export function TaskRunsTable({
</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="tertiary/small"
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
@@ -254,8 +258,9 @@ export function TaskRunsTable({
</Paragraph>
<LinkButton
to={docsPath("v3/tags")}
variant="tertiary/small"
variant="docs/small"
LeadingIcon={BookOpenIcon}
className="mt-3"
>
Read docs
</LinkButton>
@@ -282,7 +287,7 @@ export function TaskRunsTable({
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-2 pr-0">
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.id)}
onChange={(element) => {
@@ -367,7 +372,7 @@ export function TaskRunsTable({
</TableCell>
)}
<TableCell to={path}>
{run.isTest ? <CheckIcon className="h-4 w-4 text-charcoal-400" /> : ""}
{run.isTest ? <CheckIcon className="size-4 text-charcoal-400" /> : ""}
</TableCell>
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
@@ -405,34 +410,114 @@ function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
return (
<TableCellMenu isSticky>
{run.isCancellable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
title="View run"
/>
</Dialog>
)}
{run.isReplayable && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</TableCellMenu>
{run.isCancellable && (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Cancel run
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{run.isReplayable && (
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Replay run
</Button>
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
hiddenButtons={
<div className="flex items-center">
{run.isCancellable && (
<SimpleTooltip
button={
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-bright transition hover:bg-charcoal-700"
>
<NoSymbolIcon className="size-3" />
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
}
content="Cancel run"
side="left"
disableHoverableContent
/>
)}
{run.isCancellable && run.isReplayable && (
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
)}
{run.isReplayable && (
<SimpleTooltip
button={
<Dialog>
<DialogTrigger
asChild
className="h-6 w-6 rounded-sm p-1 text-text-bright transition hover:bg-charcoal-700"
>
<ArrowPathIcon className="size-3" />
</DialogTrigger>
<ReplayRunDialog
runFriendlyId={run.friendlyId}
failedRedirect={`${location.pathname}${location.search}`}
/>
</Dialog>
}
content="Replay run…"
side="left"
disableHoverableContent
/>
)}
</div>
}
/>
);
}
@@ -503,19 +588,29 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
return (
<TableBlankRow colSpan={14}>
<div className="flex flex-col items-center justify-center gap-2">
<Paragraph className="w-auto" variant="small">
No runs currently match your filters. Try refreshing or modifying your filters.
<div className="flex flex-col items-center justify-center gap-6">
<Paragraph className="w-auto" variant="base/bright">
No runs match your filters. Try refreshing, modifying your filters or run a test.
</Paragraph>
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/small"
onClick={() => {
window.location.reload();
}}
>
Refresh
</Button>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={() => {
window.location.reload();
}}
>
Refresh
</Button>
<Paragraph>or</Paragraph>
<LinkButton
LeadingIcon={BeakerIcon}
variant="tertiary/medium"
to={v3TestPath(organization, project)}
>
Run a test
</LinkButton>
</div>
</div>
</TableBlankRow>
);
+7
View File
@@ -32,6 +32,7 @@ const EnvironmentSchema = z.object({
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
API_ORIGIN: z.string().optional(),
STREAM_ORIGIN: z.string().optional(),
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
APP_ENV: z.string().default(process.env.NODE_ENV),
SERVICE_NAME: z.string().default("trigger.dev webapp"),
@@ -141,6 +142,10 @@ const EnvironmentSchema = z.object({
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
DEPLOY_REGISTRY_HOST: z.string().optional(),
DEPLOY_REGISTRY_NAMESPACE: z.string().default("trigger"),
DEPLOY_TIMEOUT_MS: z.coerce
.number()
.int()
.default(60 * 1000 * 8), // 8 minutes
OBJECT_STORE_BASE_URL: z.string().optional(),
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
@@ -232,10 +237,12 @@ const EnvironmentSchema = z.object({
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
-2
View File
@@ -4,7 +4,6 @@ import { requestUrl } from "./utils/requestUrl.server";
export type TriggerFeatures = {
isManagedCloud: boolean;
v3Enabled: boolean;
alertsEnabled: boolean;
};
function isManagedCloud(host: string): boolean {
@@ -20,7 +19,6 @@ function featuresForHost(host: string): TriggerFeatures {
return {
isManagedCloud: isManagedCloud(host),
v3Enabled: env.V3_ENABLED === "true",
alertsEnabled: env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined,
};
}
+1 -1
View File
@@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server";
export function useFeatures(): TriggerFeatures {
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false };
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false };
}
+5 -2
View File
@@ -6,11 +6,10 @@ import type {
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
import type {
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
TaskRun,
TaskRunAttempt,
TaskRunAttemptStatus as TaskRunAttemptStatusType,
TaskRunStatus as TaskRunStatusType,
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
} from "@trigger.dev/database";
import { assertNever } from "assert-never";
@@ -50,6 +49,7 @@ export function executionResultForTaskRun(
return {
ok: true,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
output: attempt.output ?? undefined,
outputType: attempt.outputType,
} satisfies TaskRunSuccessfulExecutionResult;
@@ -60,6 +60,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
@@ -92,6 +93,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
@@ -102,6 +104,7 @@ export function executionResultForTaskRun(
return {
ok: false,
id: taskRun.friendlyId,
taskIdentifier: taskRun.taskIdentifier,
error: error.data,
} satisfies TaskRunFailedExecutionResult;
}
@@ -15,7 +15,7 @@ import assertNever from "assert-never";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { generatePresignedUrl } from "~/v3/r2.server";
import { BasePresenter } from "./basePresenter.server";
import { prisma } from "~/db.server";
import { $replica, prisma } from "~/db.server";
// Build 'select' object
const commonRunSelect = {
@@ -59,48 +59,46 @@ type CommonRelatedRun = Prisma.Result<
"findFirstOrThrow"
>;
type FoundRun = NonNullable<Awaited<ReturnType<typeof ApiRetrieveRunPresenter.findRun>>>;
export class ApiRetrieveRunPresenter extends BasePresenter {
public static async findRun(friendlyId: string, env: AuthenticatedEnvironment) {
return $replica.taskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
attempts: true,
lockedToVersion: true,
schedule: true,
tags: true,
batch: {
select: {
id: true,
friendlyId: true,
},
},
parentTaskRun: {
select: commonRunSelect,
},
rootTaskRun: {
select: commonRunSelect,
},
childRuns: {
select: {
...commonRunSelect,
},
},
},
});
}
public async call(
friendlyId: string,
taskRun: FoundRun,
env: AuthenticatedEnvironment
): Promise<RetrieveRunResponse | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const taskRun = await this._replica.taskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
},
include: {
attempts: true,
lockedToVersion: true,
schedule: true,
tags: true,
batch: {
select: {
id: true,
friendlyId: true,
},
},
parentTaskRun: {
select: commonRunSelect,
},
rootTaskRun: {
select: commonRunSelect,
},
childRuns: {
select: {
...commonRunSelect,
},
},
},
});
if (!taskRun) {
logger.debug("Task run not found", { friendlyId, envId: env.id });
return undefined;
}
let $payload: any;
let $payloadPresignedUrl: string | undefined;
let $output: any;
@@ -119,6 +119,7 @@ export const ApiRunListSearchParams = z.object({
"filter[createdAt][from]": CoercedDate,
"filter[createdAt][to]": CoercedDate,
"filter[createdAt][period]": z.string().optional(),
"filter[batch]": z.string().optional(),
});
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
@@ -209,6 +210,10 @@ export class ApiRunListPresenter extends BasePresenter {
options.isTest = searchParams["filter[isTest]"];
}
if (searchParams["filter[batch]"]) {
options.batchId = searchParams["filter[batch]"];
}
const presenter = new RunListPresenter();
logger.debug("Calling RunListPresenter", { options });
@@ -0,0 +1,220 @@
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import parse from "parse-duration";
import { type Direction } from "~/components/runs/RunStatuses";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
export type BatchListOptions = {
userId?: string;
projectId: string;
//filters
friendlyId?: string;
statuses?: BatchTaskRunStatus[];
environments?: string[];
period?: string;
from?: number;
to?: number;
//pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const DEFAULT_PAGE_SIZE = 25;
export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
export type BatchListItem = BatchList["batches"][0];
export type BatchListAppliedFilters = BatchList["filters"];
export class BatchListPresenter extends BasePresenter {
public async call({
userId,
projectId,
friendlyId,
statuses,
environments,
period,
from,
to,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: BatchListOptions) {
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
hasStatusFilters ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
friendlyId !== undefined ||
from !== undefined ||
to !== undefined;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
id: projectId,
},
});
let environmentIds = project.environments.map((e) => e.id);
if (environments && environments.length > 0) {
//if environments are passed in, we only include them if they're in the project
environmentIds = environments.filter((e) => project.environments.some((pe) => pe.id === e));
}
if (environmentIds.length === 0) {
throw new Error("No matching environments found for the project");
}
const periodMs = period ? parse(period) : undefined;
//get the batches
const batches = await this._replica.$queryRaw<
{
id: string;
friendlyId: string;
runtimeEnvironmentId: string;
status: BatchTaskRunStatus;
createdAt: Date;
updatedAt: Date;
runCount: BigInt;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."runCount",
b."batchVersion"
FROM
${sqlDatabaseSchema}."BatchTaskRun" b
WHERE
-- environments
b."runtimeEnvironmentId" IN (${Prisma.join(environmentIds)})
-- cursor
${
cursor
? direction === "forward"
? Prisma.sql`AND b.id < ${cursor}`
: Prisma.sql`AND b.id > ${cursor}`
: Prisma.empty
}
-- filters
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
${
statuses && statuses.length > 0
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
statuses
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
: Prisma.empty
}
${
periodMs
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
from
? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp`
: Prisma.empty
}
${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty}
ORDER BY
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
LIMIT ${pageSize + 1}`;
const hasMore = batches.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? batches.at(0)?.id : undefined;
if (hasMore) {
next = batches[pageSize - 1]?.id;
}
break;
case "backward":
batches.reverse();
if (hasMore) {
previous = batches[1]?.id;
next = batches[pageSize]?.id;
} else {
next = batches[pageSize - 1]?.id;
}
break;
}
const batchesToReturn =
direction === "backward" && hasMore
? batches.slice(1, pageSize + 1)
: batches.slice(0, pageSize);
return {
batches: batchesToReturn.map((batch) => {
const environment = project.environments.find(
(env) => env.id === batch.runtimeEnvironmentId
);
if (!environment) {
throw new Error(`Environment not found for Batch ${batch.id}`);
}
const hasFinished = batch.status === "COMPLETED";
return {
id: batch.id,
friendlyId: batch.friendlyId,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
hasFinished,
finishedAt: hasFinished ? batch.updatedAt.toISOString() : undefined,
status: batch.status,
environment: displayableEnvironment(environment, userId),
runCount: Number(batch.runCount),
batchVersion: batch.batchVersion,
};
}),
pagination: {
next,
previous,
},
filters: {
friendlyId,
statuses: statuses || [],
environments: environments || [],
from,
to,
},
hasFilters,
};
}
}
@@ -108,6 +108,7 @@ export class DeploymentPresenter {
},
},
sdkVersion: true,
cliVersion: true,
},
},
triggeredBy: {
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
},
deployedBy: deployment.triggeredBy,
sdkVersion: deployment.worker?.sdkVersion,
cliVersion: deployment.worker?.cliVersion,
imageReference: deployment.imageReference,
externalBuildData:
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
@@ -22,6 +22,9 @@ export type RunListOptions = {
from?: number;
to?: number;
isTest?: boolean;
rootOnly?: boolean;
batchId?: string;
runId?: string;
//pagination
direction?: Direction;
cursor?: string;
@@ -47,6 +50,9 @@ export class RunListPresenter extends BasePresenter {
period,
bulkId,
isTest,
rootOnly,
batchId,
runId,
from,
to,
direction = "forward",
@@ -66,7 +72,10 @@ export class RunListPresenter extends BasePresenter {
to !== undefined ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
typeof isTest === "boolean";
batchId !== undefined ||
runId !== undefined ||
typeof isTest === "boolean" ||
rootOnly === true;
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
@@ -141,6 +150,43 @@ export class RunListPresenter extends BasePresenter {
}
}
//batch id is a friendly id
if (batchId) {
const batch = await this._replica.batchTaskRun.findFirst({
select: {
id: true,
},
where: {
friendlyId: batchId,
},
});
if (batch) {
batchId = batch.id;
}
}
//scheduleId can be a friendlyId
if (scheduleId && scheduleId.startsWith("sched_")) {
const schedule = await this._replica.taskSchedule.findFirst({
select: {
id: true,
},
where: {
friendlyId: scheduleId,
},
});
if (schedule) {
scheduleId = schedule?.id;
}
}
//show all runs if we are filtering by batchId or runId
if (batchId || runId || scheduleId || tasks?.length) {
rootOnly = false;
}
const periodMs = period ? parse(period) : undefined;
//get the runs
@@ -166,9 +212,10 @@ export class RunListPresenter extends BasePresenter {
costInCents: number;
baseCostInCents: number;
usageDurationMs: BigInt;
tags: string[];
tags: null | string[];
depth: number;
rootTaskRunId: string | null;
batchId: string | null;
}[]
>`
SELECT
@@ -194,15 +241,11 @@ export class RunListPresenter extends BasePresenter {
tr."usageDurationMs" AS "usageDurationMs",
tr."depth" AS "depth",
tr."rootTaskRunId" AS "rootTaskRunId",
array_remove(array_agg(tag.name), NULL) AS "tags"
tr."runTags" AS "tags"
FROM
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
LEFT JOIN
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A"
LEFT JOIN
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
WHERE
-- project
tr."projectId" = ${project.id}
@@ -215,6 +258,8 @@ WHERE
: Prisma.empty
}
-- filters
${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty}
${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty}
${
restrictToRunIds
? restrictToRunIds.length === 0
@@ -248,26 +293,16 @@ WHERE
from
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
: Prisma.empty
}
}
${
to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
}
${
tags && tags.length > 0
? Prisma.sql`AND (
tr.id IN (
SELECT
trtg."A"
FROM
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg
JOIN
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
WHERE
tag.name IN (${Prisma.join(tags)})
)
)`
: Prisma.empty
}
}
${
tags && tags.length > 0
? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]`
: Prisma.empty
}
${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty}
GROUP BY
tr.id, bw.version
ORDER BY
@@ -336,7 +371,7 @@ WHERE
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
usageDurationMs: Number(run.usageDurationMs),
tags: run.tags.sort((a, b) => a.localeCompare(b)),
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
depth: run.depth,
rootTaskRunId: run.rootTaskRunId,
};
@@ -149,6 +149,11 @@ export class SpanPresenter extends BasePresenter {
spanId: true,
},
},
batch: {
select: {
friendlyId: true,
},
},
},
where: {
spanId,
@@ -210,7 +215,7 @@ export class SpanPresenter extends BasePresenter {
const span = await eventRepository.getSpan(spanId, run.traceId);
const metadata = run.metadata
? await prettyPrintPacket(run.metadata, run.metadataType)
? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams"] })
: undefined;
const context = {
@@ -312,6 +317,7 @@ export class SpanPresenter extends BasePresenter {
context: JSON.stringify(context, null, 2),
metadata,
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
};
}
@@ -31,6 +31,7 @@ export type Task = {
environments: {
id: string;
type: RuntimeEnvironmentType;
slug: string;
userName?: string;
}[];
};
@@ -113,10 +113,10 @@ export class TestPresenter extends BasePresenter {
triggerSource: TaskTriggerSource;
}[]
>`WITH workers AS (
SELECT
SELECT
bw.*,
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
FROM
FROM
${sqlDatabaseSchema}."BackgroundWorker" bw
WHERE "runtimeEnvironmentId" = ${envId}
),
@@ -1,13 +1,15 @@
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import { RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
import { BackgroundWorkerTask, RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { getTimezones } from "~/utils/timezones.server";
import { getUsername } from "~/utils/username";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
type TestTaskOptions = {
userId: string;
projectSlug: string;
taskFriendlyId: string;
environmentSlug: string;
taskIdentifier: string;
};
type Task = {
@@ -37,6 +39,15 @@ export type TestTask =
runs: ScheduledRun[];
};
export type TestTaskResult =
| {
foundTask: true;
task: TestTask;
}
| {
foundTask: false;
};
type RawRun = {
id: string;
number: BigInt;
@@ -71,55 +82,79 @@ export class TestTaskPresenter {
this.#prismaClient = prismaClient;
}
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions): Promise<TestTask> {
const task = await this.#prismaClient.backgroundWorkerTask.findFirstOrThrow({
public async call({
userId,
projectSlug,
environmentSlug,
taskIdentifier,
}: TestTaskOptions): Promise<TestTaskResult> {
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
where: {
slug: environmentSlug,
project: {
slug: projectSlug,
},
orgMember: environmentSlug === "dev" ? { userId } : undefined,
},
select: {
id: true,
filePath: true,
exportName: true,
slug: true,
triggerSource: true,
runtimeEnvironment: {
type: true,
orgMember: {
select: {
id: true,
type: true,
orgMember: {
user: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
id: true,
name: true,
displayName: true,
},
},
},
},
},
where: {
friendlyId: taskFriendlyId,
},
});
let task: BackgroundWorkerTask | null = null;
if (environment.type !== "DEVELOPMENT") {
const deployment = await findCurrentWorkerDeployment(environment.id);
if (deployment) {
task = deployment.worker?.tasks.find((t) => t.slug === taskIdentifier) ?? null;
}
} else {
task = await this.#prismaClient.backgroundWorkerTask.findFirst({
where: {
slug: taskIdentifier,
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
});
}
if (!task) {
return {
foundTask: false,
};
}
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
WITH taskruns AS (
SELECT
tr.*
FROM
SELECT
tr.*
FROM
${sqlDatabaseSchema}."TaskRun" as tr
JOIN
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
ON
tr."taskIdentifier" = bwt.slug
WHERE
bwt."friendlyId" = ${taskFriendlyId} AND
tr."runtimeEnvironmentId" = ${task.runtimeEnvironment.id}
ORDER BY
bwt."friendlyId" = ${task.friendlyId} AND
tr."runtimeEnvironmentId" = ${environment.id}
ORDER BY
tr."createdAt" DESC
LIMIT 5
)
SELECT
SELECT
taskr.id,
taskr.number,
taskr."friendlyId",
@@ -131,7 +166,7 @@ export class TestTaskPresenter {
taskr."seedMetadata",
taskr."seedMetadataType",
taskr."runtimeEnvironmentId"
FROM
FROM
taskruns AS taskr
WHERE
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
@@ -143,58 +178,64 @@ export class TestTaskPresenter {
taskIdentifier: task.slug,
filePath: task.filePath,
exportName: task.exportName,
friendlyId: taskFriendlyId,
friendlyId: task.friendlyId,
environment: {
id: task.runtimeEnvironment.id,
type: task.runtimeEnvironment.type,
userId: task.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
id: environment.id,
type: environment.type,
userId: environment.orgMember?.user.id,
userName: getUsername(environment.orgMember?.user),
},
};
switch (task.triggerSource) {
case "STANDARD":
return {
triggerSource: "STANDARD",
task: taskWithEnvironment,
runs: await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
foundTask: true,
task: {
triggerSource: "STANDARD",
task: taskWithEnvironment,
runs: await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
return {
...r,
number,
payload: await prettyPrintPacket(r.payload, r.payloadType),
metadata: r.seedMetadata
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
: undefined,
};
})
),
return {
...r,
number,
payload: await prettyPrintPacket(r.payload, r.payloadType),
metadata: r.seedMetadata
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
: undefined,
};
})
),
},
};
case "SCHEDULED":
const possibleTimezones = getTimezones();
return {
triggerSource: "SCHEDULED",
task: taskWithEnvironment,
possibleTimezones,
runs: (
await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
foundTask: true,
task: {
triggerSource: "SCHEDULED",
task: taskWithEnvironment,
possibleTimezones,
runs: (
await Promise.all(
latestRuns.map(async (r) => {
const number = Number(r.number);
const payload = await getScheduleTaskRunPayload(r);
const payload = await getScheduleTaskRunPayload(r);
if (payload.success) {
return {
...r,
number,
payload: payload.data,
};
}
})
)
).filter(Boolean),
if (payload.success) {
return {
...r,
number,
payload: payload.data,
};
}
})
)
).filter(Boolean),
},
};
}
}
@@ -1,11 +1,24 @@
import { ChatBubbleLeftRightIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import { useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
BeakerIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
ChevronDownIcon,
ChevronUpIcon,
LightBulbIcon,
UserPlusIcon,
VideoCameraIcon,
} from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { Feedback } from "~/components/Feedback";
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
@@ -13,14 +26,22 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import {
@@ -28,7 +49,7 @@ import {
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
@@ -38,17 +59,30 @@ import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import {
TaskTriggerSourceIcon,
taskTriggerSourceDescription,
TaskTriggerSourceIcon,
} from "~/components/runs/v3/TaskTriggerSource";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
import {
getUsefulLinksPreference,
setUsefulLinksPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3RunsPath, v3TasksStreamingPath } from "~/utils/pathBuilder";
import {
docsPath,
inviteTeamMemberPath,
ProjectParamSchema,
v3RunsPath,
v3TasksStreamingPath,
v3TestPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -62,12 +96,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
projectSlug: projectParam,
});
const usefulLinksPreference = await getUsefulLinksPreference(request);
return typeddefer({
tasks,
userHasTasks,
activity,
runningStats,
durations,
usefulLinksPreference,
});
} catch (error) {
console.error(error);
@@ -78,10 +115,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
const session = await setUsefulLinksPreference(showUsefulLinks, request);
return json(
{ success: true },
{
headers: {
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
},
}
);
}
export default function Page() {
const organization = useOrganization();
const project = useProject();
const { tasks, userHasTasks, activity, runningStats, durations } =
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
useTypedLoaderData<typeof loader>();
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
items: tasks,
@@ -123,6 +176,16 @@ export default function Page() {
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
// Create a submit handler to save the preference
const submit = useSubmit();
const handleUsefulLinksToggle = (show: boolean) => {
setShowUsefulLinks(show);
submit({ showUsefulLinks: show.toString() }, { method: "post" });
};
return (
<PageContainer>
<NavBar>
@@ -144,145 +207,223 @@ export default function Page() {
))}
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/tasks/overview")}
>
Task docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex flex-col gap-4 pb-4">
{!userHasTasks && <UserHasNoTasks />}
<div className="pb-4">
<div className="h-8">
<Input
placeholder="Search tasks"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Running</TableHeaderCell>
<TableHeaderCell>Queued</TableHeaderCell>
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
tasks: [task.slug],
});
return (
<TableRow key={task.slug} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path} className="py-0" actionClassName="py-0">
<TaskFunctionName
functionName={task.exportName}
variant="extra-extra-small"
/>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense
fallback={
<>
<Spinner color="muted" />
</>
}
>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.running ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.queued ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0" actionClassName="py-0">
<Suspense fallback={<TaskActivityBlankState />}>
<TypedAwait resolve={activity}>
{(data) => {
const taskData = data[task.slug];
return (
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="tasks-main" className="max-h-full">
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex min-w-0 max-w-full flex-col">
{!userHasTasks && <UserHasNoTasks />}
<div className="max-h-full overflow-hidden">
<div className="flex items-center p-2">
<Input
placeholder="Search tasks"
variant="tertiary"
icon="search"
fullWidth={true}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
autoFocus
/>
{!showUsefulLinks && (
<Button
variant="minimal/small"
TrailingIcon={LightBulbIcon}
onClick={() => handleUsefulLinksToggle(true)}
className="px-2.5"
/>
)}
</div>
<Table containerClassName="max-h-full pb-[2.5rem]">
<TableHeader>
<TableRow>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Running</TableHeaderCell>
<TableHeaderCell>Queued</TableHeaderCell>
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
tasks: [task.slug],
});
const devYouEnvironment = task.environments.find(
(e) => e.type === "DEVELOPMENT" && !e.userName
);
const firstDeployedEnvironment = task.environments
.filter((e) => e.type !== "DEVELOPMENT")
.at(0);
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
const testPath = testEnvironment
? v3TestTaskPath(
organization,
project,
{ taskIdentifier: task.slug },
testEnvironment.slug
)
: v3TestPath(organization, project);
return (
<TableRow key={task.slug} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path} className="py-0" actionClassName="py-0">
<TaskFunctionName
functionName={task.exportName}
variant="extra-extra-small"
/>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense
fallback={
<>
{taskData !== undefined ? (
<div className="h-6 w-[5.125rem] rounded-sm">
<TaskActivityGraph activity={taskData} />
</div>
) : (
<TaskActivityBlankState />
)}
<Spinner color="muted" />
</>
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={durations}>
{(data) => {
const taskData = data[task.slug];
return taskData
? formatDurationMilliseconds(taskData * 1000, {
style: "short",
})
: "";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path}>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
}
>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.running ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.queued ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0" actionClassName="py-0">
<Suspense fallback={<TaskActivityBlankState />}>
<TypedAwait resolve={activity}>
{(data) => {
const taskData = data[task.slug];
return (
<>
{taskData !== undefined ? (
<div className="h-6 w-[5.125rem] rounded-sm">
<TaskActivityGraph activity={taskData} />
</div>
) : (
<TaskActivityBlankState />
)}
</>
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} className="p-0">
<Suspense fallback={<></>}>
<TypedAwait resolve={durations}>
{(data) => {
const taskData = data[task.slug];
return taskData
? formatDurationMilliseconds(taskData * 1000, {
style: "short",
})
: "";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path}>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
icon="runs"
to={path}
title="View runs"
leadingIconClassName="text-teal-500"
/>
<PopoverMenuItem
icon="beaker"
to={testPath}
title="Test task"
/>
</>
}
hiddenButtons={
<LinkButton
variant="minimal/small"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-text-bright"
to={testPath}
>
Test
</LinkButton>
}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
) : (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
</MainCenteredContainer>
)}
</div>
</ResizablePanel>
{hasTasks && showUsefulLinks ? (
<>
<ResizableHandle id="tasks-handle" />
<ResizablePanel
id="tasks-inspector"
min="200px"
default="400px"
max="500px"
className="w-full"
>
<HelpfulInfoHasTasks onClose={() => handleUsefulLinksToggle(false)} />
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
@@ -329,43 +470,45 @@ function UserHasNoTasks() {
const [open, setOpen] = useState(false);
return (
<Callout
variant="info"
cta={
<Button
variant="tertiary/small"
TrailingIcon={open ? ChevronUpIcon : ChevronDownIcon}
onClick={() => setOpen((o) => !o)}
>
{open ? "Close" : "Setup your dev environment"}
</Button>
}
>
{open ? (
<div>
<Header2 spacing>Get setup in 3 minutes</Header2>
<div className="px-2 pt-2">
<Callout
variant="info"
cta={
<Button
variant="tertiary/small"
TrailingIcon={open ? ChevronUpIcon : ChevronDownIcon}
onClick={() => setOpen((o) => !o)}
>
{open ? "Close" : "Setup your dev environment"}
</Button>
}
>
{open ? (
<div>
<Header2 spacing>Get setup in 3 minutes</Header2>
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
<StepContentContainer>
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
<StepContentContainer>
<TriggerLoginStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
) : (
"Your DEV environment isn't setup yet."
)}
</Callout>
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
<StepContentContainer>
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
<StepContentContainer>
<TriggerLoginStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
) : (
"Your DEV environment isn't setup yet."
)}
</Callout>
</div>
);
}
@@ -457,7 +600,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
<Header3 className="border-b border-b-charcoal-650 pb-2">{formattedDate}</Header3>
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
{items.map((item) => (
<Fragment key={item.status}>
@@ -473,3 +616,251 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
return null;
};
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
const organization = useOrganization();
const project = useProject();
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
return (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="overflow-y-scroll p-3 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-grid-dimmed pb-2">
<Header2 className="flex items-center gap-2">
<LightBulbIcon className="size-4 min-w-4 text-sun-500" />
Helpful next steps
</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-[0.375rem]"
/>
</div>
<LinkWithIcon
variant="withIcon"
to={v3TestPath(organization, project)}
description="Test your tasks"
icon={<BeakerIcon className="size-5 text-lime-500" />}
/>
<LinkWithIcon
variant="withIcon"
to={inviteTeamMemberPath(organization)}
description="Invite team members"
icon={<UserPlusIcon className="size-5 text-amber-500" />}
/>
<div
role="button"
onClick={() => setIsVideoDialogOpen(true)}
className={cn(
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
variants["withIcon"].container
)}
>
<div className="flex items-center gap-2">
<div className={variants["withIcon"].iconContainer}>
<VideoCameraIcon className="size-5 text-rose-500" />
</div>
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
Watch a 14 min walkthrough video
</Paragraph>
</div>
<AnimatingArrow direction="right" theme="dimmed" />
</div>
<LinkWithIcon
variant="withIcon"
to="https://trigger.dev/discord"
description="Join our Discord for help and support"
icon={<DiscordIcon className="size-5" />}
isExternal
/>
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
<Header2 className="flex items-center gap-2">
<BookOpenIcon className="size-5 text-blue-500" />
From the docs
</Header2>
</div>
<LinkWithIcon
to={docsPath("/writing-tasks-introduction")}
description="How to write a task"
isExternal
/>
<LinkWithIcon
to={docsPath("/tasks/scheduled")}
description="Scheduled tasks (cron)"
isExternal
/>
<LinkWithIcon to={docsPath("/triggering")} description="How to trigger a task" isExternal />
<LinkWithIcon to={docsPath("/cli-dev")} description="Running the CLI" isExternal />
<LinkWithIcon
to={docsPath("/how-it-works")}
description="How Trigger.dev works"
isExternal
/>
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
<Header2 className="flex items-center gap-2">
<TaskIcon className="size-4 text-blue-500" />
Example tasks
</Header2>
</div>
<LinkWithIcon
to={docsPath("/examples/dall-e3-generate-image")}
description="DALL·E 3 image generation"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/deepgram-transcribe-audio")}
description="Deepgram audio transcription"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/fal-ai-image-to-cartoon")}
description="Fal.ai image to cartoon"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/fal-ai-realtime")}
description="Fal.ai with Realtime"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/ffmpeg-video-processing")}
description="FFmpeg video processing"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/firecrawl-url-crawl")}
description="Firecrawl URL crawl"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/libreoffice-pdf-conversion")}
description="LibreOffice PDF conversion"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/open-ai-with-retrying")}
description="OpenAI with retrying"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/pdf-to-image")}
description="PDF to image"
isExternal
/>
<LinkWithIcon to={docsPath("/examples/puppeteer")} description="Puppeteer" isExternal />
<LinkWithIcon to={docsPath("/examples/react-pdf")} description="React to PDF" isExternal />
<LinkWithIcon
to={docsPath("/examples/resend-email-sequence")}
description="Resend email sequence"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/scrape-hacker-news")}
description="Scrape Hacker News"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/sentry-error-tracking")}
description="Sentry error tracking"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/sharp-image-processing")}
description="Sharp image processing"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/supabase-database-operations")}
description="Supabase database operations"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/supabase-storage-upload")}
description="Supabase Storage upload"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/vercel-ai-sdk")}
description="Vercel AI SDK"
isExternal
/>
<LinkWithIcon
to={docsPath("/examples/vercel-sync-env-vars")}
description="Vercel sync environment variables"
isExternal
/>
</div>
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>
<DialogContent className="sm:max-w-screen-lg">
<DialogHeader className="mb-4 pt-1">
<DialogTitle>Trigger.dev walkthrough</DialogTitle>
</DialogHeader>
<div className="aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/YH_4c0K7fGM?si=BcX6MAt_V139sRw9"
title="Trigger.dev walkthrough"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
const variants = {
withIcon: {
container: "",
iconContainer:
"grid size-9 min-w-9 place-items-center rounded border border-transparent bg-charcoal-750 shadow transition group-hover:border-charcoal-650",
},
minimal: {
container: "pl-3 py-2",
iconContainer: "",
},
} as const;
type LinkWithIconProps = {
to: string;
description: string;
icon?: React.ReactNode;
isExternal?: boolean;
variant?: keyof typeof variants;
};
function LinkWithIcon({
to,
description,
icon,
isExternal,
variant = "minimal",
}: LinkWithIconProps) {
const variation = variants[variant];
return (
<Link
to={to}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noreferrer" : undefined}
className={cn(
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
variation.container
)}
>
<div className="flex items-center gap-2">
{variant === "withIcon" && icon && <div className={variation.iconContainer}>{icon}</div>}
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
{description}
</Paragraph>
</div>
<AnimatingArrow direction={isExternal ? "topRight" : "right"} theme="dimmed" />
</Link>
);
}
@@ -23,6 +23,7 @@ import { Label } from "~/components/primitives/Label";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -150,9 +151,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
const url = new URL(request.url);
const option = url.searchParams.get("option");
const emailAlertsEnabled =
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
return typedjson({
...results,
option: option === "slack" ? ("SLACK" as const) : undefined,
emailAlertsEnabled,
});
}
@@ -200,7 +205,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { slack, option } = useTypedLoaderData<typeof loader>();
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
@@ -271,16 +276,23 @@ export default function Page() {
</InputGroup>
{currentAlertChannel === "EMAIL" ? (
<InputGroup fullWidth>
<Label>Email</Label>
<Input
{...conform.input(channelValue)}
placeholder="email@youremail.com"
type="email"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
</InputGroup>
emailAlertsEnabled ? (
<InputGroup fullWidth>
<Label>Email</Label>
<Input
{...conform.input(channelValue)}
placeholder="email@youremail.com"
type="email"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
</InputGroup>
) : (
<Callout variant="warning">
Email integration is not available. Please contact your organization
administrator.
</Callout>
)
) : currentAlertChannel === "SLACK" ? (
<InputGroup fullWidth>
{slack.status === "READY" ? (
@@ -416,28 +428,21 @@ export default function Page() {
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="border-t border-grid-bright pt-3">
<FormButtons
confirmButton={
<Button
variant="primary/medium"
disabled={isLoading}
name="action"
value="create"
>
{isLoading ? "Saving" : "Save"}
</Button>
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</div>
<FormButtons
confirmButton={
<Button variant="primary/medium" disabled={isLoading} name="action" value="create">
{isLoading ? "Saving" : "Save"}
</Button>
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</DialogContent>
@@ -37,6 +37,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
@@ -163,17 +164,17 @@ export default function Page() {
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/troubleshooting-alerts")}
variant="minimal/small"
variant="docs/small"
>
Alerts docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
{alertChannels.length > 0 && !requiresUpgrade && (
<div className="flex items-end justify-between">
<Header2 className="">Project alerts</Header2>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex h-fit items-end justify-between p-2 pl-3">
<Header2 className="">Project alerts</Header2>
{alertChannels.length > 0 && !requiresUpgrade && (
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
@@ -182,8 +183,8 @@ export default function Page() {
>
New alert
</LinkButton>
</div>
)}
)}
</div>
<Table>
<TableHeader>
<TableRow>
@@ -225,20 +226,27 @@ export default function Page() {
disabledIcon={BellSlashIcon}
/>
</TableCell>
<TableCellMenu isSticky>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</TableCellMenu>
<TableCellMenu
isSticky
popoverContent={
<>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</>
}
className={
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
}
/>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5}>
<TableCell colSpan={6}>
<div className="flex flex-col items-center justify-center py-6">
<Header2 spacing className="text-text-bright">
You haven't created any project alerts yet
@@ -261,40 +269,55 @@ export default function Page() {
)}
</TableBody>
</Table>
<div className="flex items-stretch gap-3">
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more alerts"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available alerts. Upgrade your plan to
enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
<div className="flex items-center justify-between gap-6">
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
<div className="flex h-fit items-stretch gap-3">
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your available alerts. Upgrade your plan to
enable more.
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
)}
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
<div
className="h-full bg-grid-bright"
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
/>
</div>
</div>
)}
</div>
</div>
</div>
<Outlet />
@@ -328,6 +351,8 @@ function DeleteAlertChannelButton(props: { id: string }) {
<Button
name="action"
value="delete"
fullWidth
textAlignLeft
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
@@ -367,6 +392,8 @@ function DisableAlertChannelButton(props: { id: string }) {
name="action"
value="disable"
type="submit"
fullWidth
textAlignLeft
variant="small-menu-item"
LeadingIcon={BellSlashIcon}
leadingIconClassName="text-dimmed"
@@ -405,6 +432,8 @@ function EnableAlertChannelButton(props: { id: string }) {
name="action"
value="enable"
type="submit"
fullWidth
textAlignLeft
variant="small-menu-item"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-success"
@@ -430,6 +459,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
leadingIconClassName="text-charcoal-400"
label={"Email"}
description={alertChannel.properties.email}
boxClassName="group-hover/table-row:bg-charcoal-800"
/>
);
}
@@ -461,6 +491,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
className="mt-1 w-80"
/>
}
boxClassName="group-hover/table-row:bg-charcoal-800"
/>
);
}
@@ -476,6 +507,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
leadingIconClassName="text-charcoal-400"
label={"Slack"}
description={`#${alertChannel.properties.channelName}`}
boxClassName="group-hover/table-row:bg-charcoal-800"
/>
);
}
@@ -1,4 +1,5 @@
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -72,7 +73,7 @@ export default function Page() {
</AdminDebugTooltip>
<LinkButton
variant={"minimal/small"}
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/v3/apikeys")}
>
@@ -80,9 +81,9 @@ export default function Page() {
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className="mt-1 flex flex-col gap-4">
<Table>
<PageBody scrollable={false}>
<div className="flex flex-col">
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
@@ -104,7 +105,7 @@ export default function Page() {
className="w-full max-w-none"
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
value={environment.apiKey}
variant={"tertiary/small"}
variant={"secondary/small"}
/>
</TableCell>
<TableCell>
@@ -112,19 +113,22 @@ export default function Page() {
</TableCell>
<TableCell>{environment.latestVersion ?? ""}</TableCell>
<TableCell>{environment.environmentVariableCount}</TableCell>
<TableCellMenu isSticky>
<RegenerateApiKeyModal
id={environment.id}
title={environmentTitle(environment)}
/>
</TableCellMenu>
<TableCellMenu
isSticky
popoverContent={
<RegenerateApiKeyModal
id={environment.id}
title={environmentTitle(environment)}
/>
}
></TableCellMenu>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex gap-3">
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-sm">
<div className="flex flex-wrap justify-between">
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
<Paragraph variant="small">
Secret keys should be used on your server. They give full API access and allow you
to <TextLink to={docsPath("v3/triggering")}>trigger tasks</TextLink> from your
@@ -133,16 +137,19 @@ export default function Page() {
</InfoPanel>
{!hasStaging && (
<InfoPanel
icon={LockOpenIcon}
variant="upgrade"
title="Unlock a Staging environment"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
iconClassName="text-indigo-500"
>
Upgrade your plan to add a Staging environment.
</InfoPanel>
<div className="flex items-center gap-2 pl-3 pr-2">
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
<Paragraph variant="small" className="text-text-bright">
Upgrade to add a Staging environment
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
</div>
)}
</div>
</div>
@@ -0,0 +1,296 @@
import {
ArrowPathRoundedSquareIcon,
ArrowRightIcon,
ExclamationCircleIcon,
} from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { useLocation, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
import {
allBatchStatuses,
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
BatchList,
BatchListItem,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
environments: url.searchParams.getAll("environments"),
statuses: url.searchParams.getAll("statuses"),
period: url.searchParams.get("period") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
id: url.searchParams.get("id") ?? undefined,
};
const filters = BatchListFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const presenter = new BatchListPresenter();
const list = await presenter.call({
userId,
projectId: project.id,
...filters,
friendlyId: filters.id,
});
return typedjson(list);
};
export default function Page() {
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
const project = useProject();
return (
<PageContainer>
<NavBar>
<PageTitle title="Batches" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/triggering")}
>
Batches docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters possibleEnvironments={project.environments} hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
/>
</div>
</PageBody>
</PageContainer>
);
}
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
return (
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{allBatchStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BatchStatusCombo status={status} />
</div>
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
{descriptionForBatchStatus(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Finished</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Go to batch</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{batches.length === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
{!isLoading && (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">No batches</Paragraph>
</div>
)}
</TableBlankRow>
) : batches.length === 0 ? (
<TableBlankRow colSpan={8}>
<div className="flex items-center justify-center">
<Paragraph className="w-auto">No batches match these filters</Paragraph>
</div>
</TableBlankRow>
) : (
batches.map((batch, index) => {
const path = v3BatchRunsPath(organization, project, batch);
return (
<TableRow key={batch.id}>
<TableCell to={path}>{batch.friendlyId}</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={batch.environment}
userName={batch.environment.userName}
/>
</TableCell>
<TableCell to={path}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
content="Upgrade to the latest SDK for batch statuses to appear."
disableHoverableContent
button={
<span className="flex items-center gap-1">
<ExclamationCircleIcon className="size-4 text-slate-500" />
<span>Legacy batch</span>
</span>
}
/>
) : (
<SimpleTooltip
content={descriptionForBatchStatus(batch.status)}
disableHoverableContent
button={<BatchStatusCombo status={batch.status} />}
/>
)}
</TableCell>
<TableCell to={path}>{batch.runCount}</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
{batch.finishedAt ? (
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
style: "short",
})
) : (
<LiveTimer startTime={new Date(batch.createdAt)} />
)}
</TableCell>
<TableCell to={path}>
<DateTime date={batch.createdAt} />
</TableCell>
<TableCell to={path}>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</TableCell>
<BatchActionsCell batch={batch} path={path} />
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
const location = useLocation();
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
return <TableCell to={path}>{""}</TableCell>;
}
return (
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
title="View batch"
/>
{!batch.hasFinished && (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathRoundedSquareIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Try and resume
</Button>
</DialogTrigger>
<CheckBatchCompletionDialog
batchId={batch.id}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
/>
);
}
@@ -1,4 +1,8 @@
import { ArrowUpCircleIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import {
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
} from "@heroicons/react/20/solid";
import { Await } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense } from "react";
@@ -27,6 +31,8 @@ import {
import { requireUserId } from "~/services/session.server";
import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { LockOpenIcon } from "@heroicons/react/24/solid";
import { Paragraph } from "~/components/primitives/Paragraph";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -62,7 +68,7 @@ export default function Page() {
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"minimal/small"}
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/queue-concurrency")}
>
@@ -70,50 +76,56 @@ export default function Page() {
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<div className="mb-2 flex items-center justify-between">
<Header2>Environments</Header2>
{plan ? (
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
<Feedback
button={
<Button LeadingIcon={ArrowUpCircleIcon} variant="tertiary/small">
Request more concurrency
</Button>
}
defaultValue="help"
/>
) : (
<LinkButton
LeadingIcon={ArrowUpCircleIcon}
to={v3BillingPath(organization)}
variant="tertiary/small"
>
Upgrade for more concurrency
</LinkButton>
)
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
<TableHeaderCell alignment="right">Running</TableHeaderCell>
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Suspense fallback={<Spinner />}>
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
{(environments) => <EnvironmentsTable environments={environments} />}
</Await>
</Suspense>
</TableBody>
</Table>
</div>
<PageBody scrollable={false}>
<div className="flex flex-col">
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
<TableHeaderCell alignment="right">Running</TableHeaderCell>
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
<Suspense fallback={<Spinner />}>
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
{(environments) => <EnvironmentsTable environments={environments} />}
</Await>
</Suspense>
</TableBody>
</Table>
{plan ? (
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
<div className="flex w-full items-center justify-end gap-2 pl-3 pr-2 pt-3">
<Paragraph variant="small" className="text-text-bright">
Need more concurrency?
</Paragraph>
<Feedback
button={
<Button LeadingIcon={ChatBubbleLeftEllipsisIcon} variant="tertiary/small">
Request more
</Button>
}
defaultValue="help"
/>
</div>
) : (
<div className="flex w-full items-center justify-end gap-2 pl-3 pr-2 pt-3">
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
<Paragraph variant="small" className="text-text-bright">
Upgrade for more concurrency
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
</div>
)
) : null}
</div>
</PageBody>
</PageContainer>
@@ -151,6 +151,10 @@ export default function Page() {
<Property.Label>SDK Version</Property.Label>
<Property.Value>{deployment.sdkVersion ? deployment.sdkVersion : ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>CLI Version</Property.Label>
<Property.Value>{deployment.cliVersion ? deployment.cliVersion : ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
@@ -16,7 +16,7 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -46,6 +46,7 @@ import {
DeploymentListPresenter,
} from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
@@ -98,111 +99,122 @@ export default function Page() {
<PageContainer>
<NavBar>
<PageTitle title="Deployments" />
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/cli-deploy")}
>
Deployments docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full overflow-y-auto">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
{hasDeployments ? (
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col gap-4 p-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(
organization,
project,
deployment,
currentPage
);
return (
<TableRow key={deployment.id} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<div className="grid max-h-full grid-rows-[1fr_auto]">
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(
organization,
project,
deployment,
currentPage
);
const isSelected = deploymentParam === deployment.shortCode;
return (
<TableRow
key={deployment.id}
className={cn("group", isSelected ? "bg-grid-dimmed" : undefined)}
>
<TableCell to={path}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
{deployment.label && (
<Badge variant="outline-rounded">{deployment.label}</Badge>
)}
</div>
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={
deployment.deployedBy.name ??
deployment.deployedBy.displayName
}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">
{deployment.shortCode}
{deployment.deployedBy.name ??
deployment.deployedBy.displayName}
</Paragraph>
{deployment.label && (
<Badge variant="outline-rounded">{deployment.label}</Badge>
)}
</div>
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={
deployment.deployedBy.name ??
deployment.deployedBy.displayName
}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">
{deployment.deployedBy.name ??
deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</TableCell>
<DeploymentActionsCell deployment={deployment} path={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<Paragraph variant="small" className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
<div className="flex justify-end">
) : (
""
)}
</TableCell>
<DeploymentActionsCell deployment={deployment} path={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={8}>
<Paragraph className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="-mt-px flex justify-end border-t border-grid-dimmed py-2 pr-2">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
)}
</div>
) : (
<CreateDeploymentInstructions />
@@ -212,7 +224,7 @@ export default function Page() {
{deploymentParam && (
<>
<ResizableHandle id="deployments-handle" />
<ResizablePanel id="deployments-inspector" min="225px" max="500px">
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
<Outlet />
</ResizablePanel>
</>
@@ -231,7 +243,7 @@ function CreateDeploymentInstructions() {
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-blue-400"
iconClassName="text-blue-500"
title="Deploy for the first time"
panelClassName="max-w-full"
>
@@ -247,7 +259,7 @@ function CreateDeploymentInstructions() {
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="tertiary/small"
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
@@ -255,7 +267,7 @@ function CreateDeploymentInstructions() {
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="tertiary/small"
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
@@ -285,35 +297,52 @@ function DeploymentActionsCell({
}
return (
<TableCellMenu isSticky>
{canRollback && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowUturnLeftIcon}>
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{canRetryIndexing && (
<Dialog>
<DialogTrigger asChild>
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
Retry indexing
</Button>
</DialogTrigger>
<RetryDeploymentIndexingDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</TableCellMenu>
<TableCellMenu
isSticky
popoverContent={
<>
{canRollback && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnLeftIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{canRetryIndexing && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Retry indexing
</Button>
</DialogTrigger>
<RetryDeploymentIndexingDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
/>
);
}
@@ -311,7 +311,6 @@ export default function Page() {
Cancel
</LinkButton>
}
className="mt-2"
/>
</Fieldset>
</Form>
@@ -8,6 +8,7 @@ import {
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
import {
ActionFunctionArgs,
@@ -28,6 +29,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
@@ -184,33 +186,33 @@ export default function Page() {
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/deploy-environment-variables")}
variant="minimal/small"
variant="docs/small"
>
Environment variables docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
{environmentVariables.length > 0 && (
<PageBody scrollable={false}>
<div className={cn("flex h-full flex-col")}>
{environmentVariables.length > 0 && (
<div className="flex items-center justify-end gap-2 px-2 py-2">
<Switch
variant="small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
)}
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
<Table>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
)}
<Table containerClassName={cn(environmentVariables.length === 0 && "border-t-0")}>
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
@@ -239,26 +241,39 @@ export default function Page() {
className="-ml-2"
secure={!revealAll}
value={value}
variant={"tertiary/small"}
variant={"secondary/small"}
/>
</TableCell>
);
})}
<TableCellMenu isSticky>
<EditEnvironmentVariablePanel
environments={environments}
variable={variable}
revealAll={revealAll}
/>
<DeleteEnvironmentVariableButton variable={variable} />
</TableCellMenu>
<TableCellMenu
isSticky
popoverContent={
<>
<EditEnvironmentVariablePanel
environments={environments}
variable={variable}
revealAll={revealAll}
/>
<DeleteEnvironmentVariableButton variable={variable} />
</>
}
></TableCellMenu>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={environments.length + 2}>
<div className="flex items-center justify-center">
<Paragraph>No environment variables have been set</Paragraph>
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
</TableCell>
</TableRow>
@@ -266,22 +281,25 @@ export default function Page() {
</TableBody>
</Table>
<div className="flex gap-3">
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem]">
<div className="z-10 -mt-px flex w-full flex-wrap justify-between border-t border-grid-dimmed">
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
Dev environment variables specified here will be overridden by ones in your .env file
when running locally.
</InfoPanel>
{!hasStaging && (
<InfoPanel
icon={LockOpenIcon}
variant="upgrade"
title="Unlock a Staging environment"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
iconClassName="text-indigo-500"
>
Upgrade your plan to add a Staging environment.
</InfoPanel>
<div className="flex items-center gap-2 pl-3 pr-2">
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
<Paragraph variant="small" className="text-text-bright">
Upgrade to add a Staging environment
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
</div>
)}
</div>
</div>
@@ -328,14 +346,7 @@ function EditEnvironmentVariablePanel({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={PencilSquareIcon}
leadingIconClassName="text-charcoal-500"
className="text-xs"
fullWidth
textAlignLeft
>
<Button variant="small-menu-item" LeadingIcon={PencilSquareIcon} fullWidth textAlignLeft>
Edit
</Button>
</DialogTrigger>
@@ -457,9 +468,11 @@ function DeleteEnvironmentVariableButton({
value="delete"
type="submit"
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
className="text-xs"
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
className="transition-colors group-hover/button:bg-error"
>
{isLoading ? "Deleting" : "Delete"}
</Button>
@@ -1,6 +1,7 @@
import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -70,6 +71,7 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { lerp } from "~/utils/lerp";
import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
@@ -193,6 +195,9 @@ export default function Page() {
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/runs")}>
Run docs
</LinkButton>
<Dialog key={`replay-${run.friendlyId}`}>
<DialogTrigger asChild>
<Button
@@ -21,7 +21,7 @@ import {
} from "~/components/primitives/Dialog";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
SelectedItemsProvider,
@@ -35,12 +35,22 @@ import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { findProjectBySlug } from "~/models/project.server";
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
import {
getRootOnlyFilterPreference,
setRootOnlyFilterPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectPath, v3RunsPath, v3TestPath } from "~/utils/pathBuilder";
import {
docsPath,
ProjectParamSchema,
v3ProjectPath,
v3RunsPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -48,6 +58,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
let rootOnlyValue = false;
if (url.searchParams.has("rootOnly")) {
rootOnlyValue = url.searchParams.get("rootOnly") === "true";
} else {
rootOnlyValue = await getRootOnlyFilterPreference(request);
}
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
@@ -57,6 +75,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
period: url.searchParams.get("period") ?? undefined,
bulkId: url.searchParams.get("bulkId") ?? undefined,
tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)),
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
rootOnly: rootOnlyValue,
runId: url.searchParams.get("runId") ?? undefined,
batchId: url.searchParams.get("batchId") ?? undefined,
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
};
const {
tasks,
@@ -70,6 +94,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
rootOnly,
runId,
batchId,
scheduleId,
} = TaskRunListSearchFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -91,26 +119,49 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
bulkId,
from,
to,
batchId,
runId,
scheduleId,
rootOnly,
direction: direction,
cursor: cursor,
});
return typeddefer({
data: list,
});
const session = await setRootOnlyFilterPreference(rootOnlyValue, request);
const cookieValue = await uiPreferencesStorage.commitSession(session);
return typeddefer(
{
data: list,
rootOnlyDefault: rootOnlyValue,
},
{
headers: {
"Set-Cookie": cookieValue,
},
}
);
};
export default function Page() {
const { data } = useTypedLoaderData<typeof loader>();
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const project = useProject();
const user = useUser();
return (
<>
<NavBar>
<PageTitle title="Runs" />
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/runs-and-attempts")}
>
Runs docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<SelectedItemsProvider
@@ -121,61 +172,61 @@ export default function Page() {
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_3.5rem]"
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
)}
>
<div className="overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-2">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
)
</div>
}
>
<TypedAwait resolve={data}>
{(list) => (
<>
{list.runs.length === 0 && !list.hasFilters ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-start justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
<ListPagination list={list} className="mt-2 justify-end" />
<RunTaskInstructions />
)
) : (
<div
className={cn(
"grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden"
)}
>
<div className="flex items-start justify-between gap-x-2 p-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
rootOnlyDefault={rootOnlyDefault}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
</div>
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
isLoading={isLoading}
allowSelection
/>
</div>
)}
</>
)}
</TypedAwait>
</Suspense>
<BulkActionBar />
</div>
)}
@@ -198,7 +249,7 @@ function BulkActionBar() {
initial={{ translateY: "100%" }}
animate={{ translateY: 0 }}
exit={{ translateY: "100%" }}
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright pl-4 pr-3"
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright py-3 pl-4 pr-3"
>
<div className="flex items-center gap-1.5 text-sm text-text-bright">
<ListChecks className="mr-1 size-7 text-indigo-400" />
@@ -216,9 +216,10 @@ export default function Page() {
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
variant="minimal/medium"
LeadingIcon={ExitIcon}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
/>
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
@@ -252,7 +253,7 @@ export default function Page() {
<Property.Label>Timezone</Property.Label>
<Property.Value>{schedule.timezone}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Item className="gap-1">
<Property.Label>Environments</Property.Label>
<Property.Value>
<EnvironmentLabels size="small" environments={schedule.environments} />
@@ -272,7 +273,7 @@ export default function Page() {
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Item className="gap-1.5">
<Property.Label>Status</Property.Label>
<Property.Value>
<EnabledStatus enabled={schedule.active} />
@@ -354,11 +355,11 @@ export default function Page() {
</div>
{isImperative && (
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Form method="post">
<Button
type="submit"
variant="minimal/medium"
variant="tertiary/medium"
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
name="action"
@@ -371,26 +372,24 @@ export default function Page() {
<DialogTrigger asChild>
<Button
type="submit"
variant="minimal/medium"
variant="danger/medium"
LeadingIcon={TrashIcon}
leadingIconClassName="text-error"
className="text-error"
name="action"
value="delete"
>
Delete
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent className="sm:max-w-sm">
<DialogHeader>Delete schedule</DialogHeader>
<DialogDescription>
<DialogDescription className="mt-3">
Are you sure you want to delete this schedule? This can't be reversed.
</DialogDescription>
<DialogFooter>
<DialogFooter className="sm:justify-end">
<Form method="post">
<Button
type="submit"
variant="danger/small"
variant="danger/medium"
LeadingIcon={TrashIcon}
name="action"
value="delete"
@@ -63,6 +63,8 @@ import {
v3SchedulePath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -94,7 +96,6 @@ export default function Page() {
possibleTasks,
possibleEnvironments,
hasFilters,
filters,
limits,
currentPage,
totalPages,
@@ -132,11 +133,20 @@ export default function Page() {
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/tasks/scheduled")}
>
Schedules docs
</LinkButton>
{limits.used >= limits.limit ? (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-background-dimmed"
variant="primary/small"
shortcut={{ key: "n" }}
disabled={possibleTasks.length === 0 || isShowingNewPane}
@@ -177,16 +187,16 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="schedules-main" min={"100px"}>
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
{possibleTasks.length === 0 ? (
<CreateScheduledTaskInstructions />
) : schedules.length === 0 && !hasFilters ? (
<AttachYourFirstScheduleInstructions />
) : (
<div className="p-3">
<div className="mb-2 flex items-center justify-between gap-x-2">
<>
<div className="flex items-center justify-between gap-x-2 p-2">
<ScheduleFilters
possibleEnvironments={possibleEnvironments}
possibleTasks={possibleTasks}
@@ -200,51 +210,73 @@ export default function Page() {
</div>
</div>
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
<div className="mt-3 flex w-full items-start justify-between">
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more schedules"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available schedules. Upgrade your
plan to enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
<div className="flex items-center justify-between gap-6">
<div className="h-fit max-h-full overflow-x-auto">
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
<div className="flex justify-end py-3">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your available schedules. Upgrade your
plan to enable more.
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your schedules.
</Header3>
)}
{canUpgrade ? (
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
<div
className="h-full bg-grid-bright"
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
{canUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
</div>
)}
</div>
)}
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
</div>
</>
)}
</div>
</ResizablePanel>
@@ -270,13 +302,18 @@ function CreateScheduledTaskInstructions() {
icon={ClockIcon}
iconClassName="text-sun-500"
panelClassName="max-w-full"
to={docsPath("v3/tasks-scheduled")}
buttonLabel="Scheduled task docs"
>
<Paragraph variant="small">
<Paragraph spacing variant="small">
You have no scheduled tasks in your project. Before you can schedule a task you need to
create a <InlineCode>schedules.task</InlineCode>.
</Paragraph>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
>
View the docs
</LinkButton>
</InfoPanel>
</MainCenteredContainer>
);
@@ -372,14 +409,14 @@ function SchedulesTable({
deleted from the dashboard or using the SDK.
</Paragraph>
</div>
<div>
<LinkButton
variant="tertiary/medium"
to="https://trigger.dev/docs/v3/tasks-scheduled"
>
View the docs
</LinkButton>
</div>
<LinkButton
variant="docs/small"
to={docsPath("v3/tasks-scheduled")}
LeadingIcon={BookOpenIcon}
className="mb-1"
>
View the docs
</LinkButton>
</div>
}
>
@@ -5,7 +5,7 @@ import { Form, useActionData, useSubmit } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { TaskRunStatus } from "@trigger.dev/database";
import { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { Button } from "~/components/primitives/Buttons";
@@ -32,7 +32,11 @@ import { TextLink } from "~/components/primitives/TextLink";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import { TimezoneList } from "~/components/scheduled/timezones";
import { useSearchParams } from "~/hooks/useSearchParam";
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import {
redirectBackWithErrorMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import {
ScheduledRun,
StandardRun,
@@ -42,7 +46,7 @@ import {
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { TestTaskData } from "~/v3/testTask";
@@ -51,14 +55,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, taskParam } = v3TaskParamsSchema.parse(params);
const presenter = new TestTaskPresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
taskFriendlyId: taskParam,
});
//need an environment
const searchParams = new URL(request.url).searchParams;
const environment = searchParams.get("environment");
if (!environment) {
return redirect(v3TestPath({ slug: organizationSlug }, { slug: projectParam }));
}
return typedjson(result);
const presenter = new TestTaskPresenter();
try {
const result = await presenter.call({
userId,
projectSlug: projectParam,
taskIdentifier: taskParam,
environmentSlug: environment,
});
return typedjson(result);
} catch (error) {
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
request,
`Couldn't load test page for ${taskParam}`
);
}
};
export const action: ActionFunction = async ({ request, params }) => {
@@ -113,16 +133,20 @@ export const action: ActionFunction = async ({ request, params }) => {
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
switch (result.triggerSource) {
if (!result.foundTask) {
return <div></div>;
}
switch (result.task.triggerSource) {
case "STANDARD": {
return <StandardTaskForm task={result.task} runs={result.runs} />;
return <StandardTaskForm task={result.task.task} runs={result.task.runs} />;
}
case "SCHEDULED": {
return (
<ScheduledTaskForm
task={result.task}
runs={result.runs}
possibleTimezones={result.possibleTimezones}
task={result.task.task}
runs={result.task.runs}
possibleTimezones={result.task.possibleTimezones}
/>
);
}
@@ -194,7 +218,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
return (
<Form
className="grid h-full max-h-full grid-rows-[1fr_2.5rem]"
className="grid h-full max-h-full grid-rows-[1fr_auto]"
method="post"
{...form.props}
onSubmit={(e) => submitForm(e)}
@@ -202,7 +226,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
<input type="hidden" name="triggerSource" value={"STANDARD"} />
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel id="test-task-main" min="100px" default="60%">
<div className="h-full bg-charcoal-900">
<div className="flex h-full flex-col overflow-hidden bg-charcoal-900">
<TabContainer className="px-3 pt-2">
<TabButton
isActive={!tab || tab === "payload"}
@@ -224,7 +248,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
Metadata
</TabButton>
</TabContainer>
<div>
<div className="flex-1 overflow-hidden">
<JSONEditor
defaultValue={defaultPayloadJson}
readOnly={false}
@@ -241,11 +265,9 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
}
}}
height="100%"
min-height="100%"
max-height="100%"
autoFocus={!tab || tab === "payload"}
placeholder="{ }"
className={cn("h-full", tab === "metadata" && "hidden")}
className={cn("h-full overflow-auto", tab === "metadata" && "hidden")}
/>
<JSONEditor
defaultValue={defaultMetadataJson}
@@ -263,11 +285,9 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
}
}}
height="100%"
min-height="100%"
max-height="100%"
autoFocus={tab === "metadata"}
placeholder=""
className={cn("h-full", tab !== "metadata" && "hidden")}
className={cn("h-full overflow-auto", tab !== "metadata" && "hidden")}
/>
</div>
</div>
@@ -287,16 +307,16 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
<div className="flex items-center justify-end gap-3 border-t border-grid-bright bg-background-dimmed p-2">
<div className="flex items-center gap-1">
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
</Paragraph>
<EnvironmentLabel environment={task.environment} />
<EnvironmentLabel environment={task.environment} size="large" />
</div>
<Button
type="submit"
variant="primary/small"
variant="primary/medium"
LeadingIcon={BeakerIcon}
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
>
@@ -358,7 +378,7 @@ function ScheduledTaskForm({
});
return (
<Form className="grid h-full max-h-full grid-rows-[1fr_2.5rem]" method="post" {...form.props}>
<Form className="grid h-full max-h-full grid-rows-[1fr_auto]" method="post" {...form.props}>
<input
type="hidden"
{...conform.input(triggerSource, { type: "hidden" })}
@@ -392,6 +412,7 @@ function ScheduledTaskForm({
granularity="second"
showNowButton
variant="medium"
utc
/>
<Hint>
This is the timestamp of the CRON, it will come through to your run in the
@@ -416,6 +437,7 @@ function ScheduledTaskForm({
showNowButton
showClearButton
variant="medium"
utc
/>
<Hint>
This is the timestamp of the previous run. You can use this in your code to find
@@ -484,7 +506,7 @@ function ScheduledTaskForm({
/>
</ResizablePanel>
</ResizablePanelGroup>
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed p-2">
<div className="flex items-center gap-1">
<Paragraph variant="small" className="whitespace-nowrap">
This test will run in
@@ -519,7 +541,7 @@ function RecentPayloads({
onSelected: (id: string) => void;
}) {
return (
<div className="flex flex-col gap-2 pl-4">
<div className="flex flex-col gap-2 px-3">
<div className="flex h-10 items-center border-b border-grid-dimmed">
<Header2>Recent payloads</Header2>
</div>
@@ -1,3 +1,4 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -8,9 +9,11 @@ import {
environmentTitle,
} from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import {
@@ -42,7 +45,7 @@ import {
} from "~/presenters/v3/TestPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
import { docsPath, ProjectParamSchema, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
export const TestSearchParams = z.object({
environment: z.string().optional(),
@@ -75,18 +78,23 @@ export default function Page() {
const navigation = useNavigation();
const location = useLocation();
const locationSearchParams = new URLSearchParams(location.search);
const navigationSearchParams = new URLSearchParams(navigation.location?.search);
const currentEnvironment = new URLSearchParams(location.search).get("environment");
const pendingEnvironment = new URLSearchParams(navigation.location?.search).get("environment");
const isLoadingTasks =
navigation.state === "loading" &&
navigation.location.pathname === location.pathname &&
navigationSearchParams.get("environment") !== locationSearchParams.get("environment");
currentEnvironment !== pendingEnvironment;
return (
<PageContainer>
<NavBar>
<PageTitle title="Test" />
<PageAccessories>
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/run-tests")}>
Test docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className={cn("grid h-full max-h-full grid-cols-1")}>
@@ -106,10 +114,19 @@ export default function Page() {
"flex h-8 flex-1 items-center justify-center rounded-sm border text-xs uppercase tracking-wider",
isSelected
? cn(environmentBorderClassName(env), environmentTextClassName(env))
: "border-grid-bright text-text-dimmed"
: "border-grid-bright text-text-dimmed transition hover:border-charcoal-600 hover:text-text-bright"
)}
key={env.id}
to={v3TestPath(organization, project, env.slug)}
to={
taskParam
? v3TestTaskPath(
organization,
project,
{ taskIdentifier: taskParam },
env.slug
)
: v3TestPath(organization, project, env.slug)
}
>
<span>{environmentTitle(env)}</span>
</Link>
@@ -122,8 +139,8 @@ export default function Page() {
<Spinner />
</div>
) : hasSelectedEnvironment ? (
<div className="grid grid-rows-[2rem_1fr] overflow-hidden">
<div className="mx-3 flex items-end">
<div className="grid grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-end px-3 pt-2">
<Header2>Select a task</Header2>
</div>
{!rest.tasks?.length ? (
@@ -132,6 +149,7 @@ export default function Page() {
<TaskSelector
tasks={rest.tasks}
environmentSlug={rest.selectedEnvironment.slug}
activeTaskIdentifier={taskParam}
/>
)}
</div>
@@ -154,14 +172,24 @@ export default function Page() {
function TaskSelector({
tasks,
environmentSlug,
activeTaskIdentifier,
}: {
tasks: TaskListItem[];
environmentSlug: string;
activeTaskIdentifier?: string;
}) {
const { filterText, setFilterText, filteredItems } = useFilterTasks<TaskListItem>({ tasks });
const hasTaskInEnvironment = activeTaskIdentifier
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
: undefined;
return (
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div
className={cn(
"grid max-h-full overflow-hidden",
hasTaskInEnvironment === false ? "grid-rows-[auto_auto_1fr]" : "grid-rows-[auto_1fr]"
)}
>
<div className="p-2">
<Input
placeholder="Search tasks"
@@ -173,13 +201,19 @@ function TaskSelector({
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
{hasTaskInEnvironment === false && (
<div className="px-2 pb-2">
<Callout variant="warning">
There is no task {activeTaskIdentifier} in the selected environment.
</Callout>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell className="px-2">
<span className="sr-only">Go to test task</span>
<TableHeaderCell className="pl-3" colSpan={2}>
Task
</TableHeaderCell>
<TableHeaderCell className="px-2">Task</TableHeaderCell>
<TableHeaderCell className="px-2">File path</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -204,9 +238,9 @@ function TaskSelector({
function NoTaskInstructions({ environment }: { environment?: SelectedEnvironment }) {
return (
<div className="px-3 py-3">
<Paragraph spacing variant="small">
<Callout variant="info">
You have no tasks {environment ? `in ${environmentTitle(environment)}` : ""}.
</Paragraph>
</Callout>
</div>
);
}
@@ -217,23 +251,29 @@ function TaskRow({ task, environmentSlug }: { task: TaskListItem; environmentSlu
const path = v3TestTaskPath(organization, project, task, environmentSlug);
const { isActive, isPending } = useLinkStatus(path);
return (
<TableRow
key={task.taskIdentifier}
className={cn(
(isActive || isPending) &&
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
)}
className={cn((isActive || isPending) && "bg-indigo-500/10")}
>
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
<TableCell
to={path}
actionClassName="pl-2.5 pr-2 py-1"
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
>
<RadioButtonCircle checked={isActive || isPending} />
</TableCell>
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
<TableCell
to={path}
actionClassName="pl-1 pr-2 py-1.5"
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
>
<div className="flex flex-col gap-0.5">
<TaskFunctionName
variant="extra-small"
functionName={task.exportName}
className="-ml-1 inline-flex"
className="inline-flex w-fit"
/>
<div className="flex items-start gap-1">
<TaskTriggerSourceIcon source={task.triggerSource} className="size-3.5" />
@@ -244,7 +284,11 @@ function TaskRow({ task, environmentSlug }: { task: TaskListItem; environmentSlu
</div>
</TableCell>
<TableCell to={path} actionClassName="px-2 py-1">
<TableCell
to={path}
actionClassName="px-2 py-1"
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
>
{task.filePath}
</TableCell>
</TableRow>
@@ -87,6 +87,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "utc",
});
export default function Page() {
@@ -101,13 +102,13 @@ export default function Page() {
<NavBar>
<PageTitle title="Usage" />
</NavBar>
<PageBody scrollable={true}>
<PageBody scrollable={true} className="p-0">
<div className="flex flex-col gap-6">
<div>
<Select
name="month"
placeholder="Select a month"
className="mb-3"
className="m-3"
defaultValue={month}
items={months.map((date) => ({
label: monthDateFormatter.format(date),
@@ -118,7 +119,7 @@ export default function Page() {
replace({ month: value });
}}
dropdownIcon
variant="tertiary/medium"
variant="tertiary/small"
>
{(matches) =>
matches.map((month) => (
@@ -128,7 +129,7 @@ export default function Page() {
))
}
</Select>
<div className="flex w-full flex-col gap-2 rounded-sm border border-grid-dimmed p-4">
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
<Suspense fallback={<Spinner />}>
<Await
resolve={usage}
@@ -139,11 +140,11 @@ export default function Page() {
}
>
{(usage) => (
<div className="flex items-center gap-8">
<div className="flex flex-col gap-2">
<Header3 className="whitespace-nowrap">
<div className="flex items-end gap-8">
<div className="flex flex-col gap-1">
<Header2 className="whitespace-nowrap">
{isCurrentMonth ? "Month-to-date" : "Usage"}
</Header3>
</Header2>
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
{formatCurrency(usage.overall.current, false)}
</p>
@@ -164,8 +165,10 @@ export default function Page() {
</div>
</div>
<div>
<Header2 spacing>Usage by day</Header2>
<div className="rounded-sm border border-grid-dimmed p-4">
<Header2 spacing className="pl-3">
Usage by day
</Header2>
<div className="p-3">
<Suspense
fallback={
<div className="flex min-h-40 items-center justify-center">
@@ -187,7 +190,9 @@ export default function Page() {
</div>
</div>
<div>
<Header2 spacing>Tasks</Header2>
<Header2 spacing className="pl-3">
Tasks
</Header2>
<Suspense fallback={<Spinner />}>
<Await
resolve={tasks}
@@ -215,8 +220,10 @@ export default function Page() {
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<div className="flex items-center justify-center">
<Paragraph variant="small">No runs for this period</Paragraph>
<div className="flex items-center justify-center py-8">
<Paragraph variant="base/bright">
No runs for this period
</Paragraph>
</div>
</TableCell>
</TableRow>
@@ -248,7 +255,11 @@ export default function Page() {
)}
</TableBody>
</Table>
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem] mt-3">
<InfoPanel
icon={InformationCircleIcon}
variant="minimal"
panelClassName="max-w-full"
>
Dev environment runs are excluded from the usage data above, since they do
not have an associated compute cost.
</InfoPanel>
+68 -30
View File
@@ -1,13 +1,14 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useFetcher } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
@@ -27,10 +28,12 @@ import {
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
CreatedPersonalAccessToken,
@@ -40,7 +43,7 @@ import {
revokePersonalAccessToken,
} from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { personalAccessTokensPath } from "~/utils/pathBuilder";
import { docsPath, personalAccessTokensPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -127,6 +130,13 @@ export default function Page() {
<NavBar>
<PageTitle title="Personal Access Tokens" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("management/overview#personal-access-token-pat")}
variant="docs/small"
>
Personal Access Token docs
</LinkButton>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small">Create new token</Button>
@@ -139,9 +149,9 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody>
<div className="flex flex-col gap-3">
<Table>
<PageBody scrollable={false}>
<div className="grid max-h-full grid-rows-1">
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
@@ -168,15 +178,19 @@ export default function Page() {
"Never"
)}
</TableCell>
<TableCell alignment="right">
<RevokePersonalAccessToken token={personalAccessToken} />
</TableCell>
<TableCellMenu
isSticky
visibleButtons={<RevokePersonalAccessToken token={personalAccessToken} />}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={5}>
<Paragraph variant="small" className="flex items-center justify-center">
<Paragraph
variant="base/bright"
className="flex items-center justify-center py-8"
>
You have no Personal Access Tokens (that haven't been revoked).
</Paragraph>
</TableBlankRow>
@@ -249,6 +263,11 @@ function CreatePersonalAccessToken() {
Update
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</fetcher.Form>
@@ -270,25 +289,44 @@ function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessT
});
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>
<SimpleTooltip
button={
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-error transition hover:bg-charcoal-700"
>
<TrashIcon className="size-3" />
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Revoke Personal Access Token</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph spacing>
Are you sure you want to revoke "{token.name}"? This can't be reversed.
</Paragraph>
<FormButtons
confirmButton={
<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">
Revoke token
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
}
content="Revoke token…"
side="left"
disableHoverableContent
/>
);
}
@@ -0,0 +1,39 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: (params, auth) => {
return $replica.batchTaskRun.findFirst({
where: {
friendlyId: params.batchId,
runtimeEnvironmentId: auth.environment.id,
},
});
},
authorization: {
action: "read",
resource: (batch) => ({ batch: batch.friendlyId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ resource: batch }) => {
return json({
id: batch.friendlyId,
status: batch.status,
idempotencyKey: batch.idempotencyKey ?? undefined,
createdAt: batch.createdAt,
updatedAt: batch.updatedAt,
runCount: batch.runCount,
});
}
);
+24 -23
View File
@@ -2,6 +2,7 @@ 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 { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { generatePresignedUrl } from "~/v3/r2.server";
const ParamsSchema = z.object({
@@ -39,28 +40,28 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ presignedUrl });
}
export async function loader({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
async ({ params, authentication }) => {
const filename = params["*"];
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
const presignedUrl = await generatePresignedUrl(
authentication.environment.project.externalRef,
authentication.environment.slug,
filename,
"GET"
);
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
// Caller can now use this URL to fetch that object.
return json({ presignedUrl });
}
const parsedParams = ParamsSchema.parse(params);
const filename = parsedParams["*"];
const presignedUrl = await generatePresignedUrl(
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"GET"
);
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
// Caller can now use this URL to fetch that object.
return json({ presignedUrl });
}
);
@@ -5,7 +5,7 @@ import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -61,8 +61,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "An unknown error occurred" }, { status: 500 });
}
const run = await ApiRetrieveRunPresenter.findRun(
updatedRun.friendlyId,
authenticationResult.environment
);
if (!run) {
return json({ error: "Run not found" }, { status: 404 });
}
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment);
const result = await presenter.call(run, authenticationResult.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
+3 -2
View File
@@ -3,7 +3,7 @@ import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
{
@@ -12,9 +12,10 @@ export const loader = createLoaderApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
superScopes: ["read:runs", "read:all", "admin"],
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
async ({ searchParams, authentication }) => {
const presenter = new ApiRunListPresenter();
@@ -1,6 +1,5 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { assertExhaustive } from "@trigger.dev/core";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -69,9 +68,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ action: "SET", key: decodedKey, value: setValue });
}
default: {
assertExhaustive(parsedMethod.data);
}
}
} catch (error) {
if (error instanceof Error) {
@@ -136,9 +132,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return new Response("Key found", { status: 200 });
}
default: {
assertExhaustive(parsedMethod.data);
}
}
} catch (error) {
if (error instanceof Error) {
@@ -1,15 +1,14 @@
import { fromZodError } from "zod-validation-error";
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { generateJWT as internal_generateJWT, TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
import { startActiveSpan } from "~/v3/tracer.server";
const ParamsSchema = z.object({
taskId: z.string(),
@@ -17,118 +16,137 @@ const ParamsSchema = z.object({
export const HeadersSchema = z.object({
"idempotency-key": z.string().nullish(),
"idempotency-key-ttl": z.string().nullish(),
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
"x-trigger-worker": z.string().nullish(),
"x-trigger-client": z.string().nullish(),
traceparent: z.string().optional(),
tracestate: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
params: ParamsSchema,
body: TriggerTaskRequestBody,
allowJWT: true,
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "trigger",
resource: (params) => ({ tasks: params.taskId }),
superScopes: ["write:tasks", "admin"],
},
corsStrategy: "all",
},
async ({ body, headers, params, authentication }) => {
const {
"idempotency-key": idempotencyKey,
"idempotency-key-ttl": idempotencyKeyTTL,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
"x-trigger-client": triggerClient,
} = headers;
const service = new TriggerTaskService();
try {
const traceContext =
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
const oneTimeUseToken = await getOneTimeUseToken(authentication);
logger.debug("Triggering task", {
taskId: params.taskId,
idempotencyKey,
idempotencyKeyTTL,
triggerVersion,
headers,
options: body.options,
isFromWorker,
traceContext,
});
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
const run = await service.call(params.taskId, authentication.environment, body, {
idempotencyKey: idempotencyKey ?? undefined,
idempotencyKeyExpiresAt: idempotencyKeyExpiresAt,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
oneTimeUseToken,
});
if (!run) {
return json({ error: "Task not found" }, { status: 404 });
}
const $responseHeaders = await responseHeaders(
run,
authentication.environment,
triggerClient
);
return json(
{
id: run.friendlyId,
},
{
headers: $responseHeaders,
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
return json({ error: "Request body too large" }, { status: 413 });
}
const rawHeaders = Object.fromEntries(request.headers);
const headers = HeadersSchema.safeParse(rawHeaders);
if (!headers.success) {
return json({ error: "Invalid headers" }, { status: 400 });
}
const {
"idempotency-key": idempotencyKey,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
} = headers.data;
const { taskId } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await parseRequestJsonAsync(request, { taskId });
const body = await startActiveSpan("TriggerTaskRequestBody.safeParse()", async (span) => {
return TriggerTaskRequestBody.safeParse(anyBody);
async function responseHeaders(
run: TaskRun,
environment: AuthenticatedEnvironment,
triggerClient?: string | null
): Promise<Record<string, string>> {
const claimsHeader = JSON.stringify({
sub: environment.id,
pub: true,
});
if (!body.success) {
return json(
{ error: fromZodError(body.error, { prefix: "Invalid trigger call" }).toString() },
{ status: 400 }
);
}
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`],
};
const service = new TriggerTaskService();
try {
const traceContext =
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
logger.debug("Triggering task", {
taskId,
idempotencyKey,
triggerVersion,
headers: Object.fromEntries(request.headers),
options: body.data.options,
isFromWorker,
traceContext,
const jwt = await internal_generateJWT({
secretKey: environment.apiKey,
payload: claims,
expirationTime: "1h",
});
const run = await service.call(taskId, authenticationResult.environment, body.data, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
if (!run) {
return json({ error: "Task not found" }, { status: 404 });
}
return json(
{
id: run.friendlyId,
},
{
headers: {
"x-trigger-jwt-claims": JSON.stringify({
sub: authenticationResult.environment.id,
pub: true,
}),
},
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
};
}
export { action, loader };
@@ -0,0 +1,159 @@
import { json } from "@remix-run/server-runtime";
import {
BatchTriggerTaskResponse,
BatchTriggerTaskV2RequestBody,
BatchTriggerTaskV2Response,
generateJWT,
} from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
body: BatchTriggerTaskV2RequestBody,
allowJWT: true,
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "batchTrigger",
resource: (_, __, ___, body) => ({
tasks: Array.from(new Set(body.items.map((i) => i.task))),
}),
superScopes: ["write:tasks", "admin"],
},
corsStrategy: "all",
},
async ({ body, headers, params, authentication }) => {
if (!body.items.length) {
return json({ error: "Batch cannot be triggered with no items" }, { status: 400 });
}
// Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items
if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) {
return json(
{
error: `Batch size of ${body.items.length} is too large. Maximum allowed batch size is ${env.MAX_BATCH_V2_TRIGGER_ITEMS}.`,
},
{ status: 400 }
);
}
const {
"idempotency-key": idempotencyKey,
"idempotency-key-ttl": idempotencyKeyTTL,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
"x-trigger-worker": isFromWorker,
"x-trigger-client": triggerClient,
traceparent,
tracestate,
} = headers;
const oneTimeUseToken = await getOneTimeUseToken(authentication);
logger.debug("Batch trigger request", {
idempotencyKey,
idempotencyKeyTTL,
triggerVersion,
spanParentAsLink,
isFromWorker,
triggerClient,
traceparent,
tracestate,
});
const traceContext =
traceparent && isFromWorker // If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
// By default, the idempotency key expires in 30 days
const idempotencyKeyExpiresAt =
resolveIdempotencyKeyTTL(idempotencyKeyTTL) ??
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30);
const service = new BatchTriggerV2Service();
try {
const batch = await service.call(authentication.environment, body, {
idempotencyKey: idempotencyKey ?? undefined,
idempotencyKeyExpiresAt,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
oneTimeUseToken,
});
const $responseHeaders = await responseHeaders(
batch,
authentication.environment,
triggerClient
);
return json(batch, { status: 202, headers: $responseHeaders });
} catch (error) {
logger.error("Batch trigger error", {
error: {
message: (error as Error).message,
stack: (error as Error).stack,
},
});
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json(
{ error: error.message },
{ status: 500, headers: { "x-should-retry": "false" } }
);
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
async function responseHeaders(
batch: BatchTriggerTaskV2Response,
environment: AuthenticatedEnvironment,
triggerClient?: string | null
): Promise<Record<string, string>> {
const claimsHeader = JSON.stringify({
sub: environment.id,
pub: true,
});
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:batch:${batch.id}`],
};
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: claims,
expirationTime: "1h",
});
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
};
}
export { action, loader };
+16 -5
View File
@@ -1,7 +1,7 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -12,18 +12,29 @@ export const loader = createLoaderApiRoute(
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: (params, auth) => {
return ApiRetrieveRunPresenter.findRun(params.runId, auth.environment);
},
authorization: {
action: "read",
resource: (params) => ({ runs: params.runId }),
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication }) => {
async ({ authentication, resource }) => {
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(params.runId, authentication.environment);
const result = await presenter.call(resource, authentication.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
return json(
{ error: "Run not found" },
{ status: 404, headers: { "x-should-retry": "true" } }
);
}
return json(result);
@@ -1,8 +1,7 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
@@ -13,24 +12,26 @@ export const loader = createLoaderApiRoute(
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: (params, auth) => {
return $replica.batchTaskRun.findFirst({
where: {
friendlyId: params.batchId,
runtimeEnvironmentId: auth.environment.id,
},
});
},
authorization: {
action: "read",
resource: (params) => ({ batch: params.batchId }),
resource: (batch) => ({ batch: batch.friendlyId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const batchRun = await $replica.batchTaskRun.findFirst({
where: {
friendlyId: params.batchId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!batchRun) {
return json({ error: "Batch not found" }, { status: 404 });
}
return realtimeClient.streamBatch(request.url, authentication.environment, batchRun.id);
async ({ authentication, request, resource: batchRun }) => {
return realtimeClient.streamBatch(
request.url,
authentication.environment,
batchRun.id,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -13,24 +13,38 @@ export const loader = createLoaderApiRoute(
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, authentication) => {
return $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: authentication.environment.id,
},
include: {
batch: {
select: {
friendlyId: true,
},
},
},
});
},
authorization: {
action: "read",
resource: (params) => ({ runs: params.runId }),
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!run) {
return json({ error: "Run not found" }, { status: 404 });
}
return realtimeClient.streamRun(request.url, authentication.environment, run.id);
async ({ authentication, request, resource: run }) => {
return realtimeClient.streamRun(
request.url,
authentication.environment,
run.id,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
+9 -3
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
@@ -16,13 +16,19 @@ export const loader = createLoaderApiRoute(
searchParams: SearchParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1, // This is a dummy value, it's not used
authorization: {
action: "read",
resource: (_, searchParams) => searchParams,
resource: (_, __, searchParams) => searchParams,
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ searchParams, authentication, request }) => {
return realtimeClient.streamRuns(request.url, authentication.environment, searchParams);
return realtimeClient.streamRuns(
request.url,
authentication.environment,
searchParams,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
@@ -0,0 +1,56 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeStreams } from "~/services/realtimeStreamsGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const $params = ParamsSchema.parse(params);
if (!request.body) {
return new Response("No body provided", { status: 400 });
}
return realtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
}
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async (params, auth) => {
return $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
},
include: {
batch: {
select: {
friendlyId: true,
},
},
},
});
},
authorization: {
action: "read",
resource: (run) => ({
runs: run.friendlyId,
tags: run.runTags,
batch: run.batch?.friendlyId,
tasks: run.taskIdentifier,
}),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, request, resource: run }) => {
return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal);
}
);
@@ -0,0 +1,70 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { assertExhaustive } from "@trigger.dev/core";
import { z } from "zod";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
export const checkCompletionSchema = z.object({
redirectUrl: z.string(),
});
const ParamSchema = z.object({
batchId: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const { batchId } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: checkCompletionSchema });
if (!submission.value) {
return json(submission);
}
try {
const resumeBatchRunService = new ResumeBatchRunService();
const resumeResult = await resumeBatchRunService.call(batchId);
let message: string | undefined;
switch (resumeResult) {
case "ERROR": {
throw "Unknown error during batch completion check";
}
case "ALREADY_COMPLETED": {
message = "Batch already completed.";
break;
}
case "COMPLETED": {
message = "Batch completed and parent tasks resumed.";
break;
}
case "PENDING": {
message = "Child runs still in progress. Please try again later.";
break;
}
default: {
assertExhaustive(resumeResult);
}
}
return redirectWithSuccessMessage(submission.value.redirectUrl, request, message);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to check batch completion", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return redirectWithErrorMessage(submission.value.redirectUrl, request, error.message);
} else {
logger.error("Failed to check batch completion", { error });
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Unknown error");
}
}
};
@@ -80,7 +80,7 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
}, []);
return (
<div>
<div className="max-w-md">
<Label>
<AISparkleIcon className="inline-block h-4 w-4" /> Describe your schedule using natural
language
@@ -95,7 +95,7 @@ export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
placeholder="e.g. the last Friday of the month at 6am"
onChange={(e) => setText(e.target.value)}
rows={3}
className="m-0 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
className="m-0 min-h-10 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="flex justify-end gap-2 px-2 pb-2">
<Button
@@ -383,7 +383,7 @@ export function UpsertScheduleForm({
<div className="flex items-center gap-4">
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
variant="minimal/medium"
variant="tertiary/medium"
>
Cancel
</LinkButton>
@@ -56,6 +56,8 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import {
v3BatchPath,
v3BatchRunsPath,
v3RunDownloadLogsPath,
v3RunPath,
v3RunSpanPath,
@@ -583,6 +585,21 @@ function RunBody({
</>
)
) : null}
{run.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink to={v3BatchPath(organization, project, run.batch)}>
{run.batch.friendlyId}
</TextLink>
}
content={`Jump to ${run.batch.friendlyId}`}
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
@@ -190,6 +190,11 @@ const pricingDefinitions = {
content:
"A single email address, Slack channel, or webhook URL that you want to send alerts to.",
},
realtime: {
title: "Realtime connections",
content:
"Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
},
};
type PricingPlansProps = {
@@ -494,6 +499,7 @@ export function TierFree({
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
</ul>
</>
)}
@@ -608,6 +614,7 @@ export function TierHobby({
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
</ul>
</TierContainer>
);
@@ -678,6 +685,7 @@ export function TierPro({
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
<RealtimeConnecurrency limits={plan.limits} />
</ul>
</TierContainer>
);
@@ -950,3 +958,18 @@ function Alerts({ limits }: { limits: Limits }) {
</FeatureItem>
);
}
function RealtimeConnecurrency({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.realtimeConcurrentConnections.number}
{limits.realtimeConcurrentConnections.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.realtime.title}
content={pricingDefinitions.realtime.content}
>
concurrent Realtime connections
</DefinitionTip>
</FeatureItem>
);
}
+172 -7
View File
@@ -20,6 +20,8 @@ import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
// One-time use token
otu: z.boolean().optional(),
});
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
@@ -29,29 +31,71 @@ export type AuthenticatedEnvironment = Optional<
"orgMember"
>;
export type ApiAuthenticationResult = {
export type ApiAuthenticationResult =
| ApiAuthenticationResultSuccess
| ApiAuthenticationResultFailure;
export type ApiAuthenticationResultSuccess = {
ok: true;
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
scopes?: string[];
oneTimeUse?: boolean;
};
export type ApiAuthenticationResultFailure = {
ok: false;
error: string;
};
/**
* @deprecated Use `authenticateApiRequestWithFailure` instead.
*/
export async function authenticateApiRequest(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
): Promise<ApiAuthenticationResultSuccess | undefined> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, options);
const authentication = await authenticateApiKey(apiKey, options);
return authentication;
}
/**
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
export async function authenticateApiRequestWithFailure(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return {
ok: false,
error: "Invalid API Key",
};
}
const authentication = await authenticateApiKeyWithFailure(apiKey, options);
return authentication;
}
/**
* @deprecated Use `authenticateApiKeyWithFailure` instead.
*/
export async function authenticateApiKey(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
): Promise<ApiAuthenticationResultSuccess | undefined> {
const result = getApiKeyResult(apiKey);
if (!result) {
@@ -69,16 +113,24 @@ export async function authenticateApiKey(
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
if (!environment) return;
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const environment = await findEnvironmentByApiKey(result.apiKey);
if (!environment) return;
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
@@ -86,16 +138,100 @@ export async function authenticateApiKey(
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults) {
if (!validationResults.ok) {
return;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
};
}
}
}
/**
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
export async function authenticateApiKeyWithFailure(
apiKey: string,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult> {
const result = getApiKeyResult(apiKey);
if (!result) {
return {
ok: false,
error: "Invalid API Key",
};
}
if (!options.allowPublicKey && result.type === "PUBLIC") {
return {
ok: false,
error: "Public API keys are not allowed for this request",
};
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return {
ok: false,
error: "Public JWT API keys are not allowed for this request",
};
}
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey);
if (!environment) {
return {
ok: false,
error: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const environment = await findEnvironmentByApiKey(result.apiKey);
if (!environment) {
return {
ok: false,
error: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults.ok) {
return validationResults;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
};
}
}
@@ -206,6 +342,10 @@ export async function authenticatedEnvironmentForAuthentication(
switch (auth.type) {
case "apiKey": {
if (!auth.result.ok) {
throw json({ error: auth.result.error }, { status: 401 });
}
if (auth.result.environment.project.externalRef !== projectRef) {
throw json(
{
@@ -336,6 +476,14 @@ export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
return;
}
if (!authenticatedEnv.ok) {
logger.error("Failed to renew JWT token, invalid API key", {
error: error.message,
});
return;
}
const payload = payloadSchema.safeParse(error.payload);
if (!payload.success) {
@@ -388,3 +536,20 @@ function calculateJWTExpiration() {
return (Date.now() + DEFAULT_JWT_EXPIRATION_IN_MS) / 1000;
}
export async function getOneTimeUseToken(
auth: ApiAuthenticationResultSuccess
): Promise<string | undefined> {
if (auth.type !== "PUBLIC_JWT") {
return;
}
if (!auth.oneTimeUse) {
return;
}
// Hash the API key to make it unique
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth.apiKey));
return Buffer.from(hash).toString("hex");
}
@@ -29,7 +29,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
allowJWT: true,
});
if (!authenticatedEnv) {
if (!authenticatedEnv || !authenticatedEnv.ok) {
return;
}
@@ -1,4 +1,4 @@
export type AuthorizationAction = "read"; // Add more actions as needed
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
@@ -35,36 +35,45 @@ export type AuthorizationEntity = {
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
* ```
*/
export type AuthorizationResult = { authorized: true } | { authorized: false; reason: string };
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*/
export function checkAuthorization(
entity: AuthorizationEntity,
action: AuthorizationAction,
resource: AuthorizationResources,
superScopes?: string[]
) {
): AuthorizationResult {
// "PRIVATE" is a secret key and has access to everything
if (entity.type === "PRIVATE") {
return true;
return { authorized: true };
}
// "PUBLIC" is a deprecated key and has no access
if (entity.type === "PUBLIC") {
return false;
return { authorized: false, reason: "PUBLIC type is deprecated and has no access" };
}
// If the entity has no permissions, deny access
if (!entity.scopes || entity.scopes.length === 0) {
return false;
return {
authorized: false,
reason:
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
// If the resource object is empty, deny access
if (Object.keys(resource).length === 0) {
return false;
return { authorized: false, reason: "Resource object is empty" };
}
// Check for any of the super scopes
if (superScopes && superScopes.length > 0) {
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
return true;
return { authorized: true };
}
}
@@ -79,25 +88,26 @@ export function checkAuthorization(
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
let resourceAuthorized = false;
for (const value of resourceValues) {
// Check for specific resource permission
const specificPermission = `${action}:${resourceType}:${value}`;
// Check for general resource type permission
const generalPermission = `${action}:${resourceType}`;
// If any permission matches, return authorized
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
resourceAuthorized = true;
break;
return { authorized: true };
}
}
// If any resource is not authorized, return false
if (!resourceAuthorized) {
return false;
}
}
// All resources are authorized
return true;
// No matching permissions found
return {
authorized: false,
reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes
.map((s) => `'${s}'`)
.join(
", "
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
@@ -4,6 +4,7 @@ export type HttpLocalStorage = {
requestId: string;
path: string;
host: string;
method: string;
};
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
@@ -0,0 +1,44 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { env } from "~/env.server";
export const uiPreferencesStorage = createCookieSessionStorage({
cookie: {
name: "__ui_prefs",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [env.SESSION_SECRET],
secure: env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
},
});
export function getUiPreferencesSession(request: Request) {
return uiPreferencesStorage.getSession(request.headers.get("Cookie"));
}
export async function getUsefulLinksPreference(request: Request): Promise<boolean | undefined> {
const session = await getUiPreferencesSession(request);
return session.get("showUsefulLinks");
}
export async function setUsefulLinksPreference(show: boolean, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("showUsefulLinks", show);
return session;
}
export async function getRootOnlyFilterPreference(request: Request): Promise<boolean> {
const session = await getUiPreferencesSession(request);
const rootOnly = session.get("rootOnly");
if (rootOnly === undefined) {
return false;
}
return rootOnly;
}
export async function setRootOnlyFilterPreference(rootOnly: boolean, request: Request) {
const session = await getUiPreferencesSession(request);
session.set("rootOnly", rootOnly);
return session;
}
@@ -1,7 +1,22 @@
import { json } from "@remix-run/server-runtime";
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import { AuthenticatedEnvironment } from "../apiAuth.server";
export async function validatePublicJwtKey(token: string) {
export type ValidatePublicJwtKeySuccess = {
ok: true;
environment: AuthenticatedEnvironment;
claims: Record<string, unknown>;
};
export type ValidatePublicJwtKeyError = {
ok: false;
error: string;
};
export type ValidatePublicJwtKeyResult = ValidatePublicJwtKeySuccess | ValidatePublicJwtKeyError;
export async function validatePublicJwtKey(token: string): Promise<ValidatePublicJwtKeyResult> {
// Get the sub claim from the token
// Use the sub claim to find the environment
// Validate the token against the environment.apiKey
@@ -9,24 +24,46 @@ export async function validatePublicJwtKey(token: string) {
const sub = extractJWTSub(token);
if (!sub) {
return;
return { ok: false, error: "Invalid Public Access Token, missing subject." };
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return;
return { ok: false, error: "Invalid Public Access Token, environment not found." };
}
const claims = await validateJWT(token, environment.apiKey);
const result = await validateJWT(token, environment.apiKey);
if (!claims) {
return;
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
return {
ok: false,
error:
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
case "ERR_JWT_CLAIM_INVALID": {
return {
ok: false,
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
default: {
return {
ok: false,
error:
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
}
}
return {
ok: true,
environment,
claims,
claims: result.payload,
};
}
@@ -37,18 +37,36 @@ export class RealtimeClient {
this.#registerCommands();
}
async streamRun(url: URL | string, environment: RealtimeEnvironment, runId: string) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`);
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
clientVersion?: string
) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`, clientVersion);
}
async streamBatch(url: URL | string, environment: RealtimeEnvironment, batchId: string) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`);
async streamBatch(
url: URL | string,
environment: RealtimeEnvironment,
batchId: string,
clientVersion?: string
) {
const whereClauses: string[] = [
`"runtimeEnvironmentId"='${environment.id}'`,
`"batchId"='${batchId}'`,
];
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause, clientVersion);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams
params: RealtimeRunsParams,
clientVersion?: string
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
@@ -58,54 +76,66 @@ export class RealtimeClient {
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause);
return this.#streamRunsWhere(url, environment, whereClause, clientVersion);
}
async #streamRunsWhere(url: URL | string, environment: RealtimeEnvironment, whereClause: string) {
const electricUrl = this.#constructElectricUrl(url, whereClause);
async #streamRunsWhere(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
clientVersion?: string
) {
const electricUrl = this.#constructElectricUrl(url, whereClause, clientVersion);
return this.#performElectricRequest(electricUrl, environment);
return this.#performElectricRequest(electricUrl, environment, clientVersion);
}
#constructElectricUrl(url: URL | string, whereClause: string): URL {
#constructElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
const $url = new URL(url.toString());
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape/public."TaskRun"`);
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape`);
// Copy over all the url search params to the electric url
$url.searchParams.forEach((value, key) => {
electricUrl.searchParams.set(key, value);
});
// const electricParams = ["shape_id", "live", "offset", "columns", "cursor"];
// electricParams.forEach((param) => {
// if ($url.searchParams.has(param) && $url.searchParams.get(param)) {
// electricUrl.searchParams.set(param, $url.searchParams.get(param)!);
// }
// });
electricUrl.searchParams.set("where", whereClause);
electricUrl.searchParams.set("table", 'public."TaskRun"');
if (!clientVersion) {
// If the client version is not provided, that means we're using an older client
// This means the client will be sending shape_id instead of handle
electricUrl.searchParams.set("handle", electricUrl.searchParams.get("shape_id") ?? "");
}
return electricUrl;
}
async #performElectricRequest(url: URL, environment: RealtimeEnvironment) {
async #performElectricRequest(
url: URL,
environment: RealtimeEnvironment,
clientVersion?: string
) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
const rewriteResponseHeaders: Record<string, string> = clientVersion
? {}
: { "electric-handle": "electric-shape-id", "electric-offset": "electric-chunk-last-offset" };
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return longPollingFetch(url.toString());
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return longPollingFetch(url.toString());
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
}
const requestId = randomUUID();
@@ -147,7 +177,7 @@ export class RealtimeClient {
try {
// ... (rest of your existing code for the long polling request)
const response = await longPollingFetch(url.toString());
const response = await longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
@@ -231,7 +261,7 @@ export class RealtimeClient {
}
function extractShapeId(url: URL) {
return url.searchParams.get("shape_id");
return url.searchParams.get("handle");
}
function isLiveRequestUrl(url: URL) {
@@ -0,0 +1,177 @@
import Redis, { RedisKey, RedisOptions, RedisValue } from "ioredis";
import { logger } from "./logger.server";
export type RealtimeStreamsOptions = {
redis: RedisOptions | undefined;
};
const END_SENTINEL = "<<CLOSE_STREAM>>";
export class RealtimeStreams {
constructor(private options: RealtimeStreamsOptions) {}
async streamResponse(runId: string, streamId: string, signal: AbortSignal): Promise<Response> {
const redis = new Redis(this.options.redis ?? {});
const streamKey = `stream:${runId}:${streamId}`;
let isCleanedUp = false;
const stream = new ReadableStream({
start: async (controller) => {
let lastId = "0";
let retryCount = 0;
const maxRetries = 3;
try {
while (!signal.aborted) {
try {
const messages = await redis.xread(
"COUNT",
100,
"BLOCK",
5000,
"STREAMS",
streamKey,
lastId
);
retryCount = 0;
if (messages && messages.length > 0) {
const [_key, entries] = messages[0];
for (const [id, fields] of entries) {
lastId = id;
if (fields && fields.length >= 2) {
if (fields[1] === END_SENTINEL) {
controller.close();
return;
}
controller.enqueue(`data: ${fields[1]}\n\n`);
if (signal.aborted) {
controller.close();
return;
}
}
}
}
} catch (error) {
if (signal.aborted) break;
logger.error("[RealtimeStreams][streamResponse] Error reading from Redis stream:", {
error,
});
retryCount++;
if (retryCount >= maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
error,
});
controller.error(error);
} finally {
await cleanup();
}
},
cancel: async () => {
await cleanup();
},
});
async function cleanup() {
if (isCleanedUp) return;
isCleanedUp = true;
await redis.quit().catch(console.error);
}
signal.addEventListener("abort", cleanup);
return new Response(stream.pipeThrough(new TextEncoderStream()), {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string
): Promise<Response> {
const redis = new Redis(this.options.redis ?? {});
const streamKey = `stream:${runId}:${streamId}`;
async function cleanup() {
try {
await redis.quit();
} catch (error) {
logger.error("[RealtimeStreams][ingestData] Error in cleanup:", { error });
}
}
try {
// Use TextDecoderStream to simplify text decoding
const textStream = stream.pipeThrough(new TextDecoderStream());
const reader = textStream.getReader();
const batchSize = 10; // Adjust this value based on performance testing
let batchCommands: Array<[key: RedisKey, ...args: RedisValue[]]> = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
logger.debug("[RealtimeStreams][ingestData] Reading data", { streamKey, value });
// 'value' is a string containing the decoded text
const lines = value.split("\n");
for (const line of lines) {
if (line.trim()) {
// Avoid unnecessary parsing; assume 'line' is already a JSON string
// Add XADD command with MAXLEN option to limit stream size
batchCommands.push([streamKey, "MAXLEN", "~", "2500", "*", "data", line]);
if (batchCommands.length >= batchSize) {
// Send batch using a pipeline
const pipeline = redis.pipeline();
for (const args of batchCommands) {
pipeline.xadd(...args);
}
await pipeline.exec();
batchCommands = [];
}
}
}
}
// Send any remaining commands
if (batchCommands.length > 0) {
const pipeline = redis.pipeline();
for (const args of batchCommands) {
pipeline.xadd(...args);
}
await pipeline.exec();
}
// Send the __end message to indicate the end of the stream
await redis.xadd(streamKey, "MAXLEN", "~", "1000", "*", "data", END_SENTINEL);
return new Response(null, { status: 200 });
} catch (error) {
logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
return new Response(null, { status: 500 });
} finally {
await cleanup();
}
}
}
@@ -0,0 +1,19 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { RealtimeStreams } from "./realtimeStreams.server";
function initializeRealtimeStreams() {
return new RealtimeStreams({
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "tr:realtime:streams:",
},
});
}
export const realtimeStreams = singleton("realtimeStreams", initializeRealtimeStreams);
@@ -1,260 +0,0 @@
import { z } from "zod";
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
} from "../personalAccessToken.server";
type ApiKeyRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined
) => AuthorizationResources;
superScopes?: string[];
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: ApiAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
return wrapResponse(
request,
json({ error: "Unauthorized" }, { status: 403 }),
corsStrategy !== "none"
);
}
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
corsStrategy?: "all" | "none";
};
type PATHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
corsStrategy = "none",
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
function wrapResponse(request: Request, response: Response, useCors: boolean) {
return useCors ? apiCors(request, response) : response;
}
@@ -0,0 +1,637 @@
import { z } from "zod";
import {
ApiAuthenticationResultSuccess,
authenticateApiRequestWithFailure,
} from "../apiAuth.server";
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
} from "../personalAccessToken.server";
import { safeJsonParse } from "~/utils/json";
type ApiKeyRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TResource = never
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
findResource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
authentication: ApiAuthenticationResultSuccess
) => Promise<TResource | undefined>;
authorization?: {
action: AuthorizationAction;
resource: (
resource: NonNullable<TResource>,
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined
) => AuthorizationResources;
superScopes?: string[];
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TResource = never
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
resource: NonNullable<TResource>;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TResource = never
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
findResource,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
if (!authenticationResult) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (!authenticationResult.ok) {
return await wrapResponse(
request,
json({ error: authenticationResult.error }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
// Find the resource
const resource = await findResource(parsedParams, authenticationResult);
if (!resource) {
return await wrapResponse(
request,
json({ error: "Not found" }, { status: 404 }),
corsStrategy !== "none"
);
}
if (authorization) {
const { action, resource: authResource, superScopes } = authorization;
const $authResource = authResource(
resource,
parsedParams,
parsedSearchParams,
parsedHeaders
);
logger.debug("Checking authorization", {
action,
resource: $authResource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$authResource,
superScopes
);
if (!authorizationResult.authorized) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
resource,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
corsStrategy?: "all" | "none";
};
type PATHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
authentication: PersonalAccessTokenAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
corsStrategy = "none",
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}
type ApiKeyActionRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined,
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined
) => AuthorizationResources;
superScopes?: string[];
};
maxContentLength?: number;
body?: TBodySchema;
};
type ApiKeyActionHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
}) => Promise<Response>;
export function createActionApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyActionRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>,
handler: ApiKeyActionHandlerFunction<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>
) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
body: bodySchema,
allowJWT = false,
corsStrategy = "none",
authorization,
maxContentLength,
} = options;
async function loader({ request, params }: LoaderFunctionArgs) {
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
return new Response(null, { status: 405 });
}
async function action({ request, params }: ActionFunctionArgs) {
try {
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
if (!authenticationResult) {
return await wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (!authenticationResult.ok) {
return await wrapResponse(
request,
json({ error: authenticationResult.error }, { status: 401 }),
corsStrategy !== "none"
);
}
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > maxContentLength) {
return json({ error: "Request body too large" }, { status: 413 });
}
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
let parsedBody: any = undefined;
if (bodySchema) {
const rawBody = await request.text();
if (rawBody.length === 0) {
return await wrapResponse(
request,
json({ error: "Request body is empty" }, { status: 400 }),
corsStrategy !== "none"
);
}
const rawParsedJson = safeJsonParse(rawBody);
if (!rawParsedJson) {
return await wrapResponse(
request,
json({ error: "Invalid JSON" }, { status: 400 }),
corsStrategy !== "none"
);
}
const body = bodySchema.safeParse(rawParsedJson);
if (!body.success) {
return await wrapResponse(
request,
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
corsStrategy !== "none"
);
}
parsedBody = body.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders, parsedBody);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$resource,
superScopes
);
if (!authorizationResult.authorized) {
return await wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
return { loader, action };
}
async function wrapResponse(
request: Request,
response: Response,
useCors: boolean
): Promise<Response> {
return useCors
? await apiCors(request, response, {
exposedHeaders: ["x-trigger-jwt", "x-trigger-jwt-claims"],
})
: response;
}
+12 -1
View File
@@ -55,6 +55,7 @@ import {
CancelDevSessionRunsServiceOptions,
} from "~/v3/services/cancelDevSessionRuns.server";
import { logger } from "./logger.server";
import { BatchProcessingOptions, BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -197,6 +198,7 @@ const workerCatalog = {
attemptId: z.string(),
}),
"v3.cancelDevSessionRuns": CancelDevSessionRunsServiceOptions,
"v3.processBatchTaskRun": BatchProcessingOptions,
};
const executionWorkerCatalog = {
@@ -561,7 +563,7 @@ function getWorkerQueue() {
handler: async (payload, job) => {
const service = new ResumeBatchRunService();
return await service.call(payload.batchRunId);
await service.call(payload.batchRunId);
},
},
"v3.resumeTaskDependency": {
@@ -727,6 +729,15 @@ function getWorkerQueue() {
return await service.call(payload);
},
},
"v3.processBatchTaskRun": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new BatchTriggerV2Service();
await service.processBatchTaskRun(payload);
},
},
},
});
}
+8
View File
@@ -13,6 +13,14 @@
@apply bg-background-dimmed text-text-dimmed;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* Text selection styles */
::selection {
@apply bg-text-bright/30 text-text-bright;
}
::-moz-selection {
@apply bg-text-bright/30 text-text-bright;
}
/* shadcn charts: https://ui.shadcn.com/docs/components/chart#add-a-grid */
:root {
+1
View File
@@ -8,6 +8,7 @@ type CorsOptions = {
maxAge?: number;
origin?: boolean | string;
credentials?: boolean;
exposedHeaders?: string[];
};
export async function apiCors(
@@ -0,0 +1,41 @@
/**
* Resolve the TTL for an idempotency key.
*
* The TTL format is a string like "5m", "1h", "7d"
*
* @param ttl The TTL string
* @returns The date when the key will expire
* @throws If the TTL string is invalid
*/
export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined {
if (!ttl) {
return undefined;
}
const match = ttl.match(/^(\d+)([smhd])$/);
if (!match) {
return;
}
const [, value, unit] = match;
const now = new Date();
switch (unit) {
case "s":
now.setSeconds(now.getSeconds() + parseInt(value, 10));
break;
case "m":
now.setMinutes(now.getMinutes() + parseInt(value, 10));
break;
case "h":
now.setHours(now.getHours() + parseInt(value, 10));
break;
case "d":
now.setDate(now.getDate() + parseInt(value, 10));
break;
}
return now;
}
+25 -1
View File
@@ -6,7 +6,11 @@
import { logger } from "~/services/logger.server";
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
export async function longPollingFetch(url: string, options?: RequestInit) {
export async function longPollingFetch(
url: string,
options?: RequestInit,
rewriteResponseHeaders?: Record<string, string>
) {
try {
let response = await fetch(url, options);
@@ -14,12 +18,32 @@ export async function longPollingFetch(url: string, options?: RequestInit) {
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("content-length");
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
if (rewriteResponseHeaders) {
const headers = new Headers(response.headers);
for (const [fromKey, toKey] of Object.entries(rewriteResponseHeaders)) {
const value = headers.get(fromKey);
if (value) {
headers.set(toKey, value);
headers.delete(fromKey);
}
}
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return response;
} catch (error) {
if (error instanceof TypeError) {
+26 -4
View File
@@ -24,7 +24,9 @@ export type TriggerForPath = Pick<TriggerSource, "id">;
export type EventForPath = Pick<EventRecord, "id">;
export type WebhookForPath = Pick<Webhook, "id">;
export type HttpEndpointForPath = Pick<TriggerHttpEndpoint, "key">;
export type TaskForPath = Pick<BackgroundWorkerTask, "friendlyId">;
export type TaskForPath = {
taskIdentifier: string;
};
export type v3RunForPath = Pick<TaskRun, "friendlyId">;
export type v3SpanForPath = Pick<TaskRun, "spanId">;
export type DeploymentForPath = Pick<WorkerDeployment, "shortCode">;
@@ -362,9 +364,9 @@ export function v3TestTaskPath(
task: TaskForPath,
environmentSlug: string
) {
return `${v3TestPath(organization, project)}/tasks/${
task.friendlyId
}?environment=${environmentSlug}`;
return `${v3TestPath(organization, project)}/tasks/${encodeURIComponent(
task.taskIdentifier
)}?environment=${environmentSlug}`;
}
export function v3RunsPath(
@@ -435,6 +437,26 @@ export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForP
return `${v3ProjectPath(organization, project)}/schedules/new`;
}
export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/batches`;
}
export function v3BatchPath(
organization: OrgForPath,
project: ProjectForPath,
batch: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/batches?id=${batch.friendlyId}`;
}
export function v3BatchRunsPath(
organization: OrgForPath,
project: ProjectForPath,
batch: { friendlyId: string }
) {
return `${v3ProjectPath(organization, project)}/runs?batchId=${batch.friendlyId}`;
}
export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/settings`;
}

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