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