Compare commits

...

233 Commits

Author SHA1 Message Date
Matt Aitken d4043fc258 Release 3.3.14 2025-02-11 12:29:54 +00:00
github-actions[bot] 6ec786c7b2 chore: Update version for release (#1666)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-11 12:28:35 +00:00
Matt Aitken d2ee232fae Revert "Complete the original attempt span if retrying due to an OOM"
This reverts commit 5f652c6212.
2025-02-11 11:33:07 +00:00
Matt Aitken 5f652c6212 Complete the original attempt span if retrying due to an OOM 2025-02-11 11:01:21 +00:00
Matt Aitken 036a5061e1 Kubernetes OOMs appear as non-zero sigkills, adding support for treating these as OOMs 2025-02-11 10:32:01 +00:00
Matt Aitken bb65b2614a OOM retrying on larger machines (#1691)
* OOM retrying on larger machines

* Create forty-windows-shop.md

* Update forty-windows-shop.md

* Only retry again if the machine is different from the original
2025-02-10 17:43:59 +00:00
Matt Aitken 4dd42cd6d4 Retry heartbeat timeouts by putting back in the queue (#1689)
* If there’s a heartbeat error and no attempts we put it back in the queue to try again

* When nacking, return whether it was put back in the queue or not

* Try and nack, if it fails then fail the run

* Consolidated switch statement

* Fail executing/retrying runs
2025-02-10 13:41:32 +00:00
Matt Aitken bc7d44582d If there’s no queue concurrency limit, default to the env concurrency limit (not 1m) (#1687) 2025-02-09 21:34:40 +00:00
Victor Duarte 2771fd7408 Missing construction option in AudioWaveformExtension (#1684)
* missing construction option in `AudioWaveformExtension`

* Create lemon-fireants-repair.md

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-02-09 15:52:46 +00:00
James Ritchie 1f1a6b0405 Fixed runs page filter bug (#1681) 2025-02-07 17:32:30 +00:00
Eric Allam 9f843d0a0f Remove v2 event list (#1680) 2025-02-07 13:51:34 +00:00
Eric Allam 9dfd6a5a0a Rate limit alerts by channel for task run alerts using generic cell rate algo (#1679) 2025-02-07 13:36:37 +00:00
Eric Allam 5210d3a8bb Add internal spans to event repo (#1678) 2025-02-07 13:36:15 +00:00
Eric Allam b5867627be Use idempotency key index when finding existing runs by idempotency key in batch (#1677) 2025-02-07 13:36:03 +00:00
Eric Allam 0b555faf01 Force upgrade v1 batches to v3 (#1676)
* Force upgrade v1 batches to v3

* Actually lets process v1 batches async
2025-02-06 22:37:26 +00:00
Eric Allam dcf1ab6f38 Remove schedule constraints from TaskRun to prevent db load issues when a schedule or instance is deleted (#1674) 2025-02-06 19:16:48 +00:00
Matt Aitken c49af774ba Retry batch item completion (#1675)
* Added isPrismaRetriableError()

* Retry completeBatchTaskRunItem if they fail because of a retriable Prisma error

* Retry using Redis worker

* Handle more retriable errors. Add special condition in for race condition error

* Added Postgres connection_timeout with default 20s

* Added a simple batchTriggerAndWait example
2025-02-06 19:16:30 +00:00
Eric Allam f00ed9b13e Add the sha256 digest to the deployment image reference (#1673) 2025-02-06 19:14:34 +00:00
Eric Allam 3ec6983e45 Move run ttl and delays from graphile to redis worker (#1672) 2025-02-06 14:25:37 +00:00
Eric Allam a2c2d920b7 Use redis worker for run heartbeats and alerts (#1669)
* Move the task run heartbeats to RedisWorker

* Move alerts to redis worker, improving redis worker

* Fix typecheck errors

* Use single threaded tests for redis worker

* Enable/disable the redis workers independently

* Remove preview release from PR checks
2025-02-06 10:21:57 +00:00
nicktrn b946b9f38e Add optional env var and label to disable image pre-pulls (#1667)
* add env var to disable pre-pulls

* add label to disable pre-pulls on specific nodes
2025-02-05 16:49:57 +00:00
Eric Allam 99d38151f6 Auto-fix config.dirs of /trigger and /src/trigger (#1665) 2025-02-05 13:00:18 +00:00
Eric Allam e35b6f539a Release 3.3.13 (#1664)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2025-02-04 16:32:26 +00:00
github-actions[bot] 20d983e08a chore: Update version for release (#1654)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-02-04 16:31:00 +00:00
Eric Allam 3887c4a621 Fix "no tasks defined" issue (fix #1681) (#1663) 2025-02-04 16:08:57 +00:00
Eric Allam e61573600b Reduce contention on batchTaskRun when setting expected count (#1662) 2025-02-04 13:43:50 +00:00
Eric Allam 53e6d478ed Fix deploy timeout issues (#1661)
* deploy v2 streaming WIP

* finalize deployment now SSE and won't timeout

* handle zodfetchSSE connection errors

* Create tender-cycles-melt.md
2025-02-04 12:44:14 +00:00
nicktrn 7fa45ad337 Fix pre-pulling of deployment images onto worker nodes (#1660)
* lint

* get pre-pull data from finalized deployment
2025-02-04 10:07:07 +00:00
Eric Allam 42c5f4d9fa Add text_pattern_ops index on SecretKey.key to improve getSecrets perf (#1659) 2025-02-03 22:52:16 +00:00
Eric Allam 6f52b00560 Set taskRunAttemptId on batch task run items on completion (#1658) 2025-02-03 15:25:12 +00:00
D-K-P d285f7930e Added guides and examples links and snippet 2025-02-03 14:30:03 +00:00
Eric Allam b377b577ca db efficiency improvements (#1657)
- remove calls to findUnique, especially when including/selecting relations
- add some missing indexes
- add spans to $transaction calls to help track down long running txs
2025-02-03 13:59:58 +00:00
Eric Allam b67ea9a2f5 improve batch completion system for run engine v1 (#1656)
* Automatically retry TriggerTaskService when hitting a unique constraint error on idempotency key

* improve batch completion system for run engine v1

* Rename batch stuff to v3 so it's not confusing

* Handle unique constraint error on BatchTaskRunItem creation and allow different limits for batchTrigger and batchTriggerAndWait
2025-02-03 11:29:51 +00:00
D-K-P de31220820 Docs correction 2025-01-31 15:15:23 +00:00
Eric Allam 3641647add Add the keyPrefix option when redis is not in cluster mode (#1655) 2025-01-31 10:50:52 +00:00
D-K-P 664bce561d Added vercel ai sdk image generator example to the docs 2025-01-30 15:39:28 +00:00
Eric Allam 73471589ef Fixed issue with asResponse and withResponse not working on runs.retrieve (#1648) 2025-01-30 14:01:38 +00:00
Eric Allam 813d73d1a3 Fix issue with heavy contention on TaskQueue updating concurrency limit (#1653)
* Fix issue with heavy contention on TaskQueue updating concurrency limit

* Remove concurrency limit when creating background worker tasks
2025-01-30 14:01:11 +00:00
nicktrn 9ad396bdb1 Fix custom queue docs 2025-01-30 13:53:48 +00:00
James Ritchie 4e7c55920d Docs maxDuration page now states it’s a required param 2025-01-29 19:30:25 +00:00
Eric Allam 3eede3c339 Better handle redis error events (#1652) 2025-01-29 17:27:52 +00:00
Eric Allam b3afbcf558 Correctly configure redis cluster for aws elasticache (#1651) 2025-01-29 16:56:51 +00:00
Eric Allam f43de6ab74 Support redis/valkey cluster mode (#1650) 2025-01-29 16:28:51 +00:00
mintlify[bot] 3127df5b67 Add new Bulk Actions section to documentation (#1649)
* Documentation edits made through Mintlify web editor

* Moves files to correct path and improves copy

* Corrects video props to camel case and removes deprecated prop

* Link to the new bulk actions page from the replaying page

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: James Ritchie <james@trigger.dev>
2025-01-29 16:01:01 +00:00
Eric Allam 9fd175e860 Prevent findUnique from bringing down our database (#1633) 2025-01-29 13:57:16 +00:00
Eric Allam ef0fc6b0b4 Support for logically separated redis instances (#1647)
* Remove unnecessary disabled org check

* Add support for separate redis servers for caching, rate limiter, marqs, and the pub/sub stuff (they all default to the previously used env vars for redis)
2025-01-29 13:53:58 +00:00
Eric Allam a24b2a7732 More v1 run engine fixes (#1644)
* Remove the socket count from shared queue consumer cause it can take up to 5s to complete

* Add support for re-using queue snapshots across dequeues

* Only consider the top N orgs when dequeuing, to help mitigate large spikes in queues (like around the hour and half hour marks)
2025-01-28 16:16:37 +00:00
Eric Allam 6da5e7a4cf Various fixes for run engine v1 (#1643)
* Various fixes for run engine v1

- Make sure there are connected providers before sending a scheduled attempt message, nack and retry if there are not
- Fail runs that fail task heartbeats when pending and locked
- More and better logging around shared queue consumer
- Fix bug when failing a task run with no attempt

* Prevent findUnique from bringing down our database
2025-01-27 17:15:17 +00:00
Eric Allam 75753ec5ad Release 3.3.12 2025-01-24 19:19:55 +00:00
github-actions[bot] 40e0d2f7e9 chore: Update version for release (#1626)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-24 19:18:39 +00:00
Eric Allam 246eb4361e If a deployment is already deployed, just return the deployment so the request succeeds (#1639) 2025-01-24 18:47:26 +00:00
nicktrn ab6e6afbf5 switch to github builders 2025-01-24 18:15:40 +00:00
Eric Allam 7445d9bf35 install ca-certificates in docker image (#1638) 2025-01-24 18:01:42 +00:00
Eric Allam f6eeaa1001 Fix broken cloud deploys by using depot ephemeral registry, skip the registry proxy (#1637) 2025-01-24 17:22:35 +00:00
Eric Allam eaf46bac58 engine v1 improved dequeue selection algorithm (#1632)
* engine v1 fairness improvements (env based queue selection)

* Add ability to bias env selection by concurrency limit and capacity

* Add some randomization to the list of queues within each evn

* Configure biases using env vars

* Typo

* revert v3 catalog project ref change

* Fix v2 marqs
2025-01-23 15:01:42 +00:00
James Ritchie 16cf357380 Focus visible style refinements (#1577)
* reduces the thickness of the focus-visible

* More subtle Input style

* Updates focus-visible styles for the SimpleSelect

* Tables tab through rows and action cells only

* Refined focus visible style

* Adds the custom focus visible to the TextLink

* Moves the onClick handling to the table row rather than the cell

* Makes table row heights consistent

* Adds gap between task search bar and button

* Prevents long tag values from wrapping

* Removes unnecessary rows from table header

* Added gap between search and filters

* Fixes the schedules table pagination staying fixed to the bottom of the page

* Removed more unnecessary header table rows to prevent them being selectable

* Removed isSelected styles (not working)

* Added <tr> back to the main Table compontent

* Table row handles modifier keys

* Adds to={path} to the TableRow only

* Revert "Adds to={path} to the TableRow only"

This reverts commit 8a814d434ff85b8ab9c2305d336074f659d86094.

* Revert "Table row handles modifier keys"

This reverts commit b2394741fa2ed6b908e00673b1a395ae89ca7146.

* Table reverted to use linked cells rather than rows

* Set the tab index of a cell and style the table row when tabbed

* Tabbed row style applied to the sticky cells

* Adds isTabbableCell to each table

* Improves the spcificity of the row highlighting

* Reduces the height of the task rows to match the other tables

* Adds tab styles to fill in row dividers top and bottom

* Removed old row onClick and to props

* Removed duplicate table header row

* Creates table style variants so tables look good in the inspector panels

* Full width tables on the schedules inspector panel

* Reduce padding on Alerts page table rows

* Updates the Deploy page inspector table to the new style

* Removes the duplicate Table Row from the table headers in v2

* Adds TableRow to the EventsTable

* Adds TableRow to more tables

* Adds TableRow to more tables

* Adds TableRow to more tables

* Adds TableRow to more tables

* Fix for showing correct cursor for linked table rows

* Removed variants from some of the table components
2025-01-21 21:11:59 +00:00
Eric Allam f96bf7210b Engine v1 improvements (#1627)
* Efficiency improvements to SharedQueueConsumer and MarQS for run engine v1

* Reliabily resolve task queue for a run and ack runs where we can't find the queue

* engine v1: stop nacking after reaching the max nack count, ack to remove the message

* Handle division by 0 possibility in distributeQueues
2025-01-21 14:24:02 +00:00
James Ritchie c855e4a96c v3 pages have page titles (#1582)
* Adds tasks page title

* Adds page titles to the <head>
2025-01-21 12:07:32 +00:00
Eric Allam e1497c802d Update config docs for new telemetry property (#1610) 2025-01-20 10:29:57 +00:00
Eric Allam e2612224b4 Fix "crypto is not defined error" on Node.js 18 in dev (#1623)
* Add --experimental-global-webcrypto node option fix "crypto is not defined error" on Node.js 18 in dev

* Only add flag when on node 18

* Missed the function
2025-01-20 10:29:26 +00:00
Matt Aitken 0d38ea0feb Updated the batch REST API docs to the new endpoint 2025-01-17 13:28:02 +00:00
Eric Allam 0687ce722c Release 3.3.11 (#1621)
🚀 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
2025-01-17 12:06:41 +00:00
github-actions[bot] d627fa718e chore: Update version for release (#1611)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-17 12:05:15 +00:00
Matt Aitken 5798024a5b Require max duration in trigger.config. Validate TRIGGER_ACCESS_TOKEN is a PAT. (#1620)
* Show a good error if TRIGGER_ACCESS_TOKEN isn’t a PAT

* Require maxDuration in trigger.config from now on

* Changeset

* Update the trigger.config templates copy, and set the default to 1hr

* Set the maxDuration in the trigger.config files inside the repo

* Fixed the e2e tests by setting the package to 0.0.0-prerelease-20250116195421
2025-01-17 11:32:55 +00:00
Eric Allam 5750f35add Shared queue consumer telemetry improvements and perf changes (#1619)
* Shared queue consumer telemetry improvements

* findUnique -> findFirst to stop the prisma dataloader issue on api key auth

* Added action stats, audited all actions and improved reasons

* Better debug logging filtering
2025-01-17 09:15:21 +00:00
Matt Aitken 11f2832ae5 Fix for detecting internal errors 2025-01-16 17:03:39 +00:00
Matt Aitken fd04ed50ef Prevent multiple waits callback delayed by one tick
To ensure the first wait doesn't checkpoint before the second is called
2025-01-16 16:23:56 +00:00
Matt Aitken 6d17443e16 Changeset: Detect parallel waits and show a useful error message 2025-01-16 15:48:55 +00:00
Matt Aitken dd321e3616 Throw an error for concurrent waits (with nice docs link) (#1618)
* WIP preventing concurrent waits, throw an error

* Added ConcurrentWaitError (not retryable)

* Move preventMultipleWaits out of the RuntimeAPI

* Added preventMultipleWaits to the devRuntimeManager

* Added throwable InternalError. Plus new TASK_DID_CONCURRENT_WAIT code

* Docs link for troubleshooting concurrent waits

* Docs for troubleshooting concurrent waits

* preventMultipleWaits function

* Added TASK_DID_CONCURRENT_WAIT code

* Deal with InternalErrors that skipRetrying

* Added preventMultipleWaits to prod
2025-01-16 14:33:14 +00:00
Gabe O'Leary 2ad664d17d Add note about prisma-json-types-generator (#1617) 2025-01-15 23:18:42 +00:00
Matt Aitken 5eb30d026d Improve worker deployment queries (#1616)
* workerDeployment change findUnique to findFirst

* Changed the worker deployment queries to only select what they need, some of the columns are huge

* Only get the bare minimum data for tasks too
2025-01-15 20:34:28 +00:00
nicktrn 719ae83057 Add support for specifying machine preset at trigger time (#1608)
* add machine preset trigger option

* add changeset

* add examples to changeset

* machinePreset -> machine

* task options now also accept machine as simple string

* update openapi spec
2025-01-15 13:47:13 +00:00
Eric Allam 7eda22851e Prevent v2 batches from being resumed before all items have been created (#1614) 2025-01-14 14:37:04 +00:00
Eric Allam b3aa87ee26 Add custom telemetry exporter support (#1602)
* WIP langsmith & AI SDK integration

* Add changeset

* Add exporter support to deployed tasks

* Better support for external exporters and group exporters and instrumentations under the telemetry config property

* Missing changes
2025-01-13 22:43:34 +00:00
Eric Allam 14ce599218 Add reason to MarQS acknowledgeMessage to track why it's called (#1607) 2025-01-13 17:29:09 +00:00
Eric Allam 0a18e1d919 Additional dev queue consumer logging (#1606) 2025-01-13 16:37:19 +00:00
Eric Allam ca51f65394 Truncate ingested log messages to 4096, and truncate message to 256 in the summary tree view (#1604) 2025-01-13 11:35:39 +00:00
Eric Allam 46a37fdc49 Release 3.3.10 (#1601)
🚀 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
2025-01-10 19:52:30 +00:00
github-actions[bot] 24383dc65d chore: Update version for release (#1600)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-10 19:50:41 +00:00
Eric Allam e13edd3a20 Handle errors thrown by requests in Realtime react hooks (#1599) 2025-01-10 19:40:34 +00:00
Eric Allam 9e0f03623d Support ignoring test/spec files inside trigger dirs (fixes #1593) (#1596)
* Support ignoring test/spec files inside trigger dirs (fixes #1593)

* Add changeset
2025-01-10 19:40:08 +00:00
Eric Allam 914ceaf3c5 dev CLI: cleanup deprecated background worker files (fixes #1572) (#1595)
* dev CLI: cleanup deprecated background worker files (fixes #1572)

* Add changeset
2025-01-10 19:39:34 +00:00
nicktrn 472e5f93e1 Fix: Prevent failed resumes for very large batches (#1598)
* findUnique -> findFirst

* remove tx

* make query more legible

* cut resume payload queries in half

* remove the additional status checks

* tweak the catalog batch parent task

* fix error message
2025-01-10 18:33:25 +00:00
D-K-P 51a054f534 Added Realtime CSV importer to the docs 2025-01-09 17:13:55 +00:00
Eric Allam efd61f1374 Release 3.3.9 (#1591)
🚀 Publish Trigger.dev Docker / units (push) Failing after 3s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2025-01-09 11:35:16 +00:00
github-actions[bot] 2c7f7b1d5e chore: Update version for release (#1586)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-09 11:34:03 +00:00
Eric Allam c92edd61af New metadata system doc updates (#1590) 2025-01-09 11:13:48 +00:00
Eric Allam ab69b60348 New metadata deployed task fix (#1588)
* Add more logging around new metadata system

* Make the authenticated env optional when updating run metadata
2025-01-08 22:42:34 +00:00
Dan d52e3d7d57 Added LLM evaluator to example projects (#1589)
* Added repo links

* Added links to remix / nextjs webhooks examples

* Added a Realtime learn more snippet

* Added the batch llm evaluator docs

* Improved snippet

* Updated intro
2025-01-08 21:16:56 +00:00
Eric Allam 3ca0c15161 Add ability to disable metadata flushing and logging (#1587) 2025-01-08 12:37:43 +00:00
James Ritchie 0aa597104c Shortcut improvements (#1573)
* Added shortcuts (with tooltip) to the pagination

* WIP fixing the disabled hover state

* Styled the tooltip

* Added a shortcuts panel to the help menu

* Adds new shortcut to list

* Changes “meta” for “mod”

* Adds more shortcuts to the list

* Adding shortcut to open the shortcuts panel

* tweak gap between shortcut letters

* button component now has icon spacing adjustment (for lucide icons)

* Fixed some ilegal markup

* Pagination uses disabled prop rather than a disabled wrapper

* Improved the Switch styles

* Makes the shortcut modifier optional

* Added new icon based shortcut keys for mac and win

* Updated PC modifier shortcuts

* Adds a new windows key icon

* Allows variants and react nodes to be used as the modifier key

* Adds more shortcuts to the storybook

* Adds missing focus-visible styles to the pagination

* Removed test modifier keys

* number style is tabular

* Update apps/webapp/app/components/primitives/ShortcutKey.tsx

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

* Tooltip now just 1 prop on the button component

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-01-08 11:58:18 +00:00
Eric Allam 34f8bd588e Add ability to update parent and root run metadata from children (#1563)
* Increase the number of active streams from 2 to 5

* WIP parent metadata updates

* Fix noop metadata manager

* Implement run metadata updates from ancestor tasks

* Add changeset

* Add ability to stream into parent and root task runs

* Don't duplicate exporting run metadata types

* Add ability to fetch streams through metadata

* Couple of fixes from CodeRabbit

* Fix metadata tests

* Make sure streams are subscribed in the "background"

* Move the stream subscription stuff to the API client, expose it through `runs.fetchStream`

* Fixed run stream tests
2025-01-08 11:29:24 +00:00
Eric Allam 4243cb2451 Release 3.3.8
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2025-01-08 10:29:06 +00:00
github-actions[bot] 7e8a0d96d2 chore: Update version for release (#1570)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-01-08 10:26:37 +00:00
Willow (GHOST) 4d19dec71a fix: add user-agent to the websocket request (#1584) 2025-01-07 22:21:32 +00:00
Eric Allam 76a5ac293e Realtime fixes: safari, timezones, and TIMED_OUT status (#1585)
* Fix realtime safari bug because of missing ReadableStream async iterable support
Limit to only safari

* Fix missing TIMED_OUT run status

* When coercing realtime date strings, make sure they are set to UTC
2025-01-07 20:47:49 +00:00
Dan 668b34d25e Adding example projects (#1564)
* Added new side menu section and updated intro

* Updated the fal ai realtime project

* Copy update

* Added example project

* Swapped walkthrough and repo

* Restore original fal task page

* Added links back to the intro
2025-01-03 20:00:40 +00:00
James Ritchie 6a2d033f32 Project setup improvements (#1579)
* Adds ‘an existing project’ to step 1

* When you click pnpm or yarn, the other tabs change too
2024-12-30 18:07:07 +00:00
James Ritchie e71df640a9 Adds more org names to the alert emails (#1561)
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-12-30 17:54:38 +00:00
James Ritchie 330f6c548e Softened the language in the Pro upgrade modal (#1574) 2024-12-30 17:49:00 +00:00
Erin Allison 4d2412a0fe Add Support for Alternate Email Transports (#1580)
* Add support for AWS SES and SMTP email transports

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Correct environment variable names for new email settings

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Correct option names being passed to nodemailer for SMTP

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Remove use of AWS SDK synthetic default export

It apparently causes issues when transpiled/bundled

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Add documentation for new email settings

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Move nodemailer types to devDependencies

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Adjust mail transport error handling

Gotta keep the linter happy :)

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Fix typecheck error on MailTransportOptions

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

* Correct environment variable usage for SMTP email transport

Signed-off-by: Erin Allison <eallison@andrettikarting.com>

---------

Signed-off-by: Erin Allison <eallison@andrettikarting.com>
2024-12-30 17:38:17 +00:00
Matt Aitken 21a4fabc28 Docs update batch limits to 5MB 2024-12-18 11:47:50 +00:00
Eric Allam 3418aa614b Don't expire enqueued runs if they have lockedAt set (#1571) 2024-12-16 19:00:18 +00:00
Eric Allam 88a0754ae0 Support scopes packages in externals when found in a transitive dep #1552 (#1569)
* Support scopes packages in externals when found in a transitive dep

* fixed typescript errro

* Add changeset
2024-12-16 17:55:44 +00:00
Eric Allam f3e4597ef4 Update scrape-hacker-news.mdx 2024-12-14 21:11:37 +00:00
Eric Allam 2bf04ece54 Release 3.3.7 (#1562)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-13 15:49:52 +00:00
github-actions[bot] 295b2eb3b6 chore: Update version for release (#1558)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-13 15:48:04 +00:00
James Ritchie f0182abc83 Alert emails now contain the Org name (#1555)
* Alert emails now contain the Org name

* Adds org title to the alert parameters
2024-12-13 13:32:34 +00:00
James Ritchie 1105b9b71a 3 small improvements (#1560)
* Updated login page logos

* Adds an isSelected state to the Table

* Toast style now matches the design

* Adds a space between the upgrade panel and the list of users
2024-12-13 13:32:19 +00:00
Eric Allam 7077dbf65e @trigger.dev/react-hooks now works with React 19 (#1559) 2024-12-13 13:31:47 +00:00
Eric Allam 6b355ab9ad Upgrades and fixes to Realtime and Realtime streams (#1549)
* Fix streaming splits in realtime streams v2

* Add changeset

* Skip all flaky tests 😡

* Improve the way we stream from tasks to the server

* Improve the v1 realtime streams (Redis)

* Turn on the relay realtime stream service

* Improved the relay realtime cleanup

* Fixed consuming realtime runs w/streams after the run is already finished

* Remove some logs

* Update changeset

* Fixed runStream tests
2024-12-13 11:42:50 +00:00
Niloy 68d7139ebe Update deploy-environment-variables.mdx (#1509)
base64 with -i for input file
2024-12-13 11:16:47 +00:00
James Ritchie c58f6f7a68 Upgrading from Hobby to Pro displays a dialog to confirm (#1545)
* Upgrading from Hobby to Pro displays a dialog to confirm

* Improved the logic for upgrading to pro

* Added the spinner component to storybook

* Improved the dark style spinner
2024-12-13 11:14:47 +00:00
James Ritchie 6516e15bd8 Changes frozen to waiting (#1551)
* Offset the tooltip in more

* The Select component’s shortcut has a better hover state

* “Frozen” changed to “Waiting”
2024-12-13 11:13:34 +00:00
James Ritchie 7f2365f4e0 Adds a tooltip to the Status header showing deployment status descriptions (#1554) 2024-12-13 11:11:14 +00:00
James Ritchie f560594610 Tabs horizontally overflow scroll in the side panel inspectors (#1556) 2024-12-13 11:08:04 +00:00
Matt Aitken ebd9767926 When replaying, using the concurrencyLimit from the original run’s queue 2024-12-12 16:48:22 +00:00
Matt Aitken 1c631a1b6f Skip the RealtimeClient tests for now in CI… flaky 2024-12-12 14:39:35 +00:00
Matt Aitken 110a8ea48c Added queue updating logging. For Triggering changed the concurrency limit calculation to match deploying 2024-12-12 13:49:20 +00:00
James Ritchie 10f0ef3301 Adds v2 countdown eol text in side menu (#1546) 2024-12-11 12:24:23 +00:00
nicktrn 2e4a6307b4 Fix: Prevent some attempt creation retries from causing system failures (#1550) 2024-12-11 12:01:18 +00:00
Eric Allam 30ea5eb13a Release 3.3.6 (#1544)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-12-10 15:53:45 +00:00
github-actions[bot] 5846f30228 chore: Update version for release (#1537)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-12-10 15:52:05 +00:00
Eric Allam 3afa42c209 Make TRIGGER_REALTIME_STREAM_VERSION overridable by the project env vars (#1543) 2024-12-10 14:54:50 +00:00
Eric Allam 86b1628953 Add run metadata to runs.list endpoint (#1542) 2024-12-10 12:52:42 +00:00
Matt Aitken ea23dbd297 Throw an error if Resend responds with an error (#1540) 2024-12-10 12:37:36 +00:00
Matt Aitken 9065e64be8 Added logging when we remove a queue’s concurrency limit 2024-12-10 09:57:41 +00:00
Eric Allam 9970b9b68e Realtime streams now powered by electric (#1541)
* Realtime streams now powered by electric, and fix the streaming update duplicate issues by converting the electric Shape materialized view into a ReadableStream of changes

* Ensure realtime subscription stops when runs are finished, and add an onComplete handle to use realtime hooks

* Fix tests
2024-12-09 22:09:30 +00:00
Matt Aitken b4113134ad Fix: test form: update the callback when the task changes 2024-12-08 22:07:42 +00:00
Eric Allam 2a07ea42f1 Optionally trigger batched items sequentially to preserve order (#1536)
* Optionally trigger batched items sequentially to preserve order

* Fix infinite v3.processBatchTaskRun enqueuings by checking the attemptCount
2024-12-05 15:16:06 +00:00
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
Eric Allam c9811beae2 Release 3.1.1
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
2024-11-01 21:33:00 +00:00
github-actions[bot] 97533cba2e chore: Update version for release (#1442)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-11-01 21:32:04 +00:00
Dan e92459bf1e Added revalidation docs (#1444)
* Added revalidation docs

* Typo

* Copy tweak
2024-11-01 21:25:44 +00:00
Eric Allam cc74cae393 chore: remove unnecessary browser export condition (#1455) 2024-11-01 21:11:45 +00:00
nicktrn 58def055d0 Add new internal error for very outdated SDK versions (#1453)
* add outdated sdk error and crash outdated runs instead of staying queued forever

* add changeset
2024-11-01 15:41:31 +00:00
D-K-P 41f5261fe5 Added drizzle docs and updated prisma 2024-11-01 14:40:25 +00:00
Eric Allam ce1a54eb3b Renamed useBatch to useRealtimeBatch (#1447)
* Renamed useBatch to useRealtimeBatch

* Use the new hook name in the nextjs-realtime ref
2024-11-01 13:23:02 +00:00
Eric Allam 25285488f8 Add individual run ids to auto-generated public access token when calling batchTrigger (#1449) 2024-11-01 13:22:34 +00:00
Eric Allam befdcef1f0 Add known issue for realtime (#1452) 2024-11-01 13:21:01 +00:00
nicktrn fa18e6c012 Increase maximum retry count for deploy run controller (#1450)
* increase max retries for all deploy run controller operations

* add changeset
2024-11-01 11:23:11 +00:00
nicktrn 4194bce4a3 Forked worker processes now inherit PATH from parent (#1448)
* set PATH when forking

* add changeset
2024-11-01 11:23:00 +00:00
nicktrn da08e5015f Pass init output to all error handlers (#1441)
* pass init output to all error handlers

* add changeset
2024-10-31 11:23:47 +00:00
D-K-P 9b7472844d Updated deploy docs 2024-10-31 09:49:59 +00:00
Dan eace78fa04 Added fal AI example (#1439) 2024-10-30 17:30:23 +00:00
Dan e81b2a7839 Firecrawl example docs (#1438)
* Added firecrawl example docs

* Added Firecrawl and scrape hn to the intro page table
2024-10-30 16:41:04 +00:00
Eric Allam cdcfc81aca Add API_ORIGIN env var to be able to split dashboard & API servers 2024-10-29 16:43:22 +00:00
Eric Allam 816b0f9e3d Add some more react hooks & realtime docs 2024-10-29 16:41:57 +00:00
Eric Allam 6fb073672d Add realtime API docs 2024-10-29 13:57:02 +00:00
Eric Allam 9203bd8e67 Add realtime walkthrough video to docs 2024-10-29 13:23:57 +00:00
Eric Allam 3252d9ec31 docs: realtime, react hooks, and frontend usage (#1434)
* WIP realtime/frontend docs

* WIP

* Deleted old examples

* Improved styling

* Improved the generated example styles

* More style improvements

* More realtime docs

* Couple doc tweeks

* runs-and-attempts -> runs

* Remove the prerelease package version

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2024-10-29 12:31:04 +00:00
610 changed files with 40680 additions and 13051 deletions
+25 -3
View File
@@ -31,10 +31,32 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Resend is an email service used for signing in to Trigger.dev via a Magic Link.
# Emails will print to the console if you leave these commented out
# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link.
# If none are configured, emails will print to the console instead.
# Uncomment one of the following blocks to allow delivery of
# Resend
### Visit https://resend.com, create an account and get your API key. Then insert it below along with your From and Reply To email addresses. Visit https://resend.com/docs for more information.
# RESEND_API_KEY=<api_key>
# EMAIL_TRANSPORT=resend
# FROM_EMAIL=
# REPLY_TO_EMAIL=
# RESEND_API_KEY=
# Generic SMTP
### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information
### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587)
# EMAIL_TRANSPORT=smtp
# FROM_EMAIL=
# REPLY_TO_EMAIL=
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASSWORD=
# AWS Simple Email Service
### Authentication is configured using the default Node.JS credentials provider chain (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain)
# EMAIL_TRANSPORT=aws-ses
# FROM_EMAIL=
# REPLY_TO_EMAIL=
+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.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [buildjet-8vcpu-ubuntu-2204, windows-latest]
os: [ubuntu-latest, windows-latest]
package-manager: ["npm", "pnpm"]
steps:
- name: ⬇️ Checkout repo
-31
View File
@@ -28,34 +28,3 @@ jobs:
with:
package: cli-v3
secrets: inherit
preview-release:
name: Preview Release
needs: [typecheck, units, e2e]
if: github.repository == 'triggerdotdev/trigger.dev'
runs-on: buildjet-8vcpu-ubuntu-2204
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 🏗️ Build
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: ⚡ Publish preview release
run: npx pkg-pr-new publish --no-template $(ls -d ./packages/*)
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
strategy:
matrix:
package: [coordinator, docker-provider, kubernetes-provider]
runs-on: buildjet-16vcpu-ubuntu-2204
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
steps:
+1 -1
View File
@@ -5,7 +5,7 @@ on:
jobs:
typecheck:
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
+1 -1
View File
@@ -6,7 +6,7 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests"
runs-on: buildjet-16vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
+34
View File
@@ -13,6 +13,24 @@
"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": "node-terminal",
"request": "launch",
"name": "Debug fairDequeuingStrategy.test.ts",
"command": "pnpm run test -t FairDequeuingStrategy",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
@@ -28,6 +46,14 @@
"cwd": "${workspaceFolder}/references/init-shell",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 init dev CLI",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/init-shell",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
@@ -36,6 +62,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",
+23
View File
@@ -37,6 +37,8 @@ const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS |
const POD_EPHEMERAL_STORAGE_SIZE_LIMIT = process.env.POD_EPHEMERAL_STORAGE_SIZE_LIMIT || "10Gi";
const POD_EPHEMERAL_STORAGE_SIZE_REQUEST = process.env.POD_EPHEMERAL_STORAGE_SIZE_REQUEST || "2Gi";
const PRE_PULL_DISABLED = process.env.PRE_PULL_DISABLED === "true";
const logger = new SimpleLogger(`[${NODE_NAME}]`);
logger.log(`running in ${RUNTIME_ENV} mode`);
@@ -301,6 +303,11 @@ class KubernetesTaskOperations implements TaskOperations {
}
async prePullDeployment(opts: TaskOperationsPrePullDeploymentOptions) {
if (PRE_PULL_DISABLED) {
logger.debug("Pre-pull is disabled, skipping.", { opts });
return;
}
const metaName = this.#getPrePullContainerName(opts.shortCode);
const metaLabels = {
@@ -332,6 +339,22 @@ class KubernetesTaskOperations implements TaskOperations {
spec: {
...this.#defaultPodSpec,
restartPolicy: "Always",
affinity: {
nodeAffinity: {
requiredDuringSchedulingIgnoredDuringExecution: {
nodeSelectorTerms: [
{
matchExpressions: [
{
key: "trigger.dev/pre-pull-disabled",
operator: "DoesNotExist",
},
],
},
],
},
},
},
initContainers: [
{
name: "prepull",
+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,17 @@
export function KeyboardDownIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.8779 16.3905C12.4989 17.0854 11.5011 17.0854 11.1221 16.3905L6.80665 8.47885C6.44317 7.81248 6.92548 7 7.68454 7L16.3155 7C17.0745 7 17.5568 7.81248 17.1934 8.47885L12.8779 16.3905Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,17 @@
export function KeyboardLeftIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.60947 12.8779C6.91461 12.4989 6.91461 11.5011 7.60947 11.1221L15.5211 6.80665C16.1875 6.44317 17 6.92548 17 7.68454L17 16.3155C17 17.0745 16.1875 17.5568 15.5211 17.1934L7.60947 12.8779Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,17 @@
export function KeyboardRightIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.3905 11.1221C17.0854 11.5011 17.0854 12.4989 16.3905 12.8779L8.47885 17.1934C7.81248 17.5568 7 17.0745 7 16.3155L7 7.68454C7 6.92548 7.81248 6.44317 8.47885 6.80665L16.3905 11.1221Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,17 @@
export function KeyboardUpIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.1221 6.60947C11.5011 5.91461 12.4989 5.91461 12.8779 6.60947L17.1934 14.5211C17.5568 15.1875 17.0745 16 16.3155 16L7.68454 16C6.92548 16 6.44317 15.1875 6.80665 14.5211L11.1221 6.60947Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,17 @@
export function KeyboardWindowsIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="12"
height="16"
viewBox="0 0 12 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1.66666" y="3.66663" width="4" height="4" fill="currentColor" />
<rect x="6.33334" y="3.66663" width="4" height="4" fill="currentColor" />
<rect x="1.66666" y="8.33337" width="4" height="4" fill="currentColor" />
<rect x="6.33334" y="8.33337" width="4" height="4" fill="currentColor" />
</svg>
);
}
@@ -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>
);
}
+19
View File
@@ -0,0 +1,19 @@
export function LyftLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="48"
height="34"
viewBox="0 0 48 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 0.593341H7.28904V20.1709C7.28904 23.2692 8.70514 25.1147 9.82493 25.9058C8.63943 26.9605 5.01673 27.8834 2.3162 25.6421C0.724053 24.3209 0 22.1485 0 20.1049V0.593341ZM45.5536 16.856V14.7986H47.7767V7.58069H45.3354C44.3982 3.24724 40.5432 0 35.9328 0C30.6217 0 26.3164 4.30894 26.3164 9.62402V26.48C27.8295 26.6928 29.6322 26.4536 31.1659 25.1807C32.7578 23.8594 33.4818 21.6872 33.4818 19.6436V19.0226H37.1222V11.8047H33.4818V9.62402H33.4905C33.4905 8.27426 34.5839 7.18004 35.9328 7.18004C37.2815 7.18004 38.3792 8.27426 38.3792 9.62402V16.856C38.3792 22.1711 42.6891 26.48 48 26.48V19.3C46.6513 19.3 45.5536 18.2057 45.5536 16.856ZM17.9474 7.58069V18.1737C17.9474 18.7751 17.4488 19.2626 16.8337 19.2626C16.2185 19.2626 15.7199 18.7751 15.7199 18.1737V7.58069H8.50752V20.0392C8.50752 22.2803 9.26697 25.1147 12.7231 26.0376C16.1828 26.9615 18.1899 25.049 18.1899 25.049C18.007 26.3089 16.8213 27.2318 14.9113 27.4296C13.4661 27.5791 11.6178 27.1 10.6959 26.7045V33.305C13.0451 33.9983 15.5298 34.2223 17.9614 33.7501C22.3744 32.8932 25.1596 29.2019 25.1596 24.2908V7.58069H17.9474Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,23 @@
export function MiddayLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width={102}
height={30}
viewBox="0 0 100 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M14.347 0a14.931 14.931 0 0 0-6.282 1.68l6.282 10.88V0Zm0 17.443L8.067 28.32a14.933 14.933 0 0 0 6.28 1.68V17.443ZM15.652 30V17.432l6.285 10.887A14.932 14.932 0 0 1 15.652 30Zm0-17.43V0c2.26.097 4.392.693 6.287 1.682l-6.287 10.889ZM2.336 23.068l10.884-6.284-6.284 10.884a15.093 15.093 0 0 1-4.6-4.6Zm25.33-16.132-10.88 6.282 6.282-10.88a15.094 15.094 0 0 1 4.598 4.598ZM2.335 6.934a15.094 15.094 0 0 1 4.6-4.6l6.284 10.884L2.335 6.934Zm-.654 1.13A14.931 14.931 0 0 0 0 14.35h12.568L1.681 8.064Zm0 13.873a14.932 14.932 0 0 1-1.68-6.282h12.562L1.682 21.938Zm15.754-7.587H30a14.93 14.93 0 0 0-1.68-6.285L17.435 14.35Zm10.884 7.586-10.878-6.28H30a14.932 14.932 0 0 1-1.68 6.28Zm-11.533-5.151 6.281 10.88a15.092 15.092 0 0 0 4.598-4.599l-10.88-6.281Z"
clipRule="evenodd"
/>
<path
fill="currentColor"
d="M92.34 11.912h1.637l2.995 8.223 2.884-8.223h1.619l-4 11.107c-.372 1.06-1.08 1.544-2.196 1.544h-1.172v-1.358h1.024c.502 0 .8-.186.986-.707l.353-.912h-.52l-3.61-9.674ZM82.744 14.814c.39-1.916 1.916-3.126 4.018-3.126 2.549 0 3.963 1.489 3.963 4.13v3.964c0 .446.186.632.614.632h.39v1.358h-.65c-1.005 0-1.88-.335-1.861-1.544-.428.93-1.544 1.767-3.107 1.767-1.954 0-3.535-1.041-3.535-2.79 0-2.028 1.544-2.55 3.702-2.977l2.921-.558c-.018-1.712-.818-2.53-2.437-2.53-1.265 0-2.102.65-2.4 1.804l-1.618-.13Zm1.432 4.39c0 .8.689 1.452 2.14 1.433 1.637 0 2.92-1.153 2.92-3.442v-.167l-2.362.41c-1.47.26-2.698.371-2.698 1.767ZM80.129 8.563v13.21h-1.377l-.056-1.452c-.558 1.042-1.618 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.451 0 2.493.558 3.051 1.562V8.563h1.526Zm-7.145 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM68.936 8.563v13.21H67.56l-.056-1.452c-.558 1.042-1.619 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.45 0 2.493.558 3.05 1.562V8.563h1.526Zm-7.144 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM56.212 11.912h1.525v9.86h-1.525v-9.86Zm-.037-1.544V8.6h1.6v1.768h-1.6ZM40.224 11.912h1.395l.056 1.674c.446-1.21 1.47-1.898 2.846-1.898 1.414 0 2.438.763 2.865 2.084.428-1.34 1.47-2.084 3.014-2.084 1.973 0 3.126 1.377 3.126 3.74v6.344H52v-5.897c0-1.805-.707-2.828-1.916-2.828-1.544 0-2.437 1.041-2.437 2.846v5.88H46.12v-5.899c0-1.767-.725-2.827-1.916-2.827-1.526 0-2.456 1.079-2.456 2.827v5.898h-1.525v-9.86Z"
/>
</svg>
);
}
@@ -0,0 +1,41 @@
export function TldrawLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="96"
height="25"
viewBox="0 0 96 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.3031 0.283066C15.4501 0.283066 18.5971 0.281726 21.7441 0.283066C23.5701 0.283066 24.6473 1.36288 24.6486 3.19829C24.6513 9.4695 24.6513 15.7407 24.6486 22.0133C24.6486 23.8607 23.5808 24.9325 21.7521 24.9338C15.4581 24.9352 9.16417 24.9352 2.87018 24.9338C1.08836 24.9338 0.000510729 23.8446 -0.000828987 22.0521C-0.0021687 15.7581 -0.0021687 9.46414 0.000510729 3.17015C0.00185044 1.34546 1.08568 0.284406 2.93047 0.283066C6.05469 0.281726 9.18024 0.283066 12.3045 0.283066H12.3031ZM12.1223 16.9518C12.0499 17.7543 11.6386 18.3116 11.088 18.8006C10.8201 19.0391 10.4798 19.3713 10.8308 19.6902C11.0532 19.8938 11.5475 19.9997 11.8356 19.9019C13.7808 19.2374 15.2183 16.2498 14.582 14.3019C14.2725 13.3533 13.2825 12.6178 12.3674 12.6594C11.4095 12.7022 10.5682 13.4016 10.3525 14.3327C10.0819 15.4982 10.6553 16.3637 12.1236 16.9518H12.1223ZM12.4143 5.20384C11.2354 5.21858 10.315 6.1604 10.3257 7.34337C10.3364 8.52768 11.2662 9.47218 12.4331 9.48155C13.6134 9.49093 14.586 8.49016 14.5605 7.29112C14.5351 6.10815 13.5892 5.18776 12.4157 5.2025L12.4143 5.20384Z"
fill="currentColor"
/>
<path
d="M85.3405 16.6586C85.4195 16.4443 85.5267 16.2353 85.5722 16.0142C85.9849 13.9846 86.3961 11.9549 86.786 9.92119C86.8784 9.44024 87.1035 9.22588 87.6006 9.25402C88.3093 9.29287 89.2229 9.03832 89.6731 9.39067C90.1206 9.73899 90.138 10.6768 90.284 11.3681C90.6176 12.9597 90.9096 14.5606 91.2339 16.1536C91.2674 16.317 91.408 16.459 91.4978 16.6117C91.5808 16.455 91.7269 16.3023 91.7389 16.1415C91.889 14.1185 92.0323 12.0942 92.1449 10.0699C92.1784 9.48043 92.4021 9.23124 93.0063 9.25134C93.9602 9.28215 94.9167 9.25937 95.9992 9.25937C95.8612 10.157 95.7433 10.9809 95.604 11.8022C95.1793 14.3061 94.7292 16.8073 94.3259 19.3153C94.2201 19.9731 93.995 20.308 93.2582 20.2598C92.3472 20.1995 91.4268 20.2008 90.5158 20.2598C89.8781 20.3013 89.6316 20.0227 89.5284 19.4586C89.2564 17.9689 88.9724 16.4818 88.683 14.9947C88.6415 14.783 88.5397 14.5847 88.4647 14.3798L88.2543 14.3945C88.182 14.7026 88.1029 15.0094 88.0373 15.3189C87.7412 16.735 87.4425 18.1524 87.1598 19.5712C87.0593 20.0776 86.7873 20.2692 86.2702 20.2531C85.2668 20.2236 84.262 20.2249 83.2585 20.2531C82.807 20.2651 82.6048 20.0736 82.5351 19.6596C81.9979 16.4724 81.4526 13.2879 80.9247 10.0994C80.8149 9.4322 80.985 9.26607 81.6763 9.26071C82.4346 9.25536 83.1942 9.28081 83.9525 9.25134C84.3799 9.23526 84.538 9.37995 84.5688 9.81C84.7148 11.9013 84.8863 13.9899 85.0511 16.0785C85.0645 16.25 85.0872 16.4202 85.106 16.5916L85.3405 16.6586Z"
fill="currentColor"
/>
<path
d="M58.7188 5.17457C58.7188 5.4961 58.7188 5.73457 58.7188 5.97438C58.7188 10.3928 58.7188 14.8125 58.7188 19.2309C58.7188 20.1981 58.6692 20.2437 57.6832 20.245C56.7266 20.245 55.7701 20.245 54.7492 20.245C54.7305 19.8846 54.7332 19.5778 54.6943 19.2764C54.6648 19.05 54.5844 18.8316 54.5268 18.6092C54.3446 18.7432 54.1276 18.8477 53.9856 19.0165C53.3988 19.7132 52.7678 20.3107 51.7952 20.4178C50.0951 20.6041 48.6656 19.7091 48.5263 18.0157C48.3534 15.9151 48.399 13.7863 48.5075 11.6775C48.5731 10.3968 49.3663 9.47371 50.6618 9.16424C52.0082 8.84405 53.1804 9.15888 54.0472 10.3244C54.1651 10.4825 54.3621 10.583 54.5215 10.7103C54.5818 10.512 54.6903 10.3137 54.693 10.1141C54.7077 8.75295 54.7198 7.39046 54.693 6.03065C54.6809 5.38892 54.9435 5.15045 55.5691 5.16653C56.5913 5.19332 57.6135 5.17323 58.7188 5.17323V5.17457ZM54.6956 14.6879C54.6956 14.6879 54.6983 14.6879 54.6997 14.6879C54.6997 14.2431 54.7064 13.7983 54.6983 13.3535C54.6863 12.6984 54.4009 12.4827 53.522 12.4465C52.816 12.4171 52.4181 12.681 52.3953 13.2839C52.3605 14.1949 52.3578 15.1086 52.394 16.0182C52.4235 16.7778 53.1175 17.086 54.149 16.7658C54.3728 16.6961 54.6313 16.3866 54.6715 16.1522C54.7532 15.6766 54.6956 15.1782 54.6956 14.6892V14.6879Z"
fill="currentColor"
/>
<path
d="M79.2702 14.5379C79.2702 14.9612 79.2809 15.3859 79.2675 15.8093C79.2528 16.3103 79.2072 16.7886 79.9052 16.9105C80.0419 16.9346 80.1999 17.2937 80.2107 17.504C80.2482 18.2609 80.2442 19.0219 80.2107 19.7788C80.204 19.9383 79.991 20.2276 79.8891 20.2209C78.942 20.1566 77.9747 20.1312 77.061 19.9034C76.5974 19.7882 76.1821 19.3247 75.8365 18.9375C75.5176 18.5811 75.3328 18.5651 75.0796 18.9563C74.1592 20.3804 72.7833 20.6202 71.2587 20.3549C69.998 20.1352 69.1861 19.1559 69.1192 17.8805C69.1004 17.5241 69.1031 17.1664 69.1084 16.81C69.1299 14.988 70.1775 13.8948 72.0036 13.8265C72.873 13.7943 73.7439 13.8359 74.6133 13.805C74.8692 13.7957 75.1197 13.6537 75.3743 13.5733C75.2376 13.241 75.18 12.8257 74.9416 12.6006C74.7326 12.4037 74.3159 12.3314 74.0038 12.3582C72.8087 12.456 71.6124 12.5725 70.4321 12.7735C69.8815 12.8672 69.6778 12.7574 69.6872 12.2108C69.6993 11.5423 69.6483 10.8684 69.7127 10.2052C69.7354 9.96676 69.9712 9.60906 70.1789 9.54877C72.2661 8.94992 74.3896 8.7436 76.5171 9.29556C78.3324 9.7658 79.2072 10.9876 79.2702 13.0012C79.2863 13.5143 79.2729 14.0274 79.2729 14.5405L79.2702 14.5379ZM74.0426 15.6244C73.6956 15.7972 73.0177 15.9258 72.944 16.2112C72.6828 17.22 72.8797 17.721 74.0212 17.512C74.3668 17.4491 74.7138 17.3379 75.0286 17.1838C75.4359 16.9842 75.5833 16.2473 75.235 15.9941C74.9576 15.7932 74.5504 15.7704 74.0426 15.623V15.6244Z"
fill="currentColor"
/>
<path
d="M36.6847 6.1808C36.6847 6.91899 36.6834 7.60224 36.6847 8.28549C36.6874 9.2032 36.7437 9.25813 37.6855 9.25947C38.3527 9.26081 39.0212 9.25947 39.7862 9.25947C39.7862 10.4116 39.8062 11.5343 39.758 12.6543C39.7527 12.7816 39.3628 12.9745 39.1391 12.9933C38.563 13.0415 37.9802 13.0187 37.4001 13.0067C36.8937 12.9959 36.6646 13.2197 36.6807 13.7328C36.6981 14.2901 36.6727 14.8488 36.6887 15.4047C36.7102 16.1282 36.9848 16.4202 37.7016 16.4604C38.1906 16.4886 38.6822 16.4725 39.1739 16.4698C39.5731 16.4685 39.8196 16.6373 39.8223 17.0566C39.829 18.0802 39.825 19.105 39.825 20.2304C37.802 20.1822 35.7576 20.6095 34.0227 19.2176C33.1144 18.4888 32.7567 17.4572 32.691 16.3291C32.6468 15.5735 32.6709 14.8126 32.6669 14.0543C32.6615 13.0991 32.5879 13.0241 31.6501 13.0053C31.4973 13.0026 31.3433 12.9852 31.0927 12.9665C31.0927 11.8706 31.0633 10.7854 31.1249 9.70693C31.1343 9.54349 31.5683 9.28358 31.8081 9.27956C32.4512 9.26885 32.695 8.99688 32.6709 8.38061C32.6495 7.84607 32.6133 7.30214 32.695 6.77831C32.7299 6.55458 33.0407 6.21295 33.239 6.20358C34.3469 6.14731 35.4602 6.17678 36.6861 6.17678L36.6847 6.1808Z"
fill="currentColor"
/>
<path
d="M46.9683 20.2397C45.9474 20.2397 45.0766 20.2732 44.2098 20.2317C42.7214 20.162 41.7796 19.2631 41.5947 17.7894C41.5639 17.5469 41.5545 17.3004 41.5545 17.0552C41.5518 13.4206 41.5518 9.78594 41.5545 6.15129C41.5545 5.30191 41.6778 5.17732 42.5124 5.17464C43.5547 5.17062 44.5983 5.17464 45.7063 5.17464C45.7063 5.531 45.7063 5.79493 45.7063 6.05885C45.7063 9.27015 45.7143 12.4814 45.6982 15.6914C45.6956 16.2206 45.7746 16.5515 46.4177 16.5167C46.5945 16.5073 46.9388 16.7551 46.9442 16.8945C46.9884 17.9957 46.9683 19.0996 46.9683 20.2384V20.2397Z"
fill="currentColor"
/>
<path
d="M65.088 9.26208C65.088 9.88237 65.076 10.3915 65.0961 10.8992C65.1014 11.0265 65.2126 11.1497 65.2756 11.2757C65.3801 11.1819 65.5194 11.1069 65.5824 10.9903C65.7833 10.6206 65.9146 10.2093 66.1477 9.86361C66.6073 9.18304 67.3106 8.97404 68.0823 9.09729C68.2819 9.12945 68.5793 9.43222 68.5874 9.62112C68.6343 10.7559 68.6075 11.8933 68.6128 13.0307C68.6155 13.5184 68.3275 13.4487 67.9831 13.4353C67.2383 13.4058 66.4867 13.3991 65.7458 13.4607C65.2635 13.5009 65.0639 13.8305 65.0733 14.3543C65.1041 16.0263 65.0719 17.6983 65.0907 19.3702C65.0974 19.9865 64.8777 20.2745 64.2319 20.2531C63.3196 20.2236 62.4046 20.2317 61.4909 20.2531C61.1104 20.2611 60.9751 20.1111 60.9751 19.7467C60.9805 16.4925 60.9764 13.237 60.9805 9.98285C60.9805 9.58763 61.1117 9.26744 61.5833 9.26476C62.7154 9.2594 63.8474 9.26342 65.0867 9.26342L65.088 9.26208Z"
fill="currentColor"
/>
</svg>
);
}
@@ -0,0 +1,17 @@
export function UnkeyLogo({ className }: { className?: string }) {
return (
<svg
className={className}
width="94"
height="24"
viewBox="0 0 96 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.9012 20.0088C11.5775 20.0088 8.54297 17.1873 8.54297 12.3427V1.9082H11.3912V12.1564C11.3912 15.7499 12.9883 17.2405 16.9012 17.2405C20.8141 17.2405 22.4112 15.7499 22.4112 12.1564V1.9082H25.286V12.3427C25.286 17.1873 22.2515 20.0088 16.9012 20.0088ZM30.7083 19.7426H27.8335V6.51322H30.4688V10.6125H30.6551C31.0544 8.37651 32.8112 6.24703 36.1651 6.24703C39.8385 6.24703 41.6486 8.72256 41.6486 11.7837V19.7426H38.7738V12.5556C38.7738 10.0801 37.6558 8.82903 34.9141 8.82903C32.0127 8.82903 30.7083 10.3197 30.7083 13.1945V19.7426ZM47.085 19.7426H44.2102V1.9082H47.085V11.7571H50.8648L54.831 6.51322H58.1849L53.2072 12.8751L58.1583 19.7426H54.7777L50.8648 14.3391H47.085V19.7426ZM66.2537 20.0088C61.9149 20.0088 59.0667 17.5599 59.0667 13.1412C59.0667 9.01536 61.8883 6.24703 66.2005 6.24703C70.2997 6.24703 73.0947 8.50961 73.0947 12.529C73.0947 13.0081 73.0681 13.3808 72.9882 13.7801H61.7552C61.8616 16.3355 63.1127 17.693 66.1739 17.693C68.9422 17.693 70.0868 16.788 70.0868 15.2175V15.0045H72.9616V15.2441C72.9616 18.0657 70.1933 20.0088 66.2537 20.0088ZM66.1472 8.50961C63.2192 8.50961 61.9415 9.81392 61.7818 12.183H70.3796V12.1297C70.3796 9.68083 68.9688 8.50961 66.1472 8.50961ZM77.3773 24.2678H75.4874V21.6592H78.0694C79.2406 21.6592 79.7198 21.3398 80.1191 20.4347L80.4385 19.7426L73.9169 6.51322H77.1378L80.5183 13.5405L81.8226 16.7081H82.0356L83.2867 13.5139L86.401 6.51322H89.5686L82.6744 21.18C81.5831 23.5491 80.0924 24.2678 77.3773 24.2678Z"
fill="currentColor"
/>
</svg>
);
}
+3 -7
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?: {
@@ -48,25 +49,20 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
{message && <Paragraph>{message}</Paragraph>}
<LinkButton
to={button ? button.to : "/"}
shortcut={{ modifiers: ["meta"], key: "g" }}
shortcut={{ modifiers: ["mod"], key: "g" }}
variant="primary/medium"
LeadingIcon={HomeIcon}
>
{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>
+10 -6
View File
@@ -26,14 +26,16 @@ 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",
!path &&
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={!path}
>
Next
</LinkButton>
@@ -47,14 +49,16 @@ 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",
!path &&
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
)}
onClick={(e) => !path && e.preventDefault()}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={!path}
>
Prev
</LinkButton>
@@ -9,6 +9,10 @@ import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { TextLink } from "./primitives/TextLink";
import { TldrawLogo } from "~/assets/logos/TldrawLogo";
import { UnkeyLogo } from "~/assets/logos/UnkeyLogo";
import { LyftLogo } from "~/assets/logos/LyftLogo";
import { MiddayLogo } from "~/assets/logos/MiddayLogo";
interface QuoteType {
quote: string;
@@ -72,11 +76,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
<div className="flex flex-col items-center gap-4 px-8">
<Paragraph>Trusted by developers at</Paragraph>
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-charcoal-500 xl:justify-between xl:gap-0">
<VerizonLogo />
<ShopifyLogo />
<ATAndTLogo />
<LyftLogo className="w-11" />
<UnkeyLogo />
<MiddayLogo />
<AppsmithLogo />
<CalComLogo />
<TldrawLogo />
</div>
</div>
</div>
+48 -6
View File
@@ -1,3 +1,5 @@
import { createContext, useContext, useState } from "react";
import { useAppOrigin } from "~/hooks/useAppOrigin";
import { useProject } from "~/hooks/useProject";
import { InlineCode } from "./code/InlineCode";
import {
@@ -8,7 +10,31 @@ import {
} from "./primitives/ClientTabs";
import { ClipboardField } from "./primitives/ClipboardField";
import { Paragraph } from "./primitives/Paragraph";
import { useAppOrigin } from "~/hooks/useAppOrigin";
type PackageManagerContextType = {
activePackageManager: string;
setActivePackageManager: (value: string) => void;
};
const PackageManagerContext = createContext<PackageManagerContextType | undefined>(undefined);
export function PackageManagerProvider({ children }: { children: React.ReactNode }) {
const [activePackageManager, setActivePackageManager] = useState("npm");
return (
<PackageManagerContext.Provider value={{ activePackageManager, setActivePackageManager }}>
{children}
</PackageManagerContext.Provider>
);
}
function usePackageManager() {
const context = useContext(PackageManagerContext);
if (context === undefined) {
throw new Error("usePackageManager must be used within a PackageManagerProvider");
}
return context;
}
export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
return (
@@ -131,7 +157,6 @@ export function TriggerDevStep({ extra }: { extra?: string }) {
);
}
// Trigger.dev version 3 setup commands
const v3PackageTag = "latest";
function getApiUrlArg() {
@@ -160,14 +185,19 @@ function getApiUrlArg() {
export function InitCommandV3() {
const project = useProject();
const projectRef = project.ref;
const apiUrlArg = getApiUrlArg();
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
const initCommand = initCommandParts.filter(Boolean).join(" ");
const { activePackageManager, setActivePackageManager } = usePackageManager();
return (
<ClientTabs defaultValue="npm">
<ClientTabs
defaultValue="npm"
value={activePackageManager}
onValueChange={setActivePackageManager}
>
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
@@ -202,8 +232,14 @@ export function InitCommandV3() {
}
export function TriggerDevStepV3() {
const { activePackageManager, setActivePackageManager } = usePackageManager();
return (
<ClientTabs defaultValue="npm">
<ClientTabs
defaultValue="npm"
value={activePackageManager}
onValueChange={setActivePackageManager}
>
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
@@ -238,8 +274,14 @@ export function TriggerDevStepV3() {
}
export function TriggerLoginStepV3() {
const { activePackageManager, setActivePackageManager } = usePackageManager();
return (
<ClientTabs defaultValue="npm">
<ClientTabs
defaultValue="npm"
value={activePackageManager}
onValueChange={setActivePackageManager}
>
<ClientTabsList>
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
+148
View File
@@ -0,0 +1,148 @@
import { Keyboard } from "lucide-react";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
import { Button } from "./primitives/Buttons";
export function Shortcuts() {
return (
<Sheet>
<SheetTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={Keyboard}
leadingIconClassName="text-blue-500"
data-action="shortcuts"
fullWidth
textAlignLeft
shortcut={{ modifiers: ["shift"], key: "?" }}
className="gap-x-0 pl-0.5"
iconSpacing="gap-x-0.5"
>
Shortcuts
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>
<div className="flex items-center gap-x-2">
<Keyboard className="size-5 text-indigo-500" />
<span className="font-sans text-base font-medium text-text-bright">
Keyboard shortcuts
</span>
</div>
</SheetTitle>
<div className="space-y-6 px-4 pb-4 pt-2">
<div className="space-y-3">
<Header3>General</Header3>
<Shortcut name="Close">
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Confirm">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter">
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select filter">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
to
</Paragraph>
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Previous page">
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Next page">
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Help & Feedback">
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Runs page</Header3>
<Shortcut name="Bulk action: Cancel runs">
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Bulk action: Replay runs">
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Bulk action: Clear selection">
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Run page</Header3>
<Shortcut name="Replay run">
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Overview">
<ShortcutKey shortcut={{ key: "o" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Details">
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Context">
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Metadata">
<ShortcutKey shortcut={{ key: "m" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Navigate">
<ShortcutKey shortcut={{ key: "arrowup" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowdown" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Collapse all">
<ShortcutKey shortcut={{ key: "w" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Toggle level">
<ShortcutKey shortcut={{ key: "0" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
to
</Paragraph>
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
<Shortcut name="New schedule">
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Alerts page</Header3>
<Shortcut name="New alert">
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
</Shortcut>
</div>
</div>
</SheetHeader>
</SheetContent>
</Sheet>
);
}
function Shortcut({ children, name }: { children: React.ReactNode; name: string }) {
return (
<div className="flex items-center justify-between gap-x-2">
<span className="text-sm text-text-dimmed">{name}</span>
<span className="flex items-center gap-x-0.5">{children}</span>
</div>
);
}
@@ -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,183 @@
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";
import { Shortcuts } from "../Shortcuts";
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"
/>
<Shortcuts />
</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,20 @@
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";
@@ -44,7 +37,6 @@ import {
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
projectEventsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
@@ -52,6 +44,7 @@ import {
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3ConcurrencyPath,
v3DeploymentsPath,
@@ -64,17 +57,13 @@ 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 { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
@@ -83,12 +72,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">;
@@ -102,6 +90,34 @@ type SideMenuProps = {
defaultValue?: FeedbackType;
};
function V2Countdown() {
const [days, setDays] = useState(0);
useEffect(() => {
const targetDate = new Date("2025-01-31T00:00:00Z");
const calculateDays = () => {
const now = new Date();
const difference = targetDate.getTime() - now.getTime();
return Math.floor(difference / (1000 * 60 * 60 * 24));
};
const timer = setInterval(() => {
setDays(calculateDays());
}, 1000 * 60 * 60); // Update every hour
setDays(calculateDays()); // Initial calculation
return () => clearInterval(timer);
}, []);
return (
<Header2 className="flex-wrap gap-4 text-error">
V2 goes offline in <span className="tabular-nums">{days}d</span>
</Header2>
);
}
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
@@ -226,7 +242,8 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
<div className="m-2">
{project.version === "V2" && (
<div className="flex flex-col gap-3 rounded border border-success/50 bg-success/10 p-3">
<div className="flex flex-col gap-3 rounded border border-error/50 bg-error/5 p-3">
<V2Countdown />
<Paragraph variant="small/bright">
This is a v2 project. V2 will be deprecated on January 31, 2025.{" "}
<TextLink
@@ -261,167 +278,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,
@@ -593,12 +449,6 @@ function V2ProjectSideMenu({
to={projectTriggersPath(organization, project)}
data-action="triggers"
/>
<SideMenuItem
name="Events"
icon={CursorArrowRaysIcon}
activeIconColor="text-sky-500"
to={projectEventsPath(organization, project)}
/>
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
@@ -631,8 +481,6 @@ function V3ProjectSideMenu({
project: SideMenuProject;
organization: MatchedOrganization;
}) {
const { alertsEnabled } = useFeatures();
return (
<>
<SideMenuHeader title={"Project"} />
@@ -649,6 +497,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 +540,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>
);
}
+101 -41
View File
@@ -1,9 +1,10 @@
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
import React, { forwardRef, useImperativeHandle, useRef } from "react";
import React, { forwardRef, ReactNode, useImperativeHandle, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
const sizes = {
small: {
@@ -41,40 +42,52 @@ 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",
textColor: "text-text-dimmed group-disabled/button:text-text-dimmed transition",
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,9 @@ export type ButtonContentPropsType = {
className?: string;
shortcut?: ShortcutDefinition;
variant: keyof typeof variant;
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
tooltip?: ReactNode;
iconSpacing?: string;
};
export function ButtonContent(props: ButtonContentPropsType) {
@@ -169,36 +190,52 @@ export function ButtonContent(props: ButtonContentPropsType) {
fullWidth,
textAlignLeft,
className,
tooltip,
iconSpacing,
} = props;
const variation = allVariants.variant[props.variant];
// Based on the size prop, we'll use the corresponding variant classnames
const btnClassName = cn(allVariants.$all, variation.button);
const iconClassName = variation.icon;
const iconSpacingClassName = variation.iconSpacing;
const shortcutClassName = variation.shortcut;
const textColorClassName = variation.textColor;
return (
const renderShortcutKey = () =>
shortcut && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
);
const buttonContent = (
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
<div
className={cn(
textAlignLeft ? "text-left" : "justify-center",
"flex w-full items-center",
iconSpacingClassName
iconSpacingClassName,
iconSpacing
)}
>
{LeadingIcon &&
(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,32 +251,55 @@ export function ButtonContent(props: ButtonContentPropsType) {
<>{text}</>
))}
{shortcut &&
!tooltip &&
props.shortcutPosition === "before-trailing-icon" &&
renderShortcutKey()}
{TrailingIcon &&
(typeof TrailingIcon === "string" ? (
<NamedIcon
name={TrailingIcon}
className={cn(iconClassName, trailingIconClassName, "shrink-0 justify-end")}
className={cn(
iconClassName,
trailingIconClassName,
"shrink-0 justify-end",
variation.icon
)}
/>
) : (
<TrailingIcon
className={cn(
iconClassName,
textColorClassName,
variation.icon,
trailingIconClassName,
"shrink-0 justify-end"
)}
/>
))}
{shortcut && (
<ShortcutKey
className={cn(shortcutClassName)}
shortcut={shortcut}
variant={variation.shortcutVariant ?? "medium"}
/>
)}
{shortcut &&
!tooltip &&
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
renderShortcutKey()}
</div>
</div>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return buttonContent;
}
type ButtonPropsType = Pick<
@@ -267,7 +327,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 +373,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 +387,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 +402,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 +423,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 }) => (
@@ -5,7 +5,11 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
const ClientTabs = TabsPrimitive.Root;
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -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
)}
>
@@ -4,7 +4,7 @@ import { cn } from "~/utils/cn";
import { Icon, RenderIcon } from "./Icon";
const containerBase =
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-text-link has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-2 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-charcoal-650 has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-1 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
const inputBase =
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
@@ -17,27 +17,27 @@ const variants = {
container:
"px-1 w-full h-10 rounded-[3px] border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-2 text-sm",
iconSize: "h-4 w-4 ml-1",
iconSize: "size-4 ml-1",
shortcut: "mr-1 min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem] select-none",
},
medium: {
container:
"px-1 h-8 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-1.5 rounded text-sm",
iconSize: "h-4 w-4 ml-0.5",
iconSize: "size-4 ml-0.5",
shortcut: "min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem]",
},
small: {
container:
"px-1 h-6 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
input: "px-1 rounded text-xs",
iconSize: "h-3 w-3 ml-0.5",
iconSize: "size-3 ml-0.5",
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
},
tertiary: {
container: "px-1 h-6 w-full rounded hover:bg-charcoal-750",
input: "px-1 rounded text-xs",
iconSize: "h-3 w-3 ml-0.5",
iconSize: "size-3 ml-0.5",
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
},
};
@@ -1,19 +0,0 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
type Props = Parameters<typeof Link>[0] & {
disabled?: boolean;
disabledClassName?: string;
};
export function LinkDisabled({ disabled = false, disabledClassName, ...props }: Props) {
if (disabled) {
return (
<span {...props} className={cn(props.className, disabledClassName)}>
{props.children}
</span>
);
} else {
return <Link {...props}>{props.children}</Link>;
}
}
@@ -2,8 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { ButtonContent } from "./Buttons";
import { LinkDisabled } from "./LinkWithDisabled";
import { LinkButton } from "./Buttons";
export function PaginationControls({
currentPage,
@@ -20,17 +19,18 @@ export function PaginationControls({
}
return (
<nav className="flex items-center gap-1" aria-label="Pagination">
<LinkDisabled
<nav className="flex items-center gap-0.5" aria-label="Pagination">
<LinkButton
to={pageUrl(location, currentPage - 1)}
className={currentPage > 1 ? "group" : ""}
variant="minimal/small"
LeadingIcon={ChevronLeftIcon}
shortcut={{ key: "j" }}
tooltip="Previous"
disabled={currentPage === 1}
disabledClassName="opacity-30 cursor-default"
className={currentPage > 1 ? "group" : ""}
>
<ButtonContent variant="minimal/medium" LeadingIcon={ChevronLeftIcon}>
Previous
</ButtonContent>
</LinkDisabled>
Prev
</LinkButton>
{showPageNumbers
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
@@ -38,16 +38,17 @@ export function PaginationControls({
))
: null}
<LinkDisabled
<LinkButton
to={pageUrl(location, currentPage + 1)}
className={currentPage !== totalPages ? "group" : ""}
variant="minimal/small"
TrailingIcon={ChevronRightIcon}
shortcut={{ key: "k" }}
tooltip="Next"
disabled={currentPage === totalPages}
disabledClassName="opacity-30 cursor-default"
className={currentPage !== totalPages ? "group" : ""}
>
<ButtonContent variant="minimal/medium" TrailingIcon={ChevronRightIcon}>
Next
</ButtonContent>
</LinkDisabled>
Next
</LinkButton>
</nav>
);
}
@@ -61,9 +62,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 focus-visible:focus-custom";
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,
@@ -482,7 +482,11 @@ export function SelectItem({
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
<ShortcutKey
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.SelectItem>
@@ -613,7 +617,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
)}
@@ -0,0 +1,126 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/utils/cn";
import { Header2 } from "./Headers";
import { NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 overflow-y-auto bg-background-dimmed shadow-lg transition ease-in-out border-grid-dimmed data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn(
"sticky top-0 flex items-center justify-between border-b border-grid-bright bg-background-dimmed pb-1.5 pl-3 pr-1.5 pt-2",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="flex items-center gap-1 rounded-sm p-1 pl-0 transition hover:bg-charcoal-750 focus-visible:focus-custom disabled:pointer-events-none">
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
<NamedIcon name="close" className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Title>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("overflow-y-auto px-3 py-1", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
@@ -1,25 +1,35 @@
import { Fragment } from "react";
import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { KeyboardDownIcon } from "~/assets/icons/KeyboardDownIcon";
import { KeyboardLeftIcon } from "~/assets/icons/KeyboardLeftIcon";
import { KeyboardRightIcon } from "~/assets/icons/KeyboardRightIcon";
import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon";
import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon";
import { Modifier, Shortcut } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
} from "@heroicons/react/20/solid";
const medium =
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
medium:
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
medium,
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
export type ShortcutKeyVariant = keyof typeof variants;
type ShortcutKey = Partial<Shortcut>;
type ShortcutKeyDefinition =
| {
windows: ShortcutKey;
mac: ShortcutKey;
}
| ShortcutKey;
type ShortcutKeyProps = {
shortcut: ShortcutDefinition;
shortcut: ShortcutKeyDefinition;
variant: ShortcutKeyVariant;
className?: string;
};
@@ -29,50 +39,62 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
const isMac = platform === "mac";
let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers ?? [];
const character = keyString(relevantShortcut.key, isMac, variant);
const character = relevantShortcut.key ? keyString(relevantShortcut.key, isMac, variant) : null;
return (
<span className={cn(variants[variant], className)}>
{modifiers.map((k) => (
<Fragment key={k}>{modifierString(k, isMac)}</Fragment>
<span key={k}>
<span>{modifierString(k, isMac, variant)}</span>
</span>
))}
{character}
{character && <span>{character}</span>}
</span>
);
}
function keyString(key: String, isMac: boolean, size: "small" | "medium") {
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
key = key.toLowerCase();
const className = size === "small" ? "w-2.5 h-4" : "w-3 h-5";
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
switch (key) {
case "enter":
return isMac ? "↵" : key;
return isMac ? "↵" : <span className="capitalize">Enter</span>;
case "esc":
return <span className="capitalize">Esc</span>;
case "del":
return <span className="capitalize">Del</span>;
case "arrowdown":
return <ChevronDownIcon className={className} />;
return <KeyboardDownIcon className={className} />;
case "arrowup":
return <ChevronUpIcon className={className} />;
return <KeyboardUpIcon className={className} />;
case "arrowleft":
return <ChevronLeftIcon className={className} />;
return <KeyboardLeftIcon className={className} />;
case "arrowright":
return <ChevronRightIcon className={className} />;
return <KeyboardRightIcon className={className} />;
default:
return key;
}
}
function modifierString(modifier: Modifier, isMac: boolean) {
function modifierString(
modifier: Modifier,
isMac: boolean,
variant: "small" | "medium" | "medium/bright"
): string | JSX.Element {
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
switch (modifier) {
case "alt":
return isMac ? "⌥" : "Alt+";
return isMac ? "⌥" : <span className="capitalize">Alt</span>;
case "ctrl":
return isMac ? "⌃" : "Ctrl+";
return isMac ? "⌃" : <span className="capitalize">Ctrl</span>;
case "meta":
return isMac ? "⌘" : "⊞+";
return isMac ? "⌘" : <KeyboardWindowsIcon className={className} />;
case "shift":
return isMac ? "⇧" : "Shift+";
return "⇧";
case "mod":
return isMac ? "⌘" : "Ctrl+";
return isMac ? "⌘" : <span className="capitalize">Ctrl</span>;
}
}
@@ -29,7 +29,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:focus-custom disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
@@ -26,8 +26,8 @@ export function Spinner({
foreground: "#3C4B62",
},
dark: {
background: "#15171A",
foreground: "#272A2E",
background: "rgba(18, 19, 23, 0.35)",
foreground: "#1A1B1F",
},
};
@@ -8,15 +8,15 @@ const variations = {
large: {
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition focus-custom",
root: "h-6 w-11",
thumb: "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-charcoal-400 group-hover:text-charcoal-200 transition",
thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
text: "text-sm text-text-dimmed",
},
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",
thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
text: "text-xs text-text-dimmed",
},
};
@@ -38,7 +38,7 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
) : null}
<div
className={cn(
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-text-link group-data-[state=unchecked]:bg-charcoal-700",
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
root
)}
>
+175 -67
View File
@@ -1,10 +1,33 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import { ReactNode, forwardRef, useState } from "react";
import React, { ReactNode, forwardRef, useState, useContext, createContext } from "react";
import { cn } from "~/utils/cn";
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
import { InfoIconTooltip } from "./Tooltip";
const variants = {
bright: {
header: "bg-background-bright",
cell: "group-hover/table-row:bg-charcoal-750 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
stickyCell: "bg-background-bright group-hover/table-row:bg-charcoal-750",
menuButton:
"bg-background-bright group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/70 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
menuButtonDivider: "group-hover/table-row:border-charcoal-600/70",
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
},
dimmed: {
header: "bg-background-dimmed",
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
stickyCell: "group-hover/table-row:bg-charcoal-800",
menuButton:
"bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
menuButtonDivider: "group-hover/table-row:border-grid-dimmed",
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
},
} as const;
export type TableVariant = keyof typeof variants;
type TableProps = {
containerClassName?: string;
className?: string;
@@ -12,20 +35,25 @@ type TableProps = {
fullWidth?: boolean;
};
export const Table = forwardRef<HTMLTableElement, TableProps>(
({ className, containerClassName, children, fullWidth }, ref) => {
// Add TableContext
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
return (
<div
className={cn(
"overflow-x-auto whitespace-nowrap rounded-md border border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
containerClassName,
fullWidth && "w-full"
)}
>
<table ref={ref} className={cn("w-full", className)}>
{children}
</table>
</div>
<TableContext.Provider value={{ variant }}>
<div
className={cn(
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
containerClassName,
fullWidth && "w-full"
)}
>
<table ref={ref} className={cn("w-full", className)}>
{children}
</table>
</div>
</TableContext.Provider>
);
}
);
@@ -37,11 +65,13 @@ type TableHeaderProps = {
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
({ className, children }, ref) => {
const { variant } = useContext(TableContext);
return (
<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 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
variants[variant].header,
className
)}
>
@@ -59,10 +89,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>
);
@@ -73,12 +100,22 @@ type TableRowProps = {
className?: string;
children: ReactNode;
disabled?: boolean;
isSelected?: boolean;
};
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, disabled, children }, ref) => {
({ className, disabled, isSelected, children }, ref) => {
const { variant } = useContext(TableContext);
return (
<tr ref={ref} className={cn(disabled && "opacity-50", "group/table-row w-full", className)}>
<tr
ref={ref}
className={cn(
"group/table-row relative w-full outline-none after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
isSelected && variants[variant].rowSelected,
disabled && "opacity-50",
className
)}
>
{children}
</tr>
);
@@ -88,7 +125,7 @@ export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
type TableCellBasicProps = {
className?: string;
alignment?: "left" | "center" | "right";
children: ReactNode;
children?: ReactNode;
colSpan?: number;
};
@@ -114,11 +151,12 @@ 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
)}
colSpan={colSpan}
tabIndex={-1}
>
{hiddenLabel ? (
<span className="sr-only">{children}</span>
@@ -141,18 +179,12 @@ type TableCellProps = TableCellBasicProps & {
hasAction?: boolean;
isSticky?: boolean;
actionClassName?: string;
rowHoverStyle?: keyof typeof rowHoverStyles;
rowHoverStyle?: string;
isSelected?: boolean;
isTabbableCell?: boolean;
children?: ReactNode;
};
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",
};
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]";
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
(
{
@@ -165,7 +197,8 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
onClick,
hasAction = false,
isSticky = false,
rowHoverStyle = "default",
isSelected,
isTabbableCell = false,
},
ref
) => {
@@ -180,33 +213,47 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
const flexClasses = cn(
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
"flex w-full whitespace-nowrap px-3 py-3 items-center text-xs text-text-dimmed",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
? "justify-center text-center"
: "justify-end text-right"
);
const { variant } = useContext(TableContext);
return (
<td
ref={ref}
className={cn(
"text-xs text-charcoal-400 transition-colors",
to || onClick || hasAction ? "cursor-pointer" : "px-3 py-3 align-middle",
"text-xs text-charcoal-400 has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
variants[variant].cell,
to || onClick || hasAction ? "cursor-pointer" : "cursor-default px-3 py-3 align-middle",
!to && !onClick && alignmentClassName,
isSticky && stickyStyles,
rowHoverStyles[rowHoverStyle],
isSticky &&
"[&:has(.group-hover/table-row:block)]:w-auto sticky right-0 bg-background-dimmed",
isSticky && variants[variant].stickyCell,
isSelected && variants[variant].rowSelected,
!isSelected &&
"group-hover/table-row:before:absolute group-hover/table-row:before:left-0 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:before:bg-charcoal-750 group-hover/table-row:after:absolute 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 group-hover/table-row:after:bg-charcoal-750 group-focus-visible/table-row:bg-background-bright",
className
)}
colSpan={colSpan}
>
{to ? (
<Link to={to} className={cn("focus-custom", flexClasses, actionClassName)}>
<Link
to={to}
className={cn("cursor-pointer focus:outline-none", flexClasses, actionClassName)}
tabIndex={isTabbableCell ? 0 : -1}
>
{children}
</Link>
) : onClick ? (
<button onClick={onClick} className={cn("focus-custom", flexClasses, actionClassName)}>
<button
onClick={onClick}
className={cn("cursor-pointer focus:outline-none", flexClasses, actionClassName)}
tabIndex={isTabbableCell ? 0 : -1}
>
{children}
</button>
) : (
@@ -237,42 +284,103 @@ 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>
);
});
export const TableCellMenu = forwardRef<
HTMLTableCellElement,
{
TableCellProps & {
className?: string;
children?: ReactNode;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
visibleButtons?: ReactNode;
hiddenButtons?: ReactNode;
popoverContent?: ReactNode;
children?: ReactNode;
isSelected?: boolean;
}
>(({ 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,
isSelected,
},
ref
) => {
const [isOpen, setIsOpen] = useState(false);
const { variant } = useContext(TableContext);
return (
<TableCell
className={className}
isSticky={isSticky}
onClick={onClick}
ref={ref}
alignment="right"
hasAction={true}
isSelected={isSelected}
>
<div className="relative h-full p-1">
<div
className={cn(
"absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 rounded-[0.25rem] p-0.5 group-hover/table-row:ring-1",
variants[variant].menuButton
)}
>
{/* Hidden buttons that show on hover */}
{hiddenButtons && (
<div
className={cn(
"hidden pr-0.5 group-hover/table-row:block group-hover/table-row:border-r",
variants[variant].menuButtonDivider
)}
>
{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;
@@ -4,9 +4,9 @@ import { cn } from "~/utils/cn";
const variations = {
primary:
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group",
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
secondary:
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group",
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group focus-visible:focus-custom",
} as const;
type TextLinkProps = {
@@ -34,14 +34,14 @@ export function TextLink({
<Link to={to} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && (
<NamedIcon name={trailingIcon} className={cn("h-4 w-4", trailingIconClassName)} />
<NamedIcon name={trailingIcon} className={cn("size-4", trailingIconClassName)} />
)}
</Link>
) : href ? (
<a href={href} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && (
<NamedIcon name={trailingIcon} className={cn("h-4 w-4", trailingIconClassName)} />
<NamedIcon name={trailingIcon} className={cn("size-4", trailingIconClassName)} />
)}
</a>
) : (
@@ -6,6 +6,7 @@ import { useTypedLoaderData } from "remix-typedjson";
import { loader } from "~/root";
import { useEffect } from "react";
import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -39,23 +40,27 @@ export function ToastUI({
}) {
return (
<div
className={`self-end rounded-md border border-grid-bright bg-background-dimmed`}
className={cn(
"self-end rounded-md border border-grid-bright bg-background-dimmed",
variant === "success" && "border-success",
variant === "error" && "border-error"
)}
style={{
width: toastWidth,
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
{variant === "success" ? (
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
) : (
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
)}
<Paragraph className="py-1 text-text-dimmed">{message}</Paragraph>
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
<button
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="h-4 w-4" />
<XMarkIcon className="size-4" />
</button>
</div>
</div>
@@ -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: ["mod"],
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: ["mod"], 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: ["mod"], key: "enter" }}
>
{isLoading ? "Attempting resume..." : "Attempt resume"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant={"tertiary/medium"}>Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
);
}
@@ -118,3 +118,34 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
}
}
}
// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"BUILDING",
"DEPLOYING",
"DEPLOYED",
"FAILED",
"TIMED_OUT",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "The deployment is queued and waiting to be processed.";
case "BUILDING":
return "The code is being built and prepared for deployment.";
case "DEPLOYING":
return "The deployment is in progress and tasks are being indexed.";
case "DEPLOYED":
return "The deployment has completed successfully.";
case "CANCELED":
return "The deployment was manually canceled.";
case "FAILED":
return "The deployment encountered an error and could not complete.";
case "TIMED_OUT":
return "The deployment exceeded the maximum allowed time and was stopped.";
default: {
assertNever(status);
}
}
}
@@ -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,14 +152,14 @@ 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 }}
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
>
{isSubmitting ? "Replaying..." : "Replay run"}
</Button>
@@ -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,10 +45,10 @@ 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" }}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Retrying..." : "Retry indexing"}
</Button>
@@ -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,10 +45,10 @@ 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" }}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Roll back deployment"}
</Button>
+394 -241
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 (
@@ -340,7 +356,7 @@ function StatusDropdown({
<TooltipTrigger className="group flex w-full flex-col py-0">
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<TooltipContent side="right" sideOffset={50}>
<Paragraph variant="extra-small">
{descriptionForTaskRunStatus(item.value)}
</Paragraph>
@@ -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,
@@ -522,9 +444,11 @@ function TasksDropdown({
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.slug}
key={`${item.triggerSource}-${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: ["mod"],
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: ["mod"],
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: ["mod"],
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>
);
}
@@ -121,7 +121,7 @@ export function RunInspector({
/>
)}
</div>
<div className="px-3">
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<TabContainer>
<TabButton
isActive={!tab || tab === "overview"}
@@ -22,7 +22,7 @@ export function RunTag({ tag }: { tag: string }) {
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
{tagResult.key}
</span>
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
<span className="flex items-center whitespace-nowrap rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
{tagResult.value}
</span>
</span>
@@ -93,7 +93,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
}, []);
return (
<div className="flex w-full flex-row">
<div className="flex w-full">
<Input
name="search"
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
@@ -103,7 +103,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
defaultValue={search}
onChange={(e) => handleSearchChange(e.target.value)}
/>
<SelectGroup>
<SelectGroup className="ml-2">
<Select name="type" value={type ?? "ALL"} onValueChange={handleTypeChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder={"Select type"} className="ml-2 whitespace-nowrap p-0" />
@@ -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: ["mod"],
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);
}
@@ -67,7 +67,7 @@ export function SpanInspector({
/>
)}
</div>
<div className="px-3">
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<TabContainer>
<TabButton
isActive={!tab || tab === "overview"}
@@ -2,15 +2,14 @@ import {
CheckCircleIcon,
ClockIcon,
NoSymbolIcon,
PauseCircleIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
import { TaskRunAttemptStatus } from "~/database-types";
import assertNever from "assert-never";
import { SnowflakeIcon } from "lucide-react";
import { HourglassIcon, SnowflakeIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskRunAttemptStatus } from "~/database-types";
import { cn } from "~/utils/cn";
export const allTaskRunAttemptStatuses = Object.values(
@@ -65,7 +64,7 @@ export function TaskRunAttemptStatusIcon({
case "EXECUTING":
return <Spinner className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "PAUSED":
return <SnowflakeIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
return <HourglassIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "FAILED":
return <XCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
case "CANCELED":
@@ -91,7 +90,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus
case "EXECUTING":
return "text-pending";
case "PAUSED":
return "text-sky-300";
return "text-charcoal-500";
case "FAILED":
return "text-error";
case "CANCELED":
@@ -117,7 +116,7 @@ export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null):
case "EXECUTING":
return "Executing";
case "PAUSED":
return "Frozen";
return "Waiting";
case "FAILED":
return "Failed";
case "CANCELED":
@@ -8,13 +8,12 @@ import {
NoSymbolIcon,
PauseCircleIcon,
RectangleStackIcon,
StopIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { SnowflakeIcon } from "lucide-react";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -41,9 +40,9 @@ export const filterableTaskRunStatuses = [
"WAITING_FOR_DEPLOY",
"DELAYED",
"PENDING",
"WAITING_TO_RESUME",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
@@ -55,21 +54,21 @@ export const filterableTaskRunStatuses = [
] as const satisfies Readonly<Array<TaskRunStatus>>;
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
DELAYED: "Task has been delayed and is waiting to be executed",
PENDING: "Task is waiting to be executed",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
EXECUTING: "Task is currently being executed",
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure",
WAITING_TO_RESUME: "Task has been frozen and is waiting to be resumed",
COMPLETED_SUCCESSFULLY: "Task has been successfully completed",
CANCELED: "Task has been canceled",
COMPLETED_WITH_ERRORS: "Task has failed with errors",
INTERRUPTED: "Task has failed because it was interrupted",
SYSTEM_FAILURE: "Task has failed due to a system failure",
PAUSED: "Task has been paused by the user",
CRASHED: "Task has crashed and won't be retried",
EXPIRED: "Task has surpassed its ttl and won't be executed",
TIMED_OUT: "Task has failed because it exceeded its maxDuration",
DELAYED: "Task has been delayed and is waiting to be executed.",
PENDING: "Task is waiting to be executed.",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing.",
EXECUTING: "Task is currently being executed.",
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure.",
WAITING_TO_RESUME: `You have used a "wait" function. When the wait is complete, the task will resume execution.`,
COMPLETED_SUCCESSFULLY: "Task has been successfully completed.",
CANCELED: "Task has been canceled.",
COMPLETED_WITH_ERRORS: "Task has failed with errors.",
INTERRUPTED: "Task has failed because it was interrupted.",
SYSTEM_FAILURE: "Task has failed due to a system failure.",
PAUSED: "Task has been paused by the user.",
CRASHED: "Task has crashed and won't be retried.",
EXPIRED: "Task has surpassed its ttl and won't be executed.",
TIMED_OUT: "Task has failed because it exceeded its maxDuration.",
};
export const QUEUED_STATUSES = [
@@ -126,7 +125,7 @@ export function TaskRunStatusIcon({
case "EXECUTING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_TO_RESUME":
return <SnowflakeIcon className={cn(runStatusClassNameColor(status), className)} />;
return <HourglassIcon className={cn(runStatusClassNameColor(status), className)} />;
case "RETRYING_AFTER_FAILURE":
return <ArrowPathIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PAUSED":
@@ -165,7 +164,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
case "RETRYING_AFTER_FAILURE":
return "text-pending";
case "WAITING_TO_RESUME":
return "text-sky-300";
return "text-charcoal-500";
case "PAUSED":
return "text-amber-300";
case "CANCELED":
@@ -200,7 +199,7 @@ export function runStatusTitle(status: TaskRunStatus): string {
case "EXECUTING":
return "Executing";
case "WAITING_TO_RESUME":
return "Frozen";
return "Waiting";
case "RETRYING_AFTER_FAILURE":
return "Reattempting";
case "PAUSED":
@@ -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";
@@ -37,6 +39,7 @@ import {
TableHeader,
TableHeaderCell,
TableRow,
TableVariant,
} from "../../primitives/Table";
import { CancelRunDialog } from "./CancelRunDialog";
import { LiveTimer } from "./LiveTimer";
@@ -56,6 +59,7 @@ type RunsTableProps = {
runs: RunListItem[];
isLoading?: boolean;
allowSelection?: boolean;
variant?: TableVariant;
};
export function TaskRunsTable({
@@ -65,6 +69,7 @@ export function TaskRunsTable({
runs,
isLoading = false,
allowSelection = false,
variant = "dimmed",
}: RunsTableProps) {
const user = useUser();
const organization = useOrganization();
@@ -102,11 +107,11 @@ export function TaskRunsTable({
);
return (
<Table>
<Table variant={variant} 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 +215,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 +239,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 +261,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 +290,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) => {
@@ -295,7 +303,7 @@ export function TaskRunsTable({
/>
</TableCell>
)}
<TableCell to={path} alignment="right">
<TableCell to={path} alignment="right" isTabbableCell>
{formatNumber(run.number)}
</TableCell>
<TableCell to={path}>
@@ -367,7 +375,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 +413,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 +591,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>
);
+69 -14
View File
@@ -13,6 +13,8 @@ import { logger } from "./services/logger.server";
import { isValidDatabaseUrl } from "./utils/db";
import { singleton } from "./utils/singleton";
import { $transaction as transac } from "@trigger.dev/database";
import { startActiveSpan } from "./v3/tracer.server";
import { Span } from "@opentelemetry/api";
export type {
PrismaTransactionClient,
@@ -21,25 +23,76 @@ export type {
PrismaReplicaClient,
};
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
name: string,
fn: (prisma: PrismaTransactionClient, span?: Span) => Promise<R>,
options?: PrismaTransactionOptions
): Promise<R | undefined>;
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
fn: (prisma: PrismaTransactionClient) => Promise<R>,
options?: PrismaTransactionOptions
): Promise<R | undefined>;
export async function $transaction<R>(
prisma: PrismaClientOrTransaction,
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
options?: PrismaTransactionOptions
): Promise<R | undefined> {
return transac(
prisma,
fn,
(error) => {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
message: error.message,
name: error.name,
});
},
options
);
if (typeof fnOrName === "string") {
return await startActiveSpan(fnOrName, async (span) => {
span.setAttribute("$transaction", true);
if (options?.isolationLevel) {
span.setAttribute("isolation_level", options.isolationLevel);
}
if (options?.timeout) {
span.setAttribute("timeout", options.timeout);
}
if (options?.maxWait) {
span.setAttribute("max_wait", options.maxWait);
}
if (options?.swallowPrismaErrors) {
span.setAttribute("swallow_prisma_errors", options.swallowPrismaErrors);
}
const fn = fnOrOptions as (prisma: PrismaTransactionClient, span: Span) => Promise<R>;
return transac(
prisma,
(client) => fn(client, span),
(error) => {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
message: error.message,
name: error.name,
});
},
options
);
});
} else {
return transac(
prisma,
fnOrName,
(error) => {
logger.error("prisma.$transaction error", {
code: error.code,
meta: error.meta,
stack: error.stack,
message: error.message,
name: error.name,
});
},
typeof fnOrOptions === "function" ? undefined : fnOrOptions
);
}
}
export { Prisma };
@@ -58,6 +111,7 @@ function getClient() {
const databaseUrl = extendQueryParams(DATABASE_URL, {
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
});
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
@@ -109,6 +163,7 @@ function getReplicaClient() {
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
});
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
+246 -3
View File
@@ -13,6 +13,7 @@ const EnvironmentSchema = z.object({
),
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20),
DIRECT_URL: z
.string()
.refine(
@@ -31,6 +32,8 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
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"),
@@ -42,9 +45,16 @@ const EnvironmentSchema = z.object({
HIGHLIGHT_PROJECT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_SECURE: z.coerce.boolean().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
PLAIN_API_KEY: z.string().optional(),
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
WORKER_SCHEMA: z.string().default("graphile_worker"),
@@ -82,6 +92,96 @@ const EnvironmentSchema = z.object({
REDIS_PASSWORD: z.string().optional(),
REDIS_TLS_DISABLED: z.string().optional(),
RATE_LIMIT_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
RATE_LIMIT_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
RATE_LIMIT_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
RATE_LIMIT_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
RATE_LIMIT_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
RATE_LIMIT_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
RATE_LIMIT_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
CACHE_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
CACHE_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
CACHE_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
CACHE_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
CACHE_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
CACHE_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
PUBSUB_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
PUBSUB_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
PUBSUB_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
PUBSUB_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
PUBSUB_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
PUBSUB_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
PUBSUB_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
@@ -138,8 +238,15 @@ const EnvironmentSchema = z.object({
CONTAINER_REGISTRY_ORIGIN: z.string().optional(),
CONTAINER_REGISTRY_USERNAME: z.string().optional(),
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
ENABLE_REGISTRY_PROXY: z.string().optional(),
DEPLOY_REGISTRY_HOST: z.string().optional(),
DEPLOY_REGISTRY_USERNAME: z.string().optional(),
DEPLOY_REGISTRY_PASSWORD: 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(),
@@ -149,6 +256,8 @@ const EnvironmentSchema = z.object({
SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10),
SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100),
SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100),
SHARED_QUEUE_CONSUMER_EMIT_RESUME_DEPENDENCY_TIMEOUT_MS: z.coerce.number().int().default(1000),
SHARED_QUEUE_CONSUMER_RESOLVE_PAYLOADS_BATCH_SIZE: z.coerce.number().int().default(25),
// Development OTEL environment variables
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
@@ -177,20 +286,61 @@ const EnvironmentSchema = z.object({
// Internal OTEL environment variables
INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(),
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADER_NAME: z.string().optional(),
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADER_VALUE: z.string().optional(),
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADERS: z.string().optional(),
INTERNAL_OTEL_TRACE_LOGGING_ENABLED: z.string().default("1"),
// this means 1/20 traces or 5% of traces will be sampled (sampled = recorded)
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
/** These enable the alerts feature in v3 */
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
ALERT_FROM_EMAIL: z.string().optional(),
ALERT_REPLY_TO_EMAIL: z.string().optional(),
ALERT_RESEND_API_KEY: z.string().optional(),
ALERT_SMTP_HOST: z.string().optional(),
ALERT_SMTP_PORT: z.coerce.number().optional(),
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
ALERT_SMTP_USER: z.string().optional(),
ALERT_SMTP_PASSWORD: z.string().optional(),
ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500),
ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000),
ALERT_RATE_LIMITER_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
ALERT_RATE_LIMITER_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
ALERT_RATE_LIMITER_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
ALERT_RATE_LIMITER_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
ALERT_RATE_LIMITER_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
@@ -200,6 +350,15 @@ const EnvironmentSchema = z.object({
.number()
.int()
.default(60 * 1000 * 15),
MARQS_SHARED_QUEUE_LIMIT: z.coerce.number().int().default(1000),
MARQS_DEV_QUEUE_LIMIT: z.coerce.number().int().default(1000),
MARQS_MAXIMUM_NACK_COUNT: z.coerce.number().int().default(64),
MARQS_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75),
MARQS_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3),
MARQS_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25),
MARQS_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0),
MARQS_MAXIMUM_ORG_COUNT: z.coerce.number().int().optional(),
PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
VERBOSE_GRAPHILE_LOGGING: z.string().default("false"),
@@ -231,10 +390,94 @@ 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
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
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),
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
BATCH_METADATA_OPERATIONS_FLUSH_ENABLED: z.string().default("1"),
BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED: z.string().default("1"),
LEGACY_RUN_ENGINE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(1),
LEGACY_RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
LEGACY_RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
LEGACY_RUN_ENGINE_WORKER_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
LEGACY_RUN_ENGINE_WORKER_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
LEGACY_RUN_ENGINE_WORKER_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
LEGACY_RUN_ENGINE_WORKER_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
LEGACY_RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
LEGACY_RUN_ENGINE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
COMMON_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
COMMON_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
COMMON_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
COMMON_WORKER_REDIS_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_HOST),
COMMON_WORKER_REDIS_READER_HOST: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_READER_HOST),
COMMON_WORKER_REDIS_READER_PORT: z.coerce
.number()
.optional()
.transform(
(v) =>
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
),
COMMON_WORKER_REDIS_PORT: z.coerce
.number()
.optional()
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
COMMON_WORKER_REDIS_USERNAME: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_USERNAME),
COMMON_WORKER_REDIS_PASSWORD: z
.string()
.optional()
.transform((v) => v ?? process.env.REDIS_PASSWORD),
COMMON_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
});
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,7 +5,7 @@ import { getUsername } from "~/utils/username";
export type { RuntimeEnvironment };
export async function findEnvironmentByApiKey(apiKey: string) {
const environment = await prisma.runtimeEnvironment.findUnique({
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
apiKey,
},
@@ -25,7 +25,7 @@ export async function findEnvironmentByApiKey(apiKey: string) {
}
export async function findEnvironmentByPublicApiKey(apiKey: string) {
const environment = await prisma.runtimeEnvironment.findUnique({
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
pkApiKey: apiKey,
},
@@ -45,7 +45,7 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
}
export async function findEnvironmentById(id: string) {
const environment = await prisma.runtimeEnvironment.findUnique({
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id,
},
@@ -85,7 +85,7 @@ export async function createNewSession(environment: RuntimeEnvironment, ipAddres
}
export async function disconnectSession(environmentId: string) {
const environment = await prisma.runtimeEnvironment.findUnique({
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id: environmentId,
},
@@ -0,0 +1,63 @@
import { QueueOptions } from "@trigger.dev/core/v3/schemas";
import { TaskQueue } from "@trigger.dev/database";
import { prisma } from "~/db.server";
export async function findQueueInEnvironment(
queueName: string,
environmentId: string,
backgroundWorkerTaskId?: string,
backgroundTask?: { queueConfig?: unknown }
): Promise<TaskQueue | undefined> {
const sanitizedQueueName = sanitizeQueueName(queueName);
const queue = await prisma.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environmentId,
name: sanitizedQueueName,
},
});
if (queue) {
return queue;
}
const task = backgroundTask
? backgroundTask
: backgroundWorkerTaskId
? await prisma.backgroundWorkerTask.findFirst({
where: {
id: backgroundWorkerTaskId,
},
})
: undefined;
if (!task) {
return;
}
const queueConfig = QueueOptions.safeParse(task.queueConfig);
if (queueConfig.success) {
const taskQueueName = queueConfig.data.name
? sanitizeQueueName(queueConfig.data.name)
: undefined;
if (taskQueueName && taskQueueName !== sanitizedQueueName) {
const queue = await prisma.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environmentId,
name: taskQueueName,
},
});
if (queue) {
return queue;
}
}
}
}
// Only allow alphanumeric characters, underscores, hyphens, and slashes (and only the first 128 characters)
export function sanitizeQueueName(queueName: string) {
return queueName.replace(/[^a-zA-Z0-9_\-\/]/g, "").substring(0, 128);
}
+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;
}
@@ -1,169 +0,0 @@
import { PrismaClient, prisma } from "~/db.server";
import { Direction, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { getUsername } from "~/utils/username";
type EventListOptions = {
userId: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
filterEnvironment?: FilterableEnvironment;
cursor?: string;
pageSize?: number;
from?: number;
to?: number;
};
const DEFAULT_PAGE_SIZE = 20;
export type EventList = Awaited<ReturnType<EventListPresenter["call"]>>;
export class EventListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
organizationSlug,
projectSlug,
filterEnvironment,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
from,
to,
}: EventListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
const events = await this.#prismaClient.eventRecord.findMany({
select: {
id: true,
name: true,
deliverAt: true,
deliveredAt: true,
isTest: true,
createdAt: true,
cancelledAt: true,
environment: {
select: {
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
runs: {
select: {
id: true,
},
},
},
where: {
internal: false,
name: {
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
},
projectId: project.id,
organizationId: organization.id,
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
createdAt: {
gte: from ? new Date(from).toISOString() : undefined,
lte: to ? new Date(to).toISOString() : undefined,
},
},
orderBy: [{ id: "desc" }],
//take an extra record to tell if there are more
take: directionMultiplier * (pageSize + 1),
//skip the cursor if there is one
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
: undefined,
});
const hasMore = events.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
let previous: string | undefined;
switch (direction) {
case "forward":
previous = cursor ? events.at(0)?.id : undefined;
if (hasMore) {
next = events[pageSize - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = events[1]?.id;
next = events[pageSize]?.id;
} else {
next = events[pageSize - 1]?.id;
}
break;
}
const eventsToReturn =
direction === "backward" && hasMore
? events.slice(1, pageSize + 1)
: events.slice(0, pageSize);
return {
events: eventsToReturn.map((event) => ({
id: event.id,
name: event.name,
deliverAt: event.deliverAt,
deliveredAt: event.deliveredAt,
createdAt: event.createdAt,
cancelledAt: event.cancelledAt,
isTest: event.isTest,
environment: {
type: event.environment.type,
slug: event.environment.slug,
userId: event.environment.orgMember?.user.id,
userName: getUsername(event.environment.orgMember?.user),
},
runs: event.runs.length,
})),
pagination: {
next,
previous,
},
};
}
}
@@ -9,7 +9,7 @@ export class ApiBatchResultsPresenter extends BasePresenter {
env: AuthenticatedEnvironment
): Promise<BatchTaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const batchRun = await this._prisma.batchTaskRun.findUnique({
const batchRun = await this._prisma.batchTaskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
@@ -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 = {
@@ -39,6 +39,7 @@ const commonRunSelect = {
idempotencyKey: true,
isTest: true,
depth: true,
scheduleId: true,
lockedToVersion: {
select: {
version: true,
@@ -59,48 +60,45 @@ 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,
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;
@@ -159,20 +157,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
output: $output,
outputPresignedUrl: $outputPresignedUrl,
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
schedule: taskRun.schedule
? {
id: taskRun.schedule.friendlyId,
externalId: taskRun.schedule.externalId ?? undefined,
deduplicationKey: taskRun.schedule.userProvidedDeduplicationKey
? taskRun.schedule.deduplicationKey
: undefined,
generator: {
type: "CRON" as const,
expression: taskRun.schedule.generatorExpression,
description: taskRun.schedule.generatorDescription,
},
}
: undefined,
schedule: await resolveSchedule(taskRun),
// We're removing attempts from the API
attemptCount: taskRun.attempts.length,
attempts: [],
@@ -322,6 +307,33 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
}
}
async function resolveSchedule(run: CommonRelatedRun) {
if (!run.scheduleId) {
return undefined;
}
const schedule = await prisma.taskSchedule.findFirst({
where: {
id: run.scheduleId,
},
});
if (!schedule) {
return undefined;
}
return {
id: schedule.friendlyId,
externalId: schedule.externalId ?? undefined,
deduplicationKey: schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : undefined,
generator: {
type: "CRON" as const,
expression: schedule.generatorExpression,
description: schedule.generatorDescription,
},
};
}
async function createCommonRunStructure(run: CommonRelatedRun) {
const metadata = await parsePacket({
data: run.metadata ?? undefined,
@@ -1,4 +1,4 @@
import { ListRunResponse, ListRunResponseItem, RunStatus } from "@trigger.dev/core/v3";
import { ListRunResponse, ListRunResponseItem, parsePacket, RunStatus } from "@trigger.dev/core/v3";
import { Project, RuntimeEnvironment, TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
@@ -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,42 +210,61 @@ 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 });
const results = await presenter.call(options);
const data: ListRunResponseItem[] = results.runs.map((run) => {
return {
id: run.friendlyId,
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
taskIdentifier: run.taskIdentifier,
idempotencyKey: run.idempotencyKey,
version: run.version ?? undefined,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt),
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
isTest: run.isTest,
ttl: run.ttl ?? undefined,
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
env: {
id: run.environment.id,
name: run.environment.slug,
user: run.environment.userName,
},
tags: run.tags,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.usageDurationMs,
depth: run.depth,
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
),
};
});
logger.debug("RunListPresenter results", { results });
const data: ListRunResponseItem[] = await Promise.all(
results.runs.map(async (run) => {
const metadata = await parsePacket(
{
data: run.metadata ?? undefined,
dataType: run.metadataType,
},
{
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
}
);
return {
id: run.friendlyId,
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
taskIdentifier: run.taskIdentifier,
idempotencyKey: run.idempotencyKey,
version: run.version ?? undefined,
createdAt: new Date(run.createdAt),
updatedAt: new Date(run.updatedAt),
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
isTest: run.isTest,
ttl: run.ttl ?? undefined,
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
env: {
id: run.environment.id,
name: run.environment.slug,
user: run.environment.userName,
},
tags: run.tags,
costInCents: run.costInCents,
baseCostInCents: run.baseCostInCents,
durationMs: run.usageDurationMs,
depth: run.depth,
metadata,
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
),
};
})
);
return {
data,
@@ -9,7 +9,7 @@ export class ApiRunResultPresenter extends BasePresenter {
env: AuthenticatedEnvironment
): Promise<TaskRunExecutionResult | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const taskRun = await this._prisma.taskRun.findUnique({
const taskRun = await this._prisma.taskRun.findFirst({
where: {
friendlyId,
runtimeEnvironmentId: env.id,
@@ -0,0 +1,226 @@
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;
completedAt: Date | null;
runCount: BigInt;
batchVersion: string;
}[]
>`
SELECT
b.id,
b."friendlyId",
b."runtimeEnvironmentId",
b.status,
b."createdAt",
b."updatedAt",
b."completedAt",
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: batch.completedAt
? batch.completedAt.toISOString()
: 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,
};
}
}
@@ -53,12 +53,10 @@ export class DeploymentPresenter {
},
});
const deployment = await this.#prismaClient.workerDeployment.findUniqueOrThrow({
const deployment = await this.#prismaClient.workerDeployment.findFirstOrThrow({
where: {
projectId_shortCode: {
projectId: project.id,
shortCode: deploymentShortCode,
},
projectId: project.id,
shortCode: deploymentShortCode,
},
select: {
id: true,
@@ -108,6 +106,7 @@ export class DeploymentPresenter {
},
},
sdkVersion: true,
cliVersion: true,
},
},
triggeredBy: {
@@ -145,6 +144,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,
@@ -15,7 +15,7 @@ export class EnvironmentVariablesPresenter {
}
public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) {
const project = await this.#prismaClient.project.findUnique({
const project = await this.#prismaClient.project.findFirst({
select: {
id: true,
},
@@ -8,7 +8,7 @@ import { WebClient } from "@slack/web-api";
export class NewAlertChannelPresenter extends BasePresenter {
public async call(projectId: string) {
const project = await this._prisma.project.findUniqueOrThrow({
const project = await this._prisma.project.findFirstOrThrow({
where: {
id: projectId,
},
@@ -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({
@@ -122,7 +131,7 @@ export class RunListPresenter extends BasePresenter {
//bulk id
if (bulkId) {
const bulkAction = await this._replica.bulkActionGroup.findUnique({
const bulkAction = await this._replica.bulkActionGroup.findFirst({
select: {
items: {
select: {
@@ -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,12 @@ export class RunListPresenter extends BasePresenter {
costInCents: number;
baseCostInCents: number;
usageDurationMs: BigInt;
tags: string[];
tags: null | string[];
depth: number;
rootTaskRunId: string | null;
batchId: string | null;
metadata: string | null;
metadataType: string;
}[]
>`
SELECT
@@ -194,15 +243,13 @@ 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",
tr."metadata" AS "metadata",
tr."metadataType" AS "metadataType"
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 +262,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 +297,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,9 +375,11 @@ 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,
metadata: run.metadata,
metadataType: run.metadataType,
};
}),
pagination: {
@@ -21,7 +21,7 @@ export class RunStreamPresenter {
request: Request;
runFriendlyId: TaskRun["friendlyId"];
}) {
const run = await this.#prismaClient.taskRun.findUnique({
const run = await this.#prismaClient.taskRun.findFirst({
where: {
friendlyId: runFriendlyId,
},
@@ -29,7 +29,7 @@ export class SpanPresenter extends BasePresenter {
spanId: string;
runFriendlyId: string;
}) {
const project = await this._replica.project.findUnique({
const project = await this._replica.project.findFirst({
where: {
slug: projectSlug,
},
@@ -102,14 +102,7 @@ export class SpanPresenter extends BasePresenter {
queue: true,
concurrencyKey: true,
//schedule
schedule: {
select: {
friendlyId: true,
generatorExpression: true,
timezone: true,
generatorDescription: true,
},
},
scheduleId: true,
//usage
baseCostInCents: true,
costInCents: true,
@@ -149,6 +142,11 @@ export class SpanPresenter extends BasePresenter {
spanId: true,
},
},
batch: {
select: {
friendlyId: true,
},
},
},
where: {
spanId,
@@ -210,7 +208,9 @@ 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", "$$streamsVersion", "$$streamsBaseUrl"],
})
: undefined;
const context = {
@@ -274,14 +274,7 @@ export class SpanPresenter extends BasePresenter {
sdkVersion: run.lockedToVersion?.sdkVersion,
isTest: run.isTest,
environmentId: run.runtimeEnvironment.id,
schedule: run.schedule
? {
friendlyId: run.schedule.friendlyId,
generatorExpression: run.schedule.generatorExpression,
description: run.schedule.generatorDescription,
timezone: run.schedule.timezone,
}
: undefined,
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
queue: {
name: run.queue,
isCustomQueue: !run.queue.startsWith("task/"),
@@ -312,6 +305,36 @@ export class SpanPresenter extends BasePresenter {
context: JSON.stringify(context, null, 2),
metadata,
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
};
}
async resolveSchedule(scheduleId?: string) {
if (!scheduleId) {
return;
}
const schedule = await this._replica.taskSchedule.findFirst({
where: {
id: scheduleId,
},
select: {
friendlyId: true,
generatorExpression: true,
timezone: true,
generatorDescription: true,
},
});
if (!schedule) {
return;
}
return {
friendlyId: schedule.friendlyId,
generatorExpression: schedule.generatorExpression,
description: schedule.generatorDescription,
timezone: schedule.timezone,
};
}
@@ -31,6 +31,7 @@ export type Task = {
environments: {
id: string;
type: RuntimeEnvironmentType;
slug: string;
userName?: string;
}[];
};
@@ -33,7 +33,7 @@ export class TasksStreamPresenter {
projectSlug: string;
userId: string;
}) {
const project = await this.#prismaClient.project.findUnique({
const project = await this.#prismaClient.project.findFirst({
where: {
slug: projectSlug,
organization: {
@@ -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,18 @@
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 {
BackgroundWorkerTaskSlim,
findCurrentWorkerDeployment,
} from "~/v3/models/workerDeployment.server";
type TestTaskOptions = {
userId: string;
projectSlug: string;
taskFriendlyId: string;
environmentSlug: string;
taskIdentifier: string;
};
type Task = {
@@ -37,6 +42,15 @@ export type TestTask =
runs: ScheduledRun[];
};
export type TestTaskResult =
| {
foundTask: true;
task: TestTask;
}
| {
foundTask: false;
};
type RawRun = {
id: string;
number: BigInt;
@@ -71,55 +85,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: BackgroundWorkerTaskSlim | 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 +169,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 +181,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),
},
};
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Cluster, Redis, type ClusterNode, type ClusterOptions } from "ioredis";
import { logger } from "./services/logger.server";
export type RedisWithClusterOptions = {
host?: string;
port?: number;
username?: string;
password?: string;
tlsDisabled?: boolean;
clusterMode?: boolean;
clusterOptions?: Omit<ClusterOptions, "redisOptions">;
keyPrefix?: string;
};
export type RedisClient = Redis | Cluster;
export function createRedisClient(
connectionName: string,
options: RedisWithClusterOptions
): Redis | Cluster {
let redis: Redis | Cluster;
if (options.clusterMode) {
const nodes: ClusterNode[] = [
{
host: options.host,
port: options.port,
},
];
logger.debug("Creating a redis cluster client", {
connectionName,
host: options.host,
port: options.port,
});
redis = new Redis.Cluster(nodes, {
...options.clusterOptions,
redisOptions: {
connectionName,
keyPrefix: options.keyPrefix,
username: options.username,
password: options.password,
enableAutoPipelining: true,
...(options.tlsDisabled
? {
checkServerIdentity: () => {
// disable TLS verification
return undefined;
},
}
: { tls: {} }),
},
dnsLookup: (address, callback) => callback(null, address),
slotsRefreshTimeout: 10000,
});
} else {
logger.debug("Creating a redis client", {
connectionName,
host: options.host,
port: options.port,
});
redis = new Redis({
connectionName,
host: options.host,
port: options.port,
username: options.username,
password: options.password,
enableAutoPipelining: true,
keyPrefix: options.keyPrefix,
...(options.tlsDisabled ? {} : { tls: {} }),
});
}
redis.on("error", (error) => {
logger.error("Redis client error", { connectionName, error });
});
return redis;
}
@@ -1,21 +1,22 @@
import { Link } from "@remix-run/react";
import simplur from "simplur";
import { Link, MetaFunction } from "@remix-run/react";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import {
PageAccessories,
NavBar,
PageInfoGroup,
PageInfoRow,
PageTitle,
} from "~/components/primitives/PageHeader";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { newProjectPath, projectPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: "Projects | Trigger.dev",
},
];
};
export default function Page() {
const organization = useOrganization();
@@ -3,7 +3,6 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EventDetail } from "~/components/event/EventDetail";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { RunsFilters } from "~/components/runs/RunFilters";
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
import { RunsTable } from "~/components/runs/RunsTable";
@@ -13,7 +12,7 @@ import { useUser } from "~/hooks/useUser";
import { EventPresenter } from "~/presenters/EventPresenter.server";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { EventParamSchema, projectEventsPath, projectPath } from "~/utils/pathBuilder";
import { EventParamSchema, projectPath } from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -69,16 +68,6 @@ export default function Page() {
return (
<PageContainer>
<NavBar>
<PageTitle
title={event.name}
backButton={{
to: projectEventsPath(organization, project),
text: "Events",
}}
/>
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full grid-cols-2">
<div className="overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
@@ -1,87 +0,0 @@
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EventListSearchSchema } from "~/components/events/EventStatuses";
import { EventsFilters } from "~/components/events/EventsFilters";
import { EventsTable } from "~/components/events/EventsTable";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { EventListPresenter } from "~/presenters/EventListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { BookOpenIcon } from "@heroicons/react/20/solid";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = EventListSearchSchema.parse(s);
const presenter = new EventListPresenter();
const list = await presenter.call({
userId,
filterEnvironment: searchParams.environment,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
from: searchParams.from,
to: searchParams.to,
pageSize: 25,
});
return typedjson({
list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
const user = useUser();
return (
<PageContainer>
<NavBar>
<PageTitle title={`${project.name} events`} />
<PageAccessories>
<LinkButton
variant={"minimal/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("documentation/concepts/triggers/events")}
>
Event documentation
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="mb-2 flex items-center justify-between gap-x-2">
<EventsFilters />
<ListPagination list={list} />
</div>
<EventsTable
total={list.events.length}
hasFilters={false}
events={list.events}
isLoading={isLoading}
eventsParentPath={projectPath(organization, project)}
currentUser={user}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</PageBody>
</PageContainer>
);
}
@@ -1,26 +1,52 @@
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, type MetaFunction } 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 {
InitCommandV3,
PackageManagerProvider,
TriggerDevStepV3,
TriggerLoginStepV3,
} from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
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 +54,7 @@ import {
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
@@ -38,17 +64,38 @@ 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 meta: MetaFunction = () => {
return [
{
title: `Tasks | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -62,12 +109,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 +128,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 +189,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 +220,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 gap-1 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} isTabbableCell>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
content={taskTriggerSourceDescription(task.triggerSource)}
/>
<span>{task.slug}</span>
</div>
</TableCell>
<TableCell to={path}>
<TaskFunctionName
functionName={task.exportName}
variant="extra-extra-small"
/>
</TableCell>
<TableCell to={path}>
<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}>
<Suspense fallback={<></>}>
<TypedAwait resolve={runningStats}>
{(data) => {
const taskData = data[task.slug];
return taskData?.queued ?? "0";
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path} actionClassName="py-1.5">
<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}>
<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>
);
@@ -290,38 +444,40 @@ export default function Page() {
function CreateTaskInstructions() {
return (
<div>
<div className="mb-6 flex items-center justify-between border-b">
<Header1 spacing>Get setup in 3 minutes</Header1>
<div className="flex items-center gap-2">
<Feedback
button={
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<Header1 spacing>Get setup in 3 minutes</Header1>
<div className="flex items-center gap-2">
<Feedback
button={
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
<StepContentContainer>
<InitCommandV3 />
<Paragraph spacing>
You'll notice a new folder in your project called{" "}
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
in here to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
<StepNumber stepNumber="1" title="Run the CLI 'init' command in your project" />
<StepContentContainer>
<InitCommandV3 />
<Paragraph spacing>
Youll notice a new folder in your project called{" "}
<InlineCode variant="small">trigger</InlineCode>. Weve added a very simple example task
in here to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
}
@@ -329,43 +485,47 @@ 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 ? (
<PackageManagerProvider>
<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>
</PackageManagerProvider>
) : (
"Your DEV environment isn't setup yet."
)}
</Callout>
</div>
);
}
@@ -457,7 +617,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 +633,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>
@@ -7,11 +7,10 @@ import {
EnvelopeIcon,
GlobeAltIcon,
LockClosedIcon,
LockOpenIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { Form, MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database";
@@ -24,7 +23,6 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -37,6 +35,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
@@ -62,6 +61,14 @@ import {
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Alerts | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
@@ -163,17 +170,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 +189,8 @@ export default function Page() {
>
New alert
</LinkButton>
</div>
)}
)}
</div>
<Table>
<TableHeader>
<TableRow>
@@ -215,7 +222,7 @@ export default function Page() {
/>
))}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
@@ -225,20 +232,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 +275,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 +357,8 @@ function DeleteAlertChannelButton(props: { id: string }) {
<Button
name="action"
value="delete"
fullWidth
textAlignLeft
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
@@ -367,6 +398,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 +438,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 +465,8 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
leadingIconClassName="text-charcoal-400"
label={"Email"}
description={alertChannel.properties.email}
boxClassName="group-hover/table-row:bg-charcoal-800"
className="h-12"
/>
);
}
@@ -461,6 +498,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
className="mt-1 w-80"
/>
}
boxClassName="group-hover/table-row:bg-charcoal-800"
/>
);
}
@@ -476,6 +514,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,6 @@
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { MetaFunction } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -27,6 +29,14 @@ import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, v3BillingPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `API keys | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
@@ -72,7 +82,7 @@ export default function Page() {
</AdminDebugTooltip>
<LinkButton
variant={"minimal/small"}
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/v3/apikeys")}
>
@@ -80,9 +90,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 +114,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 +122,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 +146,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>

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