Compare commits

...

191 Commits

Author SHA1 Message Date
James Ritchie efd77109d9 Copy updates 2025-11-21 16:42:17 +00:00
James Ritchie 158392f352 Simplify the Deploy blank state on dev 2025-11-21 15:23:59 +00:00
James Ritchie 4d0647a7bb Adds props for Github connect button 2025-11-21 14:36:22 +00:00
James Ritchie c1f822a22f Tabs hover state improvement 2025-11-21 13:48:08 +00:00
James Ritchie 87ae16b964 Storybook layout update 2025-11-21 13:47:57 +00:00
James Ritchie 4a6159a3d5 Better tab layout 2025-11-21 13:37:12 +00:00
James Ritchie bf288519d8 Removed unneeded div 2025-11-21 13:36:58 +00:00
James Ritchie a8513011d4 Improves the tab transition animation 2025-11-21 12:09:33 +00:00
James Ritchie fff74e1719 ClientTabs now mirror the Tabs component 2025-11-21 12:09:03 +00:00
James Ritchie cd629524ae Update storybook tabs layout 2025-11-21 12:08:33 +00:00
James Ritchie 86a7f0911d Remove unused styles 2025-11-10 14:45:35 +00:00
James Ritchie dd55ef67aa Update storybook tabs 2025-11-10 14:45:26 +00:00
James Ritchie 05439c955a update the outlet 2025-11-05 14:59:22 -08:00
James Ritchie 15f0d7a937 storybook update for the new style variants 2025-11-05 14:59:08 -08:00
James Ritchie aaad5f1a36 Adds new style variants to the tabs 2025-11-05 14:58:46 -08:00
James Ritchie 8a7b74c75e Tidy exports 2025-11-02 16:06:03 +00:00
James Ritchie dff6abc1a2 Remove unused component 2025-11-02 16:05:44 +00:00
James Ritchie d9a0638f34 typed imports 2025-11-02 16:05:35 +00:00
James Ritchie 1bf2b55f68 Show the 3 deploy options in tabs instead 2025-11-02 16:04:24 +00:00
James Ritchie 414dd6d635 Add GitHub step to deployments page onboarding 2025-11-02 15:15:02 +00:00
James Ritchie 50c9836cdc Make the onboarding steps container wider 2025-11-02 15:14:26 +00:00
James Ritchie b2a3fb1490 Separate hint from function 2025-11-02 15:14:05 +00:00
James Ritchie 6f6df7e475 Export function 2025-11-02 15:13:43 +00:00
nicktrn 8fdbbeb02f chore(helm): increase default clickhouse resources (#2635) 2025-10-27 13:45:09 +00:00
Marcus Nerløe 255ea0a4b3 fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler (#2627)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
* fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler

* fix: added catch handler for informer.start() failures

* fix: removed 'errorStack' from error log
2025-10-23 11:13:45 +01:00
Saadi Myftija 7f25e82299 feat(deployments): support local builds in cloud (#2628)
* Enable skipping image push during deployment finalization step

* Add endpoint to generate registry credentials for a deployment

* Add a --force-local-build flag to the deployment command to skip remote build

* Do not show the new flag in the help output

* Add changeset

* Remove registry login logs from onLog, not useful

* Rename var

* Update platform package to the latest version
2025-10-23 10:57:36 +02:00
Eric Allam d90da7abf7 fix(replication): allow disabling of task run payload inserts via env var (#2626) 2025-10-22 11:22:14 +01:00
Matt Aitken 2affe541e8 Dev concurrency limit by env (with optional global limit) (#2625)
* Limit local dev concurrency using the dev environment concurrency limit

Previously it was limited to max of 25, no matter the environment limit

* Have global dev limit
2025-10-22 10:55:05 +01:00
Matt Aitken 3157b657c7 Test page recent payload: use ClickHouse to get the latest run ids (#2614)
The Postgres query to get the latest run ids for the test page was very 
slow when there were a lot of runs and/or versions.

This now uses the standard runs list we use everywhere else.
2025-10-17 17:17:36 +01:00
Matt Aitken 41bdab58d5 Document the dev --analyze command (#2613) 2025-10-17 13:00:30 +01:00
James Ritchie fe3fe01fe8 feat(queues): Override queue concurrency limits from the dashboard or API (#2609)
* feat(queues): add ability to override concurrency limit via API and dashboard

* Updates the modal layout and tweaks copy

* Improves the dropdown menu item

* Popover supports both Button and LinkButton

* Right align the columns and fix the dropdown menu item styles

* Organize imports,

* Fix spinner icon in dropdown menu

* Remove unused props

* Adds a tooltip to the Concurrency override badge

* Fixes console error with popover menu

* typo

* Fixes incorrect className

* Minimal buttons to view runs

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2025-10-17 12:58:25 +01:00
Matt Aitken 68d0037e60 fix(dev): dotenv issues when getting setup with the repo (#2612)
- The .env.example was missing required Clickhouse values.
- A symlink was needed for .env from the root to the apps/webapp folder
2025-10-17 11:49:40 +01:00
Matt Aitken 885d2d3560 Tags listing now uses ClickHouse (#2576)
* WIP using ClickHouse for the tags filter list

* WIP on tags listing

* Webapp: exclude test files when typechecking

* Tags filtering working with CH

* Remove unused import

* The AI filter should only look at the last past 30d of tags

* Do the text query in ClickHouse

* Deal with encoded characters better

* More encoding fixes

* Fix for wrong items being checked

* Put applied tags back

* Add the env.id to the dependencies array
2025-10-15 12:56:08 +01:00
Eric Allam a445af1b79 feat(otel): allow clickhouse task events to be inserted using async_insert via env vars (#2608) 2025-10-15 10:37:17 +01:00
nicktrn 5781783c74 fix(engine): default to paid placement on billing errors (#2604)
* chore(billing): improve logs to distinguish between failure modes

* fix(engine): default to paid placement on billing errors

* chore(engine): set plan type according to paying field when missing
2025-10-15 10:32:27 +01:00
nicktrn cca10c22d3 chore(supervisor): add machine label (#2603) 2025-10-15 10:31:37 +01:00
Leonardo Kaynan 63b6fc93fa fix(helm): align values-production-example.yaml with values.yaml (#2606)
- Move S3 credentials from secrets.objectStore to s3.auth
- Update external PostgreSQL config to use databaseUrl/directUrl approach
- Add existingSecret support for PostgreSQL with secretKeys
- Add TLS configuration for external Redis
- Add existingSecret support for Redis, ClickHouse and S3
- Add complete external S3 configuration example
- Improve secure credential management documentation

These changes align the production example file with the current values.yaml
structure, making it easier to configure external services with better
support for secret management.
2025-10-14 23:16:58 +01:00
Saadi Myftija aa66462971 docs(build-server): adjust build env vars section (#2605)
Adds a clarification about the env var prefix stripping.
2025-10-14 17:29:42 +02:00
James Ritchie f6461684ad chore(webapp): adds shortcut key for admin area (#2570)
* Allow shortcuts hook to work if undefined

* Conditionally show shortcut button if only 1 result

* LinkButton can accept conditionally shown shortcuts
2025-10-13 13:26:00 +01:00
James Ritchie a6896b411a Adds a link to edit the profile icon from the main menu (#2572) 2025-10-13 13:25:06 +01:00
Saadi Myftija 0cabbdd31f docs: deploying using the github integration (#2598)
* docs: deploying using the github integration

* Add hint in the gh actions docs page

* Remove extra space

* Add a couple of hints to the build config fields
2025-10-13 14:15:48 +02:00
Eric Allam f8977a7b70 chore(db): remove unnecessary FK constraints on TaskRunExecutionSnapshot (#2533) 2025-10-09 14:07:45 +01:00
Saadi Myftija f0643f76f5 feat(build-server): add option to specify pre-build command (#2596)
* feat(build-server): add option to specify pre-build command

Adds an option to specify a pre-build command in the build settings. Can
be useful for projects that need a step before the build, e.g., to
generate a prisma client.

Also, remove the install directory in favor of simplicity. Both
pre-build and install commands are run from the root of the repo. Users
that need to run the commands in a different dir can just prepend to the
command, e.g., `cd apps/web && pnpm run primsa:migrate`

* Fix spelling
2025-10-08 17:25:44 +02:00
Saadi Myftija 416dbcd536 fix(webapp): disable gh-triggered preview deployments if the preview env is disabled (#2595)
* Show hint if preview branches are disabled in the project

* Enable preview deployments only if the preview environemtn is enabled

* Fix prisma reference
2025-10-08 16:59:25 +02:00
Eric Allam 679b41dc7e chore(electric): upgrade server to 1.1.14 (#2590) 2025-10-08 14:33:10 +01:00
Eric Allam be98aecbfd fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors (#2594)
* fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors

* only check parts of the string that are not going to get truncated
remove unnecessary taks
2025-10-08 14:32:35 +01:00
nicktrn 129dc02f2a chore: add deepwiki badge to main readme (#2587)
This means DeepWiki will automatically re-index our repo and keep our page fresh
2025-10-08 13:15:06 +01:00
nicktrn 8917478d3c fix(runner): SIGTERM handling during warm start long poll (#2593) 2025-10-08 12:51:10 +01:00
Leonardo Kaynan f5caa66348 fix(helm): use bitnami legacy repo for minio console (#2592)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* fix(charts): use bitnamilegacy repo for MinIO Object Browser

The Bitnami `minio-object-browser` image has been removed from Docker
Hub. This patch updates the Trigger Helm chart to reference the
`bitnamilegacy/minio-object-browser` repository under the
`s3.console.image` section, ensuring that the legacy MinIO browser pod
can still be deployed without image pull errors.

All other MinIO components continue using the `bitnamilegacy/minio`
image for consistency across Bitnami Legacy dependencies.

* chore(helm): bump Helm chart version to 4.0.4
2025-10-07 22:17:37 +01:00
Eric Allam 64fcc88fa7 fix(otel): spans with entities (like waitpoints) now correctly returned from clickhouse repo (#2591) 2025-10-07 16:55:00 +01:00
nicktrn 23dbe282ed security: upgrade transitive axios dep (CVE-2025-58754) (#2589) 2025-10-07 14:56:16 +01:00
Eric Allam 692316e82a fix(realtime): Upgrade to @electric-sql/client@1.0.14 to prevent cached 409 Conflict errors from breaking realtime updates (#2588) 2025-10-07 14:26:03 +01:00
Saadi Myftija 107f4dc87c fix(deployments): retry transient depot build init failures (#2586)
The Depot build init with `depot.build.v1.BuildService.createBuild` fails surprisingly often due to transient errors, causing the whole deployment to fail. This PR adds a simple retry mechanism with backoff using p-retry. This should improve the failure rate.
2025-10-06 11:33:31 +02:00
Eric Allam b90f3e2173 fix(otel): remove clickhouse event repo feature flag support from v3, now v4 only (#2585) 2025-10-06 09:54:23 +01:00
Eric Allam b3b2553651 fix(otel): propagate the task event store to run descendants (#2583) 2025-10-04 07:28:16 -07:00
Eric Allam cdd1a8838c fix(otel): prevent spans with negative durations (#2582) 2025-10-03 06:50:16 -07:00
Eric Allam 200b7354d0 fix(otel): clickhouse logs/span metrics now exclude partials and debug events (#2581) 2025-10-02 13:51:50 -07:00
Eric Allam eeed38d223 fix(clickhouse): correctly format datetime64(9) input format (#2580) 2025-10-02 11:32:32 -07:00
nicktrn 0ca092651b feat(supervisor): optional custom scheduler (#2579) 2025-10-02 16:22:53 +01:00
Eric Allam 53acdf8ef5 fix(otel): don't pass isDebug when creating postgresql task events (#2578) 2025-10-01 21:57:35 -07:00
Eric Allam 128bc437f6 feat(otel): Add support for storing run spans and log data in Clickhouse (#2567) 2025-10-01 12:41:18 -07:00
James Ritchie 0597691001 Adds 200 and 500 % billing alert options (#2571) 2025-09-30 13:59:44 -07:00
mintlify[bot] dae84a0d29 Update docs/idempotency.mdx (#2575)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-09-30 13:50:02 -07:00
nicktrn f72d63aac2 chore(helm): migrate to bitnami legacy registry and add configurable utility images (#2574)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* chore(docker): use bitnami legacy repo

* chore(helm): use bitnami legacy repo

* Make Helm webapp chart images configurable

Adds configurability for init and token syncer container images through
new values in the Helm chart configuration

* chore(helm): refactor utility image config

* chore(helm): bump chart version to 4.0.3

---------

Co-authored-by: LeoKaynan <leokaynan@hotmail.com>
2025-09-30 16:02:08 +01:00
nicktrn 12cceaa779 feat(helm): support topology spread constraints for webapp (#2560)
* feat(helm): support topology spread constraints

* chore(helm): update topology env var

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

* chore(helm): limit spread constraints to webapp for now

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-30 15:31:04 +01:00
Saadi Myftija ddebe4dce0 feat(webapp): rate limit magic-link login attempts (#2568)
* feat(webapp): rate limit magic-link login attempts

Adds a simple rate limiter to the login with magic link flow. Similar implementation to the MFA rate limits.

* Fix error message

* Add an env var feature flags for login rate limiting

* Use BoolEnv instead of `0`/`1`

* Parse xff properly
2025-09-29 17:28:36 +02:00
Saadi Myftija 09d51c6d24 fix(webapp): add recommended security headers (#2569)
Sets `Referrer-Policy`, `X-Content-Type-Options` and `Permissions-Policy` headers.
Relevant against certain types of attacks.
2025-09-29 16:55:22 +02:00
Saadi Myftija 3ceea774a8 fix(run-engine): waitpoint update misleading error logs (#2566) 2025-09-26 21:16:12 +02:00
Eric Allam 05b6a26c4f fix(run-engine): pass through engine fair dequeue selection strategy options instead of using defaults (#2565) 2025-09-26 17:12:36 +01:00
Eric Allam 558fb11b89 feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status (#2564)
* feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing
feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status

* Handle FINISHED snapshot in the repair
2025-09-26 15:16:10 +01:00
nicktrn 9aedda23a4 fix(run-engine): carryover batchId after PENDING_EXECUTING stalls (#2563) 2025-09-26 14:14:28 +01:00
Eric Allam 743b8dbe0c chore(run-engine): add additional logging around dequeueing and worker queues (#2562) 2025-09-26 11:50:21 +01:00
Eric Allam eb0263e942 feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing (#2559) 2025-09-25 19:33:10 +01:00
Saadi Myftija 7bf579fa50 fix: use higher entropy invite tokens (#2558)
* fix: use higher entropy invite tokens

We currently use CUIDs for invite tokens, which are generated using
a pattern and are not cryptographically secure. This PR switches to
a higher entropy string generated with `nanoid`.

* Dedupe the invite emails in the application
2025-09-25 19:42:59 +02:00
Saadi Myftija 69d52db856 fix(webapp): org scoping issues in plan selection, alerts, pats and usage (#2549)
* fix: org scoping in the select plan flow

Adds proper org scoping in the loader and action in the plans page.

* Fix billing alerts scope

* Fix org usage page scope

* Fix token revoking flow scope check

* Throw error for failed PAT revokes instead of silent failure
2025-09-25 19:17:51 +02:00
Saadi Myftija a3cea1302e fix(webapp): org invite scoping (#2554)
* fix: org invite scoping

Fixes some scoping issues with team invites.

* Fix invite flow changes
2025-09-25 19:17:19 +02:00
Eric Allam a3bdd3c64b chore(run-engine): improve concurrency sweeper logging to get better visibility (#2557) 2025-09-25 16:43:43 +01:00
Saadi Myftija 6d6e98aa11 fix(webapp): project scoping for runs (#2553)
* fix: project scoping for runs

* Apply some 🐰 suggestions
2025-09-25 17:43:13 +02:00
Eric Allam e22c321dd1 fix(engine) truncate errors before storing them on a run and waitpoint output (#2552) 2025-09-25 13:39:51 +01:00
Eric Allam 59df4af1eb chore(engine): add additional logging when we fail to get snapshots since (#2551) 2025-09-25 12:59:02 +02:00
Eric Allam 8863ff05c9 fix(engine): limit the number of snapshots returned when getting latest snapshots since (#2550) 2025-09-25 11:53:35 +01:00
Matt Aitken d10281e655 Additional files docs with legacyDevProcessCwdBehaviour (#2543) 2025-09-24 14:32:02 +01:00
nicktrn 6798d57e72 chore(helm): bump image versions to 4.0.4 (#2537) 2025-09-24 14:31:30 +01:00
Saadi Myftija 480c0d34d3 fix(webapp): toast message issue after gh app installation (#2546)
* fix(webapp): toast message issue after gh app installation

Fixes an issue with displaying toasts messages in the project settings
page. The github callback cookie was interfering with the flash cookie used
for toast messages.

* Do not set a tracking branch in the staging env by default
2025-09-24 15:20:03 +02:00
Saadi Myftija 700a6ea598 feat: enable canceling deployments (#2545)
* Add canceledAt to the deployment db schema

* Expose an api endpoint to cancel deployments

* Show the canceled status description in the dashboard

* Enable canceling deployments from the dashboard

* Show cancelation reason in the deployment details

* Make verifyProjectMembership a function for consistency

* Apply some good 🐰 suggestions
2025-09-24 11:14:29 +02:00
Saadi Myftija cc94d121f2 feat: installing status for deployments (#2544)
* Add installing status to the deployment db schema

* Replace the deployments /start endpoint with /progress

* Show the installing status in the dashboard

* Add installing status to the api schema and cli

* Add changeset
2025-09-24 10:27:43 +02:00
Saadi Myftija 412e80fdde fix(webapp): hide outdated connected repos from deleted installations (#2538) 2025-09-23 13:24:58 +01:00
Saadi Myftija 49728b5a5f feat(api): defer remote build creation for pending deployments (#2536)
Depot builds have short-lived tokens and their TTL is not exposed in the SDK. As queued deployments can stay in the queue for an arbitrary amount of time, deferring the remote build creation helps avoid expired Depot token issues.
2025-09-22 13:01:56 +02:00
Saadi Myftija d45696c000 fix(api): 204 response issue in deployment start endpoint (#2534) 2025-09-19 19:05:18 +02:00
Eric Allam 8313800746 fix(webapp): don't override spans from ancestors unless the span is partial (#2532) 2025-09-19 14:58:53 +01:00
James Ritchie 28f8cee3a4 Fix for errors returned from searching time specific queries (#2525)
* Fix for errors returned from searching time specific queries

* Adds prompt patterns
2025-09-19 13:49:43 +01:00
Eric Allam 87b3603b23 feat(webapp): completing spans server-side no longer write-after-read, improving efficiency and perf (#2530)
* Cancel run events which then propogate cancellation status to span ancestors

* WIP

* convert closing cached run spans to new system

* converted expired complete span event to new method

* move v3 over to new methods

* Convert getDetailedTraceSummary to use the new ancestor override stuff

* remove debug logs

* Don't return UNSPECIFIED task events in getRunEvents

* fix the call site for cancelling run event in v3

* Add changeset

* remove methods
2025-09-19 13:39:48 +01:00
James Ritchie 365adc24a6 Chore(webapp): adds more copy buttons (#2529)
* Pass up asChild

* Include padding when asChild specified

* Adds copy buttons to useful data in Details tab

* Improve view batch tooltip message
2025-09-19 13:02:10 +01:00
Saadi Myftija 7d17730b52 feat(api): handle build server deployment init gracefully for older cli versionsi (#2526)
This PR adapts the deployment initialization endpoint to handle build server deployments with older CLI versions gracefully.

When we introduced automatic deployments via the build server, we slightly changed the deployment flow
mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status
and updated to `BUILDING` once the build server dequeues the build job.
Newer versions of the `deploy` command in the CLI will automatically attach to the existing deployment
and continue with the build process. For older versions, we can't change the command's client-side behavior,
so we need to handle this case here in the initialization endpoint. As we control the env variables which
the git meta is extracted from in the build server, we can use those to pass the existing deployment ID
to this endpoint. This doesn't affect the git meta on the deployment as it is set prior to this step using the
/start endpoint. It's a rather hacky solution, but it will do for now as it enables us to avoid degrading the
build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
once we stop supporting 3.x CLI versions.
2025-09-19 10:40:59 +02:00
Saadi Myftija e4982bfd6d feat(webapp): deployments page live reloading (#2524)
* Fix `current` badge inconsistency in the deployment details page

* Add custom hook for auto revalidation based on an interval and/or focus change

* Use the autoRevalidate hook for live reloading of the deployments page

* Extract autoReloadPollIntervalMs to an env var

* Replace the sse-based autoreload in bulk actions and queues page with the simpler autoRevalidate hook
2025-09-19 10:40:07 +02:00
github-actions[bot] a03783d1a0 Release v4.0.4
🚀 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 / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 19s
* chore: Update version for release

* Release v4.0.4

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-09-18 23:18:59 +01:00
Eric Allam 0178bdbb00 fix(packages): remove effect from optional peer dependencies (#2527)
Fixes ERROR: Could not resolve "effect"
2025-09-18 23:09:37 +01:00
github-actions[bot] ad51168181 Release v4.0.3 (#2486)
* chore: Update version for release

* Release v4.0.3

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-09-18 21:28:30 +01:00
nicktrn db87295049 fix(runner): reduce restore recovery time and deprecated runner false positives (#2523)
* fix(runner): improve restore detection

* chore(supervisor): skip schema parsing when debug logs disabled

* fix(runner): deprecation race condition

* add changeset
2025-09-18 16:59:44 +01:00
Saadi Myftija a3ef6ea236 feat: separate deployment initialize and start steps (#2522)
* Enable setting the initial status on deployment creation

* Expose endpoint to start deployments

* Extend build timeout on deployment start

* Use separate timeout value for queued deployments

* Add startedAt to the deployment schema

* Show the new startedAt instead of createdAt in the dashboard

* Show github user tag also in the deployment details page

* Show `pending` deployment status as `queued` in the dashboard

* Apply some good 🐰 suggestions

* Add missing return
2025-09-18 16:12:17 +02:00
James Ritchie ae22000409 chore(webapp): pricing page improvements (#2457)
* Improve styling of onboarding pricing plans

* type only import

* Improve the onboarding plan page so it scrolls on smaller screens

* Adds additional pricing for bolt-ons for the Pro plan

* fix text wrapping issue

* add bg color back in
2025-09-18 14:31:06 +01:00
James Ritchie 2bbf8ec6cd chore(webapp): a new cancelation reason (#2465)
* Adds a new cancelation option

* Better wording
2025-09-18 14:30:25 +01:00
James Ritchie c8858edf0a New jump to parent or root run buttons (#2067)
* Change the color to indigo

* Pro tier pricing information now matches the marketing site

* Update the button styles to secondary

* WIP adding separate links to Parent and Root runs

* TextLink now supports optional shortcuts

* Adds shortcut keys to the root and parent links + the shortcut help panel

* Adds new icons for root and parent

* root friendlyId works

* Updates icons for jump to root and parent

* Copy tweak

* Improve how the Free tier shows no preview branches

* Improve the wording in the tooltip

* Align the x icon better

* Show price for additional preview branches

* Change the shortcut key

* Fixes button alignment

* Adds nested dependencies task hello-world

* Fixes typo “Cancelled”

* Removes taskIdentifier, not needed

* Removes unused taskIdentifier
2025-09-18 14:29:47 +01:00
Eric Allam 9c087646bf fix(engine): prevent race condition that prevents triggerAndWait runs from resuming by atomically creating associated waitpoint records (#2519) 2025-09-17 13:58:17 +01:00
Saadi Myftija 691903cf58 feat(api): accept OATs in preview branch related endpoints (#2517)
Adjusts the authentication in a couple of endpoints to accept OATs too.
2025-09-17 14:10:25 +02:00
nicktrn 885ae5e06f fix(runner): immediate poll to decrease restore time (#2516)
* fix(runner): immediate poll to decrease restore time

* chore: bump default prerelease tag

* fix(cli): s is not a function

* add changeset
2025-09-16 16:17:04 +01:00
Saadi Myftija 501a383bcd feat: expose project build settings (#2507)
This PR enables setting project build settings in the settings page:
root directory, install command and trigger config file path.

For most cases there should be no need to set these explicitly.
2025-09-16 15:02:09 +02:00
nicktrn 3c199e6d9c chore(supervisor): remove deprecated route (#2513) 2025-09-16 13:47:07 +01:00
nicktrn a55294b7dd fix(run-engine): retry SIGSEGV errors (#2514) 2025-09-16 13:46:58 +01:00
Eric Allam c9d1aadfc3 core(docs): link to the v4 self-hosting in the introduction (#2512) 2025-09-16 13:26:55 +02:00
Eric Allam 08702cd710 chore(mcp): Add our MCP server to the official MCP registry (#2510)
See: 
https://blog.modelcontextprotocol.io/posts/2025-09-08-mcp-registry-preview/
2025-09-16 10:44:41 +01:00
Dan 04dcb81496 Added product image generator and replicate examples (#2511)
* Added nano banana task

* Renamed the file and updated docs.json

* Added product imgae generator demo project

* Removed old section

* Code tweak
2025-09-16 10:26:11 +01:00
Eric Allam 0f9b83db09 fix(core): prettyPrintingPacket will now do a structuredClone on non-circular references instead of outputting [Circular] (#2508)
* Mo-Stashed changes

* fix(core): prettyPrintingPacket will now do a structuredClone on non-circular references instead of outputting [Circular]

This also fixes an issue with replaying of runs that include 
non-circular references
2025-09-15 17:43:46 +01:00
Saadi Myftija 7d333e5b3c feat(cli): attach to existing deployments in the build server (#2501)
* Add external build data and image platform to the get deployment endpoint

* If provided, attach to an existing deployment in the deploy command

* Check status for existing deployments

* Add changeset
2025-09-15 17:16:26 +02:00
nicktrn 7c4ce6f76b feat(supervisor): add optional memory limit overhead (#2506) 2025-09-15 16:09:05 +01:00
Eric Allam dc42ae7aa4 chore(repo): update changeset snapshot instructions (#2505) 2025-09-15 15:27:54 +02:00
Saadi Myftija b0b88f1e05 fix(webapp): show deployments where triggeredById is missing (#2504)
Small fix for the sql query used in the deployments list page.
2025-09-15 14:12:48 +01:00
Eric Allam 6483a0f1c6 fix(core): Improves our schema to JSON Schema conversion (fix for zod 4) (#2483) 2025-09-15 14:11:40 +01:00
Eric Allam 6f1abe058b chore(docs): heartbeat timeout is now 5 minutes (#2500) 2025-09-15 15:11:32 +02:00
Eric Allam 83bd6f5f9e fix(engine): carry over completed waitpoints on PENDING_EXECUTING stalls (#2503) 2025-09-15 12:19:46 +01:00
Eric Allam e36d78e4fc fix: don't carry over the checkpoint ID when nack and requeuing (#2502) 2025-09-15 12:06:52 +01:00
Eric Allam 3188dc9b28 perf(webapp): Add BatchTaskRun index to speed up the batch list dashboard page (#2499) 2025-09-12 17:23:52 +01:00
Eric Allam 10e7985fbc Add index for waitpoint tokens dashboard query (#2498) 2025-09-12 16:48:42 +01:00
Eric Allam 2eddda1233 fix(webapp): worker actions now catch service validation errors and respond properly (#2481)
This also stops all the unnecessary error logging when throwing 
ServiceValidationErrors
2025-09-12 14:56:54 +01:00
HUORT Louis 49f2c54031 fix: webapp crash on clickhouse data corruption (#2491) 2025-09-12 14:55:29 +01:00
Eric Allam f077d49291 feat(engine): Improve execution stalls troubleshooting, align dev and prod behavior, adding heartbeats.yield utility (#2489)
* feat(engine): Improve execution stalls troubleshooting, align dev and prod behavior, adding heartbeats.yield utility

* A few improvements via the 🐇 review

* Allow treating EXECUTION stalls as OOM errors, improve the error message, add more information to the docs, improve resource monitor and add it to the docs

* Add changeset
2025-09-12 14:54:39 +01:00
Eric Allam 5db583b6cd docs: add machine option in triggering docs (#2487) 2025-09-11 15:28:26 +02:00
Matt Aitken 1227e5463e Fix for broken retrieve runs docs page (#2496)
The API path was accidentally edited as part of a /v3 purge
2025-09-10 16:59:37 +01:00
Eric Allam 8e66913e59 fix(run-engine): Preserve snapshot checkpoint ID when a PENDING_EXECUTING snapshot stalls (#2493) 2025-09-10 14:55:57 +01:00
Dan b9b17e24ac Added anchor browser example (#2488)
* Added anchor browser example project

* Updated node ver
2025-09-09 14:32:26 +01:00
Saadi Myftija 12bef0a938 feat: env command in the cli (#2485)
* Add a CLI command to list and view env vars

* Add changeset

* Restrict pemissions on env files created with `env pull`

* Escape env vars when exporting to file

* Switch changeset to patch
2025-09-09 13:13:24 +02:00
Saadi Myftija 5567f49846 feat(webapp): expose project git settings (#2464)
* Fix settigns page delete project width issue

* Apply a couple of touch-ups to the project settings page

* Add UI flow to connect gh repos

* Enabling adding another gh account in the ui

* Enable connecting a repo to a project

* Enable updating git settings

* Enable disconnecting gh repos from a project

* Remove prisma migration drifts

* Hide git settings when github app is disabled

* Fix migration order

* Avoid using `location` to avoid SSR issues

* Make branch tracking optional

* Disable save buttons when there are no field changes

* Disable delete project button unless the input matches the project slug

* Show connected repo connectedAt date

* Check that tracking branch exists when updating git settings

* Show tracking branch hint in the deployments page

* Fix positioning issue of the pagination pane in the deployments page

* Use mono font for branch names

* Add link to git settings

* Show tracking branch hint for the preview env too

* Add a confirmation prompt on repo disconnect

* Add link to configure repo access in gh

* Add rel prop to github links

* Automatically open repo connection modal after app installation

* Apply some fixes suggested by mr rabbit

* Fix flash cookie issue

* Extract project settings actions into a service

* Extract project settings loader into a presenter service

* Introduce neverthrow for error handling

* Try out neverthrow for error handling in the project setting flows

* Move env gh branch resolution to the presenter service
2025-09-09 13:03:43 +02:00
Dan 71060d93b1 Removed some remaining /v3’s from the docs (#2478)
* Removed /v3 from numerous files

* Fix AWS SDK v3 documentation link for S3 uploads
2025-09-08 10:01:09 +01:00
Eric Allam 89b1d8ba13 fix all lifecycle hooks (#2480) 2025-09-05 11:17:38 +01:00
Eric Allam 97015ba8c8 fix(docs): update sentry error tracking guide for v4 (#2479) 2025-09-05 11:13:13 +01:00
nicktrn 99660112bd feat(supervisor): add configurable resource requests (#2474) 2025-09-04 17:09:34 +01:00
Matt Aitken e6586d3c1a Remove deprecated releaseConcurrency from wait.forToken() docs (#2477) 2025-09-04 14:57:17 +01:00
Eric Allam 00d32ed4ee feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf (#2472)
* feat(webapp): add support for running web services (api, engine, webapp) in cluster mode for better perf

* cleaned up signal handling and resolved some valid 🐇 issues
2025-09-04 09:53:15 +01:00
nicktrn a1e9738faa fix(webapp): prevent duplicate preview env image tags (#2475)
* fix(webapp): prevent duplicate preview env image tags

* use deploy shortcode instead of new nanoid

* replace regexps in tests
2025-09-04 09:37:22 +01:00
nicktrn 59c17e04e9 feat(run-engine): worker queue resolver (#2476) 2025-09-04 09:27:06 +01:00
Eric Allam ed23615aa4 perf(webapp): add event loop utilization metric (#2471)
* perf(webapp): add event loop utilization metric

* add event loop utilization logging as well
2025-09-03 11:39:03 +01:00
Saadi Myftija 436d951b65 feat(webapp): github app installation flow (#2463)
* Add schemas for gh app installations

* Implement gh app installation flow

* Make the gh app configs optional

* Add additional org check on gh app installation callback

* Save account handle and repo default branch on install

* Do repo hard deletes in favor of simplicity

* Disable github app by default

* Fix gh env schema union issue

* Use octokit's iterator for paginating repos

* Parse gh app install callback with a discriminated union

* Remove duplicate env vars

* Use bigint for github integer IDs

* Sanitize redirect paths in the gh installation and auth flow

* Regenerate migration after rebase on main to fix ordering

* Handle gh install updates separately from new installs
2025-09-02 16:35:33 +02:00
nicktrn cf9398b56e fix(run-engine): retry SIGTERM errors (#2468) 2025-09-02 15:18:16 +01:00
Eric Allam 9b1877bef2 fix(run-engine): retry non-zero exit code errors (#2467)
We’re also now saving the retryConfig from the BackgroundWorkerTask on 
TaskRun.lockedRetryConfig when the run is first locked to the version
2025-09-02 14:12:14 +01:00
Eric Allam 0b2b73fc52 chore(docs): improve version pinning advise to use installed trigger.dev CLI (#2466) 2025-09-02 14:10:01 +01:00
nicktrn 0d1eac9406 feat(supervisor): dynamic queue consumer pool (#2461)
* feat(supervisor): dynamic queue consumer pool

* add changeset

* fix: correctly handle zero median and even samples

* feat(supervisor): consumer pool metrics

* fix tests

* more tests and fixes

* decrease default scaling cooldowns

* don't treat initial pool size as scale up

* handle scale down when queue length drops to zero

* remove changeset, supervisor changes only

* add damping factor env var
2025-09-02 13:52:42 +01:00
Willow (GHOST) ddbae6b6b4 fix(docker): clickhouse healthcheck (#2462) 2025-09-01 20:20:17 +01:00
nicktrn 2b095b1072 chore(webapp): upgrade otel packages and add more metrics (#2458)
* feat(webapp): upgrade otel packages and add more metrics

* add env var to disable additional detectors

* expose more prisma metrics

* chore(webapp): drop node 16 support
2025-09-01 16:24:12 +01:00
Dan 847ea866b6 Various docs improvements (#2456)
* Added MCP to the intro

* Added human-in-the-loop and new build extensions

* Waitpoint notes

* Moved openai guardrails example to Python

* Added a connection limit note

* Added Supabase + Prisma note to supabase auth

* Updates based on what the rabbit said

* Moved the supavisor section to the prismaExtension docs
2025-08-29 16:48:04 +01:00
James Ritchie fee31f2dc0 chore(webapp): update region message to say we are GDPR compliant (#2455)
* Updates the Region message to say we are GDPR compliant

* Hide the message if not on cloud
2025-08-29 14:33:46 +01:00
github-actions[bot] 10baa396d8 Release v4.0.2 (#2447)
* chore: Update version for release

* Release v4.0.2

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-08-29 12:58:22 +01:00
James Ritchie 27bc2ad232 chore(webapp): show a regions info panel to explain about data residency (#2428) 2025-08-29 11:35:54 +01:00
James Ritchie 3a3e863daf chore(webapp): bulk action cancel as default (#2437)
* Switches “replay” and “cancel” options in the list

* Fixed incorrect cancel icon being used

* Fixes the alignment of the radiobuttons
2025-08-29 11:30:46 +01:00
Eric Allam f5f14c4206 chore(webapp): fix @trigger.dev/database imports so it doesn't end up in the frontend bundle (#2452) 2025-08-29 11:27:01 +01:00
Eric Allam 892adfe81c fix(mcp): don't require a trigger.config.ts file to be present when installing rules (#2453) 2025-08-29 11:20:45 +01:00
nicktrn 73e7378459 feat(webapp): add ecs otel resource attributes (#2454)
* add attributes to runlock dequeue span

* feat(webapp): add ecs otel resource attributes
2025-08-29 11:05:47 +01:00
Eric Allam b0b0df69be fix(run-engine): prevent stalled SUSPENDED snapshots from endlessly retrying (#2448) 2025-08-28 14:25:50 +01:00
Saadi Myftija e629810e96 fix: dev env connection issue (#2449)
This PR fixes an issue introduced in #2391 where the dev runtime env was not being resolved correctly.
2025-08-28 14:05:10 +01:00
Ognjen Simic e6f6d93e59 docs: update triggering.mdx fixing wrongly stated argument position for options (#2283)
* docs: update triggering.mdx fixing wrong argument numeration for options

Update the docs triggering.mdx: text indicating options argument position inside `tasks.trigger` function.

Options object is a third argument instead of stated second argument.

* update triggering.mdx to change options argument position in batchTrigger
2025-08-28 10:16:42 +01:00
Eric Allam 6f6ca01584 feat(mcp): add wait_for_run_to_complete tool so agents don't spam the get_run_details call after triggering (#2445)
* feat(mcp): add wait_for_run_to_complete tool so agents don't spam the get_run_details call after triggering

This also fixes the search docs MCP tool

* Install mcp using the latest tag, not the specific version
2025-08-28 10:13:46 +01:00
James Ritchie 11cbd1b5a0 Docs: Fixes incorrect retrieving runs from batchTrigger (#2427)
* Docs: fix for retrieving runs in batchTrigger

* Removes old v3-to-v4 migration guide (we have a redirect)

* Corrects tag limit from 5 to 10
2025-08-28 10:11:42 +01:00
Eric Allam 82401ec4fe fix(run-engine): dequeue performance improvements and improved telemetry (#2446)
* more eager dequeuing, queue cooloff periods, return workerQueueLength when dequeueing

* Cache worker group authentication and remove old self-hosted worker code (only managed is currently supported)

* add additional spans during dequeue

* Add env vars and additional spans
2025-08-27 21:22:10 +01:00
Matt Aitken 24a915133e Prisma 6.14.0 upgrade (#2444)
* Initial work on upgrading to 6.14.0

Set the output to node_modules still to make it easier

* Use ./generated Prisma folder, update types to fix issues

* Docker compose restart Clickhouse

* Prisma instrumentation update

* Docker

* Removed database dockerignore file, add generated prisma client to the top-level one

* Delete v3-catalog package.json

* Resolved pnpm lock file

* Log errors for very slow queries
2025-08-27 16:52:58 +01:00
Eric Allam 688b108ec3 chore(references): remove v3-catalog (#2443) 2025-08-27 14:35:55 +01:00
Saadi Myftija 1cc62230ab feat: introduce organization access tokens (#2391)
* Create schema and migration for organization access tokens

* Add helpers for creating and authenticating OATs

* Adapt the auth service to also accept OATs

* Accept OATs in the whoami v2 endpoint

* Enable deployments with the CLI using OATs

* Avoid reading env variables directly in the token utils

* Remove duplicate cli token utils

* Validate ENCRYPTION_KEY length when parsing env vars

* Make token utils a server-only module

* Disallow revoking already revoked OATs

* Simplify generics in authenticateRequest

* Use 32 bytes mock encryption key in the test setup

* Update dummy encryption key values in tests and templates

* Add a column in the OATs table to differentiate between user and system generated

* Simplify args for v3ProjectPath

Co-authored-by: Matt Aitken <matt@mattaitken.com>

* Add index on org id and createdAt

* Avoid storing the encrypted oat token and its obfuscated version in the DB at all

It is a safer approach. Also we do not need to ever read the decrypted token value after creation.

* Fix prisma update condition

* Add token type to the OAT table index

* Accept OATs in the mcp auth flow

* Simplify env auth flow around the /projects endpoints

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-08-27 13:23:45 +02:00
Eric Allam 96243efe16 docs: MCP Server 2.0 (#2385)
* Add MCP Server documentation to documentation site

This change introduces comprehensive documentation for the Trigger.dev MCP Server, covering its installation, usage, and available tools. The addition is necessary to provide users with detailed guidance on leveraging the MCP Server for managing Trigger.dev projects, tasks, and deployment processes.

- Updated `docs.json` to include a new section for the MCP Server documentation, improving navigation and accessibility.
- Added `mcp-introduction.mdx` as an introductory guide to the MCP Server, detailing its purpose and installation steps.
- Included `mcp-tools.mdx`, a document outlining each tool available in the MCP Server, complete with descriptions and example usages.
- The new content is aimed at improving user understanding and efficiency in using Trigger.dev's MCP Server capabilities.

* Refactor MCP installation documentation

The changes were necessary to streamline the MCP installation guidance and align our documentation with the latest client support capabilities. Removing the manual installation section and redundant information helps reduce confusion for users.

- Updated installation instructions to lead with auto-configuration for supported clients.
- Cleaned up the options and examples sections to enhance clarity.
- Reformatted client configuration details for better readability.
- Removed outdated links in the "Next Steps" section, focusing on more direct resources such as available MCP tools.

* mcp docs edits

* Add debugMode to get_run_details

* add cancel_run

* Add list deployments tools

* Add list preview branches tool

* Docs: adds agent rules page

* Adds redirect for the CLI path to work

* Adds subagent install image

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-08-22 15:37:35 +01:00
Eric Allam ad26cded99 fix(v3): prevent saturated queues from dominating dequeue attempts by adding a cooloff period of successive failed dequeues (#2439)
* fix(v3): prevent saturated queues from dominating dequeue attempts by adding a cooloff period of successive failed dequeues

* Add env vars and add a marqs shutdown on SIGTERM/INT
2025-08-22 14:37:21 +01:00
Eric Allam 5e4756f8b7 fix(v3): eagerly dequeue messages from a queue when that queue is added to or removed from (v4 backport) (#2438)
* fix(v3): eagerly dequeue messages from a queue when that queue is added to or removed from (v4 backport)

* fixed configuration
2025-08-22 13:23:34 +01:00
Eric Allam c16cc577b0 fix(v3): adopt v4 dequeue strategy to increase perf (#2436) 2025-08-22 07:40:59 +01:00
github-actions[bot] 5b40c44ca6 Release v4.0.1
* chore: Update version for release

* Release v4.0.1

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-08-21 17:41:58 +01:00
Eric Allam c7e3811794 fix(webapp): correctly associate event-loop-blocked spans with parent span context (#2435) 2025-08-21 16:21:26 +01:00
Eric Allam bf2120d5a7 fix(runengine): remove dev worker queues from metrics (#2434) 2025-08-21 14:11:22 +01:00
Eric Allam 895e24a0d7 fix(cli): various mcp server fixes (#2430)
* fix: span events are optional, as logs don't have them

* remove CI: true env var to prevent massive tool responses

* fix github resolver relative path

* prevent the dev command from asking for user input when not interactive

* Remove lodash.get because it's deprecated

* span output can be object other than an object, like a string

* prevent large traces from causing get_run_details failures

* Decreased max log lines, made it a tool input property

* Some install mcp tweaks
2025-08-21 13:57:14 +01:00
Matt Aitken 58ff034325 Add attemptDequeue span to marqs (#2433)
* Add attemptDequeue span to marqs

More telemetry to figure out why dequeuing is slow

* More spans

* use "receive" for all operation names
2025-08-21 13:09:08 +01:00
Dan 84750f68a4 Updated main README (#2432)
* readme wip

* Added new commands

* Simplified readme

* Added docs links and improved about

* Added nice header and updated links

* Consistent headers

* Copy update

* Updated badges

* Added features / agents section

* Updated list

* Added links

* Updated links

* Minor readme tweaks

* Updated banner

* removed /v3/

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-08-21 12:28:52 +01:00
Matt Aitken 1087e5ede0 When replaying a v3 run on v4, don't pass the region (#2431) 2025-08-21 10:15:26 +01:00
Eric Allam f086626a41 feat(cli): MCP server 2.0 (#2384) 2025-08-20 16:04:50 +01:00
Matt Aitken 8483900832 Updated queue/concurrency docs for v4 (#2418) 2025-08-20 11:47:56 +01:00
Matt Aitken 4264fc066e chore(webapp): More false errors turned to logs (#2422)
* Remove variables from dequeue log message

* Continue snapshot throw json

* Waiting for deploy error removed

* Realtime ECONNRESET is expected

* Redis worker logErrors changes, removed ids

* Preview branch without a branch shouldn't log an error, it's a user provided issue

* "Task run is not in a cancellable state" isn't an error, it's expected

* "CreateCheckpointService: Child run already resumed" is expected

* "CreateCheckpointService: Batch already resumed" is expected

* "Failed to insert events, will attempt bisection" changed to info, we have errors for complete failures

* Ignore "PrismaClient error"

* Don't log Redis worker DLQ errors if we're ignoring

* "Failed to parse machine config" is fine, sometimes a config is null or undefined

* "Failed to parse machine config" for v3

* MetadataTooLargeError shouldn't log an error
2025-08-20 11:44:26 +01:00
nicktrn da79c2b185 chore(docker): add registry namespace var to .env.example (#2426) 2025-08-20 11:03:30 +01:00
nicktrn 677b7aed9c chore(docs): update bun guide (#2425)
* chore(docs): remove missing snippet references

* chore(docs): update bun guide
2025-08-20 11:03:23 +01:00
nicktrn 9092ca863f feat(supervisor): add ecr support to docker client (#2424) 2025-08-20 10:49:19 +01:00
James Ritchie b41129a24f Tabular-nums for the individually selected runs number (#2421) 2025-08-19 16:24:02 +01:00
James Ritchie 0261ccbc68 Fixes conflicting shortcut keys (#2420) 2025-08-19 15:38:53 +01:00
nicktrn 32e3fab5ea feat(helm): release 4.0.0 chart version (#2416)
* feat(helm): release 4.0.0 chart version

* chore(helm): remove last few beta references

* fix(docker): use latest tag for commands shown in webapp
2025-08-19 11:06:35 +02:00
Matt Aitken 0ee4234117 Reduce false error logging (round 1) (#2415)
* Don't log an error when the snapshot shouldn't be created, it's normal for this to happen

* Slack alerts, skip `account_inactive` errors

* v3 finalize run with no locked isn't an error

* Another false error

* Finalize run CRASHED runs were logging errors

* All slack alert errors are warnings except invalid blocks
2025-08-19 09:58:21 +01:00
Matt Aitken 471c96079a Some more queue docs changes for v4 (#2414) 2025-08-18 16:49:43 +01:00
Eric Allam 0ae59cf7b9 fix appEnv on undefined error and useShortcuts error on ErrorBoundary (#2412) 2025-08-18 15:42:16 +01:00
Matt Aitken 49e6470b6b Added missing trigger.config options to the docs (#2409) 2025-08-18 15:11:34 +01:00
Matt Aitken 9dd831f68a Fix for v4 pending versions not getting dequeued after a deploy (#2411)
We didn’t call `scheduleEnqueueRunsForBackgroundWorker` in the right 
place…
2025-08-18 15:09:12 +01:00
nicktrn 112f6f602e feat(supervisor): optionally strip digests from image refs (#2410) 2025-08-18 15:08:49 +01:00
588 changed files with 40076 additions and 20446 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Stop failing attempt spans when a run is cancelled
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
fix(realtime): Upgrade to @electric-sql/client@1.0.14 to prevent cached 409 Conflict errors from breaking realtime updates
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Added INSTALLING status to the deployment status enum.
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Fix SIGTERM handling during warm start long poll
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Added support for deployments with local builds.
+2 -6
View File
@@ -1,7 +1,3 @@
{
"mcpServers": {
"trigger.dev": {
"url": "http://localhost:3333/sse"
}
}
}
"mcpServers": {}
}
+6 -3
View File
@@ -6,7 +6,7 @@ alwaysApply: false
The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.1.0 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo:
- `@trigger.dev/database` exports a Prisma 5.4.1 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma)
- `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json)
- `@internal/run-engine` has all the code needed to trigger a run and take it through it's lifecycle to completion.
- `@trigger.dev/redis-worker` is a custom redis based background job/worker system that's used in the webapp and also used inside the run engine.
@@ -31,7 +31,10 @@ We originally the Trigger.dev "Run Engine" not as a single system, but just spre
- The batch trigger API endpoint is [api.v1.tasks.batch.ts](mdc:apps/webapp/app/routes/api.v1.tasks.batch.ts)
- Setup code for the prisma client is in [db.server.ts](mdc:apps/webapp/app/db.server.ts)
- The run engine is configured in [runEngine.server.ts](mdc:apps/webapp/app/v3/runEngine.server.ts)
- All the "services" that are found in app/v3/services/**/*.server.ts
- All the "services" that are found in app/v3/services/\*_/_.server.ts
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/**/*.server.ts
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
- All the "services" that are found in app/v3/services/\*_/_.server.ts
- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts)
- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts
+2
View File
@@ -16,6 +16,8 @@
**/dist
**/node_modules
**/generated/prisma
apps/webapp/build
apps/webapp/public/build
+5
View File
@@ -13,6 +13,11 @@ APP_ORIGIN=http://localhost:3030
ELECTRIC_ORIGIN=http://localhost:3060
NODE_ENV=development
# Clickhouse
CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_ENABLED=1
# Set this to UTC because Node.js uses the system timezone
TZ="UTC"
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
SESSION_SECRET: "secret"
MAGIC_LINK_SECRET: "secret"
ENCRYPTION_KEY: "secret"
ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes"
DEPLOY_REGISTRY_HOST: "docker.io"
CLICKHOUSE_URL: "http://default:password@localhost:8123"
+2 -1
View File
@@ -63,4 +63,5 @@ apps/**/public/build
/packages/core/src/package.json
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
.claude
.claude
.mcp.log
+5 -5
View File
@@ -59,7 +59,7 @@
"request": "launch",
"name": "Debug V3 Dev CLI",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -83,7 +83,7 @@
"request": "launch",
"name": "Debug V3 Deploy CLI",
"command": "pnpm exec trigger deploy --self-hosted --load-image",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -91,7 +91,7 @@
"request": "launch",
"name": "Debug V3 list-profiles CLI",
"command": "pnpm exec trigger list-profiles --log-level debug",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -99,7 +99,7 @@
"request": "launch",
"name": "Debug V3 update CLI",
"command": "pnpm exec trigger update",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
@@ -107,7 +107,7 @@
"request": "launch",
"name": "Debug V3 Management",
"command": "pnpm run management",
"cwd": "${workspaceFolder}/references/v3-catalog",
"cwd": "${workspaceFolder}/references/hello-world",
"sourceMaps": true
},
{
+2 -1
View File
@@ -6,5 +6,6 @@
"**/node_modules/**": true,
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": false
}
+8 -6
View File
@@ -30,14 +30,16 @@ Please follow the best-practice of adding changesets in the same commit as the c
## Snapshot instructions
1. Delete the `.changeset/pre.json` file (if it exists)
1. Update the `.changeset/config.json` file to set the `"changelog"` field to this:
```json
"changelog": "@changesets/cli/changelog",
```
2. Do a temporary commit (do NOT push this, you should undo it after)
3. Copy the `GITHUB_TOKEN` line from the .env file
3. Run `./scripts/publish-prerelease.sh prerelease`
4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2`
You can choose a different tag if you want, but usually `prerelease` is fine.
Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release.
5. Undo the commit where you deleted the pre.json file.
5. Undo the commit where you updated the config.json file.
+15 -70
View File
@@ -84,17 +84,17 @@ branch are tagged into a release periodically.
2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard.
## Manual testing using v3-catalog
## Manual testing using hello-world
We use the `<root>/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3).
We use the `<root>/references/hello-world` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `hello-world` for local development of Trigger.dev.
### First-time setup
First, make sure you are running the webapp according to the instructions above. Then:
1. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog".
1. Visit http://localhost:3030 in your browser and create a new V3 project called "hello-world".
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
2. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `proj_rrkpdguyagvsoktglnod`.
3. Build the CLI
@@ -105,10 +105,10 @@ pnpm run build --filter trigger.dev
pnpm i
```
4. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
4. Change into the `<root>/references/hello-world` directory and authorize the CLI to the local server:
```sh
cd references/v3-catalog
cd references/hello-world
cp .env.example .env
pnpm exec trigger login -a http://localhost:3030
```
@@ -118,7 +118,7 @@ This will open a new browser window and authorize the CLI against your local use
You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development:
```sh
cd references/v3-catalog
cd references/hello-world
pnpm exec trigger login -a http://localhost:3030 --profile local
# later when you run the dev or deploy command:
pnpm exec trigger dev --profile local
@@ -137,84 +137,29 @@ The following steps should be followed any time you start working on a new featu
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
```
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
3. Open another terminal window, and change into the `<root>/references/hello-world` directory.
4. You'll need to run the following commands to setup prisma and migrate the database:
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
```sh
pnpm exec prisma migrate deploy
pnpm run generate:prisma
```
5. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
```sh
# in <root>/references/v3-catalog
# in <root>/references/hello-world
pnpm exec trigger dev
```
If you want additional debug logging, you can use the `--log-level debug` flag:
```sh
# in <root>/references/v3-catalog
# in <root>/references/hello-world
pnpm exec trigger dev --log-level debug
```
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command.
6. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `hello-world/src/trigger` dir will automatically be rebuilt by the `dev` command.
7. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
7. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks.
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload.
8. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/hello-world/src/trigger` folder. Many of them accept an empty payload.
9. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
## Running end-to-end webapp tests (deprecated)
To run the end-to-end tests, follow the steps below:
1. Set up environment variables (copy example envs into the correct place)
```sh
cp ./.env.example ./.env
cp ./references/nextjs-test/.env.example ./references/nextjs-test/.env.local
```
2. Set up dependencies
```sh
# Build packages
pnpm run build --filter @references/nextjs-test^...
pnpm --filter @trigger.dev/database generate
# Move trigger-cli bin to correct place
pnpm install --frozen-lockfile
# Install playwrite browsers (ONE TIME ONLY)
npx playwright install
```
3. Set up the database
```sh
pnpm run docker
pnpm run db:migrate
pnpm run db:seed
```
4. Run the end-to-end tests
```sh
pnpm run test:e2e
```
### Cleanup
The end-to-end tests use a `setup` and `teardown` script to seed the database with test data. If the test runner doesn't exit cleanly, then the database can be left in a state where the tests can't run because the `setup` script will try to create data that already exists. If this happens, you can manually delete the `users` and `organizations` from the database using prisma studio:
```sh
# With the database running (i.e. pnpm run docker)
pnpm run db:studio
```
9. Feel free to add additional files in `hello-world/src/trigger` to test out specific aspects of the system, or add in edge cases.
## Adding and running migrations
+68 -31
View File
@@ -1,44 +1,76 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
<source media="(prefers-color-scheme: light)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/3f5ad4c1-c4c8-4277-b622-290e7f37bd00/public">
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
</picture>
### Open source background jobs and AI infrastructure
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
![Trigger.dev logo](https://content.trigger.dev/github-header-banner.jpg)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/triggerdotdev.svg?style=social&label=Follow%20%40trigger.dev)](https://twitter.com/triggerdotdev)
### Build and deploy fullymanaged AI agents and workflows
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
[![npm](https://img.shields.io/npm/v/@trigger.dev/sdk.svg?label=npm)](https://www.npmjs.com/package/@trigger.dev/sdk)
[![SDK downloads](https://img.shields.io/npm/dm/@trigger.dev/sdk.svg?label=SDK%20downloads)](https://www.npmjs.com/package/@trigger.dev/sdk)
[![Twitter Follow](https://img.shields.io/twitter/follow/triggerdotdev?style=social)](https://twitter.com/triggerdotdev)
[![Discord](https://img.shields.io/discord/1066956501299777596?logo=discord&logoColor=white&color=7289da)](https://discord.gg/nkqV9xBYWy)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/triggerdotdev/trigger.dev)
[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
</div>
## About Trigger.dev
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs. Write normal async code, deploy, and never hit a timeout.
Trigger.dev is the open-source platform for building AI workflows in TypeScript. Long-running tasks with retries, queues, observability, and elastic scaling.
### Key features:
## The platform designed for building AI agents
- JavaScript and TypeScript SDK
- No timeouts
- Retries (with exponential backoff)
- Queues and concurrency controls
- Schedules and crons
- Full Observability; logs, live trace views, advanced filtering
- React hooks to interact with the Trigger API from your React app
- Pipe LLM streams straight to your users through the Realtime API
- Trigger tasks and display the run status and metadata anywhere in your app
- Custom alerts, get notified by email, Slack or webhooks
- No infrastructure to manage
- Elastic (scaling)
- Works with your existing tech stack
Build [AI agents](https://trigger.dev/product/ai-agents) using all the frameworks, services and LLMs you're used to, deploy them to Trigger.dev and get durable, long-running tasks with retries, queues, observability, and elastic scaling out of the box.
## In your codebase
- **Long-running without timeouts**: Execute your tasks with absolutely no timeouts, unlike AWS Lambda, Vercel, and other serverless platforms.
- **Durability, retries & queues**: Build rock solid agents and AI applications using our durable tasks, retries, queues and idempotency.
- **True runtime freedom**: Customize your deployed tasks with system packages run browsers, Python scripts, FFmpeg and more.
- **Human-in-the-loop**: Programmatically pause your tasks until a human can approve, reject or give feedback.
- **Realtime apps & streaming**: Move your background jobs to the foreground by subscribing to runs or streaming AI responses to your app.
- **Observability & monitoring**: Each run has full tracing and logs. Configure error alerts to catch bugs fast.
## Key features:
- **[JavaScript and TypeScript SDK](https://trigger.dev/docs/tasks/overview)** - Build background tasks using familiar programming models
- **[Long-running tasks](https://trigger.dev/docs/runs/max-duration)** - Handle resource-heavy tasks without timeouts
- **[Durable cron schedules](https://trigger.dev/docs/tasks/scheduled#scheduled-tasks-cron)** - Create and attach recurring schedules of up to a year
- **[Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview)** - Trigger, subscribe to, and get real-time updates for runs, with LLM streaming support
- **[Build extensions](https://trigger.dev/docs/config/extensions/overview#build-extensions)** - Hook directly into the build system and customize the build process. Run Python scripts, FFmpeg, browsers, and more.
- **[React hooks](https://trigger.dev/docs/frontend/react-hooks#react-hooks)** - Interact with the Trigger.dev API on your frontend using our React hooks package
- **[Batch triggering](https://trigger.dev/docs/triggering#tasks-batchtrigger)** - Use batchTrigger() to initiate multiple runs of a task with custom payloads and options
- **[Structured inputs / outputs](https://trigger.dev/docs/tasks/schemaTask#schematask)** - Define precise data schemas for your tasks with runtime payload validation
- **[Waits](https://trigger.dev/docs/wait)** - Add waits to your tasks to pause execution for a specified duration
- **[Preview branches](https://trigger.dev/docs/deployment/preview-branches)** - Create isolated environments for testing and development. Integrates with Vercel and git workflows
- **[Waitpoints](https://trigger.dev/docs/wait-for-token#wait-for-token)** - Add human-in-the-loop judgment at critical decision points without disrupting workflow
- **[Concurrency & queues](https://trigger.dev/docs/queue-concurrency#concurrency-and-queues)** - Set concurrency rules to manage how multiple tasks execute
- **[Multiple environments](https://trigger.dev/docs/how-it-works#dev-mode)** - Support for DEV, PREVIEW, STAGING, and PROD environments
- **[No infrastructure to manage](https://trigger.dev/docs/how-it-works#trigger-dev-architecture)** - Auto-scaling infrastructure that eliminates timeouts and server management
- **[Automatic retries](https://trigger.dev/docs/errors-retrying)** - If your task encounters an uncaught error, we automatically attempt to run it again
- **[Checkpointing](https://trigger.dev/docs/how-it-works#the-checkpoint-resume-system)** - Tasks are inherently durable, thanks to our checkpointing feature
- **[Versioning](https://trigger.dev/docs/versioning)** - Atomic versioning allows you to deploy new versions without affecting running tasks
- **[Machines](https://trigger.dev/docs/machines)** - Configure the number of vCPUs and GBs of RAM you want the task to use
- **[Observability & monitoring](https://trigger.dev/product/observability-and-monitoring)** - Monitor every aspect of your tasks' performance with comprehensive logging and visualization tools
- **[Logging & tracing](https://trigger.dev/docs/logging)** - Comprehensive logging and tracing for all your tasks
- **[Tags](https://trigger.dev/docs/tags#tags)** - Attach up to ten tags to each run, allowing you to filter via the dashboard, realtime, and the SDK
- **[Run metadata](https://trigger.dev/docs/runs/metadata#run-metadata)** - Attach metadata to runs which updates as the run progresses and is available to use in your frontend for live updates
- **[Bulk actions](https://trigger.dev/docs/bulk-actions)** - Perform actions on multiple runs simultaneously, including replaying and cancelling
- **[Real-time alerts](https://trigger.dev/docs/troubleshooting-alerts#alerts)** - Choose your preferred notification method for run failures and deployments
## Write tasks in your codebase
Create tasks where they belong: in your codebase. Version control, localhost, test and review like you're already used to.
```ts
import { task } from "@trigger.dev/sdk/v3";
import { task } from "@trigger.dev/sdk";
//1. You need to export each task
export const helloWorld = task({
@@ -58,13 +90,13 @@ Use our SDK to write tasks in your codebase. There's no infrastructure to manage
## Environments
We support `Development`, `Staging`, and `Production` environments, allowing you to test your tasks before deploying them to production.
We support `Development`, `Staging`, `Preview`, and `Production` environments, allowing you to test your tasks before deploying them to production.
## Full visibility of every job run
View every task in every run so you can tell exactly what happened. We provide a full trace view of every task run so you can see what happened at every step.
![Trace view image](https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/7c1b347f-004c-4482-38a7-3f6fa9c00d00/public)
![Trace view image](https://content.trigger.dev/trace-view.png)
# Getting started
@@ -73,14 +105,19 @@ The quickest way to get started is to create an account and project in our [web
### Useful links:
- [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes
- [How it works](https://trigger.dev/docs/v3/how-it-works) - understand how Trigger.dev works under the hood
- [How it works](https://trigger.dev/docs/how-it-works) - understand how Trigger.dev works under the hood
- [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases
## Self-hosting
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guides](https://trigger.dev/docs/self-hosting/overview):
We also have a dedicated self-hosting channel in our [Discord server](https://trigger.dev/discord) for support.
- [Docker self-hosting guide](https://trigger.dev/docs/self-hosting/docker) - use Docker Compose to spin up a Trigger.dev instance
- [Kubernetes self-hosting guide](https://trigger.dev/docs/self-hosting/kubernetes) - use our official Helm chart to deploy Trigger.dev to your Kubernetes cluster
## Support and community
We have a large active community in our official [Discord server](https://trigger.dev/discord) for support, including a dedicated channel for self-hosting.
## Development
+1
View File
@@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-ecr": "^3.839.0",
"@kubernetes/client-node": "^1.0.0",
"@trigger.dev/core": "workspace:*",
"dockerode": "^4.0.6",
+17 -2
View File
@@ -35,8 +35,16 @@ const Env = z.object({
TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true),
TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250),
TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000),
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(10),
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1),
TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10),
TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"),
TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds
TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds
TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer)
TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current)
TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms)
TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate)
// Optional services
TRIGGER_WARM_START_URL: z.string().optional(),
@@ -76,6 +84,13 @@ const Env = z.object({
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false),
KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0),
KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
+12 -1
View File
@@ -128,7 +128,18 @@ class ManagedSupervisor {
dequeueIdleIntervalMs: env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS,
queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED,
maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT,
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
metricsRegistry: register,
scaling: {
strategy: env.TRIGGER_DEQUEUE_SCALING_STRATEGY,
minConsumerCount: env.TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT,
maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT,
scaleUpCooldownMs: env.TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS,
scaleDownCooldownMs: env.TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS,
targetRatio: env.TRIGGER_DEQUEUE_SCALING_TARGET_RATIO,
ewmaAlpha: env.TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA,
batchWindowMs: env.TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS,
dampingFactor: env.TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR,
},
runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED,
heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS,
sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS,
@@ -25,6 +25,7 @@ export class FailedPodHandler {
private readonly informer: Informer<V1Pod>;
private readonly reconnectIntervalMs: number;
private reconnecting = false;
// Metrics
private readonly register: Registry;
@@ -250,21 +251,48 @@ export class FailedPodHandler {
}
private makeOnError(informerName: string) {
return () => this.onError(informerName);
return (err?: unknown) => this.onError(informerName, err);
}
private async onError(informerName: string) {
private async onError(informerName: string, err?: unknown) {
if (!this.isRunning) {
this.logger.warn("onError: informer not running");
return;
}
this.logger.error("error event fired", { informerName });
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Guard against multiple simultaneous reconnections
if (this.reconnecting) {
this.logger.debug("onError: reconnection already in progress, skipping", {
informerName,
});
return;
}
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
this.reconnecting = true;
try {
const error = err instanceof Error ? err : undefined;
this.logger.error("error event fired", {
informerName,
error: error?.message,
errorType: error?.name,
});
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
} catch (handlerError) {
const error = handlerError instanceof Error ? handlerError : undefined;
this.logger.error("onError: reconnection attempt failed", {
informerName,
error: error?.message,
errorType: error?.name,
errorStack: error?.stack,
});
} finally {
this.reconnecting = false;
}
}
private makeOnConnect(informerName: string) {
+34 -4
View File
@@ -8,14 +8,16 @@ import { env } from "../env.js";
import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js";
import Docker from "dockerode";
import { tryCatch } from "@trigger.dev/core";
import { ECRAuthService } from "./ecrAuth.js";
export class DockerWorkloadManager implements WorkloadManager {
private readonly logger = new SimpleStructuredLogger("docker-workload-manager");
private readonly docker: Docker;
private readonly runnerNetworks: string[];
private readonly auth?: Docker.AuthConfig;
private readonly staticAuth?: Docker.AuthConfig;
private readonly platformOverride?: string;
private readonly ecrAuthService?: ECRAuthService;
constructor(private opts: WorkloadManagerOptions) {
this.docker = new Docker({
@@ -44,13 +46,18 @@ export class DockerWorkloadManager implements WorkloadManager {
url: env.DOCKER_REGISTRY_URL,
});
this.auth = {
this.staticAuth = {
username: env.DOCKER_REGISTRY_USERNAME,
password: env.DOCKER_REGISTRY_PASSWORD,
serveraddress: env.DOCKER_REGISTRY_URL,
};
} else if (ECRAuthService.hasAWSCredentials()) {
this.logger.info("🐋 AWS credentials found, initializing ECR auth service");
this.ecrAuthService = new ECRAuthService();
} else {
this.logger.warn("🐋 No Docker registry credentials provided, skipping auth");
this.logger.warn(
"🐋 No Docker registry credentials or AWS credentials provided, skipping auth"
);
}
}
@@ -160,9 +167,12 @@ export class DockerWorkloadManager implements WorkloadManager {
imageArchitecture: inspectResult?.Architecture,
});
// Get auth config (static or ECR)
const authConfig = await this.getAuthConfig();
// Ensure the image is present
const [createImageError, imageResponseReader] = await tryCatch(
this.docker.createImage(this.auth, {
this.docker.createImage(authConfig, {
fromImage: imageRef,
...(this.platformOverride ? { platform: this.platformOverride } : {}),
})
@@ -216,6 +226,26 @@ export class DockerWorkloadManager implements WorkloadManager {
logger.debug("create succeeded", { startResult, containerId: container.id });
}
/**
* Get authentication config for Docker operations
* Uses static credentials if available, otherwise attempts ECR auth
*/
private async getAuthConfig(): Promise<Docker.AuthConfig | undefined> {
// Use static credentials if available
if (this.staticAuth) {
return this.staticAuth;
}
// Use ECR auth if service is available
if (this.ecrAuthService) {
const ecrAuth = await this.ecrAuthService.getAuthConfig();
return ecrAuth || undefined;
}
// No auth available
return undefined;
}
private async attachContainerToNetworks({
containerId,
networkNames,
@@ -0,0 +1,144 @@
import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { tryCatch } from "@trigger.dev/core";
import Docker from "dockerode";
interface ECRTokenCache {
token: string;
username: string;
serverAddress: string;
expiresAt: Date;
}
export class ECRAuthService {
private readonly logger = new SimpleStructuredLogger("ecr-auth-service");
private readonly ecrClient: ECRClient;
private tokenCache: ECRTokenCache | null = null;
constructor() {
this.ecrClient = new ECRClient();
this.logger.info("🔐 ECR Auth Service initialized", {
region: this.ecrClient.config.region,
});
}
/**
* Check if we have AWS credentials configured
*/
static hasAWSCredentials(): boolean {
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
return true;
}
if (
process.env.AWS_PROFILE ||
process.env.AWS_ROLE_ARN ||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE
) {
return true;
}
return false;
}
/**
* Check if the current token is still valid with a 10-minute buffer
*/
private isTokenValid(): boolean {
if (!this.tokenCache) {
return false;
}
const now = new Date();
const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration
return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs);
}
/**
* Get a fresh ECR authorization token from AWS
*/
private async fetchNewToken(): Promise<ECRTokenCache | null> {
const [error, response] = await tryCatch(
this.ecrClient.send(new GetAuthorizationTokenCommand({}))
);
if (error) {
this.logger.error("Failed to get ECR authorization token", { error });
return null;
}
const authData = response.authorizationData?.[0];
if (!authData?.authorizationToken || !authData.proxyEndpoint) {
this.logger.error("Invalid ECR authorization response", { authData });
return null;
}
// Decode the base64 token to get username:password
const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8");
const [username, password] = decoded.split(":", 2);
if (!username || !password) {
this.logger.error("Failed to parse ECR authorization token");
return null;
}
const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours
const tokenCache: ECRTokenCache = {
token: password,
username,
serverAddress: authData.proxyEndpoint,
expiresAt,
};
this.logger.info("🔐 Successfully fetched ECR token", {
username,
serverAddress: authData.proxyEndpoint,
expiresAt: expiresAt.toISOString(),
});
return tokenCache;
}
/**
* Get ECR auth config for Docker operations
* Returns cached token if valid, otherwise fetches a new one
*/
async getAuthConfig(): Promise<Docker.AuthConfig | null> {
// Check if cached token is still valid
if (this.isTokenValid()) {
this.logger.debug("Using cached ECR token");
return {
username: this.tokenCache!.username,
password: this.tokenCache!.token,
serveraddress: this.tokenCache!.serverAddress,
};
}
// Fetch new token
this.logger.info("Fetching new ECR authorization token");
const newToken = await this.fetchNewToken();
if (!newToken) {
return null;
}
// Cache the new token
this.tokenCache = newToken;
return {
username: newToken.username,
password: newToken.token,
serveraddress: newToken.serverAddress,
};
}
/**
* Clear the cached token (useful for testing or forcing refresh)
*/
clearCache(): void {
this.tokenCache = null;
this.logger.debug("ECR token cache cleared");
}
}
@@ -20,6 +20,13 @@ export class KubernetesWorkloadManager implements WorkloadManager {
private namespace = env.KUBERNETES_NAMESPACE;
private placementTagProcessor: PlacementTagProcessor;
// Resource settings
private readonly cpuRequestMinCores = env.KUBERNETES_CPU_REQUEST_MIN_CORES;
private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO;
private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB;
private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO;
private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB;
constructor(private opts: WorkloadManagerOptions) {
this.k8s = createK8sApi();
this.placementTagProcessor = new PlacementTagProcessor({
@@ -49,6 +56,24 @@ export class KubernetesWorkloadManager implements WorkloadManager {
};
}
private stripImageDigest(imageRef: string): string {
if (!env.KUBERNETES_STRIP_IMAGE_DIGEST) {
return imageRef;
}
const atIndex = imageRef.lastIndexOf("@");
if (atIndex === -1) {
return imageRef;
}
return imageRef.substring(0, atIndex);
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
async create(opts: WorkloadManagerCreateOptions) {
this.logger.log("[KubernetesWorkloadManager] Creating container", { opts });
@@ -74,7 +99,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
containers: [
{
name: "run-controller",
image: opts.image,
image: this.stripImageDigest(opts.image),
ports: [
{
containerPort: 8000,
@@ -249,6 +274,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
restartPolicy: "Never",
automountServiceAccountToken: false,
imagePullSecrets: this.getImagePullSecrets(),
...(env.KUBERNETES_SCHEDULER_NAME
? {
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
}
: {}),
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
? {
nodeSelector: {
@@ -277,20 +307,32 @@ export class KubernetesWorkloadManager implements WorkloadManager {
envtype: this.#envTypeToLabelValue(opts.envType),
org: opts.orgId,
project: opts.projectId,
machine: opts.machine.name,
};
}
#getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities {
const cpuRequest = preset.cpu * this.cpuRequestRatio;
const memoryRequest = preset.memory * this.memoryRequestRatio;
// Clamp between min and max
const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu);
const clampedMemory = this.clamp(memoryRequest, this.memoryRequestMinGb, preset.memory);
return {
cpu: `${preset.cpu * 0.75}`,
memory: `${preset.memory}G`,
cpu: `${clampedCpu}`,
memory: `${clampedMemory}G`,
};
}
#getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities {
const memoryLimit = this.memoryOverheadGb
? preset.memory + this.memoryOverheadGb
: preset.memory;
return {
cpu: `${preset.cpu}`,
memory: `${preset.memory}G`,
memory: `${memoryLimit}G`,
};
}
+26 -41
View File
@@ -16,7 +16,6 @@ import {
type WorkloadRunAttemptCompleteResponseBody,
WorkloadRunAttemptStartRequestBody,
type WorkloadRunAttemptStartResponseBody,
type WorkloadRunLatestSnapshotResponseBody,
WorkloadRunSnapshotsSinceResponseBody,
type WorkloadServerToClientEvents,
type WorkloadSuspendRunResponseBody,
@@ -126,7 +125,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
}
private createHttpServer({ host, port }: { host: string; port: number }) {
return new HttpServer({
const httpServer = new HttpServer({
port,
host,
metrics: {
@@ -322,28 +321,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
},
}
)
.route("/api/v1/workload-actions/runs/:runFriendlyId/snapshots/latest", "GET", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
handler: async ({ req, reply, params }) => {
const latestSnapshotResponse = await this.workerClient.getLatestSnapshot(
params.runFriendlyId,
this.runnerIdFromRequest(req)
);
if (!latestSnapshotResponse.success) {
this.logger.error("Failed to get latest snapshot", {
runId: params.runFriendlyId,
error: latestSnapshotResponse.error,
});
reply.empty(500);
return;
}
reply.json({
execution: latestSnapshotResponse.data.execution,
} satisfies WorkloadRunLatestSnapshotResponseBody);
},
})
.route(
"/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId",
"GET",
@@ -369,23 +346,6 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
},
}
)
.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
bodySchema: WorkloadDebugLogRequestBody,
handler: async ({ req, reply, params, body }) => {
reply.empty(204);
if (!env.SEND_RUN_DEBUG_LOGS) {
return;
}
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
this.runnerIdFromRequest(req)
);
},
})
.route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", {
paramsSchema: z.object({
deploymentId: z.string(),
@@ -410,6 +370,31 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
reply.json(dequeueResponse.data satisfies WorkloadDequeueFromVersionResponseBody);
},
});
if (env.SEND_RUN_DEBUG_LOGS) {
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }),
bodySchema: WorkloadDebugLogRequestBody,
handler: async ({ req, reply, params, body }) => {
reply.empty(204);
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
this.runnerIdFromRequest(req)
);
},
});
} else {
// Lightweight mock route without schemas
httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", {
handler: async ({ reply }) => {
reply.empty(204);
},
});
}
return httpServer;
}
private createWebsocketServer() {
+1
View File
@@ -0,0 +1 @@
../../.env
+2 -1
View File
@@ -9,7 +9,8 @@ node_modules
/app/styles/tailwind.css
# Ensure the .env symlink is not removed by accident
!.env
# Storybook build outputs
build-storybook.log
@@ -0,0 +1,34 @@
export function MoveToTopIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_17186_103975)">
<path
d="M12 21L12 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 3L21 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 11.5L12 7L7.5 11.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_17186_103975">
<rect width="24" height="24" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
@@ -0,0 +1,41 @@
export function MoveUpIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_17177_110851)">
<path
d="M12 21L12 13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 3L21 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 7L21 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16.5 15.5L12 11L7.5 15.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_17177_110851">
<rect width="24" height="24" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
+150 -132
View File
@@ -52,6 +52,15 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import { GitHubConnectionPrompt } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route";
import SegmentedControl from "./primitives/SegmentedControl";
import { useState } from "react";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
export function HasNoTasksDev() {
return (
@@ -93,62 +102,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +220,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +229,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +592,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubConnectionPrompt
gitHubAppInstallations={[]}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
/>
</div>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
@@ -11,10 +11,11 @@ import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v
import { formatNumber } from "~/utils/numberFormatter";
import { SpinnerWhite } from "./primitives/Spinner";
import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline";
import assertNever from "assert-never";
import { AppliedFilter } from "./primitives/AppliedFilter";
import { runStatusTitle } from "./runs/v3/TaskRunStatus";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]);
export type BulkActionMode = z.infer<typeof BulkActionMode>;
@@ -244,7 +245,7 @@ function Action({ action }: { action: BulkActionAction }) {
case "cancel":
return (
<span>
<XCircleIcon className="mb-0.5 inline-block size-4 text-error" />
<XCircleIconOutline className="mb-0.5 inline-block size-4 text-error" />
<span className="ml-0.5 text-text-bright">Canceled</span>
</span>
);
@@ -14,7 +14,7 @@ export function DefinitionTip({
return (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger>
<TooltipTrigger className="text-left">
<span className="cursor-default underline decoration-charcoal-500 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-400">
{children}
</span>
+7 -1
View File
@@ -123,7 +123,7 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Context">
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "x" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Metadata">
<ShortcutKey shortcut={{ key: "m" }} variant="medium/bright" />
@@ -147,6 +147,12 @@ function ShortcutContent() {
</Paragraph>
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to root run">
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to parent run">
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
@@ -1,6 +1,6 @@
import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { type User } from "@trigger.dev/database";
import type { User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -13,13 +13,14 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
UsersIcon,
} from "@heroicons/react/20/solid";
import { useNavigation } from "@remix-run/react";
import { Link, useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
@@ -397,9 +398,15 @@ function ProjectSelector({
>
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
<div className="flex items-center gap-2.5">
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
<Link
to={organizationSettingsPath(organization)}
className="group relative box-content size-10 overflow-clip rounded-sm bg-charcoal-800"
>
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
</div>
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
<PencilSquareIcon className="size-5 text-text-bright" />
</div>
</Link>
<div className="space-y-0.5">
<Paragraph variant="small/bright">{organization.title}</Paragraph>
<div className="flex items-baseline gap-2">
@@ -6,7 +6,7 @@ import {
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import { type Prisma } from "@trigger.dev/database";
import type { Prisma } from "@trigger.dev/database";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
@@ -276,7 +276,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
<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">
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
@@ -298,19 +298,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled,
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled: disabled || !props.shortcut,
});
return (
<button
@@ -345,16 +343,16 @@ export const LinkButton = ({
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: disabled || !props.shortcut,
});
if (disabled) {
return (
@@ -1,41 +1,185 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex items-center justify-center transition duration-100", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
@@ -61,39 +205,7 @@ export type TabsProps = {
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
@@ -8,10 +8,12 @@ export function CopyableText({
value,
copyValue,
className,
asChild,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
@@ -35,6 +37,7 @@ export function CopyableText({
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
@@ -50,6 +53,7 @@ export function CopyableText({
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
@@ -5,9 +5,10 @@ import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { Link } from "@remix-run/react";
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
import { type ButtonContentPropsType, Button, ButtonContent } from "./Buttons";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
import { type RenderIcon } from "./Icon";
@@ -52,42 +53,78 @@ function PopoverSectionHeader({
);
}
function PopoverMenuItem({
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
}: {
to: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
}) {
return (
<LinkButton
to={to}
variant={variant.variant}
LeadingIcon={icon}
leadingIconClassName={leadingIconClassName}
fullWidth
textAlignLeft
TrailingIcon={isSelected ? CheckIcon : undefined}
className={cn(
const PopoverMenuItem = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
{
to?: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
disabled?: boolean;
}
>(
(
{
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
onClick,
disabled,
},
ref
) => {
const contentProps = {
variant: variant.variant,
LeadingIcon: icon,
leadingIconClassName,
fullWidth: true,
textAlignLeft: true,
TrailingIcon: isSelected ? CheckIcon : undefined,
className: cn(
"group-hover:bg-charcoal-700",
isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined,
className
)}
>
{title}
</LinkButton>
);
}
),
} as const;
if (to) {
return (
<Link
to={to}
ref={ref as React.Ref<HTMLAnchorElement>}
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
onClick={onClick as any}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</Link>
);
}
return (
<button
type="button"
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
disabled={disabled}
className={cn(
"group/button outline-none focus-custom",
contentProps.fullWidth ? "w-full" : ""
)}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</button>
);
}
);
PopoverMenuItem.displayName = "PopoverMenuItem";
function PopoverCustomTrigger({
isOpen,
@@ -39,7 +39,7 @@ const variants = {
description: {
button:
"w-full p-2.5 hover:data-[state=checked]:bg-white/[4%] data-[state=checked]:bg-white/[4%] transition data-[disabled]:opacity-70 hover:border-charcoal-600 border-charcoal-650 hover:data-[state=checked]:border-charcoal-600 border rounded-md",
label: "text-text-bright font-semibold -mt-1 text-left text-sm",
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
description: "text-text-dimmed -mt-0 text-left",
inputPosition: "mt-0",
icon: "w-8 h-8 mb-2",
+111 -14
View File
@@ -1,10 +1,12 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
@@ -12,13 +14,14 @@ export type TabsProps = {
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId }: TabsProps) {
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className}>
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId}>
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
{tab.label}
</TabLink>
))}
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
@@ -51,13 +138,19 @@ export function TabLink({
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -116,7 +209,11 @@ export function TabButton({
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -1,6 +1,10 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "./Icon";
import { useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
const variations = {
primary:
@@ -17,6 +21,9 @@ type TextLinkProps = {
trailingIconClassName?: string;
variant?: keyof typeof variations;
children: React.ReactNode;
shortcut?: ShortcutDefinition;
hideShortcutKey?: boolean;
tooltip?: React.ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
export function TextLink({
@@ -27,20 +34,61 @@ export function TextLink({
trailingIcon,
trailingIconClassName,
variant = "primary",
shortcut,
hideShortcutKey,
tooltip,
...props
}: TextLinkProps) {
const innerRef = useRef<HTMLAnchorElement>(null);
const classes = variations[variant];
return to ? (
<Link to={to} className={cn(classes, className)} {...props}>
if (shortcut) {
useShortcutKeys({
shortcut: shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
const renderShortcutKey = () =>
shortcut &&
!hideShortcutKey && <ShortcutKey className="ml-1.5" shortcut={shortcut} variant="small" />;
const linkContent = (
<>
{children}{" "}
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
{shortcut && !tooltip && renderShortcutKey()}
</>
);
const linkElement = to ? (
<Link ref={innerRef} to={to} className={cn(classes, className)} {...props}>
{linkContent}
</Link>
) : href ? (
<a href={href} className={cn(classes, className)} {...props}>
{children}{" "}
{trailingIcon && <Icon icon={trailingIcon} className={cn("size-4", trailingIconClassName)} />}
<a ref={innerRef} href={href} className={cn(classes, className)} {...props}>
{linkContent}
</a>
) : (
<span>Need to define a path or href</span>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{linkElement}</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 linkElement;
}
@@ -1,5 +1,5 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import { BatchTaskRunStatus } from "@trigger.dev/database";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -1,5 +1,5 @@
import { ArrowPathIcon, CheckCircleIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
import { BulkActionStatus, type BulkActionType } from "@trigger.dev/database";
import type { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -2,9 +2,10 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
@@ -49,6 +50,10 @@ export function DeploymentStatusIcon({
}) {
switch (status) {
case "PENDING":
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
@@ -73,6 +78,8 @@ export function DeploymentStatusIcon({
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return "text-pending";
@@ -92,7 +99,9 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
switch (status) {
case "PENDING":
return "Pending…";
return "Queued…";
case "INSTALLING":
return "Installing…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
@@ -121,17 +130,22 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"PENDING",
"INSTALLING",
"BUILDING",
"DEPLOYING",
"DEPLOYED",
"FAILED",
"TIMED_OUT",
"CANCELED",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "The deployment is queued and waiting to be processed.";
case "INSTALLING":
return "The project dependencies are being installed.";
case "BUILDING":
return "The code is being built and prepared for deployment.";
case "DEPLOYING":
@@ -1,102 +0,0 @@
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 {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
export function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
export function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -58,10 +58,10 @@ import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionTypeCombo } from "./BulkAction";
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
import { AIFilterInput } from "./AIFilterInput";
import {
allTaskRunStatuses,
@@ -280,7 +280,7 @@ export function getRunFiltersFromSearchParams(
bulkId: searchParams.get("bulkId") ?? undefined,
tags:
searchParams.getAll("tags").filter((v) => v.length > 0).length > 0
? searchParams.getAll("tags").map((t) => decodeURIComponent(t))
? searchParams.getAll("tags")
: undefined,
from: searchParams.get("from") ?? undefined,
to: searchParams.get("to") ?? undefined,
@@ -810,8 +810,8 @@ function TagsDropdown({
searchValue: string;
onClose?: () => void;
}) {
const project = useProject();
const { values, replace } = useSearchParams();
const environment = useEnvironment();
const { values, value, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
@@ -822,6 +822,12 @@ function TagsDropdown({
});
};
const { period, from, to } = timeFilters({
period: value("period"),
from: value("from"),
to: value("to"),
});
const tagValues = values("tags").filter((v) => v !== "");
const selected = tagValues.length > 0 ? tagValues : undefined;
@@ -830,25 +836,34 @@ function TagsDropdown({
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
searchParams.set("name", searchValue);
}
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
}, [searchValue]);
if (period) {
searchParams.set("period", period);
}
if (from) {
searchParams.set("from", from.getTime().toString());
}
if (to) {
searchParams.set("to", to.getTime().toString());
}
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = selected ?? [];
items = [...(selected ?? [])];
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
items.push(...fetcher.data.tags.map((t) => t.name));
items.push(...fetcher.data.tags);
return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
@@ -958,7 +973,7 @@ function QueuesDropdown({
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", encodeURIComponent(s));
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
@@ -1220,7 +1235,7 @@ function VersionsDropdown({
(s) => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("query", encodeURIComponent(s));
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
@@ -1,6 +1,5 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
@@ -11,7 +11,7 @@ import {
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
@@ -1,5 +1,5 @@
import { ClockIcon } from "@heroicons/react/20/solid";
import { type TaskTriggerSource } from "@trigger.dev/database";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
@@ -330,7 +330,7 @@ function TagsDropdown({
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
searchParams.set("name", searchValue);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
+33 -9
View File
@@ -1,10 +1,11 @@
import {
Prisma,
PrismaClient,
PrismaClientOrTransaction,
PrismaReplicaClient,
PrismaTransactionClient,
PrismaTransactionOptions,
$transaction as transac,
type PrismaClientOrTransaction,
type PrismaReplicaClient,
type PrismaTransactionClient,
type PrismaTransactionOptions,
} from "@trigger.dev/database";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -12,9 +13,9 @@ import { env } from "./env.server";
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";
import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server";
export type {
PrismaTransactionClient,
@@ -153,13 +154,19 @@ function getClient() {
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
// Query performance monitoring
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
? [
{
emit: "event",
level: "query",
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{
emit: "stdout",
level: "query",
@@ -201,10 +208,16 @@ function getClient() {
message: log.message,
target: log.target,
},
ignoreError: true,
});
});
}
// Add query performance monitoring
client.$on("query", (log) => {
queryPerformanceMonitor.onQuery("writer", log);
});
// connect eagerly
client.$connect();
@@ -264,13 +277,19 @@ function getReplicaClient() {
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
// Query performance monitoring
...((process.env.VERBOSE_PRISMA_LOGS === "1" ||
process.env.VERY_SLOW_QUERY_THRESHOLD_MS !== undefined
? [
{
emit: "event",
level: "query",
},
]
: []) satisfies Prisma.LogDefinition[]),
// verbose
...((process.env.VERBOSE_PRISMA_LOGS === "1"
? [
{
emit: "stdout",
level: "query",
@@ -316,6 +335,11 @@ function getReplicaClient() {
});
}
// Add query performance monitoring for replica client
replicaClient.$on("query", (log) => {
queryPerformanceMonitor.onQuery("replica", log);
});
// connect eagerly
replicaClient.$connect();
+5
View File
@@ -240,6 +240,7 @@ import { logger } from "./services/logger.server";
import { Prisma } from "./db.server";
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import { resourceMonitor } from "./services/resourceMonitor.server";
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
eventLoopMonitor.enable();
@@ -250,3 +251,7 @@ if (remoteBuildsEnabled()) {
} else {
console.log("🏗️ Local builds enabled");
}
if (env.RESOURCE_MONITOR_ENABLED === "1") {
resourceMonitor.startMonitoring(1000);
}
+1200 -1057
View File
File diff suppressed because it is too large Load Diff
+55 -8
View File
@@ -1,10 +1,15 @@
import { createHook } from "node:async_hooks";
import { singleton } from "./utils/singleton";
import { tracer } from "./v3/tracer.server";
import { env } from "./env.server";
import { context, Context } from "@opentelemetry/api";
import { performance } from "node:perf_hooks";
import { logger } from "./services/logger.server";
import { signalsEmitter } from "./services/signals.server";
const THRESHOLD_NS = 1e8; // 100ms
const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6;
const cache = new Map<number, { type: string; start?: [number, number] }>();
const cache = new Map<number, { type: string; start?: [number, number]; parentCtx?: Context }>();
function init(asyncId: number, type: string, triggerAsyncId: number, resource: any) {
cache.set(asyncId, {
@@ -26,6 +31,7 @@ function before(asyncId: number) {
cache.set(asyncId, {
...cached,
start: process.hrtime(),
parentCtx: context.active(),
});
}
@@ -47,13 +53,17 @@ function after(asyncId: number) {
if (diffNs > THRESHOLD_NS) {
const time = diffNs / 1e6; // in ms
const newSpan = tracer.startSpan("event-loop-blocked", {
startTime: new Date(new Date().getTime() - time),
attributes: {
asyncType: cached.type,
label: "EventLoopMonitor",
const newSpan = tracer.startSpan(
"event-loop-blocked",
{
startTime: new Date(new Date().getTime() - time),
attributes: {
asyncType: cached.type,
label: "EventLoopMonitor",
},
},
});
cached.parentCtx
);
newSpan.end();
}
@@ -62,16 +72,53 @@ function after(asyncId: number) {
export const eventLoopMonitor = singleton("eventLoopMonitor", () => {
const hook = createHook({ init, before, after, destroy });
let stopEventLoopUtilizationMonitoring: () => void;
return {
enable: () => {
console.log("🥸 Initializing event loop monitor");
hook.enable();
stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring();
},
disable: () => {
console.log("🥸 Disabling event loop monitor");
hook.disable();
stopEventLoopUtilizationMonitoring?.();
},
};
});
function startEventLoopUtilizationMonitoring() {
let lastEventLoopUtilization = performance.eventLoopUtilization();
const interval = setInterval(() => {
const currentEventLoopUtilization = performance.eventLoopUtilization();
const diff = performance.eventLoopUtilization(
currentEventLoopUtilization,
lastEventLoopUtilization
);
const utilization = Number.isFinite(diff.utilization) ? diff.utilization : 0;
if (Math.random() < env.EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE) {
logger.info("nodejs.event_loop.utilization", { utilization });
}
lastEventLoopUtilization = currentEventLoopUtilization;
}, env.EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS);
signalsEmitter.on("SIGTERM", () => {
clearInterval(interval);
});
signalsEmitter.on("SIGINT", () => {
clearInterval(interval);
});
return () => {
clearInterval(interval);
};
}
@@ -0,0 +1,48 @@
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
type UseAutoRevalidateOptions = {
interval?: number; // in milliseconds
onFocus?: boolean;
disabled?: boolean;
};
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
const { interval = 5000, onFocus = true, disabled = false } = options;
const revalidator = useRevalidator();
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
if (revalidator.state === "loading") {
return;
}
revalidator.revalidate();
}, interval);
return () => clearInterval(intervalId);
}, [interval, disabled]);
useEffect(() => {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
revalidator.revalidate();
}
};
// Revalidate when the page becomes visible
document.addEventListener("visibilitychange", handleFocus);
// Revalidate when the window gains focus
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleFocus);
window.removeEventListener("focus", handleFocus);
};
}, [onFocus, disabled]);
return revalidator;
}
+2 -8
View File
@@ -33,12 +33,7 @@ export function useSearchParams() {
const value = useCallback(
(param: string) => {
const search = new URLSearchParams(location.search);
const val = search.get(param) ?? undefined;
if (val === undefined) {
return val;
}
return decodeURIComponent(val);
return search.get(param) ?? undefined;
},
[location]
);
@@ -46,8 +41,7 @@ export function useSearchParams() {
const values = useCallback(
(param: string) => {
const search = new URLSearchParams(location.search);
const all = search.getAll(param);
return all.map((v) => decodeURIComponent(v));
return search.getAll(param);
},
[location]
);
+58 -33
View File
@@ -1,5 +1,9 @@
import { prisma } from "~/db.server";
import { type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import { customAlphabet } from "nanoid";
const tokenValueLength = 40;
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
export async function getTeamMembersAndInvites({
userId,
@@ -95,14 +99,19 @@ export async function inviteMembers({
throw new Error("User does not have access to this organization");
}
const created = await prisma.orgMemberInvite.createMany({
data: emails.map((email) => ({
email,
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
})),
skipDuplicates: true,
const invites = [...new Set(emails)].map(
(email) =>
({
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
} satisfies Prisma.OrgMemberInviteCreateManyInput)
);
await prisma.orgMemberInvite.createMany({
data: invites,
});
return await prisma.orgMemberInvite.findMany({
@@ -147,12 +156,19 @@ export async function getUsersInvites({ email }: { email: string }) {
});
}
export async function acceptInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
export async function acceptInvite({
user,
inviteId,
}: {
user: { id: string; email: string };
inviteId: string;
}) {
return await prisma.$transaction(async (tx) => {
// 1. Delete the invite and get the invite details
const invite = await tx.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
include: {
organization: {
@@ -167,7 +183,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
const member = await tx.orgMember.create({
data: {
organizationId: invite.organizationId,
userId,
userId: user.id,
role: invite.role,
},
});
@@ -187,7 +203,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
// 4. Check for other invites
const remainingInvites = await tx.orgMemberInvite.findMany({
where: {
email: invite.email,
email: user.email,
},
});
@@ -195,28 +211,29 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
});
}
export async function declineInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
export async function declineInvite({
user,
inviteId,
}: {
user: { id: string; email: string };
inviteId: string;
}) {
return await prisma.$transaction(async (tx) => {
//1. delete invite
const declinedInvite = await prisma.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
include: {
organization: true,
},
});
//2. get email
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
//3. check for other invites
//2. check for other invites
const remainingInvites = await prisma.orgMemberInvite.findMany({
where: {
email: user!.email,
email: user.email,
},
});
@@ -224,10 +241,11 @@ export async function declineInvite({ userId, inviteId }: { userId: string; invi
});
}
export async function resendInvite({ inviteId }: { inviteId: string }) {
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
return await prisma.orgMemberInvite.update({
where: {
id: inviteId,
inviterId: userId,
},
data: {
updatedAt: new Date(),
@@ -241,26 +259,27 @@ export async function resendInvite({ inviteId }: { inviteId: string }) {
export async function revokeInvite({
userId,
slug,
orgSlug,
inviteId,
}: {
userId: string;
slug: string;
orgSlug: string;
inviteId: string;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
});
if (!org) {
throw new Error("User does not have access to this organization");
}
const invite = await prisma.orgMemberInvite.delete({
const invite = await prisma.orgMemberInvite.findFirst({
where: {
id: inviteId,
organizationId: org.id,
organization: {
slug: orgSlug,
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
email: true,
organization: true,
},
@@ -270,5 +289,11 @@ export async function revokeInvite({
throw new Error("Invite not found");
}
await prisma.orgMemberInvite.delete({
where: {
id: invite.id,
},
});
return { email: invite.email, organization: invite.organization };
}
@@ -37,7 +37,7 @@ export async function findEnvironmentByApiKey(
if (environment.type === "PREVIEW") {
if (!branchName) {
logger.error("findEnvironmentByApiKey(): Preview env with no branch name provided", {
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
environmentId: environment.id,
});
return null;
@@ -75,6 +75,7 @@ export class ApiRetrieveRunPresenter {
},
select: {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
@@ -1,5 +1,5 @@
import {
Prisma,
type Prisma,
type WorkerDeploymentStatus,
type WorkerInstanceGroupType,
} from "@trigger.dev/database";
@@ -9,6 +9,7 @@ import { type Project } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { processGitMetadata } from "./BranchesPresenter.server";
import { BranchTrackingConfigSchema, getTrackedBranchForEnvironment } from "~/v3/github";
const pageSize = 20;
@@ -56,6 +57,18 @@ export class DeploymentListPresenter {
},
},
},
connectedGithubRepository: {
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
repository: {
select: {
htmlUrl: true,
fullName: true,
},
},
},
},
},
where: {
slug: projectSlug,
@@ -131,7 +144,7 @@ export class DeploymentListPresenter {
wd."git"
FROM
${sqlDatabaseSchema}."WorkerDeployment" as wd
INNER JOIN
LEFT JOIN
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
WHERE
wd."projectId" = ${project.id}
@@ -140,9 +153,28 @@ ORDER BY
string_to_array(wd."version", '.')::int[] DESC
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
const { connectedGithubRepository } = project;
const branchTrackingOrError =
connectedGithubRepository &&
BranchTrackingConfigSchema.safeParse(connectedGithubRepository.branchTracking);
const environmentGitHubBranch =
branchTrackingOrError && branchTrackingOrError.success
? getTrackedBranchForEnvironment(
branchTrackingOrError.data,
connectedGithubRepository.previewDeploymentsEnabled,
{
type: environment.type,
branchName: environment.branchName ?? undefined,
}
)
: undefined;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
connectedGithubRepository: project.connectedGithubRepository ?? undefined,
environmentGitHubBranch,
deployments: deployments.map((deployment, index) => {
const label = labeledDeployments.find(
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
@@ -102,6 +102,10 @@ export class DeploymentPresenter {
builtAt: true,
deployedAt: true,
createdAt: true,
startedAt: true,
installedAt: true,
canceledAt: true,
canceledReason: true,
git: true,
promotions: {
select: {
@@ -145,8 +149,12 @@ export class DeploymentPresenter {
version: deployment.version,
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
installedAt: deployment.installedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
canceledAt: deployment.canceledAt,
canceledReason: deployment.canceledReason,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
@@ -114,6 +114,9 @@ export class QueueListPresenter extends BasePresenter {
name: true,
orderableName: true,
concurrencyLimit: true,
concurrencyLimitBase: true,
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
type: true,
paused: true,
},
@@ -135,6 +138,17 @@ export class QueueListPresenter extends BasePresenter {
),
]);
// Manually "join" the overridden users because there is no way to implement the relationship
// in prisma without adding a foreign key constraint
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
const overriddenByUsers = await this._replica.user.findMany({
where: {
id: { in: overriddenByIds },
},
});
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
// Transform queues to include running and queued counts
return queues.map((queue) =>
toQueueItem({
@@ -144,6 +158,11 @@ export class QueueListPresenter extends BasePresenter {
running: results[1][queue.name] ?? 0,
queued: results[0][queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy
? overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null
: null,
paused: queue.paused,
})
);
@@ -1,12 +1,18 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
import { type TaskQueueType } from "@trigger.dev/database";
import { TaskQueue, User, type TaskQueueType } from "@trigger.dev/database";
import { assertExhaustive } from "@trigger.dev/core";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
import { type Prettify, type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
import { PrismaClientOrTransaction } from "@trigger.dev/database";
export type FoundQueue = Prettify<
Omit<TaskQueue, "concurrencyLimitOverriddenBy"> & {
concurrencyLimitOverriddenBy?: User | null;
}
>;
/**
* Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService
*/
@@ -16,22 +22,50 @@ export async function getQueue(
queue: RetrieveQueueParam
) {
if (typeof queue === "string") {
return prismaClient.taskQueue.findFirst({
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
},
});
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
},
})
);
}
const queueName =
queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name;
return prismaClient.taskQueue.findFirst({
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
},
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
},
})
);
}
async function joinQueueWithUser(
prismaClient: PrismaClientOrTransaction,
queue?: TaskQueue | null
): Promise<FoundQueue | undefined> {
if (!queue) return undefined;
if (!queue.concurrencyLimitOverriddenBy) {
return {
...queue,
concurrencyLimitOverriddenBy: undefined,
};
}
const user = await prismaClient.user.findFirst({
where: { id: queue.concurrencyLimitOverriddenBy },
});
return {
...queue,
concurrencyLimitOverriddenBy: user,
};
}
export class QueueRetrievePresenter extends BasePresenter {
@@ -75,6 +109,9 @@ export class QueueRetrievePresenter extends BasePresenter {
running: results[1]?.[queue.name] ?? 0,
queued: results[0]?.[queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
paused: queue.paused,
}),
};
@@ -104,6 +141,9 @@ export function toQueueItem(data: {
running: number;
queued: number;
concurrencyLimit: number | null;
concurrencyLimitBase: number | null;
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: User | null;
paused: boolean;
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
return {
@@ -113,9 +153,22 @@ export function toQueueItem(data: {
type: queueTypeFromType(data.type),
running: data.running,
queued: data.queued,
concurrencyLimit: data.concurrencyLimit,
paused: data.paused,
concurrencyLimit: data.concurrencyLimit,
concurrency: {
current: data.concurrencyLimit,
base: data.concurrencyLimitBase,
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
overriddenAt: data.concurrencyLimitOverriddenAt,
},
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
releaseConcurrencyOnWaitpoint: true,
};
}
function toQueueConcurrencyOverriddenBy(user: User | null) {
if (!user) return null;
return user.displayName ?? user.name ?? null;
}
@@ -1,11 +1,13 @@
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
import { prisma, PrismaClient } from "~/db.server";
import { prisma, type PrismaClient } from "~/db.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { eventRepository } from "~/v3/eventRepository.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { env } from "~/env.server";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
@@ -28,7 +30,6 @@ export class RunPresenter {
public async call({
userId,
projectSlug,
organizationSlug,
environmentSlug,
runFriendlyId,
showDeletedLogs,
@@ -36,7 +37,6 @@ export class RunPresenter {
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
environmentSlug: string;
runFriendlyId: string;
showDeletedLogs: boolean;
@@ -47,9 +47,11 @@ export class RunPresenter {
id: true,
createdAt: true,
taskEventStore: true,
taskIdentifier: true,
number: true,
traceId: true,
spanId: true,
parentSpanId: true,
friendlyId: true,
status: true,
startedAt: true,
@@ -58,7 +60,13 @@ export class RunPresenter {
rootTaskRun: {
select: {
friendlyId: true,
taskIdentifier: true,
spanId: true,
createdAt: true,
},
},
parentTaskRun: {
select: {
friendlyId: true,
spanId: true,
createdAt: true,
},
@@ -87,6 +95,13 @@ export class RunPresenter {
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
@@ -111,6 +126,7 @@ export class RunPresenter {
completedAt: run.completedAt,
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
rootTaskRun: run.rootTaskRun,
parentTaskRun: run.parentTaskRun,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
@@ -125,21 +141,56 @@ export class RunPresenter {
return {
run: runData,
trace: undefined,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
};
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
// get the events
const traceSummary = await eventRepository.getTraceSummary(
let traceSummary = await eventRepository.getTraceSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
run.traceId,
run.rootTaskRun?.createdAt ?? run.createdAt,
run.completedAt ?? undefined,
{ includeDebugLogs: showDebug }
);
if (!traceSummary) {
return {
run: runData,
trace: undefined,
const spanSummary: SpanSummary = {
id: run.spanId,
parentId: run.parentSpanId ?? undefined,
runId: run.friendlyId,
data: {
message: run.taskIdentifier,
style: { icon: "task", variant: "primary" },
events: [],
startTime: run.createdAt,
duration: 0,
isError:
run.status === "COMPLETED_WITH_ERRORS" ||
run.status === "CRASHED" ||
run.status === "EXPIRED" ||
run.status === "SYSTEM_FAILURE" ||
run.status === "TIMED_OUT",
isPartial:
run.status === "DELAYED" ||
run.status === "PENDING" ||
run.status === "PAUSED" ||
run.status === "RETRYING_AFTER_FAILURE" ||
run.status === "DEQUEUED" ||
run.status === "EXECUTING" ||
run.status === "WAITING_TO_RESUME",
isCancelled: run.status === "CANCELED",
isDebug: false,
level: "TRACE",
},
};
traceSummary = {
rootSpan: spanSummary,
spans: [spanSummary],
};
}
@@ -202,15 +253,15 @@ export class RunPresenter {
trace: {
rootSpanStatus,
events: events,
parentRunFriendlyId:
tree?.id === traceSummary.rootSpan.id ? undefined : traceSummary.rootSpan.runId,
duration: totalDuration,
rootStartedAt: tree?.data.startTime,
startedAt: run.startedAt,
queuedDuration: run.startedAt
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
: undefined,
overridesBySpanId: traceSummary.overridesBySpanId,
},
maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting,
};
}
}
@@ -3,7 +3,7 @@ import { eventStream } from "remix-utils/sse/server";
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { throttle } from "~/utils/throttle";
import { eventRepository } from "~/v3/eventRepository.server";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
const pingInterval = 1000;
@@ -41,7 +41,7 @@ export class RunStreamPresenter {
let pinger: NodeJS.Timeout | undefined = undefined;
const { unsubscribe, eventEmitter } = await eventRepository.subscribeToTrace(run.traceId);
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
return eventStream(request.signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
@@ -1,8 +1,16 @@
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BasePresenter } from "./basePresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { type PrismaClient } from "@trigger.dev/database";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
export type TagListOptions = {
userId?: string;
organizationId: string;
environmentId: string;
projectId: string;
period?: string;
from?: Date;
to?: Date;
//filters
name?: string;
//pagination
@@ -17,40 +25,39 @@ export type TagListItem = TagList["tags"][number];
export class RunTagListPresenter extends BasePresenter {
public async call({
userId,
organizationId,
environmentId,
projectId,
name,
period,
from,
to,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const tags = await this._replica.taskRunTag.findMany({
where: {
projectId,
name: name
? {
startsWith: name,
mode: "insensitive",
}
: undefined,
},
orderBy: {
id: "desc",
},
take: pageSize + 1,
skip: (page - 1) * pageSize,
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
prisma: this._replica as PrismaClient,
});
const tags = await runsRepository.listTags({
organizationId,
projectId,
environmentId,
query: name,
period,
from: from ? from.getTime() : undefined,
to: to ? to.getTime() : undefined,
offset: (page - 1) * pageSize,
limit: pageSize + 1,
});
return {
tags: tags
.map((tag) => ({
id: tag.friendlyId,
name: tag.name,
}))
.slice(0, pageSize),
tags: tags.tags,
currentPage: page,
hasMore: tags.length > pageSize,
hasMore: tags.tags.length > pageSize,
hasFilters,
};
}
@@ -10,13 +10,15 @@ import {
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { logger } from "~/services/logger.server";
import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server";
import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { WaitpointPresenter } from "./WaitpointPresenter.server";
import { engine } from "~/v3/runEngine.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
export type Span = NonNullable<NonNullable<Result>["span"]>;
@@ -24,14 +26,16 @@ export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
type FindRunResult = NonNullable<
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
>;
type GetSpanResult = NonNullable<Awaited<ReturnType<(typeof eventRepository)["getSpan"]>>>;
type GetSpanResult = SpanDetail;
export class SpanPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
spanId,
runFriendlyId,
}: {
userId: string;
projectSlug: string;
spanId: string;
runFriendlyId: string;
@@ -39,6 +43,13 @@ export class SpanPresenter extends BasePresenter {
const project = await this._replica.project.findFirst({
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
@@ -57,6 +68,7 @@ export class SpanPresenter extends BasePresenter {
},
where: {
friendlyId: runFriendlyId,
projectId: project.id,
},
});
@@ -66,14 +78,18 @@ export class SpanPresenter extends BasePresenter {
const { traceId } = parentRun;
const eventRepository = resolveEventRepositoryForStore(parentRun.taskEventStore);
const eventStore = getTaskEventStoreTableForRun(parentRun);
const run = await this.getRun({
eventStore,
traceId,
eventRepository,
spanId,
createdAt: parentRun.createdAt,
completedAt: parentRun.completedAt,
environmentId: parentRun.runtimeEnvironmentId,
});
if (run) {
return {
@@ -82,15 +98,15 @@ export class SpanPresenter extends BasePresenter {
};
}
//get the run
const span = await this.#getSpan({
eventStore,
traceId,
spanId,
traceId,
environmentId: parentRun.runtimeEnvironmentId,
projectId: parentRun.projectId,
createdAt: parentRun.createdAt,
completedAt: parentRun.completedAt,
eventRepository,
});
if (!span) {
@@ -105,30 +121,31 @@ export class SpanPresenter extends BasePresenter {
async getRun({
eventStore,
environmentId,
traceId,
eventRepository,
spanId,
createdAt,
completedAt,
}: {
eventStore: TaskEventStoreTable;
environmentId: string;
traceId: string;
eventRepository: IEventRepository;
spanId: string;
createdAt: Date;
completedAt: Date | null;
}) {
const span = await eventRepository.getSpan(
const originalRunId = await eventRepository.getSpanOriginalRunId(
eventStore,
environmentId,
spanId,
traceId,
createdAt,
completedAt ?? undefined
);
if (!span) {
return;
}
const run = await this.findRun({ span, spanId });
const run = await this.findRun({ originalRunId, spanId, environmentId });
if (!run) {
return;
@@ -251,8 +268,9 @@ export class SpanPresenter extends BasePresenter {
engine: run.engine,
region,
workerQueue: run.workerQueue,
traceId: run.traceId,
spanId: run.spanId,
isCached: !!span.originalRun,
isCached: !!originalRunId,
machinePreset: machine?.name,
externalTraceId,
};
@@ -287,7 +305,15 @@ export class SpanPresenter extends BasePresenter {
};
}
async findRun({ span, spanId }: { span: GetSpanResult; spanId: string }) {
async findRun({
originalRunId,
spanId,
environmentId,
}: {
originalRunId?: string;
spanId: string;
environmentId: string;
}) {
const run = await this._replica.taskRun.findFirst({
select: {
id: true,
@@ -397,12 +423,14 @@ export class SpanPresenter extends BasePresenter {
},
},
},
where: span.originalRun
where: originalRunId
? {
friendlyId: span.originalRun,
friendlyId: originalRunId,
runtimeEnvironmentId: environmentId,
}
: {
spanId,
runtimeEnvironmentId: environmentId,
},
});
@@ -411,6 +439,7 @@ export class SpanPresenter extends BasePresenter {
async #getSpan({
eventStore,
eventRepository,
traceId,
spanId,
environmentId,
@@ -418,6 +447,7 @@ export class SpanPresenter extends BasePresenter {
createdAt,
completedAt,
}: {
eventRepository: IEventRepository;
traceId: string;
spanId: string;
environmentId: string;
@@ -428,12 +458,14 @@ export class SpanPresenter extends BasePresenter {
}) {
const span = await eventRepository.getSpan(
eventStore,
environmentId,
spanId,
traceId,
createdAt,
completedAt ?? undefined,
{ includeDebugLogs: true }
);
if (!span) {
return;
}
@@ -445,11 +477,7 @@ export class SpanPresenter extends BasePresenter {
spanId: true,
createdAt: true,
number: true,
lockedToVersion: {
select: {
version: true,
},
},
taskVersion: true,
},
where: {
parentSpanId: spanId,
@@ -457,11 +485,21 @@ export class SpanPresenter extends BasePresenter {
});
const data = {
...span,
spanId: span.spanId,
parentId: span.parentId,
message: span.message,
isError: span.isError,
isPartial: span.isPartial,
isCancelled: span.isCancelled,
level: span.level,
startTime: span.startTime,
duration: span.duration,
events: span.events,
style: span.style,
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
entity: span.entity,
metadata: span.metadata,
triggeredRuns,
showActionBar: span.show?.actions === true,
};
switch (span.entity.type) {
@@ -1,14 +1,17 @@
import { ClickHouse } from "@internal/clickhouse";
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import {
type TaskRunTemplate,
type RuntimeEnvironmentType,
type TaskRunStatus,
type TaskRunTemplate,
PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import parse from "parse-duration";
import { type PrismaClient } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
import parse from "parse-duration";
export type RunTemplate = TaskRunTemplate & {
scheduledTaskPayload?: ScheduledRun["payload"];
@@ -20,6 +23,8 @@ type TestTaskOptions = {
environment: {
id: string;
type: RuntimeEnvironmentType;
projectId: string;
organizationId: string;
};
taskIdentifier: string;
};
@@ -111,11 +116,10 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
};
export class TestTaskPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
public async call({
userId,
@@ -128,7 +132,7 @@ export class TestTaskPresenter {
? (
await findCurrentWorkerDeployment({ environmentId: environment.id })
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
: await this.#prismaClient.backgroundWorkerTask.findFirst({
: await this.replica.backgroundWorkerTask.findFirst({
where: {
slug: taskIdentifier,
runtimeEnvironmentId: environment.id,
@@ -145,7 +149,7 @@ export class TestTaskPresenter {
}
const taskQueue = task.queueId
? await this.#prismaClient.taskQueue.findFirst({
? await this.replica.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environment.id,
id: task.queueId,
@@ -159,7 +163,7 @@ export class TestTaskPresenter {
})
: undefined;
const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({
const backgroundWorkers = await this.replica.backgroundWorker.findMany({
where: {
runtimeEnvironmentId: environment.id,
},
@@ -173,7 +177,7 @@ export class TestTaskPresenter {
take: 20, // last 20 versions should suffice
});
const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({
const taskRunTemplates = await this.replica.taskRunTemplate.findMany({
where: {
projectId,
taskSlug: task.slug,
@@ -190,47 +194,55 @@ export class TestTaskPresenter {
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
WITH taskruns AS (
SELECT
tr.*
FROM
${sqlDatabaseSchema}."TaskRun" as tr
JOIN
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
ON
tr."taskIdentifier" = bwt.slug
WHERE
bwt."friendlyId" = ${task.friendlyId} AND
tr."runtimeEnvironmentId" = ${environment.id}
ORDER BY
tr."createdAt" DESC
LIMIT 10
)
SELECT
taskr.id,
taskr."queue",
taskr."friendlyId",
taskr."taskIdentifier",
taskr."createdAt",
taskr.status,
taskr.payload,
taskr."payloadType",
taskr."seedMetadata",
taskr."seedMetadataType",
taskr."runtimeEnvironmentId",
taskr."concurrencyKey",
taskr."maxAttempts",
taskr."maxDurationInSeconds",
taskr."machinePreset",
taskr."ttl",
taskr."runTags"
FROM
taskruns AS taskr
WHERE
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
ORDER BY
taskr."createdAt" DESC;`;
// Get the latest runs, for the payloads
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
});
const runIds = await runsRepository.listRunIds({
organizationId: environment.organizationId,
environmentId: environment.id,
projectId: environment.projectId,
tasks: [task.slug],
period: "30d",
page: {
size: 10,
},
});
const latestRuns = await this.replica.taskRun.findMany({
select: {
id: true,
queue: true,
friendlyId: true,
taskIdentifier: true,
createdAt: true,
status: true,
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
ttl: true,
runTags: true,
},
where: {
id: {
in: runIds,
},
payloadType: {
in: ["application/json", "application/super+json"],
},
},
orderBy: {
createdAt: "desc",
},
});
const taskWithEnvironment = {
id: task.id,
@@ -258,6 +270,12 @@ export class TestTaskPresenter {
async (r) =>
({
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: await prettyPrintPacket(r.payload, r.payloadType),
metadata: r.seedMetadata
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
@@ -300,6 +318,12 @@ export class TestTaskPresenter {
if (payload.success) {
return {
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: payload.data,
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
} satisfies ScheduledRun;
+15 -6
View File
@@ -20,10 +20,17 @@ export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
};
export const headers = () => ({
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy":
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
});
export const meta: MetaFunction = ({ data }) => {
const typedData = data as UseDataFunctionReturn<typeof loader>;
return [
{ title: `Trigger.dev${appEnvTitleTag(typedData.appEnv)}` },
{ title: typedData?.appEnv ? `Trigger.dev${appEnvTitleTag(typedData.appEnv)}` : "Trigger.dev" },
{
name: "viewport",
content: "width=1024, initial-scale=1",
@@ -84,11 +91,13 @@ export function ErrorBoundary() {
<Links />
</head>
<body className="h-full overflow-hidden bg-background-dimmed">
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
</AppContainer>
<ShortcutsProvider>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
</AppContainer>
</ShortcutsProvider>
<Scripts />
</body>
</html>
@@ -0,0 +1,121 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { tryCatch } from "@trigger.dev/core";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.discriminatedUnion("setup_action", [
z.object({
setup_action: z.literal("install"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("update"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("request"),
state: z.string(),
}),
]);
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(url.searchParams);
const cookieHeader = request.headers.get("Cookie");
const result = QuerySchema.safeParse(queryParams);
if (!result.success) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const callbackData = result.data;
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
if (!sessionResult.valid) {
logger.error("GitHub App callback with invalid session", {
callbackData,
error: sessionResult.error,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
// the secure cookie approach should already protect against this
// just an additional check
logger.error("GitHub app installation attempt on unauthenticated org", {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
switch (callbackData.setup_action) {
case "install": {
const [error] = await tryCatch(
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
);
if (error) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
}
case "update": {
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
if (error) {
logger.error("Failed to update GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
}
case "request": {
// This happens when a non-admin user requests installation
// The installation_id won't be available until an admin approves
logger.info("GitHub App installation requested, awaiting approval", {
callbackData,
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
}
default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
}
@@ -0,0 +1,52 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { requireUser } from "~/services/session.server";
import { newOrganizationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.object({
org_slug: z.string(),
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
message: "Invalid redirect path",
}),
});
export const loader = async ({ request }: LoaderFunctionArgs) => {
const searchParams = new URL(request.url).searchParams;
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) {
logger.warn("GitHub App installation redirect with invalid params", {
searchParams,
error: parsed.error,
});
throw redirect("/");
}
const { org_slug, redirect_to } = parsed.data;
const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
throw redirect(newOrganizationPath());
}
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
return redirect(url, {
headers: {
"Set-Cookie": cookieHeader,
},
});
};
@@ -14,7 +14,7 @@ import { Link, useRevalidator, useSubmit } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { type TaskRunStatus } from "@trigger.dev/database";
import type { TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
@@ -13,7 +13,7 @@ import {
import { Form, type 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";
import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -130,7 +130,10 @@ export async function action({ request }: ActionFunctionArgs) {
}
const upsertBranchService = new UpsertBranchService();
const result = await upsertBranchService.call(userId, submission.value);
const result = await upsertBranchService.call(
{ type: "userMembership", userId },
submission.value
);
if (result.success) {
if (result.alreadyExisted) {
@@ -1,10 +1,9 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useRevalidator } from "@remix-run/react";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { BulkActionStatus, BulkActionType } from "@trigger.dev/database";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
@@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Error(error.message);
}
return typedjson({ bulkAction: data });
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({ bulkAction: data, autoReloadPollIntervalMs });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
};
export default function Page() {
const { bulkAction } = useTypedLoaderData<typeof loader>();
const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const disabled = bulkAction.status !== BulkActionStatus.PENDING;
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
{
event: "progress",
disabled,
}
);
const revalidation = useRevalidator();
useEffect(() => {
if (disabled || streamedEvents === null) {
return;
}
revalidation.revalidate();
}, [streamedEvents, disabled]);
useAutoRevalidate({
interval: autoReloadPollIntervalMs,
onFocus: true,
disabled: bulkAction.status !== "PENDING",
});
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
@@ -239,7 +227,7 @@ export default function Page() {
<BulkActionFilterSummary
selected={bulkAction.totalCount}
mode={bulkAction.mode}
action={bulkAction.type === BulkActionType.REPLAY ? "replay" : "cancel"}
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
filters={bulkAction.filters}
final={true}
/>
@@ -327,7 +315,7 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
<div className="h-2 w-2 rounded-[1px] bg-charcoal-550" />
<Paragraph variant="extra-small">
{formatNumber(failureCount)} {typeText(type)} failed{" "}
{type === BulkActionType.CANCEL ? " (already finished)" : ""}
{type === "CANCEL" ? " (already finished)" : ""}
</Paragraph>
</div>
</div>
@@ -337,9 +325,9 @@ function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
function typeText(type: BulkActionType) {
switch (type) {
case BulkActionType.CANCEL:
case "CANCEL":
return "canceled";
case BulkActionType.REPLAY:
case "REPLAY":
return "replayed";
}
}
@@ -4,7 +4,6 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
@@ -32,6 +31,7 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -131,7 +131,11 @@ export default function Page() {
<Property.Label>Deploy</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{deployment.shortCode}</span>
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
{deployment.label && (
<Badge variant="extra-small" className="capitalize">
{deployment.label}
</Badge>
)}
</Property.Value>
</Property.Item>
<Property.Item>
@@ -154,6 +158,22 @@ export default function Page() {
/>
</Property.Value>
</Property.Item>
{deployment.canceledAt && (
<Property.Item>
<Property.Label>Canceled at</Property.Label>
<Property.Value>
<>
<DateTimeAccurate date={deployment.canceledAt} /> UTC
</>
</Property.Value>
</Property.Item>
)}
{deployment.canceledReason && (
<Property.Item>
<Property.Label>Cancelation reason</Property.Label>
<Property.Value>{deployment.canceledReason}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Tasks</Property.Label>
<Property.Value>{deployment.tasks ? deployment.tasks.length : ""}</Property.Value>
@@ -187,7 +207,25 @@ export default function Page() {
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
<DateTimeAccurate date={deployment.createdAt} /> UTC
{deployment.startedAt ? (
<>
<DateTimeAccurate date={deployment.startedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Installed at</Property.Label>
<Property.Value>
{deployment.installedAt ? (
<>
<DateTimeAccurate date={deployment.installedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
@@ -226,17 +264,16 @@ export default function Page() {
<Property.Item>
<Property.Label>Deployed by</Property.Label>
<Property.Value>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
{deployment.git?.source === "trigger_github_app" ? (
<UserTag
name={deployment.git.ghUsername ?? "GitHub Integration"}
avatarUrl={deployment.git.ghUserAvatarUrl}
/>
) : deployment.deployedBy ? (
<UserTag
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
/>
) : (
""
)}
@@ -1,11 +1,26 @@
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
Outlet,
useLocation,
useNavigate,
useNavigation,
useParams,
} from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
@@ -13,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -37,10 +60,6 @@ import {
deploymentStatusDescription,
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -50,9 +69,18 @@ import {
} from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { titleCase } from "~/utils";
import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
docsPath,
v3DeploymentPath,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { env } from "~/env.server";
import { DialogClose } from "@radix-ui/react-dialog";
export const meta: MetaFunction = () => {
return [
@@ -108,7 +136,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
? result.deployments.find((d) => d.version === version)
: undefined;
return typedjson({ ...result, selectedDeployment });
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -122,14 +152,23 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { deployments, currentPage, totalPages, selectedDeployment } =
useTypedLoaderData<typeof loader>();
const {
deployments,
currentPage,
totalPages,
selectedDeployment,
connectedGithubRepository,
environmentGitHubBranch,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
const { deploymentParam } = useParams();
const location = useLocation();
const navigate = useNavigate();
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
// If we have a selected deployment from the version param, show it
useEffect(() => {
if (selectedDeployment && !deploymentParam) {
@@ -160,8 +199,8 @@ export default function Page() {
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
{hasDeployments ? (
<div className="grid max-h-full grid-rows-[1fr_auto]">
<Table containerClassName="border-t-0">
<div className="flex h-full max-h-full flex-col">
<Table containerClassName="border-t-0 grow">
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
@@ -286,18 +325,45 @@ export default function Page() {
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="-mt-px flex justify-end border-t border-grid-dimmed py-2 pr-2">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
)}
<div
className={cn(
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
connectedGithubRepository && environmentGitHubBranch && "justify-between"
)}
>
{connectedGithubRepository && environmentGitHubBranch && (
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
<OctoKitty className="size-4" />
Automatically triggered by pushes to{" "}
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
<GitBranchIcon className="size-3 shrink-0" />
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
</div>{" "}
in
<a
href={connectedGithubRepository.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
>
{connectedGithubRepository.repository.fullName}
</a>
<LinkButton
variant="minimal/small"
LeadingIcon={CogIcon}
to={v3ProjectSettingsPath(organization, project, environment)}
/>
</div>
)}
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
@@ -317,7 +383,7 @@ export default function Page() {
);
}
function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
return (
<div className="flex items-center gap-1">
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4" />
@@ -347,7 +413,10 @@ function DeploymentActionsCell({
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
if (!canBeRolledBack && !canBePromoted) {
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
const canBeCanceled = !finalStatuses.includes(deployment.status);
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
@@ -371,7 +440,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Rollback
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
@@ -391,7 +460,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Promote
Promote
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
@@ -401,8 +470,155 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canBeCanceled && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
>
Cancel
</Button>
</DialogTrigger>
<CancelDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
/>
);
}
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function CancelDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this deployment?</DialogHeader>
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Back</Button>
</DialogClose>
<Form action={formAction} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -1,4 +1,5 @@
import {
AdjustmentsHorizontalIcon,
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
@@ -8,19 +9,13 @@ import {
RectangleStackIcon,
} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
useNavigate,
useNavigation,
useRevalidator,
useSearchParams,
type MetaFunction,
} from "@remix-run/react";
import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -30,13 +25,16 @@ import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BigNumber } from "~/components/metrics/BigNumber";
import { Badge } from "~/components/primitives/Badge";
import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
@@ -55,25 +53,25 @@ import {
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useThrottle } from "~/hooks/useThrottle";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getUserById } from "~/models/user.server";
import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server";
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { useThrottle } from "~/hooks/useThrottle";
import { RunsIcon } from "~/assets/icons/RunsIcon";
const SearchParamsSchema = z.object({
query: z.string().optional(),
@@ -121,9 +119,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const environmentQueuePresenter = new EnvironmentQueuePresenter();
const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({
...queues,
environment: await environmentQueuePresenter.call(environment),
autoReloadPollIntervalMs,
});
} catch (error) {
console.error(error);
@@ -211,34 +212,98 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
);
}
case "queue-override": {
const friendlyId = formData.get("friendlyId");
const concurrencyLimit = formData.get("concurrencyLimit");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
if (!concurrencyLimit) {
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
}
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
if (isNaN(limitNumber) || limitNumber < 0) {
return redirectWithErrorMessage(
redirectPath,
request,
"Concurrency limit must be a valid number"
);
}
const user = await getUserById(userId);
if (!user) {
return redirectWithErrorMessage(redirectPath, request, "User not found");
}
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
environment,
friendlyId.toString(),
limitNumber,
user
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to override queue concurrency limit"
);
}
return redirectWithSuccessMessage(
redirectPath,
request,
"Queue concurrency limit overridden"
);
}
case "queue-remove-override": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const result = await concurrencySystem.queues.resetConcurrencyLimit(
environment,
friendlyId.toString()
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to reset queue concurrency limit"
);
}
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
}
default:
return redirectWithErrorMessage(redirectPath, request, "Something went wrong");
}
};
export default function Page() {
const { environment, queues, success, pagination, code, totalQueues, hasFilters } =
useTypedLoaderData<typeof loader>();
const {
environment,
queues,
success,
pagination,
code,
totalQueues,
hasFilters,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const env = useEnvironment();
const plan = useCurrentPlan();
// Reload the page periodically
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`,
{
event: "update",
}
);
const revalidation = useRevalidator();
useEffect(() => {
if (streamedEvents) {
revalidation.revalidate();
}
}, [streamedEvents]);
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
const limitStatus =
environment.running === environment.concurrencyLimit * environment.burstFactor
@@ -275,17 +340,19 @@ export default function Page() {
animate
accessory={
<div className="flex items-start gap-1">
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
<LinkButton
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
>
View runs
</LinkButton>
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
tooltip="View queued runs"
/>
</div>
}
valueClassName={env.paused ? "text-warning" : undefined}
@@ -308,15 +375,17 @@ export default function Page() {
}
accessory={
<LinkButton
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
>
View runs
</LinkButton>
tooltip="View runs"
/>
}
compactThreshold={1000000}
/>
@@ -383,7 +452,8 @@ export default function Page() {
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
<TableHeaderCell alignment="right">Running/limit</TableHeaderCell>
<TableHeaderCell alignment="right">Running</TableHeaderCell>
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
<TableHeaderCell
alignment="right"
tooltip={
@@ -409,6 +479,17 @@ export default function Page() {
This queue is limited by a concurrency limit set in your code.
</Paragraph>
</div>
<div className="space-y-0.5">
<Header3>Override</Header3>
<Paragraph
variant="small"
className="!text-wrap text-text-dimmed"
spacing
>
This queue's concurrency limit has been manually overridden from the
dashboard or API.
</Paragraph>
</div>
</div>
}
>
@@ -423,7 +504,7 @@ export default function Page() {
{queues.length > 0 ? (
queues.map((queue) => {
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
const isAtLimit = queue.running === limit;
const isAtLimit = queue.running >= limit;
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
queue.name
}`;
@@ -459,6 +540,18 @@ export default function Page() {
<span className={queue.paused ? "opacity-50" : undefined}>
{queue.name}
</span>
{queue.concurrency?.overriddenAt ? (
<SimpleTooltip
button={
<Badge variant="extra-small" className="text-text-bright">
Concurrency limit overridden
</Badge>
}
content="This queue's concurrency limit has been manually overridden from the dashboard or API."
className="max-w-xs"
disableHoverableContent
/>
) : null}
{queue.paused ? (
<Badge variant="extra-small" className="text-warning">
Paused
@@ -473,31 +566,50 @@ export default function Page() {
</TableCell>
<TableCell
alignment="right"
className={queue.paused ? "opacity-50" : undefined}
className={cn(
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined
)}
>
{queue.queued}
</TableCell>
<TableCell
alignment="right"
className={cn(
queue.paused ? "tabular-nums opacity-50" : undefined,
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined,
queue.running > 0 && "text-text-bright",
isAtLimit && "text-warning"
)}
>
{queue.running}/
<span className={cn("tabular-nums", isAtLimit && "text-warning")}>
{limit}
</span>
{queue.running}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined,
isAtLimit && "text-warning"
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{queue.concurrencyLimit ? "User" : "Environment"}
{limit}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%]",
queue.paused ? "opacity-50" : undefined,
isAtLimit && "text-warning",
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{queue.concurrency?.overriddenAt ? (
<span className="text-text-bright">Override</span>
) : queue.concurrencyLimit ? (
"User"
) : (
"Environment"
)}
</TableCell>
<TableCellMenu
isSticky
@@ -524,50 +636,43 @@ export default function Page() {
showTooltip={false}
/>
)}
<LinkButton
variant="minimal/small"
<PopoverMenuItem
icon={RunsIcon}
leadingIconClassName="text-runs"
title="View all runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
>
View all runs
</LinkButton>
<LinkButton
variant="minimal/small"
/>
<PopoverMenuItem
icon={RectangleStackIcon}
leadingIconClassName="text-queues"
title="View queued runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={RectangleStackIcon}
leadingIconClassName="text-queues"
>
View queued runs
</LinkButton>
<LinkButton
variant="minimal/small"
/>
<PopoverMenuItem
icon={Spinner}
leadingIconClassName="text-queues animate-none"
title="View running runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={Spinner}
leadingIconClassName="size-4 animate-none"
>
View running runs
</LinkButton>
/>
<QueueOverrideConcurrencyButton
queue={queue}
environmentConcurrencyLimit={environment.concurrencyLimit}
/>
</>
}
/>
@@ -576,7 +681,7 @@ export default function Page() {
})
) : (
<TableRow>
<TableCell colSpan={6}>
<TableCell colSpan={7}>
<div className="grid place-items-center py-6 text-text-dimmed">
<Paragraph>
{hasFilters
@@ -733,29 +838,26 @@ function QueuePauseResumeButton({
fullWidth?: boolean;
showTooltip?: boolean;
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const button = (
<Button
type="button"
variant={variant}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
>
{queue.paused ? "Resume..." : "Pause..."}
</Button>
);
const trigger = showTooltip ? (
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogTrigger asChild>
<Button
type="button"
variant={variant}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
>
{queue.paused ? "Resume..." : "Pause..."}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
@@ -767,7 +869,13 @@ function QueuePauseResumeButton({
</TooltipProvider>
</div>
) : (
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogTrigger asChild>
<PopoverMenuItem
icon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
title={queue.paused ? "Resume..." : "Pause..."}
/>
</DialogTrigger>
);
return (
@@ -814,6 +922,128 @@ function QueuePauseResumeButton({
);
}
function QueueOverrideConcurrencyButton({
queue,
environmentConcurrencyLimit,
}: {
queue: {
id: string;
name: string;
concurrencyLimit: number | null;
concurrency?: { overriddenAt: Date | null };
};
environmentConcurrencyLimit: number;
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
);
const isOverridden = !!queue.concurrency?.overriddenAt;
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
useEffect(() => {
if (navigation.state === "loading" || navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
const isLoading = Boolean(
navigation.formData?.get("action") === "queue-override" ||
navigation.formData?.get("action") === "queue-remove-override"
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<PopoverMenuItem
icon={AdjustmentsHorizontalIcon}
title={isOverridden ? "Edit override…" : "Override limit…"}
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
{isOverridden ? (
<Paragraph>
This queue's concurrency limit is currently overridden to {currentLimit}.
{queue.concurrencyLimit !== null &&
` The original limit set in code was ${queue.concurrencyLimit}.`}{" "}
You can update the override or remove it to restore the{" "}
{queue.concurrencyLimit !== null
? "limit set in code"
: "environment concurrency limit"}
.
</Paragraph>
) : (
<Paragraph>
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
</Paragraph>
)}
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
<input type="hidden" name="friendlyId" value={queue.id} />
<div className="space-y-2">
<label htmlFor="concurrencyLimit" className="text-sm text-text-bright">
Concurrency limit
</label>
<Input
type="number"
name="concurrencyLimit"
id="concurrencyLimit"
min="0"
value={concurrencyLimit}
onChange={(e) => setConcurrencyLimit(e.target.value)}
placeholder={currentLimit.toString()}
autoFocus
/>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="queue-override"
disabled={isLoading || !concurrencyLimit}
variant="primary/medium"
LeadingIcon={isLoading && <Spinner color="white" />}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isOverridden ? "Update override" : "Override limit"}
</Button>
}
cancelButton={
<div className="flex items-center justify-between gap-2">
{isOverridden && (
<Button
type="submit"
name="action"
value="queue-remove-override"
disabled={isLoading}
variant="danger/medium"
>
Remove override
</Button>
)}
<DialogClose asChild>
<Button type="button" variant="tertiary/medium">
Cancel
</Button>
</DialogClose>
</div>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function EngineVersionUpgradeCallout() {
return (
<div className="mt-4 flex max-w-lg flex-col gap-4 rounded-sm border border-grid-bright bg-background-bright px-4">
@@ -3,6 +3,7 @@ import {
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
InformationCircleIcon,
MapPinIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
@@ -31,6 +32,7 @@ import {
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
@@ -45,12 +47,13 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
@@ -130,6 +133,7 @@ export default function Page() {
const { regions, isPaying } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
return (
<PageContainer>
@@ -158,7 +162,7 @@ export default function Page() {
</MainCenteredContainer>
) : (
<>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -293,6 +297,31 @@ export default function Page() {
</TableRow>
</TableBody>
</Table>
{isManagedCloud && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="max-w-full gap-1"
>
<Paragraph variant="extra-small" className="flex items-baseline gap-x-0.5">
Trigger.dev is fully GDPR compliant. Learn more in our{" "}
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
<Feedback
button={
<Paragraph
variant="extra-small"
className="cursor-pointer text-indigo-500 transition hover:text-indigo-400"
>
get in touch
</Paragraph>
}
defaultValue="help"
/>
.
</Paragraph>
</InfoPanel>
)}
</div>
</>
)}
@@ -11,7 +11,7 @@ import {
MagnifyingGlassPlusIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
import { useLoaderData, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
import { type Virtualizer } from "@tanstack/react-virtual";
import {
@@ -20,12 +20,13 @@ import {
nanosecondsToMilliseconds,
tryCatch,
} from "@trigger.dev/core/v3";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { redirect } from "remix-typedjson";
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { DevDisconnectedBanner, useCrossEngineIsConnected } from "~/components/DevPresence";
import { WarmStartIconWithTooltip } from "~/components/WarmStarts";
@@ -87,7 +88,6 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -97,6 +97,7 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
const resizableSettings = {
parent: {
@@ -142,7 +143,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [error, result] = await tryCatch(
presenter.call({
userId,
organizationSlug,
showDeletedLogs: !!impersonationId,
projectSlug: projectParam,
runFriendlyId: runParam,
@@ -172,7 +172,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return json({
run: result.run,
trace: result.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
resizable: {
parent,
tree,
@@ -302,7 +302,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
return <></>;
}
const { events, parentRunFriendlyId, duration, rootSpanStatus, rootStartedAt, queuedDuration } =
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
@@ -325,6 +325,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
// 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 spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
@@ -340,7 +342,6 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
selectedId={selectedSpanId}
key={events[0]?.id ?? "-"}
events={events}
parentRunFriendlyId={parentRunFriendlyId}
onSelectedIdChanged={(selectedSpan) => {
//instantly close the panel if no span is selected
if (!selectedSpan) {
@@ -358,6 +359,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
shouldLiveReload={shouldLiveReload}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
parentRun={run.parentTaskRun}
isCompleted={run.completedAt !== null}
/>
</ResizablePanel>
@@ -373,6 +375,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
<SpanView
runParam={run.friendlyId}
spanId={selectedSpanId}
spanOverrides={spanOverrides as SpanOverride | undefined}
closePanel={() => replaceSearchParam("span")}
/>
</ResizablePanel>
@@ -476,7 +479,6 @@ function NoLogsView({ run, resizable }: LoaderData) {
type TasksTreeViewProps = {
events: TraceEvent[];
selectedId?: string;
parentRunFriendlyId?: string;
onSelectedIdChanged: (selectedId: string | undefined) => void;
totalDuration: number;
rootSpanStatus: "executing" | "completed" | "failed";
@@ -487,7 +489,10 @@ type TasksTreeViewProps = {
maximumLiveReloadingSetting: number;
rootRun: {
friendlyId: string;
taskIdentifier: string;
spanId: string;
} | null;
parentRun: {
friendlyId: string;
spanId: string;
} | null;
isCompleted: boolean;
@@ -496,7 +501,6 @@ type TasksTreeViewProps = {
function TasksTreeView({
events,
selectedId,
parentRunFriendlyId,
onSelectedIdChanged,
totalDuration,
rootSpanStatus,
@@ -506,6 +510,7 @@ function TasksTreeView({
shouldLiveReload,
maximumLiveReloadingSetting,
rootRun,
parentRun,
isCompleted,
}: TasksTreeViewProps) {
const isAdmin = useHasAdminAccess();
@@ -596,20 +601,30 @@ function TasksTreeView({
id={resizableSettings.tree.tree.id}
default={resizableSettings.tree.tree.default}
min={resizableSettings.tree.tree.min}
className="pl-3"
>
<div className="grid h-full grid-rows-[2rem_1fr] overflow-hidden">
<div className="flex items-center pr-2">
{rootRun ? (
<ShowParentLink
runFriendlyId={rootRun.friendlyId}
isRoot={true}
spanId={rootRun.spanId}
<div className="flex items-center justify-between pl-1 pr-2">
{rootRun || parentRun ? (
<ShowParentOrRootLinks
relationships={{
root: rootRun
? {
friendlyId: rootRun.friendlyId,
spanId: rootRun.spanId,
isParent: parentRun ? rootRun.friendlyId === parentRun.friendlyId : true,
}
: undefined,
parent:
parentRun && rootRun?.friendlyId !== parentRun.friendlyId
? {
friendlyId: parentRun.friendlyId,
spanId: "",
}
: undefined,
}}
/>
) : parentRunFriendlyId ? (
<ShowParentLink runFriendlyId={parentRunFriendlyId} isRoot={false} />
) : (
<Paragraph variant="small" className="flex-1 text-charcoal-500">
<Paragraph variant="extra-small" className="flex-1 pl-3 text-charcoal-500">
This is the root task
</Paragraph>
)}
@@ -628,6 +643,7 @@ function TasksTreeView({
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
parentClassName="pl-3"
renderNode={({ node, state, index }) => (
<>
<div
@@ -1139,60 +1155,108 @@ function TaskLine({ isError, isSelected }: { isError: boolean; isSelected: boole
return <div className={cn("h-8 w-2 border-r border-grid-bright")} />;
}
function ShowParentLink({
runFriendlyId,
spanId,
isRoot,
function ShowParentOrRootLinks({
relationships,
}: {
runFriendlyId: string;
spanId?: string;
isRoot: boolean;
relationships: {
root?: {
friendlyId: string;
spanId: string;
isParent?: boolean;
};
parent?: {
friendlyId: string;
spanId: string;
};
};
}) {
const [mouseOver, setMouseOver] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { spanParam } = useParams();
const span = spanId ? spanId : spanParam;
return (
<LinkButton
variant="minimal/medium"
to={
span
? v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: runFriendlyId,
},
{ spanId: span }
)
: v3RunPath(organization, project, environment, {
friendlyId: runFriendlyId,
})
}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
fullWidth
textAlignLeft
shortcut={{ key: "p" }}
className="flex-1"
>
{mouseOver ? (
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
) : (
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
)}
<Paragraph
variant="small"
className={cn(mouseOver ? "text-indigo-500" : "text-charcoal-500")}
// Case 1: Root is also the parent
if (relationships.root?.isParent === true) {
return (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root and parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
{isRoot ? "Show root run" : "Show parent run"}
</Paragraph>
</LinkButton>
Root/parent
</LinkButton>
);
}
// Case 2: Root and Parent are different runs
return (
<div className="flex items-center">
{relationships.root && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.root.friendlyId },
{ spanId: relationships.root.spanId }
)}
LeadingIcon={MoveToTopIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "t" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to root run</Paragraph>
<ShortcutKey shortcut={{ key: "t" }} variant="small" />
</div>
}
className="text-xs"
>
Root
</LinkButton>
)}
{relationships.parent && (
<LinkButton
variant="minimal/small"
to={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: relationships.parent.friendlyId },
{ spanId: relationships.parent.spanId }
)}
LeadingIcon={MoveUpIcon}
leadingIconClassName="gap-x-2"
shortcut={{ key: "p" }}
hideShortcutKey
tooltip={
<div className="-mr-1 flex items-center gap-1">
<Paragraph variant="extra-small">Jump to parent run</Paragraph>
<ShortcutKey shortcut={{ key: "p" }} variant="small" />
</div>
}
className="text-xs"
>
Parent
</LinkButton>
)}
</div>
);
}
@@ -73,6 +73,8 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
@@ -96,7 +98,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const presenter = new TestTaskPresenter();
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const result = await presenter.call({
userId,
@@ -47,7 +47,7 @@ export const meta: MetaFunction = () => {
};
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
@@ -55,8 +55,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
@@ -181,7 +181,7 @@ export default function Page() {
const fieldValues = useRef<string[]>(alerts.emails);
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
const checkboxLevels = [0.75, 0.9, 1.0];
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
useEffect(() => {
if (alerts.emails.length > 0) {
@@ -28,7 +28,7 @@ export const meta: MetaFunction = () => {
};
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
@@ -41,8 +41,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
@@ -46,7 +46,7 @@ export const meta: MetaFunction = () => {
};
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
@@ -54,8 +54,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
@@ -1,7 +1,7 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { AppContainer } from "~/components/layout/AppLayout";
import { AppContainer, MainBody, PageBody } from "~/components/layout/AppLayout";
import { Header1 } from "~/components/primitives/Headers";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
@@ -49,22 +49,24 @@ export default function ChoosePlanPage() {
useTypedLoaderData<typeof loader>();
return (
<AppContainer className="bg-charcoal-900">
<BackgroundWrapper>
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-8 p-3">
<Header1 className="text-center">Subscribe for full access</Header1>
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
periodEnd={periodEnd}
/>
<AppContainer>
<PageBody className="bg-charcoal-900">
<BackgroundWrapper>
<div className="mx-auto mt-4 flex h-fit min-h-full max-w-[80rem] flex-col items-center justify-center gap-8 lg:mt-0">
<Header1 className="text-center">Subscribe for full access</Header1>
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
showGithubVerificationBadge
periodEnd={periodEnd}
/>
</div>
</div>
</div>
</BackgroundWrapper>
</BackgroundWrapper>
</PageBody>
</AppContainer>
);
}
@@ -1,11 +1,8 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { title } from "process";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { Icon } from "~/components/primitives/Icon";
@@ -13,12 +10,16 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { logger } from "~/services/logger.server";
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
import { rootPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
authorizationCode: z.string(),
});
const SearchParamsSchema = z.object({
source: z.string().optional(),
clientName: z.string().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -32,6 +33,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const url = new URL(request.url);
const searchObject = Object.fromEntries(url.searchParams.entries());
const searchParams = SearchParamsSchema.safeParse(searchObject);
const source = (searchParams.success ? searchParams.data.source : undefined) ?? "cli";
const clientName = (searchParams.success ? searchParams.data.clientName : undefined) ?? "unknown";
try {
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
parsedParams.data.authorizationCode,
@@ -39,6 +48,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
);
return typedjson({
success: true as const,
source,
clientName,
});
} catch (error) {
if (error instanceof Response) {
@@ -49,6 +60,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
success: false as const,
error: error.message,
source,
clientName,
});
}
@@ -73,7 +86,7 @@ export default function Page() {
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>Return to your terminal to continue.</Paragraph>
<Paragraph>{getInstructionsForSource(result.source, result.clientName)}</Paragraph>
</div>
) : (
<div>
@@ -91,3 +104,21 @@ export default function Page() {
</AppContainer>
);
}
const prettyClientNames: Record<string, string> = {
"claude-code": "Claude Code",
"cursor-vscode": "Cursor",
"Visual Studio Code": "VSCode",
"windsurf-client": "Windsurf",
"claude-ai": "Claude Desktop",
};
function getInstructionsForSource(source: string, clientName: string) {
if (source === "mcp") {
if (clientName) {
return `Return to your ${prettyClientNames[clientName] ?? clientName} to continue.`;
}
}
return `Return to your terminal to continue.`;
}
@@ -3,8 +3,8 @@ import { parse } from "@conform-to/zod";
import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid";
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, MetaFunction, useActionData, useFetcher } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -16,7 +16,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
@@ -36,8 +35,8 @@ import {
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
CreatedPersonalAccessToken,
ObfuscatedPersonalAccessToken,
type CreatedPersonalAccessToken,
type ObfuscatedPersonalAccessToken,
createPersonalAccessToken,
getValidPersonalAccessTokens,
revokePersonalAccessToken,
@@ -53,7 +52,7 @@ export const meta: MetaFunction = () => {
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
@@ -113,7 +112,7 @@ export const action: ActionFunction = async ({ request }) => {
}
case "revoke": {
try {
await revokePersonalAccessToken(submission.value.tokenId);
await revokePersonalAccessToken(submission.value.tokenId, userId);
return redirectWithSuccessMessage(
personalAccessTokensPath(),
@@ -125,6 +124,7 @@ export const action: ActionFunction = async ({ request }) => {
}
}
default: {
submission.value satisfies never;
return json({ errors: { body: "Invalid action" } }, { status: 400 });
}
}
+5 -1
View File
@@ -138,13 +138,17 @@ export default function AdminDashboardRoute() {
<TableCell isSticky={true}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="tertiary/small"
shortcut={
users.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</Button>
@@ -0,0 +1,105 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import pMap from "p-map";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { engine } from "~/v3/runEngine.server";
const ParamsSchema = z.object({
environmentId: z.string(),
});
const BodySchema = z.object({
dryRun: z.boolean().default(true),
queues: z.array(z.string()).default([]),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const parsedParams = ParamsSchema.parse(params);
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id: parsedParams.environmentId,
},
include: {
organization: true,
project: true,
orgMember: true,
},
});
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
return json({ error: "Engine version is V1" }, { status: 400 });
}
const body = await request.json();
const parsedBody = BodySchema.parse(body);
const queues = await $replica.taskQueue.findMany({
where: {
runtimeEnvironmentId: environment.id,
version: "V2",
name: parsedBody.queues.length > 0 ? { in: parsedBody.queues } : undefined,
},
select: {
friendlyId: true,
name: true,
concurrencyLimit: true,
type: true,
paused: true,
},
orderBy: {
orderableName: "asc",
},
});
const repairEnvironmentResults = await engine.repairEnvironment(environment, parsedBody.dryRun);
const repairResults = await pMap(
queues,
async (queue) => {
const repair = await engine.repairQueue(
environment,
queue.name,
parsedBody.dryRun,
repairEnvironmentResults.runIds
);
return {
queue: queue.name,
...repair,
};
},
{ concurrency: 5 }
);
return json({ environment: repairEnvironmentResults, queues: repairResults });
}
@@ -0,0 +1,95 @@
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica, prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { engine } from "~/v3/runEngine.server";
const ParamsSchema = z.object({
environmentId: z.string(),
});
const SearchParamsSchema = z.object({
verbose: z.string().default("0"),
page: z.coerce.number().optional(),
per_page: z.coerce.number().optional(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const parsedParams = ParamsSchema.parse(params);
const environment = await prisma.runtimeEnvironment.findFirst({
where: {
id: parsedParams.environmentId,
},
include: {
organization: true,
project: true,
orgMember: true,
},
});
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const engineVersion = await determineEngineVersion({ environment });
if (engineVersion === "V1") {
return json({ error: "Engine version is V1" }, { status: 400 });
}
const url = new URL(request.url);
const searchParams = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
const page = searchParams.page ?? 1;
const perPage = searchParams.per_page ?? 50;
const queues = await $replica.taskQueue.findMany({
where: {
runtimeEnvironmentId: environment.id,
version: "V2",
},
select: {
friendlyId: true,
name: true,
concurrencyLimit: true,
type: true,
paused: true,
},
orderBy: {
orderableName: "asc",
},
skip: (page - 1) * perPage,
take: perPage,
});
const report = await engine.generateEnvironmentReport(
environment,
queues,
searchParams.verbose === "1"
);
return json(report);
}
+5
View File
@@ -117,6 +117,11 @@ export default function AdminDashboardRoute() {
to={`/@/orgs/${org.slug}`}
className="mr-2"
variant="tertiary/small"
shortcut={
organizations.length === 1
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
: undefined
}
>
Impersonate
</LinkButton>
@@ -0,0 +1,72 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DeploymentService } from "~/v3/services/deployment.server";
const ParamsSchema = z.object({
deploymentId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const [, rawBody] = await tryCatch(request.json());
const body = CancelDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const deploymentService = new DeploymentService();
return await deploymentService
.cancelDeployment(authenticatedEnv, deploymentId, {
canceledReason: body.data.reason,
})
.match(
() => {
return new Response(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "failed_to_delete_deployment_timeout":
return new Response(null, { status: 204 }); // not a critical error, ignore
case "deployment_cannot_be_cancelled":
return json(
{ error: "Deployment is already in a final state and cannot be canceled" },
{ status: 409 }
);
case "other":
default:
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
@@ -0,0 +1,100 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
type GenerateRegistryCredentialsResponseBody,
ProgressDeploymentRequestBody,
tryCatch,
} from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DeploymentService } from "~/v3/services/deployment.server";
const ParamsSchema = z.object({
deploymentId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const [, rawBody] = await tryCatch(request.json());
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const deploymentService = new DeploymentService();
return await deploymentService.generateRegistryCredentials(authenticatedEnv, deploymentId).match(
(result) => {
return json(
{
username: result.username,
password: result.password,
expiresAt: result.expiresAt.toISOString(),
repositoryUri: result.repositoryUri,
} satisfies GenerateRegistryCredentialsResponseBody,
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_has_no_image_reference":
logger.error(
"Failed to generate registry credentials: deployment_has_no_image_reference",
{ deploymentId }
);
return json({ error: "Deployment has no image reference" }, { status: 409 });
case "deployment_is_already_final":
return json(
{ error: "Failed to generate registry credentials: deployment_is_already_final" },
{ status: 409 }
);
case "missing_registry_credentials":
logger.error("Failed to generate registry credentials: missing_registry_credentials", {
deploymentId,
});
return json({ error: "Missing registry credentials" }, { status: 409 });
case "registry_not_supported":
logger.error("Failed to generate registry credentials: registry_not_supported", {
deploymentId,
});
return json({ error: "Registry not supported" }, { status: 409 });
case "registry_region_not_supported":
logger.error("Failed to generate registry credentials: registry_region_not_supported", {
deploymentId,
});
return json({ error: "Registry region not supported" }, { status: 409 });
case "other":
default:
error.type satisfies "other";
logger.error("Failed to generate registry credentials", { error: error.cause });
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
@@ -0,0 +1,80 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DeploymentService } from "~/v3/services/deployment.server";
const ParamsSchema = z.object({
deploymentId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const [, rawBody] = await tryCatch(request.json());
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const deploymentService = new DeploymentService();
return await deploymentService
.progressDeployment(authenticatedEnv, deploymentId, {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
})
.match(
() => {
return new Response(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "failed_to_extend_deployment_timeout": {
logger.warn("Failed to extend deployment timeout", { error: error.cause });
return new Response(null, { status: 204 }); // ignore these errors for now
}
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_cannot_be_progressed":
return json(
{ error: "Deployment is not in a progressable state (PENDING or INSTALLING)" },
{ status: 409 }
);
case "failed_to_create_remote_build": {
logger.error("Failed to create remote Depot build", { error: error.cause });
return json({ error: "Failed to create remote build" }, { status: 500 });
}
case "other":
default:
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
@@ -1,4 +1,5 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { type GetDeploymentResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -52,7 +53,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
shortCode: deployment.shortCode,
version: deployment.version,
imageReference: deployment.imageReference,
errorData: deployment.errorData,
imagePlatform: deployment.imagePlatform,
externalBuildData:
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
worker: deployment.worker
? {
id: deployment.worker.friendlyId,
@@ -65,5 +69,5 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
})),
}
: undefined,
});
} satisfies GetDeploymentResponseBody);
}
+121 -2
View File
@@ -1,10 +1,13 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
ApiDeploymentListSearchParams,
InitializeDeploymentRequestBody,
InitializeDeploymentResponseBody,
type InitializeDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { $replica } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { InitializeDeploymentService } from "~/v3/services/initializeDeployment.server";
@@ -60,3 +63,119 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
}
}
export const loader = createLoaderApiRoute(
{
searchParams: ApiDeploymentListSearchParams,
allowJWT: true,
corsStrategy: "none",
authorization: {
action: "read",
resource: () => ({ deployments: "list" }),
superScopes: ["read:deployments", "read:all", "admin"],
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
async ({ searchParams, authentication }) => {
const limit = Math.max(Math.min(searchParams["page[size]"] ?? 20, 100), 5);
const afterDeployment = searchParams["page[after]"]
? await $replica.workerDeployment.findFirst({
where: {
friendlyId: searchParams["page[after]"],
environmentId: authentication.environment.id,
},
})
: undefined;
const deployments = await $replica.workerDeployment.findMany({
where: {
environmentId: authentication.environment.id,
...(afterDeployment ? { id: { lt: afterDeployment.id } } : {}),
...getCreatedAtFilter(searchParams),
...(searchParams.status ? { status: searchParams.status } : {}),
},
orderBy: {
id: "desc",
},
take: limit + 1,
});
const hasMore = deployments.length > limit;
const nextCursor = hasMore ? deployments[limit - 1].friendlyId : undefined;
const data = hasMore ? deployments.slice(0, limit) : deployments;
return json({
data: data.map((deployment) => ({
id: deployment.friendlyId,
createdAt: deployment.createdAt,
shortCode: deployment.shortCode,
version: deployment.version.toString(),
runtime: deployment.runtime,
runtimeVersion: deployment.runtimeVersion,
status: deployment.status,
deployedAt: deployment.deployedAt,
git: deployment.git,
error: deployment.errorData ?? undefined,
})),
pagination: {
next: nextCursor,
},
});
}
);
import parseDuration from "parse-duration";
import { parseDate } from "@trigger.dev/core/v3/isomorphic";
function getCreatedAtFilter(searchParams: ApiDeploymentListSearchParams) {
if (searchParams.period) {
const duration = parseDuration(searchParams.period, "ms");
if (!duration) {
throw new ServiceValidationError(
`Invalid search query parameter: period=${searchParams.period}`,
400
);
}
return {
createdAt: {
gte: new Date(Date.now() - duration),
lte: new Date(),
},
};
}
if (searchParams.from && searchParams.to) {
const fromDate = safeDateFromString(searchParams.from, "from");
const toDate = safeDateFromString(searchParams.to, "to");
return {
createdAt: {
gte: fromDate,
lte: toDate,
},
};
}
if (searchParams.from) {
const fromDate = safeDateFromString(searchParams.from, "from");
return {
createdAt: {
gte: fromDate,
},
};
}
return {};
}
function safeDateFromString(value: string, paramName: string) {
const date = parseDate(value);
if (!date) {
throw new ServiceValidationError(`Invalid search query parameter: ${paramName}=${value}`, 400);
}
return date;
}
@@ -0,0 +1,138 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import {
CreateProjectRequestBody,
GetProjectResponseBody,
GetProjectsResponseBody,
} from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { createProject } from "~/models/project.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { isCuid } from "cuid";
const ParamsSchema = z.object({
orgParam: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("get projects", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const { orgParam } = ParamsSchema.parse(params);
const projects = await prisma.project.findMany({
where: {
organization: {
...orgParamWhereClause(orgParam),
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
},
},
},
version: "V3",
deletedAt: null,
},
include: {
organization: true,
},
});
if (!projects) {
return json({ error: "Projects not found" }, { status: 404 });
}
const result: GetProjectsResponseBody = projects.map((project) => ({
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
},
}));
return json(result);
}
export async function action({ request, params }: ActionFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const { orgParam } = ParamsSchema.parse(params);
const organization = await prisma.organization.findFirst({
where: {
...orgParamWhereClause(orgParam),
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
},
},
},
});
if (!organization) {
return json({ error: "Organization not found" }, { status: 404 });
}
const body = await request.json();
const parsedBody = CreateProjectRequestBody.safeParse(body);
if (!parsedBody.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
const project = await createProject({
organizationSlug: organization.slug,
name: parsedBody.data.name,
userId: authenticationResult.userId,
version: "v3",
});
const result: GetProjectResponseBody = {
id: project.id,
externalRef: project.externalRef,
name: project.name,
slug: project.slug,
createdAt: project.createdAt,
organization: {
id: project.organization.id,
title: project.organization.title,
slug: project.organization.slug,
createdAt: project.organization.createdAt,
},
};
return json(result);
}
function orgParamWhereClause(orgParam: string) {
// If the orgParam is an ID, or if it's a slug
// IDs are cuid
if (isCuid(orgParam)) {
return {
id: orgParam,
};
}
return {
slug: orgParam,
};
}
+37
View File
@@ -0,0 +1,37 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { GetOrgsResponseBody } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
export async function loader({ request }: LoaderFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const orgs = await prisma.organization.findMany({
where: {
deletedAt: null,
members: {
some: {
userId: authenticationResult.userId,
},
},
},
});
if (!orgs) {
return json({ error: "Orgs not found" }, { status: 404 });
}
const result: GetOrgsResponseBody = orgs.map((org) => ({
id: org.id,
title: org.title,
slug: org.slug,
createdAt: org.createdAt,
}));
return json(result);
}

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