Compare commits

...

89 Commits

Author SHA1 Message Date
github-actions[bot] d75c3aeadd chore: Update version for release (#2665)
* chore: Update version for release

* Release 4.0.7

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-11-11 14:29:24 +00:00
Matt Aitken a342332146 Fix for the MCP tool that gets logs for debugging runs (#2653)
* Fix for the MCP tool that gets logs for debugging runs

This was broken when we changed the data on the backend that returns 
log/span data from runs. We changed the data structured and the internal
API that the MCP client uses was failing to parse with the Zod schema

* add changeset

* Revert "add changeset"

This reverts commit 86eca836d5907fa0d0f8ac595d4d5ebade140514.

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-11-11 13:26:07 +00:00
Saadi Myftija 9624465ee2 fix: error handling issue with s2 streams (#2664)
* Fix logger import

*old man yells at auto-import*

* Fix s2 error handling for non-existing streams
2025-11-11 09:44:54 +01:00
Saadi Myftija 42f53b12b7 feat(deployments): stream build server logs (#2663)
* Use read-only project-scoped s2 tokens for streaming deployment logs

* Add http2 to remix polyfills

Needed for using s2 client-side.

* Stream build-server logs in the deployment details page

* Disable 12-hour format in the DateTime component

* Enable collapsing the logs panel

* Auto-collapse logs for succesful/timedout/queued deployments

* Make S2 env vars optional

* Show the logs section only for gh-triggered deployments

* Cache s2 access tokens in redis

* Reset streaming state

* Expose 12h format as a param for the Datetime components
2025-11-10 12:42:40 +01:00
github-actions[bot] 9fdf91a1c4 Release v4.0.6 (#2647)
* chore: Update version for release

* Update lock file

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: myftija <saadi.myftija@gmail.com>
2025-11-03 14:42:02 +01:00
nicktrn 9593a46364 fix(extensions): prevent audiowaveform binary removal (#2643)
* fix(extensions): prevent audiowaveform binary removal

* add changeset
2025-11-03 14:17:38 +01:00
Saadi Myftija 27376df903 fix(cli): show a useful error message when config file is missing (#2650)
* fix(cli): show a useful error message when config file is missing

* Add changeset
2025-10-31 16:34:48 +01:00
Saadi Myftija e0cece72ae docs: deployments with local builds (#2645)
* docs: deployments with local builds

Adds a section to the deployment docs page about the option to deploy using a locally built image.

* Update installation link
2025-10-30 18:10:23 +01:00
Saadi Myftija 2f3f82f3a9 feat(deployments): show local build hint during depot outages (#2646)
* Add an API endpoint to query remote build provider status

* Show local build hint for failed deployments when Depot is down

* Show the local build flag in the help output

* Add changeset

* Fix import

* Fix docs link
2025-10-30 18:09:54 +01:00
Dan 4264bdf429 Added use cases section in the docs (#2641)
* Started adding use cases pages

* Updates

* More improvements

* Improved diagrams

* Added overview page

* More copy + diagram updates

* Improved diagram titles

* Further diagram improvements

* Corrected workflow

* Updated copy

* Typos

* Updated intro file

* Reverted aiRunFilterService.server.ts
2025-10-30 09:21:31 +00:00
nicktrn bf6735bd56 fix(webapp): concurrency override upper bound should be env not org (#2642) 2025-10-29 19:16:42 +00:00
James Ritchie dacd53b906 Fix for showing the incident UI in the side menu (#2638) 2025-10-28 21:13:23 +00:00
github-actions[bot] d4fe71df34 Release v4.0.5 (#2531)
* chore: Update version for release

* Release v4.0.5

---------

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-10-28 14:20:20 +00:00
nicktrn ae8e83b2d0 chore(runner): move max duration logic into parent process (#2637)
* chore(runner): move max duration logic into parent process

* chore(rsc): remove type-marker package.json

* add changeset

* chore(core): remove irrelevant test after our changes

* chore(core): clarify we don't care about the timeout promise
2025-10-28 13:49:59 +00:00
nicktrn 2283ca6ad3 fix(webapp): persist concurrency overrides on deploy (#2636)
* fix(webapp): display correct concurrency override base value

* fix(webapp): persist concurrency overrides on deploy

* fix(webapp): use correct override base value type

* fix(webapp): override input is bounded by env concurrency
2025-10-28 11:28:38 +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
263 changed files with 14996 additions and 4390 deletions
+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"
+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
}
+1
View File
@@ -13,6 +13,7 @@
[![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>
+1
View File
@@ -90,6 +90,7 @@ const Env = z.object({
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),
@@ -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) {
@@ -274,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: {
@@ -302,6 +307,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
envtype: this.#envTypeToLabelValue(opts.envType),
org: opts.orgId,
project: opts.projectId,
machine: opts.machine.name,
};
}
+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
@@ -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">
@@ -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 (
@@ -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>
@@ -13,7 +13,9 @@ type DateTimeProps = {
includeTime?: boolean;
showTimezone?: boolean;
showTooltip?: boolean;
hideDate?: boolean;
previousDate?: Date | string | null; // Add optional previous date for comparison
hour12?: boolean;
};
export const DateTime = ({
@@ -23,6 +25,7 @@ export const DateTime = ({
includeTime = true,
showTimezone = false,
showTooltip = true,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -50,7 +53,8 @@ export const DateTime = ({
timeZone ?? localTimeZone,
locales,
includeSeconds,
includeTime
includeTime,
hour12
).replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</Fragment>
@@ -66,7 +70,8 @@ export function formatDateTime(
timeZone: string,
locales: string[],
includeSeconds: boolean,
includeTime: boolean
includeTime: boolean,
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
year: "numeric",
@@ -76,6 +81,7 @@ export function formatDateTime(
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
hour12,
}).format(date);
}
@@ -122,7 +128,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
@@ -132,8 +138,8 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
: null;
// Initial formatted values
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
// State for the formatted time
const [formattedDateTime, setFormattedDateTime] = useState<string>(
@@ -150,10 +156,10 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
// Format with appropriate function
setFormattedDateTime(
showDatePart
? formatSmartDateTime(realDate, userTimeZone, locales)
: formatTimeOnly(realDate, userTimeZone, locales)
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
);
}, [locales, realDate, realPrevDate]);
}, [locales, realDate, realPrevDate, hour12]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
@@ -168,7 +174,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
// Format with date and time
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
return new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -178,18 +184,20 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
// Format time only
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
return new Intl.DateTimeFormat(locales, {
hour: "numeric",
hour: "2-digit",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
@@ -198,6 +206,8 @@ export const DateTimeAccurate = ({
timeZone = "UTC",
previousDate = null,
showTooltip = true,
hideDate = false,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -214,11 +224,13 @@ export const DateTimeAccurate = ({
}, []);
// Smart formatting based on whether date changed
const formattedDateTime = realPrevDate
const formattedDateTime = hideDate
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, localTimeZone, locales)
: formatDateTimeAccurate(realDate, localTimeZone, locales)
: formatDateTimeAccurate(realDate, localTimeZone, locales);
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
if (!showTooltip)
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
@@ -241,7 +253,7 @@ export const DateTimeAccurate = ({
);
};
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -251,26 +263,27 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
}
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
}, [locales, realDate]);
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
}, [locales, realDate, hour12]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
@@ -278,6 +291,7 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
@@ -17,6 +17,10 @@ const paragraphVariants = {
text: "font-sans text-sm font-normal text-text-bright",
spacing: "mb-2",
},
"small/dimmed": {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
},
"extra-small": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
@@ -25,6 +29,14 @@ const paragraphVariants = {
text: "font-sans text-xs font-normal text-text-bright",
spacing: "mb-1.5",
},
"extra-small/dimmed": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/dimmed/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
@@ -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,
@@ -53,6 +53,7 @@ export function DeploymentStatusIcon({
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
@@ -78,6 +79,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return "text-pending";
@@ -98,6 +100,8 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
switch (status) {
case "PENDING":
return "Queued…";
case "INSTALLING":
return "Installing…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
@@ -127,17 +131,21 @@ 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/${
@@ -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}`
+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);
}
+52 -5
View File
@@ -25,6 +25,27 @@ const GithubAppEnvSchema = z.preprocess(
])
);
// eventually we can make all S2 env vars required once the S2 OSS version is out
const S2EnvSchema = z.preprocess(
(val) => {
const obj = val as any;
if (!obj || !obj.S2_ENABLED) {
return { ...obj, S2_ENABLED: "0" };
}
return obj;
},
z.discriminatedUnion("S2_ENABLED", [
z.object({
S2_ENABLED: z.literal("1"),
S2_ACCESS_TOKEN: z.string(),
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
}),
z.object({
S2_ENABLED: z.literal("0"),
}),
])
);
const EnvironmentSchema = z
.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -59,6 +80,7 @@ const EnvironmentSchema = z
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
APP_ORIGIN: z.string().default("http://localhost:3030"),
API_ORIGIN: z.string().optional(),
STREAM_ORIGIN: z.string().optional(),
@@ -492,6 +514,7 @@ const EnvironmentSchema = z
CENTS_PER_RUN: z.coerce.number().default(0),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
@@ -752,8 +775,8 @@ const EnvironmentSchema = z
/** The max number of runs per API call that we'll dequeue in DEV */
DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10),
/** The maximum concurrent local run processes executing at once in dev */
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().default(25),
/** The maximum concurrent local run processes executing at once in dev. This is a hard limit */
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().optional(),
/** The CLI should connect to this for dev runs */
DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"),
@@ -1028,8 +1051,9 @@ const EnvironmentSchema = z
TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"),
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000),
QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000),
DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
SLACK_BOT_TOKEN: z.string().optional(),
SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(),
@@ -1099,6 +1123,7 @@ const EnvironmentSchema = z
RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100),
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
// Clickhouse
CLICKHOUSE_URL: z.string(),
@@ -1108,6 +1133,27 @@ const EnvironmentSchema = z
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
EVENTS_CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
EVENT_REPOSITORY_DEFAULT_STORE: z.enum(["postgres", "clickhouse"]).default("postgres"),
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
// Bootstrap
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
@@ -1177,7 +1223,8 @@ const EnvironmentSchema = z
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
})
.and(GithubAppEnvSchema);
.and(GithubAppEnvSchema)
.and(S2EnvSchema);
export type Environment = z.infer<typeof EnvironmentSchema>;
export const env = EnvironmentSchema.parse(process.env);
@@ -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 };
}
@@ -75,6 +75,7 @@ export class ApiRetrieveRunPresenter {
},
select: {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
@@ -3,7 +3,7 @@ import {
ExternalBuildData,
prepareDeploymentError,
} from "@trigger.dev/core/v3";
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
import { type Organization } from "~/models/organization.server";
import { type Project } from "~/models/project.server";
@@ -11,6 +11,24 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { processGitMetadata } from "./BranchesPresenter.server";
import { S2 } from "@s2-dev/streamstore";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
const s2TokenRedis = createRedisClient("s2-token-cache", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
});
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
export type ErrorData = {
name: string;
@@ -43,6 +61,7 @@ export class DeploymentPresenter {
select: {
id: true,
organizationId: true,
externalRef: true,
},
where: {
slug: projectSlug,
@@ -103,6 +122,9 @@ export class DeploymentPresenter {
deployedAt: true,
createdAt: true,
startedAt: true,
installedAt: true,
canceledAt: true,
canceledReason: true,
git: true,
promotions: {
select: {
@@ -135,11 +157,29 @@ export class DeploymentPresenter {
},
});
const gitMetadata = processGitMetadata(deployment.git);
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
let s2Logs = undefined;
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
if (error) {
logger.error("Failed getting S2 access token", { error });
} else {
s2Logs = {
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
accessToken,
};
}
}
return {
s2Logs,
deployment: {
id: deployment.id,
shortCode: deployment.shortCode,
@@ -147,8 +187,11 @@ export class DeploymentPresenter {
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: {
@@ -172,11 +215,46 @@ export class DeploymentPresenter {
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
isBuilt: !!deployment.builtAt,
type: deployment.type,
git: processGitMetadata(deployment.git),
git: gitMetadata,
},
};
}
private async getS2AccessToken(projectRef: string): Promise<string> {
if (env.S2_ENABLED !== "1" || !s2) {
throw new Error("Failed getting S2 access token: S2 is not enabled");
}
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
const cachedToken = await s2TokenRedis.get(redisKey);
if (cachedToken) {
return cachedToken;
}
const { access_token: accessToken } = await s2.accessTokens.issue({
id: `${projectRef}-${new Date().getTime()}`,
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
scope: {
ops: ["read"],
basins: {
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
},
streams: {
prefix: `projects/${projectRef}/deployments/`,
},
},
});
await s2TokenRedis.setex(
redisKey,
59 * 60, // slightly shorter than the token validity period
accessToken
);
return accessToken;
}
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
@@ -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;
}
@@ -3,9 +3,11 @@ import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/Tr
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,
@@ -93,6 +95,13 @@ export class RunPresenter {
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
@@ -132,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],
};
}
@@ -215,7 +259,9 @@ export class RunPresenter {
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;
+7
View File
@@ -20,6 +20,13 @@ 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 [
@@ -1,13 +1,9 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
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,
setRequestSuccessMessage,
commitSession,
} from "~/models/message.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";
@@ -41,7 +37,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const callbackData = result.data;
@@ -54,7 +50,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
error: sessionResult.error,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
@@ -76,7 +72,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
switch (callbackData.setup_action) {
@@ -89,17 +85,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
const session = await setRequestSuccessMessage(request, "GitHub App installed successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
}
case "update": {
@@ -112,14 +101,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}
const session = await setRequestSuccessMessage(request, "GitHub App updated successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
}
case "request": {
@@ -129,17 +111,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
callbackData,
});
const session = await setRequestSuccessMessage(request, "GitHub App installation requested");
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
}
default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
}
@@ -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 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 !== "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">
@@ -1,10 +1,12 @@
import { Link, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEffect, useState, useRef, useCallback } from "react";
import { S2, S2Error } from "@s2-dev/streamstore";
import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp } from "lucide-react";
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";
@@ -23,10 +25,15 @@ import {
} from "~/components/primitives/Table";
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
@@ -41,7 +48,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new DeploymentPresenter();
const { deployment } = await presenter.call({
const { deployment, s2Logs } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
@@ -49,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
deploymentShortCode: deploymentParam,
});
return typedjson({ deployment });
return typedjson({ deployment, s2Logs });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -59,15 +66,99 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
type LogEntry = {
message: string;
timestamp: Date;
level: "info" | "error" | "warn";
};
export default function Page() {
const { deployment } = useTypedLoaderData<typeof loader>();
const { deployment, s2Logs } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
const user = useUser();
const page = new URLSearchParams(location.search).get("page");
const logsDisabled = s2Logs === undefined;
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isStreaming, setIsStreaming] = useState(true);
const [streamError, setStreamError] = useState<string | null>(null);
useEffect(() => {
if (logsDisabled) return;
const abortController = new AbortController();
setLogs([]);
setStreamError(null);
setIsStreaming(true);
const streamLogs = async () => {
try {
const s2 = new S2({ accessToken: s2Logs.accessToken });
const basin = s2.basin(s2Logs.basin);
const stream = basin.stream(s2Logs.stream);
const readSession = await stream.readSession(
{
seq_num: 0,
wait: 60,
as: "bytes",
},
{ signal: abortController.signal }
);
const decoder = new TextDecoder();
for await (const record of readSession) {
try {
const headers: Record<string, string> = {};
if (record.headers) {
for (const [nameBytes, valueBytes] of record.headers) {
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
}
}
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
setLogs((prevLogs) => [
...prevLogs,
{
timestamp: new Date(record.timestamp),
message: decoder.decode(record.body),
level,
},
]);
} catch (err) {
console.error("Failed to parse log record:", err);
}
}
} catch (error) {
if (abortController.signal.aborted) return;
const isNotFoundError =
error instanceof S2Error &&
error.code &&
["permission_denied", "stream_not_found"].includes(error.code);
if (isNotFoundError) return;
console.error("Failed to stream logs:", error);
setStreamError("Failed to stream logs");
} finally {
if (!abortController.signal.aborted) {
setIsStreaming(false);
}
}
};
streamLogs();
return () => {
abortController.abort();
};
}, [s2Logs?.basin, s2Logs?.stream, s2Logs?.accessToken]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
@@ -132,7 +223,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>
@@ -155,6 +250,35 @@ export default function Page() {
/>
</Property.Value>
</Property.Item>
{!logsDisabled && (
<Property.Item>
<Property.Label>Logs</Property.Label>
<LogsDisplay
logs={logs}
isStreaming={isStreaming}
streamError={streamError}
initialCollapsed={(
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
).includes(deployment.status)}
/>
</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>
@@ -197,6 +321,18 @@ export default function Page() {
)}
</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>
<Property.Label>Built at</Property.Label>
<Property.Value>
@@ -289,3 +425,189 @@ export default function Page() {
</div>
);
}
function LogsDisplay({
logs,
isStreaming,
streamError,
initialCollapsed = false,
}: {
logs: LogEntry[];
isStreaming: boolean;
streamError: string | null;
initialCollapsed?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const [collapsed, setCollapsed] = useState(initialCollapsed);
const logsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCollapsed(initialCollapsed);
}, [initialCollapsed]);
// auto-scroll log container to bottom when new logs arrive
useEffect(() => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [logs]);
const onCopyLogs = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const logsText = logs.map((log) => log.message).join("\n");
navigator.clipboard.writeText(logsText);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[logs]
);
const errorCount = logs.filter((log) => log.level === "error").length;
const warningCount = logs.filter((log) => log.level === "warn").length;
return (
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
errorCount > 0 ? "bg-error/80" : "bg-charcoal-600"
)}
/>
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
</Paragraph>
</div>
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
warningCount > 0 ? "bg-warning/80" : "bg-charcoal-600"
)}
/>
<Paragraph variant="extra-small/dimmed/mono">
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
</Paragraph>
</div>
</div>
{logs.length > 0 && (
<div className="flex items-center gap-3">
<TooltipProvider>
<Tooltip open={copied || mouseOver} disableHoverableContent>
<TooltipTrigger
onClick={onCopyLogs}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
)}
>
<div className="size-4 shrink-0">
{copied ? (
<ClipboardCheck className="size-full" />
) : (
<Clipboard className="size-full" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setCollapsed(!collapsed)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
"text-text-dimmed hover:text-text-bright"
)}
>
{collapsed ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{collapsed ? "Expand" : "Collapse"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div className="relative">
<div
ref={logsContainerRef}
className={cn(
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
collapsed ? "h-16" : "h-64"
)}
>
<div className="flex w-fit min-w-full flex-col">
{logs.length === 0 && (
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
{streamError ? (
<span className="text-error">Failed fetching logs</span>
) : (
<span className="text-text-dimmed">
{isStreaming ? "Waiting for logs..." : "No logs yet"}
</span>
)}
</div>
)}
{logs.map((log, index) => {
return (
<div
key={index}
className={cn(
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
log.level === "info" && "border-transparent hover:bg-charcoal-750"
)}
>
<span
className={cn(
"select-none whitespace-nowrap py-px",
log.level === "error" && "text-error/80",
log.level === "warn" && "text-warning/70",
log.level === "info" && "text-text-dimmed"
)}
>
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
</span>
<span
className={cn(
"whitespace-nowrap",
log.level === "error" && "text-error",
log.level === "warn" && "text-warning",
log.level === "info" && "text-text-bright"
)}
>
{log.message}
</span>
</div>
);
})}
</div>
</div>
{collapsed && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-charcoal-800/90 to-transparent" />
)}
</div>
</div>
);
}
@@ -1,5 +1,18 @@
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";
@@ -15,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";
@@ -39,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";
@@ -61,6 +78,9 @@ import {
} 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 [
@@ -116,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, {
@@ -137,6 +159,7 @@ export default function Page() {
selectedDeployment,
connectedGithubRepository,
environmentGitHubBranch,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
@@ -144,6 +167,8 @@ export default function Page() {
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) {
@@ -347,7 +372,7 @@ export default function Page() {
{deploymentParam && (
<>
<ResizableHandle id="deployments-handle" />
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
<ResizablePanel id="deployments-inspector" min="500px" max="800px">
<Outlet />
</ResizablePanel>
</>
@@ -388,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}>
{""}
@@ -412,7 +440,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Rollback
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
@@ -432,7 +460,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Promote
Promote
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
@@ -442,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,14 @@ 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 { QueueItem } from "@trigger.dev/core/v3/schemas";
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 +26,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 +54,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 +120,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 +213,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 +341,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 +376,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 +453,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 +480,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 +505,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 +541,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 +567,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 +637,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 +682,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 +839,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 +870,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 +923,124 @@ function QueuePauseResumeButton({
);
}
function QueueOverrideConcurrencyButton({
queue,
environmentConcurrencyLimit,
}: {
queue: QueueItem;
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}.
{typeof queue.concurrency?.base === "number" &&
` The original limit set in code was ${queue.concurrency.base}.`}{" "}
You can update the override or remove it to restore the{" "}
{typeof queue.concurrency?.base === "number"
? "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"
max={environmentConcurrencyLimit}
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">
@@ -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,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
return <></>;
}
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace;
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
@@ -324,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
@@ -372,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>
@@ -14,6 +14,7 @@ import {
useActionData,
useNavigation,
useNavigate,
useSearchParams,
} from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -49,8 +50,6 @@ import {
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
getSession,
commitSession,
} from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
@@ -61,6 +60,8 @@ import {
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
docsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { Select, SelectItem } from "~/components/primitives/Select";
@@ -78,6 +79,7 @@ import { TextLink } from "~/components/primitives/TextLink";
import { cn } from "~/utils/cn";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
export const meta: MetaFunction = () => {
return [
@@ -123,22 +125,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { gitHubApp, buildSettings } = resultOrFail.value;
const session = await getSession(request.headers.get("Cookie"));
const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true;
const headers = new Headers({
"Set-Cookie": await commitSession(session),
return typedjson({
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
isPreviewEnvironmentEnabled: gitHubApp.isPreviewEnvironmentEnabled,
buildSettings,
});
return typedjson(
{
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
openGitHubRepoConnectionModal,
buildSettings,
},
{ headers }
);
};
const ConnectGitHubRepoFormSchema = z.object({
@@ -167,14 +160,6 @@ const UpdateBuildSettingsFormSchema = z.object({
.refine((val) => !val || val.length <= 255, {
message: "Config file path must not exceed 255 characters",
}),
installDirectory: z
.string()
.trim()
.optional()
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
.refine((val) => !val || val.length <= 255, {
message: "Install directory must not exceed 255 characters",
}),
installCommand: z
.string()
.trim()
@@ -185,6 +170,16 @@ const UpdateBuildSettingsFormSchema = z.object({
.refine((val) => !val || val.length <= 500, {
message: "Install command must not exceed 500 characters",
}),
preBuildCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Pre-build command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Pre-build command must not exceed 500 characters",
}),
});
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
@@ -412,11 +407,11 @@ export const action: ActionFunction = async ({ request, params }) => {
});
}
case "update-build-settings": {
const { installDirectory, installCommand, triggerConfigFilePath } = submission.value;
const { installCommand, preBuildCommand, triggerConfigFilePath } = submission.value;
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
installDirectory: installDirectory || undefined,
installCommand: installCommand || undefined,
preBuildCommand: preBuildCommand || undefined,
triggerConfigFilePath: triggerConfigFilePath || undefined,
});
@@ -448,8 +443,8 @@ export default function Page() {
githubAppInstallations,
connectedGithubRepository,
githubAppEnabled,
openGitHubRepoConnectionModal,
buildSettings,
isPreviewEnvironmentEnabled,
} = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
@@ -577,14 +572,16 @@ export default function Page() {
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
{connectedGithubRepository ? (
<ConnectedGitHubRepoForm connectedGitHubRepo={connectedGithubRepository} />
<ConnectedGitHubRepoForm
connectedGitHubRepo={connectedGithubRepository}
previewEnvironmentEnabled={isPreviewEnvironmentEnabled}
/>
) : (
<GitHubConnectionPrompt
gitHubAppInstallations={githubAppInstallations ?? []}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
openGitHubRepoConnectionModal={openGitHubRepoConnectionModal}
/>
)}
</div>
@@ -667,7 +664,6 @@ function ConnectGitHubRepoModal({
organizationSlug,
projectSlug,
environmentSlug,
open = false,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
@@ -675,7 +671,7 @@ function ConnectGitHubRepoModal({
environmentSlug: string;
open?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(open);
const [isModalOpen, setIsModalOpen] = useState(false);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
@@ -703,6 +699,17 @@ function ConnectGitHubRepoModal({
},
});
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (params.get("openGithubRepoModal") === "1") {
setIsModalOpen(true);
params.delete("openGithubRepoModal");
setSearchParams(params);
}
}, [searchParams, setSearchParams]);
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
@@ -759,11 +766,11 @@ function ConnectGitHubRepoModal({
navigate(
githubAppInstallPath(
organizationSlug,
v3ProjectSettingsPath(
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)
)}?openGithubRepoModal=1`
)
);
}}
@@ -856,13 +863,11 @@ function GitHubConnectionPrompt({
organizationSlug,
projectSlug,
environmentSlug,
openGitHubRepoConnectionModal = false,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
openGitHubRepoConnectionModal?: boolean;
}) {
return (
<Fieldset>
@@ -871,11 +876,11 @@ function GitHubConnectionPrompt({
<LinkButton
to={githubAppInstallPath(
organizationSlug,
v3ProjectSettingsPath(
`${v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)
)}?openGithubRepoModal=1`
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
@@ -890,7 +895,6 @@ function GitHubConnectionPrompt({
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
open={openGitHubRepoConnectionModal}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
@@ -913,11 +917,14 @@ type ConnectedGitHubRepo = {
function ConnectedGitHubRepoForm({
connectedGitHubRepo,
previewEnvironmentEnabled,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
previewEnvironmentEnabled?: boolean;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const organization = useOrganization();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
@@ -1013,10 +1020,10 @@ function ConnectedGitHubRepoForm({
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every commit on the selected tracking branch creates a deployment in the corresponding
Every push to the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="grid grid-cols-[120px_1fr] gap-3">
<div className="mt-1 grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
@@ -1064,19 +1071,34 @@ function ConnectedGitHubRepoForm({
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<Switch
name="previewDeploymentsEnabled"
defaultChecked={connectedGitHubRepo.previewDeploymentsEnabled}
variant="small"
label="create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
<div className="flex items-center gap-1.5">
<Switch
name="previewDeploymentsEnabled"
disabled={!previewEnvironmentEnabled}
defaultChecked={
connectedGitHubRepo.previewDeploymentsEnabled && previewEnvironmentEnabled
}
variant="small"
label="Create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
{!previewEnvironmentEnabled && (
<InfoIconTooltip
content={
<span className="text-xs">
<TextLink to={v3BillingPath(organization)}>Upgrade</TextLink> your plan to
enable preview branches
</span>
}
/>
)}
</div>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
@@ -1110,14 +1132,14 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
const [buildSettingsValues, setBuildSettingsValues] = useState({
installDirectory: buildSettings?.installDirectory || "",
preBuildCommand: buildSettings?.preBuildCommand || "",
installCommand: buildSettings?.installCommand || "",
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
});
useEffect(() => {
const hasChanges =
buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") ||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "");
setHasBuildSettingsChanges(hasChanges);
@@ -1167,7 +1189,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, or `bun install`"
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
@@ -1175,26 +1197,30 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
}));
}}
/>
<Hint>Command to install your project dependencies. Auto-detected by default.</Hint>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installDirectory.id}>Install directory</Label>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.installDirectory, { type: "text" })}
defaultValue={buildSettings?.installDirectory || ""}
placeholder=""
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installDirectory: e.target.value,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>The directory where the install command is run in. Auto-detected by default.</Hint>
<FormError id={fields.installDirectory.errorId}>
{fields.installDirectory.error}
</FormError>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
@@ -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) {
@@ -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>
@@ -1,5 +1,5 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { StartDeploymentRequestBody } from "@trigger.dev/core/v3";
import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -34,8 +34,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const rawBody = await request.json();
const body = StartDeploymentRequestBody.safeParse(rawBody);
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 });
@@ -44,23 +44,24 @@ export async function action({ request, params }: ActionFunctionArgs) {
const deploymentService = new DeploymentService();
return await deploymentService
.startDeployment(authenticatedEnv, deploymentId, {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
.cancelDeployment(authenticatedEnv, deploymentId, {
canceledReason: body.data.reason,
})
.match(
() => {
return json(null, { status: 204 });
return new Response(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "failed_to_extend_deployment_timeout":
return json(null, { status: 204 }); // ignore these errors for now
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_not_pending":
return json({ error: "Deployment is not pending" }, { status: 409 });
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";
@@ -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 });
}
}
);
}
@@ -0,0 +1,75 @@
import { json } from "@remix-run/server-runtime";
import { type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3";
import { z } from "zod";
import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
const BodySchema = z.object({
type: RetrieveQueueType.default("id"),
concurrencyLimit: z.number().int().min(0).max(100000),
});
export const { action } = createActionApiRoute(
{
body: BodySchema,
params: z.object({
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
}),
},
async ({ params, body, authentication }) => {
const input: RetrieveQueueParam =
body.type === "id"
? params.queueParam
: {
type: body.type,
name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"),
};
return concurrencySystem.queues
.overrideQueueConcurrencyLimit(authentication.environment, input, body.concurrencyLimit)
.match(
(queue) => {
return json(
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: queue.running,
queued: queue.queued,
concurrencyLimit: queue.concurrencyLimit,
concurrencyLimitBase: queue.concurrencyLimitBase,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt,
concurrencyLimitOverriddenBy: null,
paused: queue.paused,
}),
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "queue_not_found": {
return json({ error: "Queue not found" }, { status: 404 });
}
case "queue_update_failed": {
return json({ error: "Failed to update queue concurrency limit" }, { status: 500 });
}
case "sync_queue_concurrency_to_engine_failed": {
return json(
{ error: "Failed to sync queue concurrency limit to engine" },
{ status: 500 }
);
}
case "get_queue_stats_failed": {
return json({ error: "Failed to get queue stats" }, { status: 500 });
}
case "other":
default: {
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
}
);
}
);
@@ -0,0 +1,75 @@
import { json } from "@remix-run/server-runtime";
import { type RetrieveQueueParam, RetrieveQueueType } from "@trigger.dev/core/v3";
import { z } from "zod";
import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
const BodySchema = z.object({
type: RetrieveQueueType.default("id"),
});
export const { action } = createActionApiRoute(
{
body: BodySchema,
params: z.object({
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
}),
},
async ({ params, body, authentication }) => {
const input: RetrieveQueueParam =
body.type === "id"
? params.queueParam
: {
type: body.type,
name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"),
};
return concurrencySystem.queues.resetConcurrencyLimit(authentication.environment, input).match(
(queue) => {
return json(
toQueueItem({
friendlyId: queue.friendlyId,
name: queue.name,
type: queue.type,
running: queue.running,
queued: queue.queued,
concurrencyLimit: queue.concurrencyLimit,
concurrencyLimitBase: queue.concurrencyLimitBase,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt,
concurrencyLimitOverriddenBy: null,
paused: queue.paused,
}),
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "queue_not_found": {
return json({ error: "Queue not found" }, { status: 404 });
}
case "queue_not_overridden": {
return json({ error: "Queue is not overridden" }, { status: 400 });
}
case "queue_update_failed": {
return json({ error: "Failed to update queue concurrency limit" }, { status: 500 });
}
case "sync_queue_concurrency_to_engine_failed": {
return json(
{ error: "Failed to sync queue concurrency limit to engine" },
{ status: 500 }
);
}
case "get_queue_stats_failed": {
return json({ error: "Failed to get queue stats" }, { status: 500 });
}
case "other":
default: {
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
}
);
}
);
@@ -0,0 +1,94 @@
import { json } from "@remix-run/node";
import { err, fromPromise, fromSafePromise, ok } from "neverthrow";
import z from "zod";
import { logger } from "~/services/logger.server";
import { type RemoteBuildProviderStatusResponseBody } from "@trigger.dev/core/v3/schemas";
const DEPOT_STATUS_URL = "https://status.depot.dev/proxy/status.depot.dev";
const FETCH_TIMEOUT_MS = 2000;
export async function loader() {
return await fetchDepotStatus().match(
({ summary: { ongoing_incidents } }) => {
if (ongoing_incidents.length > 0) {
return json(
{
status: "degraded",
message:
"Our remote build provider is currently facing issues. You can use the `--force-local-build` flag to build and deploy the image locally. Read more about local builds here: https://trigger.dev/docs/deployment/overview#local-builds",
} satisfies RemoteBuildProviderStatusResponseBody,
{ status: 200 }
);
}
return json(
{
status: "operational",
message: "Depot is operational",
} satisfies RemoteBuildProviderStatusResponseBody,
{ status: 200 }
);
},
() => {
return json(
{
status: "unknown",
message: "Failed to fetch remote build provider status",
} satisfies RemoteBuildProviderStatusResponseBody,
{ status: 200 }
);
}
);
}
function fetchDepotStatus() {
return fromPromise(
fetch(DEPOT_STATUS_URL, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
}),
(error) => {
if (
error instanceof Error &&
(error.name === "TimeoutError" || error.name === "AbortError")
) {
return {
type: "timeout" as const,
};
}
return {
type: "other" as const,
cause: error,
};
}
)
.andThen((response) => {
if (!response.ok) {
return err({
type: "other" as const,
cause: new Error(`Failed to fetch Depot status: ${response.status}`),
});
}
return fromSafePromise(response.json());
})
.andThen((json) => {
const parsed = DepotStatusResponseSchema.safeParse(json);
if (!parsed.success) {
logger.warn("Invalid Depot status response", { error: parsed.error });
return err({
type: "validation_failed" as const,
});
}
return ok(parsed.data);
});
}
// partial schema
const DepotStatusResponseSchema = z.object({
summary: z.object({
ongoing_incidents: z.array(z.any()),
}),
});
@@ -2,8 +2,8 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
const ParamsSchema = z.object({
runId: z.string(), // This is the run friendly ID
@@ -30,9 +30,13 @@ export const loader = createLoaderApiRoute(
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ resource: run }) => {
async ({ resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const runEvents = await eventRepository.getRunEvents(
getTaskEventStoreTableForRun(run),
authentication.environment.id,
run.traceId,
run.friendlyId,
run.createdAt,
run.completedAt ?? undefined
@@ -3,7 +3,7 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
const ParamsSchema = z.object({
@@ -35,9 +35,12 @@ export const loader = createLoaderApiRoute(
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ resource: run }) => {
async ({ resource: run, authentication }) => {
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const traceSummary = await eventRepository.getTraceDetailedSummary(
getTaskEventStoreTableForRun(run),
authentication.environment.id,
run.traceId,
run.createdAt,
run.completedAt ?? undefined
@@ -20,7 +20,11 @@ export const loader = createLoaderApiRoute(
environmentId: authentication.environment.id,
dequeueIntervalWithRun: env.DEV_DEQUEUE_INTERVAL_WITH_RUN,
dequeueIntervalWithoutRun: env.DEV_DEQUEUE_INTERVAL_WITHOUT_RUN,
maxConcurrentRuns: env.DEV_MAX_CONCURRENT_RUNS,
// Limit max runs to smaller of an optional global limit and the environment limit
maxConcurrentRuns: Math.min(
env.DEV_MAX_CONCURRENT_RUNS ?? authentication.environment.maximumConcurrencyLimit,
authentication.environment.maximumConcurrencyLimit
),
engineUrl: env.DEV_ENGINE_URL,
});
} catch (error) {
@@ -1,79 +1,3 @@
import { TypedResponse } from "@remix-run/server-runtime";
import { assertExhaustive } from "@trigger.dev/core/utils";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import {
WorkerApiDebugLogBody,
WorkerApiRunAttemptStartResponseBody,
} from "@trigger.dev/core/v3/workers";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { recordRunDebugLog } from "~/v3/eventRepository.server";
// const { action } = createActionApiRoute(
// {
// params: z.object({
// runFriendlyId: z.string(),
// }),
// body: WorkerApiDebugLogBody,
// method: "POST",
// },
// async ({
// authentication,
// body,
// params,
// }): Promise<TypedResponse<WorkerApiRunAttemptStartResponseBody>> => {
// const { runFriendlyId } = params;
// try {
// const run = await prisma.taskRun.findFirst({
// where: {
// friendlyId: params.runFriendlyId,
// runtimeEnvironmentId: authentication.environment.id,
// },
// });
// if (!run) {
// throw new Response("You don't have permissions for this run", { status: 401 });
// }
// const eventResult = await recordRunDebugLog(
// RunId.fromFriendlyId(runFriendlyId),
// body.message,
// {
// attributes: {
// properties: body.properties,
// },
// startTime: body.time,
// }
// );
// if (eventResult.success) {
// return new Response(null, { status: 204 });
// }
// switch (eventResult.code) {
// case "FAILED_TO_RECORD_EVENT":
// return new Response(null, { status: 400 }); // send a 400 to prevent retries
// case "RUN_NOT_FOUND":
// return new Response(null, { status: 404 });
// default:
// return assertExhaustive(eventResult.code);
// }
// } catch (error) {
// logger.error("Failed to record dev log", {
// environmentId: authentication.environment.id,
// error,
// });
// throw error;
// }
// }
// );
// export { action };
// Create a generic JSON action in remix
export function action() {
return new Response(null, { status: 204 });
}
@@ -1,5 +1,5 @@
import { json, TypedResponse } from "@remix-run/server-runtime";
import { MachinePreset } from "@trigger.dev/core/v3";
import { MachinePreset, SemanticInternalAttributes } from "@trigger.dev/core/v3";
import { RunId, SnapshotId } from "@trigger.dev/core/v3/isomorphic";
import {
WorkerApiRunAttemptStartRequestBody,
@@ -57,7 +57,8 @@ const { action } = createActionApiRoute(
const envVars = await getEnvVars(
authentication.environment,
engineResult.run.id,
engineResult.execution.machine ?? defaultMachinePreset
engineResult.execution.machine ?? defaultMachinePreset,
engineResult.run.taskEventStore
);
return json({
@@ -77,7 +78,8 @@ const { action } = createActionApiRoute(
async function getEnvVars(
environment: RuntimeEnvironment,
runId: string,
machinePreset: MachinePreset
machinePreset: MachinePreset,
taskEventStore?: string
): Promise<Record<string, string>> {
const variables = await resolveVariablesForEnvironment(environment);
@@ -94,6 +96,19 @@ async function getEnvVars(
]
);
if (taskEventStore) {
const resourceAttributes = JSON.stringify({
[SemanticInternalAttributes.TASK_EVENT_STORE]: taskEventStore,
});
variables.push(
...[
{ key: "OTEL_RESOURCE_ATTRIBUTES", value: resourceAttributes },
{ key: "TRIGGER_OTEL_RESOURCE_ATTRIBUTES", value: resourceAttributes },
]
);
}
return variables.reduce((acc: Record<string, string>, curr) => {
acc[curr.key] = curr.value;
return acc;
@@ -3,7 +3,7 @@ import { RunId } from "@trigger.dev/core/v3/isomorphic";
import { WorkerApiDebugLogBody } from "@trigger.dev/core/v3/runEngineWorker";
import { z } from "zod";
import { createActionWorkerApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { recordRunDebugLog } from "~/v3/eventRepository.server";
import { recordRunDebugLog } from "~/v3/eventRepository/index.server";
export const action = createActionWorkerApiRoute(
{
+6 -6
View File
@@ -18,6 +18,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
);
}
if (!user) {
return redirectWithSuccessMessage("/", request, "Please log in to accept the invite.", {
ephemeral: false,
});
}
const invite = await getInviteFromToken({ token });
if (!invite) {
return redirectWithErrorMessage(
@@ -28,12 +34,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
);
}
if (!user) {
return redirectWithSuccessMessage("/", request, "Please log in to accept the invite.", {
ephemeral: false,
});
}
if (invite.email !== user.email) {
return redirectWithErrorMessage(
"/",
+2 -1
View File
@@ -1,5 +1,5 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { env } from "process";
import { z } from "zod";
import { resendInvite } from "~/models/member.server";
@@ -25,6 +25,7 @@ export const action: ActionFunction = async ({ request }) => {
try {
const invite = await resendInvite({
inviteId: submission.value.inviteId,
userId,
});
try {
+2 -2
View File
@@ -1,5 +1,5 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { revokeInvite } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -24,7 +24,7 @@ export const action: ActionFunction = async ({ request }) => {
try {
const { email, organization } = await revokeInvite({
userId,
slug: submission.value.slug,
orgSlug: submission.value.slug,
inviteId: submission.value.inviteId,
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ActionFunction, LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { type ActionFunction, type LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -36,7 +36,7 @@ const schema = z.object({
});
export const action: ActionFunction = async ({ request }) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const formData = await request.formData();
const submission = parse(formData, { schema });
@@ -49,7 +49,7 @@ export const action: ActionFunction = async ({ request }) => {
if (submission.intent === "accept") {
const { remainingInvites, organization } = await acceptInvite({
inviteId: submission.value.inviteId,
userId,
user: { id: user.id, email: user.email },
});
if (remainingInvites.length === 0) {
@@ -64,7 +64,7 @@ export const action: ActionFunction = async ({ request }) => {
} else if (submission.intent === "decline") {
const { remainingInvites, organization } = await declineInvite({
inviteId: submission.value.inviteId,
userId,
user: { id: user.id, email: user.email },
});
if (remainingInvites.length === 0) {
return redirectWithSuccessMessage(
+101 -19
View File
@@ -1,7 +1,11 @@
import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import {
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner";
import { TextLink } from "~/components/primitives/TextLink";
import { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import {
checkMagicLinkEmailRateLimit,
checkMagicLinkEmailDailyRateLimit,
MagicLinkRateLimitError,
checkMagicLinkIpRateLimit,
} from "~/services/magicLinkRateLimiter.server";
import { logger, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
@@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) {
const payload = Object.fromEntries(await clonedRequest.formData());
const { action } = z
.object({
action: z.enum(["send", "reset"]),
})
const data = z
.discriminatedUnion("action", [
z.object({
action: z.literal("send"),
email: z.string().trim().toLowerCase(),
}),
z.object({
action: z.literal("reset"),
}),
])
.parse(payload);
if (action === "send") {
return authenticator.authenticate("email-link", request, {
successRedirect: "/login/magic",
failureRedirect: "/login/magic",
});
} else {
const session = await getUserSession(request);
session.unset("triggerdotdev:magiclink");
switch (data.action) {
case "send": {
if (!env.LOGIN_RATE_LIMITS_ENABLED) {
return authenticator.authenticate("email-link", request, {
successRedirect: "/login/magic",
failureRedirect: "/login/magic",
});
}
return redirect("/login/magic", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const { email } = data;
const xff = request.headers.get("x-forwarded-for");
const clientIp = extractClientIp(xff);
const [error] = await tryCatch(
Promise.all([
clientIp ? checkMagicLinkIpRateLimit(clientIp) : Promise.resolve(),
checkMagicLinkEmailRateLimit(email),
checkMagicLinkEmailDailyRateLimit(email),
])
);
if (error) {
if (error instanceof MagicLinkRateLimitError) {
logger.warn("Login magic link rate limit exceeded", {
clientIp,
email,
error,
});
} else {
logger.error("Failed sending login magic link", {
clientIp,
email,
error,
});
}
const errorMessage =
error instanceof MagicLinkRateLimitError
? "Too many magic link requests. Please try again shortly."
: "Failed sending magic link. Please try again shortly.";
const session = await getUserSession(request);
session.set("auth:error", {
message: errorMessage,
});
return redirect("/login/magic", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
return authenticator.authenticate("email-link", request, {
successRedirect: "/login/magic",
failureRedirect: "/login/magic",
});
}
case "reset":
default: {
data.action satisfies "reset";
const session = await getUserSession(request);
session.unset("triggerdotdev:magiclink");
return redirect("/login/magic", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
}
}
const extractClientIp = (xff: string | null) => {
if (!xff) return null;
const parts = xff.split(",").map((p) => p.trim());
return parts[parts.length - 1]; // take last item, ALB appends the real client IP by default
};
export default function LoginMagicLinkPage() {
const { magicLinkSent, magicLinkError } = useTypedLoaderData<typeof loader>();
const navigate = useNavigation();
@@ -3,14 +3,14 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, v3DeploymentPath, v3RunPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3RunPath } from "~/utils/pathBuilder";
const ParamSchema = ProjectParamSchema.extend({
runParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireUserId(request);
const userId = await requireUserId(request);
const { organizationSlug, projectParam, runParam } = ParamSchema.parse(params);
const run = await prisma.taskRun.findFirst({
@@ -18,6 +18,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
friendlyId: runParam,
project: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
},
select: {
+4 -4
View File
@@ -4,15 +4,15 @@ import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
try {
const contentType = request.headers.get("content-type");
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType === "application/json") {
if (contentType.startsWith("application/json")) {
const body = await request.json();
const exportResponse = await otlpExporter.exportLogs(body as ExportLogsServiceRequest, false);
const exportResponse = await otlpExporter.exportLogs(body as ExportLogsServiceRequest);
return json(exportResponse, { status: 200 });
} else if (contentType === "application/x-protobuf") {
} else if (contentType.startsWith("application/x-protobuf")) {
const buffer = await request.arrayBuffer();
const exportRequest = ExportLogsServiceRequest.decode(new Uint8Array(buffer));
+4 -7
View File
@@ -4,18 +4,15 @@ import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
try {
const contentType = request.headers.get("content-type");
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (contentType === "application/json") {
if (contentType.startsWith("application/json")) {
const body = await request.json();
const exportResponse = await otlpExporter.exportTraces(
body as ExportTraceServiceRequest,
false
);
const exportResponse = await otlpExporter.exportTraces(body as ExportTraceServiceRequest);
return json(exportResponse, { status: 200 });
} else if (contentType === "application/x-protobuf") {
} else if (contentType.startsWith("application/x-protobuf")) {
const buffer = await request.arrayBuffer();
const exportRequest = ExportTraceServiceRequest.decode(new Uint8Array(buffer));
@@ -0,0 +1,133 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { errAsync, fromPromise, okAsync } from "neverthrow";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { DeploymentService } from "~/v3/services/deployment.server";
export const cancelSchema = z.object({
redirectUrl: z.string(),
});
const ParamSchema = z.object({
projectId: z.string(),
deploymentShortCode: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectId, deploymentShortCode } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: cancelSchema });
if (!submission.value) {
return json(submission);
}
const verifyProjectMembership = () =>
fromPromise(
prisma.project.findFirst({
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
},
}),
(error) => ({ type: "other" as const, cause: error })
).andThen((project) => {
if (!project) {
return errAsync({ type: "project_not_found" as const });
}
return okAsync(project);
});
const findDeploymentFriendlyId = ({ id }: { id: string }) =>
fromPromise(
prisma.workerDeployment.findUnique({
select: {
friendlyId: true,
projectId: true,
},
where: {
projectId_shortCode: {
projectId: id,
shortCode: deploymentShortCode,
},
},
}),
(error) => ({ type: "other" as const, cause: error })
).andThen((deployment) => {
if (!deployment) {
return errAsync({ type: "deployment_not_found" as const });
}
return okAsync(deployment);
});
const deploymentService = new DeploymentService();
const result = await verifyProjectMembership()
.andThen(findDeploymentFriendlyId)
.andThen((deployment) =>
deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId)
);
if (result.isErr()) {
logger.error(
`Failed to cancel deployment: ${result.error.type}`,
result.error.type === "other"
? {
cause: result.error.cause,
}
: undefined
);
switch (result.error.type) {
case "project_not_found":
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
case "deployment_not_found":
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment not found"
);
case "deployment_cannot_be_cancelled":
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment is already in a final state and cannot be canceled"
);
case "failed_to_delete_deployment_timeout":
// not a critical error, ignore
return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Canceled deployment ${deploymentShortCode}.`
);
case "other":
default:
result.error.type satisfies "other";
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Internal server error"
);
}
}
return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Canceled deployment ${deploymentShortCode}.`
);
};
@@ -0,0 +1,66 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { RunTagListPresenter } from "~/presenters/v3/RunTagListPresenter.server";
import { requireUserId } from "~/services/session.server";
const Params = z.object({
envId: z.string(),
});
const SearchParams = z.object({
name: z.string().optional(),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { envId } = Params.parse(params);
const environment = await $replica.runtimeEnvironment.findFirst({
select: {
id: true,
projectId: true,
organizationId: true,
},
where: { id: envId, organization: { members: { some: { userId } } } },
});
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const search = new URL(request.url).searchParams;
const parsedSearchParams = SearchParams.safeParse({
name: search.get("name") ?? undefined,
period: search.get("period") ?? undefined,
from: search.get("from") ?? undefined,
to: search.get("to") ?? undefined,
});
if (!parsedSearchParams.success) {
throw new Response("Invalid search params", { status: 400 });
}
const { period, from, to } = timeFilters({
period: parsedSearchParams.data.period,
from: parsedSearchParams.data.from,
to: parsedSearchParams.data.to,
});
const presenter = new RunTagListPresenter();
const result = await presenter.call({
environmentId: environment.id,
projectId: environment.projectId,
organizationId: environment.organizationId,
name: parsedSearchParams.data.name,
period,
from,
to,
});
return result;
}
@@ -1,64 +0,0 @@
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { createSSELoader } from "~/utils/sse";
export const loader = createSSELoader({
timeout: env.QUEUE_SSE_AUTORELOAD_TIMEOUT_MS,
interval: env.QUEUE_SSE_AUTORELOAD_INTERVAL_MS,
debug: false,
handler: async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectParam, envParam } = EnvironmentParamSchema.parse(params);
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
slug: envParam,
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
project: {
slug: projectParam,
},
},
});
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
return {
beforeStream: async () => {
logger.debug("Start queue page SSE session", {
environmentId: environment.id,
});
},
initStream: async ({ send }) => {
send({ event: "time", data: new Date().toISOString() });
},
iterator: async ({ send }) => {
send({
event: "update",
data: new Date().toISOString(),
});
},
cleanup: async () => {
logger.debug("End queue page SSE session", {
environmentId: environment.id,
});
},
};
},
});
@@ -14,6 +14,7 @@ import { assertNever } from "assert-never";
import { useEffect } from "react";
import { typedjson, useTypedFetcher } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { FlagIcon } from "~/assets/icons/RegionIcons";
import { AdminDebugRun } from "~/components/admin/debugRun";
import { CodeBlock } from "~/components/code/CodeBlock";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
@@ -22,6 +23,7 @@ import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { MachineTooltipInfo } from "~/components/MachineTooltipInfo";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -76,9 +78,11 @@ import {
} from "~/utils/pathBuilder";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route";
import { FlagIcon } from "~/assets/icons/RegionIcons";
import { requireUserId } from "~/services/session.server";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam, spanParam } =
v3SpanParamsSchema.parse(params);
@@ -89,6 +93,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
projectSlug: projectParam,
spanId: spanParam,
runFriendlyId: runParam,
userId,
});
return typedjson(result);
@@ -116,10 +121,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export function SpanView({
runParam,
spanId,
spanOverrides,
closePanel,
}: {
runParam: string;
spanId: string | undefined;
spanOverrides?: SpanOverride;
closePanel?: () => void;
}) {
const organization = useOrganization();
@@ -170,17 +177,26 @@ export function SpanView({
);
}
case "span": {
return <SpanBody span={fetcher.data.span} runParam={runParam} closePanel={closePanel} />;
return (
<SpanBody
span={fetcher.data.span}
spanOverrides={spanOverrides}
runParam={runParam}
closePanel={closePanel}
/>
);
}
}
}
function SpanBody({
span,
spanOverrides,
runParam,
closePanel,
}: {
span: Span;
spanOverrides?: SpanOverride;
runParam?: string;
closePanel?: () => void;
}) {
@@ -194,6 +210,8 @@ function SpanBody({
tab = "overview";
}
span = applySpanOverrides(span, spanOverrides);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
@@ -228,88 +246,47 @@ function SpanBody({
>
Overview
</TabButton>
<TabButton
isActive={tab === "detail"}
layoutId="span-span"
onClick={() => {
replace({ tab: "detail" });
}}
shortcut={{ key: "d" }}
>
Detail
</TabButton>
</TabContainer>
</div>
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
{tab === "detail" ? (
<div className="flex flex-col gap-4 px-3 pt-3">
<Property.Table>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<TaskRunAttemptStatusCombo
status={
span.isCancelled
? "CANCELED"
: span.isError
? "FAILED"
: span.isPartial
? "EXECUTING"
: "COMPLETED"
}
className="text-sm"
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunsPath(organization, project, environment, {
tasks: [span.taskSlug],
})}
>
{span.taskSlug}
</TextLink>
}
content={`Filter runs by ${span.taskSlug}`}
/>
</Property.Value>
</Property.Item>
{span.idempotencyKey && (
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>{span.idempotencyKey}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{span.workerVersion ? (
span.workerVersion
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
) : (
<SpanEntity span={span} />
)}
<SpanEntity span={span} />
</div>
</div>
);
}
function applySpanOverrides(span: Span, spanOverrides?: SpanOverride): Span {
if (!spanOverrides) {
return span;
}
const newSpan = { ...span };
if (spanOverrides.isCancelled) {
newSpan.isCancelled = true;
newSpan.isPartial = false;
newSpan.isError = false;
} else if (spanOverrides.isError) {
newSpan.isError = true;
newSpan.isPartial = false;
newSpan.isCancelled = false;
}
if (typeof spanOverrides.duration !== "undefined") {
newSpan.duration = spanOverrides.duration;
}
if (spanOverrides.events) {
if (newSpan.events) {
newSpan.events = [...newSpan.events, ...spanOverrides.events];
} else {
newSpan.events = spanOverrides.events;
}
}
return newSpan;
}
function RunBody({
run,
runParam,
@@ -409,6 +386,7 @@ function RunBody({
<SimpleTooltip
button={<TaskRunStatusCombo status={run.status} />}
content={descriptionForTaskRunStatus(run.status)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
@@ -422,82 +400,129 @@ function RunBody({
tasks: [run.taskIdentifier],
})}
>
{run.taskIdentifier}
<CopyableText
value={run.taskIdentifier}
copyValue={run.taskIdentifier}
asChild
/>
</TextLink>
}
content={`Filter runs by ${run.taskIdentifier}`}
content={`View runs filtered by ${run.taskIdentifier}`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
{run.relationships.root ? (
run.relationships.root.isParent ? (
<Property.Item>
<Property.Label>Root & Parent</Property.Label>
<Property.Label>Root & Parent run</Property.Label>
<Property.Value>
<TextLink
to={v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: run.relationships.root.friendlyId,
},
{ spanId: run.relationships.root.spanId }
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
{run.relationships.root.taskIdentifier}
<span className="break-all text-text-dimmed transition-colors group-hover:text-text-bright/80">
({run.relationships.root.friendlyId})
</span>
</TextLink>
</Property.Value>
</Property.Item>
) : (
<>
<Property.Item>
<Property.Label>Root</Property.Label>
<Property.Value>
<TextLink
to={v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: run.relationships.root.friendlyId,
},
{ spanId: run.relationships.root.spanId }
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
{run.relationships.root.taskIdentifier}
<span className="break-all text-text-dimmed transition-colors group-hover:text-text-bright/80">
({run.relationships.root.friendlyId})
</span>
</TextLink>
</Property.Value>
</Property.Item>
{run.relationships.parent ? (
<Property.Item>
<Property.Label>Parent</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: run.relationships.parent.friendlyId,
friendlyId: run.relationships.root.friendlyId,
},
{ spanId: run.relationships.parent.spanId }
{ spanId: run.relationships.root.spanId }
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
{run.relationships.parent.taskIdentifier}
<CopyableText
value={run.relationships.root.taskIdentifier}
copyValue={run.relationships.root.taskIdentifier}
asChild
/>
<span className="break-all text-text-dimmed transition-colors group-hover:text-text-bright/80">
({run.relationships.parent.friendlyId})
<CopyableText
value={run.relationships.root.friendlyId}
copyValue={run.relationships.root.friendlyId}
asChild
/>
</span>
</TextLink>
}
content={`Jump to root/parent run`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
) : (
<>
<Property.Item>
<Property.Label>Root run</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: run.relationships.root.friendlyId,
},
{ spanId: run.relationships.root.spanId }
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText
value={run.relationships.root.taskIdentifier}
copyValue={run.relationships.root.taskIdentifier}
asChild
/>
<span className="break-all text-text-dimmed transition-colors group-hover:text-text-bright/80">
<CopyableText
value={run.relationships.root.friendlyId}
copyValue={run.relationships.root.friendlyId}
asChild
/>
</span>
</TextLink>
}
content={`Jump to root run`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
{run.relationships.parent ? (
<Property.Item>
<Property.Label>Parent run</Property.Label>
<Property.Value>
<SimpleTooltip
button={
<TextLink
to={v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: run.relationships.parent.friendlyId,
},
{ spanId: run.relationships.parent.spanId }
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText
value={run.relationships.parent.taskIdentifier}
copyValue={run.relationships.parent.taskIdentifier}
asChild
/>
<span className="break-all text-text-dimmed transition-colors group-hover:text-text-bright/80">
<CopyableText
value={run.relationships.parent.friendlyId}
copyValue={run.relationships.parent.friendlyId}
asChild
/>
</span>
</TextLink>
}
content={`Jump to parent run`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
) : null}
@@ -511,10 +536,15 @@ function RunBody({
<SimpleTooltip
button={
<TextLink to={v3BatchPath(organization, project, environment, run.batch)}>
{run.batch.friendlyId}
<CopyableText
value={run.batch.friendlyId}
copyValue={run.batch.friendlyId}
asChild
/>
</TextLink>
}
content={`Jump to ${run.batch.friendlyId}`}
content={`View batches filtered by ${run.batch.friendlyId}`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
@@ -540,7 +570,7 @@ function RunBody({
<Property.Value>
{run.version ? (
environment.type === "DEVELOPMENT" ? (
run.version
<CopyableText value={run.version} copyValue={run.version} asChild />
) : (
<SimpleTooltip
button={
@@ -553,7 +583,7 @@ function RunBody({
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
{run.version}
<CopyableText value={run.version} copyValue={run.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
@@ -606,13 +636,23 @@ function RunBody({
<Property.Item>
<Property.Label>Replayed from</Property.Label>
<Property.Value>
<TextLink
to={v3RunRedirectPath(organization, project, {
friendlyId: run.replayedFromTaskRunFriendlyId,
})}
>
{run.replayedFromTaskRunFriendlyId}
</TextLink>
<SimpleTooltip
button={
<TextLink
to={v3RunRedirectPath(organization, project, {
friendlyId: run.replayedFromTaskRunFriendlyId,
})}
>
<CopyableText
value={run.replayedFromTaskRunFriendlyId}
copyValue={run.replayedFromTaskRunFriendlyId}
asChild
/>
</TextLink>
}
content={`Jump to replayed run`}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
)}
@@ -747,11 +787,15 @@ function RunBody({
</Property.Item>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>{run.friendlyId}</Property.Value>
<Property.Value>
<CopyableText value={run.friendlyId} copyValue={run.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Internal ID</Property.Label>
<Property.Value>{run.id}</Property.Value>
<Property.Value>
<CopyableText value={run.id} copyValue={run.id} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run Engine</Property.Label>
@@ -772,6 +816,14 @@ function RunBody({
<Property.Label>Worker queue</Property.Label>
<Property.Value>{run.workerQueue}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Trace ID</Property.Label>
<Property.Value>{run.traceId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Span ID</Property.Label>
<Property.Value>{run.spanId}</Property.Value>
</Property.Item>
</div>
)}
</Property.Table>
@@ -1002,7 +1054,7 @@ function SpanEntity({ span }: { span: Span }) {
{run.taskIdentifier}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
{run.lockedToVersion?.version ?? ""}
{run.taskVersion ?? ""}
</TableCell>
<TableCell to={path} actionClassName="py-1.5" rowHoverStyle="bright">
<DateTime date={run.createdAt} />
@@ -65,13 +65,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
query: async (search) => {
const tagPresenter = new RunTagListPresenter();
const tags = await tagPresenter.call({
organizationId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
name: search,
page: 1,
pageSize: 50,
period: "30d",
});
return {
tags: tags.tags.map((t) => t.name),
tags: tags.tags,
};
},
};
@@ -1,103 +0,0 @@
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { createSSELoader, type SendFunction } from "~/utils/sse";
const Params = EnvironmentParamSchema.extend({
bulkActionParam: z.string(),
});
export const loader = createSSELoader({
timeout: env.DEV_PRESENCE_SSE_TIMEOUT,
interval: env.DEV_PRESENCE_POLL_MS,
debug: false,
handler: async ({ id, controller, debug, request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, bulkActionParam } = Params.parse(params);
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: envParam,
project: {
slug: projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const getBulkActionProgress = async (send: SendFunction) => {
try {
const bulkAction = await $replica.bulkActionGroup.findFirst({
select: {
status: true,
successCount: true,
failureCount: true,
},
where: {
friendlyId: bulkActionParam,
environmentId: environment.id,
},
});
send({
event: "progress",
data: JSON.stringify({
status: bulkAction?.status,
successCount: bulkAction?.successCount,
failureCount: bulkAction?.failureCount,
}),
});
return bulkAction;
} catch (error) {
// Handle the case where the controller is closed
logger.debug("Failed to send bulk action progress data, stream might be closed", { error });
return null;
}
};
return {
beforeStream: async () => {
logger.debug("Start dev presence listening SSE session", {
environmentId: environment.id,
});
},
initStream: async ({ send }) => {
const bulkAction = await getBulkActionProgress(send);
send({ event: "time", data: new Date().toISOString() });
if (bulkAction?.status !== "PENDING") {
return false;
}
return true;
},
iterator: async ({ send, date }) => {
const bulkAction = await getBulkActionProgress(send);
if (bulkAction?.status !== "PENDING") {
return false;
}
return true;
},
cleanup: async ({ send }) => {
await getBulkActionProgress(send);
},
};
},
});
@@ -7,16 +7,16 @@ import {
} from "@heroicons/react/20/solid";
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { uiComponent } from "@team-plain/typescript-sdk";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import {
FreePlanDefinition,
Limits,
PaidPlanDefinition,
Plans,
SetPlanBody,
SubscriptionResult,
type FreePlanDefinition,
type Limits,
type PaidPlanDefinition,
type Plans,
type SetPlanBody,
type SubscriptionResult,
} from "@trigger.dev/platform";
import React, { useEffect, useState } from "react";
import { z } from "zod";
@@ -75,8 +75,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
message: message || undefined,
});
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId: user.id } } },
});
if (!organization) {
@@ -1,32 +0,0 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { RunTagListPresenter } from "~/presenters/v3/RunTagListPresenter.server";
import { requireUserId } from "~/services/session.server";
const Params = z.object({
projectParam: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { projectParam } = Params.parse(params);
const project = await $replica.project.findFirst({
where: { slug: projectParam, deletedAt: null, organization: { members: { some: { userId } } } },
});
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const search = new URL(request.url).searchParams;
const name = search.get("name");
const presenter = new RunTagListPresenter();
const result = await presenter.call({
projectId: project.id,
name: name ? decodeURIComponent(name) : undefined,
});
return result;
}
@@ -2,13 +2,13 @@ import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";
import { RunPreparedEvent, eventRepository } from "~/v3/eventRepository.server";
import type { RunPreparedEvent } from "~/v3/eventRepository/eventRepository.types";
import { createGzip } from "zlib";
import { Readable } from "stream";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations";
import { getDateFromNanoseconds } from "~/utils/taskEvent";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { TaskEventKind } from "@trigger.dev/database";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
@@ -33,8 +33,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return new Response("Not found", { status: 404 });
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
const runEvents = await eventRepository.getRunEvents(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironmentId,
run.traceId,
run.friendlyId,
run.createdAt,
run.completedAt ?? undefined
@@ -117,3 +121,7 @@ function formatRunEvent(event: RunPreparedEvent): string {
return entries.join("\n");
}
function getDateFromNanoseconds(nanoseconds: bigint) {
return new Date(Number(nanoseconds) / 1_000_000);
}
@@ -1,9 +1,10 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
export const cancelSchema = z.object({
@@ -15,6 +16,7 @@ const ParamSchema = z.object({
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { runParam } = ParamSchema.parse(params);
const formData = await request.formData();
@@ -25,9 +27,18 @@ export const action: ActionFunction = async ({ request, params }) => {
}
try {
const taskRun = await prisma.taskRun.findUnique({
const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
@@ -1,4 +1,4 @@
import { LoaderFunctionArgs } from "@remix-run/node";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { $replica } from "~/db.server";
@@ -17,7 +17,10 @@ export class IdempotencyKeyConcern {
private readonly traceEventConcern: TraceEventConcern
) {}
async handleTriggerRequest(request: TriggerTaskRequest): Promise<IdempotencyKeyConcernResult> {
async handleTriggerRequest(
request: TriggerTaskRequest,
parentStore: string | undefined
): Promise<IdempotencyKeyConcernResult> {
const idempotencyKey = request.options?.idempotencyKey ?? request.body.options?.idempotencyKey;
const idempotencyKeyExpiresAt =
request.options?.idempotencyKeyExpiresAt ??
@@ -83,6 +86,7 @@ export class IdempotencyKeyConcern {
if (associatedWaitpoint && resumeParentOnCompletion && parentRunId) {
await this.traceEventConcern.traceIdempotentRun(
request,
parentStore,
{
existingRun,
idempotencyKey,
@@ -90,11 +94,18 @@ export class IdempotencyKeyConcern {
isError: associatedWaitpoint.outputIsError,
},
async (event) => {
const spanId =
request.options?.parentAsLinkType === "replay"
? event.spanId
: event.traceparent?.spanId
? `${event.traceparent.spanId}:${event.spanId}`
: event.spanId;
//block run with waitpoint
await this.engine.blockRunWithWaitpoint({
runId: RunId.fromFriendlyId(parentRunId),
waitpoints: associatedWaitpoint.id,
spanIdToComplete: event.spanId,
spanIdToComplete: spanId,
batch: request.options?.batchId
? {
id: request.options.batchId,
@@ -1,41 +1,40 @@
import { EventRepository } from "~/v3/eventRepository.server";
import { TracedEventSpan, TraceEventConcern, TriggerTaskRequest } from "../types";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3/semanticInternalAttributes";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { TaskRun } from "@trigger.dev/database";
import { IEventRepository } from "~/v3/eventRepository/eventRepository.types";
import { getEventRepository } from "~/v3/eventRepository/index.server";
import { TracedEventSpan, TraceEventConcern, TriggerTaskRequest } from "../types";
export class DefaultTraceEventsConcern implements TraceEventConcern {
private readonly eventRepository: EventRepository;
constructor(eventRepository: EventRepository) {
this.eventRepository = eventRepository;
async #getEventRepository(
request: TriggerTaskRequest,
parentStore: string | undefined
): Promise<{ repository: IEventRepository; store: string }> {
return await getEventRepository(
request.environment.organization.featureFlags as Record<string, unknown>,
parentStore
);
}
async traceRun<T>(
request: TriggerTaskRequest,
callback: (span: TracedEventSpan) => Promise<T>
parentStore: string | undefined,
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
return await this.eventRepository.traceEvent(
const { repository, store } = await this.#getEventRepository(request, parentStore);
return await repository.traceEvent(
request.taskId,
{
context: request.options?.traceContext,
spanParentAsLink: request.options?.spanParentAsLink,
parentAsLinkType: request.options?.parentAsLinkType,
kind: "SERVER",
environment: request.environment,
taskSlug: request.taskId,
attributes: {
properties: {
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
},
properties: {},
style: {
icon: request.options?.customIcon ?? "task",
},
runIsTest: request.body.options?.test ?? false,
batchId: request.options?.batchId
? BatchId.toFriendlyId(request.options.batchId)
: undefined,
idempotencyKey: request.options?.idempotencyKey,
},
incomplete: true,
immediate: true,
@@ -44,52 +43,50 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
: undefined,
},
async (event, traceContext, traceparent) => {
return await callback({
traceId: event.traceId,
spanId: event.spanId,
traceContext,
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
});
return await callback(
{
traceId: event.traceId,
spanId: event.spanId,
traceContext,
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
},
store
);
}
);
}
async traceIdempotentRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
idempotencyKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan) => Promise<T>
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T> {
const { existingRun, idempotencyKey, incomplete, isError } = options;
const { repository, store } = await this.#getEventRepository(request, parentStore);
return await this.eventRepository.traceEvent(
return await repository.traceEvent(
`${request.taskId} (cached)`,
{
context: request.options?.traceContext,
spanParentAsLink: request.options?.spanParentAsLink,
parentAsLinkType: request.options?.parentAsLinkType,
kind: "SERVER",
environment: request.environment,
taskSlug: request.taskId,
attributes: {
properties: {
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
},
style: {
icon: "task-cached",
},
runIsTest: request.body.options?.test ?? false,
batchId: request.options?.batchId
? BatchId.toFriendlyId(request.options.batchId)
: undefined,
idempotencyKey,
runId: existingRun.friendlyId,
},
incomplete,
@@ -98,7 +95,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
},
async (event, traceContext, traceparent) => {
//log a message
await this.eventRepository.recordEvent(
await repository.recordEvent(
`There's an existing run for idempotencyKey: ${idempotencyKey}`,
{
taskSlug: request.taskId,
@@ -111,14 +108,17 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
}
);
return await callback({
traceId: event.traceId,
spanId: event.spanId,
traceContext,
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
});
return await callback(
{
traceId: event.traceId,
spanId: event.spanId,
traceContext,
traceparent,
setAttribute: (key, value) => event.setAttribute(key as any, value),
failWithError: event.failWithError.bind(event),
},
store
);
}
);
}
@@ -29,7 +29,6 @@ import type {
TriggerTaskServiceOptions,
TriggerTaskServiceResult,
} from "../../v3/services/triggerTask.server";
import { getTaskEventStore } from "../../v3/taskEventStore.server";
import { clampMaxDuration } from "../../v3/utils/maxDuration";
import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
import type {
@@ -198,7 +197,8 @@ export class RunEngineTriggerTaskService {
}
const idempotencyKeyConcernResult = await this.idempotencyKeyConcern.handleTriggerRequest(
triggerRequest
triggerRequest,
parentRun?.taskEventStore
);
if (idempotencyKeyConcernResult.isCached) {
@@ -267,105 +267,109 @@ export class RunEngineTriggerTaskService {
const workerQueue = await this.queueConcern.getWorkerQueue(environment, body.options?.region);
try {
return await this.traceEventConcern.traceRun(triggerRequest, async (event) => {
const result = await this.runNumberIncrementer.incrementRunNumber(
triggerRequest,
async (num) => {
event.setAttribute("queueName", queueName);
span.setAttribute("queueName", queueName);
event.setAttribute("runId", runFriendlyId);
span.setAttribute("runId", runFriendlyId);
return await this.traceEventConcern.traceRun(
triggerRequest,
parentRun?.taskEventStore,
async (event, store) => {
const result = await this.runNumberIncrementer.incrementRunNumber(
triggerRequest,
async (num) => {
event.setAttribute("queueName", queueName);
span.setAttribute("queueName", queueName);
event.setAttribute("runId", runFriendlyId);
span.setAttribute("runId", runFriendlyId);
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
const taskRun = await this.engine.trigger(
{
number: num,
friendlyId: runFriendlyId,
environment: environment,
idempotencyKey,
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
taskIdentifier: taskId,
payload: payloadPacket.data ?? "",
payloadType: payloadPacket.dataType,
context: body.context,
traceContext: this.#propagateExternalTraceContext(
event.traceContext,
parentRun?.traceContext,
event.traceparent?.spanId
),
traceId: event.traceId,
spanId: event.spanId,
parentSpanId:
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
lockedToVersionId: lockedToBackgroundWorker?.id,
taskVersion: lockedToBackgroundWorker?.version,
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
cliVersion: lockedToBackgroundWorker?.cliVersion,
concurrencyKey: body.options?.concurrencyKey,
queue: queueName,
lockedQueueId,
workerQueue,
isTest: body.options?.test ?? false,
delayUntil,
queuedAt: delayUntil ? undefined : new Date(),
maxAttempts: body.options?.maxAttempts,
taskEventStore: getTaskEventStore(),
ttl,
tags,
oneTimeUseToken: options.oneTimeUseToken,
parentTaskRunId: parentRun?.id,
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
id: options.batchId,
index: options.batchIndex ?? 0,
}
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
metadata: metadataPacket?.data,
metadataType: metadataPacket?.dataType,
seedMetadata: metadataPacket?.data,
seedMetadataType: metadataPacket?.dataType,
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
machine: body.options?.machine,
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
queueTimestamp:
options.queueTimestamp ??
(parentRun && body.options?.resumeParentOnCompletion
? parentRun.queueTimestamp ?? undefined
: undefined),
scheduleId: options.scheduleId,
scheduleInstanceId: options.scheduleInstanceId,
createdAt: options.overrideCreatedAt,
bulkActionId: body.options?.bulkActionId,
planType,
},
this.prisma
);
const taskRun = await this.engine.trigger(
{
number: num,
friendlyId: runFriendlyId,
environment: environment,
idempotencyKey,
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
taskIdentifier: taskId,
payload: payloadPacket.data ?? "",
payloadType: payloadPacket.dataType,
context: body.context,
traceContext: this.#propagateExternalTraceContext(
event.traceContext,
parentRun?.traceContext,
event.traceparent?.spanId
),
traceId: event.traceId,
spanId: event.spanId,
parentSpanId:
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
lockedToVersionId: lockedToBackgroundWorker?.id,
taskVersion: lockedToBackgroundWorker?.version,
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
cliVersion: lockedToBackgroundWorker?.cliVersion,
concurrencyKey: body.options?.concurrencyKey,
queue: queueName,
lockedQueueId,
workerQueue,
isTest: body.options?.test ?? false,
delayUntil,
queuedAt: delayUntil ? undefined : new Date(),
maxAttempts: body.options?.maxAttempts,
taskEventStore: store,
ttl,
tags,
oneTimeUseToken: options.oneTimeUseToken,
parentTaskRunId: parentRun?.id,
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
id: options.batchId,
index: options.batchIndex ?? 0,
}
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
metadata: metadataPacket?.data,
metadataType: metadataPacket?.dataType,
seedMetadata: metadataPacket?.data,
seedMetadataType: metadataPacket?.dataType,
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
machine: body.options?.machine,
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
queueTimestamp:
options.queueTimestamp ??
(parentRun && body.options?.resumeParentOnCompletion
? parentRun.queueTimestamp ?? undefined
: undefined),
scheduleId: options.scheduleId,
scheduleInstanceId: options.scheduleInstanceId,
createdAt: options.overrideCreatedAt,
bulkActionId: body.options?.bulkActionId,
planType,
},
this.prisma
);
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
if (error) {
event.failWithError(error);
if (error) {
event.failWithError(error);
}
return { run: taskRun, error, isCached: false };
}
return { run: taskRun, error, isCached: false };
}
);
if (result?.error) {
throw new ServiceValidationError(
taskRunErrorToString(taskRunErrorEnhancer(result.error))
);
}
return result;
});
if (result?.error) {
throw new ServiceValidationError(
taskRunErrorToString(taskRunErrorEnhancer(result.error))
);
}
return result;
}
);
} catch (error) {
if (error instanceof RunDuplicateIdempotencyKeyError) {
//retry calling this function, because this time it will return the idempotent run
+4 -2
View File
@@ -143,17 +143,19 @@ export type TracedEventSpan = {
export interface TraceEventConcern {
traceRun<T>(
request: TriggerTaskRequest,
callback: (span: TracedEventSpan) => Promise<T>
parentStore: string | undefined,
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T>;
traceIdempotentRun<T>(
request: TriggerTaskRequest,
parentStore: string | undefined,
options: {
existingRun: TaskRun;
idempotencyKey: string;
incomplete: boolean;
isError: boolean;
},
callback: (span: TracedEventSpan) => Promise<T>
callback: (span: TracedEventSpan, store: string) => Promise<T>
): Promise<T>;
}
@@ -22,6 +22,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
maxItems: 1000,
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
@@ -2,14 +2,13 @@ import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } f
import { MemoryStore } from "@unkey/cache/stores";
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { z } from "zod";
import { env } from "~/env.server";
import { RedisWithClusterOptions } from "~/redis.server";
import { logger } from "./logger.server";
import { createRedisRateLimitClient, Duration, RateLimiter } from "./rateLimiter.server";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { RedisWithClusterOptions } from "~/redis.server";
const DurationSchema = z.custom<Duration>((value) => {
if (typeof value !== "string") {
@@ -64,6 +63,7 @@ type Options = {
limiterCache?: {
fresh: number;
stale: number;
maxItems: number;
};
log?: {
requests?: boolean;
@@ -145,7 +145,10 @@ export function authorizationRateLimitMiddleware({
limiterConfigOverride,
}: Options) {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: { frequency: 0.001, maxItems: limiterCache?.maxItems ?? 1000 },
});
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
@@ -1,5 +1,4 @@
import { env } from "~/env.server";
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import { Duration } from "./rateLimiter.server";
@@ -22,6 +21,7 @@ export const engineRateLimiter = authorizationRateLimitMiddleware({
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
maxItems: 1000,
},
pathMatchers: [/^\/engine/],
// Regex allow any path starting with /engine/v1/worker-actions/
@@ -0,0 +1,96 @@
import { Ratelimit } from "@upstash/ratelimit";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
export class MagicLinkRateLimitError extends Error {
public readonly retryAfter: number;
constructor(retryAfter: number) {
super("Magic link request rate limit exceeded.");
this.retryAfter = retryAfter;
}
}
function getRedisClient() {
return createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
}
const magicLinkEmailRateLimiter = singleton(
"magicLinkEmailRateLimiter",
initializeMagicLinkEmailRateLimiter
);
function initializeMagicLinkEmailRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:email",
limiter: Ratelimit.slidingWindow(3, "1 m"), // 3 requests per minute per email
logSuccess: false,
logFailure: true,
});
}
const magicLinkEmailDailyRateLimiter = singleton(
"magicLinkEmailDailyRateLimiter",
initializeMagicLinkEmailDailyRateLimiter
);
function initializeMagicLinkEmailDailyRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:email:daily",
limiter: Ratelimit.slidingWindow(30, "1 d"), // 30 requests per day per email
logSuccess: false,
logFailure: true,
});
}
const magicLinkIpRateLimiter = singleton(
"magicLinkIpRateLimiter",
initializeMagicLinkIpRateLimiter
);
function initializeMagicLinkIpRateLimiter() {
return new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:magiclink:ip",
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute per IP
logSuccess: false,
logFailure: true,
});
}
export async function checkMagicLinkEmailRateLimit(identifier: string): Promise<void> {
const result = await magicLinkEmailRateLimiter.limit(identifier);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
export async function checkMagicLinkEmailDailyRateLimit(identifier: string): Promise<void> {
const result = await magicLinkEmailDailyRateLimiter.limit(identifier);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
export async function checkMagicLinkIpRateLimit(ip: string): Promise<void> {
const result = await magicLinkIpRateLimiter.limit(ip);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new MagicLinkRateLimitError(retryAfter);
}
}
@@ -79,15 +79,20 @@ export async function getPersonalAccessTokenFromAuthorizationCode(authorizationC
};
}
export async function revokePersonalAccessToken(tokenId: string) {
await prisma.personalAccessToken.update({
export async function revokePersonalAccessToken(tokenId: string, userId: string) {
const result = await prisma.personalAccessToken.updateMany({
where: {
id: tokenId,
userId,
},
data: {
revokedAt: new Date(),
},
});
if (result.count === 0) {
throw new Error("PAT not found or already revoked");
}
}
export type PersonalAccessTokenAuthenticationResult = {
+39 -15
View File
@@ -44,7 +44,13 @@ const client = singleton("billingClient", initializeClient);
function initializePlatformCache() {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: {
frequency: 0.01,
maxItems: 1000,
},
});
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:platform:v3",
@@ -199,7 +205,7 @@ export async function getCurrentPlan(orgId: string) {
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
if (!result.success) {
logger.error("Error getting current plan", { orgId, error: result.error });
logger.error("Error getting current plan - no success", { orgId, error: result.error });
return undefined;
}
@@ -215,7 +221,7 @@ export async function getCurrentPlan(orgId: string) {
return { ...result, usage };
} catch (e) {
logger.error("Error getting current plan", { orgId, error: e });
logger.error("Error getting current plan - caught error", { orgId, error: e });
return undefined;
}
}
@@ -226,13 +232,13 @@ export async function getLimits(orgId: string) {
try {
const result = await client.currentPlan(orgId);
if (!result.success) {
logger.error("Error getting limits", { orgId, error: result.error });
logger.error("Error getting limits - no success", { orgId, error: result.error });
return undefined;
}
return result.v3Subscription?.plan?.limits;
} catch (e) {
logger.error("Error getting limits", { orgId, error: e });
logger.error("Error getting limits - caught error", { orgId, error: e });
return undefined;
}
}
@@ -273,12 +279,12 @@ export async function getPlans() {
try {
const result = await client.plans();
if (!result.success) {
logger.error("Error getting plans", { error: result.error });
logger.error("Error getting plans - no success", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting plans", { error: e });
logger.error("Error getting plans - caught error", { error: e });
return undefined;
}
}
@@ -356,12 +362,12 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat
try {
const result = await client.usage(organizationId, { from, to });
if (!result.success) {
logger.error("Error getting usage", { error: result.error });
logger.error("Error getting usage - no success", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting usage", { error: e });
logger.error("Error getting usage - caught error", { error: e });
return undefined;
}
}
@@ -390,12 +396,12 @@ export async function getUsageSeries(organizationId: string, params: UsageSeries
try {
const result = await client.usageSeries(organizationId, params);
if (!result.success) {
logger.error("Error getting usage series", { error: result.error });
logger.error("Error getting usage series - no success", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting usage series", { error: e });
logger.error("Error getting usage series - caught error", { error: e });
return undefined;
}
}
@@ -414,12 +420,12 @@ export async function reportInvocationUsage(
additionalData,
});
if (!result.success) {
logger.error("Error reporting invocation", { error: result.error });
logger.error("Error reporting invocation - no success", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error reporting invocation", { error: e });
logger.error("Error reporting invocation - caught error", { error: e });
return undefined;
}
}
@@ -442,14 +448,14 @@ export async function getEntitlement(
try {
const result = await client.getEntitlement(organizationId);
if (!result.success) {
logger.error("Error getting entitlement", { error: result.error });
logger.error("Error getting entitlement - no success", { error: result.error });
return {
hasAccess: true as const,
};
}
return result;
} catch (e) {
logger.error("Error getting entitlement", { error: e });
logger.error("Error getting entitlement - caught error", { error: e });
return {
hasAccess: true as const,
};
@@ -505,6 +511,24 @@ export async function setBillingAlert(
return result;
}
export async function generateRegistryCredentials(
projectId: string,
region: "us-east-1" | "eu-central-1"
) {
if (!client) return undefined;
const result = await client.generateRegistryCredentials(projectId, region);
if (!result.success) {
logger.error("Error generating registry credentials", {
error: result.error,
projectId,
region,
});
throw new Error("Failed to generate registry credentials");
}
return result;
}
function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
@@ -4,7 +4,7 @@ import { DeleteProjectService } from "~/services/deleteProject.server";
import { BranchTrackingConfigSchema, type BranchTrackingConfig } from "~/v3/github";
import { checkGitHubBranchExists } from "~/services/gitHub.server";
import { errAsync, fromPromise, okAsync, ResultAsync } from "neverthrow";
import { BuildSettings } from "~/v3/buildSettings";
import { type BuildSettings } from "~/v3/buildSettings";
export class ProjectSettingsService {
#prismaClient: PrismaClient;
@@ -82,7 +82,7 @@ export class ProjectSettingsService {
(error) => ({ type: "other" as const, cause: error })
);
const createConnectedRepo = (defaultBranch: string) =>
const createConnectedRepo = (defaultBranch: string, previewDeploymentsEnabled: boolean) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.create({
data: {
@@ -90,23 +90,25 @@ export class ProjectSettingsService {
repositoryId: repositoryId,
branchTracking: {
prod: { branch: defaultBranch },
staging: { branch: defaultBranch },
staging: {},
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled: true,
previewDeploymentsEnabled,
},
}),
(error) => ({ type: "other" as const, cause: error })
);
return ResultAsync.combine([getRepository(), findExistingConnection()]).andThen(
([repository, existingConnection]) => {
if (existingConnection) {
return errAsync({ type: "project_already_has_connected_repository" as const });
}
return createConnectedRepo(repository.defaultBranch);
return ResultAsync.combine([
getRepository(),
findExistingConnection(),
this.isPreviewEnvironmentEnabled(projectId),
]).andThen(([repository, existingConnection, previewEnvironmentEnabled]) => {
if (existingConnection) {
return errAsync({ type: "project_already_has_connected_repository" as const });
}
);
return createConnectedRepo(repository.defaultBranch, previewEnvironmentEnabled);
});
}
disconnectGitHubRepo(projectId: string) {
@@ -208,7 +210,11 @@ export class ProjectSettingsService {
return okAsync(stagingBranch);
};
const updateConnectedRepo = () =>
const updateConnectedRepo = (data: {
productionBranch: string | undefined;
stagingBranch: string | undefined;
previewDeploymentsEnabled: boolean | undefined;
}) =>
fromPromise(
this.#prismaClient.connectedGithubRepository.update({
where: {
@@ -216,10 +222,10 @@ export class ProjectSettingsService {
},
data: {
branchTracking: {
prod: productionBranch ? { branch: productionBranch } : {},
staging: stagingBranch ? { branch: stagingBranch } : {},
prod: data.productionBranch ? { branch: data.productionBranch } : {},
staging: data.stagingBranch ? { branch: data.stagingBranch } : {},
} satisfies BranchTrackingConfig,
previewDeploymentsEnabled: previewDeploymentsEnabled,
previewDeploymentsEnabled: data.previewDeploymentsEnabled,
},
}),
(error) => ({ type: "other" as const, cause: error })
@@ -240,8 +246,14 @@ export class ProjectSettingsService {
fullRepoName: connectedRepo.repository.fullName,
oldStagingBranch: connectedRepo.branchTracking?.staging?.branch,
}),
this.isPreviewEnvironmentEnabled(projectId),
]);
})
.map(([productionBranch, stagingBranch, previewEnvironmentEnabled]) => ({
productionBranch,
stagingBranch,
previewDeploymentsEnabled: previewDeploymentsEnabled && previewEnvironmentEnabled,
}))
.andThen(updateConnectedRepo);
}
@@ -296,4 +308,22 @@ export class ProjectSettingsService {
});
});
}
private isPreviewEnvironmentEnabled(projectId: string) {
return fromPromise(
this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
}
}
@@ -3,7 +3,7 @@ import { prisma } from "~/db.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { err, fromPromise, ok, okAsync } from "neverthrow";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { BuildSettingsSchema } from "~/v3/buildSettings";
export class ProjectSettingsPresenter {
@@ -20,33 +20,31 @@ export class ProjectSettingsPresenter {
fromPromise(findProjectBySlug(organizationSlug, projectSlug, userId), (error) => ({
type: "other" as const,
cause: error,
})).andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
return ok(project);
});
}))
.andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
return ok(project);
})
.map((project) => {
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
return { ...project, buildSettings };
});
if (!githubAppEnabled) {
return getProject().andThen((project) => {
if (!project) {
return err({ type: "project_not_found" as const });
}
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
return ok({
gitHubApp: {
enabled: false,
connectedRepository: undefined,
installations: undefined,
},
buildSettings,
});
});
return getProject().map(({ buildSettings }) => ({
gitHubApp: {
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
},
buildSettings,
}));
}
const findConnectedGithubRepository = (projectId: string) =>
@@ -54,6 +52,12 @@ export class ProjectSettingsPresenter {
this.#prismaClient.connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
@@ -130,37 +134,39 @@ export class ProjectSettingsPresenter {
})
);
const isPreviewEnvironmentEnabled = (projectId: string) =>
fromPromise(
this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return getProject().andThen((project) =>
findConnectedGithubRepository(project.id).andThen((connectedGithubRepository) => {
const buildSettingsOrFailure = BuildSettingsSchema.safeParse(project.buildSettings);
const buildSettings = buildSettingsOrFailure.success
? buildSettingsOrFailure.data
: undefined;
if (connectedGithubRepository) {
return okAsync({
gitHubApp: {
enabled: true,
connectedRepository: connectedGithubRepository,
// skip loading installations if there is a connected repository
// a project can have only a single connected repository
installations: undefined,
},
buildSettings,
});
}
return listGithubAppInstallations(project.organizationId).map((githubAppInstallations) => {
return {
gitHubApp: {
enabled: true,
connectedRepository: undefined,
installations: githubAppInstallations,
},
buildSettings,
};
});
})
ResultAsync.combine([
isPreviewEnvironmentEnabled(project.id),
findConnectedGithubRepository(project.id),
listGithubAppInstallations(project.organizationId),
]).map(
([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
gitHubApp: {
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
},
buildSettings: project.buildSettings,
})
)
);
}
}
@@ -83,7 +83,10 @@ export class RealtimeClient {
this.#registerCommands();
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: { frequency: 0.01, maxItems: 1000 },
});
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:realtime",
@@ -33,7 +33,13 @@ export class RequestIdempotencyService<TTypes extends string> {
: "request-idempotency:";
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: {
frequency: 0.001,
maxItems: 1000,
},
});
const redisCacheStore = new RedisCacheStore({
name: "request-idempotency",
connection: {
@@ -0,0 +1,11 @@
import { ResourceMonitor } from "@trigger.dev/core/v3/serverOnly";
import { singleton } from "~/utils/singleton";
export const resourceMonitor = singleton("resourceMonitor", initializeResourceMonitor);
function initializeResourceMonitor() {
return new ResourceMonitor({
ctx: {},
verbose: false,
});
}
@@ -66,6 +66,7 @@ function initializeRunsReplicationInstance() {
insertBaseDelayMs: env.RUN_REPLICATION_INSERT_BASE_DELAY_MS,
insertMaxDelayMs: env.RUN_REPLICATION_INSERT_MAX_DELAY_MS,
insertStrategy: env.RUN_REPLICATION_INSERT_STRATEGY,
disablePayloadInsert: env.RUN_REPLICATION_DISABLE_PAYLOAD_INSERT === "1",
});
if (env.RUN_REPLICATION_ENABLED === "1") {
@@ -57,6 +57,7 @@ export type RunsReplicationServiceOptions = {
insertMaxRetries?: number;
insertBaseDelayMs?: number;
insertMaxDelayMs?: number;
disablePayloadInsert?: boolean;
};
type PostgresTaskRun = TaskRun & { masterQueue: string };
@@ -100,6 +101,7 @@ export class RunsReplicationService {
private _insertBaseDelayMs: number;
private _insertMaxDelayMs: number;
private _insertStrategy: "insert" | "insert_async";
private _disablePayloadInsert: boolean;
public readonly events: EventEmitter<RunsReplicationServiceEvents>;
@@ -112,6 +114,7 @@ export class RunsReplicationService {
this._acknowledgeTimeoutMs = options.acknowledgeTimeoutMs ?? 1_000;
this._insertStrategy = options.insertStrategy ?? "insert";
this._disablePayloadInsert = options.disablePayloadInsert ?? false;
this._replicationClient = new LogicalReplicationClient({
pgConfig: {
@@ -750,7 +753,7 @@ export class RunsReplicationService {
};
}
if (event === "update" || event === "delete") {
if (event === "update" || event === "delete" || this._disablePayloadInsert) {
const taskRunInsert = await this.#prepareTaskRunInsert(
run,
run.organizationId,
@@ -6,8 +6,10 @@ import {
type ListRunsOptions,
type RunListInputOptions,
type RunsRepositoryOptions,
type TagListOptions,
convertRunListInputOptionsToFilterRunsOptions,
} from "./runsRepository.server";
import parseDuration from "parse-duration";
export class ClickHouseRunsRepository implements IRunsRepository {
constructor(private readonly options: RunsRepositoryOptions) {}
@@ -162,6 +164,57 @@ export class ClickHouseRunsRepository implements IRunsRepository {
return result[0].count;
}
async listTags(options: TagListOptions) {
const queryBuilder = this.options.clickhouse.taskRuns
.tagQueryBuilder()
.where("organization_id = {organizationId: String}", {
organizationId: options.organizationId,
})
.where("project_id = {projectId: String}", {
projectId: options.projectId,
})
.where("environment_id = {environmentId: String}", {
environmentId: options.environmentId,
});
const periodMs = options.period ? parseDuration(options.period) ?? undefined : undefined;
if (periodMs) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({period: Int64})", {
period: new Date(Date.now() - periodMs).getTime(),
});
}
if (options.from) {
queryBuilder.where("created_at >= fromUnixTimestamp64Milli({from: Int64})", {
from: options.from,
});
}
if (options.to) {
queryBuilder.where("created_at <= fromUnixTimestamp64Milli({to: Int64})", { to: options.to });
}
// Filter by query (case-insensitive contains search)
if (options.query && options.query.trim().length > 0) {
queryBuilder.where("positionCaseInsensitiveUTF8(tag, {query: String}) > 0", {
query: options.query,
});
}
// Add ordering and pagination
queryBuilder.orderBy("tag ASC").limit(options.limit);
const [queryError, result] = await queryBuilder.execute();
if (queryError) {
throw queryError;
}
return {
tags: result.map((row) => row.tag),
};
}
}
function applyRunFiltersToQueryBuilder<T>(
@@ -8,6 +8,7 @@ import {
type ListedRun,
type RunListInputOptions,
type RunsRepositoryOptions,
type TagListOptions,
convertRunListInputOptionsToFilterRunsOptions,
} from "./runsRepository.server";
@@ -104,6 +105,32 @@ export class PostgresRunsRepository implements IRunsRepository {
return Number(result[0].count);
}
async listTags({ projectId, query, offset, limit }: TagListOptions) {
const tags = await this.options.prisma.taskRunTag.findMany({
select: {
name: true,
},
where: {
projectId,
name: query
? {
startsWith: query,
mode: "insensitive",
}
: undefined,
},
orderBy: {
id: "desc",
},
take: limit + 1,
skip: offset,
});
return {
tags: tags.map((tag) => tag.name),
};
}
#buildRunIdsQuery(
filterOptions: FilterRunsOptions,
page: { size: number; cursor?: string; direction?: "forward" | "backward" }
@@ -69,6 +69,11 @@ type Pagination = {
};
};
type OffsetPagination = {
offset: number;
limit: number;
};
export type ListedRun = Prisma.TaskRunGetPayload<{
select: {
id: true;
@@ -104,6 +109,21 @@ export type ListedRun = Prisma.TaskRunGetPayload<{
export type ListRunsOptions = RunListInputOptions & Pagination;
export type TagListOptions = {
organizationId: string;
projectId: string;
environmentId: string;
period?: string;
from?: number;
to?: number;
/** Performs a case insensitive contains search on the tag name */
query?: string;
} & OffsetPagination;
export type TagList = {
tags: string[];
};
export interface IRunsRepository {
name: string;
listRunIds(options: ListRunsOptions): Promise<string[]>;
@@ -115,6 +135,7 @@ export interface IRunsRepository {
};
}>;
countRuns(options: RunListInputOptions): Promise<number>;
listTags(options: TagListOptions): Promise<TagList>;
}
export class RunsRepository implements IRunsRepository {
@@ -291,6 +312,24 @@ export class RunsRepository implements IRunsRepository {
}
);
}
async listTags(options: TagListOptions): Promise<TagList> {
const repository = await this.#getRepository();
return startActiveSpan(
"runsRepository.listTags",
async () => {
return await repository.listTags(options);
},
{
attributes: {
"repository.name": repository.name,
organizationId: options.organizationId,
projectId: options.projectId,
environmentId: options.environmentId,
},
}
);
}
}
export function parseRunListInputOptions(data: any): RunListInputOptions {

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