feat: expose all run options in the test run page (#2227)
* Implement a new primitive UI component for picking durations * Implement a new component to input run tags * Expose all run options in the test run page * Add subtle animations when adding/removing run tags in the test page * Add a new resource endpoint for fetching queues * Fetch usable queues for the selected task * Fix width display issue in the select component * Enable locking a run to a version from the test page * Disable entering max attemps <0 * Validate tags * Add recent runs popover * Only show latest version for development environments * Update run options when selecting a recent run * Rearrange the test page layout * Add subtle animation to the duration picker segments on focus * Improve queue selection dropdown styling * Fix disabled state issue for the SelectTrigger component * Disable version selection field for dev envs * Add usage hints next to the run option fields * Add machine preset to the run options list * Allow arbitrary queue inputs for v1 engine runs * Show truncated run ID instead of run numbers for recent runs Run numbers will soon get deprecated due to contention issues * Fix duplicate queue issue * Extract common elements across the standard and scheduled test task forms * Apply values from recent runs to scheduled tasks too * Add additional run options for scheduled tasks * Use a slightly smaller font size for run option labels * Disallow commas in the run tag input field * Switch to a custom icon for recent runs button * Flatten the load function test task result object * Avoid redefining machine presets, use zod schema instead * Fix ClockRotateLeftIcon jsx issues * Remove recent runs button tooltip as it causes nesting errors * Adjust the page layout to make it clear which task is currently selected * Inline the tab group with the copy/clear buttons
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export function ClockRotateLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4.01784 10.9999C4.27072 9.07068 5.21806 7.29972 6.68252 6.01856C8.14697 4.73741 10.0282 4.03389 11.9739 4.03971C13.9197 4.04553 15.7966 4.76028 17.2534 6.05017C18.7101 7.34006 19.6469 9.11666 19.8882 11.0474C20.1296 12.9781 19.659 14.9306 18.5645 16.5394C17.4701 18.1482 15.8268 19.303 13.9424 19.7876C12.0579 20.2722 10.0615 20.0534 8.32671 19.1721C6.59196 18.2909 5.23784 16.8076 4.51784 14.9999M4.01784 19.9999L4.01784 14.9999L9.01784 14.9999"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M12 12L12 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M12 12L14 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
showClearButton?: boolean;
|
||||
linterEnabled?: boolean;
|
||||
allowEmpty?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const languages = {
|
||||
@@ -64,6 +65,7 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
showClearButton = true,
|
||||
linterEnabled,
|
||||
allowEmpty,
|
||||
additionalActions,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
@@ -152,6 +154,7 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
>
|
||||
{showButtons && (
|
||||
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
|
||||
{additionalActions && additionalActions}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { cn } from "~/utils/cn";
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export interface DurationPickerProps {
|
||||
id?: string; // used for the hidden input for form submission
|
||||
name?: string; // used for the hidden input for form submission
|
||||
defaultValueSeconds?: number;
|
||||
value?: number;
|
||||
onChange?: (totalSeconds: number) => void;
|
||||
variant?: "small" | "medium";
|
||||
showClearButton?: boolean;
|
||||
}
|
||||
|
||||
export function DurationPicker({
|
||||
name,
|
||||
defaultValueSeconds: defaultValue = 0,
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
variant = "small",
|
||||
showClearButton = true,
|
||||
}: DurationPickerProps) {
|
||||
// Use controlled value if provided, otherwise use default
|
||||
const initialValue = controlledValue ?? defaultValue;
|
||||
|
||||
const defaultHours = Math.floor(initialValue / 3600);
|
||||
const defaultMinutes = Math.floor((initialValue % 3600) / 60);
|
||||
const defaultSeconds = initialValue % 60;
|
||||
|
||||
const [hours, setHours] = useState<number>(defaultHours);
|
||||
const [minutes, setMinutes] = useState<number>(defaultMinutes);
|
||||
const [seconds, setSeconds] = useState<number>(defaultSeconds);
|
||||
|
||||
const minuteRef = useRef<HTMLInputElement>(null);
|
||||
const hourRef = useRef<HTMLInputElement>(null);
|
||||
const secondRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
|
||||
|
||||
const isEmpty = hours === 0 && minutes === 0 && seconds === 0;
|
||||
|
||||
// Sync internal state with external value changes
|
||||
useEffect(() => {
|
||||
if (controlledValue !== undefined && controlledValue !== totalSeconds) {
|
||||
const newHours = Math.floor(controlledValue / 3600);
|
||||
const newMinutes = Math.floor((controlledValue % 3600) / 60);
|
||||
const newSeconds = controlledValue % 60;
|
||||
|
||||
setHours(newHours);
|
||||
setMinutes(newMinutes);
|
||||
setSeconds(newSeconds);
|
||||
}
|
||||
}, [controlledValue]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.(totalSeconds);
|
||||
}, [totalSeconds, onChange]);
|
||||
|
||||
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
setHours(Math.max(0, value));
|
||||
};
|
||||
|
||||
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
if (value >= 60) {
|
||||
setHours((prev) => prev + Math.floor(value / 60));
|
||||
setMinutes(value % 60);
|
||||
return;
|
||||
}
|
||||
|
||||
setMinutes(Math.max(0, Math.min(59, value)));
|
||||
};
|
||||
|
||||
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
if (value >= 60) {
|
||||
setMinutes((prev) => {
|
||||
const newMinutes = prev + Math.floor(value / 60);
|
||||
if (newMinutes >= 60) {
|
||||
setHours((prevHours) => prevHours + Math.floor(newMinutes / 60));
|
||||
return newMinutes % 60;
|
||||
}
|
||||
return newMinutes;
|
||||
});
|
||||
setSeconds(value % 60);
|
||||
return;
|
||||
}
|
||||
|
||||
setSeconds(Math.max(0, Math.min(59, value)));
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
nextRef?: React.RefObject<HTMLInputElement>,
|
||||
prevRef?: React.RefObject<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowRight" && nextRef) {
|
||||
e.preventDefault();
|
||||
nextRef.current?.focus();
|
||||
nextRef.current?.select();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowLeft" && prevRef) {
|
||||
e.preventDefault();
|
||||
prevRef.current?.focus();
|
||||
prevRef.current?.select();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const clearDuration = () => {
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
setSeconds(0);
|
||||
hourRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="hidden" name={name} value={totalSeconds} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="group flex items-center gap-1">
|
||||
<Input
|
||||
variant={variant}
|
||||
ref={hourRef}
|
||||
className={cn(
|
||||
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
|
||||
isEmpty && "text-text-dimmed"
|
||||
)}
|
||||
value={hours.toString()}
|
||||
onChange={handleHoursChange}
|
||||
onKeyDown={(e) => handleKeyDown(e, minuteRef)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
type="number"
|
||||
min={0}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
|
||||
h
|
||||
</span>
|
||||
</div>
|
||||
<div className="group flex items-center gap-1">
|
||||
<Input
|
||||
variant={variant}
|
||||
ref={minuteRef}
|
||||
className={cn(
|
||||
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
|
||||
isEmpty && "text-text-dimmed"
|
||||
)}
|
||||
value={minutes.toString()}
|
||||
onChange={handleMinutesChange}
|
||||
onKeyDown={(e) => handleKeyDown(e, secondRef, hourRef)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
|
||||
m
|
||||
</span>
|
||||
</div>
|
||||
<div className="group flex items-center gap-1">
|
||||
<Input
|
||||
variant={variant}
|
||||
ref={secondRef}
|
||||
className={cn(
|
||||
"w-10 text-center font-mono tabular-nums caret-transparent [&::-webkit-inner-spin-button]:appearance-none",
|
||||
isEmpty && "text-text-dimmed"
|
||||
)}
|
||||
value={seconds.toString()}
|
||||
onChange={handleSecondsChange}
|
||||
onKeyDown={(e) => handleKeyDown(e, undefined, minuteRef)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<span className="text-sm text-text-dimmed transition-colors duration-200 group-focus-within:text-text-bright/80">
|
||||
s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showClearButton && (
|
||||
<Button type="button" variant={`tertiary/${variant}`} onClick={clearDuration}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
small: {
|
||||
text: "font-sans text-sm font-normal text-text-bright leading-tight flex items-center gap-1",
|
||||
text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1",
|
||||
},
|
||||
medium: {
|
||||
text: "font-sans text-sm text-text-bright leading-tight flex items-center gap-1",
|
||||
|
||||
@@ -327,6 +327,7 @@ export function SelectTrigger({
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
@@ -615,7 +616,7 @@ export function SelectPopover({
|
||||
unmountOnHide={unmountOnHide}
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
|
||||
"min-w-[max(180px,var(--popover-anchor-width))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(600px,var(--popover-available-height))]",
|
||||
"origin-[var(--popover-transform-origin)]",
|
||||
|
||||
@@ -3,11 +3,21 @@ import tagLeftPath from "./tag-left.svg";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { ClipboardCheckIcon, ClipboardIcon, XIcon } from "lucide-react";
|
||||
|
||||
type Tag = string | { key: string; value: string };
|
||||
|
||||
export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip?: string }) {
|
||||
export function RunTag({
|
||||
tag,
|
||||
to,
|
||||
tooltip,
|
||||
action = { type: "copy" },
|
||||
}: {
|
||||
tag: string;
|
||||
action?: { type: "copy" } | { type: "delete"; onDelete: (tag: string) => void };
|
||||
to?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
const tagResult = useMemo(() => splitTag(tag), [tag]);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -57,7 +67,11 @@ export function RunTag({ tag, to, tooltip }: { tag: string; to?: string; tooltip
|
||||
return (
|
||||
<div className="group relative inline-flex shrink-0" onMouseLeave={() => setIsHovered(false)}>
|
||||
{tagContent}
|
||||
<CopyButton textToCopy={tag} isHovered={isHovered} />
|
||||
{action.type === "delete" ? (
|
||||
<DeleteButton tag={tag} onDelete={action.onDelete} isHovered={isHovered} />
|
||||
) : (
|
||||
<CopyButton textToCopy={tag} isHovered={isHovered} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +119,45 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered:
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
tag,
|
||||
onDelete,
|
||||
isHovered,
|
||||
}: {
|
||||
tag: string;
|
||||
onDelete: (tag: string) => void;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(tag);
|
||||
},
|
||||
[tag, onDelete]
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
onClick={handleDelete}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-charcoal-650 bg-charcoal-750",
|
||||
isHovered ? "flex" : "hidden",
|
||||
"text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-rose-400"
|
||||
)}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</span>
|
||||
}
|
||||
content="Remove tag"
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Takes a string and turns it into a tag
|
||||
*
|
||||
* If the string has 12 or fewer alpha characters followed by an underscore or colon then we return an object with a key and value
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useState, useEffect, type KeyboardEvent } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { RunTag } from "./RunTag";
|
||||
|
||||
interface TagInputProps {
|
||||
id?: string; // used for the hidden input for form submission
|
||||
name?: string; // used for the hidden input for form submission
|
||||
defaultTags?: string[];
|
||||
tags?: string[];
|
||||
placeholder?: string;
|
||||
variant?: "small" | "medium";
|
||||
maxTags?: number;
|
||||
maxTagLength?: number;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
export function RunTagInput({
|
||||
id,
|
||||
name,
|
||||
defaultTags = [],
|
||||
tags: controlledTags,
|
||||
placeholder = "Type and press Enter to add tags",
|
||||
variant = "small",
|
||||
maxTags = 10,
|
||||
maxTagLength = 128,
|
||||
onTagsChange,
|
||||
}: TagInputProps) {
|
||||
// Use controlled tags if provided, otherwise use default
|
||||
const initialTags = controlledTags ?? defaultTags;
|
||||
|
||||
const [tags, setTags] = useState<string[]>(initialTags);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
// Sync internal state with external tag changes
|
||||
useEffect(() => {
|
||||
if (controlledTags !== undefined) {
|
||||
setTags(controlledTags);
|
||||
}
|
||||
}, [controlledTags]);
|
||||
|
||||
const addTag = useCallback(
|
||||
(tagText: string) => {
|
||||
const trimmedTag = tagText.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||||
const newTags = [...tags, trimmedTag];
|
||||
setTags(newTags);
|
||||
onTagsChange?.(newTags);
|
||||
}
|
||||
setInputValue("");
|
||||
},
|
||||
[tags, onTagsChange, maxTags]
|
||||
);
|
||||
|
||||
const removeTag = useCallback(
|
||||
(tagToRemove: string) => {
|
||||
const newTags = tags.filter((tag) => tag !== tagToRemove);
|
||||
setTags(newTags);
|
||||
onTagsChange?.(newTags);
|
||||
},
|
||||
[tags, onTagsChange]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addTag(inputValue);
|
||||
} else if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
} else if (e.key === ",") {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[inputValue, addTag, removeTag, tags]
|
||||
);
|
||||
|
||||
const maxTagsReached = tags.length >= maxTags;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input type="hidden" name={name} id={id} value={tags.join(",")} />
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={maxTagsReached ? `A maximum of ${maxTags} tags is allowed` : placeholder}
|
||||
variant={variant}
|
||||
disabled={maxTagsReached}
|
||||
maxLength={maxTagLength}
|
||||
/>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tags.map((tag, i) => (
|
||||
<motion.div
|
||||
key={tag}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: "easeOut",
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
duration: 0.15,
|
||||
}}
|
||||
>
|
||||
<RunTag tag={tag} action={{ type: "delete", onDelete: removeTag }} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,16 @@ import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { toQueueItem } from "./QueueRetrievePresenter.server";
|
||||
import { TaskQueueType } from "@trigger.dev/database";
|
||||
|
||||
const DEFAULT_ITEMS_PER_PAGE = 25;
|
||||
const MAX_ITEMS_PER_PAGE = 100;
|
||||
|
||||
const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = {
|
||||
task: TaskQueueType.VIRTUAL,
|
||||
custom: TaskQueueType.NAMED,
|
||||
};
|
||||
|
||||
export class QueueListPresenter extends BasePresenter {
|
||||
private readonly perPage: number;
|
||||
|
||||
@@ -18,13 +25,15 @@ export class QueueListPresenter extends BasePresenter {
|
||||
environment,
|
||||
query,
|
||||
page,
|
||||
type,
|
||||
}: {
|
||||
environment: AuthenticatedEnvironment;
|
||||
query?: string;
|
||||
page: number;
|
||||
perPage?: number;
|
||||
type?: "task" | "custom";
|
||||
}) {
|
||||
const hasFilters = query !== undefined && query.length > 0;
|
||||
const hasFilters = (query !== undefined && query.length > 0) || type !== undefined;
|
||||
|
||||
// Get total count for pagination
|
||||
const totalQueues = await this._replica.taskQueue.count({
|
||||
@@ -37,6 +46,7 @@ export class QueueListPresenter extends BasePresenter {
|
||||
mode: "insensitive",
|
||||
}
|
||||
: undefined,
|
||||
type: type ? typeToDBQueueType[type] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,7 +80,7 @@ export class QueueListPresenter extends BasePresenter {
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
queues: await this.getQueuesWithPagination(environment, query, page),
|
||||
queues: await this.getQueuesWithPagination(environment, query, page, type),
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalQueues / this.perPage),
|
||||
@@ -84,7 +94,8 @@ export class QueueListPresenter extends BasePresenter {
|
||||
private async getQueuesWithPagination(
|
||||
environment: AuthenticatedEnvironment,
|
||||
query: string | undefined,
|
||||
page: number
|
||||
page: number,
|
||||
type: "task" | "custom" | undefined
|
||||
) {
|
||||
const queues = await this._replica.taskQueue.findMany({
|
||||
where: {
|
||||
@@ -96,6 +107,7 @@ export class QueueListPresenter extends BasePresenter {
|
||||
mode: "insensitive",
|
||||
}
|
||||
: undefined,
|
||||
type: type ? typeToDBQueueType[type] : undefined,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class QueueRetrievePresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
function queueTypeFromType(type: TaskQueueType) {
|
||||
export function queueTypeFromType(type: TaskQueueType) {
|
||||
switch (type) {
|
||||
case "NAMED":
|
||||
return "custom" as const;
|
||||
|
||||
@@ -2,10 +2,9 @@ import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.d
|
||||
import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import {
|
||||
type BackgroundWorkerTaskSlim,
|
||||
findCurrentWorkerDeployment,
|
||||
} from "~/v3/models/workerDeployment.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
|
||||
import parse from "parse-duration";
|
||||
|
||||
type TestTaskOptions = {
|
||||
userId: string;
|
||||
@@ -24,31 +23,51 @@ type Task = {
|
||||
friendlyId: string;
|
||||
};
|
||||
|
||||
export type TestTask =
|
||||
| {
|
||||
triggerSource: "STANDARD";
|
||||
task: Task;
|
||||
runs: StandardRun[];
|
||||
}
|
||||
| {
|
||||
triggerSource: "SCHEDULED";
|
||||
task: Task;
|
||||
possibleTimezones: string[];
|
||||
runs: ScheduledRun[];
|
||||
};
|
||||
type Queue = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "custom" | "task";
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
export type TestTaskResult =
|
||||
| {
|
||||
foundTask: true;
|
||||
task: TestTask;
|
||||
triggerSource: "STANDARD";
|
||||
queue?: Queue;
|
||||
task: Task;
|
||||
runs: StandardRun[];
|
||||
latestVersions: string[];
|
||||
disableVersionSelection: boolean;
|
||||
allowArbitraryQueues: boolean;
|
||||
}
|
||||
| {
|
||||
foundTask: true;
|
||||
triggerSource: "SCHEDULED";
|
||||
queue?: Queue;
|
||||
task: Task;
|
||||
possibleTimezones: string[];
|
||||
runs: ScheduledRun[];
|
||||
latestVersions: string[];
|
||||
disableVersionSelection: boolean;
|
||||
allowArbitraryQueues: boolean;
|
||||
}
|
||||
| {
|
||||
foundTask: false;
|
||||
};
|
||||
|
||||
export type StandardTaskResult = Extract<
|
||||
TestTaskResult,
|
||||
{ foundTask: true; triggerSource: "STANDARD" }
|
||||
>;
|
||||
export type ScheduledTaskResult = Extract<
|
||||
TestTaskResult,
|
||||
{ foundTask: true; triggerSource: "SCHEDULED" }
|
||||
>;
|
||||
|
||||
type RawRun = {
|
||||
id: string;
|
||||
number: BigInt;
|
||||
queue: string;
|
||||
friendlyId: string;
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
@@ -57,20 +76,28 @@ type RawRun = {
|
||||
runtimeEnvironmentId: string;
|
||||
seedMetadata?: string;
|
||||
seedMetadataType?: string;
|
||||
concurrencyKey?: string;
|
||||
maxAttempts?: number;
|
||||
maxDurationInSeconds?: number;
|
||||
machinePreset?: string;
|
||||
ttl?: string;
|
||||
idempotencyKey?: string;
|
||||
runTags: string[];
|
||||
};
|
||||
|
||||
export type StandardRun = Omit<RawRun, "number"> & {
|
||||
number: number;
|
||||
export type StandardRun = Omit<RawRun, "ttl"> & {
|
||||
metadata?: string;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
export type ScheduledRun = Omit<RawRun, "number" | "payload"> & {
|
||||
number: number;
|
||||
export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
|
||||
payload: {
|
||||
timestamp: Date;
|
||||
lastTimestamp?: Date;
|
||||
externalId?: string;
|
||||
timezone: string;
|
||||
};
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
export class TestTaskPresenter {
|
||||
@@ -86,23 +113,20 @@ export class TestTaskPresenter {
|
||||
environment,
|
||||
taskIdentifier,
|
||||
}: TestTaskOptions): Promise<TestTaskResult> {
|
||||
let task: BackgroundWorkerTaskSlim | null = null;
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
const deployment = await findCurrentWorkerDeployment({ environmentId: environment.id });
|
||||
if (deployment) {
|
||||
task = deployment.worker?.tasks.find((t) => t.slug === taskIdentifier) ?? null;
|
||||
}
|
||||
} else {
|
||||
task = await this.#prismaClient.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: taskIdentifier,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
const task =
|
||||
environment.type !== "DEVELOPMENT"
|
||||
? (
|
||||
await findCurrentWorkerDeployment({ environmentId: environment.id })
|
||||
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
|
||||
: await this.#prismaClient.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: taskIdentifier,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
@@ -110,6 +134,40 @@ export class TestTaskPresenter {
|
||||
};
|
||||
}
|
||||
|
||||
const taskQueue = task.queueId
|
||||
? await this.#prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
id: task.queueId,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
name: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
version: true,
|
||||
engine: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 20, // last 20 versions should suffice
|
||||
});
|
||||
|
||||
const latestVersions = backgroundWorkers.map((v) => v.version);
|
||||
|
||||
const disableVersionSelection = environment.type === "DEVELOPMENT";
|
||||
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
|
||||
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
|
||||
WITH taskruns AS (
|
||||
SELECT
|
||||
@@ -129,7 +187,7 @@ export class TestTaskPresenter {
|
||||
)
|
||||
SELECT
|
||||
taskr.id,
|
||||
taskr.number,
|
||||
taskr."queue",
|
||||
taskr."friendlyId",
|
||||
taskr."taskIdentifier",
|
||||
taskr."createdAt",
|
||||
@@ -138,7 +196,13 @@ export class TestTaskPresenter {
|
||||
taskr."payloadType",
|
||||
taskr."seedMetadata",
|
||||
taskr."seedMetadataType",
|
||||
taskr."runtimeEnvironmentId"
|
||||
taskr."runtimeEnvironmentId",
|
||||
taskr."concurrencyKey",
|
||||
taskr."maxAttempts",
|
||||
taskr."maxDurationInSeconds",
|
||||
taskr."machinePreset",
|
||||
taskr."ttl",
|
||||
taskr."runTags"
|
||||
FROM
|
||||
taskruns AS taskr
|
||||
WHERE
|
||||
@@ -157,52 +221,71 @@ export class TestTaskPresenter {
|
||||
case "STANDARD":
|
||||
return {
|
||||
foundTask: true,
|
||||
task: {
|
||||
triggerSource: "STANDARD",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
triggerSource: "STANDARD",
|
||||
queue: taskQueue
|
||||
? {
|
||||
id: taskQueue.friendlyId,
|
||||
name: taskQueue.name.replace(/^task\//, ""),
|
||||
type: queueTypeFromType(taskQueue.type),
|
||||
paused: taskQueue.paused,
|
||||
}
|
||||
: undefined,
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(
|
||||
async (r) =>
|
||||
({
|
||||
...r,
|
||||
number,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
metadata: r.seedMetadata
|
||||
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
|
||||
} satisfies StandardRun)
|
||||
)
|
||||
),
|
||||
latestVersions,
|
||||
disableVersionSelection,
|
||||
allowArbitraryQueues,
|
||||
};
|
||||
case "SCHEDULED":
|
||||
case "SCHEDULED": {
|
||||
const possibleTimezones = getTimezones();
|
||||
return {
|
||||
foundTask: true,
|
||||
task: {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
possibleTimezones,
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
triggerSource: "SCHEDULED",
|
||||
queue: taskQueue
|
||||
? {
|
||||
id: taskQueue.friendlyId,
|
||||
name: taskQueue.name.replace(/^task\//, ""),
|
||||
type: queueTypeFromType(taskQueue.type),
|
||||
paused: taskQueue.paused,
|
||||
}
|
||||
: undefined,
|
||||
task: taskWithEnvironment,
|
||||
possibleTimezones,
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
},
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
payload: payload.data,
|
||||
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
|
||||
} satisfies ScheduledRun;
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
latestVersions,
|
||||
disableVersionSelection,
|
||||
allowArbitraryQueues,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return task.triggerSource satisfies never;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+861
-315
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -138,7 +138,7 @@ function TaskSelector({
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="medium"
|
||||
variant="small"
|
||||
icon={MagnifyingGlassIcon}
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
per_page: z.coerce.number().min(1).default(20),
|
||||
type: z.enum(["task", "custom"]).optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const { page, per_page, query, type } = SearchParamsSchema.parse(
|
||||
Object.fromEntries(url.searchParams)
|
||||
);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new QueueListPresenter(per_page);
|
||||
|
||||
const result = await presenter.call({
|
||||
environment: environment,
|
||||
query,
|
||||
page,
|
||||
type,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
queues: [],
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
hasFilters: Boolean(query?.trim()) || Boolean(type),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
queues: result.queues.map((queue) => ({
|
||||
id: queue.id,
|
||||
name: queue.name,
|
||||
type: queue.type,
|
||||
paused: queue.paused,
|
||||
})),
|
||||
currentPage: result.pagination.currentPage,
|
||||
hasMore: result.pagination.currentPage < result.pagination.totalPages,
|
||||
hasFilters: result.hasFilters,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,7 @@ type WorkerDeploymentWithWorkerTasks = Prisma.WorkerDeploymentGetPayload<{
|
||||
machineConfig: true;
|
||||
maxDurationInSeconds: true;
|
||||
queueConfig: true;
|
||||
queueId: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { stringifyIO } from "@trigger.dev/core/v3";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { TestTaskData } from "../testTask";
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { type TestTaskData } from "../testTask";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { TriggerTaskService } from "./triggerTask.server";
|
||||
|
||||
@@ -15,6 +15,19 @@ export class TestTaskService extends BaseService {
|
||||
options: {
|
||||
test: true,
|
||||
metadata: data.metadata,
|
||||
delay: data.delaySeconds ? new Date(Date.now() + data.delaySeconds * 1000) : undefined,
|
||||
ttl: data.ttlSeconds,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
idempotencyKeyTTL: data.idempotencyKeyTTLSeconds
|
||||
? `${data.idempotencyKeyTTLSeconds}s`
|
||||
: undefined,
|
||||
queue: data.queue ? { name: data.queue } : undefined,
|
||||
concurrencyKey: data.concurrencyKey,
|
||||
maxAttempts: data.maxAttempts,
|
||||
maxDuration: data.maxDurationSeconds,
|
||||
tags: data.tags,
|
||||
machine: data.machine,
|
||||
lockToVersion: data.version === "latest" ? undefined : data.version,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,7 +50,22 @@ export class TestTaskService extends BaseService {
|
||||
environment,
|
||||
{
|
||||
payload: payloadPacket.data,
|
||||
options: { payloadType: payloadPacket.dataType, test: true },
|
||||
options: {
|
||||
payloadType: payloadPacket.dataType,
|
||||
test: true,
|
||||
ttl: data.ttlSeconds,
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
idempotencyKeyTTL: data.idempotencyKeyTTLSeconds
|
||||
? `${data.idempotencyKeyTTLSeconds}s`
|
||||
: undefined,
|
||||
queue: data.queue ? { name: data.queue } : undefined,
|
||||
concurrencyKey: data.concurrencyKey,
|
||||
maxAttempts: data.maxAttempts,
|
||||
maxDuration: data.maxDurationSeconds,
|
||||
tags: data.tags,
|
||||
machine: data.machine,
|
||||
lockToVersion: data.version === "latest" ? undefined : data.version,
|
||||
},
|
||||
},
|
||||
{ customIcon: "scheduled" }
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export const TestTaskData = z
|
||||
.discriminatedUnion("triggerSource", [
|
||||
@@ -56,6 +57,50 @@ export const TestTaskData = z
|
||||
z.object({
|
||||
taskIdentifier: z.string(),
|
||||
environmentId: z.string(),
|
||||
delaySeconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.transform((val) => (val === 0 ? undefined : val)),
|
||||
ttlSeconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.transform((val) => (val === 0 ? undefined : val)),
|
||||
idempotencyKey: z.string().optional(),
|
||||
idempotencyKeyTTLSeconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.transform((val) => (val === 0 ? undefined : val)),
|
||||
queue: z.string().optional(),
|
||||
concurrencyKey: z.string().optional(),
|
||||
maxAttempts: z.number().min(1).optional(),
|
||||
machine: MachinePresetName.optional(),
|
||||
maxDurationSeconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.transform((val) => (val === 0 ? undefined : val)),
|
||||
tags: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val || val.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
return val
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
})
|
||||
.refine((tags) => !tags || tags.length <= 10, {
|
||||
message: "Maximum 10 tags allowed",
|
||||
})
|
||||
.refine((tags) => !tags || tags.every((tag) => tag.length <= 128), {
|
||||
message: "Each tag must be at most 128 characters long",
|
||||
}),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user