Compare commits

...

57 Commits

Author SHA1 Message Date
Matt Aitken 9346e4358d Latest lockfile 2023-12-22 10:43:33 +00:00
github-actions[bot] 3aafd0480b chore: Update version for release (#798)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-22 10:40:55 +00:00
Matt Aitken 771feb5395 Awaiting completion with threads/runs now works with the "requires_action" status 2023-12-22 10:13:44 +00:00
Matt Aitken 03703af682 Only append a search param if the value is defined 2023-12-20 11:06:40 +00:00
Eric Allam 5359af1242 Add logpush true to yalt wrangler 2023-12-18 10:58:30 +00:00
Eric Allam dfa47b6201 Don't proxy certain headers across the Yalt boundary as it can mess-up proxied local requests 2023-12-18 10:52:31 +00:00
Eric Allam c01138b56e Fix pnpm lock file for release 2023-12-14 17:27:45 +00:00
github-actions[bot] 70fdedcea5 chore: Update version for release (#796)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-14 17:27:08 +00:00
Matt Aitken 49ca4c4430 Fix for #731, integrations getting stuck if OAuth then moved to API key (#732)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 1s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-14 15:58:50 +00:00
Eric Allam 812b5afe6f Add support for the built-in Trigger.dev tunnel for local development (#795)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 3s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 13s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Add support for the built-in Trigger.dev tunnel for local development

* Remove the test script from yalt

* Add to the README

* Improve the outdated packages warning
2023-12-14 15:33:56 +00:00
Matt Aitken 922520cefb Fix for the overage estimation on the usage page always being zero
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-13 18:57:14 +00:00
Matt Aitken 4bd1786ae5 Billing (#789)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* Initial commit - started work on new billing components

* Added a slider component

* Added features to the pricing tiers

* Small pricing tier margin tweaks

* WIP on a concurrecy chart

* Reworked the pricing tiers to include a segmented controller and tooltips

* Renamed the charts storybook page

* Made the way data is added more flexible and added some definition tool tips

* Term definitions are used properly in the tiers

* Callouts can now have an optional CTA on the right hand side

* Alignment fix for the callouts

* organize imports

* Definition tooltip now its own component

* renamed the storybook story

* WIP new volume discount table and usage sliders

* Added pricing calculator sliders

* Fixed alignment of the legend

* Breadcrumb now has an upgrade prompt and button

* New Join our Slack button in the side menu

* New progress meter in the side menu

* Use the highest of 2 values to show progress

* An attempt to fix the step count in the calculator slider

* WIP usage progress bar

* Added the 4 progress bars

* More examples of the usage bar

* Better way to include the percentage in the free plan progress meter

* The usage bar now works with the extra runs over the free limit

* Pricing calculator has better slider logic

* Moved the free plan usage bar into it’s own component and added it to storybook

* Usage bar chart now supports a paying customer option and optional billing limit. Also added more usage examples to storybook

* Added more examples of usage to storybook

* tooltip takes classname

* Format numbers nicely

* Added a tooltip to show the precise numbers in the chart

* small improvements to the billing calculator

* New onboarding choose plan page

* pricing tiers better fill the size of their container

* Removed unused code

* Usage bars animate

* Wording tweak

* Callouts fit the button size better

* Added new routes for the 2 new billing pages

* import cleanup

* Added meta info in the header for bill price, plan type and billing period

* made free a noun

* Added pricing calculator to the plans page

* Fixed some illegal markup when using tooltips

* Added container query support

* Added new concurrency chart to the usage page

* billing now has a green theme

* Simplified the plan summary info int the header

* Fixed padding alignment

* Use a custom lable for the concurrent runs chart

* Removed the Job runs table

* Latest lockfile

* Show a message if you haven’t done runs yet

* Added a layoutId to the pageTabs

* Show a message callout if you’ve exceeeded 10k runs on the free plan

* Added a callout on the plans page if you’re over the runs limit

* Fixed button inside button bug

* Removed the background gradients from the app

* The billing package is importing properly

* Getting the curent plan for an org

* Reading the current plan and usage

* Hooked up the free plan bar

* Render basic billing details

* vol discount table has optional values

* Added a new page to show new subscribers

* Created a new hook for confetti on the subscribed page

* Toast styling updated

* The Invoice and Manage card details links are working

* Meta appEnv data optional

* Data for the plans page

* Format the billing period duration in days

* Switch to 20 icons

* URL for the subscribed page now includes the org path

* New pathBuilder path for the subscribed page

* Plans are upgraded/downgraded successfully

* Fixed badly named paths

* Improved some of the display

* Deal with when the user has canceled so they can re-upgrade

* Subscribing from Stripe is working, and canceling

* Tidied up some bits, latest billing package

* The tiers are now rendering using the real data

* The onboarding screen is hooked up, but not linked to yet

* Onboarding price selection working

* Pricing slider working

* Price estimation working

* Pricing calculator working on the select a plan page

* Button copy change

* Improved the layout of the run calculator marker

* Fix for the period end when you’ve canceled

* Improved the formatting in the calculator

* Pricing table tooltip uses the vol discount pricing table

* Remove the vertical lines from the calculator

* Contact us button in Enterprise tier opens the contact us form

* Added composite index to triggerdotdev_events.run_executions for event_time and organization_id

* Concurrent run chart data

* Improves styling, fix for React error with Enterprise contact button

* Show warning box when you’ve hit the concurrency in the past 30 days

* Lots of work o the usage page

* Improvements to the usage page

* Show 31 days of data, fix for not showing the current date…

* Stripe portal links are generated when the user clicks through

* Better alignment of the reference line and x-axis label

* Readme update

* Fixed pricing button loading buttons

* Show warnings about concurrency and runs on the plans page

* Removed some storybook stories

* Join Slack channel shows if you’re subscribed with instructions

* Added a gap between the runs charts

* Improved the definitions

* Added some margin to the page loading spinner

* A wider, cleaner feedback panel

* Modal backgrounds match the Sheet style

* Fixed menu item text being clipped

* Improved icons for execution time and exclusion count

* Concurrency chart now renders the dates nicely

* Fix for plans data on the plans page

* The healthcheck doesn’t need to do a HEAD request to /

* Better disabled states and fixed the disabled hover state issue

* Improved the segmented controller style

* loading spinner now centered inside the button

* Redirect to the project page when selecting the free plan

* Fix for “Runs” and added real date to upgrade warning

* DeCAPITALIZED some things

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-12-13 12:48:26 +00:00
github-actions[bot] daae6df428 chore: Update version for release (#794)
* chore: Update version for release

* Update lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2023-12-13 10:23:36 +00:00
nicktrn fc31c6c07a Fix: Test package run isolation (#793)
* Fix link

* Move paths to compiler options

* Add failing test case

* Fix test isolation

* Add changeset
2023-12-13 09:49:52 +00:00
Eric Allam 6d4676f204 Auto update endpoint URL on redirect (#791) 2023-12-12 13:14:25 +00:00
Andreas Thomas 0a51ef3a06 fix: typo (#788) 2023-12-10 22:24:01 +00:00
Eric Allam 772f1b41a0 Fix pnpm lock file 2023-12-08 10:20:32 +00:00
github-actions[bot] d42ba80108 chore: Update version for release (#785)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-08 10:19:57 +00:00
Eric Allam 6a3c563f14 Fixed Job.attachToClient 2023-12-08 10:15:06 +00:00
Eric Allam c04cdfde8c Fix pnpm lock file 2023-12-07 13:00:32 +00:00
github-actions[bot] 750c1ff1e3 chore: Update version for release (#783)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-07 12:59:53 +00:00
Eric Allam 0f9f010206 @trigger.dev/airtable: Removed named imports (#782) 2023-12-07 12:53:58 +00:00
Eric Allam 11f8ff1bb4 Fixing endless retrying of non-successful run executions
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-06 21:01:41 +00:00
Eric Allam 85b3352764 Fix pnpm lock file 2023-12-06 20:05:49 +00:00
github-actions[bot] 9fb8dceef4 chore: Update version for release (#781)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 20:05:29 +00:00
Eric Allam 0c14e4cdfe Stop importing subpaths in @trigger.dev/airtable (#780) 2023-12-06 20:02:37 +00:00
D-K-P 2099c6308e Latest lock file 2023-12-06 15:45:43 +00:00
github-actions[bot] 08e6cad28a chore: Update version for release (#779)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 15:42:36 +00:00
Eric Allam f3efcc0c28 Moved Logger to core-backend, no longer importing node:buffer in core/react (#778) 2023-12-06 15:37:50 +00:00
Eric Allam 37ef335b66 Resolve pnpm lock file 2023-12-06 11:29:58 +00:00
github-actions[bot] 33d555d00e chore: Update version for release (#776)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-12-06 11:28:06 +00:00
Eric Allam 17f6f29d05 Feature: Support multiple runtimes other than Node.js (#774)
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 5s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-06 10:23:37 +00:00
Eric Allam 08f7c639ef Maximum background fetch timeout is now 5 minutes
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 1s
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-04 15:02:15 +00:00
github-actions[bot] 1567239718 chore: Update version for release (#771)
* chore: Update version for release

* Release lockfile

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2023-12-04 14:14:41 +00:00
nicktrn de652c1dfb Fix: Shopify tasks and KV get() return types (#770)
* Improve KV get types

* Fix shopify find and all task return types

* Add changeset
2023-12-04 12:37:55 +00:00
Eric Allam 1f3733b70f Fix resuming timed out or auto-yielded executions
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / units (push) Failing after 21s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-12-04 09:20:40 +00:00
Dan b5aea6c534 Replaced showcase links -> api (#764)
* Updated the examples section with new links / changed showcase link to project showcase

* Added links to the api pages for all of our integrations

* Deleted card from the airtable tasks page
2023-12-01 17:35:54 +00:00
Eric Allam 0769dc4315 Fixed the event names for stripe docs 2023-12-01 08:42:05 +00:00
Eric Allam 5d00fc7cdb Update pnpm lock file for release 2023-11-30 21:54:43 +00:00
github-actions[bot] 00b0c3e02e chore: Update version for release (#765)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-30 21:54:13 +00:00
Eric Allam 7e3a82ef47 Issue #747: Add Stripe invoice triggers 2023-11-30 21:48:45 +00:00
James Ritchie 5dda6cd16c Added the border to the page header on the Runs page
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 3s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 21s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-30 10:32:38 +00:00
Matt Aitken 76b7fb2337 The status API endpoints return only the original set of statuses 2023-11-30 10:31:27 +00:00
Matt Aitken 68cbfd8d23 Log out the run status 2023-11-30 10:29:48 +00:00
Eric Allam 41a49f6bb2 Make the run list query more efficient
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-29 19:28:15 +00:00
Eric Allam 2f755158b4 Updated pnpm lock file 2023-11-29 11:25:27 +00:00
github-actions[bot] 419b93809d chore: Update version for release (#757)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-11-29 11:24:58 +00:00
Eric Allam bd4bc51daa Update plain (#762)
* updated the plain SDK

* Updated the action to support the new Threads SDK

* latest lock file

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-11-29 10:58:06 +00:00
Eric Allam ff540c9e4a Revert "Update plain (#761)"
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 3s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
This reverts commit 3f217ff3e0.
2023-11-29 10:51:13 +00:00
Eric Allam 3f217ff3e0 Update plain (#761)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 5s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
* updated the plain SDK

* Updated the action to support the new Threads SDK

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
2023-11-29 10:49:37 +00:00
Eric Allam 9cb39bf7d7 Added Redis example env vars 2023-11-29 10:34:54 +00:00
Eric Allam ca05d5f603 @trigger.dev/openai: Adding additional assistant tasks 2023-11-28 22:48:31 +00:00
Hemachandar 4dc46cbbe4 fix: transform Headers object to Record type (#760) 2023-11-28 18:42:50 +00:00
nicktrn 1dcd87a2aa URI encode keys (#758) 2023-11-28 18:05:15 +00:00
Eric Allam c4cb98af5c Clarified the run execution limits 2023-11-28 16:23:35 +00:00
Eric Allam 6ebd435e81 Feature: Run execution concurrency limits (#750)
* WIP execution concurrency controls implemented via Redis

- Split up resuming a run and executing a run
- Added some new statuses to better show what is going on in a run
- Removed preprocessing runs

* WIP

* Convert to using ZSETs and adding env vars

* Removed unused import

* Improve run number generation using advistory locks, and only on start

* More execution concurrency stuff

* Add support for job concurrency limits and concurrency limit groups

* Create wild-swans-battle.md

* Increase slots refresh timeout to 10s

* Try to fix Redis connection issues

* Don’t be so strict about the APP_ENV

* Add the blank tls option to the normal redis client as well

* Add docs
2023-11-28 16:21:06 +00:00
Eric Allam caf203c084 Made webhook index stats optional
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 4s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 19s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped
2023-11-28 15:04:24 +00:00
353 changed files with 12543 additions and 4056 deletions
+2
View File
@@ -14,6 +14,8 @@
"ignore": [
"webapp",
"emails",
"proxy",
"yalt",
"@trigger.dev/database"
],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
+5
View File
@@ -12,6 +12,11 @@ APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# Redis is used for concurrency control
# REDIS_HOST="localhost"
# REDIS_PORT="6379"
# REDIS_TLS_DISABLED="true"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
+10
View File
@@ -22,6 +22,16 @@ jobs:
node-version: 18
cache: "pnpm"
- name: ⎔ Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: ⎔ Setup bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.0.15"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1,4 +1,4 @@
{
"deno.enablePaths": ["references/deno-reference"],
"deno.enablePaths": ["references/deno-reference", "runtime_tests/tests/deno"],
"debug.toolBarLocation": "commandCenter"
}
+57
View File
@@ -1,5 +1,62 @@
# proxy
## 0.0.11
### Patch Changes
- @trigger.dev/core@2.3.5
## 0.0.10
### Patch Changes
- @trigger.dev/core@2.3.4
## 0.0.9
### Patch Changes
- @trigger.dev/core@2.3.3
## 0.0.8
### Patch Changes
- @trigger.dev/core@2.3.2
## 0.0.7
### Patch Changes
- Updated dependencies [f3efcc0c]
- @trigger.dev/core@2.3.1
## 0.0.6
### Patch Changes
- Updated dependencies [17f6f29d]
- @trigger.dev/core@2.3.0
## 0.0.5
### Patch Changes
- @trigger.dev/core@2.2.11
## 0.0.4
### Patch Changes
- @trigger.dev/core@2.2.10
## 0.0.3
### Patch Changes
- Updated dependencies [6ebd435e]
- @trigger.dev/core@2.2.9
## 0.0.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxy",
"version": "0.0.2",
"version": "0.0.11",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

@@ -0,0 +1,33 @@
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
export function DefinitionTip({
content,
children,
title,
}: {
content: React.ReactNode;
children: React.ReactNode;
title: React.ReactNode;
}) {
return (
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger>
<span className="underline decoration-slate-600 decoration-dashed underline-offset-4 transition hover:decoration-slate-500">
{children}
</span>
</TooltipTrigger>
<TooltipContent align="end" side="right" variant="dark" className="w-[16rem] min-w-[16rem]">
<Header3 className="mb-1">{title}</Header3>
{typeof content === "string" ? (
<Paragraph variant="small">{content}</Paragraph>
) : (
<div>{content}</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+24 -23
View File
@@ -1,12 +1,15 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { ReactNode, useState } from "react";
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
import { FormError } from "./primitives/FormError";
import { Header2 } from "./primitives/Headers";
import { InputGroup } from "./primitives/InputGroup";
import { Label } from "./primitives/Label";
import { Paragraph } from "./primitives/Paragraph";
@@ -20,8 +23,6 @@ import {
} from "./primitives/Select";
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
type FeedbackProps = {
button: ReactNode;
@@ -55,19 +56,16 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild={true}>{button}</SheetTrigger>
<SheetContent size="sm">
<SheetHeader className="justify-between">Help & feedback</SheetHeader>
<SheetBody>
<SheetContent>
<SheetHeader className="justify-between">How can we help?</SheetHeader>
<SheetBody className="flex h-full flex-col justify-between">
<DiscordBanner />
<Paragraph variant="small" className="mb-4 border-t border-slate-800 pt-3">
Or use this form to ask for help or give us feedback. We read every message and will get
back to you as soon as we can.
</Paragraph>
<hr className="mb-3" />
<Header2 className="mb-4">Send us a message</Header2>
<Form method="post" action="/resources/feedback" {...form.props}>
<Fieldset className="max-w-full">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
<Label>How can we help?</Label>
<SelectGroup>
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
<SelectTrigger size="medium" width="full">
@@ -90,14 +88,19 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<FormError id={message.errorId}>{message.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
className="w-full"
confirmButton={
<Button type="submit" variant="primary/medium">
Send
</Button>
}
/>
<div className="flex w-full items-center justify-between">
<Paragraph variant="small" className="w-full">
We read every message and respond quickly.
</Paragraph>
<FormButtons
className="m-0 w-max"
confirmButton={
<Button type="submit" variant="primary/medium">
Send
</Button>
}
/>
</div>
</Fieldset>
</Form>
</SheetBody>
@@ -109,7 +112,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
function DiscordBanner() {
return (
<a
href="https://discord.gg/nkqV9xBYWy"
href="https://trigger.dev/discord"
target="_blank"
className="group mb-4 flex w-full items-center justify-between rounded-md border border-slate-600 bg-gradient-to-br from-blue-400/30 to-indigo-400/50 p-4 transition hover:border-indigo-400"
>
@@ -120,13 +123,11 @@ function DiscordBanner() {
<br />
Discord community
</h2>
<Paragraph variant="small">
<Paragraph variant="small/bright">
Get help or answer questions from the Trigger.dev community.
</Paragraph>
</div>
<div className="h-full">
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
</div>
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
</a>
);
}
+22 -1
View File
@@ -16,19 +16,23 @@ export type JobEnvironment = {
lastRun?: Date;
version: string;
enabled: boolean;
concurrencyLimit?: number | null;
concurrencyLimitGroup?: { name: string; concurrencyLimit: number } | null;
};
type JobStatusTableProps = {
environments: JobEnvironment[];
displayStyle?: "short" | "long";
};
export function JobStatusTable({ environments }: JobStatusTableProps) {
export function JobStatusTable({ environments, displayStyle = "short" }: JobStatusTableProps) {
return (
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Last Run</TableHeaderCell>
{displayStyle === "long" && <TableHeaderCell>Concurrency</TableHeaderCell>}
<TableHeaderCell alignment="right">Version</TableHeaderCell>
<TableHeaderCell alignment="right">Status</TableHeaderCell>
</TableRow>
@@ -42,6 +46,23 @@ export function JobStatusTable({ environments }: JobStatusTableProps) {
<TableCell>
{environment.lastRun ? <DateTime date={environment.lastRun} /> : "Never Run"}
</TableCell>
{displayStyle === "long" && (
<TableCell>
{environment.concurrencyLimitGroup ? (
<span className="flex items-center gap-1">
<span>{environment.concurrencyLimitGroup.name}</span>
<span className="text-gray-400">
({environment.concurrencyLimitGroup.concurrencyLimit})
</span>
</span>
) : typeof environment.concurrencyLimit === "number" ? (
<span className="text-gray-400">{environment.concurrencyLimit}</span>
) : (
<span className="text-gray-400">Not specified</span>
)}
</TableCell>
)}
<TableCell alignment="right">{environment.version}</TableCell>
<TableCell alignment="right">
<ActiveBadge active={environment.enabled} />
@@ -1,12 +0,0 @@
import gradientBackground from "~/assets/images/gradient-background.png";
export function PageGradient({ children }: { children: React.ReactNode }) {
return (
<div
className="-mt-4 h-full w-full bg-cover bg-no-repeat pt-20"
style={{ backgroundImage: `url("${gradientBackground}")` }}
>
{children}
</div>
);
}
@@ -0,0 +1,114 @@
import {
Label,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paragraph } from "../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
borderRadius: "0.25rem",
border: "1px solid #1A2434",
backgroundColor: "#0B1018",
padding: "0.3rem 0.5rem",
fontSize: "0.75rem",
color: "#E2E8F0",
};
type DataItem = { date: Date; maxConcurrentRuns: number };
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
export function ConcurrentRunsChart({
concurrentRunsLimit,
data,
hasConcurrencyData,
}: {
concurrentRunsLimit?: number;
data: DataItem[];
hasConcurrencyData: boolean;
}) {
return (
<div className="relative">
{!hasConcurrencyData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No concurrent Runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
<LineChart
data={data}
margin={{
top: 20,
right: 0,
left: 0,
bottom: 10,
}}
className="-ml-8"
>
<XAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
dataKey={(item: DataItem) => {
if (item.date.getDate() === 1) {
return dateFormatter.format(item.date);
}
return `${item.date.getDate()}`;
}}
className="text-xs"
>
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
</XAxis>
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
contentStyle={tooltipStyle}
labelFormatter={(value, data) => {
const date = data.at(0)?.payload.date;
if (!date) {
return "";
}
return dateFormatter.format(date);
}}
/>
{concurrentRunsLimit && (
<ReferenceLine
y={concurrentRunsLimit}
stroke="#F43F5E"
strokeWidth={1}
strokeDasharray={5}
ifOverflow="extendDomain"
className="text-xs"
>
<Label
value="Concurrent Runs limit"
offset={8}
position="insideTopLeft"
fill="#F43F5E"
/>
</ReferenceLine>
)}
<Line
dataKey="maxConcurrentRuns"
name="Concurrent runs"
stroke="#16A34A"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,40 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { Paragraph } from "../primitives/Paragraph";
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
const cappedPercentage = Math.min(percentage, 1);
const widthProgress = useMotionValue(cappedPercentage * 100);
const color = useTransform(
widthProgress,
[0, 74, 75, 95, 100],
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
);
return (
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
<Paragraph className="text-2sm text-bright">Free Plan</Paragraph>
</div>
<Link to={to} className="text-2sm text-indigo-500">
Learn more
</Link>
</div>
<div className="relative mt-3 h-1 rounded-full bg-[#0B1018]">
<motion.div
initial={{ width: 0 }}
animate={{ width: cappedPercentage * 100 + "%" }}
style={{
backgroundColor: color,
}}
transition={{ duration: 1, type: "spring" }}
className={cn("absolute left-0 top-0 h-full rounded-full")}
/>
</div>
</div>
);
}
@@ -0,0 +1,259 @@
import * as Slider from "@radix-ui/react-slider";
import { Plans, estimate } from "@trigger.dev/billing";
import { useCallback, useState } from "react";
import { DefinitionTip } from "../DefinitionTooltip";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { cn } from "~/utils/cn";
export function PricingCalculator({ plans }: { plans: Plans }) {
const [selectedConcurrencyIndex, setSelectedConcurrencyIndex] = useState(0);
const concurrentRunTiers = [
{ code: "free", upto: plans.free.concurrentRuns?.freeAllowance! },
...(plans.paid.concurrentRuns?.pricing?.tiers ?? []),
];
const [runs, setRuns] = useState(0);
const runBrackets = [
...(plans.paid.runs?.pricing?.brackets.map((b, index, arr) => ({
unitCost: b.unitCost,
from: index === 0 ? 0 : arr[index - 1].upto! + 1,
upto: b.upto ?? arr[index - 1].upto! * 10,
})) ?? []),
];
const result = estimate({
usage: { runs, concurrent_runs: concurrentRunTiers[selectedConcurrencyIndex].upto - 1 },
plans: [plans.free, plans.paid],
});
return (
<div className="flex w-full flex-col gap-4">
<ConcurrentRunsSlider
options={concurrentRunTiers}
selectedIndex={selectedConcurrencyIndex}
setSelectedIndex={setSelectedConcurrencyIndex}
cost={result?.cost.concurrentRunCost ?? 0}
/>
<RunsSlider
brackets={runBrackets}
runs={runs}
setRuns={setRuns}
cost={result?.cost.runsCost ?? 0}
/>
<GrandTotal cost={result?.cost.total ?? 0} />
</div>
);
}
function ConcurrentRunsSlider({
options,
selectedIndex,
setSelectedIndex,
cost,
}: {
options: {
code: string;
upto: number;
}[];
selectedIndex: number;
setSelectedIndex: (index: number) => void;
cost: number;
}) {
const selectedOption = options[selectedIndex];
return (
<div>
<div className="flex">
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<Header2>
<DefinitionTip
content="The number of runs that can be executed at the same time. Get in touch if you need more than 100."
title="Concurrent runs"
>
Concurrent runs
</DefinitionTip>
</Header2>
<Header2>Up to {selectedOption.upto}</Header2>
</div>
<Slider.Root
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
value={[selectedIndex]}
onValueChange={(value) => setSelectedIndex(value[0])}
max={options.length - 1}
step={1}
>
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
</Slider.Track>
<Slider.Thumb
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
aria-label="Concurrent runs slider"
/>
</Slider.Root>
<div className="ml-1.5 flex w-[99.85%] items-center justify-between">
{options.map((tier, i) => {
return (
<Paragraph variant="extra-small" className="text-slate-600" key={i}>
{tier.upto}
</Paragraph>
);
})}
</div>
</div>
<div className="flex h-full items-start">
<span className="ml-6 text-dimmed">=</span>
<Header2 className="min-w-[8ch] text-right text-dimmed">
{formatCurrency(cost, true)}
</Header2>
</div>
</div>
<hr className="mt-6 border-border" />
</div>
);
}
const runIncrements = 10_000;
function RunsSlider({
brackets,
runs,
setRuns,
cost,
}: {
brackets: {
from: number;
upto: number;
unitCost: number;
}[];
runs: number;
setRuns: (value: number) => void;
cost: number;
}) {
const [value, setValue] = useState(0);
const updateRuns = useCallback((value: number) => {
setValue(value);
const r = calculateRuns(value / runIncrements, brackets);
setRuns(r);
}, []);
return (
<div>
<div className="flex">
<div className="flex w-full flex-col">
<div className="flex items-center justify-between">
<Header2>
<DefinitionTip content="A single execution of a Job." title="Runs">
Runs
</DefinitionTip>
</Header2>
<Header2>{formatNumberCompact(runs)}</Header2>
</div>
<Slider.Root
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
value={[value]}
onValueChange={(value) => updateRuns(value[0])}
max={runIncrements}
step={1}
>
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
</Slider.Track>
<Slider.Thumb
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
aria-label="Concurrent runs slider"
/>
</Slider.Root>
<div className="relative w-full">
{brackets.map((bracket, i, arr) => {
const percentagePerBracket = 1 / arr.length;
return (
<SliderMarker
key={i}
percentage={(i / (arr.length - 1)) * percentagePerBracket * (arr.length - 1)}
alignment={i === 0 ? "left" : "center"}
text={formatNumberCompact(bracket.from)}
/>
);
})}
<SliderMarker
percentage={1}
alignment={"right"}
text={formatNumberCompact(brackets[brackets.length - 1].upto)}
/>
</div>
</div>
<div className="flex h-full items-start">
<span className="ml-6 text-dimmed">=</span>
<Header2 className="min-w-[8ch] text-right text-dimmed">
{formatCurrency(cost, true)}
</Header2>
</div>
</div>
<hr className="mt-6 border-border" />
</div>
);
}
function calculateRuns(percentage: number, brackets: { from: number; upto: number }[]) {
//first we find which bucket we're in
const buckets = brackets.length;
const bucket = Math.min(Math.floor(percentage * buckets), brackets.length - 1);
const percentagePerBucket = 1 / buckets;
//relevant bracket
let bracket = brackets[bucket];
const from = bracket.from;
const upto = bracket.upto;
//how far as we into the bracket
const percentageIntoBracket = (percentage - bucket * percentagePerBucket) / percentagePerBucket;
//calculate the runs
const runs = Math.floor(from + (upto - from) * percentageIntoBracket);
return runs;
}
function GrandTotal({ cost }: { cost: number }) {
return (
<div className="flex justify-between">
<Header2>Total monthly estimate</Header2>
<Header2>{formatCurrency(cost, true)}</Header2>
</div>
);
}
function SliderMarker({
percentage,
alignment,
text,
}: {
percentage: number;
alignment: "left" | "center" | "right";
text: string;
}) {
return (
<div
className="absolute top-0 h-4"
style={{
left: `${percentage * 100}%`,
}}
>
<div
className={cn(
"absolute flex items-center",
alignment === "left"
? "left-0 justify-start"
: alignment === "center"
? "-translate-x-1/2 justify-center"
: "justify-middle right-0"
)}
>
<Paragraph variant="extra-small" className="text-slate-600">
{text}
</Paragraph>
</div>
</div>
);
}
@@ -0,0 +1,519 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/billing";
import { useState } from "react";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { DefinitionTip } from "../DefinitionTooltip";
import { Feedback } from "../Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import SegmentedControl from "../primitives/SegmentedControl";
import { RunsVolumeDiscountTable } from "./RunsVolumeDiscountTable";
import { Spinner } from "../primitives/Spinner";
const pricingDefinitions = {
concurrentRuns: {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
jobRuns: {
title: "Job runs",
content: "A single execution of a job.",
},
jobs: {
title: "Jobs",
content: "A durable function that can be executed on a schedule or in response to an event.",
},
tasks: {
title: "Tasks",
content: "The individual building blocks of a job run.",
},
events: {
title: "Events",
content: "Events trigger jobs to start running.",
},
integrations: {
title: "Integrations",
content: "Easily subscribe to webhooks and perform actions using APIs.",
},
};
export function PricingTiers({
organizationSlug,
plans,
className,
showActionText = true,
freeButtonPath,
}: {
organizationSlug: string;
plans: Plans;
className?: string;
showActionText?: boolean;
freeButtonPath?: string;
}) {
const currentPlan = useCurrentPlan();
//if they've canceled, we set the subscription to undefined so they can re-upgrade
let currentSubscription = currentPlan?.subscription;
if (currentPlan?.subscription?.canceledAt) {
currentSubscription = undefined;
}
return (
<div
className={cn(
"flex min-w-full flex-col items-start justify-center gap-4 md:flex-row",
className
)}
>
<TierFree
plan={plans.free}
currentSubscription={currentSubscription}
organizationSlug={organizationSlug}
showActionText={showActionText}
buttonPath={freeButtonPath}
/>
<TierPro
plan={plans.paid}
currentSubscription={currentSubscription}
organizationSlug={organizationSlug}
showActionText={showActionText}
/>
<TierEnterprise />
</div>
);
}
export function TierFree({
plan,
organizationSlug,
showActionText,
currentSubscription,
buttonPath,
}: {
plan: Plan;
organizationSlug: string;
showActionText: boolean;
currentSubscription?: ActiveSubscription;
buttonPath?: string;
}) {
const lastSubmission = useActionData();
const [form] = useForm({
id: "subscribe",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: SetPlanBodySchema });
},
});
const navigation = useNavigation();
const isLoading =
(navigation.state === "submitting" || navigation.state === "loading") &&
navigation.formData?.get("type") === "free";
const isCurrentPlan =
currentSubscription?.isPaying === undefined || currentSubscription?.isPaying === false;
let actionText = "Select plan";
if (showActionText) {
if (isCurrentPlan) {
actionText = "Current Plan";
} else {
actionText = "Downgrade";
}
}
return (
<TierContainer>
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
<Header title={plan.title} cost={0} />
<TierLimit>
Up to {plan.concurrentRuns?.freeAllowance}{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</TierLimit>
<input type="hidden" name="type" value="free" />
<div className="py-6">
{buttonPath ? (
<LinkButton
variant="secondary/large"
fullWidth
className="text-md font-medium"
to={buttonPath}
>
{actionText}
</LinkButton>
) : (
<Button
variant="secondary/large"
fullWidth
className="text-md font-medium"
disabled={isLoading || isCurrentPlan}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Spinner color="white" />
Updating plan
</div>
) : (
actionText
)}
</Button>
)}
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Up to {plan.runs?.freeAllowance ? formatNumberCompact(plan.runs.freeAllowance) : ""}{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>24 hour log retention</FeatureItem>
<FeatureItem checked>Community support</FeatureItem>
<FeatureItem>Custom integrations</FeatureItem>
<FeatureItem>Role-based access control</FeatureItem>
<FeatureItem>SSO</FeatureItem>
<FeatureItem>On-prem option</FeatureItem>
</ul>
</Form>
</TierContainer>
);
}
export function TierPro({
plan,
organizationSlug,
showActionText,
currentSubscription,
}: {
plan: Plan;
organizationSlug: string;
showActionText: boolean;
currentSubscription?: ActiveSubscription;
}) {
const lastSubmission = useActionData();
const [form] = useForm({
id: "subscribe",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: SetPlanBodySchema });
},
});
const navigation = useNavigation();
const isLoading =
(navigation.state === "submitting" || navigation.state === "loading") &&
navigation.formData?.get("planCode") === plan.code;
const currentConcurrencyTier = currentSubscription?.plan.concurrentRuns.pricing?.code;
const [concurrentBracketCode, setConcurrentBracketCode] = useState(
currentConcurrencyTier ?? plan.concurrentRuns?.pricing?.tiers[0].code
);
const concurrencyTiers = plan.concurrentRuns?.pricing?.tiers ?? [];
const selectedTier = concurrencyTiers.find((c) => c.code === concurrentBracketCode);
const freeRunCount = plan.runs?.pricing?.brackets[0].upto ?? 0;
const mostExpensiveRunCost = plan.runs?.pricing?.brackets[1]?.unitCost ?? 0;
const isCurrentPlan = currentConcurrencyTier === concurrentBracketCode;
let actionText = "Select plan";
if (showActionText) {
if (isCurrentPlan) {
actionText = "Current Plan";
} else {
const currentTierIndex = concurrencyTiers.findIndex((c) => c.code === currentConcurrencyTier);
const selectedTierIndex = concurrencyTiers.findIndex((c) => c.code === concurrentBracketCode);
actionText = currentTierIndex < selectedTierIndex ? "Upgrade" : "Downgrade";
}
}
return (
<TierContainer isHighlighted>
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
<Header title={plan.title} isHighlighted cost={selectedTier?.tierCost} />
<div className="mb-2 mt-6 font-sans text-sm font-normal text-bright">
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</div>
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<SegmentedControl
name="concurrentRunBracket"
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
fullWidth
value={concurrentBracketCode}
onChange={(v) => setConcurrentBracketCode(v)}
/>
<div className="py-6">
<Button
variant="primary/large"
fullWidth
className="text-md font-medium"
type="submit"
disabled={isLoading || isCurrentPlan}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Spinner color="white" />
Updating plan
</div>
) : (
actionText
)}
</Button>
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Includes {freeRunCount ? formatNumberCompact(freeRunCount) : ""}{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
, then{" "}
<DefinitionTip
title="Runs volume discount"
content={
<RunsVolumeDiscountTable hideHeader brackets={plan.runs?.pricing?.brackets ?? []} />
}
>
{"<"} ${(mostExpensiveRunCost * 1000).toFixed(2)}/1K runs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>7 day log retention</FeatureItem>
<FeatureItem checked>Dedicated Slack support</FeatureItem>
<FeatureItem>Custom integrations</FeatureItem>
<FeatureItem>Role-based access control</FeatureItem>
<FeatureItem>SSO</FeatureItem>
<FeatureItem>On-prem option</FeatureItem>
</ul>
</Form>
</TierContainer>
);
}
export function TierEnterprise() {
return (
<TierContainer>
<Header title="Enterprise" />
<TierLimit>
Flexible{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
{pricingDefinitions.concurrentRuns.title}
</DefinitionTip>
</TierLimit>
<div className="py-6">
<Feedback
button={
<Button variant="secondary/large" fullWidth className="text-md font-medium">
Contact us
</Button>
}
defaultValue="enterprise"
/>
</div>
<ul className="flex flex-col gap-2.5">
<FeatureItem checked>
Flexible{" "}
<DefinitionTip
title={pricingDefinitions.jobRuns.title}
content={pricingDefinitions.jobRuns.content}
>
{pricingDefinitions.jobRuns.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.jobs.title}
content={pricingDefinitions.jobs.content}
>
jobs
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.events.title}
content={pricingDefinitions.events.content}
>
events
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Unlimited team members</FeatureItem>
<FeatureItem checked>30 day log retention</FeatureItem>
<FeatureItem checked>Priority support</FeatureItem>
<FeatureItem checked>
Custom{" "}
<DefinitionTip
title={pricingDefinitions.integrations.title}
content={pricingDefinitions.integrations.content}
>
{pricingDefinitions.integrations.title}
</DefinitionTip>
</FeatureItem>
<FeatureItem checked>Role-based access control</FeatureItem>
<FeatureItem checked>SSO</FeatureItem>
<FeatureItem checked>On-prem option</FeatureItem>
</ul>
</TierContainer>
);
}
function TierContainer({
children,
isHighlighted,
}: {
children: React.ReactNode;
isHighlighted?: boolean;
}) {
return (
<div
className={cn(
"flex w-full min-w-[16rem] flex-col rounded-md border p-6",
isHighlighted ? "border-indigo-500" : "border-border"
)}
>
{children}
</div>
);
}
function Header({
title,
cost: flatCost,
isHighlighted,
}: {
title: string;
cost?: number;
isHighlighted?: boolean;
}) {
return (
<div className="flex flex-col gap-2">
<h2 className={cn("text-xl font-medium", isHighlighted ? "text-indigo-500" : "text-dimmed")}>
{title}
</h2>
{flatCost === 0 || flatCost ? (
<h3 className="text-4xl font-medium">
${flatCost}
<span className="text-2sm font-normal tracking-wide text-dimmed">/month</span>
</h3>
) : (
<h2 className="text-4xl font-medium">Custom</h2>
)}
</div>
);
}
function TierLimit({ children }: { children: React.ReactNode }) {
return (
<div>
<hr className="my-[1.9rem]" />
<div className="mb-[0.6rem] mt-6 font-sans text-sm font-normal text-bright">{children}</div>
</div>
);
}
function FeatureItem({ checked, children }: { checked?: boolean; children: React.ReactNode }) {
return (
<li className="flex items-center gap-2">
{checked ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<XMarkIcon className="h-4 w-4 text-slate-500" />
)}
<div className={cn("font-sans text-sm font-normal", checked ? "text-bright" : "text-dimmed")}>
{children}
</div>
</li>
);
}
@@ -0,0 +1,64 @@
import { RunPriceBracket } from "@trigger.dev/billing";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { formatNumberCompact } from "~/utils/numberFormatter";
export function RunsVolumeDiscountTable({
className,
hideHeader = false,
brackets,
}: {
className?: string;
hideHeader?: boolean;
brackets: RunPriceBracket[];
}) {
const runsVolumeDiscountRow =
"flex justify-between whitespace-nowrap border-b gap-16 border-border last:pb-0 last:border-none py-2";
const bracketData = bracketInfo(brackets);
return (
<div className={className}>
{hideHeader ? null : <Header2 className="mb-2">Runs volume discount</Header2>}
<ul>
{bracketData.map((bracket, index) => (
<li key={index} className={runsVolumeDiscountRow}>
<Paragraph variant="small">{bracket.range}</Paragraph>
<Paragraph variant="small">{bracket.costLabel}</Paragraph>
</li>
))}
</ul>
</div>
);
}
function bracketInfo(brackets: RunPriceBracket[]) {
return brackets.map((bracket, index) => {
const { upto, unitCost } = bracket;
if (index === 0) {
return {
range: `First ${formatNumberCompact(upto!)}/mo`,
costLabel: "Free",
};
}
const from = brackets[index - 1].upto;
const fromFormatted = formatNumberCompact(from!);
const toFormatted = upto ? formatNumberCompact(upto) : undefined;
const costLabel = `$${(unitCost * 1000).toFixed(2)}/1,000`;
if (!upto) {
return {
range: `${fromFormatted} +`,
costLabel,
};
}
return {
range: `${fromFormatted}${toFormatted}`,
costLabel,
};
});
}
@@ -0,0 +1,36 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { plansPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { Paragraph } from "../primitives/Paragraph";
type UpgradePromptProps = {
organization: MatchedOrganization;
};
export function UpgradePrompt({ organization }: UpgradePromptProps) {
const currentPlan = useCurrentPlan();
if (!currentPlan || !currentPlan.usage.exceededRunCount || !currentPlan.usage.runCountCap) {
return null;
}
return (
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
<Paragraph variant="extra-small" className="text-rose-500">
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
limit
</Paragraph>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
</div>
);
}
@@ -0,0 +1,167 @@
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { Paragraph } from "../primitives/Paragraph";
import { SimpleTooltip } from "../primitives/Tooltip";
import { motion } from "framer-motion";
type UsageBarProps = {
numberOfCurrentRuns: number;
billingLimit?: number;
tierRunLimit?: number;
projectedRuns: number;
subscribedToPaidTier?: boolean;
};
export function UsageBar({
numberOfCurrentRuns,
billingLimit,
tierRunLimit,
projectedRuns,
subscribedToPaidTier = false,
}: UsageBarProps) {
const getLargestNumber = Math.max(
numberOfCurrentRuns,
tierRunLimit ?? -Infinity,
projectedRuns,
billingLimit ?? -Infinity
);
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
const maxRange = Math.round(getLargestNumber * 1.1);
const tierRunLimitPercentage = tierRunLimit ? Math.round((tierRunLimit / maxRange) * 100) : 0;
const projectedRunsPercentage = Math.round((projectedRuns / maxRange) * 100);
const billingLimitPercentage =
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
const usagePercentage = Math.round((numberOfCurrentRuns / maxRange) * 100);
//cap the usagePercentage to the freeRunLimitPercentage
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
return (
<div className="h-fit w-full py-16">
<div className="relative h-3 w-full rounded-sm bg-slate-800">
{billingLimit && (
<motion.div
initial={{ width: 0 }}
animate={{ width: billingLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${billingLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Billing limit:"
value={formatNumberCompact(billingLimit)}
position="bottomRow2"
percentage={billingLimitPercentage}
tooltipContent={`Billing limit: ${formatNumberCompact(billingLimit)}`}
/>
</motion.div>
)}
{tierRunLimit && (
<motion.div
initial={{ width: 0 }}
animate={{ width: tierRunLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${tierRunLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-900/50"
>
<Legend
text={`${subscribedToPaidTier ? "Included free:" : "Free tier limit:"}`}
value={formatNumberCompact(tierRunLimit)}
position="bottomRow1"
percentage={tierRunLimitPercentage}
tooltipContent={`${
subscribedToPaidTier
? `Runs included free: ${formatNumberCompact(tierRunLimit)}`
: `Free tier runs limit: ${formatNumberCompact(tierRunLimit)}`
}`}
/>
</motion.div>
)}
{projectedRuns !== 0 && (
<motion.div
initial={{ width: 0 }}
animate={{ width: projectedRunsPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${projectedRunsPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Projected:"
value={formatNumberCompact(projectedRuns)}
position="topRow2"
percentage={projectedRunsPercentage}
tooltipContent={`Projected runs: ${formatNumberCompact(projectedRuns)}`}
/>
</motion.div>
)}
<motion.div
initial={{ width: 0 }}
animate={{ width: usagePercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usagePercentage}%` }}
className={cn(
"absolute h-3 rounded-l-sm",
subscribedToPaidTier ? "bg-green-600" : "bg-rose-600"
)}
>
<Legend
text="Current:"
value={formatNumberCompact(numberOfCurrentRuns)}
position="topRow1"
percentage={usagePercentage}
tooltipContent={`Current run count: ${formatNumberCompact(numberOfCurrentRuns)}`}
/>
</motion.div>
<motion.div
initial={{ width: 0 }}
animate={{ width: usageCappedToLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usageCappedToLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm bg-green-600"
/>
</div>
</div>
);
}
const positions = {
topRow1: "bottom-0 h-9",
topRow2: "bottom-0 h-14",
bottomRow1: "top-0 h-9 items-end",
bottomRow2: "top-0 h-14 items-end",
};
type LegendProps = {
text: string;
value: number | string;
percentage: number;
position: keyof typeof positions;
tooltipContent: string;
};
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
const flipLegendPositionValue = 80;
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
return (
<div
className={cn(
"absolute left-full z-10 flex border-slate-400",
positions[position],
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
)}
>
<SimpleTooltip
button={
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background px-1.5 text-xs text-dimmed">
{text}
<span className="ml-1 text-bright">{value}</span>
</Paragraph>
}
variant="dark"
side="top"
content={tooltipContent}
className="z-50 h-fit"
/>
</div>
);
}
@@ -1,42 +1,8 @@
import { cn } from "~/utils/cn";
import gradientPath from "./app-container-gradient.svg";
/** This container is used to surround the entire app, it correctly places the nav bar */
export function AppContainer({
children,
showBackgroundGradient,
}: {
children: React.ReactNode;
showBackgroundGradient?: boolean;
}) {
return (
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
</BackgroundGradient>
);
}
export function BackgroundGradient({
children,
showBackgroundGradient,
}: {
children: React.ReactNode;
showBackgroundGradient?: boolean;
}) {
return (
<div
className={cn("h-full w-full bg-contain bg-right-top bg-no-repeat")}
style={
showBackgroundGradient
? {
backgroundImage: `url(${gradientPath})`,
}
: undefined
}
>
{children}
</div>
);
export function AppContainer({ children }: { children: React.ReactNode }) {
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
}
/** This container should be placed around the content on a page */
@@ -1,9 +1,10 @@
import { useNavigation } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
import { cn } from "~/utils/cn";
export function PageNavigationIndicator() {
export function PageNavigationIndicator({ className }: { className?: string }) {
const navigation = useNavigation();
if (navigation.state === "loading") {
return <Spinner color="muted" className="h-4 w-4" />;
return <Spinner color="muted" className={cn("h-4 w-4", className)} />;
}
}
@@ -1,5 +1,6 @@
import {
AcademicCapIcon,
ArrowRightIcon,
ArrowRightOnRectangleIcon,
ChartBarIcon,
EllipsisHorizontalIcon,
@@ -7,11 +8,14 @@ import {
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
import { useFeatures } from "~/hooks/useFeatures";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { usePathName } from "~/hooks/usePathName";
import { MatchedProject } from "~/hooks/useProject";
import { User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -26,14 +30,19 @@ import {
projectEnvironmentsPath,
projectHttpEndpointsPath,
projectPath,
projectRunsPath,
projectSetupPath,
projectTriggersPath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, LinkButton } from "../primitives/Buttons";
import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { type IconNames } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
@@ -45,8 +54,8 @@ import {
PopoverMenuItem,
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<
@@ -65,6 +74,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
useEffect(() => {
const handleScroll = () => {
@@ -120,6 +130,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
to={projectPath(organization, project)}
data-action="jobs"
/>
<SideMenuItem
name="Runs"
icon="runs"
iconColor="text-teal-500"
to={projectRunsPath(organization, project)}
/>
<SideMenuItem
name="Triggers"
icon="trigger"
@@ -131,7 +147,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
<SideMenuItem
name="HTTP endpoints"
icon="http-endpoint"
iconColor="text-green-500"
iconColor="text-pink-500"
count={project.httpEndpointCount}
to={projectHttpEndpointsPath(organization, project)}
data-action="httpendpoints"
@@ -178,12 +194,78 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name={isManagedCloud ? "Usage & Billing" : "Usage"}
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-pink-500"
iconColor="text-green-600"
data-action="usage & billing"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
{currentPlan?.subscription?.isPaying === true ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={SlackIcon}
data-action="join our slack"
fullWidth
textAlignLeft
>
Join our Slack
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Join our Slack</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<div className="flex items-center gap-4">
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
<Paragraph variant="base/bright">
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
support with the Trigger.dev team.
</Paragraph>
</div>
<hr className="border-slate-800" />
<div>
<StepNumber stepNumber="1" title="Create a new Slack channel" />
<StepContentContainer>
<Paragraph>
In your Slack app, create a new channel from the main menu by going to File{" "}
<ArrowRightIcon className="inline h-4 w-4 text-dimmed" /> New Channel
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Setup your channel" />
<StepContentContainer>
<Paragraph>
Name your channel, set its visibility and click 'Create'.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
<StepContentContainer>
<Paragraph>
Invite this email address to your channel:{" "}
<ClipboardField
variant="primary/medium"
value="james@trigger.dev"
className="my-2"
/>
</Paragraph>
<Paragraph>
As soon as we can, we'll accept your invitation and say hello!
</Paragraph>
</StepContentContainer>
</div>
</div>
</DialogContent>
</Dialog>
) : (
<SideMenuItem
name="Join our Discord"
icon={DiscordIcon}
to="https://trigger.dev/discord"
data-action="join our discord"
target="_blank"
/>
)}
<SideMenuItem
name="Documentation"
icon="docs"
@@ -212,6 +294,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</Button>
}
/>
{currentPlan && !currentPlan.subscription?.isPaying && currentPlan.usage.runCountCap && (
<FreePlanUsage
to={organizationBillingPath(organization)}
percentage={currentPlan.usage.currentRunCount / currentPlan.usage.runCountCap}
/>
)}
</div>
</div>
</div>
@@ -402,8 +490,8 @@ function SideMenuItem({
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
)}
>
<div className="flex w-full items-center justify-between overflow-hidden">
<span className="truncate">{name}</span>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">
{count !== undefined && count > 0 && <MenuCount count={count} />}
{typeof hasWarning === "string" ? (
@@ -82,16 +82,16 @@ const variant = {
"primary/large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
button:
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:group-hover:bg-indigo-600",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
shortcut: undefined,
},
"secondary/large": {
textColor: "text-dimmed px-1",
textColor: "text-bright group-disabled:text-dimmed px-1",
button:
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
"h-10 px-2 text-sm transition font-medium bg-slate-800 group-hover:bg-slate-700/70 group-disabled:opacity-40 group-disabled:group-hover:bg-slate-800",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: undefined,
@@ -304,8 +304,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
}
);
type LinkPropsType = Pick<LinkProps, "to" | "target"> & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
type LinkPropsType = Pick<LinkProps, "to" | "target" | "onClick"> &
React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, onClick, ...props }: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
@@ -324,6 +325,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
href={to.toString()}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
onClick={onClick}
>
<ButtonContent {...props} />
</ExtLink>
@@ -334,6 +336,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
to={to}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
onClick={onClick}
>
<ButtonContent {...props} />
</Link>
@@ -7,6 +7,7 @@ import {
import {
ArrowTopRightOnSquareIcon,
BookOpenIcon,
ChartBarIcon,
CheckCircleIcon,
ChevronRightIcon,
} from "@heroicons/react/24/solid";
@@ -58,6 +59,12 @@ export const variantClasses = {
textColor: "text-blue-300",
linkClassName: "transition hover:bg-blue-400/40",
},
pricing: {
className: "border-indigo-400/20 bg-indigo-800/30",
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-indigo-500" />,
textColor: "text-indigo-200",
linkClassName: "transition hover:bg-indigo-400/40",
},
} as const;
export type CalloutVariant = keyof typeof variantClasses;
@@ -66,12 +73,14 @@ export function Callout({
children,
className,
icon,
cta,
variant,
to,
}: {
children?: React.ReactNode;
className?: string;
icon?: React.ReactNode;
cta?: React.ReactNode;
variant: CalloutVariant;
to?: string;
}) {
@@ -84,13 +93,13 @@ export function Callout({
href={to}
target="_blank"
className={cn(
`flex w-full items-start justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
`flex w-full items-center justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
variantDefinition.className,
variantDefinition.linkClassName,
className
)}
>
<div className={"flex w-full items-start gap-x-2"}>
<div className={"flex w-full items-center gap-x-2"}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
@@ -135,20 +144,24 @@ export function Callout({
return (
<div
className={cn(
`flex w-full items-start gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
"flex w-full items-center gap-2 rounded-md border pl-2 pr-2 shadow-md backdrop-blur-sm",
cta ? "py-2" : "py-2.5",
variantDefinition.className,
className
)}
>
{icon ? icon : variantDefinition.icon}
<div className={cn(`flex w-full items-center gap-2.5`)}>
{icon ? icon : variantDefinition.icon}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
{typeof children === "string" ? (
<Paragraph variant={"small"} className={variantDefinition.textColor}>
{children}
</Paragraph>
) : (
children
)}
</div>
{cta && cta}
</div>
);
}
@@ -5,14 +5,26 @@ type DateTimeProps = {
date: Date | string;
timeZone?: string;
includeSeconds?: boolean;
includeTime?: boolean;
};
export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: DateTimeProps) => {
export const DateTime = ({
date,
timeZone = "UTC",
includeSeconds = true,
includeTime = true,
}: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTime(realDate, timeZone, locales, includeSeconds);
const initialFormattedDateTime = formatDateTime(
realDate,
timeZone,
locales,
includeSeconds,
includeTime
);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
@@ -20,26 +32,27 @@ export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: Date
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds)
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
);
}, [locales, includeSeconds, realDate]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
function formatDateTime(
export function formatDateTime(
date: Date,
timeZone: string,
locales: string[],
includeSeconds: boolean
includeSeconds: boolean,
includeTime: boolean
): string {
return new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: includeSeconds ? "numeric" : undefined,
hour: includeTime ? "numeric" : undefined,
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
}).format(date);
}
@@ -26,7 +26,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
"fixed inset-0 z-50 bg-background/90 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
@@ -1,72 +0,0 @@
import { RadioGroup } from "@headlessui/react";
import { cn } from "~/utils/cn";
type Options = {
label: string;
value: string;
};
type FormSegmentedControlProps = {
name: string;
defaultValue?: string;
options: Options[];
onChange?: (value: string) => void;
};
export default function FormSegmentedControl({
name,
defaultValue,
options,
onChange,
}: FormSegmentedControlProps) {
return (
<div className="flex h-8 w-fit rounded bg-slate-800 p-1">
<RadioGroup
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
>
<div className="flex gap-x-1.5">
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ active, checked }) =>
cn(
"relative flex cursor-pointer rounded-[2px] px-3 py-[0.13rem] shadow-md focus:outline-none",
active
? "focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
: "",
checked
? "bg-slate-700 text-bright"
: "bg-transparent transition hover:bg-slate-750"
)
}
>
{({ checked }) => (
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className={cn("font-normal", checked ? "text-bright" : "text-dimmed")}
>
{option.label}
</RadioGroup.Label>
</div>
</div>
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
@@ -0,0 +1,79 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
type Options = {
label: string;
value: string;
};
type SegmentedControlProps = {
name: string;
value?: string;
defaultValue?: string;
options: Options[];
fullWidth?: boolean;
onChange?: (value: string) => void;
};
export default function SegmentedControl({
name,
value,
defaultValue,
options,
fullWidth,
onChange,
}: SegmentedControlProps) {
return (
<div className={cn("flex h-10 rounded bg-slate-850", fullWidth ? "w-full" : "w-fit")}>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
name={name}
onChange={(c: string) => {
if (onChange) {
onChange(c);
}
}}
className="w-full"
>
<div className="flex h-full w-full items-center justify-between gap-x-1 p-1">
{options.map((option) => (
<RadioGroup.Option
key={option.value}
value={option.value}
className={({ active, checked }) =>
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
: "",
checked
? "text-bright"
: "rounded-[2px] text-dimmed transition hover:bg-slate-750/50 hover:text-bright"
)
}
>
{({ checked }) => (
<>
<div className="relative flex h-full w-full items-center justify-between px-3 py-[0.13rem]">
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] bg-indigo-600 shadow-md"
></motion.div>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
);
}
+11 -13
View File
@@ -5,6 +5,7 @@ import { Toaster, toast } from "sonner";
import { useTypedLoaderData } from "remix-typedjson";
import { loader } from "~/root";
import { useEffect } from "react";
import { Paragraph } from "./Paragraph";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -38,26 +39,23 @@ export function ToastUI({
}) {
return (
<div
className={`self-end rounded-lg border border-slate-750 bg-midnight-900 shadow-md`}
className={`self-end rounded-md border border-border bg-background`}
style={{
width: toastWidth,
}}
>
<div
className="flex w-full gap-2 rounded-lg bg-no-repeat p-4 text-bright"
style={{
background:
"radial-gradient(at top, hsla(271, 91%, 65%, 0.18), hsla(221, 83%, 53%, 0.18)) hsla(221, 83%, 53%, 0.18)",
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
{variant === "success" ? (
<CheckCircleIcon className="h-6 w-6 text-green-600" />
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
) : (
<ExclamationCircleIcon className="h-6 w-6 text-rose-600" />
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
)}
{message}
<button className="ms-auto p-1" onClick={() => toast.dismiss(t)}>
<XMarkIcon className="h-4 w-4 text-bright" />
<Paragraph className="py-1 text-dimmed">{message}</Paragraph>
<button
className="ms-auto rounded p-2 text-dimmed transition hover:bg-midnight-800 hover:text-bright"
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
@@ -1,9 +1,15 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-popover border border-slate-800 rounded-md px-3 py-1.5 text-sm text-bright shadow-md fade-in-50",
dark: "bg-background border border-border rounded px-3 py-2 text-sm text-bright shadow-md fade-in-50",
};
type Variant = keyof typeof variantClasses;
const TooltipProvider = TooltipPrimitive.Provider;
const TooltipArrow = React.forwardRef<
@@ -22,15 +28,20 @@ Tooltip.displayName = TooltipPrimitive.Root.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger;
type TooltipContentProps = {
variant?: Variant;
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
TooltipContentProps
>(({ className, sideOffset = 4, variant = "basic", ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border border-slate-800 bg-popover px-3 py-1.5 text-sm text-bright shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
variantClasses[variant],
className
)}
{...props}
@@ -43,17 +54,26 @@ function SimpleTooltip({
content,
side,
hidden,
variant,
className,
}: {
button: React.ReactNode;
content: React.ReactNode;
side?: React.ComponentProps<typeof TooltipContent>["side"];
hidden?: boolean;
variant?: Variant;
className?: string;
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent side={side} hidden={hidden} className="text-xs">
<TooltipTrigger className="h-fit">{button}</TooltipTrigger>
<TooltipContent
side={side}
hidden={hidden}
className={cn("text-xs", className)}
variant={variant}
>
{content}
</TooltipContent>
</Tooltip>
+27 -28
View File
@@ -10,13 +10,14 @@ import {
useNavigate,
useNavigation,
} from "@remix-run/react";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { useMemo } from "react";
import { usePathName } from "~/hooks/usePathName";
import type { RunBasicStatus } from "~/models/jobRun.server";
import { ViewRun } from "~/presenters/RunPresenter.server";
import { cancelSchema } from "~/routes/resources.runs.$runId.cancel";
import { schema } from "~/routes/resources.runs.$runId.rerun";
import { formatDuration } from "~/utils";
import { formatDuration, formatDurationMilliseconds } from "~/utils";
import { cn } from "~/utils/cn";
import { runCompletedPath, runTaskPath, runTriggerPath } from "~/utils/pathBuilder";
import { CodeBlock } from "../code/CodeBlock";
@@ -26,6 +27,7 @@ import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { DateTime } from "../primitives/DateTime";
import { Header2 } from "../primitives/Headers";
import { Icon } from "../primitives/Icon";
import { NamedIcon } from "../primitives/NamedIcon";
import {
PageButtons,
@@ -38,14 +40,7 @@ import {
} from "../primitives/PageHeader";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import {
RunBasicStatus,
RunStatusIcon,
RunStatusLabel,
hasFinished,
runBasicStatus,
runStatusTitle,
} from "../runs/RunStatuses";
import { RunStatusIcon, RunStatusLabel, runStatusTitle } from "../runs/RunStatuses";
import {
RunPanel,
RunPanelBody,
@@ -95,8 +90,6 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
}
}, [pathName]);
const basicStatus = runBasicStatus(run.status);
return (
<PageContainer>
<PageHeader>
@@ -106,7 +99,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
to: paths.back,
text: "Runs",
}}
title={`Run #${run.number}`}
title={
typeof run.number === "number" ? `Run #${run.number}` : `Run ${run.id.slice(0, 8)}`
}
/>
<PageButtons>
{run.isTest && (
@@ -115,15 +110,15 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
Test run
</span>
)}
{showRerun && hasFinished(run.status) && (
{showRerun && run.isFinished && (
<RerunPopover
runId={run.id}
runsPath={paths.runsPath}
environmentType={run.environment.type}
status={basicStatus}
status={run.basicStatus}
/>
)}
{!hasFinished(run.status) && <CancelRun runId={run.id} />}
{!run.isFinished && <CancelRun runId={run.id} />}
</PageButtons>
</PageTitleRow>
<PageInfoRow>
@@ -146,11 +141,21 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
<PageInfoProperty
icon={"clock"}
label={"Duration"}
value={formatDuration(run.startedAt, run.completedAt)}
value={formatDuration(run.startedAt, run.completedAt, { style: "short" })}
/>
<PageInfoProperty
icon={<Icon icon="alarm-filled" className="h-4 w-4 text-blue-500" />}
label={"Execution Time"}
value={formatDurationMilliseconds(run.executionDuration, { style: "short" })}
/>
<PageInfoProperty
icon={<Icon icon="list-numbers" className="h-4 w-4 text-yellow-500" />}
label={"Execution Count"}
value={<>{run.executionCount}</>}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
<Paragraph variant="extra-small" className="whitespace-nowrap text-slate-600">
RUN ID: {run.id}
</Paragraph>
</PageInfoGroup>
@@ -211,10 +216,10 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
);
})
) : (
<BlankTasks status={run.status} basicStatus={basicStatus} />
<BlankTasks status={run.basicStatus} />
)}
</div>
{(basicStatus === "COMPLETED" || basicStatus === "FAILED") && (
{(run.basicStatus === "COMPLETED" || run.basicStatus === "FAILED") && (
<div>
<Header2 className={cn("mb-2")}>Run Summary</Header2>
<RunPanel
@@ -285,14 +290,8 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
);
}
function BlankTasks({
status,
basicStatus,
}: {
status: JobRunStatus;
basicStatus: RunBasicStatus;
}) {
switch (basicStatus) {
function BlankTasks({ status }: { status: RunBasicStatus }) {
switch (status) {
default:
case "COMPLETED":
return <Paragraph variant="small">There were no tasks for this run.</Paragraph>;
+21 -43
View File
@@ -3,6 +3,7 @@ import {
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PauseCircleIcon,
WrenchIcon,
XCircleIcon,
} from "@heroicons/react/24/solid";
@@ -10,18 +11,6 @@ import type { JobRunStatus } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { Spinner } from "../primitives/Spinner";
export function hasFinished(status: JobRunStatus): boolean {
return (
status === "SUCCESS" ||
status === "FAILURE" ||
status === "ABORTED" ||
status === "TIMED_OUT" ||
status === "CANCELED" ||
status === "UNRESOLVED_AUTH" ||
status === "INVALID_PAYLOAD"
);
}
export function RunStatus({ status }: { status: JobRunStatus }) {
return (
<span className="flex items-center gap-1">
@@ -40,49 +29,26 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla
case "SUCCESS":
return <CheckCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PENDING":
case "WAITING_TO_CONTINUE":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "QUEUED":
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_TO_EXECUTE":
return <PauseCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PREPROCESSING":
case "STARTED":
case "EXECUTING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "FAILURE":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "TIMED_OUT":
return <ExclamationTriangleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "UNRESOLVED_AUTH":
case "FAILURE":
case "ABORTED":
case "INVALID_PAYLOAD":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "WAITING_ON_CONNECTIONS":
return <WrenchIcon className={cn(runStatusClassNameColor(status), className)} />;
case "ABORTED":
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
case "PREPROCESSING":
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
}
}
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
switch (status) {
case "WAITING_ON_CONNECTIONS":
case "QUEUED":
case "PREPROCESSING":
case "PENDING":
return "PENDING";
case "STARTED":
return "RUNNING";
case "FAILURE":
case "TIMED_OUT":
case "UNRESOLVED_AUTH":
case "CANCELED":
case "ABORTED":
case "INVALID_PAYLOAD":
return "FAILED";
case "SUCCESS":
return "COMPLETED";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
@@ -99,7 +65,12 @@ export function runStatusTitle(status: JobRunStatus): string {
case "STARTED":
return "In progress";
case "QUEUED":
case "WAITING_TO_EXECUTE":
return "Queued";
case "EXECUTING":
return "Executing";
case "WAITING_TO_CONTINUE":
return "Waiting";
case "FAILURE":
return "Failed";
case "TIMED_OUT":
@@ -130,9 +101,12 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
case "PENDING":
return "text-slate-500";
case "STARTED":
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "text-blue-500";
case "QUEUED":
return "text-amber-300";
return "text-slate-500";
case "FAILURE":
case "UNRESOLVED_AUTH":
case "INVALID_PAYLOAD":
@@ -147,5 +121,9 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
return "text-blue-500";
case "CANCELED":
return "text-slate-500";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
+25 -8
View File
@@ -1,7 +1,7 @@
import { StopIcon } from "@heroicons/react/24/outline";
import { CheckIcon } from "@heroicons/react/24/solid";
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
import { formatDuration } from "~/utils";
import { formatDuration, formatDurationMilliseconds } from "~/utils";
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
@@ -20,14 +20,16 @@ import { RunStatus } from "./RunStatuses";
type RunTableItem = {
id: string;
number: number;
number: number | null;
environment: {
type: RuntimeEnvironmentType;
};
job: { title: string; slug: string };
status: JobRunStatus;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date | null;
executionDuration: number;
version: string;
isTest: boolean;
};
@@ -35,6 +37,7 @@ type RunTableItem = {
type RunsTableProps = {
total: number;
hasFilters: boolean;
showJob?: boolean;
runs: RunTableItem[];
isLoading?: boolean;
runsParentPath: string;
@@ -45,6 +48,7 @@ export function RunsTable({
hasFilters,
runs,
isLoading = false,
showJob = false,
runsParentPath,
}: RunsTableProps) {
return (
@@ -52,10 +56,12 @@ export function RunsTable({
<TableHeader>
<TableRow>
<TableHeaderCell>Run</TableHeaderCell>
{showJob && <TableHeaderCell>Job</TableHeaderCell>}
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Exec Time</TableHeaderCell>
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
@@ -66,19 +72,24 @@ export function RunsTable({
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs found" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs match your filters" />
<TableBlankRow colSpan={showJob ? 10 : 9}>
<NoRuns title="No runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
const path = `${runsParentPath}/${run.id}/trigger`;
const path = showJob
? `${runsParentPath}/jobs/${run.job.slug}/runs/${run.id}/trigger`
: `${runsParentPath}/${run.id}/trigger`;
return (
<TableRow key={run.id}>
<TableCell to={path}>#{run.number}</TableCell>
<TableCell to={path}>
{typeof run.number === "number" ? `#${run.number}` : "-"}
</TableCell>
{showJob && <TableCell to={path}>{run.job.slug}</TableCell>}
<TableCell to={path}>
<EnvironmentLabel environment={run.environment} />
</TableCell>
@@ -93,6 +104,11 @@ export function RunsTable({
style: "short",
})}
</TableCell>
<TableCell to={path}>
{formatDurationMilliseconds(run.executionDuration, {
style: "short",
})}
</TableCell>
<TableCell to={path}>
{run.isTest ? (
<CheckIcon className="h-4 w-4 text-slate-400" />
@@ -121,6 +137,7 @@ export function RunsTable({
</Table>
);
}
function NoRuns({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
@@ -61,11 +61,11 @@ export function WebhookDeliveryRunsTable({
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs found for this Job" />
<NoRuns title="No runs found for this job" />
</TableBlankRow>
) : runs.length === 0 ? (
<TableBlankRow colSpan={8}>
<NoRuns title="No Runs match your filters" />
<NoRuns title="No runs match your filters" />
</TableBlankRow>
) : (
runs.map((run) => {
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { MatchedOrganization } from "~/hooks/useOrganizations";
const meta: Meta<typeof FreePlanUsageBar> = {
title: "Billing/FreePlanUsage",
component: FreePlanUsageBar,
};
export default meta;
type Story = StoryObj<typeof FreePlanUsageBar>;
const mockOrganization: MatchedOrganization = {
id: "mockID",
title: "mockTitle",
slug: "mockSlug",
projects: [
{ id: "mockId1", slug: "mockSlug1", name: "mockName1", jobCount: 1 },
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
],
hasUnconfiguredIntegrations: false,
memberCount: 1,
};
export const ProgressBar: Story = {
args: {
organization: mockOrganization,
},
render: (args) => <FreePlanUsageBar {...args} />,
};
type FreePlanUsageBarProps = {
organization: MatchedOrganization;
};
function FreePlanUsageBar({ organization }: FreePlanUsageBarProps) {
return (
<div className="flex h-screen flex-col items-center justify-center p-12">
<div className="w-fit">
<FreePlanUsage to={organizationBillingPath(organization)} percentage={0.75} />
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import type { Meta, StoryObj } from "@storybook/react";
import { LinkButton } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
const meta: Meta<typeof PricingCallouts> = {
title: "Billing/PricingCallouts",
component: PricingCallouts,
};
export default meta;
type Story = StoryObj<typeof PricingCallouts>;
export const Callouts: Story = {
render: (args) => <PricingCallouts />,
};
function PricingCallouts() {
return (
<div className="mx-4 flex h-screen flex-col items-center justify-center gap-4">
<Callout
variant={"pricing"}
cta={
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="pr-0 pl-0.5"
to="#"
>
Upgrade
</LinkButton>
}
>
Some of your runs are being queued because your run concurrency is limited to 50.
</Callout>
</div>
);
}
@@ -1,19 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { withDesign } from "storybook-addon-designs";
import FormSegmentedControl from "../primitives/FormSegmentedControl";
import { MainCenteredContainer } from "../layout/AppLayout";
import SegmentedControl from "../primitives/SegmentedControl";
const meta: Meta = {
title: "Primitives/FormSegmentedControl",
const meta: Meta<typeof StyledSegmentedControl> = {
title: "Primitives/SegmentedControl",
decorators: [withDesign],
component: StyledSegmentedControl,
};
export default meta;
type Story = StoryObj<typeof FormSegmentedControl>;
type Story = StoryObj<typeof StyledSegmentedControl>;
export const Basic: Story = {
render: () => <SegmentedControl />,
render: () => <StyledSegmentedControl />,
};
Basic.parameters = {
@@ -28,10 +29,10 @@ const options = [
{ label: "Label 2", value: "Users" },
];
function SegmentedControl() {
function StyledSegmentedControl() {
return (
<MainCenteredContainer>
<FormSegmentedControl name="name" options={options} />
<SegmentedControl name="name" options={options} />
</MainCenteredContainer>
);
}
@@ -20,6 +20,11 @@ function Collection() {
<div className="flex flex-col items-start gap-y-4 p-4">
<ToastUI variant="success" message="Success UI" t="-" />
<ToastUI variant="error" message="Error UI" t="-" />
<ToastUI
variant="success"
message="This is a long error message that wraps over multiple lines so we can test the UI."
t="-"
/>
<br />
<Button
variant="primary/large"
@@ -41,6 +46,7 @@ function Collection() {
>
Error
</Button>
<Toaster />
</div>
);
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react";
import { UsageBar } from "../billing/UsageBar";
import { Paragraph } from "../primitives/Paragraph";
const meta: Meta<typeof UsageProgressBar> = {
title: "Billing/UsageBar",
component: UsageProgressBar,
};
export default meta;
type Story = StoryObj<typeof UsageProgressBar>;
export const JobsUsageBar: Story = {
render: () => <UsageProgressBar />,
};
function UsageProgressBar() {
return (
<div className="flex flex-col justify-center gap-4 p-12">
<UsageBarWrapper title="Usage within the free tier limit">
<UsageBar numberOfCurrentRuns={30000} tierRunLimit={50000} projectedRuns={120000} />
</UsageBarWrapper>
<UsageBarWrapper title="Usage over the free tier limit">
<UsageBar numberOfCurrentRuns={90000} tierRunLimit={50000} projectedRuns={120000} />
</UsageBarWrapper>
<UsageBarWrapper title="Billing limit set">
<UsageBar
numberOfCurrentRuns={35674}
tierRunLimit={50000}
projectedRuns={120000}
billingLimit={180000}
/>
</UsageBarWrapper>
<UsageBarWrapper title="Paid subscriber under the free included Runs">
<UsageBar
numberOfCurrentRuns={10000}
tierRunLimit={50000}
billingLimit={180000}
projectedRuns={120000}
subscribedToPaidTier
/>
</UsageBarWrapper>
<UsageBarWrapper title="Paid subscriber over the free included Runs">
<UsageBar
numberOfCurrentRuns={90000}
tierRunLimit={50000}
billingLimit={180000}
projectedRuns={120000}
subscribedToPaidTier
/>
</UsageBarWrapper>
<UsageBarWrapper title="Brand new user usage">
<UsageBar numberOfCurrentRuns={0} tierRunLimit={50000} projectedRuns={0} />
</UsageBarWrapper>
<UsageBarWrapper title="Overlapping UI example">
<UsageBar
numberOfCurrentRuns={95000}
tierRunLimit={50000}
billingLimit={55000}
projectedRuns={93132}
subscribedToPaidTier
/>
</UsageBarWrapper>
</div>
);
}
function UsageBarWrapper({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border border-border p-6">
<Paragraph>{title}</Paragraph>
{children}
</div>
);
}
+1 -1
View File
@@ -8,4 +8,4 @@ export const EXECUTE_JOB_RETRY_LIMIT = 10;
export const MAX_RUN_YIELDED_EXECUTIONS = 100;
export const RUN_CHUNK_EXECUTION_BUFFER = 350;
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
export const RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
+16 -8
View File
@@ -18,14 +18,7 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
APP_ENV: z
.union([
z.literal("development"),
z.literal("production"),
z.literal("test"),
z.literal("staging"),
])
.default(process.env.NODE_ENV),
APP_ENV: z.string().default(process.env.NODE_ENV),
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
POSTHOG_PROJECT_KEY: z.string().optional(),
TELEMETRY_TRIGGER_API_KEY: z.string().optional(),
@@ -59,6 +52,21 @@ const EnvironmentSchema = z.object({
AWS_SQS_QUEUE_URL: z.string().optional(),
AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(10),
DISABLE_SSE: z.string().optional(),
// Redis options
REDIS_HOST: z.string().optional(),
REDIS_READER_HOST: z.string().optional(),
REDIS_READER_PORT: z.coerce.number().optional(),
REDIS_PORT: z.coerce.number().optional(),
REDIS_USERNAME: z.string().optional(),
REDIS_PASSWORD: z.string().optional(),
REDIS_TLS_DISABLED: z.string().optional(),
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
TUNNEL_HOST: z.string().optional(),
TUNNEL_SECRET_KEY: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+1
View File
@@ -12,6 +12,7 @@ export function featuresForRequest(request: Request): TriggerFeatures {
const isManagedCloud =
url.host === "cloud.trigger.dev" ||
url.host === "test-cloud.trigger.dev" ||
url.host === "internal.trigger.dev" ||
process.env.CLOUD_ENV === "development";
return {
@@ -0,0 +1,55 @@
import { useEffect } from "react";
export function useNewCustomerSubscribed() {
useEffect(() => {
if ("confetti" in window && typeof window.confetti !== "undefined") {
const duration = 3.5 * 1000;
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: [
"#E7FF52",
"#41FF54",
"rgb(245 158 11)",
"rgb(22 163 74)",
"rgb(37 99 235)",
"rgb(67 56 202)",
"rgb(219 39 119)",
"rgb(225 29 72)",
"rgb(217 70 239)",
],
};
function randomInRange(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
// @ts-ignore
const interval = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 60 * (timeLeft / duration);
// since particles fall down, start a bit higher than random
// @ts-ignore
window.confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.1, 0.4), y: Math.random() - 0.2 },
})
);
// @ts-ignore
window.confetti(
Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.6, 0.9), y: Math.random() - 0.2 },
})
);
}, 250);
}
}, []);
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson";
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { hydrateObject, useMatchesData } from "~/utils";
import { useChanged } from "./useChanged";
import { UIMatch } from "@remix-run/react";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
+23 -3
View File
@@ -1,4 +1,4 @@
import { RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
import { VERCEL_RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
import { prisma } from "~/db.server";
import { Prettify } from "~/lib.es5";
@@ -20,13 +20,33 @@ export async function findEndpoint(id: string) {
});
}
export function detectResponseIsTimeout(response?: Response) {
export function detectResponseIsTimeout(rawBody: string, response?: Response) {
if (!response) {
return false;
}
return (
RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
isResponseVercelTimeout(response) ||
isResponseDenoDeployTimeout(rawBody, response) ||
isResponseCloudflareTimeout(rawBody, response)
);
}
function isResponseCloudflareTimeout(rawBody: string, response: Response) {
return (
response.status === 503 &&
rawBody.includes("Worker exceeded resource limits") &&
typeof response.headers.get("cf-ray") === "string"
);
}
function isResponseVercelTimeout(response: Response) {
return (
VERCEL_RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
response.headers.get("x-vercel-error") === "FUNCTION_INVOCATION_TIMEOUT"
);
}
function isResponseDenoDeployTimeout(rawBody: string, response: Response) {
return response.status === 502 && rawBody.includes("TIME_LIMIT");
}
+56
View File
@@ -0,0 +1,56 @@
import type { JobRun, JobRunStatus } from "@trigger.dev/database";
const COMPLETED_STATUSES: Array<JobRun["status"]> = [
"CANCELED",
"ABORTED",
"SUCCESS",
"TIMED_OUT",
"INVALID_PAYLOAD",
"FAILURE",
"UNRESOLVED_AUTH",
];
export function isRunCompleted(status: JobRunStatus) {
return COMPLETED_STATUSES.includes(status);
}
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
switch (status) {
case "WAITING_ON_CONNECTIONS":
case "QUEUED":
case "PREPROCESSING":
case "PENDING":
return "PENDING";
case "STARTED":
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "RUNNING";
case "FAILURE":
case "TIMED_OUT":
case "UNRESOLVED_AUTH":
case "CANCELED":
case "ABORTED":
case "INVALID_PAYLOAD":
return "FAILED";
case "SUCCESS":
return "COMPLETED";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function runOriginalStatus(status: JobRunStatus) {
switch (status) {
case "EXECUTING":
case "WAITING_TO_CONTINUE":
case "WAITING_TO_EXECUTE":
return "STARTED";
default:
return status;
}
}
@@ -1,47 +0,0 @@
import { JobRun } from "@trigger.dev/database";
import { PrismaClientOrTransaction } from "~/db.server";
import { executionWorker } from "~/services/worker.server";
export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) {
return await executionWorker.dequeue(`job_run:${run.id}`, {
tx,
});
}
export type EnqueueRunExecutionV3Options = {
runAt?: Date;
skipRetrying?: boolean;
};
export async function enqueueRunExecutionV3(
run: JobRun,
tx: PrismaClientOrTransaction,
options: EnqueueRunExecutionV3Options = {}
) {
const reason = run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB";
return await executionWorker.enqueue(
"performRunExecutionV3",
{
id: run.id,
reason: reason,
},
{
tx,
runAt: options.runAt,
queueName: `job_run:${run.id}`,
jobKey: `job_run:${reason}:${run.id}`,
maxAttempts: options.skipRetrying ? 1 : undefined,
}
);
}
export async function dequeueRunExecutionV3(run: JobRun, tx: PrismaClientOrTransaction) {
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
tx,
});
await executionWorker.dequeue(`job_run:PREPROCESS:${run.id}`, {
tx,
});
}
+27 -1
View File
@@ -94,6 +94,11 @@ export type ZodWorkerCleanupOptions = {
type ZodWorkerReporter = (event: string, properties: Record<string, any>) => Promise<void>;
export interface ZodWorkerRateLimiter {
forbiddenFlags(): Promise<string[]>;
wrapTask(t: Task, rescheduler: Task): Task;
}
export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
name: string;
runnerOptions: RunnerOptions;
@@ -104,6 +109,7 @@ export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
cleanup?: ZodWorkerCleanupOptions;
reporter?: ZodWorkerReporter;
shutdownTimeoutInMs?: number;
rateLimiter?: ZodWorkerRateLimiter;
};
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
@@ -116,6 +122,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
#runner?: GraphileRunner;
#cleanup: ZodWorkerCleanupOptions | undefined;
#reporter?: ZodWorkerReporter;
#rateLimiter?: ZodWorkerRateLimiter;
#shutdownTimeoutInMs?: number;
#shuttingDown = false;
@@ -128,6 +135,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#recurringTasks = options.recurringTasks;
this.#cleanup = options.cleanup;
this.#reporter = options.reporter;
this.#rateLimiter = options.rateLimiter;
this.#shutdownTimeoutInMs = options.shutdownTimeoutInMs ?? 60000; // default to 60 seconds
}
@@ -151,6 +159,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
noHandleSignals: true,
taskList: this.#createTaskListFromTasks(),
parsedCronItems,
forbiddenFlags: this.#rateLimiter?.forbiddenFlags.bind(this.#rateLimiter),
});
if (!this.#runner) {
@@ -395,7 +404,11 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return this.#handleMessage(key, payload, helpers);
};
taskList[key] = task;
if (this.#rateLimiter) {
taskList[key] = this.#rateLimiter.wrapTask(task, this.#rescheduleTask.bind(this));
} else {
taskList[key] = task;
}
}
for (const [key] of Object.entries(this.#recurringTasks ?? {})) {
@@ -425,6 +438,19 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return taskList;
}
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
await this.enqueue(helpers.job.task_identifier, payload, {
runAt: helpers.job.run_at,
queueName: helpers.job.queue_name ?? undefined,
priority: helpers.job.priority,
jobKey: helpers.job.key ?? undefined,
flags: Object.keys(helpers.job.flags ?? []),
maxAttempts: helpers.job.max_attempts,
});
}
#createCronItemsFromRecurringTasks() {
const cronItems: CronItem[] = [];
@@ -43,6 +43,13 @@ export class JobPresenter {
eventSpecification: true,
properties: true,
status: true,
concurrencyLimit: true,
concurrencyLimitGroup: {
select: {
name: true,
concurrencyLimit: true,
},
},
runs: {
select: {
createdAt: true,
@@ -186,6 +193,8 @@ export class JobPresenter {
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
concurrencyLimit: alias.version.concurrencyLimit,
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
}));
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
@@ -0,0 +1,60 @@
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { BillingService } from "../services/billing.server";
export class OrgBillingPlanPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ slug, isManagedCloud }: { slug: string; isManagedCloud: boolean }) {
const billingPresenter = new BillingService(isManagedCloud);
const plans = await billingPresenter.getPlans();
if (plans === undefined) {
return;
}
const organization = await this.#prismaClient.organization.findFirst({
where: {
slug,
},
});
if (!organization) {
return;
}
const maxConcurrency = await this.#prismaClient.$queryRaw<
{ organization_id: string; max_concurrent_runs: BigInt }[]
>`WITH events AS (
SELECT
re.event_time,
re.organization_id,
re.event_type,
SUM(re.event_type) OVER (PARTITION BY re.organization_id ORDER BY re.event_time) AS running_total
FROM
triggerdotdev_events.run_executions re
WHERE
re.organization_id = ${organization.id}
AND re.event_time >= DATE_TRUNC('month',
CURRENT_DATE)
)
SELECT
organization_id, MAX(running_total) AS max_concurrent_runs
FROM
events
GROUP BY
organization_id;`;
return {
plans,
maxConcurrency:
maxConcurrency.at(0) !== undefined
? Number(maxConcurrency[0].max_concurrent_runs)
: undefined,
};
}
}
@@ -1,4 +1,8 @@
import { estimate } from "@trigger.dev/billing";
import { formatDateTime } from "~/components/primitives/DateTime";
import { PrismaClient, prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
export class OrgUsagePresenter {
@@ -8,7 +12,7 @@ export class OrgUsagePresenter {
this.#prismaClient = prismaClient;
}
public async call({ userId, slug }: { userId: string; slug: string }) {
public async call({ userId, slug, request }: { userId: string; slug: string; request: Request }) {
const organization = await this.#prismaClient.organization.findFirst({
where: {
slug,
@@ -24,9 +28,6 @@ export class OrgUsagePresenter {
return;
}
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const startOfLastMonth = new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1); // this works for January as well
// Get count of runs since the start of the current month
const runsCount = await this.#prismaClient.jobRun.count({
where: {
@@ -38,18 +39,6 @@ export class OrgUsagePresenter {
},
});
// Get the count of runs for last month
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
where: {
organizationId: organization.id,
createdAt: {
gte: startOfLastMonth,
lt: startOfMonth,
},
internal: false,
},
});
// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
// [
// { month: "2021-01", count: 10 },
@@ -61,100 +50,121 @@ export class OrgUsagePresenter {
// ]
// This will be used to generate the chart on the usage page
// Use prisma queryRaw for this since prisma doesn't support grouping by month
const chartDataRaw = await this.#prismaClient.$queryRaw<
const monthlyRunsDataRaw = await this.#prismaClient.$queryRaw<
{
month: string;
count: number;
}[]
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
const chartData = chartDataRaw.map((obj) => ({
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
name: obj.month,
total: Number(obj.count), // Convert BigInt to Number
}));
const totalJobs = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
internal: false,
},
});
const monthlyRunsDataDisplay = fillInMissingRunMonthlyData(monthlyRunsData, 6);
const totalJobsLastMonth = await this.#prismaClient.job.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
deletedAt: null,
internal: false,
},
});
// Max concurrency each day over past 30 days
const concurrencyChartRawData = await this.#prismaClient.$queryRaw<
{ day: Date; max_concurrent_runs: BigInt }[]
>`
WITH time_boundaries AS (
SELECT generate_series(
NOW() - interval '30 days',
NOW(),
interval '1 day'
) AS day_start
),
events AS (
SELECT
day_start,
event_time,
event_type,
SUM(event_type) OVER (ORDER BY event_time) AS running_total
FROM
time_boundaries
JOIN
triggerdotdev_events.run_executions
ON
event_time >= day_start AND event_time < day_start + interval '1 day'
WHERE triggerdotdev_events.run_executions.organization_id = ${organization.id}
),
max_concurrent_per_day AS (
SELECT
date_trunc('day', event_time) AS day,
MAX(running_total) AS max_concurrent_runs
FROM
events
GROUP BY day
)
SELECT
day,
max_concurrent_runs
FROM
max_concurrent_per_day
ORDER BY
day;`;
const totalIntegrations = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
},
});
const ThirtyDaysAgo = new Date();
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
ThirtyDaysAgo.setHours(0, 0, 0, 0);
const totalIntegrationsLastMonth = await this.#prismaClient.integration.count({
where: {
organizationId: organization.id,
createdAt: {
lt: startOfMonth,
},
},
});
const hasConcurrencyData = concurrencyChartRawData.length > 0;
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
ThirtyDaysAgo,
31,
concurrencyChartRawData
);
const totalMembers = await this.#prismaClient.orgMember.count({
where: {
organizationId: organization.id,
},
});
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
endOfMonth.setDate(endOfMonth.getDate() - 1);
const projectedRunsCount = Math.round(
runsCount / (new Date().getDate() / endOfMonth.getDate())
);
const jobs = await this.#prismaClient.job.findMany({
where: {
organizationId: organization.id,
deletedAt: null,
internal: false,
},
select: {
id: true,
slug: true,
_count: {
select: {
runs: {
where: {
createdAt: {
gte: startOfMonth,
},
},
},
},
},
project: {
select: {
id: true,
name: true,
slug: true,
},
},
},
});
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const plans = await billingPresenter.getPlans();
const chartDataDisplay = fillInMissingMonthlyData(chartData, 6);
let runCostEstimation: number | undefined = undefined;
let projectedRunCostEstimation: number | undefined = undefined;
if (plans) {
const estimationResult = estimate({
usage: { runs: runsCount },
plans: [plans.free, plans.paid],
});
runCostEstimation = estimationResult?.cost.runsCost;
const projectedEstimationResult = estimate({
usage: { runs: projectedRunsCount },
plans: [plans.free, plans.paid],
});
projectedRunCostEstimation = projectedEstimationResult?.cost.runsCost;
}
const periodStart = new Date();
periodStart.setDate(1);
periodStart.setHours(0, 0, 0, 0);
const periodEnd = new Date();
periodEnd.setDate(1);
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setHours(0, 0, 0, 0);
return {
id: organization.id,
runsCount,
runsCountLastMonth,
chartData: chartDataDisplay,
totalJobs,
totalJobsLastMonth,
totalIntegrations,
totalIntegrationsLastMonth,
totalMembers,
jobs,
projectedRunsCount,
monthlyRunsData: monthlyRunsDataDisplay,
hasMonthlyRunData,
concurrencyData: concurrencyChartRawDataFilledIn,
hasConcurrencyData,
runCostEstimation,
projectedRunCostEstimation,
periodStart,
periodEnd,
};
}
}
@@ -163,7 +173,7 @@ export class OrgUsagePresenter {
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
// And the current month is "2021-04", then this function will return:
// [{ name: "2020-11", total: 0 }, { name: "2020-12", total: 0 }, { name: "2021-01", total: 10 }, { name: "2021-02", total: 0 }, { name: "2021-03", total: 30 }, { name: "2021-04", total: 0 }]
function fillInMissingMonthlyData(
function fillInMissingRunMonthlyData(
data: Array<{ name: string; total: number }>,
totalNumberOfMonths: number
): Array<{ name: string; total: number }> {
@@ -187,6 +197,33 @@ function fillInMissingMonthlyData(
return completeData;
}
function fillInMissingConcurrencyDays(
startDate: Date,
days: number,
data: Array<{ day: Date; max_concurrent_runs: BigInt }>
) {
const outputData: Array<{ date: Date; maxConcurrentRuns: number }> = [];
for (let i = 0; i < days; i++) {
const date = new Date(startDate);
date.setDate(date.getDate() + i);
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
if (!foundData) {
outputData.push({
date,
maxConcurrentRuns: 0,
});
} else {
outputData.push({
date,
maxConcurrentRuns: Number(foundData.max_concurrent_runs),
});
}
}
return outputData;
}
// Start month will be like 2023-03 and endMonth will be like 2023-10
// The result should be an array of months between these two months, including the start and end month
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
@@ -212,11 +249,3 @@ function getMonthsBetween(startMonth: string, endMonth: string): string[] {
return result;
}
function getLastSecondOfMonth(endMonth: string) {
const [year, month] = endMonth.split("-").map(Number);
const nextMonthFirstDay = new Date(year, month, 1);
nextMonthFirstDay.setDate(0);
nextMonthFirstDay.setHours(23, 59, 59);
return nextMonthFirstDay;
}
@@ -1,19 +1,20 @@
import { PrismaClient, prisma } from "~/db.server";
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { DirectionSchema } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
export type Direction = z.infer<typeof DirectionSchema>;
type RunListOptions = {
userId: string;
jobSlug: string;
jobSlug?: string;
organizationSlug: string;
projectSlug: string;
direction?: Direction;
cursor?: string;
pageSize?: number;
};
const PAGE_SIZE = 20;
const DEFAULT_PAGE_SIZE = 20;
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
@@ -31,9 +32,45 @@ export class RunListPresenter {
projectSlug,
direction = "forward",
cursor,
pageSize = DEFAULT_PAGE_SIZE,
}: RunListOptions) {
const directionMultiplier = direction === "forward" ? 1 : -1;
// Find the organization that the user is a member of
const organization = await this.#prismaClient.organization.findFirstOrThrow({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
});
// Find the project scoped to the organization
const project = await this.#prismaClient.project.findFirstOrThrow({
where: {
slug: projectSlug,
organizationId: organization.id,
},
});
// Find all runtimeEnvironments that the user has access to
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
where: {
projectId: project.id,
OR: [
{ orgMember: { userId } },
{ orgMemberId: null },
]
}
});
const job = jobSlug ? await this.#prismaClient.job.findFirstOrThrow({
where: {
slug: jobSlug,
projectId: project.id,
},
}) : undefined;
const runs = await this.#prismaClient.jobRun.findMany({
select: {
id: true,
@@ -41,6 +78,7 @@ export class RunListPresenter {
startedAt: true,
completedAt: true,
createdAt: true,
executionDuration: true,
isTest: true,
status: true,
environment: {
@@ -59,41 +97,34 @@ export class RunListPresenter {
version: true,
},
},
job: {
select: {
slug: true,
title: true,
},
},
},
where: {
job: {
slug: jobSlug,
},
project: {
slug: projectSlug,
},
organization: { slug: organizationSlug, members: { some: { userId } } },
environment: {
OR: [
{
orgMember: null,
},
{
orgMember: {
userId,
},
},
],
jobId: job?.id,
projectId: project.id,
organizationId: organization.id,
environmentId: {
in: environments.map((environment) => environment.id),
},
},
orderBy: [{ id: "desc" }],
//take an extra page to tell if there are more
take: directionMultiplier * (PAGE_SIZE + 1),
//take an extra record to tell if there are more
take: directionMultiplier * (pageSize + 1),
//skip the cursor if there is one
skip: cursor ? 1 : 0,
cursor: cursor
? {
id: cursor,
}
id: cursor,
}
: undefined,
});
const hasMore = runs.length > PAGE_SIZE;
const hasMore = runs.length > pageSize;
//get cursors for next and previous pages
let next: string | undefined;
@@ -102,19 +133,21 @@ export class RunListPresenter {
case "forward":
previous = cursor ? runs.at(0)?.id : undefined;
if (hasMore) {
next = runs[PAGE_SIZE - 1]?.id;
next = runs[pageSize - 1]?.id;
}
break;
case "backward":
if (hasMore) {
previous = runs[1]?.id;
next = runs[pageSize]?.id;
} else {
next = runs[pageSize - 1]?.id;
}
next = runs[PAGE_SIZE - 1]?.id;
break;
}
const runsToReturn =
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
direction === "backward" && hasMore ? runs.slice(1, pageSize + 1) : runs.slice(0, pageSize);
return {
runs: runsToReturn.map((run) => ({
@@ -123,6 +156,7 @@ export class RunListPresenter {
startedAt: run.startedAt,
completedAt: run.completedAt,
createdAt: run.createdAt,
executionDuration: run.executionDuration,
isTest: run.isTest,
status: run.status,
version: run.version?.version ?? "unknown",
@@ -131,6 +165,7 @@ export class RunListPresenter {
slug: run.environment.slug,
userId: run.environment.orgMember?.userId,
},
job: run.job,
})),
pagination: {
next,
@@ -5,6 +5,7 @@ import {
StyleSchema,
} from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { isRunCompleted, runBasicStatus } from "~/models/jobRun.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
import { taskListToTree } from "~/utils/taskListToTree";
@@ -67,6 +68,8 @@ export class RunPresenter {
id: run.id,
number: run.number,
status: run.status,
basicStatus: runBasicStatus(run.status),
isFinished: isRunCompleted(run.status),
startedAt: run.startedAt,
completedAt: run.completedAt,
isTest: run.isTest,
@@ -82,6 +85,8 @@ export class RunPresenter {
runConnections: run.runConnections,
missingConnections: run.missingConnections,
error: runError,
executionDuration: run.executionDuration,
executionCount: run.executionCount,
};
}
@@ -112,6 +117,8 @@ export class RunPresenter {
isTest: true,
properties: true,
output: true,
executionCount: true,
executionDuration: true,
version: {
select: {
version: true,
+2 -2
View File
@@ -25,7 +25,7 @@ export const links: LinksFunction = () => {
export const meta: TypedMetaFunction<typeof loader> = (args) => {
return metaV1(args, {
title: `Trigger.dev${appEnvTitleTag(args.data.appEnv)}`,
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
charset: "utf-8",
viewport: "width=1024, initial-scale=1",
});
@@ -71,7 +71,7 @@ export function ErrorBoundary() {
<Links />
</head>
<body className="h-full overflow-hidden bg-darkBackground">
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
@@ -0,0 +1,32 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { plansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
return redirectWithErrorMessage(
`${plansPath({ slug: org.slug })}`,
request,
"You didn't complete your details on Stripe. Please try again."
);
};
@@ -0,0 +1,32 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { subscribedPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
return redirectWithSuccessMessage(
`${subscribedPath({ slug: org.slug })}`,
request,
"You are now subscribed to Trigger.dev"
);
};
@@ -0,0 +1,34 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { plansPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const reason = searchParams.get("reason");
let errorMessage = reason ? decodeURIComponent(reason) : "Subscribing failed to complete";
return redirectWithErrorMessage(`${plansPath({ slug: org.slug })}`, request, errorMessage);
};
@@ -10,6 +10,8 @@ import {
PageTitleRow,
PageTitle,
PageButtons,
PageInfoRow,
PageInfoGroup,
} from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -38,6 +40,13 @@ export default function Page() {
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
UID: {organization.id}
</Paragraph>
</PageInfoGroup>
</PageInfoRow>
</PageHeader>
<PageBody>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
@@ -0,0 +1,193 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/UsageBar";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, plansPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug, request });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (active && payload) {
return (
<div className="flex gap-1 rounded border border-border bg-background px-3 py-2 text-xs text-bright">
<p>{label}:</p>
<p>{payload[0].value}</p>
</div>
);
}
return null;
};
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
? loaderData.concurrencyData.some(
(c) => c.maxConcurrentRuns >= (currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
)
: false;
const hitsRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
<div>
<Header2 spacing>Concurrent runs</Header2>
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
{hitConcurrencyLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Increase concurrent runs
</LinkButton>
}
>
{`Some of your runs are being queued because the number of concurrent runs is limited to
${currentPlan?.subscription?.limits.concurrentRuns}.`}
</Callout>
)}
<ConcurrentRunsChart
data={loaderData.concurrencyData}
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
hasConcurrencyData={loaderData.hasConcurrencyData}
/>
</div>
</div>
<div className="@container">
<Header2 spacing>Runs</Header2>
<div className="flex flex-col gap-5 rounded border border-border p-6">
{hitsRunLimit && (
<Callout
variant={"pricing"}
cta={
<LinkButton
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={plansPath(organization)}
>
Upgrade
</LinkButton>
}
>
<Paragraph variant="small">
You have exceeded the monthly{" "}
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
Upgrade to a paid plan before{" "}
<DateTime date={loaderData.periodEnd} includeSeconds={false} includeTime={false} />.
</Paragraph>
</Callout>
)}
<div className="flex flex-col gap-x-8 @4xl:flex-row">
<div className="flex w-full flex-col gap-4">
{loaderData.runCostEstimation !== undefined &&
loaderData.projectedRunCostEstimation !== undefined && (
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">Month-to-date</Header3>
<p className="text-3xl font-medium text-bright">
{formatCurrency(loaderData.runCostEstimation, false)}
</p>
</div>
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
<div className="flex flex-col gap-2 text-dimmed">
<Header3 className="text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(loaderData.projectedRunCostEstimation, false)}
</p>
</div>
</div>
)}
<UsageBar
numberOfCurrentRuns={loaderData.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
projectedRuns={loaderData.projectedRunsCount}
/>
</div>
<div className="relative w-full">
<Header3 className="mb-4">Monthly runs</Header3>
{!loaderData.hasMonthlyRunData && (
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
No runs to show
</Paragraph>
)}
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={loaderData.monthlyRunsData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="-ml-7"
>
<XAxis
dataKey="name"
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#94A3B8"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: "rgba(255,255,255,0.05)" }}
content={<CustomTooltip />}
/>
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { useActionData } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { SetPlanBodySchema } from "@trigger.dev/billing";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PricingCalculator } from "~/components/billing/PricingCalculator";
import { PricingTiers, TierEnterprise, TierFree, TierPro } from "~/components/billing/PricingTiers";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { OrgBillingPlanPresenter } from "~/presenters/OrgBillingPlanPresenter";
import { Handle } from "~/utils/handle";
import { OrganizationParamsSchema, organizationBillingPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { formatNumberCompact } from "~/utils/numberFormatter";
export async function loader({ params, request }: LoaderFunctionArgs) {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationBillingPath({ slug: organizationSlug }));
}
const presenter = new OrgBillingPlanPresenter();
const result = await presenter.call({ slug: organizationSlug, isManagedCloud });
if (!result) {
throw new Response(null, { status: 404 });
}
return typedjson({
plans: result.plans,
maxConcurrency: result.maxConcurrency,
organizationSlug,
});
}
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Plans" />,
};
export default function Page() {
const { plans, maxConcurrency, organizationSlug } = useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const hitConcurrencyLimit =
currentPlan?.subscription?.limits.concurrentRuns && maxConcurrency
? maxConcurrency >= currentPlan.subscription!.limits.concurrentRuns!
: false;
const hitRunLimit = currentPlan?.usage?.runCountCap
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
: false;
return (
<div className="flex flex-col gap-4">
{hitConcurrencyLimit && (
<Callout variant={"pricing"}>
Some of your runs are being queued because your run concurrency is limited to{" "}
{currentPlan?.subscription?.limits.concurrentRuns}.
</Callout>
)}
{hitRunLimit && (
<Callout variant={"pricing"}>
{`You have exceeded the monthly
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
can continue to perform runs.`}
</Callout>
)}
<PricingTiers organizationSlug={organizationSlug} plans={plans} />
<div>
<Header2 spacing>Estimate your usage</Header2>
<div className="flex h-full w-full rounded-md border border-border p-6">
<PricingCalculator plans={plans} />
<div className="mx-6 min-h-full w-px bg-border" />
<RunsVolumeDiscountTable brackets={plans.paid.runs?.pricing?.brackets ?? []} />
</div>
</div>
</div>
);
}
@@ -1,198 +1,132 @@
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import {
ForwardIcon,
SquaresPlusIcon,
UsersIcon,
WrenchScrewdriverIcon,
} from "@heroicons/react/24/solid";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { CalendarDaysIcon, ReceiptRefundIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Outlet } from "@remix-run/react";
import { ActiveSubscription } from "@trigger.dev/billing";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import {
OrganizationParamsSchema,
jobPath,
newProjectPath,
organizationTeamPath,
} from "~/utils/pathBuilder";
import { Link } from "@remix-run/react/dist/components";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageHeader,
PageTitleRow,
PageTitle,
PageButtons,
} from "~/components/primitives/PageHeader";
import { Handle } from "~/utils/handle";
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const presenter = new OrgUsagePresenter();
const data = await presenter.call({ userId, slug: organizationSlug });
if (!data) {
throw new Response(null, { status: 404 });
}
return typedjson(data);
}
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (active && payload) {
return (
<div className="flex items-center gap-2 rounded border border-border bg-slate-900 px-4 py-2 text-sm text-dimmed">
<p className="text-white">{label}:</p>
<p className="text-white">{payload[0].value}</p>
</div>
);
}
return null;
};
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
PageButtons,
PageHeader,
PageInfoGroup,
PageInfoProperty,
PageInfoRow,
PageTabs,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { formatDurationInDays } from "~/utils";
import { Handle } from "~/utils/handle";
import { plansPath, stripePortalPath, usagePath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const handle: Handle = {
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Usage & Billing" />,
};
function planLabel(subscription: ActiveSubscription | undefined, periodEnd: Date) {
if (!subscription) {
return "You're currently on the Free plan";
}
if (!subscription.isPaying) {
return `You're currently on the ${subscription.plan.title} plan`;
}
const costDescription = subscription.plan.concurrentRuns.pricing
? `\$${subscription.plan.concurrentRuns.pricing?.tierCost}/mo`
: "";
if (subscription.canceledAt) {
return (
<>
You're on the {costDescription} {subscription.plan.title} plan until{" "}
<DateTime includeTime={false} date={periodEnd} /> when you'll be on the Free plan
</>
);
}
return `You're currently on the ${costDescription} ${subscription.plan.title} plan`;
}
export default function Page() {
const organization = useOrganization();
const loaderData = useTypedLoaderData<typeof loader>();
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
return (
<PageContainer>
<PageHeader>
<PageHeader hideBorder>
<PageTitleRow>
<PageTitle title="Usage & Billing" />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Runs this month</Header2>
<ForwardIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.runsCount.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.runsCountLastMonth} runs last month
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Jobs</Header2>
<WrenchScrewdriverIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalJobs.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalJobs === loaderData.totalJobsLastMonth ? (
<>No change since last month</>
) : loaderData.totalJobs > loaderData.totalJobsLastMonth ? (
<>+{loaderData.totalJobs - loaderData.totalJobsLastMonth} since last month</>
) : (
<>-{loaderData.totalJobsLastMonth - loaderData.totalJobs} since last month</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Total Integrations</Header2>
<SquaresPlusIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalIntegrations.toLocaleString()}</p>
<Paragraph variant="small" className="text-dimmed">
{loaderData.totalIntegrations === loaderData.totalIntegrationsLastMonth ? (
<>No change since last month</>
) : loaderData.totalIntegrations > loaderData.totalIntegrationsLastMonth ? (
<PageTitle title={isManagedCloud ? "Usage & Billing" : "Usage"} />
<PageButtons>
{isManagedCloud && (
<>
{currentPlan?.subscription?.isPaying && (
<>
+{loaderData.totalIntegrations - loaderData.totalIntegrationsLastMonth} since
last month
</>
) : (
<>
-{loaderData.totalIntegrationsLastMonth - loaderData.totalIntegrations} since
last month
<LinkButton to={stripePortalPath(organization)} variant="secondary/small">
Invoices
</LinkButton>
<LinkButton to={stripePortalPath(organization)} variant="secondary/small">
Manage card details
</LinkButton>
</>
)}
</Paragraph>
</div>
</div>
<div className="rounded border border-border p-6">
<div className="flex flex-row items-center justify-between space-y-0 pb-2">
<Header2>Team members</Header2>
<UsersIcon className="h-6 w-6 text-dimmed" />
</div>
<div>
<p className="text-3xl font-bold">{loaderData.totalMembers.toLocaleString()}</p>
<TextLink
to={organizationTeamPath(organization)}
className="group text-sm text-dimmed hover:text-bright"
>
Manage
<ArrowRightIcon className="-mb-0.5 ml-0.5 h-4 w-4 text-dimmed transition group-hover:translate-x-1 group-hover:text-bright" />
</TextLink>
</div>
</div>
</div>
<div className="flex max-h-[500px] gap-x-4">
<div className="w-1/2 rounded border border-border py-6 pr-2">
<Header2 className="mb-8 pl-6">Job Runs per month</Header2>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={loaderData.chartData}>
<XAxis
dataKey="name"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip cursor={{ fill: "rgba(255,255,255,0.05)" }} content={<CustomTooltip />} />
<Bar dataKey="total" fill="#DB2777" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
<Header2 className="">Jobs</Header2>
<Header2 className="">Runs</Header2>
</div>
<div className="space-y-2">
{loaderData.jobs.map((job) => (
<Link
to={jobPath(organization, job.project, job)}
className="flex items-center rounded px-4 py-3 transition hover:bg-slate-850"
key={job.id}
<LinkButton
to={plansPath(organization)}
variant="primary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{job.slug}</p>
<p className="text-sm text-muted-foreground">Project: {job.project.name}</p>
</div>
<div className="ml-auto font-medium">{job._count.runs.toLocaleString()}</div>
</Link>
))}
</div>
</div>
Upgrade
</LinkButton>
</>
)}
</PageButtons>
</PageTitleRow>
<PageInfoRow>
<PageInfoGroup>
{currentPlan?.subscription && (
<PageInfoProperty
icon={<ReceiptRefundIcon className="h-4 w-4 text-green-600" />}
value={planLabel(currentPlan.subscription, currentPlan.usage.periodEnd)}
/>
)}
{currentPlan?.subscription?.isPaying && (
<PageInfoProperty
icon={<CalendarDaysIcon className="h-4 w-4 text-green-600" />}
label={"Billing period"}
value={
<>
<DateTime date={currentPlan.usage.periodStart} includeTime={false} /> to{" "}
<DateTime date={currentPlan.usage.periodEnd} includeTime={false} /> (
{formatDurationInDays(currentPlan.usage.periodRemainingDuration)} remaining)
</>
}
/>
)}
</PageInfoGroup>
</PageInfoRow>
{isManagedCloud && (
<PageTabs
tabs={[
{
label: "Usage",
to: usagePath(organization),
},
{
label: "Plans",
to: plansPath(organization),
},
]}
layoutId="usage-and-billing"
/>
)}
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<Outlet />
</div>
</PageBody>
</PageContainer>
@@ -105,12 +105,14 @@ export default function Page() {
};
}, [selected, clients]);
const isAnyClientFullyConfigured = useMemo(() => {
return clients.some((client) => {
const { DEVELOPMENT, PRODUCTION } = client.endpoints;
return PRODUCTION.state === "configured" && DEVELOPMENT.state === PRODUCTION.state;
});
}, [clients]);
const isAnyClientFullyConfigured = clients.some((client) => {
const { DEVELOPMENT, PRODUCTION, STAGING } = client.endpoints;
return (
PRODUCTION.state === "configured" ||
DEVELOPMENT.state === "configured" ||
(STAGING && STAGING.state === "configured")
);
});
const organization = useOrganization();
const project = useProject();
@@ -22,31 +22,39 @@ export function ListPagination({
function NextButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "forward");
return path ? (
return (
<LinkButton
to={path}
to={path ?? "#"}
variant={"tertiary/small"}
TrailingIcon="chevron-right"
className="flex items-center"
className={cn(
"flex items-center",
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
)}
onClick={(e) => !path && e.preventDefault()}
>
Next
</LinkButton>
) : null;
);
}
function PreviousButton({ cursor }: { cursor?: string }) {
const path = useCursorPath(cursor, "backward");
return path ? (
return (
<LinkButton
to={path}
to={path ?? "#"}
variant={"tertiary/small"}
LeadingIcon="chevron-left"
className="flex items-center"
className={cn(
"flex items-center",
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
)}
onClick={(e) => !path && e.preventDefault()}
>
Prev
</LinkButton>
) : null;
);
}
function useCursorPath(cursor: string | undefined, direction: Direction) {
@@ -72,8 +72,8 @@ export default function Page() {
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div>
<div className="mb-2 flex items-center justify-end gap-x-2">
<ListPagination list={list} />
<HelpTrigger title="How do I run my Job?" />
<ListPagination list={list} />
</div>
<RunsTable
total={list.runs.length}
@@ -24,7 +24,7 @@ export default function Page() {
const project = useProject();
return (
<Help defaultOpen>
<Help>
{(open) => (
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div className="w-full">
@@ -32,7 +32,7 @@ export default function Page() {
<Header2 className="mb-2 flex items-center gap-1">Environments</Header2>
<HelpTrigger title="How do disable a Job?" />
</div>
<JobStatusTable environments={job.environments} />
<JobStatusTable environments={job.environments} displayStyle="long" />
<div className="mt-4 flex w-full items-center justify-end gap-x-3">
{job.status === "ACTIVE" && (
<Paragraph variant="small">
@@ -297,7 +297,9 @@ export default function Page() {
label={<DateTime date={run.created} />}
description={
<>
Run #{run.number}{" "}
{typeof run.number === "number"
? `Run #${run.number}`
: `Run ${run.id.slice(0, 8)}`}
<span className={runStatusClassNameColor(run.status)}>
{runStatusTitle(run.status).toLocaleLowerCase()}
</span>
@@ -0,0 +1,88 @@
import { useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import {
PageButtons,
PageDescription,
PageHeader,
PageTitle,
PageTitleRow,
} from "~/components/primitives/PageHeader";
import { RunsTable } from "~/components/runs/RunsTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const searchParams = RunListSearchSchema.parse(s);
const presenter = new RunListPresenter();
const list = await presenter.call({
userId,
projectSlug: projectParam,
organizationSlug,
direction: searchParams.direction,
cursor: searchParams.cursor,
pageSize: 25,
});
return typedjson({
list,
});
};
export default function Page() {
const { list } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${project.name} runs`} />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("documentation/concepts/runs")}
variant="secondary/small"
>
Run documentation
</LinkButton>
</PageButtons>
</PageTitleRow>
<PageDescription>All job runs in this project</PageDescription>
</PageHeader>
<PageBody scrollable={false}>
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
<div className="mb-2 flex items-center justify-end gap-x-2">
<ListPagination list={list} />
</div>
<RunsTable
total={list.runs.length}
hasFilters={false}
showJob={true}
runs={list.runs}
isLoading={isLoading}
runsParentPath={projectPath(organization, project)}
/>
<ListPagination list={list} className="mt-2 justify-end" />
</div>
</PageBody>
</PageContainer>
);
}
@@ -1,17 +1,19 @@
import { Outlet } from "@remix-run/react";
import { Outlet, UIMatch } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { UpgradePrompt } from "~/components/billing/UpgradePrompt";
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
import { SideMenu } from "~/components/navigation/SideMenu";
import { featuresForRequest } from "~/features.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { BillingService } from "~/services/billing.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
@@ -23,6 +25,14 @@ const ParamsSchema = z.object({
projectParam: z.string().optional(),
});
export function useCurrentPlan(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof loader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.currentPlan;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -39,11 +49,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
telemetry.organization.identify({ organization });
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
return typedjson({
organizations,
organization,
currentProject: project,
isImpersonating: !!impersonationId,
currentPlan,
});
};
@@ -74,9 +89,12 @@ export default function Organization() {
organizations={organizations}
/>
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
<div className="flex w-full items-center justify-between border-b border-ui-border pr-2">
<div className="flex w-full items-center justify-between border-b border-ui-border">
<Breadcrumb />
<PageNavigationIndicator />
<div className="flex h-full items-center gap-4">
<PageNavigationIndicator className="mr-2" />
<UpgradePrompt organization={organization} />
</div>
</div>
<Outlet />
</div>
@@ -0,0 +1,89 @@
import { ChartBarIcon } from "@heroicons/react/20/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PricingCalculator } from "~/components/billing/PricingCalculator";
import { PricingTiers } from "~/components/billing/PricingTiers";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { Button } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "~/components/primitives/Sheet";
import { featuresForRequest } from "~/features.server";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { OrgBillingPlanPresenter } from "~/presenters/OrgBillingPlanPresenter";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
organizationPath,
projectPath,
} from "~/utils/pathBuilder";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationBillingPath({ slug: organizationSlug }));
}
const presenter = new OrgBillingPlanPresenter();
const result = await presenter.call({ slug: organizationSlug, isManagedCloud });
if (!result) {
throw new Response(null, { status: 404 });
}
const orgsPresenter = new OrganizationsPresenter();
const { organizations, organization, project } = await orgsPresenter.call({
userId,
request,
organizationSlug,
});
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
}
export default function ChoosePlanPage() {
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
const project = useOptionalProject();
return (
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
<Header1>Subscribe for full access</Header1>
<PricingTiers
organizationSlug={organizationSlug}
plans={plans}
showActionText={false}
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
/>
<Sheet>
<SheetTrigger asChild>
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
Estimate usage
</Button>
</SheetTrigger>
<SheetContent size="lg">
<SheetHeader className="justify-between">
<div className="flex items-center gap-4">
<Header1>Estimate your usage</Header1>
</div>
</SheetHeader>
<SheetBody>
<PricingCalculator plans={plans} />
<div className="mt-8 rounded border border-border p-6">
<RunsVolumeDiscountTable brackets={plans.paid.runs?.pricing?.brackets ?? []} />
</div>
</SheetBody>
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,96 @@
import { CheckBadgeIcon } from "@heroicons/react/24/solid";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useNewCustomerSubscribed } from "~/hooks/useNewCustomerSubscribed";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Handle } from "~/utils/handle";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId } from "~/services/session.server";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const orgsPresenter = new OrganizationsPresenter();
const { organization } = await orgsPresenter.call({
userId,
request,
organizationSlug,
});
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
const plans = await billingPresenter.getPlans();
return typedjson({
currentPlan,
plans,
});
};
export const handle: Handle = {
scripts: () => [
{
src: "https://cdn.jsdelivr.net/npm/canvas-confetti@1.5.1/dist/confetti.browser.min.js",
crossOrigin: "anonymous",
},
],
};
export default function Subscribed() {
const { currentPlan, plans } = useTypedLoaderData<typeof loader>();
useNewCustomerSubscribed();
return (
<MainCenteredContainer className="max-w-[22rem]">
<FormTitle
LeadingIcon={<CheckBadgeIcon className="h-7 w-7 text-green-600" />}
title="You're subscribed!"
className="mb-0"
/>
<ul>
<PlanItem item="Your plan" value={currentPlan?.subscription?.plan.title ?? ""} />
<PlanItem
item="Concurrent runs/mo"
value={`${currentPlan?.subscription?.plan.concurrentRuns.pricing?.upto}`}
/>
<PlanItem item="Runs/mo" value="Volume discounted" />
</ul>
<RunsVolumeDiscountTable
hideHeader
className="mb-4 border-b border-border pb-2 pl-4"
brackets={plans?.paid.runs?.pricing?.brackets ?? []}
/>
<FormButtons
confirmButton={
<LinkButton to={"/"} variant={"primary/small"} TrailingIcon={"arrow-right"}>
Continue
</LinkButton>
}
/>
</MainCenteredContainer>
);
}
function PlanItem({ item, value }: { item: string; value: string }) {
return (
<li className="flex items-center justify-between border-b border-border py-2">
<Paragraph>{item}</Paragraph>
<Paragraph variant="base/bright" className="font-medium">
{value}
</Paragraph>
</li>
);
}
+16 -4
View File
@@ -17,12 +17,13 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { RadioGroupItem } from "~/components/primitives/RadioButton";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
import { requireUserId } from "~/services/session.server";
import { projectPath, rootPath } from "~/utils/pathBuilder";
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
const schema = z.object({
orgName: z.string().min(3).max(50),
@@ -61,10 +62,20 @@ export const action: ActionFunction = async ({ request }) => {
const project = organization.projects[0];
const session = await setCurrentProjectId(project.id, request);
const { isManagedCloud } = featuresForRequest(request);
const headers = {
"Set-Cookie": await commitCurrentProjectSession(session),
};
if (isManagedCloud) {
return redirect(selectPlanPath(organization), {
headers,
});
}
return redirect(projectPath(organization, project), {
headers: {
"Set-Cookie": await commitCurrentProjectSession(session),
},
headers,
});
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
@@ -97,6 +108,7 @@ export default function NewOrganizationPage() {
{...conform.input(orgName, { type: "text" })}
placeholder="Your Organization name"
icon="organization"
autoFocus
/>
<Hint>E.g. your company name or your workspace name.</Hint>
<FormError id={orgName.errorId}>{orgName.error}</FormError>
+2 -6
View File
@@ -3,7 +3,6 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { useIsOrgChildPage } from "~/hooks/useIsOrgChildPage";
import { clearRedirectTo, commitSession } from "~/services/redirectTo.server";
import { requireUser } from "~/services/session.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
@@ -22,11 +21,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
export default function App() {
const isOrgChildPage = useIsOrgChildPage();
const showBackgroundGradient = !isOrgChildPage;
return (
<AppContainer showBackgroundGradient={showBackgroundGradient}>
<AppContainer>
<Outlet />
</AppContainer>
);
@@ -35,7 +31,7 @@ export default function App() {
export function ErrorBoundary() {
return (
<>
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<RouteErrorDisplay />
</MainCenteredContainer>
+1 -1
View File
@@ -123,7 +123,7 @@ export default function Page() {
});
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<FormTitle LeadingIcon="user" title="Profile" />
<Form method="post" {...form.props} className="max-w-md">
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
import { GetEvent } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runOriginalStatus } from "~/models/jobRun.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
@@ -49,7 +50,7 @@ function toJSON(eventRecord: FoundEventRecord): GetEvent {
updatedAt: eventRecord.updatedAt,
runs: eventRecord.runs.map((run) => ({
id: run.id,
status: run.status,
status: runOriginalStatus(run.status),
startedAt: run.startedAt,
completedAt: run.completedAt,
})),
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runOriginalStatus } from "~/models/jobRun.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { apiCors } from "~/utils/apiCors";
@@ -66,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
json({
run: {
id: run.id,
status: run.status,
status: runOriginalStatus(run.status),
output: run.output,
},
statuses: parsedStatuses,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { runOriginalStatus } from "~/models/jobRun.server";
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
@@ -79,7 +80,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
request,
json({
id: jobRun.id,
status: jobRun.status,
status: runOriginalStatus(jobRun.status),
startedAt: jobRun.startedAt,
updatedAt: jobRun.updatedAt,
completedAt: jobRun.completedAt,
+5 -5
View File
@@ -42,14 +42,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
const store = new KeyValueStore(authenticatedEnv);
const { key } = parsedParams.data;
const decodedKey = decodeURIComponent(parsedParams.data.key);
try {
switch (parsedMethod.data) {
case "DELETE": {
const deleted = await store.delete(key);
const deleted = await store.delete(decodedKey);
return json({ action: "DELETE", key, deleted });
return json({ action: "DELETE", key: decodedKey, deleted });
}
case "PUT": {
const value = await request.text();
@@ -65,9 +65,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
);
}
const setValue = await store.set(key, value);
const setValue = await store.set(decodedKey, value);
return json({ action: "SET", key, value: setValue });
return json({ action: "SET", key: decodedKey, value: setValue });
}
default: {
assertExhaustive(parsedMethod.data);
+69
View File
@@ -0,0 +1,69 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { YaltApiClient } from "@trigger.dev/yalt";
import { logger } from "~/services/logger.server";
import { prisma } from "~/db.server";
// This is for HEAD requests to check if the API supports tunneling
export async function loader({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!env.TUNNEL_HOST || !env.TUNNEL_SECRET_KEY) {
return json({ error: "Tunneling is not supported" }, { status: 501 });
}
return json({ ok: true });
}
export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (authenticationResult.environment.type !== "DEVELOPMENT") {
return json({ error: "Tunneling is only supported in development" }, { status: 501 });
}
if (!env.TUNNEL_HOST || !env.TUNNEL_SECRET_KEY) {
return json({ error: "Tunneling is not supported" }, { status: 501 });
}
const yaltClient = new YaltApiClient(env.TUNNEL_HOST, env.TUNNEL_SECRET_KEY);
let tunnelId = authenticationResult.environment.tunnelId;
if (!tunnelId) {
try {
tunnelId = await yaltClient.createTunnel();
await prisma.runtimeEnvironment.update({
where: {
id: authenticationResult.environment.id,
},
data: {
tunnelId,
},
});
} catch (error) {
logger.error("Failed to create tunnel", { error });
return json({ error: "Failed to create tunnel" }, { status: 500 });
}
}
if (!tunnelId) {
return json({ error: "Failed to create tunnel" }, { status: 500 });
}
return json({ url: yaltClient.connectUrl(tunnelId) });
}
@@ -137,7 +137,7 @@ export default function Page() {
const shouldShowConfirm = user.email !== enteredEmail || user.email === "";
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
<Form method="post" {...form.props}>
<FormTitle
+1 -16
View File
@@ -1,24 +1,9 @@
// learn more: https://fly.io/docs/reference/configuration/#services-http_checks
import { prisma } from "~/db.server";
import type { LoaderFunction } from "@remix-run/node";
export const loader: LoaderFunction = async ({ request }) => {
const host = request.headers.get("X-Forwarded-Host") ?? request.headers.get("host");
try {
const url = new URL("/", `http://${host}`);
if (request.headers.get("x-forwarded-proto") === "https") {
url.protocol = "https:";
}
// if we can connect to the database and make a simple query
// and make a HEAD request to ourselves, then we're good.
await Promise.all([
prisma.user.count(),
fetch(url.href, { method: "HEAD" }).then((r) => {
if (!r.ok) return Promise.reject(r);
}),
]);
await prisma.user.count();
return new Response("OK");
} catch (error: unknown) {
console.log("healthcheck ❌", { error });
+1 -1
View File
@@ -97,7 +97,7 @@ export default function Page() {
});
return (
<AppContainer showBackgroundGradient={true}>
<AppContainer>
<MainCenteredContainer>
<div>
<FormTitle
@@ -0,0 +1,79 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SetPlanBodySchema } from "@trigger.dev/billing";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
subscribedPath,
} from "~/utils/pathBuilder";
export async function action({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: SetPlanBodySchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
try {
const org = await prisma.organization.findUnique({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
});
if (!org) {
submission.error.message = "Invalid organization";
return json(submission);
}
const billingPresenter = new BillingService(true);
const result = await billingPresenter.setPlan(org.id, submission.value);
if (result === undefined) {
submission.error.message = "No billing client";
return json(submission);
}
if (!result.success) {
submission.error.message = result.error;
return json(submission);
}
switch (result.action) {
case "create_subscription_flow_start": {
return redirect(result.checkoutUrl);
}
case "canceled_subscription": {
return redirectWithSuccessMessage(
organizationBillingPath({ slug: organizationSlug }),
request,
"Your subscription has been canceled."
);
}
case "updated_subscription": {
return redirect(subscribedPath({ slug: organizationSlug }), request);
}
}
} catch (e) {
logger.error("Error setting plan", { error: e });
submission.error.message = e instanceof Error ? e.message : JSON.stringify(e);
return json(submission);
}
}
@@ -0,0 +1,47 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectBackWithErrorMessage, redirectWithErrorMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { requireUser } from "~/services/session.server";
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
export async function loader({ request, params }: ActionFunctionArgs) {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
});
if (!org) {
return redirectWithErrorMessage(
usagePath({ slug: organizationSlug }),
request,
"Something went wrong. Please try again later."
);
}
const billingPresenter = new BillingService(true);
const result = await billingPresenter.customerPortalUrl(org.id, organizationSlug);
if (!result || !result.success || !result.customerPortalUrl) {
return redirectWithErrorMessage(
usagePath({ slug: organizationSlug }),
request,
"Something went wrong. Please try again later."
);
}
return redirect(result.customerPortalUrl);
}
+39 -53
View File
@@ -1,12 +1,6 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
ComponentDividerSpacingSize,
ComponentSpacerSize,
ComponentTextColor,
ComponentTextSize,
PlainClient,
} from "@team-plain/typescript-sdk";
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
import { inspect } from "util";
import { z } from "zod";
import { env } from "~/env.server";
@@ -20,6 +14,7 @@ export const feedbackTypeLabel = {
feature: "Feature request",
help: "Help me out",
integration: "Request an Integration",
enterprise: "Enterprise enquiry",
};
export type FeedbackType = keyof typeof feedbackTypeLabel;
@@ -68,6 +63,8 @@ export async function action({ request }: ActionFunctionArgs) {
onCreate: {
externalId: user.id,
fullName: user.name ?? "",
// TODO - Optional: set 'first name' on user
// shortName: ''
email: {
email: user.email,
isVerified: true,
@@ -76,6 +73,8 @@ export async function action({ request }: ActionFunctionArgs) {
onUpdate: {
externalId: { value: user.id },
fullName: { value: user.name ?? "" },
// TODO - see above
// shortName: { value: "" },
email: {
email: user.email,
isVerified: true,
@@ -96,63 +95,50 @@ export async function action({ request }: ActionFunctionArgs) {
}
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
const upsertTimelineEntryRes = await client.upsertCustomTimelineEntry({
customerId: upsertCustomerRes.data.customer.id,
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
title,
components: [
{
componentText: {
text: `New ${title} reported by ${user.name} (${user.email})`,
},
},
{
componentDivider: {
dividerSpacingSize: ComponentDividerSpacingSize.M,
},
},
{
componentText: {
textSize: ComponentTextSize.S,
textColor: ComponentTextColor.Muted,
text: "Page",
},
},
{
componentText: {
text: submission.value.path,
},
},
{
componentSpacer: {
spacerSize: ComponentSpacerSize.M,
},
},
{
componentText: {
textSize: ComponentTextSize.S,
textColor: ComponentTextColor.Muted,
text: "Message",
},
},
{
componentText: {
text: submission.value.message,
},
},
uiComponent.text({
text: `New ${title} reported by ${user.name} (${user.email})`,
}),
uiComponent.divider({ spacingSize: "M" }),
uiComponent.text({
size: "S",
color: "MUTED",
text: "Page",
}),
uiComponent.text({
text: submission.value.path,
}),
uiComponent.spacer({ size: "M" }),
uiComponent.text({
size: "S",
color: "MUTED",
text: "Message",
}),
uiComponent.text({
text: submission.value.message,
}),
],
changeCustomerStatusToActive: true,
sendCustomTimelineEntryCreatedNotification: true,
// TODO: Optional: set labels on threads here on creation
// labelTypeIds: [],
// TODO: Optional: set the priority (0 is urgent, 3 is low)
// priority: 0,
});
if (upsertTimelineEntryRes.error) {
if (createThreadRes.error) {
console.error(
inspect(upsertTimelineEntryRes.error, {
inspect(createThreadRes.error, {
showHidden: false,
depth: null,
colors: true,
})
);
submission.error.message = upsertTimelineEntryRes.error.message;
submission.error.message = createThreadRes.error.message;
return json(submission);
}
+111
View File
@@ -0,0 +1,111 @@
import { BillingClient, SetPlanBody } from "@trigger.dev/billing";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { organizationBillingPath } from "~/utils/pathBuilder";
export class BillingService {
#billingClient: BillingClient | undefined;
#prismaClient: PrismaClient;
constructor(isManagedCloud: boolean, prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
if (isManagedCloud && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
this.#billingClient = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
} else {
console.log(`Billing client not initialized`);
}
}
async currentPlan(orgId: string) {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.currentPlan(orgId);
const firstDayOfMonth = new Date();
firstDayOfMonth.setDate(1);
firstDayOfMonth.setHours(0, 0, 0, 0);
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setDate(1);
firstDayOfNextMonth.setMonth(firstDayOfNextMonth.getMonth() + 1);
firstDayOfNextMonth.setHours(0, 0, 0, 0);
const currentRunCount = await this.#prismaClient.jobRun.count({
where: {
organizationId: orgId,
createdAt: {
gte: firstDayOfMonth,
},
},
});
if (!result.success) {
logger.error("Error getting current plan", { orgId, error: result.error });
return undefined;
}
const periodStart = firstDayOfMonth;
const periodEnd = firstDayOfNextMonth;
const periodRemainingDuration = periodEnd.getTime() - new Date().getTime();
const usage = {
currentRunCount,
runCountCap: result.subscription?.plan.runs?.freeAllowance,
exceededRunCount: result.subscription?.plan.runs?.freeAllowance
? currentRunCount > result.subscription?.plan.runs?.freeAllowance
: false,
periodStart,
periodEnd,
periodRemainingDuration,
};
return { ...result, usage };
} catch (e) {
logger.error("Error getting current plan", { orgId, error: e });
return undefined;
}
}
async customerPortalUrl(orgId: string, orgSlug: string) {
if (!this.#billingClient) return undefined;
try {
return this.#billingClient.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
});
} catch (e) {
logger.error("Error getting customer portal Url", { orgId, error: e });
return undefined;
}
}
async getPlans() {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.plans();
if (!result.success) {
logger.error("Error getting plans", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting plans", { error: e });
return undefined;
}
}
async setPlan(orgId: string, plan: SetPlanBody) {
if (!this.#billingClient) return undefined;
try {
const result = await this.#billingClient.setPlan(orgId, plan);
return result;
} catch (e) {
logger.error("Error setting plan", { orgId, error: e });
return undefined;
}
}
}
@@ -1,9 +1,9 @@
import { Logger } from "@trigger.dev/core-backend";
import type { PoolClient } from "pg";
import { z } from "zod";
import { Logger } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
import { NotificationCatalog, NotificationChannel, notificationCatalog } from "./types";
import { safeJsonParse } from "~/utils/json";
import { NotificationCatalog, NotificationChannel, notificationCatalog } from "./types";
export class PgListenService {
#poolClient: PoolClient;
@@ -123,6 +123,7 @@ export class EndpointApi {
"x-trigger-api-key": this.apiKey,
"x-trigger-action": "INDEX_ENDPOINT",
},
redirect: "manual",
});
return {
@@ -444,6 +445,7 @@ function addStandardRequestOptions(options: RequestInit) {
...options.headers,
"user-agent": "triggerdotdev-server/2.0.0",
"x-trigger-version": API_VERSIONS.LAZY_LOADED_CACHED_TASKS,
accept: "application/json",
},
};
}
@@ -13,6 +13,7 @@ import { fromZodError } from "zod-validation-error";
import { IndexEndpointStats } from "@trigger.dev/core";
import { RegisterHttpEndpointService } from "../triggers/registerHttpEndpoint.server";
import { RegisterWebhookService } from "../triggers/registerWebhook.server";
import { EndpointIndex } from "@trigger.dev/database";
export class PerformEndpointIndexService {
#prismaClient: PrismaClient;
@@ -29,7 +30,7 @@ export class PerformEndpointIndexService {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
public async call(id: string, redirectCount = 0): Promise<EndpointIndex> {
const endpointIndex = await this.#prismaClient.endpointIndex.update({
where: {
id,
@@ -66,6 +67,39 @@ export class PerformEndpointIndexService {
});
}
if (isRedirect(response.status)) {
// Update the endpoint URL with the response.headers.location
logger.debug("Endpoint is redirecting", {
headers: Object.fromEntries(response.headers.entries()),
});
const location = response.headers.get("location");
if (!location) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting but no location header is present`,
});
}
if (redirectCount > 5) {
return updateEndpointIndexWithError(this.#prismaClient, id, {
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting too many times`,
});
}
await this.#prismaClient.endpoint.update({
where: {
id: endpointIndex.endpoint.id,
},
data: {
url: location,
},
});
// Re-run the endpoint index
return await this.call(id, redirectCount + 1);
}
if (response.status === 401) {
const body = await safeBodyFromResponse(response, errorParser);
@@ -324,7 +358,7 @@ export class PerformEndpointIndexService {
for (const webhook of webhooks) {
try {
await this.#registerWebhookService.call(endpoint, webhook);
indexStats.webhooks++;
indexStats.webhooks = indexStats.webhooks ?? 0 + 1;
} catch (error) {
logger.error("Failed to register webhook", {
endpointId: endpoint.id,
@@ -378,3 +412,10 @@ async function updateEndpointIndexWithError(
},
});
}
const redirectStatus = [301, 302, 303, 307, 308];
const redirectStatusSet = new Set(redirectStatus);
function isRedirect(status: number) {
return redirectStatusSet.has(status);
}
@@ -1,4 +1,4 @@
import { MAX_RUN_CHUNK_EXECUTION_LIMIT, RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
import { MAX_RUN_CHUNK_EXECUTION_LIMIT } from "~/consts";
import { prisma, PrismaClient } from "~/db.server";
import { EndpointApi } from "../endpointApi.server";
import { logger } from "../logger.server";
@@ -46,8 +46,10 @@ export class ProbeEndpointService {
},
});
const rawBody = await response.text();
// If the response is a 200, or it was a timeout, we can assume the endpoint is up and update the runChunkExecutionLimit
if (response.status === 200 || detectResponseIsTimeout(response)) {
if (response.status === 200 || detectResponseIsTimeout(rawBody, response)) {
await this.#prismaClient.endpoint.update({
where: {
id,
@@ -10,6 +10,7 @@ export type CreateExecutionEventInput = {
eventTime: Date;
eventType: "start" | "finish";
drift?: number;
concurrencyLimitGroupId?: string | null;
};
export class CreateExecutionEventService {
@@ -25,7 +26,8 @@ export class CreateExecutionEventService {
"run_id",
"event_time",
"event_type",
"drift_amount_in_ms"
"drift_amount_in_ms",
"concurrency_limit_group_id"
) VALUES (
${input.organizationId},
${input.projectId},
@@ -34,7 +36,8 @@ export class CreateExecutionEventService {
${input.runId},
${input.eventTime},
${input.eventType === "start" ? 1 : -1},
${input.drift}
${input.drift},
${input.concurrencyLimitGroupId}
)
`;
}
@@ -9,7 +9,7 @@ const supabase = new SupabaseManagement({
id: "__SLUG__",
});
new Job(client, {
client.defineJob({
id: "on-new-todos",
name: "On New Todos",
version: "0.1.1",
@@ -32,7 +32,7 @@ const supabase = new SupabaseManagement({
apiKey: process.env.SUPABASE_API_KEY!,
});
new Job(client, {
client.defineJob({
id: "on-new-todos",
name: "On New Todos",
version: "0.1.1",
@@ -136,7 +136,7 @@ const supabase = new Supabase<Database>({
supabaseKey: process.env.SUPABASE_API_KEY!,
});
new Job(client, {
client.defineJob({
id: "on-new-users",
name: "On New Users",
version: "0.1.1",
@@ -14,6 +14,7 @@ import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { logger } from "../logger.server";
import { RegisterScheduleSourceService } from "../schedules/registerScheduleSource.server";
import { executionRateLimiter } from "../runExecutionRateLimiter.server";
export class RegisterJobService {
#prismaClient: PrismaClient;
@@ -105,32 +106,28 @@ export class RegisterJobService {
},
});
// Upsert the JobQueue
const queueName = "default";
const { examples, ...eventSpecification } = metadata.event;
// Job Queues are going to be deprecated or used for something else, we're just doing this for now
const jobQueue = await this.#prismaClient.jobQueue.upsert({
where: {
environmentId_name: {
environmentId: environment.id,
name: queueName,
},
},
create: {
environment: {
connect: {
id: environment.id,
},
},
name: queueName,
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
},
update: {
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
},
});
const { examples, ...eventSpecification } = metadata.event;
const concurrencyLimitGroup =
typeof metadata.concurrencyLimit === "object"
? await this.#prismaClient.concurrencyLimitGroup.upsert({
where: {
environmentId_name: {
environmentId: environment.id,
name: metadata.concurrencyLimit.id,
},
},
create: {
environmentId: environment.id,
name: metadata.concurrencyLimit.id,
concurrencyLimit: metadata.concurrencyLimit.limit,
},
update: {
concurrencyLimit: metadata.concurrencyLimit.limit,
},
})
: null;
// Upsert the JobVersion
const jobVersion = await this.#prismaClient.jobVersion.upsert({
@@ -142,57 +139,29 @@ export class RegisterJobService {
},
},
create: {
job: {
connect: {
id: job.id,
},
},
endpoint: {
connect: {
id: endpoint.id,
},
},
environment: {
connect: {
id: environment.id,
},
},
organization: {
connect: {
id: environment.organizationId,
},
},
project: {
connect: {
id: environment.projectId,
},
},
queue: {
connect: {
id: jobQueue.id,
},
},
jobId: job.id,
endpointId: endpoint.id,
environmentId: environment.id,
organizationId: environment.organizationId,
projectId: environment.projectId,
version: metadata.version,
eventSpecification,
preprocessRuns: metadata.preprocessRuns,
startPosition: "LATEST",
status: "ACTIVE",
concurrencyLimitGroupId: concurrencyLimitGroup?.id ?? null,
concurrencyLimit:
typeof metadata.concurrencyLimit === "number" ? metadata.concurrencyLimit : null,
},
update: {
status: "ACTIVE",
startPosition: "LATEST",
eventSpecification,
preprocessRuns: metadata.preprocessRuns,
queue: {
connect: {
id: jobQueue.id,
},
},
endpoint: {
connect: {
id: endpoint.id,
},
},
endpointId: endpoint.id,
concurrencyLimitGroupId: concurrencyLimitGroup?.id ?? null,
concurrencyLimit:
typeof metadata.concurrencyLimit === "number" ? metadata.concurrencyLimit : null,
},
include: {
integrations: {
@@ -200,9 +169,28 @@ export class RegisterJobService {
integration: true,
},
},
concurrencyLimitGroup: true,
},
});
try {
if (jobVersion.concurrencyLimitGroup) {
// Upsert the maxSize for the concurrency limit group
await executionRateLimiter?.putConcurrencyLimitGroup(
jobVersion.concurrencyLimitGroup,
environment
);
}
await executionRateLimiter?.putJobVersionConcurrencyLimit(jobVersion, environment);
} catch (error) {
logger.error("Error setting concurrency limit", {
error,
jobVersionId: jobVersion.id,
environmentId: environment.id,
});
}
// Upsert the examples and delete any that are no longer in the metadata
const upsertedExamples = new Set<string>();
if (examples) {
@@ -447,6 +435,7 @@ export class RegisterJobService {
title: jobIntegration.metadata.name,
authSource: "LOCAL",
connectionType: "DEVELOPER",
setupStatus: "COMPLETE",
definition: {
connectOrCreate: {
where: {
@@ -525,6 +514,7 @@ export class RegisterJobService {
title: jobIntegration.metadata.name,
authSource: "RESOLVER",
connectionType: "EXTERNAL",
setupStatus: "COMPLETE",
definition: {
connectOrCreate: {
where: {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { LogLevel } from "@trigger.dev/core";
import { Logger } from "@trigger.dev/core";
import type { LogLevel } from "@trigger.dev/core-backend";
import { Logger } from "@trigger.dev/core-backend";
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
import { AsyncLocalStorage } from "async_hooks";
@@ -0,0 +1,406 @@
import { env } from "~/env.server";
import {
Callback,
Cluster,
ClusterNode,
ClusterOptions,
Redis,
RedisOptions,
Result,
} from "ioredis";
import { JobHelpers, Task } from "graphile-worker";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
import { ZodWorkerRateLimiter } from "~/platform/zodWorker.server";
import {
ConcurrencyLimitGroup,
JobRun,
JobVersion,
RuntimeEnvironment,
} from "@trigger.dev/database";
export interface RunExecutionRateLimiter {
putConcurrencyLimitGroup(
concurrencyLimitGroup: ConcurrencyLimitGroup,
env: RuntimeEnvironment
): Promise<void>;
putJobVersionConcurrencyLimit(jobVersion: JobVersion, env: RuntimeEnvironment): Promise<void>;
setMaxSizeForFlag(flag: string, maxSize: number): Promise<void>;
delMaxSizeForFlag(flag: string): Promise<void>;
flagsForRun(
run: JobRun,
version: JobVersion & {
environment: RuntimeEnvironment;
concurrencyLimitGroup?: ConcurrencyLimitGroup;
}
): string[];
}
declare module "ioredis" {
interface RedisCommander<Context> {
beforeTask(
setKey: string,
maxSizeKey: string,
forbiddenFlagsKey: string,
jobId: string,
timestamp: string,
windowSize: string,
forbiddenFlag: string,
maxSize: string,
callback?: Callback<string>
): Result<number | null, Context>;
rollbackBeforeTask(keys: number, ...args: string[]): Result<string, Context>;
afterTask(
setKey: string,
maxSizeKey: string,
forbiddenFlagsKey: string,
jobId: string,
timestamp: string,
windowSize: string,
forbiddenFlag: string,
maxSize: string,
callback?: Callback<string>
): Result<number | null, Context>;
}
}
type RedisRunExecutionRateLimiterOptions = {
redis?: RedisOptions;
cluster?: {
startupNodes: ClusterNode[];
options?: ClusterOptions;
};
defaultConcurrency?: number;
windowSize?: number;
prefix?: string;
};
const FORBIDDEN_FLAG_KEY = "forbiddenFlags";
const KEY_PREFIX = "tr:exec:";
class RedisRunExecutionRateLimiter implements RunExecutionRateLimiter, ZodWorkerRateLimiter {
private redis: Redis | Cluster;
private defaultMaxSize: number;
private windowSize: number;
constructor(options?: RedisRunExecutionRateLimiterOptions) {
this.redis = options?.cluster
? new Redis.Cluster(options.cluster.startupNodes, options.cluster.options)
: new Redis(options?.redis ?? {});
this.defaultMaxSize = options?.defaultConcurrency ?? 10;
this.windowSize = options?.windowSize ?? 1000 * 15 * 60; // 2 minutes
this.redis.defineCommand("beforeTask", {
numberOfKeys: 3,
lua: `
local setKey = KEYS[1]
local maxSizeKey = KEYS[2]
local forbiddenFlagsKey = KEYS[3]
local jobId = ARGV[1]
local timestamp = ARGV[2]
local windowSize = ARGV[3]
local forbiddenFlag = ARGV[4]
local defaultMaxSize = ARGV[5]
local maxSize = tonumber(redis.call('GET', maxSizeKey) or defaultMaxSize)
local currentSize = redis.call('ZCOUNT', setKey, timestamp - windowSize, timestamp)
if currentSize < maxSize then
redis.call('ZADD', setKey, timestamp, jobId)
return true
else
redis.call('SADD', forbiddenFlagsKey, forbiddenFlag)
return false
end
`,
});
// This will remove the job ID from the ZSET
this.redis.defineCommand("rollbackBeforeTask", {
lua: `
for i, key in ipairs(KEYS) do
redis.call('ZREM', key, ARGV[1])
end
`,
});
this.redis.defineCommand("afterTask", {
numberOfKeys: 3,
lua: `
local setKey = KEYS[1]
local maxSizeKey = KEYS[2]
local forbiddenFlagsKey = KEYS[3]
local jobId = ARGV[1]
local timestamp = ARGV[2]
local windowSize = ARGV[3]
local forbiddenFlag = ARGV[4]
local defaultMaxSize = ARGV[5]
local maxSize = tonumber(redis.call('GET', maxSizeKey) or defaultMaxSize)
-- Remove the job ID from the ZSET
redis.call('ZREM', setKey, jobId)
-- Count the current number of jobs in the window
local currentSize = redis.call('ZCOUNT', setKey, timestamp - windowSize, timestamp)
-- The cleanup of old job IDs is now an essential part of maintaining the ZSET's size
redis.call('ZREMRANGEBYSCORE', setKey, '-inf', timestamp - windowSize)
-- Update the forbidden flags based on the current size
if currentSize < maxSize then
-- Only remove the forbidden flag if it's no longer needed
redis.call('SREM', forbiddenFlagsKey, forbiddenFlag)
return true
else
-- No need to add the forbidden flag here as it should be handled in beforeTask
return false
end
`,
});
if (this.redis instanceof Redis) {
logger.debug("⚡ RedisGraphileRateLimiter connected to Redis", {
host: this.redis.options.host,
port: this.redis.options.port,
});
} else {
logger.debug("⚡ RedisGraphileRateLimiter connected to Redis Cluster", {
nodes: this.redis.nodes,
});
}
}
async forbiddenFlags(): Promise<string[]> {
return this.redis.smembers(FORBIDDEN_FLAG_KEY);
}
async putConcurrencyLimitGroup(
concurrencyLimitGroup: ConcurrencyLimitGroup,
env: RuntimeEnvironment
): Promise<void> {
await this.setMaxSizeForFlag(
this.flagForConcurrencyLimitGroup(concurrencyLimitGroup, env),
concurrencyLimitGroup.concurrencyLimit
);
}
async putJobVersionConcurrencyLimit(
jobVersion: JobVersion,
env: RuntimeEnvironment
): Promise<void> {
const flag = this.flagForJobVersion(jobVersion, env);
if (typeof jobVersion.concurrencyLimit === "number" && jobVersion.concurrencyLimit > 0) {
await this.setMaxSizeForFlag(flag, jobVersion.concurrencyLimit);
} else {
await this.delMaxSizeForFlag(flag);
}
}
flagsForRun(
run: JobRun,
version: JobVersion & {
environment: RuntimeEnvironment;
concurrencyLimitGroup?: ConcurrencyLimitGroup | null;
}
): string[] {
const flags = [this.flagForOrganization(run)];
if (version.concurrencyLimitGroup) {
flags.push(
this.flagForConcurrencyLimitGroup(version.concurrencyLimitGroup, version.environment)
);
} else if (typeof version.concurrencyLimit === "number" && version.concurrencyLimit > 0) {
flags.push(this.flagForJobVersion(version, version.environment));
}
return flags;
}
flagForConcurrencyLimitGroup(
concurrencyLimitGroup: ConcurrencyLimitGroup,
env: RuntimeEnvironment
): string {
return `rl:group:${env.id}:${env.slug}:${concurrencyLimitGroup.name}`;
}
flagForOrganization(run: JobRun): string {
return `rl:org:${run.organizationId}`;
}
flagForJobVersion(version: JobVersion, env: RuntimeEnvironment): string {
return `rl:job:${env.slug}:${version.id}`;
}
async setMaxSizeForFlag(flag: string, maxSize: number): Promise<void> {
await this.redis.set(`${flag}:maxSize`, String(maxSize));
}
async delMaxSizeForFlag(flag: string): Promise<void> {
await this.redis.del(`${flag}:maxSize`);
}
wrapTask(t: Task, rescheduler: Task): Task {
return async (payload: unknown, helpers: JobHelpers) => {
const flags = Object.keys(helpers.job.flags ?? {}).filter((flag) => flag.startsWith("rl:"));
if (flags.length === 0) {
return t(payload, helpers);
}
let passedFlags = [];
for (const flag of flags) {
const result = await this.#callBeforeTask(flag, String(helpers.job.id));
if (
(result.status === "fulfilled" && result.value === null) ||
result.status === "rejected"
) {
logger.debug("Rolling back passed flags", {
flag,
passedFlags,
jobId: String(helpers.job.id),
result,
});
// If there are any passed flags, we need to roll them back
await this.#rollbackPassedFlags(passedFlags, String(helpers.job.id));
return await rescheduler(payload, helpers);
}
passedFlags.push(flag);
}
try {
await t(payload, helpers);
} finally {
const afterResults = await Promise.allSettled(
flags.map(async (flag) => this.#callAfterTask(flag, String(helpers.job.id)))
);
}
};
}
async #callBeforeTask(
flag: string,
jobId: string
): Promise<
| { status: "fulfilled"; value: number | null; durationInMs: number }
| { status: "rejected"; error: any }
> {
try {
const now = performance.now();
const value = await this.redis.beforeTask(
flag,
`${flag}:maxSize`,
FORBIDDEN_FLAG_KEY,
jobId,
String(Date.now()),
String(this.windowSize),
flag,
String(this.defaultMaxSize)
);
const durationInMs = performance.now() - now;
return {
status: "fulfilled",
value,
durationInMs,
};
} catch (error) {
logger.error("Failed to call beforeTask", { error, flag, jobId });
return {
status: "rejected",
error,
};
}
}
// Method for rolling back passed flags using a single Lua script
async #rollbackPassedFlags(passedFlags: string[], jobId: string) {
if (passedFlags.length > 0) {
await this.redis.rollbackBeforeTask(passedFlags.length, ...passedFlags, jobId);
}
}
async #callAfterTask(flag: string, jobId: string) {
try {
const now = performance.now();
const results = await this.redis.afterTask(
flag,
`${flag}:maxSize`,
FORBIDDEN_FLAG_KEY,
jobId,
String(Date.now()),
String(this.windowSize),
flag,
String(this.defaultMaxSize)
);
const durationInMs = performance.now() - now;
return {
results,
durationInMs,
};
} catch (error) {
logger.error("Failed to call afterTask", { error, flag, jobId });
}
}
}
export const executionRateLimiter = singleton("execution-rate-limiter", getRateLimiter);
function getRateLimiter() {
if (env.REDIS_HOST && env.REDIS_PORT) {
if (env.REDIS_READER_HOST) {
return new RedisRunExecutionRateLimiter({
cluster: {
startupNodes: [
{ host: env.REDIS_HOST, port: env.REDIS_PORT },
{ host: env.REDIS_READER_HOST, port: env.REDIS_READER_PORT ?? env.REDIS_PORT },
],
options: {
keyPrefix: KEY_PREFIX,
scaleReads: "slave",
redisOptions: {
password: env.REDIS_PASSWORD,
tls: {
checkServerIdentity: () => {
// disable TLS verification
return undefined
}
},
enableAutoPipelining: true,
},
dnsLookup: (address, callback) => callback(null, address),
slotsRefreshTimeout: 10000,
},
},
defaultConcurrency: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
});
} else {
return new RedisRunExecutionRateLimiter({
redis: {
keyPrefix: KEY_PREFIX,
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} })
},
defaultConcurrency: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
});
}
}
}
@@ -1,6 +1,6 @@
import { PrismaClient, prisma } from "~/db.server";
import { executionWorker } from "../worker.server";
import { dequeueRunExecutionV3 } from "~/models/jobRunExecution.server";
import { PerformRunExecutionV3Service } from "./performRunExecutionV3.server";
import { ResumeRunService } from "./resumeRun.server";
export class CancelRunService {
#prismaClient: PrismaClient;
@@ -39,7 +39,8 @@ export class CancelRunService {
},
});
await dequeueRunExecutionV3(run, tx);
await PerformRunExecutionV3Service.dequeue(run, tx);
await ResumeRunService.dequeue(run, tx);
});
} catch (error) {
throw error;
@@ -1,6 +1,5 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
import { ResumeRunService } from "./resumeRun.server";
const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "UNRESOLVED_AUTH", "ABORTED", "CANCELED"];
@@ -39,9 +38,7 @@ export class ContinueRunService {
},
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(run, tx);
},
{ timeout: 10000 }
);
@@ -31,12 +31,6 @@ export class CreateRunService {
},
});
const jobQueue = await this.#prismaClient.jobQueue.findUniqueOrThrow({
where: {
id: version.queueId,
},
});
const eventRecord = await this.#prismaClient.eventRecord.findUniqueOrThrow({
where: {
id: eventId,
@@ -44,22 +38,8 @@ export class CreateRunService {
});
return await $transaction(this.#prismaClient, async (tx) => {
// Get the current max number for the given jobId
const latestJob = await tx.jobRun.findFirst({
where: { jobId: job.id },
orderBy: { id: "desc" },
select: {
number: true,
},
});
// Increment the number for the new execution
const newNumber = (latestJob?.number ?? 0) + 1;
// Create the new execution with the incremented number
const run = await tx.jobRun.create({
data: {
number: newNumber,
preprocess: version.preprocessRuns,
jobId: job.id,
versionId: version.id,
@@ -68,7 +48,6 @@ export class CreateRunService {
organizationId: environment.organizationId,
projectId: environment.projectId,
endpointId: endpoint.id,
queueId: jobQueue.id,
externalAccountId: eventRecord.externalAccountId
? eventRecord.externalAccountId
: undefined,
@@ -16,7 +16,12 @@ import {
supportsFeature,
} from "@trigger.dev/core";
import { BloomFilter } from "@trigger.dev/core-backend";
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
import {
ConcurrencyLimitGroup,
JobRun,
JobVersion,
RuntimeEnvironment,
} from "@trigger.dev/database";
import { generateErrorMessage } from "zod-error";
import { eventRecordToApiJson } from "~/api.server";
import {
@@ -26,7 +31,7 @@ import {
} from "~/consts";
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
import { detectResponseIsTimeout } from "~/models/endpoint.server";
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
import { isRunCompleted } from "~/models/jobRun.server";
import { resolveRunConnections } from "~/models/runConnection.server";
import { prepareTasksForCaching, prepareTasksForCachingLegacy } from "~/models/task.server";
import { CompleteRunTaskService } from "~/routes/api.v1.runs.$runId.tasks.$id.complete";
@@ -36,8 +41,11 @@ import { EndpointApi } from "../endpointApi.server";
import { createExecutionEvent } from "../executions/createExecutionEvent.server";
import { logger } from "../logger.server";
import { ResumeTaskService } from "../tasks/resumeTask.server";
import { workerQueue } from "../worker.server";
import { executionWorker, workerQueue } from "../worker.server";
import { forceYieldCoordinator } from "./forceYieldCoordinator.server";
import { ResumeRunService } from "./resumeRun.server";
import { executionRateLimiter } from "../runExecutionRateLimiter.server";
import { env } from "~/env.server";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
type FoundTask = FoundRun["tasks"][number];
@@ -58,8 +66,15 @@ export type PerformRunExecutionV3Input = {
* @deprecated Resuming tasks now goes through ResumeTaskService, this is included here for backwards compatibility
*/
resumeTaskId?: string;
/**
* Specifies whether this should be the last attempt to execute the run. If so, we can't retry the run in case of a failure.
*/
lastAttempt: boolean;
};
export type RunExecutionPriority = "initial" | "resume";
export class PerformRunExecutionV3Service {
#prismaClient: PrismaClient;
@@ -74,206 +89,85 @@ export class PerformRunExecutionV3Service {
return;
}
switch (input.reason) {
case "PREPROCESS": {
await this.#executePreprocessing(run);
break;
}
case "EXECUTE_JOB": {
await this.#executeJob(run, input, driftInMs);
break;
}
}
await this.#executeJob(run, input, driftInMs);
}
// Execute the preprocessing step of a run, which will send the payload to the endpoint and give the job
// an opportunity to generate run properties based on the payload.
// If the endpoint is not available, or the response is not ok,
// the run execution will be marked as failed and the run will start
async #executePreprocessing(run: FoundRun) {
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
const event = eventRecordToApiJson(run.event);
const { response, parser } = await client.preprocessRunRequest({
event,
job: {
id: run.version.job.slug,
version: run.version.version,
},
run: {
static async enqueue(
run: JobRun & {
version: JobVersion & {
environment: RuntimeEnvironment;
concurrencyLimitGroup?: ConcurrencyLimitGroup | null;
};
},
priority: RunExecutionPriority,
tx: PrismaClientOrTransaction,
options: {
runAt?: Date;
skipRetrying?: boolean;
} = {}
) {
return await executionWorker.enqueue(
"performRunExecutionV3",
{
id: run.id,
isTest: run.isTest,
reason: "EXECUTE_JOB",
},
environment: {
id: run.environment.id,
slug: run.environment.slug,
type: run.environment.type,
},
organization: {
id: run.organization.id,
slug: run.organization.slug,
title: run.organization.title,
},
account: run.externalAccount
? {
id: run.externalAccount.identifier,
metadata: run.externalAccount.metadata,
}
: undefined,
});
if (!response) {
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
message: "Could not connect to the endpoint",
});
}
if (!response.ok) {
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
message: `Endpoint responded with ${response.status} status code`,
});
}
const rawBody = await response.text();
const safeBody = safeJsonZodParse(parser, rawBody);
if (!safeBody) {
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
message: "Endpoint responded with invalid JSON",
});
}
if (!safeBody.success) {
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
message: generateErrorMessage(safeBody.error.issues),
});
}
if (safeBody.data.abort) {
return this.#failRunExecution(
this.#prismaClient,
"PREPROCESS",
run,
{ message: "Endpoint aborted the run" },
"ABORTED"
);
} else {
await $transaction(this.#prismaClient, async (tx) => {
await tx.jobRun.update({
where: {
id: run.id,
},
data: {
status: "STARTED",
startedAt: new Date(),
properties: safeBody.data.properties,
forceYieldImmediately: false,
},
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
});
}
{
tx,
runAt: options.runAt,
jobKey: `job_run:EXECUTE_JOB:${run.id}`,
maxAttempts: options.skipRetrying ? env.DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS : undefined,
flags: executionRateLimiter?.flagsForRun(run, run.version) ?? [],
priority: priority === "initial" ? 0 : -1,
}
);
}
static async dequeue(run: JobRun, tx: PrismaClientOrTransaction) {
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
tx,
});
}
async #executeJob(run: FoundRun, input: PerformRunExecutionV3Input, driftInMs: number = 0) {
try {
const { isRetry, resumeTaskId } = input;
if (run.status === "CANCELED") {
await this.#cancelExecution(run);
if (isRunCompleted(run.status)) {
return;
}
try {
if (
typeof process.env.BLOCKED_ORGS === "string" &&
process.env.BLOCKED_ORGS.includes(run.organizationId)
) {
logger.debug("Skipping execution for blocked org", {
orgId: run.organizationId,
});
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: "CANCELED",
completedAt: new Date(),
},
});
return;
}
} catch (e) {}
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
const event = eventRecordToApiJson(run.event);
const startedAt = new Date();
const { executionCount } = await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: run.status === "QUEUED" ? "STARTED" : run.status,
startedAt: run.startedAt ?? new Date(),
executionCount: {
increment: 1,
},
},
select: {
executionCount: true,
},
});
const connections = await resolveRunConnections(run.runConnections);
if (!connections.success) {
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
return this.#failRunExecution(this.#prismaClient, run, {
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
});
}
let resumedTask: Task | undefined;
if (resumeTaskId) {
resumedTask =
(await this.#prismaClient.task.findUnique({
where: {
id: resumeTaskId,
},
})) ?? undefined;
if (resumedTask) {
resumedTask = await this.#prismaClient.task.update({
where: {
id: resumeTaskId,
},
data: {
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
completedAt: resumedTask.noop ? new Date() : undefined,
},
});
}
}
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
const executionBody = await this.#createExecutionBody(
run,
[run.tasks, resumedTask].flat().filter(Boolean),
run.tasks,
startedAt,
isRetry,
false,
connections.auth,
event,
sourceContext.success ? sourceContext.data : undefined
);
forceYieldCoordinator.registerRun(run.id);
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: "EXECUTING",
},
});
await createExecutionEvent({
eventType: "start",
@@ -284,8 +178,12 @@ export class PerformRunExecutionV3Service {
projectId: run.projectId,
jobId: run.jobId,
runId: run.id,
concurrencyLimitGroupId: run.version.concurrencyLimitGroupId,
});
forceYieldCoordinator.registerRun(run.id);
// TODO: add the ability to abort the execution from any server using Redis pub/sub
const { response, parser, errorParser, headersParser, durationInMs } =
await client.executeJobRequest(executionBody);
@@ -298,14 +196,20 @@ export class PerformRunExecutionV3Service {
projectId: run.projectId,
jobId: run.jobId,
runId: run.id,
concurrencyLimitGroupId: run.version.concurrencyLimitGroupId,
});
forceYieldCoordinator.deregisterRun(run.id);
if (!response) {
return await this.#failRunExecutionWithRetry({
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
});
return await this.#failRunExecutionWithRetry(
run,
input.lastAttempt,
{
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
},
durationInMs
);
}
// Update the endpoint version if it has changed
@@ -386,6 +290,8 @@ export class PerformRunExecutionV3Service {
status: response.status,
runId: run.id,
endpoint: run.endpoint.url,
headers: rawHeaders,
rawBody,
});
const errorBody = safeJsonZodParse(errorParser, rawBody);
@@ -393,14 +299,14 @@ export class PerformRunExecutionV3Service {
if (errorBody && errorBody.success) {
// Only retry if the error isn't a 4xx
if (response.status >= 400 && response.status <= 499) {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
errorBody.data
);
return await this.#failRunExecution(this.#prismaClient, run, errorBody.data);
} else {
return await this.#failRunExecutionWithRetry(errorBody.data);
return await this.#failRunExecutionWithRetry(
run,
input.lastAttempt,
errorBody.data,
durationInMs
);
}
}
@@ -408,7 +314,6 @@ export class PerformRunExecutionV3Service {
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: `Endpoint responded with ${response.status} status code`,
@@ -418,18 +323,22 @@ export class PerformRunExecutionV3Service {
);
} else {
// If the error is a timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
if (detectResponseIsTimeout(response)) {
if (detectResponseIsTimeout(rawBody, response)) {
return await this.#resumeRunExecutionAfterTimeout(
this.#prismaClient,
run,
input,
durationInMs,
executionCount
durationInMs
);
} else {
return await this.#failRunExecutionWithRetry({
message: `Endpoint responded with ${response.status} status code`,
});
return await this.#failRunExecutionWithRetry(
run,
input.lastAttempt,
{
message: `Endpoint responded with ${response.status} status code`,
},
durationInMs
);
}
}
}
@@ -439,7 +348,6 @@ export class PerformRunExecutionV3Service {
if (!safeBody) {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: "Endpoint responded with invalid JSON",
@@ -452,7 +360,6 @@ export class PerformRunExecutionV3Service {
if (!safeBody.success) {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
{
message: generateErrorMessage(safeBody.error.issues),
@@ -491,7 +398,6 @@ export class PerformRunExecutionV3Service {
break;
}
case "CANCELED": {
await this.#cancelExecution(run);
break;
}
case "UNRESOLVED_AUTH_ERROR": {
@@ -644,6 +550,9 @@ export class PerformRunExecutionV3Service {
executionDuration: {
increment: durationInMs,
},
executionCount: {
increment: 1,
},
},
});
@@ -661,17 +570,18 @@ export class PerformRunExecutionV3Service {
run: FoundRun,
data: RunJobResumeWithTask,
durationInMs: number,
executionCount: number = 1
executionCountIncrement: number = 1
) {
return await $transaction(this.#prismaClient, async (tx) => {
await tx.jobRun.update({
where: { id: run.id },
data: {
status: "WAITING_TO_CONTINUE",
executionDuration: {
increment: durationInMs,
},
executionCount: {
increment: executionCount,
increment: executionCountIncrement,
},
},
});
@@ -744,7 +654,6 @@ export class PerformRunExecutionV3Service {
case "ERROR": {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
childError.error ?? undefined,
"FAILURE",
@@ -754,7 +663,6 @@ export class PerformRunExecutionV3Service {
case "INVALID_PAYLOAD": {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
childError.errors,
"INVALID_PAYLOAD",
@@ -774,7 +682,6 @@ export class PerformRunExecutionV3Service {
case "UNRESOLVED_AUTH_ERROR": {
return await this.#failRunExecution(
this.#prismaClient,
"EXECUTE_JOB",
run,
childError.issues,
"UNRESOLVED_AUTH",
@@ -805,14 +712,7 @@ export class PerformRunExecutionV3Service {
});
}
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
execution,
data.error ?? undefined,
"FAILURE",
durationInMs
);
await this.#failRunExecution(tx, execution, data.error ?? undefined, "FAILURE", durationInMs);
});
}
@@ -822,14 +722,7 @@ export class PerformRunExecutionV3Service {
durationInMs: number
) {
return await $transaction(this.#prismaClient, async (tx) => {
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
execution,
data.issues,
"UNRESOLVED_AUTH",
durationInMs
);
await this.#failRunExecution(tx, execution, data.issues, "UNRESOLVED_AUTH", durationInMs);
});
}
@@ -839,14 +732,7 @@ export class PerformRunExecutionV3Service {
durationInMs: number
) {
return await $transaction(this.#prismaClient, async (tx) => {
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
execution,
data.errors,
"INVALID_PAYLOAD",
durationInMs
);
await this.#failRunExecution(tx, execution, data.errors, "INVALID_PAYLOAD", durationInMs);
});
}
@@ -860,7 +746,6 @@ export class PerformRunExecutionV3Service {
if (run.yieldedExecutions.length + 1 > MAX_RUN_YIELDED_EXECUTIONS) {
return await this.#failRunExecution(
tx,
"EXECUTE_JOB",
run,
{
message: `Run has yielded too many times, the maximum is ${MAX_RUN_YIELDED_EXECUTIONS}`,
@@ -875,6 +760,7 @@ export class PerformRunExecutionV3Service {
id: run.id,
},
data: {
status: "WAITING_TO_EXECUTE",
executionDuration: {
increment: durationInMs,
},
@@ -892,9 +778,7 @@ export class PerformRunExecutionV3Service {
},
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(run, tx);
});
}
@@ -910,6 +794,7 @@ export class PerformRunExecutionV3Service {
id: run.id,
},
data: {
status: "WAITING_TO_EXECUTE",
executionDuration: {
increment: durationInMs,
},
@@ -933,9 +818,7 @@ export class PerformRunExecutionV3Service {
},
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(run, tx);
});
}
@@ -968,6 +851,7 @@ export class PerformRunExecutionV3Service {
],
},
forceYieldImmediately: false,
status: "WAITING_TO_EXECUTE",
},
select: {
executionCount: true,
@@ -981,9 +865,7 @@ export class PerformRunExecutionV3Service {
output: data.output ? (JSON.parse(data.output) as any) : undefined,
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(run, tx);
});
}
@@ -1035,6 +917,7 @@ export class PerformRunExecutionV3Service {
status: "WAITING",
run: {
update: {
status: "WAITING_TO_CONTINUE",
executionDuration: {
increment: durationInMs,
},
@@ -1054,8 +937,7 @@ export class PerformRunExecutionV3Service {
prisma: PrismaClientOrTransaction,
run: FoundRun,
input: PerformRunExecutionV3Input,
durationInMs: number,
executionCount: number
durationInMs: number
) {
await $transaction(prisma, async (tx) => {
const executionDuration = run.executionDuration + durationInMs;
@@ -1064,7 +946,6 @@ export class PerformRunExecutionV3Service {
if (executionDuration >= run.organization.maximumExecutionTimePerRunInMs) {
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
run,
{
message: `Execution timed out after ${
@@ -1112,7 +993,6 @@ export class PerformRunExecutionV3Service {
await this.#failRunExecution(
tx,
"EXECUTE_JOB",
run,
{
message: `Function timeout detected in ${
@@ -1133,6 +1013,9 @@ export class PerformRunExecutionV3Service {
executionDuration: {
increment: durationInMs,
},
executionCount: {
increment: 1,
},
endpoint: {
update: {
// Never allow the execution limit to be less than 10 seconds or more than MAX_RUN_CHUNK_EXECUTION_LIMIT
@@ -1143,106 +1026,92 @@ export class PerformRunExecutionV3Service {
},
},
forceYieldImmediately: false,
status: "WAITING_TO_EXECUTE",
},
});
// The run has timed out, so we need to enqueue a new execution
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(run, tx);
});
}
async #failRunExecutionWithRetry(output: Record<string, any>): Promise<void> {
throw new Error(JSON.stringify(output));
async #failRunExecutionWithRetry(
run: FoundRun,
lastAttempt: boolean,
output: Record<string, any>,
durationInMs: number = 0
): Promise<void> {
if (lastAttempt) {
return await this.#failRunExecution(this.#prismaClient, run, output);
}
const updatedJob = await this.#prismaClient.jobRun.update({
where: { id: run.id },
data: {
status: "WAITING_TO_EXECUTE",
executionFailureCount: {
increment: 1,
},
},
});
if (updatedJob.executionFailureCount >= 10) {
return await this.#failRunExecution(this.#prismaClient, run, output);
}
// Use the job.executionFailureCount to determine how long to wait before retrying, using an exponential backoff
const runAt = new Date(Date.now() + Math.pow(1.5, updatedJob.executionFailureCount) * 500); // 500ms, 750ms, 1125ms, 1687ms, 2531ms, 3796ms, 5694ms, 8541ms, 12812ms, 19218ms
await ResumeRunService.enqueue(run, this.#prismaClient, runAt);
}
async #failRunExecution(
prisma: PrismaClientOrTransaction,
reason: "EXECUTE_JOB" | "PREPROCESS",
run: FoundRun,
output: Record<string, any>,
status: "FAILURE" | "ABORTED" | "TIMED_OUT" | "UNRESOLVED_AUTH" | "INVALID_PAYLOAD" = "FAILURE",
durationInMs: number = 0
): Promise<void> {
await $transaction(prisma, async (tx) => {
switch (reason) {
case "EXECUTE_JOB": {
// If the execution is an EXECUTE_JOB reason, we need to fail the run
await tx.jobRun.update({
where: { id: run.id },
data: {
completedAt: new Date(),
status,
output,
executionDuration: {
increment: durationInMs,
},
tasks: {
updateMany: {
where: {
status: {
in: ["WAITING", "RUNNING", "PENDING"],
},
},
data: {
status: status === "TIMED_OUT" ? "CANCELED" : "ERRORED",
completedAt: new Date(),
},
// If the execution is an EXECUTE_JOB reason, we need to fail the run
await tx.jobRun.update({
where: { id: run.id },
data: {
completedAt: new Date(),
status,
output,
executionDuration: {
increment: durationInMs,
},
executionCount: {
increment: 1,
},
tasks: {
updateMany: {
where: {
status: {
in: ["WAITING", "RUNNING", "PENDING"],
},
},
forceYieldImmediately: false,
},
});
await workerQueue.enqueue(
"deliverRunSubscriptions",
{
id: run.id,
},
{ tx }
);
break;
}
case "PREPROCESS": {
// If the status is ABORTED, we need to fail the run
if (status === "ABORTED") {
await tx.jobRun.update({
where: { id: run.id },
data: {
status: status === "TIMED_OUT" ? "CANCELED" : "ERRORED",
completedAt: new Date(),
status,
output,
},
});
break;
}
await tx.jobRun.update({
where: {
id: run.id,
},
data: {
status: "STARTED",
startedAt: new Date(),
},
});
},
forceYieldImmediately: false,
},
});
await enqueueRunExecutionV3(run, tx, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
break;
}
}
await workerQueue.enqueue(
"deliverRunSubscriptions",
{
id: run.id,
},
{ tx }
);
});
}
async #cancelExecution(run: FoundRun) {
return;
}
}
function prepareNoOpTasksBloomFilter(possibleTasks: FoundTask[]): string {
@@ -0,0 +1,158 @@
import { JobRun, RuntimeEnvironmentType } from "@trigger.dev/database";
import { PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
import { workerQueue } from "../worker.server";
import { PerformRunExecutionV3Service, RunExecutionPriority } from "./performRunExecutionV3.server";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
export class ResumeRunService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const run = await findRun(this.#prismaClient, id);
if (!run) {
return;
}
switch (run.status) {
case "ABORTED":
case "CANCELED":
case "FAILURE":
case "INVALID_PAYLOAD":
case "SUCCESS":
case "TIMED_OUT":
case "UNRESOLVED_AUTH": {
return;
}
case "QUEUED": {
await this.#resumeQueuedRun(run);
break;
}
case "WAITING_TO_EXECUTE": {
await this.#executeRun(run, "resume");
break;
}
case "WAITING_TO_CONTINUE": {
await this.#resumeWaitingToContinueRun(run);
break;
}
case "STARTED": {
await this.#resumeStartedRun(run);
break;
}
case "PENDING":
case "PREPROCESSING": {
await this.#resumePendingRun(run);
break;
}
case "EXECUTING": {
throw new Error("Cannot resume a run that is currently executing");
}
case "WAITING_ON_CONNECTIONS": {
throw new Error("Cannot resume a run that is waiting on connections");
}
default: {
const _exhaustiveCheck: never = run.status;
throw new Error(`Non-exhaustive match for value: ${run.status}`);
}
}
}
async #resumeQueuedRun(run: FoundRun) {
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
startedAt: run.startedAt ?? new Date(),
},
});
await this.#executeRun(run, "initial");
}
async #resumeStartedRun(run: FoundRun) {
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: "WAITING_TO_EXECUTE",
},
});
await this.#executeRun(run, "initial");
}
async #resumeWaitingToContinueRun(run: FoundRun) {
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: "WAITING_TO_EXECUTE",
},
});
await this.#executeRun(run, "resume");
}
async #resumePendingRun(run: FoundRun) {
await this.#prismaClient.jobRun.update({
where: {
id: run.id,
},
data: {
status: "QUEUED",
startedAt: new Date(),
},
});
await this.#executeRun(run, "initial");
}
async #executeRun(run: FoundRun, priority: RunExecutionPriority) {
await PerformRunExecutionV3Service.enqueue(run, priority, this.#prismaClient, {
skipRetrying: run.version.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
}
static async enqueue(run: JobRun, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"resumeRun",
{
id: run.id,
},
{
tx,
runAt: runAt ?? run.createdAt,
jobKey: `run_resume:${run.id}`,
}
);
}
static async dequeue(run: JobRun, tx: PrismaClientOrTransaction) {
await workerQueue.dequeue(`run_resume:${run.id}`, {
tx,
});
}
}
async function findRun(prisma: PrismaClientOrTransaction, id: string) {
return await prisma.jobRun.findUnique({
where: { id },
include: {
version: {
include: {
environment: true,
concurrencyLimitGroup: true,
},
},
},
});
}
@@ -1,13 +1,13 @@
import {
RuntimeEnvironmentType,
type ConnectionType,
type Integration,
type IntegrationConnection,
} from "@trigger.dev/database";
import type { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
import { $transaction, prisma } from "~/db.server";
import { workerQueue } from "../worker.server";
import { ResumeRunService } from "./resumeRun.server";
import { createHash } from "node:crypto";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
@@ -59,23 +59,24 @@ export class StartRunService {
: undefined
)
.filter(Boolean);
const lockId = jobIdToLockId(run.jobId);
const updateRun = async () => {
if (run.preprocess) {
// Start the jobRun and increment the jobCount
return await this.#prismaClient.jobRun.update({
where: { id },
data: {
status: "PREPROCESSING",
runConnections: {
create: createRunConnections,
},
},
await $transaction(
this.#prismaClient,
async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const counter = await tx.jobCounter.upsert({
where: { jobId: run.jobId },
update: { lastNumber: { increment: 1 } },
create: { jobId: run.jobId, lastNumber: 1 },
select: { lastNumber: true },
});
} else {
return await this.#prismaClient.jobRun.update({
const updatedRun = await this.#prismaClient.jobRun.update({
where: { id },
data: {
number: counter.lastNumber,
status: "QUEUED",
queuedAt: new Date(),
runConnections: {
@@ -83,14 +84,11 @@ export class StartRunService {
},
},
});
}
};
const updatedRun = await updateRun();
await enqueueRunExecutionV3(updatedRun, this.#prismaClient, {
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
});
await ResumeRunService.enqueue(updatedRun, tx);
},
{ timeout: 60000 }
);
}
async #handleMissingConnections(id: string, runConnectionsByKey: RunConnectionsByKey) {
@@ -237,3 +235,8 @@ async function createRunConnections(tx: PrismaClientOrTransaction, run: FoundRun
function hasMissingConnections(runConnectionsByKey: RunConnectionsByKey) {
return Object.values(runConnectionsByKey).some((connection) => connection.result === "missing");
}
function jobIdToLockId(jobId: string): number {
// Convert jobId to a unique lock identifier
return parseInt(createHash("sha256").update(jobId).digest("hex").slice(0, 8), 16);
}
@@ -214,8 +214,8 @@ export class PerformTaskOperationService {
const abortController = new AbortController();
// calculate the actual timeout. If timeoutInMs is undefined, we use the default of 120s
// Also make sure the timeout is at least 1s, but not bigger than 120s
const actualTimeoutInMs = Math.min(Math.max(timeout?.durationInMs ?? 120000, 1000), 120000);
// Also make sure the timeout is at least 1s, but not bigger than 300s
const actualTimeoutInMs = Math.min(Math.max(timeout?.durationInMs ?? 120000, 1000), 300000);
const timeoutId = setTimeout(() => {
abortController.abort();

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