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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
+1
-1
@@ -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",
|
||||
+2
-2
@@ -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">
|
||||
+4
-4
@@ -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";
|
||||
|
||||
+6
-6
@@ -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: {
|
||||
+3
-3
@@ -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({
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
+44
@@ -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>
|
||||
}
|
||||
|
||||
+42
-19
@@ -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 />
|
||||
|
||||
+65
-149
@@ -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>
|
||||
|
||||
+21
-9
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+79
-25
@@ -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`}
|
||||
|
||||
+47
-20
@@ -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>
|
||||
}
|
||||
|
||||
+12
-13
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+89
-43
@@ -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 });
|
||||
}
|
||||
@@ -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">
|
||||
We’ve received your request for v3 and we’ll notify you as soon as you have access.
|
||||
We’re granting new users access every day so you won’t 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 we’re operating a waitlist as we
|
||||
focus on the platform’s 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() {
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
2. Click "Create a new project"
|
||||
|
||||

|
||||
|
||||
3. Make sure you select "Version 3" from the dropdown!
|
||||
|
||||
3. If the "Project version" dropdown is visible, make sure you select "Version 3"!
|
||||

|
||||
|
||||
<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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_scheduleId_idx" ON "TaskRun"("scheduleId");
|
||||
@@ -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 {
|
||||
|
||||
Generated
+9
-9
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user