Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a68e71583 | |||
| b657eb6555 | |||
| 62c9a5b712 | |||
| f339b41ef3 | |||
| ae40ce3995 | |||
| 374edef020 | |||
| b82db67b81 | |||
| 26093896d2 | |||
| e7bd1ee676 | |||
| 584c7da5df | |||
| c9e1a3e9c5 | |||
| 2f5b4a8471 | |||
| 69f6891687 | |||
| acd7681e58 | |||
| 180a5ef01d | |||
| 62544d3234 | |||
| 9b045071d8 | |||
| 618d2207c6 | |||
| 36d8bee14a | |||
| 44e1b87547 | |||
| 71b0ef8f77 | |||
| f66543c9c7 | |||
| 03b104a3d5 | |||
| fde939a30e | |||
| 4986bfda2e | |||
| a3abe4ca08 | |||
| fad32a79dd | |||
| 9d843caf83 | |||
| 5eaf7bca34 | |||
| 4a0368dec3 | |||
| f10f120e55 | |||
| d3a18fbdf6 | |||
| b82a07ad1c |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add option to print console logs in the dev CLI locally (issue #1014)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Export queue from the SDK
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve the SDK function types and expose a new APIError instead of the APIResult type
|
||||
+16
-1
@@ -49,10 +49,12 @@
|
||||
"clean-pianos-listen",
|
||||
"cool-glasses-bake",
|
||||
"cuddly-feet-approve",
|
||||
"dry-walls-check",
|
||||
"eight-pumas-float",
|
||||
"few-students-share",
|
||||
"green-bags-wink",
|
||||
"khaki-apricots-design",
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
"late-steaks-behave",
|
||||
"lemon-jobs-repair",
|
||||
@@ -62,20 +64,33 @@
|
||||
"many-ligers-pump",
|
||||
"mighty-camels-joke",
|
||||
"new-rivers-tell",
|
||||
"ninety-pets-travel",
|
||||
"odd-poets-own",
|
||||
"polite-ducks-switch",
|
||||
"poor-flowers-cross",
|
||||
"rare-roses-float",
|
||||
"real-planets-stare",
|
||||
"rotten-dryers-exercise",
|
||||
"shaggy-spoons-taste",
|
||||
"sharp-emus-compare",
|
||||
"sharp-zebras-serve",
|
||||
"shiny-coats-cry",
|
||||
"silly-suits-switch",
|
||||
"smart-needles-move",
|
||||
"smart-olives-eat",
|
||||
"spicy-lamps-smoke",
|
||||
"strange-ghosts-matter",
|
||||
"strong-lemons-add",
|
||||
"stupid-bulldogs-applaud",
|
||||
"sweet-lizards-press",
|
||||
"swift-dragons-peel",
|
||||
"tall-bees-wave",
|
||||
"tame-guests-know",
|
||||
"tender-oranges-rhyme",
|
||||
"tiny-doors-type"
|
||||
"tidy-balloons-suffer",
|
||||
"tidy-dryers-sleep",
|
||||
"tiny-doors-type",
|
||||
"tiny-elephants-scream",
|
||||
"tricky-bulldogs-heal"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Adding task with a triggerSource of schedule
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Updates the `trigger`, `batchTrigger` and their `*AndWait` variants to use the first parameter for the payload/items, and the second parameter for options.
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
await yourTask.trigger({ payload: { foo: "bar" }, options: { idempotencyKey: "key_1234" } });
|
||||
await yourTask.triggerAndWait({ payload: { foo: "bar" }, options: { idempotencyKey: "key_1234" } });
|
||||
|
||||
await yourTask.batchTrigger({ items: [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }] });
|
||||
await yourTask.batchTriggerAndWait({ items: [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }] });
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
await yourTask.trigger({ foo: "bar" }, { idempotencyKey: "key_1234" });
|
||||
await yourTask.triggerAndWait({ foo: "bar" }, { idempotencyKey: "key_1234" });
|
||||
|
||||
await yourTask.batchTrigger([{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }]);
|
||||
await yourTask.batchTriggerAndWait([{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }]);
|
||||
```
|
||||
|
||||
We've also changed the API of the `triggerAndWait` result. Before, if the subtask that was triggered finished with an error, we would automatically "rethrow" the error in the parent task.
|
||||
|
||||
Now instead we're returning a `TaskRunResult` object that allows you to discriminate between successful and failed runs in the subtask:
|
||||
|
||||
Before:
|
||||
|
||||
```ts
|
||||
try {
|
||||
const result = await yourTask.triggerAndWait({ foo: "bar" });
|
||||
|
||||
// result is the output of your task
|
||||
console.log("result", result);
|
||||
|
||||
} catch (error) {
|
||||
// handle subtask errors here
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
const result = await yourTask.triggerAndWait({ foo: "bar" });
|
||||
|
||||
if (result.ok) {
|
||||
console.log(`Run ${result.id} succeeded with output`, result.output);
|
||||
} else {
|
||||
console.log(`Run ${result.id} failed with error`, result.error);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
When using idempotency keys, triggerAndWait and batchTriggerAndWait will still work even if the existing runs have already been completed (or even partially completed, in the case of batchTriggerAndWait)
|
||||
|
||||
- TaskRunExecutionResult.id is now the run friendlyId, not the attempt friendlyId
|
||||
- A single TaskRun can now have many batchItems, in the case of batchTriggerAndWait while using idempotency keys
|
||||
- A run’s idempotencyKey is now added to the ctx as well as the TaskEvent and displayed in the span view
|
||||
- When resolving batchTriggerAndWait, the runtimes no longer reject promises, leading to an error in the parent task
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add additional logging around cleaning up dev workers, and always kill them after 5 seconds if they haven't already exited
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/otlp-importer": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix package builds and CLI commands on Windows
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Remove unimplemented batchOptions
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Make optional schedule object fields nullish
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added JSDocs to the schedule SDK types
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Init command was failing on Windows because of bad template paths
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fixes an issue that caused failed tasks when resuming after calling `triggerAndWait` or `batchTriggerAndWait` in prod/staging (this doesn't effect dev).
|
||||
|
||||
The version of Node.js we use for deployed workers (latest 20) would crash with an out-of-memory error when the checkpoint was restored. This crash does not happen on Node 18x or Node21x, so we've decided to upgrade the worker version to Node.js21x, to mitigate this issue.
|
||||
|
||||
You'll need to re-deploy to production to fix the issue.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Add graceful exit for prod workers
|
||||
- Prevent overflow in long waits
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added a new global - Task Catalog - to better handle task metadata
|
||||
@@ -257,6 +257,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
return await execa("docker", [
|
||||
"exec",
|
||||
containerName,
|
||||
"busybox",
|
||||
"wget",
|
||||
"-q",
|
||||
"-O-",
|
||||
|
||||
@@ -133,6 +133,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
},
|
||||
spec: {
|
||||
...this.#defaultPodSpec,
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
name: this.#getRunContainerName(opts.runId),
|
||||
@@ -409,7 +410,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
`for i in $(seq ${retries}); do sleep 1; busybox wget -q -O- 127.0.0.1:8000/${type}?cause=${cause} && break; done`,
|
||||
];
|
||||
|
||||
logger.log("getLifecycleCommand()", { exec });
|
||||
logger.debug("getLifecycleCommand()", { exec });
|
||||
|
||||
return exec;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export function AISparkleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.9806 0.803884C14.8871 0.33646 14.4767 0 14 0C13.5233 0 13.1129 0.33646 13.0194 0.803884L12.7809 1.99644C12.7017 2.3923 12.3923 2.70174 11.9964 2.78091L10.8039 3.01942C10.3365 3.1129 10 3.52332 10 4C10 4.47668 10.3365 4.8871 10.8039 4.98058L11.9964 5.21909C12.3923 5.29826 12.7017 5.6077 12.7809 6.00356L13.0194 7.19612C13.1129 7.66354 13.5233 8 14 8C14.4767 8 14.8871 7.66354 14.9806 7.19612L15.2191 6.00356C15.2983 5.6077 15.6077 5.29826 16.0036 5.21909L17.1961 4.98058C17.6635 4.8871 18 4.47668 18 4C18 3.52332 17.6635 3.1129 17.1961 3.01942L16.0036 2.78091C15.6077 2.70174 15.2983 2.3923 15.2191 1.99644L14.9806 0.803884Z"
|
||||
fill="url(#paint0_linear_11402_36656)"
|
||||
/>
|
||||
<path
|
||||
d="M5.94868 4.68377C5.81257 4.27543 5.43043 4 5 4C4.56957 4 4.18743 4.27543 4.05132 4.68377L3.36754 6.73509C3.26801 7.03369 3.03369 7.26801 2.73509 7.36754L0.683772 8.05132C0.27543 8.18743 0 8.56957 0 9C0 9.43043 0.27543 9.81257 0.683772 9.94868L2.73509 10.6325C3.03369 10.732 3.26801 10.9663 3.36754 11.2649L4.05132 13.3162C4.18743 13.7246 4.56957 14 5 14C5.43043 14 5.81257 13.7246 5.94868 13.3162L6.63246 11.2649C6.73199 10.9663 6.96631 10.732 7.26491 10.6325L9.31623 9.94868C9.72457 9.81257 10 9.43043 10 9C10 8.56957 9.72457 8.18743 9.31623 8.05132L7.26491 7.36754C6.96631 7.26801 6.73199 7.03369 6.63246 6.73509L5.94868 4.68377Z"
|
||||
fill="url(#paint1_linear_11402_36656)"
|
||||
/>
|
||||
<path
|
||||
d="M12.9487 12.6838C12.8126 12.2754 12.4304 12 12 12C11.5696 12 11.1874 12.2754 11.0513 12.6838L10.8675 13.2351C10.768 13.5337 10.5337 13.768 10.2351 13.8675L9.68377 14.0513C9.27543 14.1874 9 14.5696 9 15C9 15.4304 9.27543 15.8126 9.68377 15.9487L10.2351 16.1325C10.5337 16.232 10.768 16.4663 10.8675 16.7649L11.0513 17.3162C11.1874 17.7246 11.5696 18 12 18C12.4304 18 12.8126 17.7246 12.9487 17.3162L13.1325 16.7649C13.232 16.4663 13.4663 16.232 13.7649 16.1325L14.3162 15.9487C14.7246 15.8126 15 15.4304 15 15C15 14.5696 14.7246 14.1874 14.3162 14.0513L13.7649 13.8675C13.4663 13.768 13.232 13.5337 13.1325 13.2351L12.9487 12.6838Z"
|
||||
fill="url(#paint2_linear_11402_36656)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_11402_36656"
|
||||
x1="9"
|
||||
y1="0"
|
||||
x2="9"
|
||||
y2="18"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#E543FF" />
|
||||
<stop offset="1" stopColor="#286399" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_11402_36656"
|
||||
x1="9"
|
||||
y1="0"
|
||||
x2="9"
|
||||
y2="18"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#E543FF" />
|
||||
<stop offset="1" stopColor="#286399" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_11402_36656"
|
||||
x1="9"
|
||||
y1="0"
|
||||
x2="9"
|
||||
y2="18"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#E543FF" />
|
||||
<stop offset="1" stopColor="#286399" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CursorArrowRaysIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
v3TestPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
@@ -571,6 +573,13 @@ function V3ProjectSideMenu({
|
||||
to={v3TestPath(organization, project)}
|
||||
data-action="test"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
iconColor="text-sun-500"
|
||||
to={v3SchedulesPath(organization, project)}
|
||||
data-action="schedules"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
|
||||
@@ -286,8 +286,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> &
|
||||
React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -295,6 +294,7 @@ export const LinkButton = ({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -309,6 +309,19 @@ export const LinkButton = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group pointer-events-none cursor-default opacity-40 outline-none",
|
||||
props.fullWidth ? "w-full" : ""
|
||||
)}
|
||||
>
|
||||
<ButtonContent {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (to.toString().startsWith("http") || to.toString().startsWith("/resources")) {
|
||||
return (
|
||||
<ExtLink
|
||||
|
||||
@@ -23,7 +23,7 @@ const variants = {
|
||||
},
|
||||
"button/small": {
|
||||
button:
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
|
||||
label: "text-sm text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-0",
|
||||
@@ -32,7 +32,7 @@ const variants = {
|
||||
},
|
||||
button: {
|
||||
button:
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition",
|
||||
label: "text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
@@ -57,7 +57,7 @@ export type CheckboxProps = Omit<
|
||||
name?: string;
|
||||
value?: string;
|
||||
variant?: keyof typeof variants;
|
||||
label?: string;
|
||||
label?: React.ReactNode;
|
||||
description?: string;
|
||||
badges?: string[];
|
||||
className?: string;
|
||||
@@ -137,7 +137,7 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
className={cn(
|
||||
inputPositionClasses,
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-700 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
|
||||
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
|
||||
)}
|
||||
id={id}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BellAlertIcon } from "@heroicons/react/20/solid";
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
@@ -5,11 +6,25 @@ import { useDateFieldState } from "@react-stately/datepicker";
|
||||
import { Granularity } from "@react-types/datepicker";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
const variants = {
|
||||
small: {
|
||||
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
|
||||
nowButtonVariant: "tertiary/small" as const,
|
||||
clearButtonVariant: "minimal/small" as const,
|
||||
},
|
||||
medium: {
|
||||
fieldStyles: "h-7 text-base rounded px-1",
|
||||
nowButtonVariant: "tertiary/medium" as const,
|
||||
clearButtonVariant: "minimal/medium" as const,
|
||||
},
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variants;
|
||||
|
||||
type DateFieldProps = {
|
||||
label?: string;
|
||||
label: string;
|
||||
defaultValue?: Date;
|
||||
minValue?: Date;
|
||||
maxValue?: Date;
|
||||
@@ -20,6 +35,7 @@ type DateFieldProps = {
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
onValueChange?: (value: Date | undefined) => void;
|
||||
variant?: Variant;
|
||||
};
|
||||
|
||||
export function DateField({
|
||||
@@ -34,6 +50,7 @@ export function DateField({
|
||||
showGuide = false,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
variant = "small",
|
||||
}: DateFieldProps) {
|
||||
const [value, setValue] = useState<undefined | CalendarDateTime>(
|
||||
utcDateToCalendarDate(defaultValue)
|
||||
@@ -90,48 +107,50 @@ export function DateField({
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start ${className || ""}`}>
|
||||
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-charcoal-300">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="flex flex-row items-center gap-1" aria-label={label}>
|
||||
<div
|
||||
{...fieldProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex rounded-sm border border-charcoal-800 bg-charcoal-750 p-0.5 px-1.5 transition-colors focus-within:border-charcoal-500 hover:border-charcoal-700 focus-within:hover:border-charcoal-500",
|
||||
"flex rounded-sm border bg-charcoal-700 p-0.5 transition focus-within:border-charcoal-600 hover:border-charcoal-600",
|
||||
fieldClassName
|
||||
)}
|
||||
>
|
||||
<DateSegment segment={yearSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={monthSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={daySegment} state={state} />
|
||||
<DateSegment segment={literalSegment(", ")} state={state} />
|
||||
<DateSegment segment={hourSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={minuteSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={secondSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(" ")} state={state} />
|
||||
<DateSegment segment={dayPeriodSegment} state={state} />
|
||||
<DateSegment segment={yearSegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
|
||||
<DateSegment segment={monthSegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} variant={variant} />
|
||||
<DateSegment segment={daySegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment(", ")} state={state} variant={variant} />
|
||||
<DateSegment segment={hourSegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
|
||||
<DateSegment segment={minuteSegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} variant={variant} />
|
||||
<DateSegment segment={secondSegment} state={state} variant={variant} />
|
||||
<DateSegment segment={literalSegment(" ")} state={state} variant={variant} />
|
||||
<DateSegment segment={dayPeriodSegment} state={state} variant={variant} />
|
||||
</div>
|
||||
{showNowButton && (
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
type="button"
|
||||
variant={variants[variant].nowButtonVariant}
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setValue(utcDateToCalendarDate(new Date()));
|
||||
onValueChange?.(now);
|
||||
}}
|
||||
>
|
||||
Now
|
||||
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
type="button"
|
||||
variant={variants[variant].clearButtonVariant}
|
||||
LeadingIcon={"close"}
|
||||
leadingIconClassName="-mr-2"
|
||||
onClick={() => {
|
||||
setValue(undefined);
|
||||
onValueChange?.(undefined);
|
||||
@@ -142,7 +161,9 @@ export function DateField({
|
||||
state.clearSegment("minute");
|
||||
state.clearSegment("second");
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showGuide && (
|
||||
@@ -172,11 +193,13 @@ function utcDateToCalendarDate(date?: Date) {
|
||||
type DateSegmentProps = {
|
||||
segment: DateSegment;
|
||||
state: DateFieldState;
|
||||
variant: Variant;
|
||||
};
|
||||
|
||||
function DateSegment({ segment, state }: DateSegmentProps) {
|
||||
function DateSegment({ segment, state, variant }: DateSegmentProps) {
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { segmentProps } = useDateSegment(segment, state, ref);
|
||||
const sizeVariant = variants[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -186,23 +209,27 @@ function DateSegment({ segment, state }: DateSegmentProps) {
|
||||
...segmentProps.style,
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
|
||||
className={cn(
|
||||
"group box-content text-center tabular-nums outline-none focus:bg-charcoal-600 focus:text-text-bright",
|
||||
sizeVariant.fieldStyles,
|
||||
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block text-center italic text-charcoal-500 group-focus:text-white"
|
||||
className="flex h-full items-center justify-center text-center text-charcoal-500 group-focus:text-text-bright"
|
||||
style={{
|
||||
visibility: segment.isPlaceholder ? undefined : "hidden",
|
||||
height: segment.isPlaceholder ? "" : 0,
|
||||
height: segment.isPlaceholder ? undefined : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{segment.placeholder}
|
||||
</span>
|
||||
{segment.isPlaceholder ? "" : segment.text}
|
||||
<span className="flex h-full items-center justify-center">
|
||||
{segment.isPlaceholder ? "" : segment.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -231,7 +258,7 @@ function DateSegmentGuide({ segment }: { segment: DateSegment }) {
|
||||
style={{
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums text-rose-500 outline-none ${
|
||||
!segment.isEditable ? "text-charcoal-500" : "text-text-bright"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ type DateTimeProps = {
|
||||
|
||||
export const DateTime = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
timeZone,
|
||||
includeSeconds = true,
|
||||
includeTime = true,
|
||||
}: DateTimeProps) => {
|
||||
@@ -20,7 +20,7 @@ export const DateTime = ({
|
||||
|
||||
const initialFormattedDateTime = formatDateTime(
|
||||
realDate,
|
||||
timeZone,
|
||||
timeZone ?? "UTC",
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime
|
||||
@@ -32,7 +32,13 @@ export const DateTime = ({
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
|
||||
setFormattedDateTime(
|
||||
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
|
||||
formatDateTime(
|
||||
realDate,
|
||||
timeZone ?? resolvedOptions.timeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime
|
||||
)
|
||||
);
|
||||
}, [locales, includeSeconds, realDate]);
|
||||
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
|
||||
|
||||
const labelVariants = {
|
||||
const variants = {
|
||||
small: {
|
||||
text: "font-sans text-sm font-normal",
|
||||
text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1",
|
||||
},
|
||||
medium: {
|
||||
text: "font-sans text-sm leading-5 font-medium",
|
||||
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",
|
||||
},
|
||||
large: {
|
||||
text: "font-sans text-base leading-6 font-medium",
|
||||
text: "font-sans text-base font-medium text-text-bright leading-tight flex items-center gap-1",
|
||||
},
|
||||
};
|
||||
|
||||
type LabelProps = React.AllHTMLAttributes<HTMLLabelElement> & {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
variant?: keyof typeof labelVariants;
|
||||
variant?: keyof typeof variants;
|
||||
required?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Label({ className, children, variant = "medium", ...props }: LabelProps) {
|
||||
const variation = labelVariants[variant];
|
||||
export function Label({
|
||||
className,
|
||||
children,
|
||||
variant = "medium",
|
||||
required = true,
|
||||
tooltip,
|
||||
...props
|
||||
}: LabelProps) {
|
||||
const variation = variants[variant];
|
||||
return (
|
||||
<label className={cn(variation.text, className)} {...props}>
|
||||
{children}
|
||||
{tooltip ? <InfoIconTooltip content={tooltip} /> : null}
|
||||
{!required && <span className="text-text-dimmed"> (optional)</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { LinkDisabled } from "./LinkWithDisabled";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ButtonContent, LinkButton } from "./Buttons";
|
||||
import { ButtonContent } from "./Buttons";
|
||||
import { LinkDisabled } from "./LinkWithDisabled";
|
||||
|
||||
export function PaginationControls({
|
||||
currentPage,
|
||||
totalPages,
|
||||
showPageNumbers = true,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
showPageNumbers?: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
if (totalPages <= 1) {
|
||||
@@ -30,9 +32,11 @@ export function PaginationControls({
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
|
||||
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
|
||||
<PageLinkComponent page={page} key={i} location={location} />
|
||||
))}
|
||||
{showPageNumbers
|
||||
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
|
||||
<PageLinkComponent page={page} key={i} location={location} />
|
||||
))
|
||||
: null}
|
||||
|
||||
<LinkDisabled
|
||||
to={pageUrl(location, currentPage + 1)}
|
||||
|
||||
@@ -22,7 +22,7 @@ const variants = {
|
||||
},
|
||||
"button/small": {
|
||||
button:
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[disabled]:opacity-70 data-[disabled]:hover:bg-transparent data-[state=checked]:bg-charcoal-850",
|
||||
label: "text-sm text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-0",
|
||||
@@ -30,7 +30,7 @@ const variants = {
|
||||
},
|
||||
button: {
|
||||
button:
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-600 hover:bg-charcoal-850 hover:border-charcoal-500 transition data-[state=checked]:bg-charcoal-850 data-[disabled]:opacity-70",
|
||||
label: "text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
@@ -38,7 +38,7 @@ const variants = {
|
||||
},
|
||||
description: {
|
||||
button:
|
||||
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
|
||||
"w-full p-2.5 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
|
||||
label: "text-text-bright font-semibold -mt-1 text-left",
|
||||
description: "text-text-dimmed -mt-0 text-left",
|
||||
inputPosition: "mt-0",
|
||||
@@ -46,7 +46,7 @@ const variants = {
|
||||
},
|
||||
icon: {
|
||||
button:
|
||||
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-800 border rounded-sm",
|
||||
"w-full p-2.5 pb-4 hover:bg-charcoal-850 transition data-[disabled]:opacity-70 data-[state=checked]:bg-charcoal-850 border-charcoal-600 border rounded-sm",
|
||||
label: "text-text-bright font-semibold -mt-1 text-left",
|
||||
description: "text-text-dimmed -mt-0 text-left",
|
||||
inputPosition: "mt-0",
|
||||
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
@@ -129,7 +129,7 @@ export const RadioGroupItem = React.forwardRef<
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variation.inputPosition
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
|
||||
type TableBodyProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { InformationCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
const variantClasses = {
|
||||
basic:
|
||||
@@ -83,4 +84,22 @@ function SimpleTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIconTooltip({
|
||||
content,
|
||||
buttonClassName,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<InformationCircleIcon className={cn("h-3.5 w-3.5 text-text-dimmed", buttonClassName)} />
|
||||
}
|
||||
content={content}
|
||||
variant="dark"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { TimeFrameFilter } from "./TimeFrameFilter";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { useCallback } from "react";
|
||||
import assertNever from "assert-never";
|
||||
|
||||
export function RunsFilters() {
|
||||
const navigate = useNavigate();
|
||||
@@ -182,8 +183,7 @@ export function FilterStatusIcon({
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,8 +205,7 @@ export function filterStatusTitle(status: FilterableStatus): string {
|
||||
case "TIMEDOUT":
|
||||
return "Timed out";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,8 +227,7 @@ export function filterStatusClassNameColor(status: FilterableStatus): string {
|
||||
case "TIMEDOUT":
|
||||
return "text-amber-300";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { JobRunStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { z } from "zod";
|
||||
import assertNever from "assert-never";
|
||||
|
||||
export function RunStatus({ status }: { status: JobRunStatus }) {
|
||||
return (
|
||||
@@ -51,8 +52,7 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla
|
||||
case "CANCELED":
|
||||
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,7 @@ export function runStatusTitle(status: JobRunStatus): string {
|
||||
case "INVALID_PAYLOAD":
|
||||
return "Invalid payload";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,8 +122,7 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
|
||||
case "CANCELED":
|
||||
return "text-charcoal-500";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DateField } from "../primitives/DateField";
|
||||
import { formatDateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { Label } from "../primitives/Label";
|
||||
|
||||
type RunTimeFrameFilterProps = {
|
||||
from?: number;
|
||||
@@ -190,7 +191,7 @@ const timeFrameValues = [
|
||||
|
||||
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
|
||||
|
||||
function AbsoluteTimeFrame({
|
||||
export function AbsoluteTimeFrame({
|
||||
from,
|
||||
to,
|
||||
onValueChange,
|
||||
@@ -202,7 +203,8 @@ function AbsoluteTimeFrame({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="space-y-1">
|
||||
<Label>From (UTC)</Label>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={from}
|
||||
@@ -214,7 +216,8 @@ function AbsoluteTimeFrame({
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="space-y-1">
|
||||
<Label>To (UTC)</Label>
|
||||
<DateField
|
||||
label="To (UTC)"
|
||||
defaultValue={to}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -54,8 +55,7 @@ export function DeploymentStatusIcon({
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
|
||||
case "FAILED":
|
||||
return "text-error";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,8 +96,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus): string {
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function EnabledStatus({ enabled }: { enabled: boolean }) {
|
||||
switch (enabled) {
|
||||
case true:
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
Enabled
|
||||
</div>
|
||||
);
|
||||
case false:
|
||||
return (
|
||||
<div className="text-dimmed flex items-center gap-1 text-xs">
|
||||
<BoltSlashIcon className="h-4 w-4" />
|
||||
Disabled
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import type { TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
|
||||
import { RuntimeEnvironment, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
@@ -247,7 +247,7 @@ export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersP
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={TrashIcon} />
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
FolderIcon,
|
||||
HandRaisedIcon,
|
||||
InformationCircleIcon,
|
||||
Squares2X2Icon,
|
||||
@@ -41,6 +40,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
switch (name) {
|
||||
case "task":
|
||||
return <TaskIcon className={cn(className, "text-blue-500")} />;
|
||||
case "scheduled":
|
||||
return <ClockIcon className={cn(className, "text-sun-500")} />;
|
||||
case "attempt":
|
||||
return <AttemptIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "wait":
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../primitives/Select";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
|
||||
export const ScheduleListFilters = z.object({
|
||||
page: z.coerce.number().default(1),
|
||||
tasks: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
environments: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
|
||||
|
||||
const All = "ALL";
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type ScheduleFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
possibleTasks: string[];
|
||||
};
|
||||
|
||||
export function ScheduleFilters({ possibleEnvironments, possibleTasks }: ScheduleFiltersProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environments, tasks, page, search } = ScheduleListFilters.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
searchParams.delete("page");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleTaskChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("tasks", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("environments", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useThrottle((value: string) => {
|
||||
handleFilterChange("search", value.length === 0 ? undefined : value);
|
||||
}, 300);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("page");
|
||||
searchParams.delete("enabled");
|
||||
searchParams.delete("tasks");
|
||||
searchParams.delete("environments");
|
||||
searchParams.delete("search");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-row">
|
||||
<Input
|
||||
name="search"
|
||||
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
|
||||
icon="search"
|
||||
variant="tertiary"
|
||||
className="grow"
|
||||
defaultValue={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environments?.at(0) ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue
|
||||
placeholder={"Select environment"}
|
||||
className="ml-2 whitespace-nowrap p-0"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{possibleEnvironments.map((env) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={env} userName={env.userName} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder="Select task" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All tasks
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{possibleTasks.map((task) => (
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
|
||||
import { TaskRunAttemptStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -72,8 +73,7 @@ export function TaskRunAttemptStatusIcon({
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,8 +99,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,8 +125,7 @@ export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null):
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -88,8 +89,7 @@ export function TaskRunStatusIcon({
|
||||
return <FireIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
|
||||
case "CRASHED":
|
||||
return "text-error";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,8 +152,7 @@ export function runStatusTitle(status: TaskRunStatus): string {
|
||||
case "CRASHED":
|
||||
return "Crashed";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ClockIcon } from "@heroicons/react/20/solid";
|
||||
import { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function TaskTriggerSourceIcon({
|
||||
source,
|
||||
className,
|
||||
}: {
|
||||
source: TaskTriggerSource;
|
||||
className?: string;
|
||||
}) {
|
||||
switch (source) {
|
||||
case "STANDARD": {
|
||||
return (
|
||||
<div className={cn("grid size-4 place-items-center text-blue-500", className)}>
|
||||
<TaskIcon className="size-[87.5%]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "SCHEDULED": {
|
||||
return <ClockIcon className={cn("size-4 text-sun-500", className)} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function taskTriggerSourceDescription(source: TaskTriggerSource) {
|
||||
switch (source) {
|
||||
case "STANDARD": {
|
||||
return "Standard task";
|
||||
}
|
||||
case "SCHEDULED": {
|
||||
return "Scheduled task";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { env } from "./env.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
|
||||
export type PrismaTransactionClient = Omit<
|
||||
PrismaClient,
|
||||
@@ -138,3 +139,23 @@ export type { PrismaClient } from "@trigger.dev/database";
|
||||
export const PrismaErrorSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
function getDatabaseSchema() {
|
||||
if (!isValidDatabaseUrl(env.DATABASE_URL)) {
|
||||
throw new Error("Invalid Database URL");
|
||||
}
|
||||
|
||||
const databaseUrl = new URL(env.DATABASE_URL);
|
||||
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
|
||||
|
||||
if (!schemaFromSearchParam) {
|
||||
console.debug("❗ database schema unspecified, will default to `public` schema");
|
||||
return "public";
|
||||
}
|
||||
|
||||
return schemaFromSearchParam;
|
||||
}
|
||||
|
||||
export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema);
|
||||
|
||||
export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]);
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server";
|
||||
import { isValidRegex } from "./utils/regex";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
|
||||
const EnvironmentSchema = z.object({
|
||||
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
|
||||
DATABASE_URL: z.string(),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.refine(
|
||||
isValidDatabaseUrl,
|
||||
"DATABASE_URL is invalid, for details please check the additional output above this message."
|
||||
),
|
||||
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
|
||||
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
||||
DIRECT_URL: z.string(),
|
||||
DIRECT_URL: z
|
||||
.string()
|
||||
.refine(
|
||||
isValidDatabaseUrl,
|
||||
"DIRECT_URL is invalid, for details please check the additional output above this message."
|
||||
),
|
||||
SESSION_SECRET: z.string(),
|
||||
MAGIC_LINK_SECRET: z.string(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
@@ -54,6 +65,7 @@ const EnvironmentSchema = z.object({
|
||||
AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(1),
|
||||
AWS_SQS_WAIT_TIME_MS: z.coerce.number().int().optional().default(100),
|
||||
DISABLE_SSE: z.string().optional(),
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// Redis options
|
||||
REDIS_HOST: z.string().optional(),
|
||||
@@ -64,7 +76,6 @@ const EnvironmentSchema = z.object({
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
REDIS_TLS_DISABLED: z.string().optional(),
|
||||
|
||||
DEFAULT_QUEUE_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(5),
|
||||
DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||||
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||||
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useThrottle<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||
export function useThrottle(fn: (...args: any[]) => void, duration: number) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!timeout.current) {
|
||||
fn(...args);
|
||||
timeout.current = setTimeout(() => {
|
||||
timeout.current = undefined;
|
||||
}, delay);
|
||||
// Clean up when the component is unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (...args: Parameters<typeof fn>) => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeout.current = undefined;
|
||||
}, duration);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,3 +97,16 @@ export async function createProject(
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
export async function findProjectBySlug(orgSlug: string, projectSlug: string, userId: string) {
|
||||
// Find the project scoped to the organization, making sure the user belongs to that org
|
||||
return await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: orgSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,3 +62,59 @@ export async function findEnvironmentById(id: string) {
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const session = await tx.runtimeEnvironmentSession.create({
|
||||
data: {
|
||||
environmentId: environment.id,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.runtimeEnvironment.update({
|
||||
where: {
|
||||
id: environment.id,
|
||||
},
|
||||
data: {
|
||||
currentSessionId: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
export async function disconnectSession(environmentId: string) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const environment = await tx.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment || !environment.currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await tx.runtimeEnvironmentSession.update({
|
||||
where: {
|
||||
id: environment.currentSessionId,
|
||||
},
|
||||
data: {
|
||||
disconnectedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.runtimeEnvironment.update({
|
||||
where: {
|
||||
id: environment.id,
|
||||
},
|
||||
data: {
|
||||
currentSessionId: null,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
TaskRunError,
|
||||
TaskRunExecutionResult,
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
BatchTaskRunItemStatus,
|
||||
TaskRun,
|
||||
TaskRunAttempt,
|
||||
TaskRunAttemptStatus,
|
||||
TaskRunStatus,
|
||||
} from "@trigger.dev/database";
|
||||
import { assertNever } from "assert-never";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const SUCCESSFUL_STATUSES = [TaskRunStatus.COMPLETED_SUCCESSFULLY];
|
||||
const FAILURE_STATUSES = [
|
||||
TaskRunStatus.CANCELED,
|
||||
TaskRunStatus.INTERRUPTED,
|
||||
TaskRunStatus.COMPLETED_WITH_ERRORS,
|
||||
TaskRunStatus.SYSTEM_FAILURE,
|
||||
TaskRunStatus.CRASHED,
|
||||
];
|
||||
|
||||
export type TaskRunWithAttempts = TaskRun & {
|
||||
attempts: TaskRunAttempt[];
|
||||
};
|
||||
|
||||
export function executionResultForTaskRun(
|
||||
taskRun: TaskRunWithAttempts
|
||||
): TaskRunExecutionResult | undefined {
|
||||
if (SUCCESSFUL_STATUSES.includes(taskRun.status)) {
|
||||
// find the last attempt that was successful
|
||||
const attempt = taskRun.attempts.find((a) => a.status === TaskRunAttemptStatus.COMPLETED);
|
||||
|
||||
if (!attempt) {
|
||||
logger.error("Task run is successful but no successful attempt found", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: taskRun.friendlyId,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
} satisfies TaskRunSuccessfulExecutionResult;
|
||||
}
|
||||
|
||||
if (FAILURE_STATUSES.includes(taskRun.status)) {
|
||||
if (taskRun.status === TaskRunStatus.CANCELED) {
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "TASK_RUN_CANCELLED",
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
const attempt = taskRun.attempts.find((a) => a.status === TaskRunAttemptStatus.FAILED);
|
||||
|
||||
if (!attempt) {
|
||||
logger.error("Task run is failed but no failed attempt found", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const error = TaskRunError.safeParse(attempt.error);
|
||||
|
||||
if (!error.success) {
|
||||
logger.error("Failed to parse error from failed task run attempt", {
|
||||
taskRunId: taskRun.id,
|
||||
taskRunStatus: taskRun.status,
|
||||
taskRunAttempts: taskRun.attempts.map((a) => a.status),
|
||||
error: attempt.error,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "CONFIGURED_INCORRECTLY",
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
error: error.data,
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
}
|
||||
|
||||
export function batchTaskRunItemStatusForRunStatus(status: TaskRunStatus): BatchTaskRunItemStatus {
|
||||
switch (status) {
|
||||
case TaskRunStatus.COMPLETED_SUCCESSFULLY:
|
||||
return BatchTaskRunItemStatus.COMPLETED;
|
||||
case TaskRunStatus.CANCELED:
|
||||
case TaskRunStatus.INTERRUPTED:
|
||||
case TaskRunStatus.COMPLETED_WITH_ERRORS:
|
||||
case TaskRunStatus.SYSTEM_FAILURE:
|
||||
case TaskRunStatus.CRASHED:
|
||||
case TaskRunStatus.COMPLETED_WITH_ERRORS:
|
||||
return BatchTaskRunItemStatus.FAILED;
|
||||
case TaskRunStatus.PENDING:
|
||||
case TaskRunStatus.WAITING_FOR_DEPLOY:
|
||||
case TaskRunStatus.WAITING_TO_RESUME:
|
||||
case TaskRunStatus.RETRYING_AFTER_FAILURE:
|
||||
case TaskRunStatus.EXECUTING:
|
||||
case TaskRunStatus.PAUSED:
|
||||
return BatchTaskRunItemStatus.PENDING;
|
||||
default:
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DisplayPropertySchema,
|
||||
EventSpecificationSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { PrismaClient, Prisma, prisma } from "~/db.server";
|
||||
import { PrismaClient, Prisma, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
@@ -122,7 +122,7 @@ export class JobListPresenter {
|
||||
"jobId",
|
||||
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
|
||||
FROM
|
||||
"JobRun"
|
||||
${sqlDatabaseSchema}."JobRun"
|
||||
WHERE
|
||||
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
|
||||
) t
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { estimate } from "@trigger.dev/billing";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
|
||||
@@ -53,7 +53,7 @@ export class OrgUsagePresenter {
|
||||
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`;
|
||||
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
|
||||
|
||||
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
|
||||
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
|
||||
@@ -117,7 +117,7 @@ export class OrgUsagePresenter {
|
||||
|
||||
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; runs: BigInt }[]
|
||||
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
|
||||
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM ${sqlDatabaseSchema}."JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
|
||||
|
||||
const hasDailyRunsData = dailyRunsRawData.length > 0;
|
||||
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BatchTaskRunExecutionResult } from "@trigger.dev/core/v3";
|
||||
import { executionResultForTaskRun } from "~/models/taskRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export class ApiBatchResultsPresenter extends BasePresenter {
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const batchRun = await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
taskRun: {
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!batchRun) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: batchRun.friendlyId,
|
||||
items: batchRun.items
|
||||
.map((item) => executionResultForTaskRun(item.taskRun))
|
||||
.filter(Boolean),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TaskRunExecutionResult } from "@trigger.dev/core/v3";
|
||||
import { executionResultForTaskRun } from "~/models/taskRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export class ApiRunResultPresenter extends BasePresenter {
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<TaskRunExecutionResult | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._prisma.taskRun.findUnique({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return executionResultForTaskRun(taskRun);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WorkerDeploymentStatus } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
@@ -97,7 +97,7 @@ export class DeploymentListPresenter {
|
||||
wd."id",
|
||||
wd."shortCode",
|
||||
wd."version",
|
||||
(SELECT COUNT(*) FROM "BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
|
||||
(SELECT COUNT(*) FROM ${sqlDatabaseSchema}."BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
|
||||
wd."environmentId",
|
||||
wd."status",
|
||||
u."id" AS "userId",
|
||||
@@ -106,9 +106,9 @@ export class DeploymentListPresenter {
|
||||
u."avatarUrl" AS "userAvatarUrl",
|
||||
wd."deployedAt"
|
||||
FROM
|
||||
"WorkerDeployment" as wd
|
||||
${sqlDatabaseSchema}."WorkerDeployment" as wd
|
||||
INNER JOIN
|
||||
"User" as u ON wd."triggeredById" = u."id"
|
||||
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
|
||||
WHERE
|
||||
wd."projectId" = ${project.id}
|
||||
ORDER BY
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
|
||||
type EditScheduleOptions = {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
friendlyId?: string;
|
||||
};
|
||||
|
||||
export type EditableScheduleElements = Awaited<ReturnType<EditSchedulePresenter["call"]>>;
|
||||
|
||||
type Environment = {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export class EditSchedulePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, projectSlug, friendlyId }: EditScheduleOptions) {
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
triggerSource: "SCHEDULED",
|
||||
},
|
||||
});
|
||||
|
||||
const possibleEnvironments = project.environments.map((environment) => {
|
||||
let userName: undefined | string;
|
||||
if (environment.orgMember) {
|
||||
if (environment.orgMember.user.id !== userId) {
|
||||
userName =
|
||||
environment.orgMember.user.displayName ?? environment.orgMember.user.name ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userName,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
possibleTasks: possibleTasks.map((task) => task.slug),
|
||||
possibleEnvironments,
|
||||
schedule: await this.#getExistingSchedule(friendlyId, possibleEnvironments),
|
||||
};
|
||||
}
|
||||
|
||||
async #getExistingSchedule(scheduleId: string | undefined, possibleEnvironments: Environment[]) {
|
||||
if (!scheduleId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const schedule = await this.#prismaClient.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
generatorExpression: true,
|
||||
externalId: true,
|
||||
deduplicationKey: true,
|
||||
userProvidedDeduplicationKey: true,
|
||||
taskIdentifier: true,
|
||||
instances: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: scheduleId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
cron: schedule.generatorExpression,
|
||||
environments: schedule.instances.map((instance) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === instance.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment with id ${instance.environmentId} not found`);
|
||||
}
|
||||
|
||||
return environment;
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
|
||||
|
||||
type RunListOptions = {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
//filters
|
||||
tasks: string[] | undefined;
|
||||
versions: string[] | undefined;
|
||||
statuses: TaskRunStatus[] | undefined;
|
||||
environments: string[] | undefined;
|
||||
from: number | undefined;
|
||||
to: number | undefined;
|
||||
tasks?: string[];
|
||||
versions?: string[];
|
||||
statuses?: TaskRunStatus[];
|
||||
environments?: string[];
|
||||
scheduleId?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
direction: Direction | undefined;
|
||||
cursor: string | undefined;
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
@@ -34,12 +34,12 @@ export class RunListPresenter {
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
scheduleId,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -52,7 +52,9 @@ export class RunListPresenter {
|
||||
tasks !== undefined ||
|
||||
versions !== undefined ||
|
||||
hasStatusFilters ||
|
||||
environments !== undefined;
|
||||
environments !== undefined ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
@@ -83,11 +85,12 @@ export class RunListPresenter {
|
||||
});
|
||||
|
||||
//get all possible tasks
|
||||
const possibleTasks = await this.#prismaClient.$queryRaw<{ slug: string }[]>`
|
||||
SELECT DISTINCT(slug)
|
||||
FROM "BackgroundWorkerTask"
|
||||
WHERE "projectId" = ${project.id};
|
||||
`;
|
||||
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
//get the runs
|
||||
let runs = await this.#prismaClient.$queryRaw<
|
||||
@@ -120,15 +123,15 @@ export class RunListPresenter {
|
||||
tr."isTest" AS "isTest",
|
||||
COUNT(tra.id) AS attempts
|
||||
FROM
|
||||
"TaskRun" tr
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
|
||||
FROM "TaskRunAttempt"
|
||||
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
|
||||
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
|
||||
LEFT JOIN
|
||||
"BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
|
||||
WHERE
|
||||
-- project
|
||||
tr."projectId" = ${project.id}
|
||||
@@ -160,6 +163,7 @@ export class RunListPresenter {
|
||||
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { calculateNextScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
|
||||
|
||||
type ScheduleListOptions = {
|
||||
projectId: string;
|
||||
userId?: string;
|
||||
pageSize?: number;
|
||||
} & ScheduleListFilters;
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type ScheduleListItem = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
deduplicationKey: string | null;
|
||||
userProvidedDeduplicationKey: boolean;
|
||||
cron: string;
|
||||
cronDescription: string;
|
||||
externalId: string | null;
|
||||
nextRun: Date;
|
||||
lastRun: Date | undefined;
|
||||
active: boolean;
|
||||
environments: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string;
|
||||
}[];
|
||||
};
|
||||
export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
|
||||
export type ScheduleListAppliedFilters = ScheduleList["filters"];
|
||||
|
||||
export class ScheduleListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
tasks,
|
||||
environments,
|
||||
search,
|
||||
page,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: ScheduleListOptions) {
|
||||
const hasFilters =
|
||||
tasks !== undefined || environments !== undefined || (search !== undefined && search !== "");
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
//get all possible scheduled tasks
|
||||
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
triggerSource: "SCHEDULED",
|
||||
},
|
||||
});
|
||||
|
||||
//do this here to protect against SQL injection
|
||||
search = search && search !== "" ? `%${search}%` : undefined;
|
||||
|
||||
const totalCount = await this.#prismaClient.taskSchedule.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
taskIdentifier: tasks ? { in: tasks } : undefined,
|
||||
instances: {
|
||||
some: {
|
||||
environmentId: environments ? { in: environments } : undefined,
|
||||
},
|
||||
},
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
externalId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
friendlyId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
deduplicationKey: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
generatorExpression: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const rawSchedules = await this.#prismaClient.taskSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
deduplicationKey: true,
|
||||
userProvidedDeduplicationKey: true,
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
externalId: true,
|
||||
instances: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
where: {
|
||||
projectId: project.id,
|
||||
taskIdentifier: tasks ? { in: tasks } : undefined,
|
||||
instances: environments
|
||||
? {
|
||||
some: {
|
||||
environmentId: environments ? { in: environments } : undefined,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
AND: search
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
externalId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
friendlyId: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
deduplicationKey: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
generatorExpression: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
});
|
||||
|
||||
const latestRuns =
|
||||
rawSchedules.length > 0
|
||||
? await this.#prismaClient.$queryRaw<{ scheduleId: string; createdAt: Date }[]>`
|
||||
SELECT t."scheduleId", t."createdAt"
|
||||
FROM (
|
||||
SELECT "scheduleId", MAX("createdAt") as "LatestRun"
|
||||
FROM ${sqlDatabaseSchema}."TaskRun"
|
||||
WHERE "scheduleId" IN (${Prisma.join(rawSchedules.map((s) => s.id))})
|
||||
GROUP BY "scheduleId"
|
||||
) r
|
||||
JOIN ${sqlDatabaseSchema}."TaskRun" t
|
||||
ON t."scheduleId" = r."scheduleId" AND t."createdAt" = r."LatestRun";`
|
||||
: [];
|
||||
|
||||
const schedules = rawSchedules.map((schedule) => {
|
||||
const latestRun = latestRuns.find((r) => r.scheduleId === schedule.id);
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
friendlyId: schedule.friendlyId,
|
||||
taskIdentifier: schedule.taskIdentifier,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
userProvidedDeduplicationKey: schedule.userProvidedDeduplicationKey,
|
||||
cron: schedule.generatorExpression,
|
||||
cronDescription: schedule.generatorDescription,
|
||||
active: schedule.active,
|
||||
externalId: schedule.externalId,
|
||||
lastRun: latestRun?.createdAt,
|
||||
nextRun: calculateNextScheduledTimestamp(schedule.generatorExpression),
|
||||
environments: schedule.instances.map((instance) => {
|
||||
const environment = project.environments.find((env) => env.id === instance.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(
|
||||
`Environment not found for TaskScheduleInstance env: ${instance.environmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: instance.environmentId,
|
||||
type: environment.type,
|
||||
userName:
|
||||
environment.orgMember?.user.id === userId
|
||||
? undefined
|
||||
: getUsername(environment.orgMember?.user),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalCount: totalCount,
|
||||
schedules,
|
||||
possibleTasks: possibleTasks.map((task) => task.slug),
|
||||
possibleEnvironments: project.environments.map((environment) => {
|
||||
return {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userName:
|
||||
environment.orgMember?.user.id === userId
|
||||
? undefined
|
||||
: getUsername(environment.orgMember?.user),
|
||||
};
|
||||
}),
|
||||
hasFilters,
|
||||
filters: {
|
||||
tasks,
|
||||
environments,
|
||||
search,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Prisma, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
@@ -61,6 +61,7 @@ export class TaskListPresenter {
|
||||
filePath: string;
|
||||
runtimeEnvironmentId: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
>`
|
||||
SELECT DISTINCT ON(bwt.slug, bwt."runtimeEnvironmentId")
|
||||
@@ -69,9 +70,10 @@ export class TaskListPresenter {
|
||||
bwt."exportName",
|
||||
bwt."filePath",
|
||||
bwt."runtimeEnvironmentId",
|
||||
bwt."createdAt"
|
||||
bwt."createdAt",
|
||||
bwt."triggerSource"
|
||||
FROM
|
||||
"BackgroundWorkerTask" as bwt
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
WHERE bwt."projectId" = ${project.id}
|
||||
ORDER BY
|
||||
bwt.slug,
|
||||
@@ -99,7 +101,7 @@ export class TaskListPresenter {
|
||||
"lockedById",
|
||||
ROW_NUMBER() OVER (PARTITION BY "lockedById" ORDER BY "updatedAt" DESC) AS rn
|
||||
FROM
|
||||
"TaskRun"
|
||||
${sqlDatabaseSchema}."TaskRun"
|
||||
WHERE
|
||||
"lockedById" IN(${Prisma.join(tasks.map((t) => t.id))})
|
||||
) t
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
@@ -89,20 +90,21 @@ export class TestPresenter {
|
||||
filePath: string;
|
||||
exportName: string;
|
||||
friendlyId: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
>`WITH workers AS (
|
||||
SELECT
|
||||
bw.*,
|
||||
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
|
||||
FROM
|
||||
"BackgroundWorker" bw
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw
|
||||
WHERE "runtimeEnvironmentId" = ${matchingEnvironment.id}
|
||||
),
|
||||
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
|
||||
SELECT "BackgroundWorkerTask".id, version, slug as "taskIdentifier", "filePath", "exportName", "BackgroundWorkerTask"."friendlyId"
|
||||
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId"
|
||||
FROM latest_workers
|
||||
JOIN "BackgroundWorkerTask" ON "BackgroundWorkerTask"."workerId" = latest_workers.id
|
||||
ORDER BY "BackgroundWorkerTask"."exportName" ASC;
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
|
||||
ORDER BY bwt."exportName" ASC;
|
||||
`;
|
||||
|
||||
return {
|
||||
@@ -117,6 +119,7 @@ export class TestPresenter {
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
friendlyId: task.friendlyId,
|
||||
triggerSource: task.triggerSource,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
RuntimeEnvironmentType,
|
||||
TaskRunAttemptStatus,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
type TestTaskOptions = {
|
||||
@@ -8,7 +14,55 @@ type TestTaskOptions = {
|
||||
taskFriendlyId: string;
|
||||
};
|
||||
|
||||
export type TestTask = Awaited<ReturnType<TestTaskPresenter["call"]>>;
|
||||
type Task = {
|
||||
id: string;
|
||||
taskIdentifier: string;
|
||||
filePath: string;
|
||||
exportName: string;
|
||||
friendlyId: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TestTask =
|
||||
| {
|
||||
triggerSource: "STANDARD";
|
||||
task: Task;
|
||||
runs: StandardRun[];
|
||||
}
|
||||
| {
|
||||
triggerSource: "SCHEDULED";
|
||||
task: Task;
|
||||
runs: ScheduledRun[];
|
||||
};
|
||||
|
||||
type RawRun = {
|
||||
id: string;
|
||||
number: BigInt;
|
||||
friendlyId: string;
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
payload: string;
|
||||
payloadType: string;
|
||||
runtimeEnvironmentId: string;
|
||||
};
|
||||
|
||||
export type StandardRun = Omit<RawRun, "number"> & {
|
||||
number: number;
|
||||
};
|
||||
|
||||
export type ScheduledRun = Omit<RawRun, "number" | "payload"> & {
|
||||
number: number;
|
||||
payload: {
|
||||
timestamp: Date;
|
||||
lastTimestamp?: Date;
|
||||
externalId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class TestTaskPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -17,13 +71,14 @@ export class TestTaskPresenter {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions) {
|
||||
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions): Promise<TestTask> {
|
||||
const task = await this.#prismaClient.backgroundWorkerTask.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
filePath: true,
|
||||
exportName: true,
|
||||
slug: true,
|
||||
triggerSource: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -47,25 +102,14 @@ export class TestTaskPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
number: BigInt;
|
||||
friendlyId: string;
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
payload: string;
|
||||
payloadType: string;
|
||||
runtimeEnvironmentId: string;
|
||||
}[]
|
||||
>`
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
|
||||
WITH taskruns AS (
|
||||
SELECT
|
||||
tr.*
|
||||
FROM
|
||||
"TaskRun" as tr
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
JOIN
|
||||
"BackgroundWorkerTask" as bwt
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
ON
|
||||
tr."taskIdentifier" = bwt.slug
|
||||
WHERE
|
||||
@@ -88,32 +132,63 @@ export class TestTaskPresenter {
|
||||
FROM
|
||||
taskruns AS taskr
|
||||
WHERE
|
||||
taskr."payloadType" = 'application/json'
|
||||
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
|
||||
ORDER BY
|
||||
taskr."createdAt" DESC;`;
|
||||
|
||||
return {
|
||||
task: {
|
||||
id: task.id,
|
||||
taskIdentifier: task.slug,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
friendlyId: taskFriendlyId,
|
||||
environment: {
|
||||
id: task.runtimeEnvironment.id,
|
||||
type: task.runtimeEnvironment.type,
|
||||
userId: task.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
const taskWithEnvironment = {
|
||||
id: task.id,
|
||||
taskIdentifier: task.slug,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
friendlyId: taskFriendlyId,
|
||||
environment: {
|
||||
id: task.runtimeEnvironment.id,
|
||||
type: task.runtimeEnvironment.type,
|
||||
userId: task.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
runs: latestRuns.map((r) => {
|
||||
//we need to format the code on the server, because we detect if the sample has been edited by comparing the contents
|
||||
try {
|
||||
r.payload = JSON.stringify(JSON.parse(r.payload ?? ""), null, 2);
|
||||
} catch (e) {}
|
||||
|
||||
return { ...r, number: Number(r.number) };
|
||||
}),
|
||||
};
|
||||
|
||||
switch (task.triggerSource) {
|
||||
case "STANDARD":
|
||||
return {
|
||||
triggerSource: "STANDARD",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
case "SCHEDULED":
|
||||
return {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await getScheduleTaskRunPayload(r),
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getScheduleTaskRunPayload(run: RawRun) {
|
||||
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
|
||||
const parsed = ScheduledTaskPayload.parse(payload);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { nextScheduledTimestamps } from "~/v3/utils/calculateNextSchedule.server";
|
||||
import { RunListPresenter } from "./RunListPresenter.server";
|
||||
import { ScheduleObject } from "@trigger.dev/core/v3";
|
||||
|
||||
type ViewScheduleOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
friendlyId: string;
|
||||
};
|
||||
|
||||
export class ViewSchedulePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, projectId, friendlyId }: ViewScheduleOptions) {
|
||||
const schedule = await this.#prismaClient.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
generatorExpression: true,
|
||||
generatorDescription: true,
|
||||
externalId: true,
|
||||
deduplicationKey: true,
|
||||
userProvidedDeduplicationKey: true,
|
||||
taskIdentifier: true,
|
||||
project: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
instances: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRuns = schedule.active
|
||||
? nextScheduledTimestamps(schedule.generatorExpression, new Date(), 5)
|
||||
: [];
|
||||
|
||||
const runPresenter = new RunListPresenter(this.#prismaClient);
|
||||
const { runs } = await runPresenter.call({
|
||||
projectSlug: schedule.project.slug,
|
||||
scheduleId: schedule.id,
|
||||
pageSize: 5,
|
||||
});
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
...schedule,
|
||||
cron: schedule.generatorExpression,
|
||||
cronDescription: schedule.generatorDescription,
|
||||
nextRuns,
|
||||
runs,
|
||||
environments: schedule.instances.map((instance) => {
|
||||
const environment = instance.environment;
|
||||
let userName: undefined | string;
|
||||
if (environment.orgMember) {
|
||||
if (environment.orgMember.user.id !== userId) {
|
||||
userName =
|
||||
environment.orgMember.user.displayName ??
|
||||
environment.orgMember.user.name ??
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userName,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public toJSONResponse(result: NonNullable<Awaited<ReturnType<ViewSchedulePresenter["call"]>>>) {
|
||||
const response: ScheduleObject = {
|
||||
id: result.schedule.friendlyId,
|
||||
task: result.schedule.taskIdentifier,
|
||||
active: result.schedule.active,
|
||||
nextRun: result.schedule.nextRuns[0],
|
||||
generator: {
|
||||
type: "CRON",
|
||||
expression: result.schedule.cron,
|
||||
description: result.schedule.cronDescription,
|
||||
},
|
||||
externalId: result.schedule.externalId ?? undefined,
|
||||
deduplicationKey: result.schedule.userProvidedDeduplicationKey
|
||||
? result.schedule.deduplicationKey ?? undefined
|
||||
: undefined,
|
||||
environments: result.schedule.instances.map((instance) => ({
|
||||
id: instance.environment.id,
|
||||
type: instance.environment.type,
|
||||
})),
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Span, SpanKind } from "@opentelemetry/api";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { attributesFromAuthenticatedEnv, tracer } from "../../v3/tracer.server";
|
||||
|
||||
export abstract class BasePresenter {
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {}
|
||||
|
||||
protected async traceWithEnv<T>(
|
||||
trace: string,
|
||||
env: AuthenticatedEnvironment,
|
||||
fn: (span: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan(
|
||||
`${this.constructor.name}.${trace}`,
|
||||
{ attributes: attributesFromAuthenticatedEnv(env), kind: SpanKind.SERVER },
|
||||
async (span) => {
|
||||
try {
|
||||
return await fn(span);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
span.recordException(e);
|
||||
} else {
|
||||
span.recordException(new Error(String(e)));
|
||||
}
|
||||
|
||||
throw e;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -27,8 +27,13 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { TaskFunctionName, TaskPath } from "~/components/runs/v3/TaskPath";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import {
|
||||
TaskTriggerSourceIcon,
|
||||
taskTriggerSourceDescription,
|
||||
} from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useDevEnvironment } from "~/hooks/useEnvironments";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -121,7 +126,15 @@ export default function Page() {
|
||||
});
|
||||
return (
|
||||
<TableRow key={task.id} className="group">
|
||||
<TableCell to={path}>{task.slug}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(task.triggerSource)}
|
||||
/>
|
||||
<span>{task.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TaskFunctionName
|
||||
functionName={task.exportName}
|
||||
|
||||
+4
-1
@@ -130,6 +130,9 @@ export default function Page() {
|
||||
)}
|
||||
<Property label="Message">{event.message}</Property>
|
||||
<Property label="Task ID">{event.taskSlug}</Property>
|
||||
{event.idempotencyKey && (
|
||||
<Property label="Idempotency key">{event.idempotencyKey}</Property>
|
||||
)}
|
||||
{event.taskPath && event.taskExportName && (
|
||||
<Property label="Task">
|
||||
<TaskPath
|
||||
@@ -296,7 +299,7 @@ function Timeline({ startTime, duration, inProgress, isError }: TimelineProps) {
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<Paragraph variant="small">
|
||||
<DateTimeAccurate date={startTime} /> UTC
|
||||
<DateTimeAccurate date={startTime} />
|
||||
</Paragraph>
|
||||
{state === "pending" ? (
|
||||
<LiveTimer startTime={startTime} className="" />
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
tasks,
|
||||
versions,
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BoltIcon, BoltSlashIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { Form, useLocation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { token } from "morgan";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
v3EditSchedulePath,
|
||||
v3ScheduleParams,
|
||||
v3SchedulePath,
|
||||
v3SchedulesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
|
||||
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, scheduleParam } = v3ScheduleParams.parse(params);
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const presenter = new ViewSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Error("Schedule not found");
|
||||
}
|
||||
|
||||
return typedjson({ schedule: result.schedule });
|
||||
};
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("enable"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("disable"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, scheduleParam } = v3ScheduleParams.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`No project found with slug ${projectParam}`
|
||||
);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "delete": {
|
||||
const deleteService = new DeleteTaskScheduleService();
|
||||
try {
|
||||
await deleteService.call({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
return redirectWithSuccessMessage(
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`${scheduleParam} deleted`
|
||||
);
|
||||
} catch (e) {
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`${scheduleParam} could not be deleted: ${
|
||||
e instanceof Error ? e.message : JSON.stringify(e)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
case "enable":
|
||||
case "disable": {
|
||||
const service = new SetActiveOnTaskScheduleService();
|
||||
const active = submission.value.action === "enable";
|
||||
try {
|
||||
await service.call({
|
||||
projectId: project.id,
|
||||
userId,
|
||||
friendlyId: scheduleParam,
|
||||
active,
|
||||
});
|
||||
return redirectWithSuccessMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`${scheduleParam} ${active ? "enabled" : "disabled"}`
|
||||
);
|
||||
} catch (e) {
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${
|
||||
e instanceof Error ? e.message : JSON.stringify(e)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule } = useTypedLoaderData<typeof loader>();
|
||||
const location = useLocation();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={`${v3SchedulesPath(organization, project)}${location.search}`}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="p-3">
|
||||
<div className="space-y-3">
|
||||
<PropertyTable>
|
||||
<Property label="Schedule ID">{schedule.friendlyId}</Property>
|
||||
<Property label="Task ID">{schedule.taskIdentifier}</Property>
|
||||
<Property label="CRON (UTC)" labelClassName="self-start">
|
||||
<div className="space-y-2">
|
||||
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
|
||||
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Environments">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{schedule.environments.map((env) => (
|
||||
<EnvironmentLabel
|
||||
key={env.id}
|
||||
size="small"
|
||||
environment={env}
|
||||
userName={env.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="External ID">
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
</Property>
|
||||
<Property label="Deduplication key">
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</Property>
|
||||
<Property label="Status">
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
</Property>
|
||||
</PropertyTable>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Last 5 runs</Header3>
|
||||
<TaskRunsTable
|
||||
total={schedule.runs.length}
|
||||
hasFilters={false}
|
||||
filters={{
|
||||
tasks: [],
|
||||
versions: [],
|
||||
statuses: [],
|
||||
environments: [],
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
}}
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
currentUser={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Next 5 runs</Header3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>UTC</TableHeaderCell>
|
||||
<TableHeaderCell>Local time</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedule.nextRuns.map((run, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<DateTime date={run} timeZone="UTC" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateTime date={run} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
>
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error"
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Delete schedule</DialogHeader>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this schedule? This can't be reversed.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ScheduleParams } from "~/utils/pathBuilder";
|
||||
import { humanToCronSupported } from "~/v3/humanToCron.server";
|
||||
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, scheduleParam } = v3ScheduleParams.parse(params);
|
||||
|
||||
const presenter = new EditSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
|
||||
return typedjson({ ...result, showGenerateField: humanToCronSupported });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<UpsertScheduleForm
|
||||
schedule={schedule}
|
||||
possibleTasks={possibleTasks}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
showGenerateField={showGenerateField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema } from "~/utils/pathBuilder";
|
||||
import { humanToCronSupported } from "~/v3/humanToCron.server";
|
||||
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const presenter = new EditSchedulePresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
return typedjson({ ...result, showGenerateField: humanToCronSupported });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { schedule, possibleTasks, possibleEnvironments, showGenerateField } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<UpsertScheduleForm
|
||||
schedule={schedule}
|
||||
possibleTasks={possibleTasks}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
showGenerateField={showGenerateField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { PlusIcon, PlusSmallIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { Outlet, useLocation, useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { ScheduleFilters, ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
ScheduleListItem,
|
||||
ScheduleListPresenter,
|
||||
} from "~/presenters/v3/ScheduleListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
v3NewSchedulePath,
|
||||
v3SchedulePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
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 filters = ScheduleListFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const presenter = new ScheduleListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
schedules,
|
||||
possibleTasks,
|
||||
possibleEnvironments,
|
||||
hasFilters,
|
||||
filters,
|
||||
currentPage,
|
||||
totalPages,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const location = useLocation();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const pathName = usePathName();
|
||||
|
||||
const { scheduleParam } = useParams();
|
||||
const isShowingNewPane = pathName.endsWith("/new");
|
||||
const isShowingSchedule = !!scheduleParam;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Schedules" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={PlusIcon}
|
||||
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
disabled={possibleTasks.length === 0 || isShowingNewPane}
|
||||
>
|
||||
New schedule
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel order={1} minSize={20} defaultSize={60}>
|
||||
{possibleTasks.length === 0 ? (
|
||||
<CreateScheduledTaskInstructions />
|
||||
) : schedules.length === 0 && !hasFilters ? (
|
||||
<AttachYourFirstScheduleInstructions />
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<ScheduleFilters
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
showPageNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
|
||||
<div className="mt-2 justify-end">
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
{(isShowingNewPane || isShowingSchedule) && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel order={2} minSize={20} defaultSize={40}>
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateScheduledTaskInstructions() {
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<BlankstateInstructions title="Create your first scheduled task">
|
||||
<Paragraph spacing>
|
||||
You have no scheduled tasks in your project. Before you can schedule a task you need to a{" "}
|
||||
<InlineCode>schedules.task</InlineCode>.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Create scheduled task docs
|
||||
</LinkButton>
|
||||
</BlankstateInstructions>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachYourFirstScheduleInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<BlankstateInstructions title="Attach your first schedule">
|
||||
<Paragraph spacing>
|
||||
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
|
||||
this in the dashboard or using the SDK.
|
||||
</Paragraph>
|
||||
<div className="flex gap-2">
|
||||
<LinkButton
|
||||
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusSmallIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Create in the dashboard
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="primary/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Use the SDK
|
||||
</LinkButton>
|
||||
</div>
|
||||
</BlankstateInstructions>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesTable({
|
||||
schedules,
|
||||
hasFilters,
|
||||
}: {
|
||||
schedules: ScheduleListItem[];
|
||||
hasFilters: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const location = useLocation();
|
||||
const { scheduleParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>CRON</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>CRON description</TableHeaderCell>
|
||||
<TableHeaderCell>External ID</TableHeaderCell>
|
||||
<TableHeaderCell>Deduplication key</TableHeaderCell>
|
||||
<TableHeaderCell>Next run (UTC)</TableHeaderCell>
|
||||
<TableHeaderCell>Last run (UTC)</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedules.length === 0 ? (
|
||||
<TableBlankRow colSpan={10}>There are no matches for your filters</TableBlankRow>
|
||||
) : (
|
||||
schedules.map((schedule) => {
|
||||
const path = `${v3SchedulePath(organization, project, schedule)}${location.search}`;
|
||||
const isSelected = scheduleParam === schedule.friendlyId;
|
||||
const cellClass = schedule.active ? "" : "opacity-50";
|
||||
return (
|
||||
<TableRow key={schedule.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.friendlyId}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.taskIdentifier}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.cron}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.cronDescription}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<DateTime date={schedule.nextRun} />
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.lastRun ? <DateTime date={schedule.lastRun} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<div className="flex gap-1">
|
||||
{schedule.environments.map((environment) => (
|
||||
<EnvironmentLabel
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
+261
-86
@@ -1,17 +1,25 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BeakerIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigation, useSubmit } from "@remix-run/react";
|
||||
import { Form, useActionData, useSubmit } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { JSONEditor } from "~/components/code/JSONEditor";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateField } from "~/components/primitives/DateField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
import {
|
||||
@@ -19,63 +27,40 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { TaskPath } from "~/components/runs/v3/TaskPath";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { TestTaskPresenter } from "~/presenters/v3/TestTaskPresenter.server";
|
||||
import {
|
||||
ScheduledRun,
|
||||
StandardRun,
|
||||
TestTask,
|
||||
TestTaskPresenter,
|
||||
} from "~/presenters/v3/TestTaskPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { v3RunPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
|
||||
import { TestTaskService } from "~/v3/services/testTask.server";
|
||||
import { TestTaskData } from "~/v3/testTask";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, taskParam } = v3TaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TestTaskPresenter();
|
||||
const { task, runs } = await presenter.call({
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
taskFriendlyId: taskParam,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
task,
|
||||
runs,
|
||||
});
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
payload: z.string().transform((payload, ctx) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
return data as any;
|
||||
} catch (e) {
|
||||
console.log("parsing error", e);
|
||||
|
||||
if (e instanceof Error) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "This is invalid JSON",
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
taskIdentifier: z.string(),
|
||||
environmentId: z.string(),
|
||||
accountId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, taskParam } = v3TaskParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
const submission = parse(formData, { schema: TestTaskData });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
@@ -98,17 +83,27 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const result = useTypedLoaderData<typeof loader>();
|
||||
|
||||
switch (result.triggerSource) {
|
||||
case "STANDARD": {
|
||||
return <StandardTaskForm task={result.task} runs={result.runs} />;
|
||||
}
|
||||
case "SCHEDULED": {
|
||||
return <ScheduledTaskForm task={result.task} runs={result.runs} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startingJson = "{\n\n}";
|
||||
|
||||
export default function Page() {
|
||||
const { task, runs } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
|
||||
function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: StandardRun[] }) {
|
||||
//form submission
|
||||
const submit = useSubmit();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
//examples
|
||||
//recent runs
|
||||
const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id);
|
||||
const selectedCodeSample = runs.find((r) => r.id === selectedCodeSampleId)?.payload;
|
||||
|
||||
@@ -123,6 +118,7 @@ export default function Page() {
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
submit(
|
||||
{
|
||||
triggerSource: "STANDARD",
|
||||
payload: currentJson.current,
|
||||
taskIdentifier: task.taskIdentifier,
|
||||
environmentId: task.environment.id,
|
||||
@@ -142,7 +138,7 @@ export default function Page() {
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
return parse(formData, { schema: TestTaskData });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -153,6 +149,7 @@ export default function Page() {
|
||||
{...form.props}
|
||||
onSubmit={(e) => submitForm(e)}
|
||||
>
|
||||
<input type="hidden" name="triggerSource" value={"STANDARD"} />
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel order={1} minSize={30} defaultSize={60}>
|
||||
<div className="h-full bg-charcoal-900">
|
||||
@@ -182,53 +179,24 @@ export default function Page() {
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel order={2} minSize={20} defaultSize={40}>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
<div className="flex h-10 items-center border-b border-grid-dimmed">
|
||||
<Header2>Recent payloads</Header2>
|
||||
</div>
|
||||
{runs.length === 0 ? (
|
||||
<Callout variant="info">
|
||||
Recent payloads will show here once you've completed a Run.
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-charcoal-850">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
setCode(run.payload ?? "");
|
||||
setSelectedCodeSampleId(run.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<RadioButtonCircle checked={run.id === selectedCodeSampleId} />
|
||||
<div className="flex flex-col items-start">
|
||||
<Paragraph variant="small">
|
||||
<DateTime date={run.createdAt} />
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<div>Run #{run.number}</div>
|
||||
<TaskRunStatusCombo status={run.status} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RecentPayloads
|
||||
runs={runs}
|
||||
selectedId={selectedCodeSampleId}
|
||||
onSelected={(id) => {
|
||||
const payload = runs.find((r) => r.id === id)?.payload;
|
||||
if (!payload) return;
|
||||
setCode(payload);
|
||||
setSelectedCodeSampleId(id);
|
||||
}}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<TaskPath
|
||||
filePath={task.filePath}
|
||||
functionName={`${task.exportName}()`}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Paragraph variant="small">will run as a test in your</Paragraph>
|
||||
<Paragraph variant="small" className="whitespace-nowrap">
|
||||
This test will run in
|
||||
</Paragraph>
|
||||
<EnvironmentLabel environment={task.environment} />
|
||||
<Paragraph variant="small">environment:</Paragraph>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -242,3 +210,210 @@ export default function Page() {
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduledTaskForm({ task, runs }: { task: TestTask["task"]; runs: ScheduledRun[] }) {
|
||||
const lastSubmission = useActionData();
|
||||
const [selectedCodeSampleId, setSelectedCodeSampleId] = useState(runs.at(0)?.id);
|
||||
const [timestampValue, setTimestampValue] = useState<Date | undefined>();
|
||||
const [lastTimestampValue, setLastTimestampValue] = useState<Date | undefined>();
|
||||
const [externalIdValue, setExternalIdValue] = useState<string | undefined>();
|
||||
|
||||
//set initial values
|
||||
useEffect(() => {
|
||||
const initialRun = runs.find((r) => r.id === selectedCodeSampleId);
|
||||
if (!initialRun) {
|
||||
setTimestampValue(new Date());
|
||||
return;
|
||||
}
|
||||
|
||||
setTimestampValue(initialRun.payload.timestamp);
|
||||
setLastTimestampValue(initialRun.payload.lastTimestamp);
|
||||
setExternalIdValue(initialRun.payload.externalId);
|
||||
}, [selectedCodeSampleId]);
|
||||
|
||||
const [
|
||||
form,
|
||||
{ timestamp, lastTimestamp, externalId, triggerSource, taskIdentifier, environmentId },
|
||||
] = useForm({
|
||||
id: "test-task-scheduled",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: TestTaskData });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form className="grid h-full max-h-full grid-rows-[1fr_2.5rem]" method="post" {...form.props}>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(triggerSource, { type: "hidden" })}
|
||||
value={"SCHEDULED"}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(taskIdentifier, { type: "hidden" })}
|
||||
value={task.taskIdentifier}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(environmentId, { type: "hidden" })}
|
||||
value={task.environment.id}
|
||||
/>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel order={1} minSize={30} defaultSize={60}>
|
||||
<div className="p-3">
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={timestamp.id}>Timestamp UTC</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(timestamp, { type: "hidden" })}
|
||||
value={timestampValue?.toISOString() ?? ""}
|
||||
/>
|
||||
<DateField
|
||||
label="Timestamp UTC"
|
||||
defaultValue={timestampValue}
|
||||
onValueChange={(val) => setTimestampValue(val)}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
variant="medium"
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the CRON, it will come through to your run in the
|
||||
payload.
|
||||
</Hint>
|
||||
<FormError id={timestamp.errorId}>{timestamp.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={lastTimestamp.id} required={false}>
|
||||
Last timestamp UTC
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(lastTimestamp, { type: "hidden" })}
|
||||
value={lastTimestampValue?.toISOString() ?? ""}
|
||||
/>
|
||||
<DateField
|
||||
label="Last timestamp UTC"
|
||||
defaultValue={lastTimestampValue}
|
||||
onValueChange={(val) => setLastTimestampValue(val)}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the previous run. You can use this in your code to find
|
||||
new data since the previous run. This can be undefined if there hasn't been a
|
||||
previous run.
|
||||
</Hint>
|
||||
<FormError id={lastTimestamp.errorId}>{lastTimestamp.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label required={false} htmlFor={externalId.id}>
|
||||
External ID
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(externalId, { type: "text" })}
|
||||
placeholder="Optionally specify your own ID, e.g. user id"
|
||||
value={externalIdValue ?? ""}
|
||||
onChange={(e) => setExternalIdValue(e.target.value)}
|
||||
/>
|
||||
<Hint>
|
||||
Optionally, you can specify your own IDs (like a user ID) and then use it inside
|
||||
the run function of your task. This allows you to have per-user CRON tasks.{" "}
|
||||
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
|
||||
</Hint>
|
||||
<FormError id={externalId.errorId}>{externalId.error}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel order={2} minSize={20} defaultSize={40}>
|
||||
<RecentPayloads
|
||||
runs={runs}
|
||||
selectedId={selectedCodeSampleId}
|
||||
onSelected={(id) => {
|
||||
const run = runs.find((r) => r.id === id);
|
||||
if (!run) return;
|
||||
setSelectedCodeSampleId(id);
|
||||
setTimestampValue(run.payload.timestamp);
|
||||
setLastTimestampValue(run.payload.lastTimestamp);
|
||||
setExternalIdValue(run.payload.externalId);
|
||||
}}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Paragraph variant="small" className="whitespace-nowrap">
|
||||
This test will run in
|
||||
</Paragraph>
|
||||
<EnvironmentLabel environment={task.environment} />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
|
||||
>
|
||||
Run test
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentPayloads({
|
||||
runs,
|
||||
selectedId,
|
||||
onSelected,
|
||||
}: {
|
||||
runs: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
number: number;
|
||||
status: TaskRunStatus;
|
||||
}[];
|
||||
selectedId?: string;
|
||||
onSelected: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
<div className="flex h-10 items-center border-b border-grid-dimmed">
|
||||
<Header2>Recent payloads</Header2>
|
||||
</div>
|
||||
{runs.length === 0 ? (
|
||||
<Callout variant="info">
|
||||
Recent payloads will show here once you've completed a Run.
|
||||
</Callout>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-charcoal-850">
|
||||
{runs.map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onSelected(run.id);
|
||||
}}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<RadioButtonCircle checked={run.id === selectedId} />
|
||||
<div className="flex flex-col items-start">
|
||||
<Paragraph variant="small">
|
||||
<DateTime date={run.createdAt} />
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<div>Run #{run.number}</div>
|
||||
<TaskRunStatusCombo status={run.status} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -27,6 +27,7 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useLinkStatus } from "~/hooks/useLinkStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -176,19 +177,22 @@ function TaskSelector({
|
||||
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
|
||||
)}
|
||||
>
|
||||
<TableCell to={path} actionClassName="px-2 py-1">
|
||||
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
|
||||
<RadioButtonCircle checked={isActive || isPending} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="px-2 py-1">
|
||||
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TaskFunctionName
|
||||
variant="extra-small"
|
||||
functionName={t.exportName}
|
||||
className="-ml-1 inline-flex"
|
||||
/>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{t.taskIdentifier}
|
||||
</Paragraph>
|
||||
<div className="flex items-start gap-1">
|
||||
<TaskTriggerSourceIcon source={t.triggerSource} className="size-3.5" />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{t.taskIdentifier}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiBatchResultsPresenter } from "~/presenters/v3/ApiBatchResultsPresenter.server";
|
||||
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
/* This is the batch friendly ID */
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid or missing run ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { batchParam } = parsed.data;
|
||||
|
||||
try {
|
||||
const presenter = new ApiBatchResultsPresenter();
|
||||
const result = await presenter.call(batchParam, authenticationResult.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Batch not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
} else {
|
||||
return json({ error: JSON.stringify(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { PrismaErrorSchema, prisma } from "~/db.server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { CancelRunService } from "~/services/runs/cancelRun.server";
|
||||
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
|
||||
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
/* This is the run friendly ID */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRunResultPresenter } from "~/presenters/v3/ApiRunResultPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
/* This is the run friendly ID */
|
||||
runParam: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid or missing run ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { runParam } = parsed.data;
|
||||
|
||||
try {
|
||||
const presenter = new ApiRunResultPresenter();
|
||||
const result = await presenter.call(runParam, authenticationResult.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run either doesn't exist or is not finished" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 500 });
|
||||
} else {
|
||||
return json({ error: JSON.stringify(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { truncateSync } from "fs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSchedule = await prisma.taskSchedule.findFirst({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSchedule) {
|
||||
return json({ error: "Schedule not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.taskSchedule.update({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
},
|
||||
data: {
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new ViewSchedulePresenter();
|
||||
|
||||
const result = await presenter.call({
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Schedule not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(presenter.toJSONResponse(result), { status: 200 });
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSchedule = await prisma.taskSchedule.findFirst({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSchedule) {
|
||||
return json({ error: "Schedule not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.taskSchedule.update({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
},
|
||||
data: {
|
||||
active: false,
|
||||
},
|
||||
});
|
||||
|
||||
const presenter = new ViewSchedulePresenter();
|
||||
|
||||
const result = await presenter.call({
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Schedule not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(presenter.toJSONResponse(result), { status: 200 });
|
||||
} catch (error) {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ScheduleObject, UpdateScheduleOptions } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { Prisma, prisma } from "~/db.server";
|
||||
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { UpsertSchedule } from "~/v3/schedules";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
scheduleId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
switch (method) {
|
||||
case "DELETE": {
|
||||
try {
|
||||
const deletedSchedule = await prisma.taskSchedule.delete({
|
||||
where: {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return json(
|
||||
{
|
||||
id: deletedSchedule.friendlyId,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
// Check if it's a Prisma error
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return json(
|
||||
{ error: error.code === "P2025" ? "Schedule not found" : error.message },
|
||||
{ status: error.code === "P2025" ? 404 : 422 }
|
||||
);
|
||||
} else {
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "PUT": {
|
||||
const rawBody = await request.json();
|
||||
|
||||
const body = UpdateScheduleOptions.safeParse(rawBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new UpsertTaskScheduleService();
|
||||
|
||||
try {
|
||||
const options: UpsertSchedule = {
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
taskIdentifier: body.data.task,
|
||||
cron: body.data.cron,
|
||||
environments: [authenticationResult.environment.id],
|
||||
externalId: body.data.externalId,
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
};
|
||||
|
||||
const schedule = await service.call(authenticationResult.environment.projectId, options);
|
||||
|
||||
const responseObject: ScheduleObject = {
|
||||
id: schedule.id,
|
||||
task: schedule.task,
|
||||
active: schedule.active,
|
||||
generator: {
|
||||
type: "CRON",
|
||||
expression: schedule.cron,
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
externalId: schedule.externalId ?? undefined,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
environments: schedule.environments,
|
||||
nextRun: schedule.nextRun,
|
||||
};
|
||||
|
||||
return json(responseObject, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: parsedParams.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const presenter = new ViewSchedulePresenter();
|
||||
|
||||
const result = await presenter.call({
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
friendlyId: parsedParams.data.scheduleId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Schedule not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(presenter.toJSONResponse(result), { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateScheduleOptions, ScheduleObject } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { UpsertSchedule } from "~/v3/schedules";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
perPage: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rawBody = await request.json();
|
||||
|
||||
const body = CreateScheduleOptions.safeParse(rawBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new UpsertTaskScheduleService();
|
||||
|
||||
try {
|
||||
const options: UpsertSchedule = {
|
||||
taskIdentifier: body.data.task,
|
||||
cron: body.data.cron,
|
||||
environments: [authenticationResult.environment.id],
|
||||
externalId: body.data.externalId,
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
};
|
||||
|
||||
const schedule = await service.call(authenticationResult.environment.projectId, options);
|
||||
|
||||
const responseObject: ScheduleObject = {
|
||||
id: schedule.id,
|
||||
task: schedule.task,
|
||||
active: schedule.active,
|
||||
generator: {
|
||||
type: "CRON",
|
||||
expression: schedule.cron,
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
externalId: schedule.externalId ?? undefined,
|
||||
deduplicationKey: schedule.deduplicationKey,
|
||||
environments: schedule.environments,
|
||||
nextRun: schedule.nextRun,
|
||||
};
|
||||
|
||||
return json(responseObject, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const rawSearchParams = new URL(request.url).searchParams;
|
||||
const params = SearchParamsSchema.safeParse(Object.fromEntries(rawSearchParams.entries()));
|
||||
|
||||
if (!params.success) {
|
||||
return json(
|
||||
{ error: "Invalid request parameters", issues: params.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const presenter = new ScheduleListPresenter();
|
||||
|
||||
const result = await presenter.call({
|
||||
projectId: authenticationResult.environment.projectId,
|
||||
page: params.data.page ?? 1,
|
||||
pageSize: params.data.perPage,
|
||||
environments: [authenticationResult.environment.id],
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.schedules.map((schedule) => ({
|
||||
id: schedule.friendlyId,
|
||||
task: schedule.taskIdentifier,
|
||||
generator: {
|
||||
type: "CRON",
|
||||
expression: schedule.cron,
|
||||
description: schedule.cronDescription,
|
||||
},
|
||||
deduplicationKey: schedule.userProvidedDeduplicationKey
|
||||
? schedule.deduplicationKey
|
||||
: undefined,
|
||||
externalId: schedule.externalId,
|
||||
active: schedule.active,
|
||||
nextRun: schedule.nextRun,
|
||||
environments: schedule.environments,
|
||||
})),
|
||||
pagination: {
|
||||
currentPage: result.currentPage,
|
||||
totalPages: result.totalPages,
|
||||
count: result.totalCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { TaskQueue } from "@trigger.dev/database";
|
||||
import { Gauge, Registry } from "prom-client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const validatedParams = ParamsSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef: validatedParams.projectRef,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId: authenticationResult.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const registry = new Registry();
|
||||
// Return prometheus metrics for the project (queues)
|
||||
|
||||
await registerProjectMetrics(registry, project.id, authenticationResult.userId);
|
||||
|
||||
return new Response(await registry.metrics(), {
|
||||
headers: {
|
||||
"Content-Type": registry.contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerProjectMetrics(
|
||||
registry: Registry,
|
||||
projectId: string,
|
||||
userId: string
|
||||
) {
|
||||
// Register project metrics here
|
||||
// Register queue metrics here
|
||||
|
||||
// Find the dev runtime environment for this project/user
|
||||
const allEnvironments = await prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
taskQueues: true,
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnv = allEnvironments[0];
|
||||
|
||||
if (firstEnv) {
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_org_queue_concurrency`),
|
||||
help: `The number of tasks currently being executed in the org environment queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.currentConcurrencyOfOrg(firstEnv);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_org_queue_concurrency_limit`),
|
||||
help: `The concurrency limit for the org queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.getOrgConcurrencyLimit(firstEnv);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_org_queue_capacity`),
|
||||
help: "The capacity of the org queue",
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const concurrencyLimit = await marqs?.getOrgConcurrencyLimit(firstEnv);
|
||||
const currentConcurrency = await marqs?.currentConcurrencyOfOrg(firstEnv);
|
||||
|
||||
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
|
||||
this.set(concurrencyLimit - currentConcurrency);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const env of allEnvironments) {
|
||||
if (env.type === "DEVELOPMENT" && env.orgMember?.userId === userId) {
|
||||
await registerEnvironmentMetrics(env, registry);
|
||||
} else if (env.type !== "DEVELOPMENT") {
|
||||
await registerEnvironmentMetrics(env, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function registerEnvironmentMetrics(
|
||||
env: AuthenticatedEnvironment & { taskQueues: TaskQueue[] },
|
||||
registry: Registry
|
||||
) {
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_concurrency`),
|
||||
help: `The number of tasks currently being executed in the dev environment queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.currentConcurrencyOfEnvironment(env);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_concurrency_limit`),
|
||||
help: `The concurrency limit for the dev environment queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.getEnvConcurrencyLimit(env);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_env_queue_${env.slug}_capacity`),
|
||||
help: `The capacity of the dev environment queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const concurrencyLimit = await marqs?.getEnvConcurrencyLimit(env);
|
||||
const currentConcurrency = await marqs?.currentConcurrencyOfEnvironment(env);
|
||||
|
||||
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
|
||||
this.set(concurrencyLimit - currentConcurrency);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for (const queue of env.taskQueues) {
|
||||
registerTaskQueueMetrics(registry, queue, env);
|
||||
}
|
||||
}
|
||||
|
||||
function registerTaskQueueMetrics(
|
||||
registry: Registry,
|
||||
queue: TaskQueue,
|
||||
env: AuthenticatedEnvironment
|
||||
) {
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_length`),
|
||||
help: `The number of tasks in the ${queue.name} queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.lengthOfQueue(env, queue.name);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_concurrency`),
|
||||
help: `The number of tasks currently being executed in the ${queue.name} queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.currentConcurrencyOfQueue(env, queue.name);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_concurrency_limit`),
|
||||
help: `The concurrency limit for the ${queue.name} queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const length = await marqs?.getQueueConcurrencyLimit(env, queue.name);
|
||||
|
||||
if (length) {
|
||||
this.set(length);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_capacity`),
|
||||
help: `The capacity of the ${queue.name} queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const concurrencyLimit = await marqs?.getQueueConcurrencyLimit(env, queue.name);
|
||||
const currentConcurrency = await marqs?.currentConcurrencyOfQueue(env, queue.name);
|
||||
|
||||
if (typeof concurrencyLimit === "number" && typeof currentConcurrency === "number") {
|
||||
this.set(concurrencyLimit - currentConcurrency);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
new Gauge({
|
||||
name: sanitizeMetricName(`trigger_${env.slug}_task_queue_${queue.name}_oldest_message_age`),
|
||||
help: `The age of the oldest message in the ${queue.name} queue`,
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const oldestMessage = await marqs?.oldestMessageInQueue(env, queue.name);
|
||||
|
||||
if (oldestMessage) {
|
||||
this.set(oldestMessage);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeMetricName(name: string) {
|
||||
return name.replace(/[^a-zA-Z0-9_]/g, "_");
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { env } from "~/env.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { humanToCron } from "~/v3/humanToCron.server";
|
||||
|
||||
const schema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return json(
|
||||
{
|
||||
isValid: false as const,
|
||||
error: "OpenAI API key is not set",
|
||||
cron: undefined,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const submission = schema.safeParse(data);
|
||||
|
||||
if (!submission.success) {
|
||||
return json(
|
||||
{
|
||||
isValid: false as const,
|
||||
error: "Invalid input",
|
||||
cron: undefined,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await humanToCron(submission.data.message, userId);
|
||||
|
||||
return json(result);
|
||||
};
|
||||
|
||||
type AIGeneratedCronFieldProps = {
|
||||
onSuccess: (cron: string) => void;
|
||||
};
|
||||
|
||||
export function AIGeneratedCronField({ onSuccess }: AIGeneratedCronFieldProps) {
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const [text, setText] = useState<string>("");
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
const resultData = fetcher.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (resultData?.cron !== undefined) {
|
||||
onSuccess(resultData.cron);
|
||||
}
|
||||
}, [resultData?.cron]);
|
||||
|
||||
const submit = useCallback(async (value: string) => {
|
||||
fetcher.submit(
|
||||
{ message: value },
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/schedules/new/natural-language`,
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>
|
||||
<AISparkleIcon className="inline-block h-4 w-4" /> Describe your schedule using natural
|
||||
language
|
||||
</Label>
|
||||
<div
|
||||
className="rounded-sm p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="rounded-[calc(0.5rem-2px)] bg-background-bright">
|
||||
<textarea
|
||||
value={text}
|
||||
placeholder="e.g. the last Friday of the month at 6am"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="m-0 w-full border-0 bg-background-bright px-3 py-2 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? "spinner" : AISparkleIcon}
|
||||
onClick={() => submit(text)}
|
||||
>
|
||||
{isLoading ? "Generating" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{resultData?.isValid === false ? (
|
||||
<FormError className="mt-2">{resultData.error}</FormError>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { parseExpression } from "cron-parser";
|
||||
import cronstrue from "cronstrue";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
environmentTextClassName,
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, docsPath, v3SchedulesPath } from "~/utils/pathBuilder";
|
||||
import { CronPattern, UpsertSchedule } from "~/v3/schedules";
|
||||
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
|
||||
import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language";
|
||||
|
||||
const cronFormat = `* * * * *
|
||||
┬ ┬ ┬ ┬ ┬
|
||||
│ │ │ │ |
|
||||
│ │ │ │ └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun)
|
||||
│ │ │ └───── month (1 - 12)
|
||||
│ │ └────────── day of month (1 - 31, L)
|
||||
│ └─────────────── hour (0 - 23)
|
||||
└──────────────────── minute (0 - 59)`;
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: UpsertSchedule });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
//first check that the user has access to the project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const createSchedule = new UpsertTaskScheduleService();
|
||||
const result = await createSchedule.call(project.id, submission.value);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"
|
||||
);
|
||||
} catch (error: any) {
|
||||
submission.error.taskIdentifier =
|
||||
error instanceof Error ? error.message : JSON.stringify(error);
|
||||
return json(submission, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
type CronPatternResult =
|
||||
| {
|
||||
isValid: true;
|
||||
description: string;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function UpsertScheduleForm({
|
||||
schedule,
|
||||
possibleTasks,
|
||||
possibleEnvironments,
|
||||
showGenerateField,
|
||||
}: EditableScheduleElements & { showGenerateField: boolean }) {
|
||||
const lastSubmission = useActionData();
|
||||
const [cronPattern, setCronPattern] = useState<string>(schedule?.cron ?? "");
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const location = useLocation();
|
||||
|
||||
const [form, { taskIdentifier, cron, externalId, environments, deduplicationKey }] = useForm({
|
||||
id: "create-schedule",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: UpsertSchedule });
|
||||
},
|
||||
});
|
||||
|
||||
let cronPatternResult: CronPatternResult | undefined = undefined;
|
||||
let nextRuns: Date[] | undefined = undefined;
|
||||
if (cronPattern !== "") {
|
||||
const result = CronPattern.safeParse(cronPattern);
|
||||
|
||||
if (!result.success) {
|
||||
cronPatternResult = {
|
||||
isValid: false,
|
||||
error: result.error.errors[0].message,
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
const expression = parseExpression(cronPattern, { utc: true });
|
||||
cronPatternResult = {
|
||||
isValid: true,
|
||||
description: cronstrue.toString(cronPattern),
|
||||
};
|
||||
nextRuns = Array.from({ length: 5 }, (_, i) => {
|
||||
const utc = expression.next().toDate();
|
||||
return utc;
|
||||
});
|
||||
} catch (e) {
|
||||
cronPatternResult = {
|
||||
isValid: false,
|
||||
error: e instanceof Error ? e.message : JSON.stringify(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mode = schedule ? "edit" : "new";
|
||||
|
||||
return (
|
||||
<Form
|
||||
method="post"
|
||||
action={`/resources/orgs/${organization.slug}/projects/${project.slug}/schedules/new`}
|
||||
{...form.props}
|
||||
className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright"
|
||||
>
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>
|
||||
{schedule?.friendlyId ? "Edit schedule" : "New schedule"}
|
||||
</Header2>
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="p-3">
|
||||
{schedule && <input type="hidden" name="friendlyId" value={schedule.friendlyId} />}
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={taskIdentifier.id}>Task</Label>
|
||||
<SelectGroup>
|
||||
<Select
|
||||
{...conform.input(taskIdentifier, { type: "select" })}
|
||||
defaultValue={schedule?.taskIdentifier}
|
||||
>
|
||||
<SelectTrigger size="medium" width="full">
|
||||
<SelectValue placeholder="Select task" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{possibleTasks.map((task) => (
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<FormError id={taskIdentifier.errorId}>{taskIdentifier.error}</FormError>
|
||||
</InputGroup>
|
||||
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
|
||||
<InputGroup>
|
||||
<Label
|
||||
htmlFor={cron.id}
|
||||
tooltip={
|
||||
<div className="spacy-y-3">
|
||||
<Paragraph variant="extra-small">We support this CRON format:</Paragraph>
|
||||
<code>
|
||||
<pre>{cronFormat}</pre>
|
||||
</code>
|
||||
<Paragraph variant="extra-small">"L" means the last.</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
CRON pattern (UTC)
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(cron, { type: "text" })}
|
||||
placeholder="? ? ? ? ?"
|
||||
required={true}
|
||||
value={cronPattern}
|
||||
onChange={(e) => {
|
||||
setCronPattern(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{cronPatternResult === undefined ? (
|
||||
<Hint>Enter a CRON pattern or use natural language above.</Hint>
|
||||
) : cronPatternResult.isValid ? (
|
||||
<ValidCronMessage isValid={true} message={`${cronPatternResult.description}.`} />
|
||||
) : (
|
||||
<ValidCronMessage isValid={false} message={cronPatternResult.error} />
|
||||
)}
|
||||
</InputGroup>
|
||||
{nextRuns !== undefined && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Next 5 runs</Header3>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>UTC</TableHeaderCell>
|
||||
<TableHeaderCell>Local time</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nextRuns.map((run, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<DateTime date={run} timeZone="UTC" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateTime date={run} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<InputGroup>
|
||||
<Label>Environments</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{possibleEnvironments.map((environment) => (
|
||||
<Checkbox
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
name="environments"
|
||||
type="radio"
|
||||
label={
|
||||
<span
|
||||
className={cn("text-xs uppercase", environmentTextClassName(environment))}
|
||||
>
|
||||
{environmentTitle(environment, environment.userName)}
|
||||
</span>
|
||||
}
|
||||
defaultChecked={
|
||||
schedule?.instances.find((i) => i.environmentId === environment.id) !==
|
||||
undefined
|
||||
}
|
||||
variant="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Hint>
|
||||
Select all the environments where you want this schedule to run. Note that scheduled
|
||||
tasks in dev environments will only run while you are connected with the dev CLI
|
||||
</Hint>
|
||||
<FormError id={environments.errorId}>{environments.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label required={false} htmlFor={externalId.id}>
|
||||
External ID
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(externalId, { type: "text" })}
|
||||
placeholder="Optionally specify your own ID, e.g. user id"
|
||||
defaultValue={schedule?.externalId ?? undefined}
|
||||
/>
|
||||
<Hint>
|
||||
Optionally, you can specify your own IDs (like a user ID) and then use it inside the
|
||||
run function of your task. This allows you to have per-user CRON tasks.{" "}
|
||||
<TextLink to={docsPath("v3/tasks-scheduled")}>Read the docs.</TextLink>
|
||||
</Hint>
|
||||
<FormError id={externalId.errorId}>{externalId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label required={false} htmlFor={deduplicationKey.id}>
|
||||
Deduplication key
|
||||
</Label>
|
||||
<Input
|
||||
{...conform.input(deduplicationKey, { type: "text" })}
|
||||
disabled={schedule !== undefined}
|
||||
defaultValue={
|
||||
schedule?.userProvidedDeduplicationKey ? schedule?.deduplicationKey : undefined
|
||||
}
|
||||
/>
|
||||
{schedule && (
|
||||
<Paragraph variant="small">
|
||||
You can't edit the Deduplication key on an existing schedule.
|
||||
</Paragraph>
|
||||
)}
|
||||
<Hint>
|
||||
Optionally specify a key, you can only create one schedule with this key. This is
|
||||
very useful when using the SDK and you don't want to create duplicate schedules for
|
||||
a user.
|
||||
</Hint>
|
||||
<FormError id={deduplicationKey.errorId}>{deduplicationKey.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
to={`${v3SchedulesPath(organization, project)}${location.search}`}
|
||||
variant="minimal/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
shortcut={{ key: "enter", modifiers: ["meta"] }}
|
||||
LeadingIcon={isLoading ? "spinner" : undefined}
|
||||
>
|
||||
{buttonText(mode, isLoading)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonText(mode: "edit" | "new", isLoading: boolean) {
|
||||
switch (mode) {
|
||||
case "edit":
|
||||
return isLoading ? "Updating schedule" : "Update schedule";
|
||||
case "new":
|
||||
return isLoading ? "Creating schedule" : "Create schedule";
|
||||
}
|
||||
}
|
||||
|
||||
function ValidCronMessage({ isValid, message }: { isValid: boolean; message: string }) {
|
||||
return (
|
||||
<Paragraph variant="small">
|
||||
<span className="mr-1">
|
||||
{isValid ? (
|
||||
<CheckIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-success" />
|
||||
) : (
|
||||
<XMarkIcon className="-mt-0.5 mr-1 inline-block h-4 w-4 text-error" />
|
||||
)}
|
||||
<span className={isValid ? "text-success" : "text-error"}>
|
||||
{isValid ? "Valid pattern:" : "Invalid pattern:"}
|
||||
</span>
|
||||
</span>
|
||||
<span>{message}</span>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DateField } from "~/components/primitives/DateField";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { AbsoluteTimeFrame } from "~/components/runs/TimeFrameFilter";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="m-8 space-y-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header2>Size = small</Header2>
|
||||
<DateField label="From (UTC)" granularity="second" showNowButton showClearButton />
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date()}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
<AbsoluteTimeFrame onValueChange={() => {}} />
|
||||
<AbsoluteTimeFrame
|
||||
from={new Date(Date.now() - 1000 * 60 * 60)}
|
||||
to={new Date()}
|
||||
onValueChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header2>Size = medium</Header2>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date()}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { TimeFrameFilter } from "~/components/runs/TimeFrameFilter";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
@@ -64,14 +56,14 @@ function InputFieldSet({ disabled }: { disabled?: boolean }) {
|
||||
disabled={disabled}
|
||||
variant="large"
|
||||
placeholder="Search"
|
||||
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
|
||||
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} size="large" />}
|
||||
shortcut="⌘K"
|
||||
/>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
variant="medium"
|
||||
placeholder="Search"
|
||||
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
|
||||
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} size="large" />}
|
||||
shortcut="⌘K"
|
||||
/>
|
||||
<Input
|
||||
|
||||
@@ -114,6 +114,10 @@ const stories: Story[] = [
|
||||
},
|
||||
{
|
||||
sectionTitle: "Forms",
|
||||
name: "Date fields",
|
||||
slug: "date-fields",
|
||||
},
|
||||
{
|
||||
name: "Simple form",
|
||||
slug: "simple-form",
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ResumeTaskDependencyService } from "~/v3/services/resumeTaskDependency.
|
||||
import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
|
||||
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -132,6 +133,9 @@ const workerCatalog = {
|
||||
"v3.executeTasksWaitingForDeploy": z.object({
|
||||
backgroundWorkerId: z.string(),
|
||||
}),
|
||||
"v3.triggerScheduledTask": z.object({
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
@@ -520,6 +524,15 @@ function getWorkerQueue() {
|
||||
return await service.call(payload.backgroundWorkerId);
|
||||
},
|
||||
},
|
||||
"v3.triggerScheduledTask": {
|
||||
priority: 0,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new TriggerScheduledTaskService();
|
||||
|
||||
return await service.call(payload.instanceId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function isValidDatabaseUrl(url: string) {
|
||||
try {
|
||||
const databaseUrl = new URL(url);
|
||||
const schemaFromSearchParam = databaseUrl.searchParams.get("schema");
|
||||
|
||||
if (schemaFromSearchParam === "") {
|
||||
console.error(
|
||||
"Invalid Database URL: The schema search param can't have an empty value. To use the `public` schema, either omit the schema param entirely or specify it in full: `?schema=public`"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import { Job } from "~/models/job.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import { objectToSearchParams } from "./searchParams";
|
||||
import { ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
|
||||
export type OrgForPath = Pick<Organization, "slug">;
|
||||
export type ProjectForPath = Pick<Project, "slug">;
|
||||
@@ -90,6 +92,10 @@ export const v3DeploymentParams = ProjectParamSchema.extend({
|
||||
deploymentParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3ScheduleParams = ProjectParamSchema.extend({
|
||||
scheduleParam: z.string(),
|
||||
});
|
||||
|
||||
export function trimTrailingSlash(path: string) {
|
||||
return path.replace(/\/$/, "");
|
||||
}
|
||||
@@ -382,6 +388,30 @@ export function v3RunStreamingPath(
|
||||
return `${v3RunPath(organization, project, run)}/stream`;
|
||||
}
|
||||
|
||||
export function v3SchedulesPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/schedules`;
|
||||
}
|
||||
|
||||
export function v3SchedulePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
schedule: { friendlyId: string }
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/schedules/${schedule.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3EditSchedulePath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
schedule: { friendlyId: string }
|
||||
) {
|
||||
return `${v3ProjectPath(organization, project)}/schedules/edit/${schedule.friendlyId}`;
|
||||
}
|
||||
|
||||
export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/schedules/new`;
|
||||
}
|
||||
|
||||
export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/settings`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ export class AuthenticatedSocketConnection {
|
||||
private _consumer: DevQueueConsumer;
|
||||
private _messageHandler: ZodMessageHandler<typeof clientWebsocketMessages>;
|
||||
|
||||
constructor(public ws: WebSocket, public authenticatedEnv: AuthenticatedEnvironment) {
|
||||
constructor(
|
||||
public ws: WebSocket,
|
||||
public authenticatedEnv: AuthenticatedEnvironment,
|
||||
private readonly ipAddress: string | string[]
|
||||
) {
|
||||
this.id = randomUUID();
|
||||
|
||||
this._sender = new ZodMessageSender({
|
||||
@@ -42,7 +46,9 @@ export class AuthenticatedSocketConnection {
|
||||
},
|
||||
});
|
||||
|
||||
this._consumer = new DevQueueConsumer(authenticatedEnv, this._sender);
|
||||
this._consumer = new DevQueueConsumer(authenticatedEnv, this._sender, {
|
||||
ipAddress: Array.isArray(this.ipAddress) ? this.ipAddress.join(", ") : this.ipAddress,
|
||||
});
|
||||
|
||||
ws.addEventListener("message", this.#handleMessage.bind(this));
|
||||
ws.addEventListener("close", this.#handleClose.bind(this));
|
||||
|
||||
@@ -63,6 +63,7 @@ export type TraceAttributes = Partial<
|
||||
| "batchId"
|
||||
| "payload"
|
||||
| "payloadType"
|
||||
| "idempotencyKey"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -72,6 +73,7 @@ export type TraceEventOptions = {
|
||||
kind?: CreatableEventKind;
|
||||
context?: Record<string, string | undefined>;
|
||||
spanParentAsLink?: boolean;
|
||||
parentAsLinkType?: "trigger" | "replay";
|
||||
spanIdSeed?: string;
|
||||
attributes: TraceAttributes;
|
||||
environment: AuthenticatedEnvironment;
|
||||
@@ -370,6 +372,7 @@ export class EventRepository {
|
||||
id: event.spanId,
|
||||
parentId: event.parentId ?? undefined,
|
||||
runId: event.runId,
|
||||
idempotencyKey: event.idempotencyKey,
|
||||
data: {
|
||||
message: event.message,
|
||||
style: event.style,
|
||||
@@ -458,7 +461,7 @@ export class EventRepository {
|
||||
const links: SpanLink[] = [];
|
||||
|
||||
if (messagingEvent.success && messagingEvent.data) {
|
||||
if ("id" in messagingEvent.data.message) {
|
||||
if (messagingEvent.data.message && "id" in messagingEvent.data.message) {
|
||||
if (messagingEvent.data.message.id.startsWith("run_")) {
|
||||
links.push({
|
||||
type: "run",
|
||||
@@ -474,10 +477,14 @@ export class EventRepository {
|
||||
|
||||
if (backLinks && backLinks.length > 0) {
|
||||
backLinks.forEach((l) => {
|
||||
const title = String(
|
||||
l.attributes?.[SemanticInternalAttributes.LINK_TITLE] ?? "Triggered by"
|
||||
);
|
||||
|
||||
links.push({
|
||||
type: "span",
|
||||
icon: "trigger",
|
||||
title: `Triggered by`,
|
||||
title,
|
||||
traceId: l.context.traceId,
|
||||
spanId: l.context.spanId,
|
||||
});
|
||||
@@ -619,6 +626,10 @@ export class EventRepository {
|
||||
spanId: propagatedContext.traceparent.spanId,
|
||||
traceFlags: TraceFlags.SAMPLED,
|
||||
},
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.LINK_TITLE]:
|
||||
options.parentAsLinkType === "replay" ? "Replay of" : "Triggered by",
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -710,6 +721,7 @@ export class EventRepository {
|
||||
links: links as unknown as Prisma.InputJsonValue,
|
||||
payload: options.attributes.payload,
|
||||
payloadType: options.attributes.payloadType,
|
||||
idempotencyKey: options.attributes.idempotencyKey,
|
||||
};
|
||||
|
||||
if (options.immediate) {
|
||||
|
||||
@@ -31,6 +31,10 @@ function initalizeWebSocketServer() {
|
||||
}
|
||||
|
||||
async function handleWebSocketConnection(ws: WebSocket, req: IncomingMessage) {
|
||||
logger.debug("Handle websocket connection", {
|
||||
ipAddress: req.headers["x-forwarded-for"] || req.socket.remoteAddress,
|
||||
});
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || typeof authHeader !== "string") {
|
||||
@@ -54,7 +58,11 @@ async function handleWebSocketConnection(ws: WebSocket, req: IncomingMessage) {
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const authenticatedConnection = new AuthenticatedSocketConnection(ws, authenticatedEnv);
|
||||
const authenticatedConnection = new AuthenticatedSocketConnection(
|
||||
ws,
|
||||
authenticatedEnv,
|
||||
req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown"
|
||||
);
|
||||
|
||||
authenticatedConnections.set(authenticatedConnection.id, authenticatedConnection);
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
|
||||
export const HumanToCronResult = z.object({
|
||||
isValid: z.boolean(),
|
||||
cron: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export type HumanToCronResult = z.infer<typeof HumanToCronResult>;
|
||||
|
||||
export const humanToCronSupported = typeof env.OPENAI_API_KEY === "string";
|
||||
|
||||
export async function humanToCron(message: string, userId: string): Promise<HumanToCronResult> {
|
||||
if (!humanToCronSupported) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "OpenAI API key is not set",
|
||||
};
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY });
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-3.5-turbo-1106",
|
||||
user: userId,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a helpful assistant who will turn nautral language into a valid CRON expresion.
|
||||
|
||||
The version of CRON that we use is an extension of the minimal.
|
||||
|
||||
* * * * *
|
||||
┬ ┬ ┬ ┬ ┬
|
||||
│ │ │ │ |
|
||||
│ │ │ │ └ day of week (0 - 7, 1L - 7L) (0 or 7 is Sun)
|
||||
│ │ │ └───── month (1 - 12)
|
||||
│ │ └────────── day of month (1 - 31, L)
|
||||
│ └─────────────── hour (0 - 23)
|
||||
└──────────────────── minute (0 - 59)
|
||||
|
||||
Supports mixed use of ranges and range increments (W character not supported currently). See tests for examples.
|
||||
|
||||
Return JSON in one of these formats, putting in the correct data where you see <THE CRON EXPRESSION> and <ERROR MESSAGE DESCRIBING WHY IT'S NOT VALID>:
|
||||
1. If it's valid: { "isValid": true, "cron": "<THE CRON EXPRESSION>" }
|
||||
2. If it's not possible to make a valid CRON expression: { "isValid": false, "error": "<ERROR MESSAGE DESCRIBING WHY IT'S NOT VALID>"}`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `What is a valid CRON expression for this: ${message}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
||||
if (!completion.choices[0]?.message.content) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "No response from OpenAI",
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("OpenAI response", {
|
||||
completion,
|
||||
});
|
||||
|
||||
const jsonResponse = safeJsonParse(completion.choices[0].message.content);
|
||||
|
||||
if (!jsonResponse) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Invalid response from OpenAI",
|
||||
};
|
||||
}
|
||||
|
||||
const parsedResponse = HumanToCronResult.safeParse(jsonResponse);
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid response from OpenAI: ${parsedResponse.error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
return parsedResponse.data;
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
import { BackgroundWorker, BackgroundWorkerTask } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createNewSession, disconnectSession } from "~/models/runtimeEnvironment.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { CancelAttemptService } from "../services/cancelAttempt.server";
|
||||
import { CancelTaskRunService } from "../services/cancelTaskRun.server";
|
||||
import { CompleteAttemptService } from "../services/completeAttempt.server";
|
||||
import {
|
||||
SEMINTATTRS_FORCE_RECORDING,
|
||||
@@ -22,7 +24,6 @@ import {
|
||||
tracer,
|
||||
} from "../tracer.server";
|
||||
import { DevSubscriber, devPubSub } from "./devPubSub.server";
|
||||
import { CancelTaskRunService } from "../services/cancelTaskRun.server";
|
||||
|
||||
const MessageBody = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
@@ -36,6 +37,7 @@ type BackgroundWorkerWithTasks = BackgroundWorker & { tasks: BackgroundWorkerTas
|
||||
export type DevQueueConsumerOptions = {
|
||||
maximumItemsPerTrace?: number;
|
||||
traceTimeoutSeconds?: number;
|
||||
ipAddress?: string;
|
||||
};
|
||||
|
||||
export class DevQueueConsumer {
|
||||
@@ -108,7 +110,7 @@ export class DevQueueConsumer {
|
||||
this._backgroundWorkerSubscriber.set(backgroundWorker.id, subscriber);
|
||||
|
||||
// Start reading from the queue if we haven't already
|
||||
this.#enable();
|
||||
await this.#enable();
|
||||
}
|
||||
|
||||
public async taskAttemptCompleted(
|
||||
@@ -116,7 +118,7 @@ export class DevQueueConsumer {
|
||||
completion: TaskRunExecutionResult,
|
||||
execution: TaskRunExecution
|
||||
) {
|
||||
this._inProgressAttempts.delete(completion.id);
|
||||
this._inProgressAttempts.delete(execution.attempt.id);
|
||||
|
||||
if (completion.ok) {
|
||||
this._taskSuccesses++;
|
||||
@@ -155,6 +157,9 @@ export class DevQueueConsumer {
|
||||
|
||||
this._enabled = false;
|
||||
|
||||
// Create the session
|
||||
await disconnectSession(this.env.id);
|
||||
|
||||
// We need to cancel all the in progress task run attempts and ack the messages so they will stop processing
|
||||
await this.#cancelInProgressRunsAndAttempts(reason);
|
||||
|
||||
@@ -261,11 +266,14 @@ export class DevQueueConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
#enable() {
|
||||
async #enable() {
|
||||
if (this._enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the session
|
||||
await createNewSession(this.env, this._options.ipAddress ?? "unknown");
|
||||
|
||||
this._enabled = true;
|
||||
this._perTraceCountdown = this._options.maximumItemsPerTrace;
|
||||
this._lastNewTrace = new Date();
|
||||
@@ -416,7 +424,7 @@ export class DevQueueConsumer {
|
||||
orderBy: { number: "desc" },
|
||||
},
|
||||
tags: true,
|
||||
batchItem: {
|
||||
batchItems: {
|
||||
include: {
|
||||
batchTaskRun: true,
|
||||
},
|
||||
@@ -491,6 +499,7 @@ export class DevQueueConsumer {
|
||||
createdAt: lockedTaskRun.createdAt,
|
||||
tags: lockedTaskRun.tags.map((tag) => tag.name),
|
||||
isTest: lockedTaskRun.isTest,
|
||||
idempotencyKey: lockedTaskRun.idempotencyKey ?? undefined,
|
||||
},
|
||||
queue: {
|
||||
id: queue.friendlyId,
|
||||
@@ -512,9 +521,10 @@ export class DevQueueConsumer {
|
||||
slug: this.env.project.slug,
|
||||
name: this.env.project.name,
|
||||
},
|
||||
batch: lockedTaskRun.batchItem?.batchTaskRun
|
||||
? { id: lockedTaskRun.batchItem.batchTaskRun.friendlyId }
|
||||
: undefined,
|
||||
batch:
|
||||
lockedTaskRun.batchItems[0] && lockedTaskRun.batchItems[0].batchTaskRun
|
||||
? { id: lockedTaskRun.batchItems[0].batchTaskRun.friendlyId }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const environmentRepository = new EnvironmentVariablesRepository();
|
||||
|
||||
@@ -39,7 +39,6 @@ const SemanticAttributes = {
|
||||
|
||||
export type MarQSOptions = {
|
||||
redis: RedisOptions;
|
||||
defaultQueueConcurrency: number;
|
||||
defaultEnvConcurrency: number;
|
||||
defaultOrgConcurrency: number;
|
||||
windowSize?: number;
|
||||
@@ -55,19 +54,21 @@ export type MarQSOptions = {
|
||||
*/
|
||||
export class MarQS {
|
||||
private redis: Redis;
|
||||
private keys: MarQSKeyProducer;
|
||||
public keys: MarQSKeyProducer;
|
||||
private queuePriorityStrategy: MarQSQueuePriorityStrategy;
|
||||
#requeueingWorkers: Array<AsyncWorker> = [];
|
||||
#rebalanceWorkers: Array<AsyncWorker> = [];
|
||||
|
||||
constructor(private readonly options: MarQSOptions) {
|
||||
this.redis = new Redis(options.redis);
|
||||
|
||||
// Spawn options.workers workers to requeue visible messages
|
||||
this.#startRequeuingWorkers();
|
||||
this.#registerCommands();
|
||||
|
||||
this.keys = options.keysProducer;
|
||||
this.queuePriorityStrategy = options.queuePriorityStrategy;
|
||||
|
||||
// Spawn options.workers workers to requeue visible messages
|
||||
this.#startRequeuingWorkers();
|
||||
this.#startRebalanceWorkers();
|
||||
this.#registerCommands();
|
||||
}
|
||||
|
||||
public async updateQueueConcurrencyLimits(
|
||||
@@ -87,6 +88,68 @@ export class MarQS {
|
||||
});
|
||||
}
|
||||
|
||||
public async getQueueConcurrencyLimit(env: AuthenticatedEnvironment, queue: string) {
|
||||
const result = await this.redis.get(this.keys.queueConcurrencyLimitKey(env, queue));
|
||||
|
||||
return result ? Number(result) : undefined;
|
||||
}
|
||||
|
||||
public async getEnvConcurrencyLimit(env: AuthenticatedEnvironment) {
|
||||
const result = await this.redis.get(this.keys.envConcurrencyLimitKey(env));
|
||||
|
||||
return result ? Number(result) : this.options.defaultEnvConcurrency;
|
||||
}
|
||||
|
||||
public async getOrgConcurrencyLimit(env: AuthenticatedEnvironment) {
|
||||
const result = await this.redis.get(this.keys.orgConcurrencyLimitKey(env));
|
||||
|
||||
return result ? Number(result) : this.options.defaultOrgConcurrency;
|
||||
}
|
||||
|
||||
public async lengthOfQueue(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
concurrencyKey?: string
|
||||
) {
|
||||
return this.redis.zcard(this.keys.queueKey(env, queue, concurrencyKey));
|
||||
}
|
||||
|
||||
public async oldestMessageInQueue(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
concurrencyKey?: string
|
||||
) {
|
||||
// Get the "score" of the sorted set to get the oldest message score
|
||||
const result = await this.redis.zrange(
|
||||
this.keys.queueKey(env, queue, concurrencyKey),
|
||||
0,
|
||||
0,
|
||||
"WITHSCORES"
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Number(result[1]);
|
||||
}
|
||||
|
||||
public async currentConcurrencyOfQueue(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
concurrencyKey?: string
|
||||
) {
|
||||
return this.redis.scard(this.keys.currentConcurrencyKey(env, queue, concurrencyKey));
|
||||
}
|
||||
|
||||
public async currentConcurrencyOfEnvironment(env: AuthenticatedEnvironment) {
|
||||
return this.redis.scard(this.keys.envCurrentConcurrencyKey(env));
|
||||
}
|
||||
|
||||
public async currentConcurrencyOfOrg(env: AuthenticatedEnvironment) {
|
||||
return this.redis.scard(this.keys.orgCurrentConcurrencyKey(env));
|
||||
}
|
||||
|
||||
public async enqueueMessage(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
@@ -178,6 +241,21 @@ export class MarQS {
|
||||
[SemanticAttributes.CONCURRENCY_KEY]: message.concurrencyKey,
|
||||
[SemanticAttributes.PARENT_QUEUE]: message.parentQueue,
|
||||
});
|
||||
} else {
|
||||
logger.error("Failed to read message, undoing the dequeueing of the message", {
|
||||
messageData,
|
||||
});
|
||||
|
||||
await this.#callAcknowledgeMessage({
|
||||
parentQueue,
|
||||
messageKey: this.keys.messageKey(messageData.messageId),
|
||||
messageQueue: messageQueue,
|
||||
visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE,
|
||||
concurrencyKey: this.keys.currentConcurrencyKeyFromQueue(messageQueue),
|
||||
envConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(messageQueue),
|
||||
orgConcurrencyKey: this.keys.orgCurrentConcurrencyKeyFromQueue(messageQueue),
|
||||
messageId: messageData.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
@@ -272,7 +350,9 @@ export class MarQS {
|
||||
});
|
||||
|
||||
await this.#callAcknowledgeMessage({
|
||||
parentQueue: message.parentQueue,
|
||||
messageKey: this.keys.messageKey(messageId),
|
||||
messageQueue: message.queue,
|
||||
visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE,
|
||||
concurrencyKey: this.keys.currentConcurrencyKeyFromQueue(message.queue),
|
||||
envConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(message.queue),
|
||||
@@ -313,7 +393,9 @@ export class MarQS {
|
||||
});
|
||||
|
||||
await this.#callAcknowledgeMessage({
|
||||
parentQueue: oldMessage.parentQueue,
|
||||
messageKey: this.keys.messageKey(messageId),
|
||||
messageQueue: oldMessage.queue,
|
||||
visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE,
|
||||
concurrencyKey: this.keys.currentConcurrencyKeyFromQueue(oldMessage.queue),
|
||||
envConcurrencyKey: this.keys.envCurrentConcurrencyKeyFromQueue(oldMessage.queue),
|
||||
@@ -556,6 +638,17 @@ export class MarQS {
|
||||
return result;
|
||||
}
|
||||
|
||||
#startRebalanceWorkers() {
|
||||
// Start a new worker to rebalance parent queues periodically
|
||||
for (let i = 0; i < this.options.workers; i++) {
|
||||
const worker = new AsyncWorker(this.#rebalanceParentQueues.bind(this), 60_000);
|
||||
|
||||
this.#rebalanceWorkers.push(worker);
|
||||
|
||||
worker.start();
|
||||
}
|
||||
}
|
||||
|
||||
#startRequeuingWorkers() {
|
||||
// Start a new worker to requeue visible messages
|
||||
for (let i = 0; i < this.options.workers; i++) {
|
||||
@@ -616,6 +709,106 @@ export class MarQS {
|
||||
}
|
||||
}
|
||||
|
||||
async #rebalanceParentQueues() {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
// Scan for sorted sets with the parent queue pattern
|
||||
const pattern = this.keys.sharedQueueScanPattern();
|
||||
const redis = this.redis.duplicate();
|
||||
const stream = redis.scanStream({
|
||||
match: pattern,
|
||||
type: "zset",
|
||||
count: 100,
|
||||
});
|
||||
|
||||
logger.debug("Streaming parent queues based on pattern", {
|
||||
pattern,
|
||||
component: "marqs",
|
||||
operation: "rebalanceParentQueues",
|
||||
});
|
||||
|
||||
stream.on("data", async (keys) => {
|
||||
stream.pause();
|
||||
|
||||
const uniqueKeys = Array.from(new Set<string>(keys));
|
||||
|
||||
logger.debug("Rebalancing parent queues", {
|
||||
component: "marqs",
|
||||
operation: "rebalanceParentQueues",
|
||||
parentQueues: uniqueKeys,
|
||||
});
|
||||
|
||||
Promise.all(
|
||||
uniqueKeys.map(async (key) => this.#rebalanceParentQueue(this.keys.stripKeyPrefix(key)))
|
||||
).finally(() => {
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
redis.quit().finally(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (e) => {
|
||||
redis.quit().finally(() => {
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Parent queue is a sorted set, the values of which are queue keys and the scores are is the oldest message in the queue
|
||||
// We need to scan the parent queue and rebalance the queues based on the oldest message in the queue
|
||||
async #rebalanceParentQueue(parentQueue: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const redis = this.redis.duplicate();
|
||||
|
||||
const stream = redis.zscanStream(parentQueue, {
|
||||
match: "*",
|
||||
count: 100,
|
||||
});
|
||||
|
||||
stream.on("data", async (childQueues) => {
|
||||
stream.pause();
|
||||
|
||||
// childQueues is a flat array but of the form [queue1, score1, queue2, score2, ...], we want to group them into pairs
|
||||
const childQueuesWithScores: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < childQueues.length; i += 2) {
|
||||
childQueuesWithScores[childQueues[i]] = childQueues[i + 1];
|
||||
}
|
||||
|
||||
logger.debug("Rebalancing child queues", {
|
||||
parentQueue,
|
||||
childQueuesWithScores,
|
||||
component: "marqs",
|
||||
operation: "rebalanceParentQueues",
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(childQueuesWithScores).map(async ([childQueue, currentScore]) =>
|
||||
this.#callRebalanceParentQueueChild({ parentQueue, childQueue, currentScore })
|
||||
)
|
||||
).finally(() => {
|
||||
stream.resume();
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
redis.quit().finally(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (e) => {
|
||||
redis.quit().finally(() => {
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async #callEnqueueMessage(message: MessagePayload) {
|
||||
logger.debug("Calling enqueueMessage", {
|
||||
messagePayload: message,
|
||||
@@ -666,7 +859,6 @@ export class MarQS {
|
||||
messageQueue,
|
||||
String(this.options.visibilityTimeoutInMs ?? 300000), // 5 minutes
|
||||
String(Date.now()),
|
||||
String(this.options.defaultQueueConcurrency),
|
||||
String(this.options.defaultEnvConcurrency),
|
||||
String(this.options.defaultOrgConcurrency)
|
||||
);
|
||||
@@ -690,14 +882,18 @@ export class MarQS {
|
||||
}
|
||||
|
||||
async #callAcknowledgeMessage({
|
||||
parentQueue,
|
||||
messageKey,
|
||||
messageQueue,
|
||||
visibilityQueue,
|
||||
concurrencyKey,
|
||||
envConcurrencyKey,
|
||||
orgConcurrencyKey,
|
||||
messageId,
|
||||
}: {
|
||||
parentQueue: string;
|
||||
messageKey: string;
|
||||
messageQueue: string;
|
||||
visibilityQueue: string;
|
||||
concurrencyKey: string;
|
||||
envConcurrencyKey: string;
|
||||
@@ -706,20 +902,25 @@ export class MarQS {
|
||||
}) {
|
||||
logger.debug("Calling acknowledgeMessage", {
|
||||
messageKey,
|
||||
messageQueue,
|
||||
visibilityQueue,
|
||||
concurrencyKey,
|
||||
envConcurrencyKey,
|
||||
orgConcurrencyKey,
|
||||
messageId,
|
||||
parentQueue,
|
||||
});
|
||||
|
||||
return this.redis.acknowledgeMessage(
|
||||
parentQueue,
|
||||
messageKey,
|
||||
messageQueue,
|
||||
visibilityQueue,
|
||||
concurrencyKey,
|
||||
envConcurrencyKey,
|
||||
orgConcurrencyKey,
|
||||
messageId
|
||||
messageId,
|
||||
messageQueue
|
||||
);
|
||||
}
|
||||
|
||||
@@ -812,16 +1013,22 @@ export class MarQS {
|
||||
concurrencyLimitKey,
|
||||
envConcurrencyLimitKey,
|
||||
orgConcurrencyLimitKey,
|
||||
String(this.options.defaultQueueConcurrency),
|
||||
String(this.options.defaultEnvConcurrency),
|
||||
String(this.options.defaultOrgConcurrency)
|
||||
);
|
||||
|
||||
const queueCurrent = Number(capacities[0]);
|
||||
const envLimit = Number(capacities[3]);
|
||||
const orgLimit = Number(capacities[5]);
|
||||
const queueLimit = capacities[1] ? Number(capacities[1]) : Math.min(envLimit, orgLimit);
|
||||
const envCurrent = Number(capacities[2]);
|
||||
const orgCurrent = Number(capacities[4]);
|
||||
|
||||
// [queue current, queue limit, env current, env limit, org current, org limit]
|
||||
return {
|
||||
queue: { current: Number(capacities[0]), limit: Number(capacities[1]) },
|
||||
env: { current: Number(capacities[2]), limit: Number(capacities[3]) },
|
||||
org: { current: Number(capacities[4]), limit: Number(capacities[5]) },
|
||||
queue: { current: queueCurrent, limit: queueLimit },
|
||||
env: { current: envCurrent, limit: envLimit },
|
||||
org: { current: orgCurrent, limit: orgLimit },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -844,6 +1051,35 @@ export class MarQS {
|
||||
);
|
||||
}
|
||||
|
||||
async #callRebalanceParentQueueChild({
|
||||
parentQueue,
|
||||
childQueue,
|
||||
currentScore,
|
||||
}: {
|
||||
parentQueue: string;
|
||||
childQueue: string;
|
||||
currentScore: string;
|
||||
}) {
|
||||
const rebalanceResult = await this.redis.rebalanceParentQueueChild(
|
||||
childQueue,
|
||||
parentQueue,
|
||||
childQueue,
|
||||
currentScore
|
||||
);
|
||||
|
||||
if (rebalanceResult) {
|
||||
logger.debug("Rebalanced parent queue child", {
|
||||
parentQueue,
|
||||
childQueue,
|
||||
currentScore,
|
||||
rebalanceResult,
|
||||
operation: "rebalanceParentQueueChild",
|
||||
});
|
||||
}
|
||||
|
||||
return rebalanceResult;
|
||||
}
|
||||
|
||||
#registerCommands() {
|
||||
this.redis.defineCommand("enqueueMessage", {
|
||||
numberOfKeys: 3,
|
||||
@@ -887,13 +1123,12 @@ local currentConcurrencyKey = KEYS[7]
|
||||
local envCurrentConcurrencyKey = KEYS[8]
|
||||
local orgCurrentConcurrencyKey = KEYS[9]
|
||||
|
||||
-- Args: childQueueName, visibilityQueue, currentTime, defaultConcurrencyLimit, defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
|
||||
-- Args: childQueueName, visibilityQueue, currentTime, defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
|
||||
local childQueueName = ARGV[1]
|
||||
local visibilityTimeout = tonumber(ARGV[2])
|
||||
local currentTime = tonumber(ARGV[3])
|
||||
local defaultConcurrencyLimit = ARGV[4]
|
||||
local defaultEnvConcurrencyLimit = ARGV[5]
|
||||
local defaultOrgConcurrencyLimit = ARGV[6]
|
||||
local defaultEnvConcurrencyLimit = ARGV[4]
|
||||
local defaultOrgConcurrencyLimit = ARGV[5]
|
||||
|
||||
-- Check current org concurrency against the limit
|
||||
local orgCurrentConcurrency = tonumber(redis.call('SCARD', orgCurrentConcurrencyKey) or '0')
|
||||
@@ -913,8 +1148,9 @@ end
|
||||
|
||||
-- Check current queue concurrency against the limit
|
||||
local currentConcurrency = tonumber(redis.call('SCARD', currentConcurrencyKey) or '0')
|
||||
local concurrencyLimit = tonumber(redis.call('GET', concurrencyLimitKey) or defaultConcurrencyLimit)
|
||||
local concurrencyLimit = tonumber(redis.call('GET', concurrencyLimitKey) or '1000000')
|
||||
|
||||
-- Check condition only if concurrencyLimit exists
|
||||
if currentConcurrency >= concurrencyLimit then
|
||||
return nil
|
||||
end
|
||||
@@ -950,22 +1186,35 @@ return {messageId, messageScore} -- Return message details
|
||||
});
|
||||
|
||||
this.redis.defineCommand("acknowledgeMessage", {
|
||||
numberOfKeys: 5,
|
||||
numberOfKeys: 7,
|
||||
lua: `
|
||||
-- Keys: messageKey, visibilityQueue, concurrencyKey, envCurrentConcurrencyKey, orgCurrentConcurrencyKey
|
||||
local messageKey = KEYS[1]
|
||||
local visibilityQueue = KEYS[2]
|
||||
local concurrencyKey = KEYS[3]
|
||||
local envCurrentConcurrencyKey = KEYS[4]
|
||||
local orgCurrentConcurrencyKey = KEYS[5]
|
||||
local globalCurrentConcurrencyKey = KEYS[6]
|
||||
-- Keys: parentQueue, messageKey, messageQueue, visibilityQueue, concurrencyKey, envCurrentConcurrencyKey, orgCurrentConcurrencyKey
|
||||
local parentQueue = KEYS[1]
|
||||
local messageKey = KEYS[2]
|
||||
local messageQueue = KEYS[3]
|
||||
local visibilityQueue = KEYS[4]
|
||||
local concurrencyKey = KEYS[5]
|
||||
local envCurrentConcurrencyKey = KEYS[6]
|
||||
local orgCurrentConcurrencyKey = KEYS[7]
|
||||
|
||||
-- Args: messageId
|
||||
-- Args: messageId, messageQueueName
|
||||
local messageId = ARGV[1]
|
||||
local messageQueueName = ARGV[2]
|
||||
|
||||
-- Remove the message from the message key
|
||||
redis.call('DEL', messageKey)
|
||||
|
||||
-- Remove the message from the queue
|
||||
redis.call('ZREM', messageQueue, messageId)
|
||||
|
||||
-- Rebalance the parent queue
|
||||
local earliestMessage = redis.call('ZRANGE', messageQueue, 0, 0, 'WITHSCORES')
|
||||
if #earliestMessage == 0 then
|
||||
redis.call('ZREM', parentQueue, messageQueueName)
|
||||
else
|
||||
redis.call('ZADD', parentQueue, earliestMessage[2], messageQueueName)
|
||||
end
|
||||
|
||||
-- Remove the message from the timeout queue
|
||||
redis.call('ZREM', visibilityQueue, messageId)
|
||||
|
||||
@@ -1059,10 +1308,9 @@ local concurrencyLimitKey = KEYS[4]
|
||||
local envConcurrencyLimitKey = KEYS[5]
|
||||
local orgConcurrencyLimitKey = KEYS[6]
|
||||
|
||||
-- Args defaultConcurrencyLimit, defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
|
||||
local defaultConcurrencyLimit = tonumber(ARGV[1])
|
||||
local defaultEnvConcurrencyLimit = tonumber(ARGV[2])
|
||||
local defaultOrgConcurrencyLimit = tonumber(ARGV[3])
|
||||
-- Args defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
|
||||
local defaultEnvConcurrencyLimit = tonumber(ARGV[1])
|
||||
local defaultOrgConcurrencyLimit = tonumber(ARGV[2])
|
||||
|
||||
local currentOrgConcurrency = tonumber(redis.call('SCARD', currentOrgConcurrencyKey) or '0')
|
||||
local orgConcurrencyLimit = tonumber(redis.call('GET', orgConcurrencyLimitKey) or defaultOrgConcurrencyLimit)
|
||||
@@ -1071,7 +1319,7 @@ local currentEnvConcurrency = tonumber(redis.call('SCARD', currentEnvConcurrency
|
||||
local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit)
|
||||
|
||||
local currentConcurrency = tonumber(redis.call('SCARD', currentConcurrencyKey) or '0')
|
||||
local concurrencyLimit = tonumber(redis.call('GET', concurrencyLimitKey) or defaultConcurrencyLimit)
|
||||
local concurrencyLimit = redis.call('GET', concurrencyLimitKey)
|
||||
|
||||
-- Return current capacity and concurrency limits for the queue, env, org
|
||||
return { currentConcurrency, concurrencyLimit, currentEnvConcurrency, envConcurrencyLimit, currentOrgConcurrency, orgConcurrencyLimit }
|
||||
@@ -1093,6 +1341,37 @@ redis.call('SET', envConcurrencyLimitKey, envConcurrencyLimit)
|
||||
redis.call('SET', orgConcurrencyLimitKey, orgConcurrencyLimit)
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("rebalanceParentQueueChild", {
|
||||
numberOfKeys: 2,
|
||||
lua: `
|
||||
-- Keys: childQueueKey, parentQueueKey
|
||||
local childQueueKey = KEYS[1]
|
||||
local parentQueueKey = KEYS[2]
|
||||
|
||||
-- Args: childQueueName, currentScore
|
||||
local childQueueName = ARGV[1]
|
||||
local currentScore = ARGV[2]
|
||||
|
||||
-- Rebalance the parent queue
|
||||
local earliestMessage = redis.call('ZRANGE', childQueueKey, 0, 0, 'WITHSCORES')
|
||||
if #earliestMessage == 0 then
|
||||
redis.call('ZREM', parentQueueKey, childQueueName)
|
||||
|
||||
-- Return true because the parent queue was rebalanced
|
||||
return true
|
||||
else
|
||||
-- If the earliest message is different, update the parent queue and return true, else return false
|
||||
if earliestMessage[2] == currentScore then
|
||||
return false
|
||||
end
|
||||
|
||||
redis.call('ZADD', parentQueueKey, earliestMessage[2], childQueueName)
|
||||
|
||||
return earliestMessage[2]
|
||||
end
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,19 +1401,21 @@ declare module "ioredis" {
|
||||
childQueueName: string,
|
||||
visibilityTimeout: string,
|
||||
currentTime: string,
|
||||
defaultConcurrencyLimit: string,
|
||||
defaultEnvConcurrencyLimit: string,
|
||||
defaultOrgConcurrencyLimit: string,
|
||||
callback?: Callback<[string, string]>
|
||||
): Result<[string, string] | null, Context>;
|
||||
|
||||
acknowledgeMessage(
|
||||
parentQueue: string,
|
||||
messageKey: string,
|
||||
messageQueue: string,
|
||||
visibilityQueue: string,
|
||||
concurrencyKey: string,
|
||||
envConcurrencyKey: string,
|
||||
orgConcurrencyKey: string,
|
||||
messageId: string,
|
||||
messageQueueName: string,
|
||||
callback?: Callback<void>
|
||||
): Result<void, Context>;
|
||||
|
||||
@@ -1168,7 +1449,6 @@ declare module "ioredis" {
|
||||
concurrencyLimitKey: string,
|
||||
envConcurrencyLimitKey: string,
|
||||
orgConcurrencyLimitKey: string,
|
||||
defaultConcurrencyLimit: string,
|
||||
defaultEnvConcurrencyLimit: string,
|
||||
defaultOrgConcurrencyLimit: string,
|
||||
callback?: Callback<number[]>
|
||||
@@ -1181,6 +1461,14 @@ declare module "ioredis" {
|
||||
orgConcurrencyLimit: string,
|
||||
callback?: Callback<void>
|
||||
): Result<void, Context>;
|
||||
|
||||
rebalanceParentQueueChild(
|
||||
childQueueKey: string,
|
||||
parentQueueKey: string,
|
||||
childQueueName: string,
|
||||
currentScore: string,
|
||||
callback?: Callback<number | string | null>
|
||||
): Result<number | string | null, Context>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,7 +1493,6 @@ function getMarQSClient() {
|
||||
envQueuePriorityStrategy: new SimpleWeightedChoiceStrategy({ queueSelectionCount: 12 }),
|
||||
workers: 1,
|
||||
redis: redisOptions,
|
||||
defaultQueueConcurrency: env.DEFAULT_QUEUE_EXECUTION_CONCURRENCY_LIMIT,
|
||||
defaultEnvConcurrency: env.DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT,
|
||||
defaultOrgConcurrency: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
visibilityTimeoutInMs: 120 * 1000, // 2 minutes,
|
||||
|
||||
@@ -15,6 +15,18 @@ const constants = {
|
||||
export class MarQSShortKeyProducer implements MarQSKeyProducer {
|
||||
constructor(private _prefix: string) {}
|
||||
|
||||
sharedQueueScanPattern() {
|
||||
return `${this._prefix}*${constants.SHARED_QUEUE}`;
|
||||
}
|
||||
|
||||
stripKeyPrefix(key: string): string {
|
||||
if (key.startsWith(this._prefix)) {
|
||||
return key.slice(this._prefix.length);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
queueConcurrencyLimitKey(env: AuthenticatedEnvironment, queue: string) {
|
||||
return [this.queueKey(env, queue), constants.CONCURRENCY_LIMIT_PART].join(":");
|
||||
}
|
||||
@@ -59,6 +71,16 @@ export class MarQSShortKeyProducer implements MarQSKeyProducer {
|
||||
return `${queue}:${constants.CURRENT_CONCURRENCY_PART}`;
|
||||
}
|
||||
|
||||
currentConcurrencyKey(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
concurrencyKey?: string
|
||||
): string {
|
||||
return [this.queueKey(env, queue, concurrencyKey), constants.CURRENT_CONCURRENCY_PART].join(
|
||||
":"
|
||||
);
|
||||
}
|
||||
|
||||
orgConcurrencyLimitKeyFromQueue(queue: string) {
|
||||
const orgId = this.normalizeQueue(queue).split(":")[1];
|
||||
|
||||
@@ -83,6 +105,14 @@ export class MarQSShortKeyProducer implements MarQSKeyProducer {
|
||||
return `${constants.ENV_PART}:${envId}:${constants.CURRENT_CONCURRENCY_PART}`;
|
||||
}
|
||||
|
||||
orgCurrentConcurrencyKey(env: AuthenticatedEnvironment): string {
|
||||
return [this.orgKeySection(env.organizationId), constants.CURRENT_CONCURRENCY_PART].join(":");
|
||||
}
|
||||
|
||||
envCurrentConcurrencyKey(env: AuthenticatedEnvironment): string {
|
||||
return [this.envKeySection(env.id), constants.CURRENT_CONCURRENCY_PART].join(":");
|
||||
}
|
||||
|
||||
messageKey(messageId: string) {
|
||||
return `${constants.MESSAGE_PART}:${messageId}`;
|
||||
}
|
||||
|
||||
@@ -812,7 +812,7 @@ class SharedQueueTasks {
|
||||
if (ok) {
|
||||
const success: TaskRunSuccessfulExecutionResult = {
|
||||
ok,
|
||||
id: attempt.friendlyId,
|
||||
id: attempt.taskRun.friendlyId,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
};
|
||||
@@ -820,7 +820,7 @@ class SharedQueueTasks {
|
||||
} else {
|
||||
const failure: TaskRunFailedExecutionResult = {
|
||||
ok,
|
||||
id: attempt.friendlyId,
|
||||
id: attempt.taskRun.friendlyId,
|
||||
error: attempt.error as TaskRunError,
|
||||
};
|
||||
return failure;
|
||||
@@ -848,7 +848,7 @@ class SharedQueueTasks {
|
||||
taskRun: {
|
||||
include: {
|
||||
tags: true,
|
||||
batchItem: {
|
||||
batchItems: {
|
||||
include: {
|
||||
batchTaskRun: true,
|
||||
},
|
||||
@@ -956,6 +956,7 @@ class SharedQueueTasks {
|
||||
createdAt: taskRun.createdAt,
|
||||
tags: taskRun.tags.map((tag) => tag.name),
|
||||
isTest: taskRun.isTest,
|
||||
idempotencyKey: taskRun.idempotencyKey ?? undefined,
|
||||
},
|
||||
queue: {
|
||||
id: queue.friendlyId,
|
||||
@@ -977,9 +978,10 @@ class SharedQueueTasks {
|
||||
slug: attempt.runtimeEnvironment.project.slug,
|
||||
name: attempt.runtimeEnvironment.project.name,
|
||||
},
|
||||
batch: taskRun.batchItem?.batchTaskRun
|
||||
? { id: taskRun.batchItem.batchTaskRun.friendlyId }
|
||||
: undefined,
|
||||
batch:
|
||||
taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun
|
||||
? { id: taskRun.batchItems[0].batchTaskRun.friendlyId }
|
||||
: undefined,
|
||||
worker: {
|
||||
id: attempt.backgroundWorkerId,
|
||||
contentHash: attempt.backgroundWorker.contentHash,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RedisOptions } from "ioredis";
|
||||
import { z } from "zod";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
|
||||
@@ -25,13 +24,22 @@ export interface MarQSKeyProducer {
|
||||
orgConcurrencyLimitKey(env: AuthenticatedEnvironment): string;
|
||||
queueKey(env: AuthenticatedEnvironment, queue: string, concurrencyKey?: string): string;
|
||||
envSharedQueueKey(env: AuthenticatedEnvironment): string;
|
||||
sharedQueueScanPattern(): string;
|
||||
concurrencyLimitKeyFromQueue(queue: string): string;
|
||||
currentConcurrencyKeyFromQueue(queue: string): string;
|
||||
currentConcurrencyKey(
|
||||
env: AuthenticatedEnvironment,
|
||||
queue: string,
|
||||
concurrencyKey?: string
|
||||
): string;
|
||||
orgConcurrencyLimitKeyFromQueue(queue: string): string;
|
||||
orgCurrentConcurrencyKeyFromQueue(queue: string): string;
|
||||
envConcurrencyLimitKeyFromQueue(queue: string): string;
|
||||
envCurrentConcurrencyKeyFromQueue(queue: string): string;
|
||||
orgCurrentConcurrencyKey(env: AuthenticatedEnvironment): string;
|
||||
envCurrentConcurrencyKey(env: AuthenticatedEnvironment): string;
|
||||
messageKey(messageId: string): string;
|
||||
stripKeyPrefix(key: string): string;
|
||||
}
|
||||
|
||||
export type PriorityStrategyChoice = string | { abort: true };
|
||||
|
||||
@@ -352,6 +352,7 @@ function extractResourceProperties(attributes: KeyValue[]) {
|
||||
queueId: extractStringAttribute(attributes, SemanticInternalAttributes.QUEUE_ID),
|
||||
queueName: extractStringAttribute(attributes, SemanticInternalAttributes.QUEUE_NAME),
|
||||
batchId: extractStringAttribute(attributes, SemanticInternalAttributes.BATCH_ID),
|
||||
idempotencyKey: extractStringAttribute(attributes, SemanticInternalAttributes.IDEMPOTENCY_KEY),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { parseExpression } from "cron-parser";
|
||||
import { z } from "zod";
|
||||
|
||||
export const CronPattern = z.string().refine(
|
||||
(val) => {
|
||||
//only allow CRON expressions that don't include seconds (they have 5 parts)
|
||||
const parts = val.split(" ");
|
||||
if (parts.length > 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (val === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
parseExpression(val);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
(val) => {
|
||||
const parts = val.split(" ");
|
||||
if (parts.length > 5) {
|
||||
return {
|
||||
message: "CRON expressions with seconds are not allowed",
|
||||
};
|
||||
}
|
||||
|
||||
if (val === "") {
|
||||
return {
|
||||
message: "CRON expression is required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
parseExpression(val);
|
||||
return {
|
||||
message: "Unknown problem",
|
||||
};
|
||||
} catch (e) {
|
||||
return { message: e instanceof Error ? e.message : JSON.stringify(e) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const UpsertSchedule = z.object({
|
||||
friendlyId: z.string().optional(),
|
||||
taskIdentifier: z.string().min(1, "Task is required"),
|
||||
cron: CronPattern,
|
||||
environments: z.preprocess(
|
||||
(data) => (typeof data === "string" ? [data] : data),
|
||||
z.array(z.string()).min(1, "At least one environment is required")
|
||||
),
|
||||
externalId: z.string().optional(),
|
||||
deduplicationKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpsertSchedule = z.infer<typeof UpsertSchedule>;
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Span, SpanKind } from "@opentelemetry/api";
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { attributesFromAuthenticatedEnv, tracer } from "../tracer.server";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { Span, SpanKind } from "@opentelemetry/api";
|
||||
|
||||
export abstract class BaseService {
|
||||
constructor(protected readonly _prisma: PrismaClient = prisma) {}
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {}
|
||||
|
||||
protected async traceWithEnv<T>(
|
||||
trace: string,
|
||||
@@ -33,3 +32,10 @@ export abstract class BaseService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ServiceValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BatchTriggerTaskRequestBody } from "@trigger.dev/core/v3";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { TriggerTaskService } from "./triggerTask.server";
|
||||
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
|
||||
|
||||
export type BatchTriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
@@ -22,23 +22,23 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
return await this.traceWithEnv("call()", environment, async (span) => {
|
||||
span.setAttribute("taskId", taskId);
|
||||
|
||||
const idempotencyKey = options.idempotencyKey ?? nanoid();
|
||||
|
||||
const existingBatch = await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
const existingBatch = options.idempotencyKey
|
||||
? await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (existingBatch) {
|
||||
span.setAttribute("batchId", existingBatch.friendlyId);
|
||||
@@ -58,7 +58,7 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("batch"),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
dependentTaskAttemptId: dependentAttempt?.id,
|
||||
},
|
||||
@@ -70,8 +70,6 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
let index = 0;
|
||||
|
||||
for (const item of body.items) {
|
||||
const idempotencyKey = nanoid();
|
||||
|
||||
const run = await triggerTaskService.call(
|
||||
taskId,
|
||||
environment,
|
||||
@@ -83,7 +81,6 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey,
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
@@ -96,6 +93,7 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
data: {
|
||||
batchTaskRunId: batch.id,
|
||||
taskRunId: run.id,
|
||||
status: batchTaskRunItemStatusForRunStatus(run.status),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { marqs } from "~/v3/marqs/index.server";
|
||||
import { devPubSub } from "../marqs/devPubSub.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
import { assertUnreachable } from "../utils/asserts.server";
|
||||
import { CancelAttemptService } from "./cancelAttempt.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import assertNever from "assert-never";
|
||||
|
||||
export const CANCELLABLE_STATUSES: Array<TaskRunStatus> = [
|
||||
"PENDING",
|
||||
@@ -148,7 +148,7 @@ export class CancelTaskRunService extends BaseService {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(attempt.status);
|
||||
assertNever(attempt.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,12 @@ export class CompleteAttemptService extends BaseService {
|
||||
env?: AuthenticatedEnvironment;
|
||||
checkpoint?: CheckpointData;
|
||||
}): Promise<"COMPLETED" | "RETRIED"> {
|
||||
const taskRunAttempt = await findAttempt(this._prisma, completion.id);
|
||||
const taskRunAttempt = await findAttempt(this._prisma, execution.attempt.id);
|
||||
|
||||
if (!taskRunAttempt) {
|
||||
logger.error("[CompleteAttemptService] Task run attempt not found", { id: completion.id });
|
||||
logger.error("[CompleteAttemptService] Task run attempt not found", {
|
||||
id: execution.attempt.id,
|
||||
});
|
||||
|
||||
// Update the task run to be failed
|
||||
await this._prisma.taskRun.update({
|
||||
@@ -76,7 +78,7 @@ export class CompleteAttemptService extends BaseService {
|
||||
env?: AuthenticatedEnvironment
|
||||
): Promise<"COMPLETED"> {
|
||||
await this._prisma.taskRunAttempt.update({
|
||||
where: { friendlyId: completion.id },
|
||||
where: { id: taskRunAttempt.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
@@ -144,7 +146,7 @@ export class CompleteAttemptService extends BaseService {
|
||||
}
|
||||
|
||||
await this._prisma.taskRunAttempt.update({
|
||||
where: { friendlyId: completion.id },
|
||||
where: { id: taskRunAttempt.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
completedAt: new Date(),
|
||||
@@ -248,14 +250,52 @@ export class CompleteAttemptService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: taskRunAttempt.taskRunId,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED_WITH_ERRORS",
|
||||
},
|
||||
});
|
||||
if (
|
||||
completion.error.type === "INTERNAL_ERROR" &&
|
||||
completion.error.code === "GRACEFUL_EXIT_TIMEOUT"
|
||||
) {
|
||||
// We need to fail all incomplete spans
|
||||
const inProgressEvents = await eventRepository.queryIncompleteEvents({
|
||||
attemptId: execution.attempt.id,
|
||||
});
|
||||
|
||||
logger.debug("Failing in-progress events", {
|
||||
inProgressEvents: inProgressEvents.map((event) => event.id),
|
||||
});
|
||||
|
||||
const exception = {
|
||||
type: "Graceful exit timeout",
|
||||
message: completion.error.message,
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
inProgressEvents.map((event) => {
|
||||
return eventRepository.crashEvent({
|
||||
event: event,
|
||||
crashedAt: new Date(),
|
||||
exception,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: taskRunAttempt.taskRunId,
|
||||
},
|
||||
data: {
|
||||
status: "SYSTEM_FAILURE",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this._prisma.taskRun.update({
|
||||
where: {
|
||||
id: taskRunAttempt.taskRunId,
|
||||
},
|
||||
data: {
|
||||
status: "COMPLETED_WITH_ERRORS",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!env || env.type !== "DEVELOPMENT") {
|
||||
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
|
||||
|
||||
@@ -3,12 +3,11 @@ import type { BackgroundWorker } from "@trigger.dev/database";
|
||||
import { Prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { projectPubSub } from "./projectPubSub.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export class CreateBackgroundWorkerService extends BaseService {
|
||||
public async call(
|
||||
@@ -91,10 +90,10 @@ export class CreateBackgroundWorkerService extends BaseService {
|
||||
error:
|
||||
err instanceof Error
|
||||
? {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
: err,
|
||||
project,
|
||||
environment,
|
||||
@@ -128,6 +127,7 @@ export async function createBackgroundTasks(
|
||||
retryConfig: task.retry,
|
||||
queueConfig: task.queue,
|
||||
machineConfig: task.machine,
|
||||
triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -141,13 +141,13 @@ export async function createBackgroundTasks(
|
||||
const concurrencyLimit =
|
||||
typeof task.queue?.concurrencyLimit === "number"
|
||||
? Math.max(
|
||||
Math.min(
|
||||
task.queue.concurrencyLimit,
|
||||
environment.maximumConcurrencyLimit,
|
||||
environment.organization.maximumConcurrencyLimit
|
||||
),
|
||||
0
|
||||
)
|
||||
Math.min(
|
||||
task.queue.concurrencyLimit,
|
||||
environment.maximumConcurrencyLimit,
|
||||
environment.organization.maximumConcurrencyLimit
|
||||
),
|
||||
0
|
||||
)
|
||||
: null;
|
||||
|
||||
const taskQueue = await prisma.taskQueue.upsert({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user