Usage and billing (v3) (#1201)

* v3 subscription endpoints

* Use pnpm linked billing package during development

* Moved v2 billing components into a subfolder

* Select plan using the real data

* Improved v3 plan display

* Use new api response that doesn’t require a Stripe call

* Free flow is working

* Added GitHub modal and verified badge

* Deleted old request v3 access component/route

* Allow setting classes on the Tooltip button

* Paid plans working

* Redirect from select plan if you’ve got v3 enabled

* Loading state improvements

* New admin API endpoint to set concurrency across multiple environments

* When projects are created, conditionally create staging based on the plan

* New billing page working with side menu and stripe portal

* Layout, formatting and some plan state improvements/fixes

* Don’t show the period if you’re on the free plan

* Temporary upgrade callout

* Refactored the platform code so it’s easier to call and doesn’t require a isManagedCloud check

* Send taskIdentifier to OpenMeter

* Side menu

* Upgrade prompts

* More improvements to the app-wide usage indicators

* Early work on usage graphs

* Added the usage bar for v3

* Moved code to presenter and now using defer

* Added the tasks table to usage

* Improved the v3 usage bar if theres’ no usage on a paid plan

* If no run data, still render a graph

* Usage page errors when defered loading fails

* Don’t show the public API key for v3, they’re not used and probably never will be

* Improved the upgrade callout and API keys page layout

* Only show the “reveal all” toggle if you have environment variables in the table

* Replaced Upgrade callout with a more generic InfoPanel component

* better panel width

* Show conditional upgrade prompts based on plan and number of schedules used

* Removed duplicate class

* Wider blank state panels for the scheduled page

* Wider info panel for the env var page

* Blank state now using the info panel

* Platform alerts prompt now using the InfoPanel

* Deploy blank state uses InfoPanel

* Github verified badge padding adjustment

* Improved the layout of the page, some style tweaks, organized imports

* Better default tooltip style

* could be undefined fix

* text fix + style updates

* Changed the billing icon in the side menu

* Billing page layout and style improvements

* plan tooltips don’t use dark variant

* Don’t highlight the plan on the billing page

* Tooltip underlines stand out more

* Fixed padding in the PageTitle

* Tooltips use the correct cursor

* Improved the plan banner on the billing page

* Fixed Header1 inconsistent font weight

* Fixed issue where input field focus states were being clipped

* Fixed large button not having large text size

* Added a link to the Get in touch copy and improved the connect to GitHub modal

* Select plan page uses the MainCenteredContainer

* Better logging from the Loops endpoint because this error finally got hit

* Move the ingestion of compute to the platform

* Reporting usage of invocations moved to the platform

* Get the entitlement before triggering a non-dev task

* Contact us enterprise plan button opens the feedback form

* Removed Github discussions link from the Feedback panel

* Swapped billing icon for credit card

* Show a Unlock staging panel on the env var page

* Updated staging environment colour

* Show a prompt to upgrade to get staging in the new env var modal

* Improved the edit env var modal

* Implement ability to disable org concurrency

* Use common logic for the plans

* Use the billing server to get the schedule limits

* Some schedules page fixes

* More convenient way of getting a limit

* Use the new schedule limit

* Team member limiting

* Made the limit visible on the team page

* Limit alerts

* Added an index for TaskRun.scheduleId

* Remove console.log on schedules page

* Added durations to the run table

* Tabular numbers

* Improved the usage page formatting

* Only admins see the compute column on the run table

* Include the base cost on the usage stats

* Moved the status to the sidebar

* Optional table header tooltip

* Allow InfoIconTooltips to have customizable content styles

* Added a tooltip to the duration header, changed no test to a dash

* Removed all references to signing up to v3 from the docs

* Switched @trigger.dev/billing to @trigger.dev/platform

* Passing up the variant for the InfoIconTooltip

* table tooltip max-width fixed

* Switch to the published @trigger.dev/platform 1.0.11

* v2 usage page title changed to include “v2"

* code theme has a transparent background so it works on any background

* duration columns now grouped together nicely at wide screen size

* Last duration column fills the width properly

* Fix for the per run price being in cents not dollars

* Show the total cost with 8 decimal places

* Show 8 decimal places in the usage graph tooltip

* Moved the UpgradePrompt to the v3 folder

* Prepare to use Shadcns chart helpers

* Much nicer chart

* Small tweaks to the graph

* Fix run table col spans for empty/loading messages

* We don’t need isManagedCloud in createProject

* Hide v3 usage/billing pages if there aren’t v3 projects in your org

* Removed unused tooltipStyle

* Usage bar now says “Included usage” instead of “Tier limit” if you’re paying

* Get the plan/usage data in parallel

* The usage page now has a month dropdown and all data is for that calendar month

* Ensure the passed date is the 1st of the month

* Use the machine presets from the platform package

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: Eric Allam <eallam@icloud.com>
This commit is contained in:
Matt Aitken
2024-07-08 14:42:19 +01:00
committed by GitHub
parent 305a6cebd4
commit 72f3a4b128
92 changed files with 3722 additions and 1207 deletions
@@ -15,11 +15,11 @@ export function DefinitionTip({
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger>
<span className="underline decoration-charcoal-600 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-500">
<span className="cursor-default underline decoration-charcoal-500 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-400">
{children}
</span>
</TooltipTrigger>
<TooltipContent align="end" side="right" variant="dark" className="w-[16rem] min-w-[16rem]">
<TooltipContent align="end" side="right" className="w-[16rem] min-w-[16rem]">
<Header3 className="mb-1">{title}</Header3>
{typeof content === "string" ? (
<Paragraph variant="small">{content}</Paragraph>
+11 -43
View File
@@ -55,16 +55,6 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<SheetTrigger asChild={true}>{button}</SheetTrigger>
<SheetContent className="@container">
<SheetBody className="flex h-full flex-col justify-between">
<Header2 className="mb-2.5 text-xl">Get help from the community</Header2>
<Paragraph className="mb-4">
The quickest way to get help and feedback or to provide advice to others is to join our
Discord.
</Paragraph>
<div className="flex flex-col gap-x-4 @[30rem]:flex-row">
<DiscordBanner />
<GitHubDiscussionsBanner />
</div>
<hr className="mb-4" />
<Header2 className="mb-2.5 text-xl">Send us an email</Header2>
<Paragraph className="mb-4">We read every message and respond quickly.</Paragraph>
<Form method="post" action="/resources/feedback" {...form.props}>
@@ -106,6 +96,8 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
</Fieldset>
</Form>
<hr className="my-4" />
<DiscordBanner />
<hr className="mb-4" />
<Header2 className="mb-2.5 text-xl">Troubleshooting</Header2>
<Paragraph className="mb-4">
If you're having trouble, check out our documentation or the Trigger.dev Status page.
@@ -134,44 +126,20 @@ function DiscordBanner({ className }: { className?: string }) {
href="https://trigger.dev/discord"
target="_blank"
className={cn(
"group mb-4 flex w-full items-center justify-between rounded-md border border-charcoal-600 p-4 transition hover:border-text-link",
"group mb-4 flex w-full items-center justify-between rounded-md border border-grid-bright bg-charcoal-750 p-4 pl-6 transition hover:border-text-link",
className
)}
>
<div className="flex flex-col gap-y-2">
<DiscordIcon className="h-8 w-8" />
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
Join our Discord community
</Header1>
<Paragraph variant="small" className="mb-4">
Get help or answer questions from the Trigger.dev community.
</Paragraph>
<div className="flex items-center gap-6">
<DiscordIcon className="size-12" />
<div className="flex flex-col gap-2">
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
Join our Discord community
</Header1>
<Paragraph>The quickest way to get answers from the Trigger.dev community.</Paragraph>
</div>
</div>
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover:text-text-link" />
</a>
);
}
function GitHubDiscussionsBanner({ className }: { className?: string }) {
return (
<a
href="https://github.com/triggerdotdev/trigger.dev/discussions"
target="_blank"
className={cn(
"group mb-4 flex w-full items-center justify-between rounded-md border border-charcoal-600 p-4 transition hover:border-text-dimmed",
className
)}
>
<div className="flex flex-col gap-y-2">
<GitHubLightIcon className="mb-1 h-7 w-7" />
<Header1 className="text-2xl font-semibold text-text-bright transition group-hover:text-white">
View our GitHub Discussions
</Header1>
<Paragraph variant="small">
Post your questions, feedback, and feature requests on GitHub.
</Paragraph>
</div>
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover:text-text-bright" />
</a>
);
}
@@ -1,61 +0,0 @@
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { MatchedOrganization } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { LinkButton } from "../primitives/Buttons";
import { Icon } from "../primitives/Icon";
import { Paragraph } from "../primitives/Paragraph";
type UpgradePromptProps = {
runsEnabled: boolean;
runCountCap: number;
planPath: string;
};
export function UpgradePrompt({ runsEnabled, runCountCap, planPath }: UpgradePromptProps) {
return (
<div
className="flex h-10 items-center justify-between border border-error bg-repeat py-0 pl-3 pr-2"
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
>
<div className="flex items-center gap-2">
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
<Paragraph variant="small" className="text-error">
{runsEnabled
? `You have exceeded the monthly ${formatNumberCompact(runCountCap)} runs
limit`
: `No runs are executing because you have exceeded the free limit`}
</Paragraph>
</div>
<LinkButton
variant={"primary/small"}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="px-0"
to={planPath}
>
Upgrade
</LinkButton>
</div>
);
}
export function useShowUpgradePrompt(organization?: MatchedOrganization) {
const currentPlan = useCurrentPlan();
const shouldShow =
organization !== undefined &&
currentPlan !== undefined &&
currentPlan.usage.exceededRunCount &&
currentPlan.usage.runCountCap !== undefined;
if (!shouldShow) {
return { shouldShow };
}
return {
shouldShow,
runCountCap: currentPlan.usage.runCountCap!,
runsEnabled: organization.runsEnabled,
};
}
@@ -8,7 +8,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { Paragraph } from "../primitives/Paragraph";
import { Paragraph } from "../../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
@@ -1,5 +1,5 @@
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Paragraph } from "../primitives/Paragraph";
import { Paragraph } from "../../primitives/Paragraph";
const tooltipStyle = {
display: "flex",
@@ -1,6 +1,6 @@
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { Paragraph } from "../primitives/Paragraph";
import { Paragraph } from "../../primitives/Paragraph";
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
@@ -28,7 +28,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
</div>
<Link to={to} className="text-2sm text-text-link">
Learn more
Upgrade
</Link>
</div>
<div className="relative mt-3 h-1 rounded-full bg-background-dimmed">
@@ -1,9 +1,9 @@
import * as Slider from "@radix-ui/react-slider";
import { Plans, estimate } from "@trigger.dev/billing";
import { Plans, estimate } from "@trigger.dev/platform/v2";
import { useCallback, useState } from "react";
import { DefinitionTip } from "../DefinitionTooltip";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
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";
@@ -2,17 +2,17 @@ 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 { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/platform/v2";
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 { 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";
import { Spinner } from "../../primitives/Spinner";
const pricingDefinitions = {
concurrentRuns: {
@@ -1,6 +1,6 @@
import { RunPriceBracket } from "@trigger.dev/billing";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { RunPriceBracket } from "@trigger.dev/platform/v2";
import { Header2 } from "../../primitives/Headers";
import { Paragraph } from "../../primitives/Paragraph";
import { formatNumberCompact } from "~/utils/numberFormatter";
export function RunsVolumeDiscountTable({
@@ -1,7 +1,7 @@
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { Paragraph } from "../primitives/Paragraph";
import { SimpleTooltip } from "../primitives/Tooltip";
import { Paragraph } from "../../primitives/Paragraph";
import { SimpleTooltip } from "../../primitives/Tooltip";
import { motion } from "framer-motion";
type UsageBarProps = {
@@ -0,0 +1,53 @@
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { v3BillingPath } from "~/utils/pathBuilder";
import { LinkButton } from "../../primitives/Buttons";
import { Icon } from "../../primitives/Icon";
import { Paragraph } from "../../primitives/Paragraph";
import { DateTime } from "~/components/primitives/DateTime";
export function UpgradePrompt() {
const organization = useOrganization();
const plan = useCurrentPlan();
if (!plan || !plan.v3Usage.hasExceededFreeTier) {
return null;
}
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
nextMonth.setHours(0, 0, 0, 0);
return (
<div
className="flex h-10 items-center justify-between border border-error bg-repeat py-0 pl-3 pr-2"
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
>
<div className="flex items-center gap-2">
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
<Paragraph variant="small" className="text-error">
You have exceeded the monthly $
{(plan.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100} free credits. No runs
will execute in Prod until <DateTime date={nextMonth} includeTime={false} />, or you
upgrade.
</Paragraph>
</div>
<LinkButton
variant={"primary/small"}
leadingIconClassName="px-0"
to={v3BillingPath(organization)}
>
Upgrade
</LinkButton>
</div>
);
}
export function useShowUpgradePrompt(organization?: MatchedOrganization) {
const currentPlan = useCurrentPlan();
const shouldShow = currentPlan?.v3Usage.hasExceededFreeTier === true;
return { shouldShow };
}
@@ -0,0 +1,170 @@
import { cn } from "~/utils/cn";
import { formatCurrency } from "~/utils/numberFormatter";
import { Paragraph } from "../../primitives/Paragraph";
import { SimpleTooltip } from "../../primitives/Tooltip";
import { motion } from "framer-motion";
type UsageBarProps = {
current: number;
billingLimit?: number;
tierLimit?: number;
projectedUsage?: number;
isPaying: boolean;
};
const startFactor = 4;
export function UsageBar({
current,
billingLimit,
tierLimit,
projectedUsage,
isPaying,
}: UsageBarProps) {
const getLargestNumber = Math.max(
current,
tierLimit ?? -Infinity,
projectedUsage ?? -Infinity,
billingLimit ?? -Infinity,
5
);
//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 = tierLimit ? Math.round((tierLimit / maxRange) * 100) : 0;
const projectedRunsPercentage = projectedUsage
? Math.round((projectedUsage / maxRange) * 100)
: 0;
const billingLimitPercentage =
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
const usagePercentage = Math.round((current / maxRange) * 100);
//cap the usagePercentage to the freeRunLimitPercentage
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
return (
<div className="h-fit w-full py-12">
<div className="relative h-3 w-full rounded-sm bg-background-bright">
{billingLimit && (
<motion.div
initial={{ width: billingLimitPercentage / startFactor + "%" }}
animate={{ width: billingLimitPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${billingLimitPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Billing limit:"
value={formatCurrency(billingLimit, false)}
position="bottomRow2"
percentage={billingLimitPercentage}
tooltipContent={`Billing limit: ${formatCurrency(billingLimit, false)}`}
/>
</motion.div>
)}
{tierLimit && (
<motion.div
initial={{ width: tierRunLimitPercentage / startFactor + "%" }}
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={isPaying ? `Included usage:` : `Tier limit:`}
value={formatCurrency(tierLimit, false)}
position="bottomRow1"
percentage={tierRunLimitPercentage}
tooltipContent={`${isPaying ? "Included usage" : "Tier limit"}: ${formatCurrency(
tierLimit,
false
)}`}
/>
</motion.div>
)}
{projectedUsage && projectedUsage !== 0 && (
<motion.div
initial={{ width: projectedRunsPercentage / startFactor + "%" }}
animate={{ width: projectedRunsPercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${projectedRunsPercentage}%` }}
className="absolute h-3 rounded-l-sm"
>
<Legend
text="Projected:"
value={formatCurrency(projectedUsage, false)}
position="topRow2"
percentage={projectedRunsPercentage}
tooltipContent={`Projected runs: ${formatCurrency(projectedUsage, false)}`}
/>
</motion.div>
)}
<motion.div
initial={{ width: usagePercentage / startFactor + "%" }}
animate={{ width: usagePercentage + "%" }}
transition={{ duration: 1.5, type: "spring" }}
style={{ width: `${usagePercentage}%` }}
className={cn(
"absolute h-3 rounded-l-sm",
tierLimit && current > tierLimit ? "bg-rose-600" : "bg-green-600"
)}
>
<Legend
text="Used:"
value={formatCurrency(current, false)}
position="topRow1"
percentage={usagePercentage}
tooltipContent={`Used: ${formatCurrency(current, false)}`}
/>
</motion.div>
<motion.div
initial={{ width: usageCappedToLimitPercentage / startFactor + "%" }}
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-charcoal-500",
positions[position],
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
)}
>
<SimpleTooltip
button={
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
{text}
<span className="ml-1 text-text-dimmed">{value}</span>
</Paragraph>
}
side="top"
content={tooltipContent}
className="z-50 h-fit"
/>
</div>
);
}
@@ -59,7 +59,7 @@ const extraLinesWhenClipping = 0.35;
const defaultTheme: PrismTheme = {
plain: {
color: "#9C9AF2",
backgroundColor: "#121317",
backgroundColor: "rgba(0, 0, 0, 0)",
},
styles: [
{
@@ -1,6 +1,6 @@
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { useShowUpgradePrompt } from "../billing/UpgradePrompt";
import { useShowUpgradePrompt } from "../billing/v3/UpgradePrompt";
/** This container is used to surround the entire app, it correctly places the nav bar */
export function AppContainer({ children }: { children: React.ReactNode }) {
@@ -60,7 +60,9 @@ export function MainCenteredContainer({
}) {
return (
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className={cn("mx-auto mt-[25vh] max-w-xs overflow-y-auto", className)}>{children}</div>
<div className={cn("mx-auto mt-6 max-w-xs overflow-y-auto p-1 md:mt-[22vh]", className)}>
{children}
</div>
</div>
);
}
@@ -1,16 +1,17 @@
import {
AcademicCapIcon,
ArrowRightIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
BellAlertIcon,
ChartBarIcon,
ClockIcon,
CreditCardIcon,
CursorArrowRaysIcon,
IdentificationIcon,
KeyIcon,
ServerStackIcon,
ShieldCheckIcon,
SignalIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
@@ -44,6 +45,7 @@ import {
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3BillingPath,
v3DeploymentsPath,
v3EnvironmentVariablesPath,
v3ProjectAlertsPath,
@@ -52,13 +54,14 @@ import {
v3RunsPath,
v3SchedulesPath,
v3TestPath,
v3UsagePath,
} from "~/utils/pathBuilder";
import { Feedback } from "../Feedback";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { StepContentContainer } from "../StepContentContainer";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { FreePlanUsage } from "../billing/v2/FreePlanUsage";
import { Badge } from "../primitives/Badge";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
@@ -75,6 +78,7 @@ import {
PopoverSectionHeader,
} from "../primitives/Popover";
import { StepNumber } from "../primitives/StepNumber";
import { TextLink } from "../primitives/TextLink";
import { SideMenuHeader } from "./SideMenuHeader";
import { MenuCount, SideMenuItem } from "./SideMenuItem";
@@ -100,8 +104,8 @@ type SideMenuProps = {
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
const { isManagedCloud } = useFeatures();
useEffect(() => {
const handleScroll = () => {
@@ -173,16 +177,36 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
name="Team"
icon={UserGroupIcon}
to={organizationTeamPath(organization)}
iconColor="text-sky-500"
iconColor="text-amber-500"
data-action="team"
/>
<SideMenuItem
name={isManagedCloud ? "Usage & Billing" : "Usage"}
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-green-600"
data-action="usage & billing"
/>
{organization.projects.some((proj) => proj.version === "V3") && isManagedCloud && (
<>
<SideMenuItem
name="Usage"
icon={ChartBarIcon}
to={v3UsagePath(organization)}
iconColor="text-green-600"
data-action="usage"
/>
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
to={v3BillingPath(organization)}
iconColor="text-blue-600"
data-action="billing"
/>
</>
)}
{organization.projects.some((proj) => proj.version === "V2") && (
<SideMenuItem
name="Usage (v2)"
icon={ChartBarIcon}
to={organizationBillingPath(organization)}
iconColor="text-green-600"
data-action="usage & billing"
/>
)}
<SideMenuItem
name="Organization settings"
icon="settings"
@@ -193,22 +217,19 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
</div>
</div>
<div className="m-2">
{project.version === "V2" ? (
<Callout variant={"info"}>This is a v2 project</Callout>
) : (
<Callout variant={"idea"}>This is a v3 project in Developer Preview</Callout>
{project.version === "V2" && (
<Callout variant={"info"}>
<Paragraph variant="small">
This is a v2 project.{" "}
<TextLink href="https://trigger.dev/docs/v3/upgrading-from-v2">
Upgrade to v3
</TextLink>
</Paragraph>
</Callout>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
{project.version === "V2" && (
<SideMenuItem
to="https://trigger.dev/v3-early-access"
target="_blank"
name="Request access to v3"
icon={V3Icon}
/>
)}
{currentPlan?.subscription?.isPaying === true && (
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
<Dialog>
<DialogTrigger asChild>
<Button
@@ -263,23 +284,13 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
data-action="join our discord"
target="_blank"
/>
{project.version === "V2" ? (
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
) : (
<SideMenuItem
name="Documentation (v3)"
icon="docs"
to="https://trigger.dev/docs/v3"
data-action="documentation"
target="_blank"
/>
)}
<SideMenuItem
name="Documentation"
icon="docs"
to="https://trigger.dev/docs"
data-action="documentation"
target="_blank"
/>
<SideMenuItem
name="Changelog"
icon="star"
@@ -287,41 +298,31 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
data-action="changelog"
target="_blank"
/>
{project.version === "V2" ? (
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
data-action="help & feedback"
fullWidth
textAlignLeft
>
Help & Feedback
</Button>
}
/>
) : (
<Feedback
defaultValue="developer preview"
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
leadingIconClassName="text-primary"
data-action="help & feedback"
fullWidth
textAlignLeft
>
<span className="text-primary">Give feedback on v3</span>
</Button>
}
/>
)}
{currentPlan && !currentPlan.subscription?.isPaying && currentPlan.usage.runCountCap && (
<SideMenuItem
name="Status"
icon={SignalIcon}
to="https://status.trigger.dev/"
data-action="status"
target="_blank"
/>
<Feedback
button={
<Button
variant="small-menu-item"
LeadingIcon="log"
leadingIconClassName="text-primary"
data-action="help & feedback"
fullWidth
textAlignLeft
>
<span className="text-primary">Help & Feedback</span>
</Button>
}
/>
{currentPlan?.v3Subscription?.isPaying === false && (
<FreePlanUsage
to={organizationBillingPath(organization)}
percentage={currentPlan.usage.currentRunCount / currentPlan.usage.runCountCap}
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
)}
</div>
@@ -474,7 +475,7 @@ function V2ProjectSideMenu({
}) {
return (
<>
<SideMenuHeader title={"Project"}>
<SideMenuHeader title={"Project (v2)"}>
<PopoverMenuItem
to={projectSetupPath(organization, project)}
title="Framework setup"
@@ -546,7 +547,7 @@ function V3ProjectSideMenu({
return (
<>
<SideMenuHeader title={"Project (v3)"} />
<SideMenuHeader title={"Project"} />
<SideMenuItem
name="Tasks"
icon={TaskIcon}
@@ -615,15 +616,3 @@ function V3ProjectSideMenu({
</>
);
}
function V3Icon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="8" fill="#A8FF53" />
<path
d="M7.7488 6.17L5.5818 12H3.6678L1.5008 6.17H3.2058L4.6248 10.339L6.0328 6.17H7.7488ZM11.0527 7.292C11.6357 7.303 12.2297 7.006 12.2297 6.28C12.2297 5.774 11.7787 5.433 11.0527 5.433C10.4147 5.433 9.98567 5.741 9.91967 6.214L8.22567 6.126C8.36867 4.861 9.51267 4.014 11.0857 4.014C12.8457 4.014 13.9567 4.806 13.9567 6.049C13.9567 6.951 13.3847 7.534 12.3067 7.776C13.5387 8.04 14.2207 8.777 14.2207 9.855C14.2207 11.274 13.0107 12.176 11.0857 12.176C9.32567 12.176 8.12667 11.197 8.04967 9.712L9.75467 9.646C9.83167 10.405 10.4917 10.757 11.0967 10.757C11.8007 10.757 12.4937 10.394 12.4937 9.591C12.4937 8.81 11.7897 8.425 11.0527 8.447L10.3817 8.458V7.281L11.0527 7.292Z"
fill="#15171A"
/>
</svg>
);
}
@@ -21,7 +21,7 @@ const sizes = {
shortcut: "-ml-0.5 -mr-1.5 rounded justify-self-center",
},
large: {
button: "h-10 px-2 text-sm font-medium",
button: "h-10 px-2 text-base font-medium",
icon: "h-5",
iconSpacing: "gap-x-0.5",
shortcutVariant: "medium" as const,
@@ -0,0 +1,324 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/utils/cn";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-text-dimmed/50 [&_.recharts-cartesian-grid_line]:stroke-grid-bright/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-grid-dimmed [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-grid-dimmed [&_.recharts-radial-bar-background-sector]:fill-grid-dimmed [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-grid-dimmed [&_.recharts-reference-line-line]:stroke-grid-dimmed [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES).map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-grid-bright/50 bg-background-dimmed px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-text-dimmed",
indicator === "dot" && "items-center"
)}
>
{formatter && item.value && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between gap-1.5 leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-text-dimmed">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-text-bright">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-text-dimmed"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -2,7 +2,7 @@ import { cn } from "~/utils/cn";
const headerVariants = {
header1: {
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-semibold",
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-medium",
spacing: "mb-2",
},
header2: {
@@ -0,0 +1,71 @@
import { LinkButton } from "./Buttons";
import { Header3 } from "./Headers";
import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
const variants = {
info: {
panelStyle: "border-grid-bright bg-background-bright",
},
upgrade: {
panelStyle: "border-indigo-400/20 bg-indigo-800/10",
},
};
type InfoPanelVariant = keyof typeof variants;
type Props = {
title?: string;
children: React.ReactNode;
to?: string;
buttonLabel?: string;
icon: React.ComponentType<any>;
iconClassName?: string;
variant?: InfoPanelVariant;
panelClassName?: string;
};
export function InfoPanel({
title,
children,
to,
buttonLabel,
icon,
iconClassName,
variant = "info",
panelClassName = "max-w-sm",
}: Props) {
const Icon = icon;
const variantStyle = variants[variant];
return (
<div
className={cn(
variantStyle.panelStyle,
title ? "flex-col" : "",
"flex h-fit items-start gap-3 rounded-md border p-4",
panelClassName
)}
>
<div className={cn("flex items-center gap-2", to ? "w-full justify-between" : "")}>
<Icon className={cn("size-5", iconClassName)} />
{to && (
<LinkButton to={to} variant="secondary/small">
{buttonLabel}
</LinkButton>
)}
</div>
<div className="flex flex-col gap-1">
{title && <Header3 className="text-text-bright">{title}</Header3>}
{typeof children === "string" ? (
<Paragraph variant={"small"} className="text-text-dimmed">
{children}
</Paragraph>
) : (
children
)}
</div>
</div>
);
}
@@ -1,10 +1,8 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { Link, useNavigation } from "@remix-run/react";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useOptionalProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { plansPath } from "~/utils/pathBuilder";
import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt";
import { UpgradePrompt, useShowUpgradePrompt } from "../billing/v3/UpgradePrompt";
import { BreadcrumbIcon } from "./BreadcrumbIcon";
import { LinkButton } from "./Buttons";
import { Header2 } from "./Headers";
@@ -23,21 +21,14 @@ export function NavBar({ children }: WithChildren) {
const showUpgradePrompt = useShowUpgradePrompt(organization);
const navigation = useNavigation();
const isLoading = navigation.state === "loading" || navigation.state === "submitting";
const project = useOptionalProject();
return (
<div>
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
<div className="flex w-full items-center justify-between pl-3 pr-1">{children}</div>
<div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
<LoadingBarDivider isLoading={isLoading} />
</div>
{showUpgradePrompt.shouldShow && organization && (
<UpgradePrompt
runsEnabled={showUpgradePrompt.runsEnabled}
runCountCap={showUpgradePrompt.runCountCap}
planPath={plansPath(organization)}
/>
)}
{showUpgradePrompt.shouldShow && organization && <UpgradePrompt />}
</div>
);
}
@@ -70,7 +61,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
}
export function PageAccessories({ children }: WithChildren) {
return <div className="flex items-center gap-3">{children}</div>;
return <div className="flex items-center gap-2">{children}</div>;
}
export function PageInfoRow({ children, className }: WithChildren) {
@@ -3,6 +3,7 @@ import { Link } from "@remix-run/react";
import { ReactNode, forwardRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
import { InfoIconTooltip } from "./Tooltip";
type TableProps = {
containerClassName?: string;
@@ -87,10 +88,11 @@ type TableCellBasicProps = {
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
};
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
({ className, alignment = "left", children, colSpan, hiddenLabel = false }, ref) => {
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -112,7 +114,16 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
)}
colSpan={colSpan}
>
{hiddenLabel ? <span className="sr-only">{children}</span> : children}
{hiddenLabel ? (
<span className="sr-only">{children}</span>
) : tooltip ? (
<div className="flex items-center gap-1">
{children}
<InfoIconTooltip content={tooltip} contentClassName="normal-case tracking-normal" />
</div>
) : (
children
)}
</th>
);
}
@@ -5,7 +5,7 @@ import { InformationCircleIcon } from "@heroicons/react/20/solid";
const variantClasses = {
basic:
"bg-background-dimmed border border-charcoal-700 rounded-md px-3 py-1.5 text-sm text-text-bright shadow-md fade-in-50",
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
};
@@ -58,6 +58,7 @@ function SimpleTooltip({
variant,
disableHoverableContent = false,
className,
buttonClassName,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -66,11 +67,12 @@ function SimpleTooltip({
variant?: Variant;
disableHoverableContent?: boolean;
className?: string;
buttonClassName?: string;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<TooltipTrigger className="h-fit">{button}</TooltipTrigger>
<TooltipTrigger className={cn("h-fit", buttonClassName)}>{button}</TooltipTrigger>
<TooltipContent
side={side}
hidden={hidden}
@@ -87,17 +89,22 @@ function SimpleTooltip({
export function InfoIconTooltip({
content,
buttonClassName,
contentClassName,
variant = "basic",
}: {
content: React.ReactNode;
buttonClassName?: string;
contentClassName?: string;
variant?: Variant;
}) {
return (
<SimpleTooltip
button={
<InformationCircleIcon className={cn("h-3.5 w-3.5 text-text-dimmed", buttonClassName)} />
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
}
content={content}
variant="dark"
variant={variant}
className={contentClassName}
/>
);
}
@@ -1,8 +1,14 @@
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
ClockIcon,
CpuChipIcon,
RectangleStackIcon,
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { StopIcon } from "@heroicons/react/24/outline";
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
import { useLocation } from "@remix-run/react";
import { formatDuration } from "@trigger.dev/core/v3";
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { useEnvironments } from "~/hooks/useEnvironments";
@@ -32,7 +38,11 @@ import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider"
import { Checkbox } from "~/components/primitives/Checkbox";
import { useCallback, useRef } from "react";
import { run } from "@remix-run/dev/dist/cli/run";
import { formatNumber } from "~/utils/numberFormatter";
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { Header3 } from "~/components/primitives/Headers";
type RunsTableProps = {
total: number;
@@ -52,10 +62,14 @@ export function TaskRunsTable({
isLoading = false,
allowSelection = false,
}: RunsTableProps) {
const user = useUser();
const organization = useOrganization();
const project = useProject();
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const showCompute = user.admin && isManagedCloud;
const navigateCheckboxes = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>, index: number) => {
@@ -110,12 +124,53 @@ export function TaskRunsTable({
</TableHeaderCell>
)}
<TableHeaderCell alignment="right">Run #</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Task ID</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Started</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell
colSpan={3}
tooltip={
<div className="flex max-w-xs flex-col gap-4 p-1">
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<RectangleStackIcon className="size-4 text-text-dimmed" />
<Header3>Queued duration</Header3>
</div>
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
The amount of time from when the run was created to it starting to run.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<ClockIcon className="size-4 text-blue-500" /> <Header3>Run duration</Header3>
</div>
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
The total amount of time from the run starting to it finishing. This includes
all time spent waiting.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<CpuChipIcon className="size-4 text-success" />
<Header3>Compute duration</Header3>
</div>
<Paragraph variant="small" className="!text-wrap text-text-dimmed">
The amount of compute time used in the run. This does not include time spent
waiting.
</Paragraph>
</div>
</div>
}
>
Duration
</TableHeaderCell>
{showCompute && (
<>
<TableHeaderCell>Compute</TableHeaderCell>
</>
)}
<TableHeaderCell>Test</TableHeaderCell>
<TableHeaderCell>Created at</TableHeaderCell>
<TableHeaderCell>Delayed until</TableHeaderCell>
@@ -127,7 +182,7 @@ export function TaskRunsTable({
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={10}>
<TableBlankRow colSpan={14}>
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
@@ -154,37 +209,63 @@ export function TaskRunsTable({
<TableCell to={path} alignment="right">
{formatNumber(run.number)}
</TableCell>
<TableCell to={path}>{run.taskIdentifier}</TableCell>
<TableCell to={path}>{run.version ?? ""}</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={run.environment}
userName={run.environment.userName}
/>
</TableCell>
<TableCell to={path}>{run.taskIdentifier}</TableCell>
<TableCell to={path}>{run.version ?? ""}</TableCell>
<TableCell to={path}>
<TaskRunStatusCombo status={run.status} />
</TableCell>
<TableCell to={path}>
{run.startedAt ? <DateTime date={run.startedAt} /> : ""}
</TableCell>
<TableCell to={path}>
{run.startedAt && run.finishedAt ? (
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
style: "short",
})
) : run.startedAt ? (
<LiveTimer startTime={new Date(run.startedAt)} />
) : (
""
)}
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
<div className="flex items-center gap-1">
<RectangleStackIcon className="size-4 text-text-dimmed" />
{run.startedAt ? (
formatDuration(new Date(run.createdAt), new Date(run.startedAt), {
style: "short",
})
) : (
<LiveTimer startTime={new Date(run.createdAt)} />
)}
</div>
</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="px-4 tabular-nums">
<div className="flex items-center gap-1">
<ClockIcon className="size-4 text-blue-500" />
{run.startedAt && run.finishedAt ? (
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
style: "short",
})
) : run.startedAt ? (
<LiveTimer startTime={new Date(run.startedAt)} />
) : (
""
)}
</div>
</TableCell>
<TableCell to={path} actionClassName="pl-0 tabular-nums">
<div className="flex items-center gap-1">
<CpuChipIcon className="size-4 text-success" />
{run.usageDurationMs > 0
? formatDurationMilliseconds(run.usageDurationMs, {
style: "short",
})
: ""}
</div>
</TableCell>
{showCompute && (
<TableCell to={path} className="tabular-nums">
{run.costInCents > 0 ? formatCurrencyAccurate(run.costInCents / 100) : ""}
</TableCell>
)}
<TableCell to={path}>
{run.isTest ? (
<CheckIcon className="h-4 w-4 text-charcoal-400" />
) : (
<StopIcon className="h-4 w-4 text-charcoal-850" />
)}
{run.isTest ? <CheckIcon className="h-4 w-4 text-charcoal-400" /> : ""}
</TableCell>
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
@@ -200,7 +281,7 @@ export function TaskRunsTable({
)}
{isLoading && (
<TableBlankRow
colSpan={8}
colSpan={14}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
@@ -260,7 +341,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
const organization = useOrganization();
const project = useProject();
const envs = useEnvironments();
if (isLoading) return <TableBlankRow colSpan={9}></TableBlankRow>;
if (isLoading) return <TableBlankRow colSpan={14}></TableBlankRow>;
const { environments, tasks, from, to, ...otherFilters } = filters;
@@ -273,7 +354,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
) {
const environment = envs?.find((env) => env.id === filters.environments[0]);
return (
<TableBlankRow colSpan={10}>
<TableBlankRow colSpan={14}>
<div className="py-14">
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
@@ -314,7 +395,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
}
return (
<TableBlankRow colSpan={10}>
<TableBlankRow colSpan={14}>
<div className="flex flex-col items-center justify-center gap-2">
<Paragraph className="w-auto" variant="small">
No runs currently match your filters. Try refreshing or modifying your filters.
+3 -8
View File
@@ -194,14 +194,9 @@ const EnvironmentSchema = z.object({
USAGE_EVENT_URL: z.string().optional(),
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
CENTS_PER_HOUR_MICRO: z.coerce.number().default(0),
CENTS_PER_HOUR_SMALL_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_SMALL_2X: z.coerce.number().default(0),
CENTS_PER_HOUR_MEDIUM_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_MEDIUM_2X: z.coerce.number().default(0),
CENTS_PER_HOUR_LARGE_1X: z.coerce.number().default(0),
CENTS_PER_HOUR_LARGE_2X: z.coerce.number().default(0),
BASE_RUN_COST_IN_CENTS: z.coerce.number().default(0),
CENTS_PER_VCPU_SECOND: z.coerce.number().default(0),
CENTS_PER_GB_RAM_SECOND: z.coerce.number().default(0),
CENTS_PER_RUN: z.coerce.number().default(0),
USAGE_OPEN_METER_API_KEY: z.string().optional(),
USAGE_OPEN_METER_BASE_URL: z.string().optional(),
+8 -2
View File
@@ -1,9 +1,15 @@
import { prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
export async function getTeamMembersAndInvites({ userId, slug }: { userId: string; slug: string }) {
export async function getTeamMembersAndInvites({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
where: { id: organizationId, members: { some: { userId } } },
select: {
members: {
select: {
@@ -77,7 +77,7 @@ export async function createOrganization(
}
export async function createEnvironment(
organization: Pick<Organization, "id">,
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
project: Pick<Project, "id">,
type: RuntimeEnvironment["type"],
member?: OrgMember,
@@ -95,7 +95,7 @@ export async function createEnvironment(
pkApiKey,
shortcode,
autoEnableInternalSources: type !== "DEVELOPMENT",
maximumConcurrencyLimit: env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT,
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
organization: {
connect: {
id: organization.id,
+11 -10
View File
@@ -4,17 +4,20 @@ import { prisma } from "~/db.server";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
import { projectCreated } from "~/services/platform.v3.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
type Options = {
organizationSlug: string;
name: string;
userId: string;
version: "v2" | "v3";
};
export async function createProject(
{
organizationSlug,
name,
userId,
version,
}: { organizationSlug: string; name: string; userId: string; version: "v2" | "v3" },
{ organizationSlug, name, userId, version }: Options,
attemptCount = 0
): Promise<Project & { organization: Organization }> {
//check the user has permissions to do this
@@ -87,14 +90,12 @@ export async function createProject(
// Create the dev and prod environments
await createEnvironment(organization, project, "PRODUCTION");
if (version === "v2") {
await createEnvironment(organization, project, "STAGING");
}
for (const member of project.organization.members) {
await createEnvironment(organization, project, "DEVELOPMENT", member);
}
await projectCreated(organization, project);
return project;
}
@@ -1,4 +1,4 @@
import { BillingService } from "../services/billing.server";
import { BillingService } from "../services/billing.v2.server";
import { BasePresenter } from "./v3/basePresenter.server";
export class OrgBillingPlanPresenter extends BasePresenter {
@@ -1,7 +1,7 @@
import { estimate } from "@trigger.dev/billing";
import { estimate } from "@trigger.dev/platform/v2";
import { sqlDatabaseSchema } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "~/services/billing.server";
import { BillingService } from "~/services/billing.v2.server";
import { BasePresenter } from "./v3/basePresenter.server";
export class OrgUsagePresenter extends BasePresenter {
@@ -0,0 +1,26 @@
import { getTeamMembersAndInvites } from "~/models/member.server";
import { BasePresenter } from "./v3/basePresenter.server";
import { getLimit } from "~/services/platform.v3.server";
export class TeamPresenter extends BasePresenter {
public async call({ userId, organizationId }: { userId: string; organizationId: string }) {
const result = await getTeamMembersAndInvites({
userId,
organizationId,
});
if (!result) {
return;
}
const limit = await getLimit(organizationId, "teamMembers", 25);
return {
...result,
limits: {
used: result.members.length + result.invites.length,
limit,
},
};
}
}
@@ -8,6 +8,7 @@ import {
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { getLimit } from "~/services/platform.v3.server";
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
export type AlertChannelListPresenterRecord =
@@ -29,6 +30,21 @@ export class AlertChannelListPresenter extends BasePresenter {
},
});
const organization = await this._replica.project.findFirst({
where: {
id: projectId,
},
select: {
organizationId: true,
},
});
if (!organization) {
throw new Error(`Project not found: ${projectId}`);
}
const limit = await getLimit(organization.organizationId, "alerts", 25);
return {
alertChannels: await Promise.all(
alertChannels.map(async (alertChannel) => ({
@@ -36,6 +52,10 @@ export class AlertChannelListPresenter extends BasePresenter {
properties: await this.#presentProperties(alertChannel),
}))
),
limits: {
used: alertChannels.length,
limit,
},
};
}
@@ -123,6 +123,7 @@ export class EnvironmentVariablesPresenter {
id: environment.id,
type: environment.type,
})),
hasStaging: environments.some((environment) => environment.type === "STAGING"),
};
}
}
@@ -165,6 +165,8 @@ export class RunListPresenter extends BasePresenter {
idempotencyKey: string | null;
ttl: string | null;
expiredAt: Date | null;
costInCents: number;
usageDurationMs: BigInt;
}[]
>`
SELECT
@@ -184,7 +186,9 @@ export class RunListPresenter extends BasePresenter {
tr."spanId" AS "spanId",
tr."idempotencyKey" AS "idempotencyKey",
tr."ttl" AS "ttl",
tr."expiredAt" AS "expiredAt"
tr."expiredAt" AS "expiredAt",
tr."costInCents" AS "costInCents",
tr."usageDurationMs" AS "usageDurationMs"
FROM
${sqlDatabaseSchema}."TaskRun" tr
LEFT JOIN
@@ -303,6 +307,8 @@ export class RunListPresenter extends BasePresenter {
idempotencyKey: run.idempotencyKey ? run.idempotencyKey : undefined,
ttl: run.ttl ? run.ttl : undefined,
expiredAt: run.expiredAt ? run.expiredAt.toISOString() : undefined,
costInCents: run.costInCents,
usageDurationMs: Number(run.usageDurationMs),
};
}),
pagination: {
@@ -1,8 +1,8 @@
import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database";
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getUsername } from "~/utils/username";
import { getCurrentPlan, getLimit, getLimits } from "~/services/platform.v3.server";
import { calculateNextScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
import { BasePresenter } from "./basePresenter.server";
@@ -53,6 +53,7 @@ export class ScheduleListPresenter extends BasePresenter {
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
@@ -71,11 +72,6 @@ export class ScheduleListPresenter extends BasePresenter {
},
},
},
organization: {
select: {
maximumSchedulesLimit: true,
},
},
},
where: {
id: projectId,
@@ -248,6 +244,8 @@ export class ScheduleListPresenter extends BasePresenter {
};
});
const limit = await getLimit(project.organizationId, "schedules", 500);
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
@@ -260,7 +258,7 @@ export class ScheduleListPresenter extends BasePresenter {
hasFilters,
limits: {
used: schedulesCount,
limit: project.organization.maximumSchedulesLimit,
limit,
},
filters: {
tasks,
@@ -0,0 +1,115 @@
import { sqlDatabaseSchema } from "~/db.server";
import { env } from "~/env.server";
import { getUsage, getUsageSeries } from "~/services/platform.v3.server";
import { createTimeSeriesData } from "~/utils/graphs";
import { BasePresenter } from "./basePresenter.server";
import { start } from "@popperjs/core";
type Options = {
organizationId: string;
startDate: Date;
};
export type TaskUsageItem = {
taskIdentifier: string;
runCount: number;
averageDuration: number;
averageCost: number;
totalDuration: number;
totalCost: number;
totalBaseCost: number;
};
export type UsageSeriesData = {
date: string;
dollars: number;
}[];
export class UsagePresenter extends BasePresenter {
public async call({ organizationId, startDate }: Options) {
//month period
const startOfMonth = new Date(startDate);
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const endOfMonth = new Date(
startOfMonth.getFullYear(),
startOfMonth.getMonth() + 1,
0,
23,
59,
59,
999
);
//usage data from the platform
const past30Days = getUsageSeries(organizationId, {
from: startOfMonth,
to: endOfMonth,
window: "DAY",
}).then((data) => {
return createTimeSeriesData({
startDate: startOfMonth,
endDate: endOfMonth,
window: "DAY",
data: data
? data.data.map((period) => ({
date: new Date(period.windowStart),
value: period.value,
}))
: [],
}).map((period) => ({
date: period.date.toISOString(),
dollars: (period.value ?? 0) / 100,
}));
});
//usage by task
const tasks = this._replica.$queryRaw<TaskUsageItem[]>`
SELECT
tr."taskIdentifier",
COUNT(*) AS "runCount",
AVG(tr."usageDurationMs") AS "averageDuration",
SUM(tr."usageDurationMs") AS "totalDuration",
AVG(tr."costInCents") / 100.0 AS "averageCost",
SUM(tr."costInCents") / 100.0 AS "totalCost",
SUM(tr."baseCostInCents") / 100.0 AS "totalBaseCost"
FROM
${sqlDatabaseSchema}."TaskRun" tr
JOIN ${sqlDatabaseSchema}."Project" pr ON pr.id = tr."projectId"
JOIN ${sqlDatabaseSchema}."Organization" org ON org.id = pr."organizationId"
WHERE
tr."createdAt" > ${startOfMonth}
AND tr."createdAt" < ${endOfMonth}
AND org.id = ${organizationId}
GROUP BY
tr."taskIdentifier";
`.then((data) => {
return data
.map((item) => ({
taskIdentifier: item.taskIdentifier,
runCount: Number(item.runCount),
averageDuration: Number(item.averageDuration),
averageCost: Number(item.averageCost) + env.CENTS_PER_RUN / 100,
totalDuration: Number(item.totalDuration),
totalCost: Number(item.totalCost + item.totalBaseCost),
}))
.sort((a, b) => b.totalCost - a.totalCost);
});
const usage = getUsage(organizationId, { from: startOfMonth, to: endOfMonth }).then((data) => {
const current = (data?.cents ?? 0) / 100;
const percentageThroughMonth = new Date().getDate() / endOfMonth.getDate();
return {
current: current,
projected: current / percentageThroughMonth,
};
});
return {
usageOverTime: past30Days,
usage,
tasks,
};
}
}
@@ -0,0 +1,32 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { v3BillingPath } 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(
v3BillingPath({ slug: org.slug }),
request,
"You didn't complete your details on Stripe. Please try again."
);
};
@@ -0,0 +1,46 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { newProjectPath, v3BillingPath } 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,
_count: {
select: {
projects: true,
},
},
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const hasProject = org._count.projects > 0;
if (hasProject) {
return redirectWithSuccessMessage(
v3BillingPath({ slug: org.slug }),
request,
"Your subscription has been successfully activated."
);
}
return redirect(
newProjectPath({ slug: org.slug }, "Your subscription has been successfully activated.")
);
};
@@ -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 { v3BillingPath } 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(v3BillingPath({ slug: org.slug }), request, errorMessage);
};
@@ -0,0 +1,44 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { newProjectPath, selectPlanPath, v3BillingPath } 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,
_count: {
select: {
projects: true,
},
},
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const hasProject = org._count.projects > 0;
if (hasProject) {
return redirectWithSuccessMessage(
v3BillingPath({ slug: org.slug }),
request,
"Free tier unlocked successfully."
);
}
return redirect(newProjectPath({ slug: org.slug }, "You're on the Free plan."));
};
@@ -4,11 +4,11 @@ import { Await, useLoaderData } from "@remix-run/react";
import { DataFunctionArgs, defer } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/UsageBar";
import { ConcurrentRunsChart } from "~/components/billing/v2/ConcurrentRunsChart";
import { UsageBar } from "~/components/billing/v2/UsageBar";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DailyRunsChart } from "~/components/billing/DailyRunsChat";
import { DailyRunsChart } from "~/components/billing/v2/DailyRunsChat";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -157,10 +157,7 @@ export default function Page() {
)}
<UsageBar
numberOfCurrentRuns={data.runsCount}
tierRunLimit={
currentPlan?.usage.runCountCap ??
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
}
tierRunLimit={currentPlan?.usage?.runCountCap}
projectedRuns={data.projectedRunsCount}
subscribedToPaidTier={
(currentPlan && currentPlan.subscription?.isPaying) ?? false
@@ -1,8 +1,8 @@
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 { PricingCalculator } from "~/components/billing/v2/PricingCalculator";
import { PricingTiers } from "~/components/billing/v2/PricingTiers";
import { RunsVolumeDiscountTable } from "~/components/billing/v2/RunsVolumeDiscountTable";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { featuresForRequest } from "~/features.server";
@@ -1,7 +1,7 @@
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 { ActiveSubscription } from "@trigger.dev/platform/v2";
import { formatDurationInDays } from "@trigger.dev/core/v3";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -29,7 +29,7 @@ function planLabel(subscription: ActiveSubscription | undefined, periodEnd: Date
return `You're currently on the ${subscription.plan.title} plan`;
}
const costDescription = subscription.plan.concurrentRuns.pricing
? `\$${subscription.plan.concurrentRuns.pricing?.tierCost}/mo`
? `\$${subscription.plan.concurrentRuns.pricing.tierCost}/mo`
: "";
if (subscription.canceledAt) {
return (
@@ -55,7 +55,7 @@ export default function Page() {
return (
<PageContainer>
<NavBar>
<PageTitle title={isManagedCloud ? "Usage & Billing" : "Usage"} />
<PageTitle title={isManagedCloud ? "Usage & Billing (v2)" : "Usage (v2)"} />
<PageAccessories>
{isManagedCloud && (
<>
@@ -86,27 +86,22 @@ export default function Page() {
<PageBody scrollable={false}>
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="px-4 pt-4">
{allV3Projects ? (
{hasV3Project ? (
<Callout variant="warning" className="mb-3">
This organization only has v3 projects. These are currently free. Usage data and
billing will be available soon.
</Callout>
) : hasV3Project ? (
<Callout variant="warning" className="mb-3">
This organization has a mix of v2 and v3 projects v3 projects do not count towards
your usage (below) and are currently free.
This organization has a mix of v2 and v3 projects. They have separate subscriptions,
this is the usage and billing for v2.
</Callout>
) : null}
{hasV2Project && (
<PageInfoRow>
<PageInfoGroup>
{currentPlan?.subscription && (
{currentPlan?.subscription && currentPlan.usage && (
<PageInfoProperty
icon={<ReceiptRefundIcon className="h-4 w-4 text-green-600" />}
value={planLabel(currentPlan.subscription, currentPlan.usage.periodEnd)}
/>
)}
{currentPlan?.subscription?.isPaying && (
{currentPlan?.subscription?.isPaying && currentPlan.usage && (
<PageInfoProperty
icon={<CalendarDaysIcon className="h-4 w-4 text-green-600" />}
label={"Billing period"}
@@ -1,9 +1,11 @@
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction } from "@remix-run/node";
import { LockOpenIcon } from "@heroicons/react/20/solid";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Fragment, useRef } from "react";
import { Fragment, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -13,16 +15,50 @@ import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/email.server";
import { requireUserId } from "~/services/session.server";
import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = Params.parse(params);
const organization = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (!organization) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId: organization.id,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
};
const schema = z.object({
emails: z.preprocess((i) => {
@@ -86,6 +122,8 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { limits } = useTypedLoaderData<typeof loader>();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
@@ -109,6 +147,22 @@ export default function Page() {
title="Invite team members"
description={`Invite new team members to ${organization.title}.`}
/>
{total > limits.limit && (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
add more.
</Paragraph>
</InfoPanel>
)}
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
@@ -122,6 +176,8 @@ export default function Page() {
autoFocus={index === 0}
onChange={(e) => {
fieldValues.current[index] = e.target.value;
const filledFields = fieldValues.current.filter((v) => v !== "");
setTotal(limits.used + filledFields.length);
if (
emailFields.length === fieldValues.current.length &&
fieldValues.current.every((v) => v !== "")
@@ -136,7 +192,7 @@ export default function Page() {
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
Send invitations
</Button>
}
@@ -1,13 +1,13 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
ArrowUpRightIcon,
BellAlertIcon,
BellSlashIcon,
BookOpenIcon,
EnvelopeIcon,
GlobeAltIcon,
LockClosedIcon,
LockOpenIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
@@ -23,7 +23,8 @@ import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { Header2 } from "~/components/primitives/Headers";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -56,6 +57,7 @@ import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
v3BillingPath,
v3NewProjectAlertPath,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
@@ -147,10 +149,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
};
export default function Page() {
const { alertChannels } = useTypedLoaderData<typeof loader>();
const { alertChannels, limits } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const requiresUpgrade = limits.used >= limits.limit;
return (
<PageContainer>
<NavBar>
@@ -167,7 +171,7 @@ export default function Page() {
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
{alertChannels.length > 0 && alertChannels.length < 10 && (
{alertChannels.length > 0 && !requiresUpgrade && (
<div className="flex items-end justify-between">
<Header2 className="">Project alerts</Header2>
<LinkButton
@@ -257,21 +261,40 @@ export default function Page() {
)}
</TableBody>
</Table>
<div className="mt-4">
<Header2 className="mb-1">Platform alerts</Header2>
<Paragraph variant="small" className="mb-4">
Subscribe to get email notifications when Trigger.dev creates, updates or resolves a
platform incident.
</Paragraph>
<LinkButton
variant="tertiary/medium"
TrailingIcon={ArrowUpRightIcon}
to="https://status.trigger.dev/"
target="_blank"
className="inline-flex"
>
Subscribe
</LinkButton>
<div className="flex items-stretch gap-3">
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more alerts"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available alerts. Upgrade your plan to
enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
<div className="flex items-center justify-between gap-6">
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
<div
className="h-full bg-grid-bright"
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
/>
</div>
</div>
)}
</div>
</div>
<Outlet />
@@ -1,16 +1,14 @@
import { BookOpenIcon, LightBulbIcon, ShieldCheckIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DateTime } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
@@ -24,14 +22,10 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { useFeatures } from "~/hooks/useFeatures";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { createEnvironment } from "~/models/organization.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, docsPath, v3ApiKeysPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, docsPath, v3BillingPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -57,76 +51,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
organizationId: true,
environments: {
select: {
type: true,
},
},
},
});
if (!project) {
return redirectWithErrorMessage(
v3ApiKeysPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"Project not found"
);
}
if (project.environments.some((env) => env.type === "STAGING")) {
return redirectWithErrorMessage(
v3ApiKeysPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"You already have a staging environment"
);
}
const environment = await createEnvironment(
{ id: project.organizationId },
{ id: project.id },
"STAGING"
);
if (!environment) {
return redirectWithErrorMessage(
v3ApiKeysPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"Failed to create staging environment"
);
}
return redirectWithSuccessMessage(
v3ApiKeysPath({ slug: organizationSlug }, { slug: projectParam }),
request,
"Staging environment created"
);
};
export default function Page() {
const { environments, hasStaging } = useTypedLoaderData<typeof loader>();
const { isManagedCloud } = useFeatures();
const organization = useOrganization();
return (
<PageContainer>
@@ -155,79 +82,68 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("h-full")}>
<Header3 spacing>Secret keys</Header3>
<Paragraph variant="small" spacing>
Secret keys should be used on your server they give full API access and allow you to{" "}
<TextLink to={docsPath("v3/triggering")}>trigger tasks</TextLink> from your backend.
</Paragraph>
<Header3 spacing>Public API keys</Header3>
<Paragraph variant="small" spacing>
These keys have limited read-only access and should be used in your frontend.
</Paragraph>
<div className="mt-4 flex flex-col gap-6">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Secret key</TableHeaderCell>
<TableHeaderCell>Public API key</TableHeaderCell>
<TableHeaderCell>Keys generated</TableHeaderCell>
<TableHeaderCell>Latest version</TableHeaderCell>
<TableHeaderCell>Env vars</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
<div className="mt-1 flex flex-col gap-4">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Secret key</TableHeaderCell>
<TableHeaderCell>Key generated</TableHeaderCell>
<TableHeaderCell>Latest version</TableHeaderCell>
<TableHeaderCell>Env vars</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel environment={environment} />
</TableCell>
<TableCell>
<ClipboardField
className="w-full max-w-none"
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
value={environment.apiKey}
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>
<DateTime date={environment.updatedAt} />
</TableCell>
<TableCell>{environment.latestVersion ?? ""}</TableCell>
<TableCell>{environment.environmentVariableCount}</TableCell>
<TableCellMenu isSticky>
<RegenerateApiKeyModal
id={environment.id}
title={environmentTitle(environment)}
/>
</TableCellMenu>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel environment={environment} />
</TableCell>
<TableCell>
<ClipboardField
className="w-full max-w-none"
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
value={environment.apiKey}
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>
<ClipboardField
className="w-full max-w-none"
value={environment.pkApiKey}
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>
<DateTime date={environment.updatedAt} />
</TableCell>
<TableCell>{environment.latestVersion ?? ""}</TableCell>
<TableCell>{environment.environmentVariableCount}</TableCell>
<TableCellMenu isSticky>
<RegenerateApiKeyModal
id={environment.id}
title={environmentTitle(environment)}
/>
</TableCellMenu>
</TableRow>
))}
</TableBody>
</Table>
))}
</TableBody>
</Table>
<div className="flex gap-3">
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-sm">
<Paragraph variant="small">
Secret keys should be used on your server. They give full API access and allow you
to <TextLink to={docsPath("v3/triggering")}>trigger tasks</TextLink> from your
backend.
</Paragraph>
</InfoPanel>
{!hasStaging && (
<Callout
variant="info"
cta={
<Form method="post">
<Button variant="tertiary/small">Enable Staging</Button>
</Form>
}
<InfoPanel
icon={LockOpenIcon}
variant="upgrade"
title="Unlock a Staging environment"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
iconClassName="text-indigo-500"
>
{isManagedCloud
? "The Staging environment will be a paid feature when we add billing. In the interim you can enable it for free."
: "You can add a Staging environment to your project."}
</Callout>
Upgrade your plan to add a Staging environment.
</InfoPanel>
)}
</div>
</div>
@@ -1,4 +1,10 @@
import { ArrowPathIcon, CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
import {
ArrowPathIcon,
BookOpenIcon,
CommandLineIcon,
ServerIcon,
ServerStackIcon,
} from "@heroicons/react/20/solid";
import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -11,6 +17,7 @@ import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -209,9 +216,14 @@ function CreateDeploymentInstructions() {
const project = useProject();
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Deploy for the first time">
<Paragraph spacing>
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-blue-400"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI, Continuous Integration
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
you{" "}
@@ -223,22 +235,22 @@ function CreateDeploymentInstructions() {
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="tertiary/medium"
LeadingIcon={CommandLineIcon}
variant="tertiary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="tertiary/medium"
LeadingIcon={ServerIcon}
variant="tertiary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</BlankstateInstructions>
</InfoPanel>
</MainCenteredContainer>
);
}
@@ -7,7 +7,7 @@ import {
useForm,
} from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
@@ -35,10 +35,18 @@ import { useProject } from "~/hooks/useProject";
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
import { ProjectParamSchema, v3BillingPath, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
import dotenv from "dotenv";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -46,13 +54,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environments } = await presenter.call({
const { environments, hasStaging } = await presenter.call({
userId,
projectSlug: projectParam,
});
return typedjson({
environments,
hasStaging,
});
} catch (error) {
throw new Response(undefined, {
@@ -156,7 +165,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { environments } = useTypedLoaderData<typeof loader>();
const { environments, hasStaging } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
@@ -200,7 +209,7 @@ export default function Page() {
<Fieldset className="mt-2">
<InputGroup fullWidth>
<Label>Environments</Label>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
{environments.map((environment) => (
<CheckboxWithLabel
key={environment.id}
@@ -210,7 +219,10 @@ export default function Page() {
type="radio"
label={
<span
className={cn("text-xs uppercase", environmentTextClassName(environment))}
className={cn(
"text-xs uppercase tracking-wide",
environmentTextClassName(environment)
)}
>
{environmentTitle(environment)}
</span>
@@ -218,6 +230,27 @@ export default function Page() {
variant="button"
/>
))}
{!hasStaging && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TextLink
to={v3BillingPath(organization)}
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-3 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
>
<LockClosedIcon className="size-4 text-charcoal-500" />
<Paragraph className="mt-0.5 text-xs uppercase tracking-wide text-staging">
Staging
</Paragraph>
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<LockOpenIcon className="size-4 text-indigo-500" />
Upgrade your plan to add a Staging environment.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<FormError id={environmentIds.errorId}>{environmentIds.error}</FormError>
<Hint>
@@ -225,9 +258,8 @@ export default function Page() {
file when running locally.
</Hint>
</InputGroup>
<Hint>Tip: Paste your .env into this form to populate it:</Hint>
<InputGroup fullWidth>
<FieldLayout>
<FieldLayout showDeleteButton={false}>
<Label>Keys</Label>
<div className="flex justify-between gap-1">
<Label>Values</Label>
@@ -254,7 +286,7 @@ export default function Page() {
<div className="flex flex-row-reverse items-center gap-2">
<Button
type="submit"
variant="primary/small"
variant="primary/medium"
disabled={isLoading}
name="override"
value="false"
@@ -262,7 +294,7 @@ export default function Page() {
{isLoading ? "Saving" : "Save"}
</Button>
<Button
variant="secondary/small"
variant="secondary/medium"
disabled={isLoading}
name="override"
value="true"
@@ -274,11 +306,12 @@ export default function Page() {
cancelButton={
<LinkButton
to={v3EnvironmentVariablesPath(organization, project)}
variant="tertiary/small"
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
className="mt-2"
/>
</Fieldset>
</Form>
@@ -287,8 +320,23 @@ export default function Page() {
);
}
function FieldLayout({ children }: { children: React.ReactNode }) {
return <div className="grid w-full grid-cols-[1fr_1fr_2rem] gap-2">{children}</div>;
function FieldLayout({
children,
showDeleteButton,
}: {
children: React.ReactNode;
showDeleteButton: boolean;
}) {
return (
<div
className={cn(
"grid w-full gap-1.5",
showDeleteButton ? "grid-cols-[1fr_1fr_2rem]" : "grid-cols-[1fr_1fr]"
)}
>
{children}
</div>
);
}
function VariableFields({
@@ -363,17 +411,23 @@ function VariableFields({
/>
);
})}
<Button
variant="tertiary/medium"
type="button"
onClick={() => {
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
append([{ key: "", value: "" }]);
}}
LeadingIcon={PlusIcon}
>
Add another
</Button>
<div className={cn("flex items-center justify-between gap-4", items.length > 1 && "pr-10")}>
<Paragraph variant="extra-small">
Tip: Paste your .env into this form to populate it.
</Paragraph>
<Button
variant="tertiary/medium"
className="w-fit"
type="button"
onClick={() => {
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
append([{ key: "", value: "" }]);
}}
LeadingIcon={PlusIcon}
>
Add another
</Button>
</div>
</>
);
}
@@ -405,7 +459,7 @@ function VariableField({
return (
<fieldset ref={ref}>
<FieldLayout>
<FieldLayout showDeleteButton={showDeleteButton}>
<Input
id={`${formId}-${baseFieldName}.key`}
name={`${baseFieldName}.key`}
@@ -1,6 +1,13 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BookOpenIcon, PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import {
BookOpenIcon,
InformationCircleIcon,
LockOpenIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
import {
ActionFunctionArgs,
@@ -16,16 +23,16 @@ import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { PageAccessories, NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Switch } from "~/components/primitives/Switch";
import {
@@ -50,6 +57,7 @@ import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
v3BillingPath,
v3EnvironmentVariablesPath,
v3NewEnvironmentVariablesPath,
} from "~/utils/pathBuilder";
@@ -65,7 +73,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments } = await presenter.call({
const { environmentVariables, environments, hasStaging } = await presenter.call({
userId,
projectSlug: projectParam,
});
@@ -73,6 +81,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
environmentVariables,
environments,
hasStaging,
});
} catch (error) {
console.error(error);
@@ -163,7 +172,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
const { environmentVariables, environments, hasStaging } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
@@ -184,12 +193,14 @@ export default function Page() {
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
<Switch
variant="small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
{environmentVariables.length > 0 && (
<Switch
variant="small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
)}
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
variant="primary/small"
@@ -254,10 +265,24 @@ export default function Page() {
</TableBody>
</Table>
<Callout variant="info" className="mb-4">
Dev environment variables specified here will be overridden by ones in your .env file
when running locally.
</Callout>
<div className="flex gap-3">
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem]">
Dev environment variables specified here will be overridden by ones in your .env file
when running locally.
</InfoPanel>
{!hasStaging && (
<InfoPanel
icon={LockOpenIcon}
variant="upgrade"
title="Unlock a Staging environment"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
iconClassName="text-indigo-500"
>
Upgrade your plan to add a Staging environment.
</InfoPanel>
)}
</div>
</div>
<Outlet />
</PageBody>
@@ -313,9 +338,11 @@ function EditEnvironmentVariablePanel({
<input type="hidden" name="key" value={variable.key} />
<FormError id={id.errorId}>{id.error}</FormError>
<Fieldset>
<InputGroup fullWidth className="mb-4 mt-2">
<InputGroup fullWidth className="mb-5 mt-2">
<Label>Key</Label>
<InlineCode>{variable.key}</InlineCode>
<InlineCode variant="base" className="pl-1.5">
{variable.key}
</InlineCode>
</InputGroup>
</Fieldset>
<Fieldset>
@@ -335,7 +362,7 @@ function EditEnvironmentVariablePanel({
className="flex items-center justify-end"
htmlFor={`values[${index}].value`}
>
<EnvironmentLabel environment={environment} className="h-5 px-2" />
<EnvironmentLabel environment={environment} size="large" className="px-2" />
</label>
<Input
name={`values[${index}].value`}
@@ -353,12 +380,12 @@ function EditEnvironmentVariablePanel({
<FormButtons
confirmButton={
<Button type="submit" variant="primary/small" disabled={isLoading}>
<Button type="submit" variant="primary/medium" disabled={isLoading}>
{isLoading ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<Button onClick={() => setIsOpen(false)} variant="tertiary/small" type="button">
<Button onClick={() => setIsOpen(false)} variant="tertiary/medium" type="button">
Cancel
</Button>
}
@@ -8,7 +8,6 @@ import { ListChecks, ListX } from "lucide-react";
import { Suspense, useState } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { StepContentContainer } from "~/components/StepContentContainer";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -21,6 +20,7 @@ import {
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
@@ -354,22 +354,21 @@ function CreateFirstTaskInstructions() {
const organization = useOrganization();
const project = useProject();
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Create your first task">
<Paragraph spacing>
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={TaskIcon}
iconClassName="text-blue-500"
panelClassName="max-full"
title="Create your first task"
to={v3ProjectPath(organization, project)}
buttonLabel="Create a task"
>
<Paragraph variant="small">
Before running a task, you must first create one. Follow the instructions on the{" "}
<TextLink to={v3ProjectPath(organization, project)}>Tasks</TextLink> page to create a
task, then return here to run it.
</Paragraph>
<LinkButton
to={v3ProjectPath(organization, project)}
variant="primary/medium"
LeadingIcon={TaskIcon}
className="inline-flex"
>
Create your first task
</LinkButton>
</BlankstateInstructions>
</InfoPanel>
</MainCenteredContainer>
);
}
@@ -1,9 +1,8 @@
import { PlusIcon, PlusSmallIcon } from "@heroicons/react/20/solid";
import { ClockIcon, LockOpenIcon, PlusIcon, RectangleGroupIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { Feedback } from "~/components/Feedback";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
@@ -19,6 +18,8 @@ import {
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -52,9 +53,11 @@ import { requireUserId } from "~/services/session.server";
import {
ProjectParamSchema,
docsPath,
v3BillingPath,
v3NewSchedulePath,
v3SchedulePath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -96,6 +99,14 @@ export default function Page() {
const project = useProject();
const pathName = usePathName();
const plan = useCurrentPlan();
const requiresUpgrade =
plan?.v3Subscription?.plan &&
limits.used >= plan.v3Subscription.plan.limits.schedules.number &&
!plan.v3Subscription.plan.limits.schedules.canExceed;
const canUpgrade =
plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed;
const { scheduleParam } = useParams();
const isShowingNewPane = pathName.endsWith("/new");
const isShowingSchedule = !!scheduleParam;
@@ -132,14 +143,19 @@ export default function Page() {
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<DialogDescription>
You've used {limits.used}/{limits.limit} of your schedules. You can request more
schedules.
You've used {limits.used}/{limits.limit} of your schedules.
</DialogDescription>
<DialogFooter>
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
{canUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -180,21 +196,47 @@ export default function Page() {
</div>
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
<div className="mt-2 justify-between">
<Paragraph variant="extra-small" className="mt-3">
<span className={limits.used >= limits.limit ? "text-warning" : ""}>
You've used {limits.used}/{limits.limit} of your schedules.
</span>{" "}
<Feedback
button={
<button className=" text-secondary transition hover:text-indigo-400">
Request more
</button>
}
defaultValue="help"
/>
.
</Paragraph>
<div className="mt-3 flex w-full items-start justify-between">
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more schedules"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available schedules. Upgrade your
plan to enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
<div className="flex items-center justify-between gap-6">
<Header3>
You've used {limits.used}/{limits.limit} of your schedules.
</Header3>
{canUpgrade ? (
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
<div
className="h-full bg-grid-bright"
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
/>
</div>
</div>
)}
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
@@ -216,21 +258,20 @@ export default function Page() {
function CreateScheduledTaskInstructions() {
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Create your first scheduled task">
<Paragraph spacing>
You have no scheduled tasks in your project. Before you can schedule a task you need to a{" "}
<InlineCode>schedules.task</InlineCode>.
<MainCenteredContainer className="max-w-md">
<InfoPanel
title="Create your first scheduled task"
icon={ClockIcon}
iconClassName="text-sun-500"
panelClassName="max-w-full"
to={docsPath("v3/tasks-scheduled")}
buttonLabel="Scheduled task docs"
>
<Paragraph variant="small">
You have no scheduled tasks in your project. Before you can schedule a task you need to
create a <InlineCode>schedules.task</InlineCode>.
</Paragraph>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="primary/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Create scheduled task docs
</LinkButton>
</BlankstateInstructions>
</InfoPanel>
</MainCenteredContainer>
);
}
@@ -241,9 +282,14 @@ function AttachYourFirstScheduleInstructions() {
const location = useLocation();
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Attach your first schedule">
<Paragraph spacing>
<MainCenteredContainer className="max-w-md">
<InfoPanel
title="Attach your first schedule"
icon={ClockIcon}
iconClassName="text-sun-500"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
this in the dashboard or using the SDK.
</Paragraph>
@@ -251,10 +297,10 @@ function AttachYourFirstScheduleInstructions() {
<LinkButton
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
variant="primary/small"
LeadingIcon={PlusSmallIcon}
LeadingIcon={RectangleGroupIcon}
className="inline-flex"
>
Create in the dashboard
Use the dashboard
</LinkButton>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
@@ -265,7 +311,7 @@ function AttachYourFirstScheduleInstructions() {
Use the SDK
</LinkButton>
</div>
</BlankstateInstructions>
</InfoPanel>
</MainCenteredContainer>
);
}
@@ -1,6 +1,6 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { UserPlusIcon } from "@heroicons/react/20/solid";
import { LockOpenIcon, UserPlusIcon } from "@heroicons/react/20/solid";
import { Form, useActionData } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useState } from "react";
@@ -23,41 +23,55 @@ import {
import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { getTeamMembersAndInvites, removeTeamMember } from "~/models/member.server";
import { removeTeamMember } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
inviteTeamMemberPath,
organizationTeamPath,
resendInvitePath,
revokeInvitePath,
v3BillingPath,
} from "~/utils/pathBuilder";
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
const { organizationSlug } = Params.parse(params);
const result = await getTeamMembersAndInvites({
userId,
slug: organizationSlug,
const organization = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (result === null) {
if (!organization) {
throw new Response("Not Found", { status: 404 });
}
return typedjson({
members: result.members,
invites: result.invites,
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId: organization.id,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
};
const schema = z.object({
@@ -101,10 +115,12 @@ type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
export default function Page() {
const { members, invites, limits } = useTypedLoaderData<typeof loader>();
const user = useUser();
const { members, invites } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const requiresUpgrade = limits.used >= limits.limit;
return (
<PageContainer>
<NavBar>
@@ -133,7 +149,9 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody>
<Header2>Members</Header2>
<Header2>
Members ({limits.used}/{limits.limit})
</Header2>
<ul className="divide-ui-border flex w-full max-w-md flex-col divide-y border-b border-grid-bright">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
@@ -150,11 +168,6 @@ export default function Page() {
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-4">
{/*
// This displays Member or Admin but we'll implement this when we implement roles properly
<Paragraph variant="extra-small">
{titleCase(member.role.toLocaleLowerCase())}
</Paragraph> */}
<LeaveRemoveButton userId={user.id} member={member} memberCount={members.length} />
</div>
</li>
@@ -186,15 +199,31 @@ export default function Page() {
</>
)}
<div className="mt-4 flex max-w-md justify-end">
<LinkButton
to={inviteTeamMemberPath(organization)}
variant={"primary/small"}
LeadingIcon={UserPlusIcon}
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
Invite a team member
</LinkButton>
</div>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="mt-4 flex max-w-md justify-end">
<LinkButton
to={inviteTeamMemberPath(organization)}
variant={"primary/small"}
LeadingIcon={UserPlusIcon}
>
Invite a team member
</LinkButton>
</div>
)}
</PageBody>
</PageContainer>
);
@@ -0,0 +1,143 @@
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { requireUserId } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationPath,
v3StripePortalPath,
} from "~/utils/pathBuilder";
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
import { PlanDefinition } from "@trigger.dev/platform/v3";
import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid";
import { DateTime } from "~/components/primitives/DateTime";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const plans = await getPlans();
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const currentPlan = await getCurrentPlan(organization.id);
//periods
const periodStart = new Date();
periodStart.setHours(0, 0, 0, 0);
periodStart.setDate(1);
const periodEnd = new Date();
periodEnd.setMonth(periodEnd.getMonth() + 1);
periodEnd.setDate(0);
periodEnd.setHours(0, 0, 0, 0);
const daysRemaining = Math.ceil(
(periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
);
return typedjson({
...plans,
...currentPlan,
organizationSlug,
periodStart,
periodEnd,
daysRemaining,
});
}
export default function ChoosePlanPage() {
const { plans, v3Subscription, organizationSlug, periodStart, periodEnd, daysRemaining } =
useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Billing" />
<PageAccessories>
{v3Subscription?.isPaying && (
<>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Invoices
</LinkButton>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Manage card details
</LinkButton>
</>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-y-3 divide-grid-bright rounded-sm border border-grid-bright bg-background-bright py-2 pr-1 text-text-bright lg:w-fit lg:flex-row lg:items-center lg:divide-x">
<div className="flex gap-2 px-3 lg:items-center">
<StarIcon className="size-5 min-w-5 lg:-mt-0.5" />
{planLabel(v3Subscription?.plan, v3Subscription?.canceledAt !== undefined, periodEnd)}
</div>
{v3Subscription?.isPaying ? (
<div className="flex gap-2 px-3 lg:items-center">
<CalendarDaysIcon className="size-5 min-w-5 lg:-mt-0.5" />
Billing period: <DateTime date={periodStart} includeTime={false} /> to{" "}
<DateTime date={periodEnd} includeTime={false} /> ({daysRemaining} days remaining)
</div>
) : null}
</div>
<div>
<PricingPlans
plans={plans}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan={false}
/>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEnd: Date) {
if (!plan || plan.type === "free") {
return "You're on the Free plan";
}
if (plan.type === "enterprise") {
return `You're on the Enterprise plan`;
}
const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`;
if (canceled) {
return (
<>
{text}. From <DateTime includeTime={false} date={periodEnd} /> you're on the Free plan.
</>
);
}
return text;
}
@@ -0,0 +1,329 @@
import { ArrowRightIcon } from "@heroicons/react/24/solid";
import { Await } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { Suspense } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { redirect, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { URL } from "url";
import { UsageBar } from "~/components/billing/v3/UsageBar";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "~/components/primitives/Chart";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useSearchParams } from "~/hooks/useSearchParam";
import { UsagePresenter, UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
//past 6 months, 1st day of the month
const months = Array.from({ length: 6 }, (_, i) => {
const date = new Date();
date.setDate(1);
date.setUTCMonth(date.getUTCMonth() - i);
date.setUTCHours(0, 0, 0, 0);
return date;
});
const search = new URL(request.url).searchParams;
const searchMonth = search.get("month");
const startDate = searchMonth ? new Date(searchMonth) : months[0];
startDate.setDate(1);
startDate.setUTCHours(0, 0, 0, 0);
const presenter = new UsagePresenter();
const { usageOverTime, usage, tasks } = await presenter.call({
organizationId: organization.id,
startDate,
});
return typeddefer({
usageOverTime,
usage,
tasks,
months,
isCurrentMonth: startDate.toISOString() === months[0].toISOString(),
});
}
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
});
export default function Page() {
const { usage, usageOverTime, tasks, months, isCurrentMonth } =
useTypedLoaderData<typeof loader>();
const currentPlan = useCurrentPlan();
const { value, replace } = useSearchParams();
const month = value("month") ?? months[0].toISOString();
return (
<PageContainer>
<NavBar>
<PageTitle title="Usage" />
</NavBar>
<PageBody scrollable={true}>
<div className="flex flex-col gap-6">
<div>
<Select
name="month"
placeholder="Select a month"
className="mb-3"
defaultValue={month}
items={months.map((date) => ({
label: monthDateFormatter.format(date),
value: date.toISOString(),
}))}
text={(value) => monthDateFormatter.format(new Date(value))}
setValue={(value) => {
replace({ month: value });
}}
dropdownIcon
variant="tertiary/medium"
>
{(matches) =>
matches.map((month) => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))
}
</Select>
<div className="flex w-full flex-col gap-2 rounded-sm border border-grid-dimmed p-4">
<Suspense fallback={<Spinner />}>
<Await
resolve={usage}
errorElement={
<div className="flex min-h-40 items-center justify-center">
<Paragraph variant="small">Failed to load graph.</Paragraph>
</div>
}
>
{(usage) => (
<>
<div className="flex w-full items-center gap-6">
<div className="flex flex-col gap-2">
<Header3 className="">
{isCurrentMonth ? "Month-to-date" : "Usage"}
</Header3>
<p className="text-3xl font-medium text-text-bright">
{formatCurrency(usage.current, false)}
</p>
</div>
{isCurrentMonth ? (
<>
<ArrowRightIcon className="h-6 w-6 text-text-dimmed/50" />
<div className="flex flex-col gap-2 text-text-dimmed">
<Header3 className="text-text-dimmed">Projected</Header3>
<p className="text-3xl font-medium">
{formatCurrency(usage.projected, false)}
</p>
</div>
</>
) : null}
</div>
<UsageBar
current={usage.current}
projectedUsage={isCurrentMonth ? usage.projected : undefined}
isPaying={currentPlan?.v3Subscription?.isPaying ?? false}
tierLimit={
isCurrentMonth
? (currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0) / 100
: undefined
}
/>
</>
)}
</Await>
</Suspense>
</div>
</div>
<div>
<Header2 spacing>Usage by day</Header2>
<div className="rounded-sm border border-grid-dimmed p-4">
<Suspense
fallback={
<div className="flex min-h-40 items-center justify-center">
<Spinner />
</div>
}
>
<Await
resolve={usageOverTime}
errorElement={
<div className="flex min-h-40 items-center justify-center">
<Paragraph variant="small">Failed to load graph.</Paragraph>
</div>
}
>
{(past30Days) => <UsageChart data={past30Days} />}
</Await>
</Suspense>
</div>
</div>
<div>
<Header2 spacing>Tasks</Header2>
<Suspense fallback={<Spinner />}>
<Await
resolve={tasks}
errorElement={
<div className="flex min-h-40 items-center justify-center">
<Paragraph variant="small">Failed to load.</Paragraph>
</div>
}
>
{(tasks) => {
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell alignment="right">Runs</TableHeaderCell>
<TableHeaderCell alignment="right">Average duration</TableHeaderCell>
<TableHeaderCell alignment="right">Average cost</TableHeaderCell>
<TableHeaderCell alignment="right">Total duration</TableHeaderCell>
<TableHeaderCell alignment="right">Total cost</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{tasks.length === 0 ? (
<TableRow>
<TableCell colSpan={6}>
<Paragraph variant="small">No runs.</Paragraph>
</TableCell>
</TableRow>
) : (
tasks.map((task) => (
<TableRow key={task.taskIdentifier}>
<TableCell>{task.taskIdentifier}</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatNumber(task.runCount)}
</TableCell>
<TableCell alignment="right">
{formatDurationMilliseconds(task.averageDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.averageCost)}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatDurationMilliseconds(task.totalDuration, {
style: "short",
})}
</TableCell>
<TableCell alignment="right" className="tabular-nums">
{formatCurrencyAccurate(task.totalCost)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}}
</Await>
</Suspense>
</div>
</div>
</PageBody>
</PageContainer>
);
}
const chartConfig = {
dollars: {
label: "Usage ($)",
color: "#7655fd",
},
} satisfies ChartConfig;
const tooltipDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
function UsageChart({ data }: { data: UsageSeriesData }) {
const maxDollar = Math.max(...data.map((d) => d.dollars));
const decimalPlaces = maxDollar < 1 ? 4 : 2;
return (
<ChartContainer config={chartConfig} className="max-h-96 min-h-40 w-full">
<BarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
<XAxis
fontSize={12}
tickLine={false}
tickMargin={10}
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
if (!value) return "";
const date = new Date(value);
return `${date.getDate()}`;
}}
className="text-xs"
/>
<YAxis
fontSize={12}
tickLine={false}
tickMargin={10}
axisLine={false}
allowDecimals={true}
tickFormatter={(value) => `$${value.toFixed(decimalPlaces)}`}
/>
<ChartTooltip
content={<ChartTooltipContent />}
labelFormatter={(value, data) => {
const dateString = data.at(0)?.payload.date;
if (!dateString) {
return "";
}
return tooltipDateFormatter.format(new Date(dateString));
}}
/>
<Bar dataKey="dollars" fill="var(--color-dollars)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
);
}
@@ -10,8 +10,8 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { BillingService } from "~/services/billing.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { getCurrentPlan, getUsage } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { organizationPath } from "~/utils/pathBuilder";
@@ -26,6 +26,7 @@ export function useCurrentPlan(matches?: UIMatch[]) {
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.currentPlan;
}
@@ -46,16 +47,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
telemetry.organization.identify({ organization });
telemetry.project.identify({ project });
const { isManagedCloud } = featuresForRequest(request);
const billingPresenter = new BillingService(isManagedCloud);
const currentPlan = await billingPresenter.currentPlan(organization.id);
//1st day of the month
const firstDayOfMonth = new Date();
firstDayOfMonth.setDate(1);
firstDayOfMonth.setHours(0, 0, 0, 0);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const [plan, usage] = await Promise.all([
getCurrentPlan(organization.id),
getUsage(organization.id, { from: firstDayOfMonth, to: tomorrow }),
]);
let hasExceededFreeTier = false;
let usagePercentage = 0;
if (plan?.v3Subscription && !plan.v3Subscription.isPaying && plan.v3Subscription.plan && usage) {
hasExceededFreeTier = usage.cents > plan.v3Subscription.plan.limits.includedUsage;
usagePercentage = usage.cents / plan.v3Subscription.plan.limits.includedUsage;
}
return typedjson({
organizations,
organization,
project,
isImpersonating: !!impersonationId,
currentPlan,
currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } },
});
};
@@ -3,7 +3,7 @@ import { parse } from "@conform-to/zod";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
@@ -16,17 +16,19 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { useUser } from "~/hooks/useUser";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
import { RequestV3Access } from "../resources.orgs.$organizationSlug.v3-access";
import {
OrganizationParamsSchema,
organizationPath,
projectPath,
selectPlanPath,
} from "~/utils/pathBuilder";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@@ -56,8 +58,16 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
//if you don't have v3 access, you must select a plan
const { isManagedCloud, v3Enabled } = featuresForRequest(request);
if (isManagedCloud && v3Enabled && !organization.v3Enabled) {
return redirect(selectPlanPath({ slug: organizationSlug }));
}
const url = new URL(request.url);
const message = url.searchParams.get("message");
return typedjson({
organization: {
id: organization.id,
@@ -69,6 +79,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
hasRequestedV3: organization.hasRequestedV3,
},
defaultVersion: url.searchParams.get("version") ?? "v2",
message: message ? decodeURIComponent(message) : undefined,
});
}
@@ -107,24 +118,13 @@ export const action: ActionFunction = async ({ request, params }) => {
}
};
export default function NewOrganizationPage() {
const { organization } = useTypedLoaderData<typeof loader>();
export default function Page() {
const { organization, message } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { v3Enabled, isManagedCloud } = useFeatures();
const canCreateV3Projects = organization.v3Enabled && v3Enabled;
const canCreateV2Projects = organization.v2Enabled || !isManagedCloud;
const canCreateProjects = canCreateV2Projects || canCreateV3Projects;
if (!canCreateProjects) {
return (
<RequestV3Access
hasRequestedV3={organization.hasRequestedV3}
organizationSlug={organization.slug}
projectsCount={organization.projectsCount}
/>
);
}
const [form, { projectName, projectVersion }] = useForm({
id: "create-project",
@@ -144,9 +144,9 @@ export default function NewOrganizationPage() {
description={`This will create a new project in your "${organization.title}" organization.`}
/>
<Form method="post" {...form.props}>
{organization.projectsCount === 0 && (
<Callout variant="info" className="mb-4">
Organizations require at least one project, please create one to continue.
{message && (
<Callout variant="success" className="mb-4">
{message}
</Callout>
)}
<Fieldset>
@@ -184,15 +184,9 @@ export default function NewOrganizationPage() {
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : canCreateV3Projects ? (
<>
<Callout variant="info">This will be a v3 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
</>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v3"} />
) : (
<>
<Callout variant="info">This will be a v2 project</Callout>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
</>
<input {...conform.input(projectVersion, { type: "hidden" })} value={"v2"} />
)}
<FormButtons
confirmButton={
@@ -1,88 +1,57 @@
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 { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Header1 } from "~/components/primitives/Headers";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTrigger,
} from "~/components/primitives/Sheet";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useOptionalProject, useProject } from "~/hooks/useProject";
import { OrgBillingPlanPresenter } from "~/presenters/OrgBillingPlanPresenter";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import {
OrganizationParamsSchema,
organizationBillingPath,
organizationPath,
projectPath,
} from "~/utils/pathBuilder";
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationBillingPath({ slug: organizationSlug }));
return redirect(organizationPath({ slug: organizationSlug }));
}
const presenter = new OrgBillingPlanPresenter();
const result = await presenter.call({ slug: organizationSlug, isManagedCloud });
if (!result) {
throw new Response(null, { status: 404 });
const plans = await getPlans();
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
const orgsPresenter = new OrganizationsPresenter();
const { project } = await orgsPresenter.call({
userId,
request,
organizationSlug,
projectSlug: undefined,
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
if (organization.v3Enabled) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const currentPlan = await getCurrentPlan(organization.id);
return typedjson({ ...plans, ...currentPlan, organizationSlug });
}
export default function ChoosePlanPage() {
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
const { plans, v3Subscription, organizationSlug } = useTypedLoaderData<typeof loader>();
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}
<MainCenteredContainer className="flex max-w-[80rem] flex-col items-center gap-8 p-3">
<Header1 className="text-center">Subscribe for full access</Header1>
<PricingPlans
plans={plans}
showActionText={false}
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan
/>
<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-grid-bright p-6">
<RunsVolumeDiscountTable brackets={plans.paid.runs?.pricing?.brackets ?? []} />
</div>
</SheetBody>
</SheetContent>
</Sheet>
</div>
</MainCenteredContainer>
);
}
@@ -1,20 +1,19 @@
import { CheckBadgeIcon } from "@heroicons/react/24/solid";
import { RunsVolumeDiscountTable } from "~/components/billing/RunsVolumeDiscountTable";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { RunsVolumeDiscountTable } from "~/components/billing/v2/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";
import { useNewCustomerSubscribed } from "~/hooks/useNewCustomerSubscribed";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { BillingService } from "~/services/billing.v2.server";
import { requireUserId } from "~/services/session.server";
import { Handle } from "~/utils/handle";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -0,0 +1,104 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { marqs } from "~/v3/marqs/index.server";
const ParamsSchema = z.object({
organizationId: z.string(),
});
const RequestBodySchema = z.object({
organization: z.number(),
development: z.number().optional(),
staging: z.number().optional(),
production: z.number().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const { organizationId } = ParamsSchema.parse(params);
const rawBody = await request.json();
const body = RequestBodySchema.parse(rawBody);
if (!body.development && !body.staging && !body.production) {
return json({ error: "At least one environment limit must be provided" }, { status: 400 });
}
//update org
const organization = await prisma.organization.update({
where: {
id: organizationId,
},
data: {
maximumConcurrencyLimit: body.organization,
},
});
//update environments
const environments = await prisma.runtimeEnvironment.findMany({
where: {
organizationId: organizationId,
project: {
version: "V3",
},
},
});
for (const environment of environments) {
let limit: number | undefined = undefined;
switch (environment.type) {
case "DEVELOPMENT": {
limit = body.development;
break;
}
case "STAGING": {
limit = body.staging;
break;
}
case "PRODUCTION": {
limit = body.production;
break;
}
}
if (!limit) continue;
const modifiedEnvironment = await prisma.runtimeEnvironment.update({
where: {
id: environment.id,
},
data: {
maximumConcurrencyLimit: limit,
},
include: {
project: true,
},
});
await marqs?.updateEnvConcurrencyLimits({ ...modifiedEnvironment, organization });
}
return json({ success: true });
}
+6 -86
View File
@@ -1,24 +1,6 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { validateJWTTokenAndRenew } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { workerQueue } from "~/services/worker.server";
import { machinePresetFromName } from "~/v3/machinePresets.server";
import { reportUsageEvent } from "~/v3/openMeter.server";
const JWTPayloadSchema = z.object({
environment_id: z.string(),
org_id: z.string(),
project_id: z.string(),
run_id: z.string(),
machine_preset: z.string(),
});
const BodySchema = z.object({
durationMs: z.number(),
});
import { reportComputeUsage } from "~/services/platform.v3.server";
export async function action({ request }: ActionFunctionArgs) {
// Ensure this is a POST request
@@ -26,72 +8,10 @@ export async function action({ request }: ActionFunctionArgs) {
return { status: 405, body: "Method Not Allowed" };
}
const jwtResult = await validateJWTTokenAndRenew(request, JWTPayloadSchema);
if (!jwtResult) {
return { status: 401, body: "Unauthorized" };
try {
return await reportComputeUsage(request);
} catch (e) {
logger.error("Error reporting compute usage", { error: e });
return new Response(null, { status: 500 });
}
const rawJson = await request.json();
const json = BodySchema.safeParse(rawJson);
if (!json.success) {
logger.error("Failed to parse request body", { rawJson });
return { status: 400, body: "Bad Request" };
}
const preset = machinePresetFromName(jwtResult.payload.machine_preset as MachinePresetName);
logger.debug("[/api/v1/usage/ingest] Reporting usage", { jwtResult, json: json.data, preset });
if (json.data.durationMs > 0) {
const costInCents = json.data.durationMs * preset.centsPerMs;
await prisma.taskRun.update({
where: {
id: jwtResult.payload.run_id,
},
data: {
usageDurationMs: {
increment: json.data.durationMs,
},
costInCents: {
increment: json.data.durationMs * preset.centsPerMs,
},
},
});
try {
await reportUsageEvent({
source: "webapp",
type: "usage",
subject: jwtResult.payload.org_id,
data: {
durationMs: json.data.durationMs,
costInCents: String(costInCents),
},
});
} catch (e) {
logger.error("Failed to report usage event, enqueing v3.reportUsage", { error: e });
await workerQueue.enqueue("v3.reportUsage", {
orgId: jwtResult.payload.org_id,
data: {
costInCents: String(costInCents),
},
additionalData: {
durationMs: json.data.durationMs,
},
});
}
}
return new Response(null, {
status: 200,
headers: {
"x-trigger-jwt": jwtResult.jwt,
},
});
}
@@ -1,10 +1,10 @@
import { parse } from "@conform-to/zod";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SetPlanBodySchema } from "@trigger.dev/billing";
import { SetPlanBodySchema } from "@trigger.dev/platform/v2";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { BillingService } from "~/services/billing.v2.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
@@ -2,7 +2,7 @@ import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { BillingService } from "~/services/billing.server";
import { BillingService } from "~/services/billing.v2.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
@@ -0,0 +1,45 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { customerPortalUrl } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
export async function loader({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
id: true,
},
where: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
});
if (!org) {
return redirectWithErrorMessage(
usagePath({ slug: organizationSlug }),
request,
"Something went wrong. Please try again later."
);
}
const result = await 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);
}
@@ -0,0 +1,731 @@
import {
CheckIcon,
ExclamationTriangleIcon,
ShieldCheckIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form, useLocation, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import {
FreePlanDefinition,
Limits,
PaidPlanDefinition,
Plans,
SetPlanBody,
SubscriptionResult,
} from "@trigger.dev/platform/v3";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import { z } from "zod";
import { DefinitionTip } from "~/components/DefinitionTooltip";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { setPlan } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
const Params = z.object({
organizationSlug: z.string(),
});
const schema = z.object({
type: z.enum(["free", "paid"]),
planCode: z.string().optional(),
callerPath: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
const { organizationSlug } = Params.parse(params);
const userId = await requireUserId(request);
const formData = Object.fromEntries(await request.formData());
const form = schema.parse(formData);
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
});
if (!organization) {
throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
}
let payload: SetPlanBody;
switch (form.type) {
case "free": {
payload = {
type: "free" as const,
userId,
};
break;
}
case "paid": {
if (form.planCode === undefined) {
throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
}
payload = {
type: "paid" as const,
planCode: form.planCode,
userId,
};
break;
}
}
return setPlan(organization, request, form.callerPath, payload);
}
const pricingDefinitions = {
usage: {
title: "Usage",
content: "The compute cost when tasks are executing.",
},
freeUsage: {
title: "Free usage",
content: "Requires a verified GitHub account.",
},
concurrentRuns: {
title: "Concurrent runs",
content: "The number of runs that can be executed at the same time.",
},
taskRun: {
title: "Task runs",
content: "A single execution of a task.",
},
tasks: {
title: "Tasks",
content:
"Tasks are functions that can run for a long time and provide strong resilience to failure.",
},
environment: {
title: "Environments",
content: "The different environments available for running your tasks.",
},
schedules: {
title: "Schedules",
content: "You can attach recurring schedules to tasks using CRON syntax.",
},
alerts: {
title: "Alert destination",
content:
"A single email address, Slack channel, or webhook URL that you want to send alerts to.",
},
};
type PricingPlansProps = {
plans: Plans;
subscription?: SubscriptionResult;
organizationSlug: string;
hasPromotedPlan: boolean;
};
export function PricingPlans({
plans,
subscription,
organizationSlug,
hasPromotedPlan,
}: PricingPlansProps) {
return (
<div className="flex w-full flex-col">
<div className="flex flex-col gap-3 lg:flex-row">
<TierFree
plan={plans.free}
subscription={subscription}
organizationSlug={organizationSlug}
/>
<TierHobby
plan={plans.hobby}
organizationSlug={organizationSlug}
subscription={subscription}
isHighlighted={hasPromotedPlan}
/>
<TierPro plan={plans.pro} organizationSlug={organizationSlug} subscription={subscription} />
</div>
<div className="mt-3">
<TierEnterprise />
</div>
</div>
);
}
export function TierFree({
plan,
subscription,
organizationSlug,
}: {
plan: FreePlanDefinition;
subscription?: SubscriptionResult;
organizationSlug: string;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
const status = subscription?.freeTierStatus ?? "requires_connect";
return (
<TierContainer>
<div className="relative">
<PricingHeader title={plan.title} cost={0} />
{status === "approved" && (
<SimpleTooltip
buttonClassName="absolute right-1 top-1"
button={
<div className="flex cursor-default items-center gap-1 rounded-sm bg-green-900 py-1 pl-1.5 pr-2.5 text-xs text-green-300">
<ShieldCheckIcon className="size-4" />
<span>GitHub verified</span>
</div>
}
content={
<div className="flex max-w-[21rem] items-center gap-4">
<div className="flex flex-col items-center gap-1.5">
<ShieldCheckIcon className="size-9 min-w-9 text-green-600" />
<Paragraph
variant="extra-extra-small"
className="uppercase tracking-wider text-green-600"
>
verified
</Paragraph>
</div>
<Paragraph variant="small">
You have connected a verified GitHub account. This is required for the Free plan
to prevent malicious use of our platform.
</Paragraph>
</div>
}
/>
)}
</div>
{status === "rejected" ? (
<div>
<hr className="my-6 border-grid-bright" />
<div className="flex flex-col gap-2 rounded-sm border border-warning p-4">
<ExclamationTriangleIcon className="size-6 text-warning" />
<Paragraph variant="small/bright">
Your Trigger.dev account failed to be verified for the Free plan because your GitHub
account is too new. We require verification to prevent malicious use of our platform.
</Paragraph>
<Paragraph variant="small/bright">
You can still select a paid plan to continue or if you think this is a mistake,{" "}
<Feedback
defaultValue="help"
button={
<span className="cursor-pointer underline decoration-charcoal-400 underline-offset-4 transition hover:decoration-charcoal-200">
get in touch
</span>
}
/>
.
</Paragraph>
</div>
</div>
) : (
<Form action={formAction} method="post" id="subscribe">
<input type="hidden" name="type" value="free" />
<input type="hidden" name="callerPath" value={location.pathname} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} free usage
</TierLimit>
<div className="py-6">
{status === "requires_connect" ? (
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="tertiary/large"
fullWidth
className="text-md font-medium"
disabled={isLoading}
LeadingIcon={isLoading ? Spinner : undefined}
>
Unlock free plan
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Unlock the Free plan</DialogHeader>
<div className="mb-3 mt-4 flex flex-col items-center gap-4 px-6">
<GitHubLightIcon className="size-16" />
<Paragraph variant="base/bright" className="text-center">
To unlock the Free plan, we need to verify that you have an active GitHub
account.
</Paragraph>
<Paragraph className="text-center">
We do this to prevent malicious use of our platform. We only ask for the
minimum permissions to verify your account.
</Paragraph>
</div>
<DialogFooter>
<Button
variant="primary/large"
fullWidth
disabled={isLoading}
LeadingIcon={isLoading ? Spinner : undefined}
form="subscribe"
>
Connect to GitHub
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
variant="tertiary/large"
fullWidth
className="text-md font-medium"
disabled={
isLoading ||
subscription?.plan?.type === plan.type ||
subscription?.canceledAt !== undefined
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === null ? Spinner : undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? "Current plan"
: `Downgrade to ${plan.title}`}
</Button>
)}
</div>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
</Form>
)}
</TierContainer>
);
}
export function TierHobby({
plan,
organizationSlug,
subscription,
isHighlighted,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
subscription?: SubscriptionResult;
isHighlighted: boolean;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
return (
<TierContainer isHighlighted={isHighlighted}>
<PricingHeader title={plan.title} isHighlighted={isHighlighted} cost={plan.tierPrice} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} usage included
</TierLimit>
<Form action={formAction} method="post" id="subscribe">
<div className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
<Button
variant={isHighlighted ? "primary/large" : "tertiary/large"}
fullWidth
className="text-md font-medium"
disabled={
isLoading ||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === plan.code ? Spinner : undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Downgrade to ${plan.title}`}
</Button>
</div>
</Form>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} /> <Environments limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
</TierContainer>
);
}
export function TierPro({
plan,
organizationSlug,
subscription,
}: {
plan: PaidPlanDefinition;
organizationSlug: string;
subscription?: SubscriptionResult;
}) {
const location = useLocation();
const navigation = useNavigation();
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
const isLoading = navigation.formAction === formAction;
return (
<TierContainer>
<PricingHeader title={plan.title} cost={plan.tierPrice} />
<TierLimit href="https://trigger.dev/pricing#computePricing">
${plan.limits.includedUsage / 100} usage included
</TierLimit>
<Form action={formAction} method="post" id="subscribe">
<div className="py-6">
<input type="hidden" name="type" value="paid" />
<input type="hidden" name="planCode" value={plan.code} />
<input type="hidden" name="callerPath" value={location.pathname} />
<Button
variant="tertiary/large"
fullWidth
className="text-md font-medium"
disabled={
isLoading ||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
}
LeadingIcon={
isLoading && navigation.formData?.get("planCode") === plan.code ? Spinner : undefined
}
>
{subscription?.plan === undefined
? "Select plan"
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
? `Upgrade to ${plan.title}`
: subscription.plan.code === plan.code
? "Current plan"
: `Upgrade to ${plan.title}`}
</Button>
</div>
</Form>
<ul className="flex flex-col gap-2.5">
<ConcurrentRuns limits={plan.limits} />
<FeatureItem checked>
Unlimited{" "}
<DefinitionTip
title={pricingDefinitions.tasks.title}
content={pricingDefinitions.tasks.content}
>
tasks
</DefinitionTip>
</FeatureItem>
<TeamMembers limits={plan.limits} />
<Environments limits={plan.limits} />
<Schedules limits={plan.limits} />
<LogRetention limits={plan.limits} />
<SupportLevel limits={plan.limits} />
<Alerts limits={plan.limits} />
</ul>
</TierContainer>
);
}
export function TierEnterprise() {
return (
<TierContainer>
<div className="flex w-full flex-col items-center justify-between gap-4 lg:flex-row">
<div className="flex w-full flex-wrap items-center justify-between gap-2 lg:flex-nowrap">
<div className="-mt-1 mb-2 flex w-full flex-col gap-2 lg:mb-0 lg:gap-0.5">
<h2 className="text-xl font-medium text-text-dimmed">Enterprise</h2>
<hr className="my-2 block border-grid-dimmed lg:hidden" />
<p className="whitespace-nowrap font-sans text-lg font-normal text-text-bright lg:text-sm">
Tailor a custom plan
</p>
</div>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
All Pro plan features +
</FeatureItem>
<FeatureItem checked checkedColor="bright">
Custom log retention
</FeatureItem>
</ul>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
Priority support
</FeatureItem>
<FeatureItem checked checkedColor="bright">
Role-based access control
</FeatureItem>
</ul>
<ul className="flex w-full flex-col gap-y-3 lg:gap-y-1">
<FeatureItem checked checkedColor="bright">
SOC 2 report
</FeatureItem>
<FeatureItem checked checkedColor="bright">
SSO
</FeatureItem>
</ul>
</div>
<div className="w-full lg:max-w-[16rem]">
<Feedback
defaultValue="enterprise"
button={
<div className="flex h-10 w-full cursor-pointer items-center justify-center rounded bg-tertiary px-8 text-base font-medium transition hover:bg-charcoal-600">
<span className="text-center text-text-bright">Contact us</span>
</div>
}
></Feedback>
</div>
</div>
</TierContainer>
);
}
function TierContainer({
children,
isHighlighted,
className,
}: {
children: React.ReactNode;
isHighlighted?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex w-full min-w-[16rem] flex-col p-6",
isHighlighted ? "border border-primary" : "border border-grid-dimmed",
className
)}
>
{children}
</div>
);
}
function PricingHeader({
title,
cost: flatCost,
isHighlighted,
per = "/month",
maximumFractionDigits = 0,
}: {
title: string;
cost?: number;
isHighlighted?: boolean;
per?: string;
maximumFractionDigits?: number;
}) {
const dollarFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits,
});
return (
<div className="flex flex-col gap-2">
<h2
className={cn("text-xl font-medium", isHighlighted ? "text-primary" : "text-text-dimmed")}
>
{title}
</h2>
{flatCost === 0 || flatCost ? (
<h3 className="text-4xl font-medium tabular-nums text-text-bright">
{dollarFormatter.format(flatCost)}
<span className="ml-1 text-sm font-normal tracking-wide text-text-dimmed">{per}</span>
</h3>
) : (
<h2 className="text-4xl font-medium">Custom</h2>
)}
</div>
);
}
function TierLimit({ children, href }: { children: React.ReactNode; href?: string }) {
return (
<>
{href ? (
<div>
<hr className="my-6 border-grid-bright" />
<a
href={href}
className="hover:decoration-bright font-sans text-lg font-normal text-text-bright underline decoration-charcoal-500 underline-offset-4 transition"
>
{children}
</a>
</div>
) : (
<div>
<hr className="my-6 border-grid-bright" />
<div className="font-sans text-lg font-normal text-text-bright">{children}</div>
</div>
)}
</>
);
}
function FeatureItem({
checked,
checkedColor = "primary",
children,
}: {
checked?: boolean;
checkedColor?: "primary" | "bright";
children: React.ReactNode;
}) {
return (
<li className="flex items-center gap-2">
{checked ? (
<CheckIcon
className={cn(
"size-4 min-w-4",
checkedColor === "primary" ? "text-primary" : "text-text-bright"
)}
/>
) : (
<XMarkIcon className="size-4 min-w-4 text-charcoal-500" />
)}
<div
className={cn(
"font-sans text-sm font-normal",
checked ? "text-text-bright" : "text-text-dimmed"
)}
>
{children}
</div>
</li>
);
}
function ConcurrentRuns({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.concurrentRuns.number}
{limits.concurrentRuns.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.concurrentRuns.title}
content={pricingDefinitions.concurrentRuns.content}
>
concurrent runs
</DefinitionTip>
</FeatureItem>
);
}
function TeamMembers({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.teamMembers.number}
{limits.concurrentRuns.canExceed ? "+" : ""} team members
</FeatureItem>
);
}
function Environments({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.hasStagingEnvironment ? "Dev, Staging and Prod" : "Dev and Prod"}{" "}
<DefinitionTip
title={pricingDefinitions.environment.title}
content={pricingDefinitions.environment.content}
>
environments
</DefinitionTip>
</FeatureItem>
);
}
function Schedules({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.schedules.number}
{limits.schedules.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.schedules.title}
content={pricingDefinitions.schedules.content}
>
schedules
</DefinitionTip>
</FeatureItem>
);
}
function LogRetention({ limits }: { limits: Limits }) {
return <FeatureItem checked>{limits.logRetentionDays.number} day log retention</FeatureItem>;
}
function SupportLevel({ limits }: { limits: Limits }) {
return (
<FeatureItem checked>
{limits.support === "community" ? "Community support" : "Dedicated Slack support"}
</FeatureItem>
);
}
function Alerts({ limits }: { limits: Limits }) {
if (limits.alerts.number === 0) {
return (
<FeatureItem>
<DefinitionTip
title={pricingDefinitions.alerts.title}
content={pricingDefinitions.alerts.content}
>
Alert destinations
</DefinitionTip>
</FeatureItem>
);
}
return (
<FeatureItem checked>
{limits.alerts.number}
{limits.alerts.canExceed ? "+" : ""}{" "}
<DefinitionTip
title={pricingDefinitions.alerts.title}
content={pricingDefinitions.alerts.content}
>
alert destinations
</DefinitionTip>
</FeatureItem>
);
}
@@ -1,210 +0,0 @@
import { Form } from "@remix-run/react";
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { PlainClient } from "@team-plain/typescript-sdk";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { useUser } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { organizationPath } from "~/utils/pathBuilder";
import v3Icon from "~/assets/icons/v3.svg";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
const ParamSchema = z.object({
organizationSlug: z.string(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
if (request.method.toLowerCase() !== "post") {
return redirectWithErrorMessage("/", request, "Invalid request method");
}
const user = await requireUser(request);
const { organizationSlug } = ParamSchema.parse(params);
const failedRedirectPath = organizationPath({ slug: organizationSlug });
try {
if (!env.PLAIN_API_KEY) {
return redirectWithErrorMessage(
failedRedirectPath,
request,
"Error requesting V3 access: Plain API key"
);
}
//mark them as having requested v3
const organization = await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
data: {
hasRequestedV3: true,
},
});
//update Plain
const client = new PlainClient({
apiKey: env.PLAIN_API_KEY,
});
const upsertCustomerRes = await client.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
fullName: user.name ?? user.email,
email: {
email: user.email,
isVerified: true,
},
},
onUpdate: {},
});
if (upsertCustomerRes.error) {
logger.error("Error upserting customer", upsertCustomerRes.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
const groupResult = await client.addCustomerToCustomerGroups({
customerId: upsertCustomerRes.data.customer.id,
customerGroupIdentifiers: [
{
customerGroupKey: "interested-in-v3",
},
],
});
if (groupResult.error) {
logger.error("Error adding customer to group", groupResult.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
const createThreadRes = await client.createThread({
customerIdentifier: {
customerId: upsertCustomerRes.data.customer.id,
},
title: "v3 early access request",
components: [
{
componentText: {
text: `${upsertCustomerRes.data.customer.email.email} has been added to the v3 early access group`,
},
},
{
componentText: {
text: `Company: ${organization.title ?? ""}`,
},
},
],
});
if (createThreadRes.error) {
logger.error("Error creating thread", createThreadRes.error);
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
return redirectWithSuccessMessage(
organizationPath(organization),
request,
"V3 access requested"
);
} catch (error) {
logger.error("Error requesting V3 access", { error });
return redirectWithErrorMessage(failedRedirectPath, request, "Error requesting V3 access");
}
};
export function RequestV3Access({
hasRequestedV3,
organizationSlug,
projectsCount,
}: {
hasRequestedV3: boolean;
organizationSlug: string;
projectsCount: number;
}) {
const user = useUser();
if (hasRequestedV3) {
return (
<MainCenteredContainer>
<div>
<div className="relative mb-4 flex size-9">
<img src={v3Icon} alt="v3" width={32} height={32} />
<div className="absolute right-0 top-0 size-4 rounded-full bg-background-dimmed">
<CheckCircleIcon className="size-4 text-success" />
</div>
</div>
<Paragraph spacing variant="base/bright">
Weve received your request for v3 and well notify you as soon as you have access.
Were granting new users access every day so you wont be waiting long.
</Paragraph>
<Paragraph spacing variant="base/bright">
Right now v3 is completely free to use but{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/#cloud-pricing">
paid tiers
</TextLink>{" "}
will be introduced soon.
</Paragraph>
<Paragraph spacing variant="base/bright">
In the meantime, check out the{" "}
<TextLink href="https://trigger.dev/docs">v3 docs</TextLink>, the{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
v3 blog post
</TextLink>{" "}
and <TextLink href="https://trigger.dev/discord">join our Discord</TextLink>.
</Paragraph>
</div>
</MainCenteredContainer>
);
}
return (
<MainCenteredContainer>
<img src={v3Icon} alt="v3" width={32} height={32} className="mb-4" />
<Form action={`/resources/orgs/${organizationSlug}/v3-access`} method="post">
{projectsCount > 0 ? (
<Paragraph spacing variant="base/bright">
You can no longer create v2 projects and your organization doesn't have access to v3
yet. We are approving access requests daily.
</Paragraph>
) : (
<Paragraph spacing variant="base/bright">
Trigger.dev v3 is currently in Developer Preview and were operating a waitlist as we
focus on the platforms reliability and scaleability.
</Paragraph>
)}
<Paragraph spacing variant="base/bright">
For more info, check out our{" "}
<TextLink href="https://trigger.dev/blog/v3-developer-preview-launch/">
v3 blog post
</TextLink>
.
</Paragraph>
<div className="mt-2 flex items-center justify-between gap-3">
{projectsCount > 0 ? (
<LinkButton variant="tertiary/small" to={organizationPath({ slug: organizationSlug })}>
Cancel
</LinkButton>
) : null}
<Button variant="primary/small" type="submit">
Request access
</Button>
</div>
</Form>
</MainCenteredContainer>
);
}
@@ -1,4 +1,4 @@
import { FreePlanUsage } from "~/components/billing/FreePlanUsage";
import { FreePlanUsage } from "~/components/billing/v2/FreePlanUsage";
export default function Story() {
return (
@@ -1,4 +1,4 @@
import { UsageBar } from "~/components/billing/UsageBar";
import { UsageBar } from "~/components/billing/v2/UsageBar";
import { Paragraph } from "~/components/primitives/Paragraph";
export default function Story() {
@@ -1,4 +1,4 @@
import { BillingClient, SetPlanBody } from "@trigger.dev/billing";
import { BillingClient, SetPlanBody } from "@trigger.dev/platform/v2";
import { $replica, PrismaClient, PrismaReplicaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
@@ -9,7 +9,11 @@ export class BillingService {
#prismaClient: PrismaClient;
#replica: PrismaReplicaClient;
constructor(isManagedCloud: boolean, prismaClient: PrismaClient = prisma, replica: PrismaReplicaClient = $replica) {
constructor(
isManagedCloud: boolean,
prismaClient: PrismaClient = prisma,
replica: PrismaReplicaClient = $replica
) {
this.#prismaClient = prismaClient;
this.#replica = replica;
if (isManagedCloud && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
@@ -2,7 +2,7 @@ import { DateFormatter } from "@internationalized/date";
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { BillingService } from "./billing.server";
import { BillingService } from "./billing.v2.server";
import { DeleteProjectService } from "./deleteProject.server";
export class DeleteOrganizationService {
+8 -1
View File
@@ -51,7 +51,14 @@ class LoopsClient {
const response = await fetch("https://app.loops.so/api/v1/events/send", options);
if (!response.ok) {
logger.error(`Loops sendEvent ${eventName} bad status`, { status: response.status });
logger.error(`Loops sendEvent ${eventName} bad status`, {
status: response.status,
email,
userId,
firstName,
eventProperties,
eventName,
});
return false;
}
@@ -0,0 +1,307 @@
import { BillingClient, Limits, SetPlanBody, UsageSeriesParams } from "@trigger.dev/platform/v3";
import { Organization, Project } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { createEnvironment } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
export async function getCurrentPlan(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.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 $replica.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;
}
}
export async function getLimits(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
if (!result.success) {
logger.error("Error getting limits", { orgId, error: result.error });
return undefined;
}
return result.v3Subscription?.plan?.limits;
} catch (e) {
logger.error("Error getting limits", { orgId, error: e });
return undefined;
}
}
export async function getLimit(orgId: string, limit: keyof Limits, fallback: number) {
const limits = await getLimits(orgId);
if (!limits) return fallback;
const result = limits[limit];
if (!result) return fallback;
if (typeof result === "number") return result;
if (typeof result === "object" && "number" in result) return result.number;
return fallback;
}
export async function customerPortalUrl(orgId: string, orgSlug: string) {
const client = getClient();
if (!client) return undefined;
try {
return client.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
});
} catch (e) {
logger.error("Error getting customer portal Url", { orgId, error: e });
return undefined;
}
}
export async function getPlans() {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.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;
}
}
export async function setPlan(
organization: { id: string; slug: string },
request: Request,
callerPath: string,
plan: SetPlanBody
) {
const client = getClient();
if (!client) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
try {
const result = await client.setPlan(organization.id, plan);
if (!result) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
if (!result.success) {
throw redirectWithErrorMessage(callerPath, request, result.error);
}
switch (result.action) {
case "free_connect_required": {
return redirect(result.connectUrl);
}
case "free_connected": {
if (result.accepted) {
return redirect(newProjectPath(organization, "You're on the Free plan."));
} else {
return redirectWithErrorMessage(
callerPath,
request,
"Free tier unlock failed, your GitHub account is too new."
);
}
}
case "create_subscription_flow_start": {
return redirect(result.checkoutUrl);
}
case "updated_subscription": {
return redirectWithSuccessMessage(
callerPath,
request,
"Subscription updated successfully."
);
}
case "canceled_subscription": {
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
}
}
} catch (e) {
logger.error("Error setting plan", { organizationId: organization.id, error: e });
throw redirectWithErrorMessage(
callerPath,
request,
e instanceof Error ? e.message : "Error setting plan"
);
}
}
export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.usage(organizationId, { from, to });
if (!result.success) {
logger.error("Error getting usage", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting usage", { error: e });
return undefined;
}
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.usageSeries(organizationId, params);
if (!result.success) {
logger.error("Error getting usage series", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error getting usage series", { error: e });
return undefined;
}
}
export async function reportInvocationUsage(
organizationId: string,
costInCents: number,
additionalData?: Record<string, any>
) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.reportInvocationUsage({
organizationId,
costInCents,
additionalData,
});
if (!result.success) {
logger.error("Error reporting invocation", { error: result.error });
return undefined;
}
return result;
} catch (e) {
logger.error("Error reporting invocation", { error: e });
return undefined;
}
}
export async function reportComputeUsage(request: Request) {
const client = getClient();
if (!client) return undefined;
return fetch(`${process.env.BILLING_API_URL}/api/v1/usage/ingest/compute`, {
method: "POST",
headers: request.headers,
body: await request.text(),
});
}
export async function getEntitlement(organizationId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.getEntitlement(organizationId);
if (!result.success) {
logger.error("Error getting entitlement", { error: result.error });
return {
hasAccess: true as const,
};
}
return result;
} catch (e) {
logger.error("Error getting entitlement", { error: e });
return {
hasAccess: true as const,
};
}
}
export async function projectCreated(organization: Organization, project: Project) {
if (project.version === "V2" || !isCloud()) {
await createEnvironment(organization, project, "STAGING");
} else {
//staging is only available on certain plans
const plan = await getCurrentPlan(organization.id);
if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) {
await createEnvironment(organization, project, "STAGING");
}
}
}
function getClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
return client;
} else {
console.log(`Billing client not initialized`);
}
}
function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
"https://test-cloud.trigger.dev",
"https://internal.trigger.dev",
];
if (acceptableHosts.includes(env.LOGIN_ORIGIN)) {
return true;
}
if (process.env.CLOUD_ENV === "development" && process.env.NODE_ENV === "development") {
return true;
}
return false;
}
+6 -10
View File
@@ -7,7 +7,6 @@ import { ZodWorker } from "~/platform/zodWorker.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { MarqsConcurrencyMonitor } from "~/v3/marqs/concurrencyMonitor.server";
import { RequeueV2Message } from "~/v3/marqs/requeueV2Message.server";
import { reportUsageEvent } from "~/v3/openMeter.server";
import { RequeueTaskRunService } from "~/v3/requeueTaskRun.server";
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
@@ -36,6 +35,7 @@ import { DeliverEventService } from "./events/deliverEvent.server";
import { InvokeDispatcherService } from "./events/invokeDispatcher.server";
import { integrationAuthRepository } from "./externalApis/integrationAuthRepository.server";
import { IntegrationConnectionCreatedService } from "./externalApis/integrationConnectionCreated.server";
import { reportInvocationUsage } from "./platform.v3.server";
import { executionRateLimiter } from "./runExecutionRateLimiter.server";
import { DeliverRunSubscriptionService } from "./runs/deliverRunSubscription.server";
import { DeliverRunSubscriptionsService } from "./runs/deliverRunSubscriptions.server";
@@ -673,15 +673,11 @@ function getWorkerQueue() {
priority: 0,
maxAttempts: 8,
handler: async (payload, job) => {
await reportUsageEvent({
source: "webapp",
type: "usage",
subject: payload.orgId,
data: {
costInCents: payload.data.costInCents,
...payload.additionalData,
},
});
await reportInvocationUsage(
payload.orgId,
Number(payload.data.costInCents),
payload.additionalData
);
},
},
"v3.enqueueDelayedRun": {
+17
View File
@@ -13,4 +13,21 @@
@apply bg-background-dimmed text-text-dimmed;
font-feature-settings: "rlig" 1, "calt" 1;
}
/* shadcn charts: https://ui.shadcn.com/docs/components/chart#add-a-grid */
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
+48
View File
@@ -0,0 +1,48 @@
type Options<R> = {
startDate: Date;
endDate: Date;
window?: "MINUTE" | "HOUR" | "DAY";
data: { date: Date; value?: R }[];
};
export function createTimeSeriesData<R>({ startDate, endDate, window = "DAY", data }: Options<R>) {
const outputData: Array<{ date: Date; value?: R }> = [];
const periodLength = periodLengthMs(window);
const periodCount = Math.floor((endDate.getTime() - startDate.getTime()) / periodLength);
for (let i = 0; i < periodCount; i++) {
const periodStart = new Date(startDate);
periodStart.setTime(periodStart.getTime() + i * periodLength);
const periodEnd = new Date(startDate);
periodEnd.setTime(periodEnd.getTime() + (i + 1) * periodLength);
const foundData = data.find((d) => {
const time = d.date.getTime();
const inRange = time >= periodStart.getTime() && time < periodEnd.getTime();
return inRange;
});
if (!foundData) {
outputData.push({
date: periodStart,
});
} else {
outputData.push({
date: periodStart,
value: foundData.value,
});
}
}
return outputData;
}
function periodLengthMs(window: "MINUTE" | "HOUR" | "DAY") {
switch (window) {
case "MINUTE":
return 60_000;
case "HOUR":
return 3_600_000;
case "DAY":
return 86_400_000;
}
}
+12
View File
@@ -25,3 +25,15 @@ const currencyFormatter = Intl.NumberFormat("en-US", {
export const formatCurrency = (num: number, rounded: boolean): string => {
return rounded ? roundedCurrencyFormatter.format(num) : currencyFormatter.format(num);
};
const accurateCurrencyFormatter = Intl.NumberFormat("en-US", {
style: "currency",
currencyDisplay: "symbol",
minimumFractionDigits: 8,
maximumFractionDigits: 8,
currency: "USD",
});
export function formatCurrencyAccurate(num: number): string {
return accurateCurrencyFormatter.format(num);
}
+16 -2
View File
@@ -296,8 +296,10 @@ export function endpointStreamingPath(environment: { id: string }) {
return `/resources/environments/${environment.id}/endpoint/stream`;
}
export function newProjectPath(organization: OrgForPath) {
return `${organizationPath(organization)}/projects/new`;
export function newProjectPath(organization: OrgForPath, message?: string) {
return `${organizationPath(organization)}/projects/new${
message ? `?message=${encodeURIComponent(message)}` : ""
}`;
}
function projectParam(project: ProjectForPath) {
@@ -445,6 +447,18 @@ export function v3DeploymentPath(
return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}`;
}
export function v3BillingPath(organization: OrgForPath) {
return `${organizationPath(organization)}/v3/billing`;
}
export function v3StripePortalPath(organization: OrgForPath) {
return `/resources/${organization.slug}/subscription/v3/portal`;
}
export function v3UsagePath(organization: OrgForPath) {
return `${organizationPath(organization)}/v3/usage`;
}
// Integration
export function integrationClientPath(organization: OrgForPath, client: IntegrationForPath) {
return `${organizationIntegrationsPath(organization)}/${clientParam(client)}`;
+5 -43
View File
@@ -1,45 +1,7 @@
import { MachineConfig, MachinePreset, MachinePresetName } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { defaultMachine, machines } from "@trigger.dev/platform/v3";
import { logger } from "~/services/logger.server";
export const presets = {
micro: {
cpu: 0.25,
memory: 0.25,
centsPerMs: env.CENTS_PER_HOUR_MICRO / 3_600_000,
},
"small-1x": {
cpu: 0.5,
memory: 0.5,
centsPerMs: env.CENTS_PER_HOUR_SMALL_1X / 3_600_000,
},
"small-2x": {
cpu: 1,
memory: 1,
centsPerMs: env.CENTS_PER_HOUR_SMALL_2X / 3_600_000,
},
"medium-1x": {
cpu: 1,
memory: 2,
centsPerMs: env.CENTS_PER_HOUR_MEDIUM_1X / 3_600_000,
},
"medium-2x": {
cpu: 2,
memory: 4,
centsPerMs: env.CENTS_PER_HOUR_MEDIUM_2X / 3_600_000,
},
"large-1x": {
cpu: 4,
memory: 8,
centsPerMs: env.CENTS_PER_HOUR_LARGE_1X / 3_600_000,
},
"large-2x": {
cpu: 8,
memory: 16,
centsPerMs: env.CENTS_PER_HOUR_LARGE_2X / 3_600_000,
},
};
export function machinePresetFromConfig(config: unknown): MachinePreset {
const parsedConfig = MachineConfig.safeParse(config);
@@ -62,20 +24,20 @@ export function machinePresetFromConfig(config: unknown): MachinePreset {
return machinePresetFromName("small-1x");
}
export function machinePresetFromName(name: MachinePresetName): MachinePreset {
function machinePresetFromName(name: MachinePresetName): MachinePreset {
return {
name,
...presets[name],
...machines[name],
};
}
// Finds the smallest machine preset name that satisfies the given CPU and memory requirements
function derivePresetNameFromValues(cpu: number, memory: number): MachinePresetName {
for (const [name, preset] of Object.entries(presets)) {
for (const [name, preset] of Object.entries(machines)) {
if (preset.cpu >= cpu && preset.memory >= memory) {
return name as MachinePresetName;
}
}
return "small-1x";
return defaultMachine;
}
+80 -13
View File
@@ -231,7 +231,7 @@ export class MarQS {
const messageQueue = await this.#getRandomQueueFromParentQueue(
parentQueue,
this.options.envQueuePriorityStrategy,
(queue) => this.#calculateMessageQueueCapacities(queue),
(queue) => this.#calculateMessageQueueCapacities(queue, { checkForDisabled: false }),
env.id
);
@@ -345,7 +345,7 @@ export class MarQS {
const messageQueue = await this.#getRandomQueueFromParentQueue(
parentQueue,
this.options.queuePriorityStrategy,
(queue) => this.#calculateMessageQueueCapacities(queue),
(queue) => this.#calculateMessageQueueCapacities(queue, { checkForDisabled: true }),
consumerId
);
@@ -737,7 +737,7 @@ export class MarQS {
return queueScores;
}
async #calculateMessageQueueCapacities(queue: string) {
async #calculateMessageQueueCapacities(queue: string, options?: { checkForDisabled?: boolean }) {
return await this.#callCalculateMessageCapacities({
currentConcurrencyKey: this.keys.currentConcurrencyKeyFromQueue(queue),
currentEnvConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(queue),
@@ -745,6 +745,9 @@ export class MarQS {
concurrencyLimitKey: this.keys.concurrencyLimitKeyFromQueue(queue),
envConcurrencyLimitKey: this.keys.envConcurrencyLimitKeyFromQueue(queue),
orgConcurrencyLimitKey: this.keys.orgConcurrencyLimitKeyFromQueue(queue),
disabledConcurrencyLimitKey: options?.checkForDisabled
? this.keys.disabledConcurrencyLimitKeyFromQueue(queue)
: undefined,
});
}
@@ -1111,6 +1114,7 @@ export class MarQS {
concurrencyLimitKey,
envConcurrencyLimitKey,
orgConcurrencyLimitKey,
disabledConcurrencyLimitKey,
}: {
currentConcurrencyKey: string;
currentEnvConcurrencyKey: string;
@@ -1118,17 +1122,30 @@ export class MarQS {
concurrencyLimitKey: string;
envConcurrencyLimitKey: string;
orgConcurrencyLimitKey: string;
disabledConcurrencyLimitKey: string | undefined;
}): Promise<QueueCapacities> {
const capacities = await this.redis.calculateMessageQueueCapacities(
currentConcurrencyKey,
currentEnvConcurrencyKey,
currentOrgConcurrencyKey,
concurrencyLimitKey,
envConcurrencyLimitKey,
orgConcurrencyLimitKey,
String(this.options.defaultEnvConcurrency),
String(this.options.defaultOrgConcurrency)
);
const capacities = disabledConcurrencyLimitKey
? await this.redis.calculateMessageQueueCapacitiesWithDisabling(
currentConcurrencyKey,
currentEnvConcurrencyKey,
currentOrgConcurrencyKey,
concurrencyLimitKey,
envConcurrencyLimitKey,
orgConcurrencyLimitKey,
disabledConcurrencyLimitKey,
String(this.options.defaultEnvConcurrency),
String(this.options.defaultOrgConcurrency)
)
: await this.redis.calculateMessageQueueCapacities(
currentConcurrencyKey,
currentEnvConcurrencyKey,
currentOrgConcurrencyKey,
concurrencyLimitKey,
envConcurrencyLimitKey,
orgConcurrencyLimitKey,
String(this.options.defaultEnvConcurrency),
String(this.options.defaultOrgConcurrency)
);
const queueCurrent = Number(capacities[0]);
const envLimit = Number(capacities[3]);
@@ -1428,6 +1445,43 @@ redis.call('ZADD', visibilityQueue, newVisibilityTimeout, messageId)
`,
});
this.redis.defineCommand("calculateMessageQueueCapacitiesWithDisabling", {
numberOfKeys: 7,
lua: `
-- Keys: currentConcurrencyKey, currentEnvConcurrencyKey, currentOrgConcurrencyKey, concurrencyLimitKey, envConcurrencyLimitKey, orgConcurrencyLimitKey, disabledConcurrencyLimitKey
local currentConcurrencyKey = KEYS[1]
local currentEnvConcurrencyKey = KEYS[2]
local currentOrgConcurrencyKey = KEYS[3]
local concurrencyLimitKey = KEYS[4]
local envConcurrencyLimitKey = KEYS[5]
local orgConcurrencyLimitKey = KEYS[6]
local disabledConcurrencyLimitKey = KEYS[7]
-- Args defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
local defaultEnvConcurrencyLimit = tonumber(ARGV[1])
local defaultOrgConcurrencyLimit = tonumber(ARGV[2])
local currentOrgConcurrency = tonumber(redis.call('SCARD', currentOrgConcurrencyKey) or '0')
-- Check if disabledConcurrencyLimitKey exists
local orgConcurrencyLimit
if redis.call('EXISTS', disabledConcurrencyLimitKey) == 1 then
orgConcurrencyLimit = 0
else
orgConcurrencyLimit = tonumber(redis.call('GET', orgConcurrencyLimitKey) or defaultOrgConcurrencyLimit)
end
local currentEnvConcurrency = tonumber(redis.call('SCARD', currentEnvConcurrencyKey) or '0')
local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit)
local currentConcurrency = tonumber(redis.call('SCARD', currentConcurrencyKey) or '0')
local concurrencyLimit = redis.call('GET', concurrencyLimitKey)
-- Return current capacity and concurrency limits for the queue, env, org
return { currentConcurrency, concurrencyLimit, currentEnvConcurrency, envConcurrencyLimit, currentOrgConcurrency, orgConcurrencyLimit }
`,
});
this.redis.defineCommand("calculateMessageQueueCapacities", {
numberOfKeys: 6,
lua: `
@@ -1589,6 +1643,19 @@ declare module "ioredis" {
callback?: Callback<number[]>
): Result<number[], Context>;
calculateMessageQueueCapacitiesWithDisabling(
currentConcurrencyKey: string,
currentEnvConcurrencyKey: string,
currentOrgConcurrencyKey: string,
concurrencyLimitKey: string,
envConcurrencyLimitKey: string,
orgConcurrencyLimitKey: string,
disabledConcurrencyLimitKey: string,
defaultEnvConcurrencyLimit: string,
defaultOrgConcurrencyLimit: string,
callback?: Callback<number[]>
): Result<number[], Context>;
updateGlobalConcurrencyLimits(
envConcurrencyLimitKey: string,
orgConcurrencyLimitKey: string,
@@ -5,6 +5,7 @@ const constants = {
SHARED_QUEUE: "sharedQueue",
CURRENT_CONCURRENCY_PART: "currentConcurrency",
CONCURRENCY_LIMIT_PART: "concurrency",
DISABLED_CONCURRENCY_LIMIT_PART: "disabledConcurrency",
ENV_PART: "env",
ORG_PART: "org",
QUEUE_PART: "queue",
@@ -13,7 +14,7 @@ const constants = {
} as const;
export class MarQSShortKeyProducer implements MarQSKeyProducer {
constructor(private _prefix: string) { }
constructor(private _prefix: string) {}
sharedQueueScanPattern() {
return `${this._prefix}*${constants.SHARED_QUEUE}`;
@@ -89,6 +90,12 @@ export class MarQSShortKeyProducer implements MarQSKeyProducer {
);
}
disabledConcurrencyLimitKeyFromQueue(queue: string) {
const orgId = this.normalizeQueue(queue).split(":")[1];
return `${constants.ORG_PART}:${orgId}:${constants.DISABLED_CONCURRENCY_LIMIT_PART}`;
}
orgConcurrencyLimitKeyFromQueue(queue: string) {
const orgId = this.normalizeQueue(queue).split(":")[1];
@@ -413,7 +413,7 @@ export class SharedQueueConsumer {
lockedById: backgroundTask.id,
lockedToVersionId: deployment.worker.id,
startedAt: existingTaskRun.startedAt ?? new Date(),
baseCostInCents: env.BASE_RUN_COST_IN_CENTS,
baseCostInCents: env.CENTS_PER_RUN,
machinePreset: machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name,
},
include: {
+1
View File
@@ -37,6 +37,7 @@ export interface MarQSKeyProducer {
queue: string,
concurrencyKey?: string
): string;
disabledConcurrencyLimitKeyFromQueue(queue: string): string;
orgConcurrencyLimitKeyFromQueue(queue: string): string;
orgCurrentConcurrencyKeyFromQueue(queue: string): string;
envConcurrencyLimitKeyFromQueue(queue: string): string;
-47
View File
@@ -1,47 +0,0 @@
import { randomUUID } from "node:crypto";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
export type UsageEvent = {
source: string;
subject: string;
type: string;
id?: string;
time?: Date;
data?: Record<string, unknown>;
};
export async function reportUsageEvent(event: UsageEvent) {
if (!env.USAGE_OPEN_METER_BASE_URL || !env.USAGE_OPEN_METER_API_KEY) {
return;
}
const body = {
specversion: "1.0",
id: event.id ?? randomUUID(),
source: event.source,
type: event.type,
time: (event.time ?? new Date()).toISOString(),
subject: event.subject,
datacontenttype: "application/json",
data: event.data,
};
const url = `${env.USAGE_OPEN_METER_BASE_URL}/api/v1/events`;
logger.debug("Reporting usage event to OpenMeter", { url, body });
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/cloudevents+json",
Authorization: `Bearer ${env.USAGE_OPEN_METER_API_KEY}`,
Accept: "application/json",
},
});
if (!response.ok) {
logger.error(`Failed to report usage event: ${response.status} ${response.statusText}`);
}
}
@@ -13,6 +13,7 @@ import { eventRepository } from "../eventRepository.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { uploadToObjectStore } from "../r2.server";
import { startActiveSpan } from "../tracer.server";
import { getEntitlement } from "~/services/platform.v3.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
@@ -27,6 +28,12 @@ export type TriggerTaskServiceOptions = {
customIcon?: string;
};
export class OutOfEntitlementError extends Error {
constructor() {
super("You can't trigger a task because you have run out of credits.");
}
}
export class TriggerTaskService extends BaseService {
public async call(
taskId: string,
@@ -63,6 +70,13 @@ export class TriggerTaskService extends BaseService {
return existingRun;
}
if (environment.type !== "DEVELOPMENT") {
const result = await getEntitlement(environment.organizationId);
if (result && result.hasAccess === false) {
throw new OutOfEntitlementError();
}
}
const runFriendlyId = generateFriendlyId("run");
const payloadPacket = await this.#handlePayloadPacket(
@@ -10,6 +10,7 @@ import cronstrue from "cronstrue";
import { calculateNextScheduledTimestamp } from "../utils/calculateNextSchedule.server";
import { getTimezones } from "~/utils/timezones.server";
import { env } from "~/env.server";
import { getLimit } from "~/services/platform.v3.server";
export type UpsertTaskScheduleServiceOptions = UpsertSchedule;
@@ -67,32 +68,29 @@ export class UpsertTaskScheduleService extends BaseService {
//if creating a schedule, check they're under the limits
if (!schedule.friendlyId) {
//check they're within their limit
const limits = await this._prisma.organization.findFirst({
select: {
maximumSchedulesLimit: true,
},
const project = await this._prisma.project.findFirst({
where: {
projects: {
some: {
id: projectId,
},
},
id: projectId,
},
select: {
organizationId: true,
},
});
if (!limits) {
throw new ServiceValidationError("Organization not found");
if (!project) {
throw new ServiceValidationError("Project not found");
}
const limit = await getLimit(project.organizationId, "schedules", 500);
const schedulesCount = await this._prisma.taskSchedule.count({
where: {
projectId,
},
});
if (schedulesCount >= limits.maximumSchedulesLimit) {
if (schedulesCount >= limit) {
throw new ServiceValidationError(
`You have created ${schedulesCount}/${limits.maximumSchedulesLimit} schedules so you'll need to increase your limits or delete some schedules. Increase your limits by contacting support.`
`You have created ${schedulesCount}/${limit} schedules so you'll need to increase your limits or delete some schedules. Increase your limits by contacting support.`
);
}
}
+2 -2
View File
@@ -91,7 +91,7 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-virtual": "^3.0.4",
"@team-plain/typescript-sdk": "^3.5.0",
"@trigger.dev/billing": "^1.0.10",
"@trigger.dev/platform": "1.0.12",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-backend": "workspace:*",
@@ -239,4 +239,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ module.exports = {
"@trigger.dev/core",
"@trigger.dev/core-backend",
"@trigger.dev/sdk",
"@trigger.dev/billing",
"@trigger.dev/platform",
"@trigger.dev/yalt",
"emails",
"highlight.run",
+1 -1
View File
@@ -146,7 +146,7 @@ const pending = colors.blue[500];
const warning = colors.amber[500];
const error = colors.rose[600];
const devEnv = colors.pink[500];
const stagingEnv = sun[400];
const stagingEnv = colors.amber[400];
const prodEnv = mint[500];
/** Other variables */
-5
View File
@@ -3,11 +3,6 @@ title: "Introduction"
description: "Welcome to the Trigger.dev v3 documentation."
---
<Warning>
The Trigger.dev v3 Cloud Developer Preview is currently in invite-only early access. [Sign up here
to request access](https://trigger.dev/v3-early-access).
</Warning>
## What is Trigger.dev (v3)?
Trigger.dev v3 makes it easy to write reliable long-running tasks without timeouts.
+2 -12
View File
@@ -3,9 +3,7 @@ title: "Migrating from Defer.run"
description: "A guide to migrating from Defer to Trigger.dev v3"
---
First of all, you should use the [Trigger.dev v3 Developer Preview](https://trigger.dev/blog/v3-developer-preview-launch/), not version 2. Version 2 is not a good choice if you're coming from Defer as the execution model is very different. [Let us know](https://) if you need v3 access.
This guide highlights the differences and should help you migrate your project.
This guide highlights the differences between Defer and Trigger.dev and should help you migrate your project.
## Features coming very soon
@@ -151,22 +149,14 @@ We will grant you v3 access.
<Step title="Create a v3 project">
1. Go to the Projects page
![Click the Projects page](/images/v3/sidemenu-projects.png)
2. Click "Create a new project"
![Click "Create a new project"](/images/v3/projects-new-button.png)
3. Make sure you select "Version 3" from the dropdown!
3. If the "Project version" dropdown is visible, make sure you select "Version 3"!
![Select "Version 3"](/images/v3/projects-new-v3.png)
<Warning>
If you don't see a dropdown then you don't have v3 access. [Fill in this
form](https://trigger.dev/v3-early-access) and mention Defer in the company name.
</Warning>
</Step>
<Snippet file="v3/step-cli-init.mdx" />
-5
View File
@@ -27,11 +27,6 @@ Once you've created an account, follow the steps in the app to:
1. Complete your account details.
2. Create your first Organization and Project.
<Warning>
Make sure you create a "Version 3" project. You might need to [request early
access](https://trigger.dev/v3-early-access).
</Warning>
</Step>
<Snippet file="v3/step-cli-init.mdx" />
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "TaskRun_scheduleId_idx" ON "TaskRun"("scheduleId");
+3
View File
@@ -111,6 +111,7 @@ model Organization {
maximumExecutionTimePerRunInMs Int @default(900000) // 15 minutes
maximumConcurrencyLimit Int @default(10)
/// This is deprecated and will be removed in the future
maximumSchedulesLimit Int @default(5)
createdAt DateTime @default(now())
@@ -1687,6 +1688,8 @@ model TaskRun {
@@index([projectId, taskIdentifier])
@@index([projectId, status])
@@index([projectId, taskIdentifier, status])
//Schedules
@@index([scheduleId])
}
enum TaskRunStatus {
+9 -9
View File
@@ -375,9 +375,6 @@ importers:
'@team-plain/typescript-sdk':
specifier: ^3.5.0
version: 3.5.0
'@trigger.dev/billing':
specifier: ^1.0.10
version: 1.0.10
'@trigger.dev/companyicons':
specifier: ^1.5.35
version: 1.5.35(react-dom@18.2.0)(react@18.2.0)
@@ -393,6 +390,9 @@ importers:
'@trigger.dev/otlp-importer':
specifier: workspace:*
version: link:../../packages/otlp-importer
'@trigger.dev/platform':
specifier: 1.0.12
version: 1.0.12
'@trigger.dev/sdk':
specifier: workspace:*
version: link:../../packages/trigger-sdk
@@ -15457,12 +15457,6 @@ packages:
- supports-color
dev: false
/@trigger.dev/billing@1.0.10:
resolution: {integrity: sha512-vkPd1UD0fRji4qFP+s2pP26oztYzyQezQqT4+12I/5+7pOKFbOr2j0tVZj/sf7BTWgRE1Ybsk4CKT0gc9yXfKA==}
dependencies:
zod: 3.22.3
dev: false
/@trigger.dev/companyicons@1.5.35(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-AhY7yshwh0onlgB6EGiyjyLSzl38Cuxo4tpUJVHxs5im8gDA+fuUq7o6Vz1WetFeNXwjMqh3f+bPW7bfqR4epg==}
peerDependencies:
@@ -15496,6 +15490,12 @@ packages:
- supports-color
dev: false
/@trigger.dev/platform@1.0.12:
resolution: {integrity: sha512-pOB9S2XG+TmTdlYtF9xuQoFz1aRlRGWSVCXuv3Vj2ArzU6Zz3ukvK24qlTd0PJB1otX71Ho/NfViGKEzCyRb5Q==}
dependencies:
zod: 3.22.3
dev: false
/@trigger.dev/sdk@2.0.7:
resolution: {integrity: sha512-40wpHx38opv2roJDz7CP4ZJYp6nMYJWLgehVjCqcvQWagcE00D6mTu3TZyFwBU8C/fy0QmdnnLehhruZMnpGpA==}
engines: {node: '>=16.8.0'}