Features: Disabling and deleting jobs (#381)

* Don’t show the Ready To Run Job prompt if you have an Integration that needs attention

* Don’t highlight the row red or green

* Improvements to the contrast of the app sections and dividers

* Removed the Jobs page title as it’s duped info

* using the new border variable

* Sticky last table cell

* Improved the sticky last table cell

* Make any last cell in a table sticky by adding isSticky to it

* Added a dropdown menu to the menu table cell

* table rows can be marked as disabled by adding disabled

* Using the jobTestPath function for the test path

* tidy up imports

* Removed un-used props

* Removed the green badge variant

* Added a new status badge to the Runs table

* Clicking the gradient clicks the row

* Delete Job triggers a modal popup

* Added a large danger button type

* Added some modal styling and started adding data

* Added more styling and data to the delete job modal

* A table can now be given a full width prop

* Large danger button added to Storybook

* Danger button disabled state looks disabled now

* New active badge component to display in the table and logic for showing the env data

* Style updates to the dialog component

* active and job status badges can now have a small size

* Added a new named icon

* The Job page shows the Job status in the PageInfoRow

* Small badge style update

* Runs table has a sticky right cell

* Created a JobStatusTable component

* Added some placeholder help panel content for disabling a Job

* WIP creating a Settings page

* Added a delete button that triggers the delete modal – just need data hooking up

* Implemented deleting jobs from the dashboard

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
This commit is contained in:
James Ritchie
2023-08-24 10:30:00 +01:00
committed by GitHub
parent 3ce5397072
commit 708ebbde14
50 changed files with 887 additions and 126 deletions
@@ -0,0 +1,57 @@
import { cn } from "~/utils/cn";
const variant = {
small:
"py-[0.25rem] px-1.5 text-xxs font-normal inline-flex items-center justify-center whitespace-nowrap rounded-[0.125rem]",
normal:
"py-1 px-1.5 text-xs font-normal inline-flex items-center justify-center whitespace-nowrap rounded-sm",
};
type ActiveBadgeProps = {
active: boolean;
className?: string;
badgeSize?: keyof typeof variant;
};
export function ActiveBadge({ active, className, badgeSize = "normal" }: ActiveBadgeProps) {
switch (active) {
case true:
return (
<span className={cn(variant[badgeSize], "bg-slate-800 text-green-500", className)}>
Active
</span>
);
case false:
return (
<span className={cn(variant[badgeSize], "bg-slate-800 text-dimmed", className)}>
Disabled
</span>
);
}
}
export function MissingIntegrationBadge({
className,
badgeSize = "normal",
}: {
className?: string;
badgeSize?: keyof typeof variant;
}) {
return (
<span className={cn(variant[badgeSize], "bg-rose-600 text-white", className)}>
Missing Integration
</span>
);
}
export function NewBadge({
className,
badgeSize = "normal",
}: {
className?: string;
badgeSize?: keyof typeof variant;
}) {
return (
<span className={cn(variant[badgeSize], "bg-green-600 text-background", className)}>New!</span>
);
}
@@ -0,0 +1,54 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { EnvironmentLabel } from "./environments/EnvironmentLabel";
import { DateTime } from "./primitives/DateTime";
import { ActiveBadge } from "./ActiveBadge";
export type JobEnvironment = {
type: RuntimeEnvironmentType;
lastRun?: Date;
version: string;
enabled: boolean;
};
type JobStatusTableProps = {
environments: JobEnvironment[];
};
export function JobStatusTable({ environments }: JobStatusTableProps) {
return (
<Table fullWidth>
<TableHeader>
<TableRow>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Last Run</TableHeaderCell>
<TableHeaderCell alignment="right">Version</TableHeaderCell>
<TableHeaderCell alignment="right">Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment, index) => (
<TableRow key={index}>
<TableCell>
<EnvironmentLabel environment={environment} />
</TableCell>
<TableCell>
{environment.lastRun ? <DateTime date={environment.lastRun} /> : "Never Run"}
</TableCell>
<TableCell alignment="right">{environment.version}</TableCell>
<TableCell alignment="right">
<ActiveBadge active={environment.enabled} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
@@ -13,7 +13,7 @@ export function EnvironmentLabel({
return (
<span
className={cn(
"flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
environmentColorClassName(environment),
className
)}
@@ -414,6 +414,60 @@ export function HowToUseThisIntegration({ integration, help, integrationClient }
);
}
export function HowToDisableAJob({
id,
name,
version,
}: {
id: string;
name: string;
version: string;
}) {
return (
<>
<Paragraph spacing>
To disable a job, you need to set the <InlineCode>enabled</InlineCode> property to{" "}
<InlineCode>false</InlineCode>.
</Paragraph>
<StepNumber
stepNumber="1"
title={
<>
Set <InlineCode>enabled</InlineCode> to <InlineCode>false</InlineCode>
</>
}
/>
<StepContentContainer>
<CodeBlock
showLineNumbers={false}
className="mb-4"
code={`client.defineJob({
id: "${id}",
name: "${name}",
version: "${version}",
enabled: false,
// ...rest of your Job definition
});`}
/>
</StepContentContainer>
<StepNumber
stepNumber="2"
title={
<>
Run the <InlineCode>@trigger.dev/cli dev</InlineCode> command
</>
}
/>
<StepContentContainer>
<Paragraph spacing>
If you aren't already running the <InlineCode>dev</InlineCode> command, run it now.
</Paragraph>
<TriggerDevCommand />
</StepContentContainer>
</>
);
}
export function HowToUseApiKeysAndEndpoints() {
return (
<>
@@ -0,0 +1,108 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { JobStatusTable } from "../JobsStatusTable";
import { Button } from "../primitives/Buttons";
import { Header1, Header2 } from "../primitives/Headers";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { useFetcher } from "@remix-run/react";
import { Spinner } from "../primitives/Spinner";
type JobEnvironment = {
type: RuntimeEnvironmentType;
lastRun?: Date;
version: string;
enabled: boolean;
};
type DeleteJobDialogContentProps = {
id: string;
title: string;
slug: string;
environments: JobEnvironment[];
redirectTo?: string;
};
export function DeleteJobDialogContent({
title,
slug,
environments,
id,
redirectTo,
}: DeleteJobDialogContentProps) {
const canDelete = environments.every((environment) => !environment.enabled);
const fetcher = useFetcher();
const isLoading =
fetcher.state === "submitting" ||
(fetcher.state === "loading" && fetcher.formMethod === "DELETE");
return (
<div className="flex w-full flex-col items-center gap-y-6">
<div className="flex flex-col items-center justify-center gap-y-2">
<Header1>{title}</Header1>
<Paragraph variant="small">ID: {slug}</Paragraph>
</div>
<JobStatusTable environments={environments} />
<Header2
className={cn(
canDelete ? "border-rose-500 bg-rose-500/10" : "border-amber-500 bg-amber-500/10",
"rounded border px-3.5 py-2 text-center text-bright"
)}
>
{canDelete
? "Are you sure you want to delete this Job?"
: "You can't delete this Job until all env are disabled"}
</Header2>
<Paragraph variant="small" className="px-6 text-center">
{canDelete ? (
<>
This will permanently delete the Job <span className="strong text-bright">{title}</span>
. This includes the deletion of all Run history. This cannot be undone.
</>
) : (
<>
This Job is still active in an environment. You need to disable it in your Job code
first before it can be deleted.{" "}
<TextLink to="https://trigger.dev/docs/documentation/guides/jobs/managing#disabling-jobs">
Learn how to disable a Job
</TextLink>
.
</>
)}
</Paragraph>
{canDelete ? (
<fetcher.Form
method="delete"
action={`/resources/jobs/${id}${redirectTo ? `?redirectTo=${redirectTo}` : ""}`}
>
<Button variant="danger/large" fullWidth>
{isLoading ? (
<Spinner />
) : (
<>
<NamedIcon
name="trash-can"
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
/>
Delete this job
</>
)}
</Button>
</fetcher.Form>
) : (
<Button variant="danger/large" fullWidth disabled>
<>
<NamedIcon
name="trash-can"
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
/>
Delete this job
</>
</Button>
)}
</div>
);
}
@@ -0,0 +1,29 @@
import { ActiveBadge, MissingIntegrationBadge, NewBadge } from "../ActiveBadge";
type JobStatusBadgeProps = {
enabled: boolean;
hasIntegrationsRequiringAction: boolean;
hasRuns: boolean;
badgeSize?: "small" | "normal";
};
export function JobStatusBadge({
enabled,
hasIntegrationsRequiringAction,
hasRuns,
badgeSize = "normal",
}: JobStatusBadgeProps) {
if (!enabled) {
return <ActiveBadge active={false} badgeSize={badgeSize} />;
}
if (hasIntegrationsRequiringAction) {
return <MissingIntegrationBadge badgeSize={badgeSize} />;
}
if (!hasRuns) {
return <NewBadge badgeSize={badgeSize} />;
}
return <ActiveBadge active={true} badgeSize={badgeSize} />;
}
+46 -22
View File
@@ -1,26 +1,29 @@
import { jobPath } from "~/utils/pathBuilder";
import { ProjectJob } from "~/hooks/useJobs";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { JobRunStatus } from "~/models/job.server";
import { jobPath, jobTestPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { DateTime } from "../primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { LabelValueStack } from "../primitives/LabelValueStack";
import { NamedIcon } from "../primitives/NamedIcon";
import { Paragraph } from "../primitives/Paragraph";
import { PopoverMenuItem } from "../primitives/Popover";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "../primitives/Table";
import { SimpleTooltip } from "../primitives/Tooltip";
import { runStatusTitle } from "../runs/RunStatuses";
import { ProjectJob } from "~/hooks/useJobs";
import { useProject } from "~/hooks/useProject";
import { useOrganization } from "~/hooks/useOrganizations";
import { JobRunStatus } from "~/models/job.server";
import { cn } from "~/utils/cn";
import { Badge } from "../primitives/Badge";
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
import { JobStatusBadge } from "./JobStatusBadge";
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
const organization = useOrganization();
@@ -35,6 +38,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<TableHeaderCell>Integrations</TableHeaderCell>
<TableHeaderCell>Properties</TableHeaderCell>
<TableHeaderCell>Last run</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -43,13 +47,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
jobs.map((job) => {
const path = jobPath(organization, project, job);
return (
<TableRow
key={job.id}
className={cn(
(job.hasIntegrationsRequiringAction && "bg-rose-500/20") ||
(job.lastRun === undefined && "bg-green-500/20")
)}
>
<TableRow key={job.id} className="group">
<TableCell to={path}>
<span className="flex items-center gap-2">
<NamedIcon name={job.event.icon} className="h-8 w-8" />
@@ -145,13 +143,39 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
<LabelValueStack label={"Never run"} value={""} />
)}
</TableCell>
<TableCellChevron to={path}>
{job.lastRun === undefined && (
<Badge className="mr-4" variant="green">
New Job!
</Badge>
)}
</TableCellChevron>
<TableCell to={path}>
<JobStatusBadge
enabled={job.status === "ACTIVE"}
hasIntegrationsRequiringAction={job.hasIntegrationsRequiringAction}
hasRuns={job.lastRun !== undefined}
/>
</TableCell>
<TableCellMenu isSticky>
<PopoverMenuItem to={path} title="View Job" icon="eye" />
<PopoverMenuItem
to={jobTestPath(organization, project, job)}
title="Test Job"
icon="beaker"
/>
<Dialog>
<DialogTrigger asChild>
<Button variant="menu-item" LeadingIcon="trash-can">
Delete Job
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DeleteJobDialogContent
id={job.id}
title={job.title}
slug={job.slug}
environments={job.environments}
/>
</DialogHeader>
</DialogContent>
</Dialog>
</TableCellMenu>
</TableRow>
);
})
@@ -11,7 +11,7 @@ import { docsRoot } from "~/utils/pathBuilder";
export function NavBar() {
return (
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-divide py-1 pl-1 pr-2.5">
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-uiBorder py-1 pl-1 pr-2.5">
<div className="flex gap-0.5">
<Link to="/" className="p-2">
<LogoIcon className="h-5 w-5" />
@@ -58,7 +58,7 @@ export function ProjectSideMenu() {
variants={menuVariants}
initial={isCollapsed ? "collapsed" : "expanded"}
className={cn(
"flex h-full flex-col justify-between overflow-hidden border-r border-slate-850 p-1 transition duration-300 ease-in-out"
"flex h-full flex-col justify-between overflow-hidden border-r border-uiBorder p-1 transition duration-300 ease-in-out"
)}
>
<div className="flex flex-col gap-1">
@@ -6,8 +6,6 @@ const variants = {
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-slate-700 text-bright uppercase whitespace-nowrap",
outline:
"grid place-items-center rounded-sm px-1 h-5 tracking-wider text-xxs border border-dimmed text-dimmed uppercase whitespace-nowrap",
green:
"grid place-items-center rounded-sm px-1.5 h-5 tracking-wider outline-offset-1 outline outline-1 outline-green-600 text-xxs bg-green-500 text-slate-900 uppercase whitespace-nowrap",
};
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -87,6 +87,14 @@ const variant = {
shortcutVariant: undefined,
shortcut: undefined,
},
"danger/large": {
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
button:
"h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
icon: "h-5",
shortcutVariant: "medium" as const,
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
},
"menu-item": {
textColor: "text-bright",
button:
@@ -2,8 +2,9 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "~/utils/cn";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { ShortcutKey } from "./ShortcutKey";
const Dialog = DialogPrimitive.Root;
@@ -25,7 +26,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100",
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
@@ -42,15 +43,29 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-lg animate-in sm:max-w-lg sm:rounded-lg sm:zoom-in-90",
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 p-6 pt-11 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
>
<hr className="-ml-6 w-[calc(100%_+_3rem)]" />
{children}
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
<div className="flex gap-x-2">
<ShortcutKey
shortcut={{
windows: {
key: "esc",
},
mac: {
key: "esc",
},
}}
variant={"small"}
/>
<XMarkIcon className="h-5 w-5" />
<span className="sr-only">Close</span>
</div>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
@@ -21,7 +21,7 @@ export function FormTitle({
<div
className={cn(
"mb-4 flex flex-col gap-3 pb-4",
divide ? "border-b border-slate-800" : "",
divide ? "border-b border-uiBorder" : "",
className
)}
>
@@ -27,6 +27,7 @@ import {
Cog8ToothIcon,
CreditCardIcon,
EnvelopeIcon,
EyeIcon,
FingerPrintIcon,
FlagIcon,
FolderIcon,
@@ -41,6 +42,7 @@ import {
QrCodeIcon,
SquaresPlusIcon,
StarIcon,
TrashIcon,
UserCircleIcon,
UserGroupIcon,
UserIcon,
@@ -50,7 +52,7 @@ import {
XMarkIcon,
} from "@heroicons/react/24/solid";
import { CompanyIcon, hasIcon } from "@trigger.dev/companyicons";
import { HourglassIcon } from "lucide-react";
import { ActivityIcon, HourglassIcon } from "lucide-react";
import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon";
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
import { ScheduleIcon } from "~/assets/icons/ScheduleIcon";
@@ -100,6 +102,7 @@ const icons = {
folder: (className: string) => <FolderIcon className={cn("text-indigo-600", className)} />,
envelope: (className: string) => <EnvelopeIcon className={cn("text-cyan-500", className)} />,
environment: (className: string) => <KeyIcon className={cn("text-yellow-500", className)} />,
eye: (className: string) => <EyeIcon className={cn("text-blue-500", className)} />,
globe: (className: string) => <GlobeAltIcon className={cn("text-fuchsia-600", className)} />,
"hand-raised": (className: string) => (
<HandRaisedIcon className={cn("text-amber-400", className)} />
@@ -129,6 +132,7 @@ const icons = {
<PlusSmallIcon className={cn("text-green-600", className)} />
),
property: (className: string) => <Cog8ToothIcon className={cn("text-slate-600", className)} />,
pulse: (className: string) => <ActivityIcon className={cn("text-green-600", className)} />,
"qr-code": (className: string) => <QrCodeIcon className={cn("text-amber-400", className)} />,
refresh: (className: string) => <ArrowPathIcon className={cn("text-bright", className)} />,
sapling: (className: string) => <SaplingIcon className={cn("text-green-500", className)} />,
@@ -139,6 +143,7 @@ const icons = {
star: (className: string) => <StarIcon className={cn("text-yellow-500", className)} />,
stop: (className: string) => <StopIcon className={cn("text-rose-500", className)} />,
team: (className: string) => <UserGroupIcon className={cn("text-blue-500", className)} />,
"trash-can": (className: string) => <TrashIcon className={cn("text-rose-500", className)} />,
tree: (className: string) => <OneTreeIcon className={cn("text-green-500", className)} />,
trees: (className: string) => <TwoTreesIcon className={cn("text-green-500", className)} />,
trigger: (className: string) => <BoltIcon className={cn("text-orange-500", className)} />,
@@ -13,7 +13,7 @@ type WithChildren = {
export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) {
return (
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-slate-800 pb-4")}>
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-uiBorder pb-4")}>
{children}
</div>
);
@@ -4,9 +4,8 @@ import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "~/utils/cn";
import { Paragraph } from "./Paragraph";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import { LinkButton } from "./Buttons";
import { IconNames } from "./NamedIcon";
const Popover = PopoverPrimitive.Root;
@@ -91,6 +90,24 @@ function PopoverArrowTrigger({
);
}
function PopoverVerticalEllipseTrigger({
isOpen,
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-dimmed transition hover:bg-slate-750 hover:text-bright",
className
)}
>
<EllipsisVerticalIcon className={cn("h-5 w-5 transition group-hover:text-bright")} />
</PopoverTrigger>
);
}
export {
Popover,
PopoverTrigger,
@@ -98,4 +115,5 @@ export {
PopoverSectionHeader,
PopoverArrowTrigger,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
};
@@ -51,7 +51,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-y border-slate-800",
"fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-uiBorder",
{
variants: {
position: {
@@ -154,7 +154,7 @@ const SheetContent = React.forwardRef<
{...props}
>
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
<div className="flex items-center gap-2 border-b border-slate-800 p-2">
<div className="flex items-center gap-2 border-b border-uiBorder p-2">
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-slate-800 disabled:pointer-events-none">
<NamedIcon name="close" className="h-4 w-4" />
<span className="sr-only">Close</span>
@@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivE
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"mx-4 flex shrink-0 items-center gap-4 border-b border-slate-800 py-3.5",
"mx-4 flex shrink-0 items-center gap-4 border-b border-uiBorder py-3.5",
className
)}
{...props}
@@ -194,7 +194,7 @@ export const SheetFooter = ({
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("shrink-0", className)} {...props}>
<div className="mx-4 border-t border-slate-800 py-3">{children}</div>
<div className="mx-4 border-t border-uiBorder py-3">{children}</div>
</div>
);
+64 -17
View File
@@ -1,25 +1,27 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import { ReactNode, forwardRef } from "react";
import { ReactNode, forwardRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Badge } from "./Badge";
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
type TableProps = {
containerClassName?: string;
className?: string;
children: ReactNode;
fullWidth?: boolean;
};
export const Table = forwardRef<HTMLTableElement, TableProps>(
({ className, containerClassName, children }, ref) => {
({ className, containerClassName, children, fullWidth }, ref) => {
return (
<div
className={cn(
"overflow-x-auto whitespace-nowrap rounded-md border border-slate-900 scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
containerClassName
"overflow-x-auto whitespace-nowrap rounded-md border border-uiBorder scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
containerClassName,
fullWidth && "w-full"
)}
>
<table ref={ref} className={cn("w-full divide-y bg-midnight-850", className)}>
<table ref={ref} className={cn("w-full divide-y", className)}>
{children}
</table>
</div>
@@ -37,7 +39,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
return (
<thead
ref={ref}
className={cn("rounded-t-md", "relative divide-y divide-slate-850", className)}
className={cn("rounded-t-md", "relative divide-y divide-uiBorder bg-slate-850", className)}
>
{children}
</thead>
@@ -53,7 +55,7 @@ type TableBodyProps = {
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className, children }, ref) => {
return (
<tbody ref={ref} className={cn("relative divide-y divide-slate-850", className)}>
<tbody ref={ref} className={cn("relative divide-y divide-uiBorder", className)}>
{children}
</tbody>
);
@@ -63,12 +65,13 @@ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
type TableRowProps = {
className?: string;
children: ReactNode;
disabled?: boolean;
};
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, children }, ref) => {
({ className, disabled, children }, ref) => {
return (
<tr ref={ref} className={cn("group w-full", className)}>
<tr ref={ref} className={cn(disabled && "opacity-50", "group w-full", className)}>
{children}
</tr>
);
@@ -103,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
ref={ref}
scope="col"
className={cn(
"px-4 py-3 align-middle text-xs font-semibold uppercase text-slate-400",
"px-4 py-3 align-middle text-xs font-normal uppercase tracking-wider text-dimmed",
alignmentClassName,
className
)}
@@ -118,10 +121,11 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
type TableCellProps = TableCellBasicProps & {
to?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
hasAction?: boolean;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, alignment = "left", children, colSpan, to, onClick }, ref) => {
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
let alignmentClassName = "text-left";
switch (alignment) {
case "center":
@@ -133,7 +137,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
const flexClasses = cn(
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-slate-400",
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-dimmed",
alignment === "left"
? "justify-start text-left"
: alignment === "center"
@@ -145,8 +149,10 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
<td
ref={ref}
className={cn(
"text-xs text-slate-400 transition group-hover:bg-slate-850/50",
to || onClick ? "cursor-pointer" : "px-4 py-3 align-middle",
"text-xs text-slate-400",
to || onClick || hasAction
? "cursor-pointer group-hover:bg-slate-900"
: "px-4 py-3 align-middle",
!to && !onClick && alignmentClassName,
className
)}
@@ -168,23 +174,64 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
);
const stickyStyles =
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
className?: string;
to?: string;
children?: ReactNode;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
>(({ className, to, children, onClick }, ref) => {
>(({ className, to, children, isSticky, onClick }, ref) => {
return (
<TableCell className={className} to={to} onClick={onClick} ref={ref} alignment="right">
<TableCell
className={cn(isSticky && stickyStyles, className)}
to={to}
onClick={onClick}
ref={ref}
alignment="right"
>
{children}
<ChevronRightIcon className="h-4 w-4 text-dimmed transition group-hover:text-bright" />
</TableCell>
);
});
export const TableCellMenu = forwardRef<
HTMLTableCellElement,
{
className?: string;
children?: ReactNode;
isSticky?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}
>(({ className, children, isSticky, onClick }, ref) => {
const [isOpen, setIsOpen] = useState(false);
return (
<TableCell
className={cn(isSticky && stickyStyles, className)}
onClick={onClick}
ref={ref}
alignment="right"
hasAction={true}
>
<Popover onOpenChange={(open) => setIsOpen(open)}>
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
<PopoverContent
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
align="end"
>
<div className="flex flex-col gap-1 p-1">{children}</div>
</PopoverContent>
</Popover>
</TableCell>
);
});
type TableBlankRowProps = {
className?: string;
colSpan: number;
@@ -12,7 +12,7 @@ export type TabsProps = {
export function Tabs({ tabs, className }: TabsProps) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-slate-800`, className)}>
<div className={cn(`flex flex-row gap-x-6 border-b border-uiBorder`, className)}>
{tabs.map((tab, index) => (
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
{({ isActive, isPending }) => (
@@ -109,7 +109,7 @@ export function RunsTable({
<TableCell to={path}>
{run.createdAt ? <DateTime date={run.createdAt} /> : ""}
</TableCell>
<TableCellChevron to={path} />
<TableCellChevron to={path} isSticky />
</TableRow>
);
})
@@ -20,7 +20,6 @@ function BadgesExample() {
<div className="flex flex-col items-start gap-y-8 p-8">
<Badge>Default</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="green">Green</Badge>
</div>
);
}
@@ -253,13 +253,11 @@ function ButtonList({ primary }: { primary: string }) {
<Header1 className="mb-2 mt-8">Large buttons</Header1>
<div className="grid grid-cols-1 gap-8 border-b border-slate-700 pb-8">
<div className="flex flex-col gap-2">
<div className="flex flex-col items-start">
<div className="flex flex-col items-start gap-2">
<Button variant="primary/large" fullWidth>
<NamedIcon name={"github"} className={"mr-1.5 h-4 w-4"} />
Continue with GitHub
</Button>
</div>
<div className="flex flex-col items-start">
<Button variant="secondary/large" fullWidth>
<NamedIcon
name={"envelope"}
@@ -267,6 +265,13 @@ function ButtonList({ primary }: { primary: string }) {
/>
Continue with Email
</Button>
<Button variant="danger/large" fullWidth>
<NamedIcon
name={"trash-can"}
className={"mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"}
/>
This is a delete button
</Button>
</div>
</div>
</div>
+4
View File
@@ -8,6 +8,10 @@ export type ProjectJob = UseDataFunctionReturn<typeof loader>["projectJobs"][num
export const jobsMatchId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
// This is only used in the JobsMenu component, which is the breadcrumb job list dropdown.
// This dropdown is only shown once you have selected a job, so we can assume that
// the route above has loaded and we can use the data from it.
export function useOptionalJobs(matches?: RouteMatch[]) {
const routeMatch = useTypedMatchesData<typeof loader>({
id: jobsMatchId,
+19
View File
@@ -102,6 +102,25 @@ export async function jsonWithSuccessMessage(
});
}
export async function jsonWithErrorMessage(
data: any,
request: Request,
message: string,
options?: ToastMessageOptions
) {
const session = await getSession(request.headers.get("cookie"));
setErrorMessage(session, message, options);
return json(data, {
headers: {
"Set-Cookie": await commitSession(session, {
expires: new Date(Date.now() + ONE_YEAR),
}),
},
});
}
export async function redirectWithSuccessMessage(
path: string,
request: Request,
@@ -49,6 +49,7 @@ export function getOrganizations({ userId }: { userId: User["id"] }) {
jobs: {
where: {
internal: false,
deletedAt: null,
},
},
},
@@ -63,6 +63,7 @@ export class IntegrationClientPresenter {
slug: projectSlug,
},
internal: false,
deletedAt: null,
},
},
},
@@ -71,6 +71,7 @@ export class IntegrationsPresenter {
slug: projectSlug,
},
internal: false,
deletedAt: null,
},
},
},
@@ -47,6 +47,7 @@ export class JobListPresenter {
version: true,
eventSpecification: true,
properties: true,
status: true,
runs: {
select: {
createdAt: true,
@@ -92,6 +93,7 @@ export class JobListPresenter {
},
where: {
internal: false,
deletedAt: null,
organization: orgWhere,
project: {
slug: projectSlug,
@@ -162,11 +164,19 @@ export class JobListPresenter {
properties = [...properties, ...versionProperties];
}
const environments = job.aliases.map((alias) => ({
type: alias.environment.type,
enabled: alias.version.status === "ACTIVE",
lastRun: alias.version.runs.at(0)?.createdAt,
version: alias.version.version,
}));
return {
id: job.id,
slug: job.slug,
title: job.title,
version: alias.version.version,
status: alias.version.status,
dynamic: job.dynamicTriggers.length > 0,
event: {
title: eventSpecification.title,
@@ -179,6 +189,7 @@ export class JobListPresenter {
),
lastRun,
properties,
environments,
};
})
.filter(Boolean);
@@ -80,6 +80,7 @@ export class ProjectPresenter {
},
where: {
internal: false,
deletedAt: null,
},
orderBy: [{ title: "asc" }],
},
@@ -73,8 +73,6 @@ export default function Page() {
const { filterText, setFilterText, filteredItems } = useFilterJobs(jobs);
const { width, height } = useWindowSize();
return (
<PageContainer>
<PageHeader>
@@ -104,7 +102,6 @@ export default function Page() {
</Callout>
)}
<div className="mb-2 flex flex-col">
<Header2 spacing>Jobs</Header2>
<div className="flex w-full">
<Input
placeholder="Search Jobs"
@@ -122,9 +119,11 @@ export default function Page() {
noResultsText={`No Jobs match ${filterText}. Try a different search
query.`}
/>
{jobs.length === 1 && jobs.every((r) => r.lastRun === undefined) && (
<RunYourJobPrompt />
)}
{jobs.length === 1 &&
jobs.every((r) => r.lastRun === undefined) &&
jobs.every((i) => i.hasIntegrationsRequiringAction === false) && (
<RunYourJobPrompt />
)}
</>
) : (
<HowToSetupYourProject />
@@ -194,7 +193,7 @@ function ExampleJobs() {
<a
href={example.codeLink}
key={example.title}
className="flex w-full items-center rounded border-b border-slate-800 py-2 transition hover:border-transparent hover:bg-slate-800"
className="flex w-full items-center rounded border-b border-uiBorder py-2 transition hover:border-transparent hover:bg-slate-800"
>
{example.icon}
<Paragraph variant="small">
@@ -355,7 +355,7 @@ function ConnectedIntegrationsList({
<TableCell to={path}>
<DateTime date={client.createdAt} includeSeconds={false} />
</TableCell>
<TableCellChevron to={path} />
<TableCellChevron to={path} isSticky />
</TableRow>
);
})}
@@ -454,7 +454,7 @@ function IntegrationsWithMissingFields({
integration={integration}
organizationId={organizationId}
button={
<ChevronRightIcon className="h-4 w-4 text-slate-700 transition group-hover:text-bright" />
<ChevronRightIcon className="h-4 w-4 text-dimmed transition group-hover:text-bright" />
}
callbackUrl={callbackUrl}
existingIntegration={client}
@@ -482,7 +482,7 @@ function AddIntegrationConnection({
icon?: string;
}) {
return (
<div className="group flex h-11 w-full items-center gap-2 rounded-md p-1 pr-3 transition hover:bg-slate-850">
<div className="group flex h-11 w-full items-center gap-2 rounded-md p-1 pr-3 transition hover:bg-slate-900">
<NamedIconInBox
name={icon ?? identifier}
className="h-9 w-9 flex-none transition group-hover:border-slate-750"
@@ -1,11 +1,66 @@
import { ComingSoon } from "~/components/ComingSoon";
import { JobStatusTable } from "~/components/JobsStatusTable";
import { HowToDisableAJob } from "~/components/helpContent/HelpContentText";
import { DeleteJobDialogContent } from "~/components/jobs/DeleteJobModalContent";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useJob } from "~/hooks/useJob";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { projectJobsPath, projectPath } from "~/utils/pathBuilder";
export default function Page() {
const job = useJob();
const organization = useOrganization();
const project = useProject();
return (
<ComingSoon
title="Job settings"
description="Disable a Job, archive it and more."
icon="settings"
/>
<Help defaultOpen>
{(open) => (
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
<div className="w-full">
<div className="flex items-center justify-between">
<Header2 className="mb-2 flex items-center gap-1">Environments</Header2>
<HelpTrigger title="How do disable a Job?" />
</div>
<JobStatusTable environments={job.environments} />
<div className="mt-4 flex w-full items-center justify-end gap-x-3">
<Paragraph variant="small">
Disable this Job in all environments before deleting
</Paragraph>
<Dialog>
<DialogTrigger asChild>
<Button
variant="danger/small"
leadingIconClassName="text-bright"
LeadingIcon="trash-can"
>
Delete Job
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DeleteJobDialogContent
title={job.title}
slug={job.slug}
environments={job.environments}
id={job.id}
redirectTo={projectJobsPath(organization, project)}
/>
</DialogHeader>
</DialogContent>
</Dialog>
</div>
</div>
<HelpContent title="How to disable a Job">
<HowToDisableAJob id={job.slug} version={job.version} name={job.title} />
</HelpContent>
</div>
)}
</Help>
);
}
@@ -2,6 +2,7 @@ import { Outlet, useLocation } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson } from "remix-typedjson";
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { JobsMenu } from "~/components/navigation/JobsMenu";
import { BreadcrumbLink } from "~/components/navigation/NavBar";
@@ -134,6 +135,18 @@ export default function Job() {
}
/>
)}
<PageInfoProperty
icon="pulse"
label={"STATUS"}
value={
<JobStatusBadge
enabled={job.status === "ACTIVE"}
hasIntegrationsRequiringAction={job.hasIntegrationsRequiringAction}
hasRuns={job.lastRun !== undefined}
badgeSize="small"
/>
}
/>
</PageInfoGroup>
<PageInfoGroup alignment="right">
<Paragraph variant="extra-small" className="text-slate-600">
@@ -103,7 +103,7 @@ export default function Page() {
<OrgAdminHeader />
<PageBody>
<Header2>Members</Header2>
<ul className="flex w-full max-w-md flex-col divide-y divide-slate-800 border-b border-slate-800">
<ul className="flex w-full max-w-md flex-col divide-y divide-uiBorder border-b border-uiBorder">
{members.map((member) => (
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
<UserAvatar
@@ -0,0 +1,74 @@
import { ActionFunction } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import {
jsonWithErrorMessage,
jsonWithSuccessMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { DeleteJobService } from "~/services/jobs/deleteJob.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
const ParamSchema = z.object({
jobId: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const { jobId } = ParamSchema.parse(params);
const userId = await requireUserId(request);
// Find the job
const job = await prisma.job.findFirst({
where: {
id: jobId,
organization: {
members: {
some: {
userId,
},
},
},
},
});
if (!job) {
return jsonWithErrorMessage({ ok: false }, request, `Job could not be scheduled for deletion.`);
}
try {
const deleteJobService = new DeleteJobService();
await deleteJobService.call(job);
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");
logger.debug("Job scheduled for deletion", {
url,
redirectTo,
job,
});
if (typeof redirectTo === "string" && redirectTo.length > 0) {
return redirectWithSuccessMessage(
redirectTo,
request,
`Job ${job.slug} has been scheduled for deletion.`
);
}
return jsonWithSuccessMessage(
{ ok: true },
request,
`Job ${job.slug} has been scheduled for deletion.`
);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return jsonWithErrorMessage(
{ ok: false },
request,
`Job could not be scheduled for deletion: ${message}`
);
}
};
@@ -65,6 +65,7 @@ export class IndexEndpointService {
const existingJobs = await this.#prismaClient.job.findMany({
where: {
projectId: endpoint.projectId,
deletedAt: null,
},
include: {
aliases: {
@@ -99,9 +100,11 @@ export class IndexEndpointService {
}
} else {
try {
await this.#registerJobService.call(endpoint, job);
const registeredVersion = await this.#registerJobService.call(endpoint, job);
indexStats.jobs++;
if (registeredVersion) {
indexStats.jobs++;
}
} catch (error) {
logger.error("Failed to register job", {
endpointId: endpoint.id,
@@ -0,0 +1,45 @@
import type { Job } from "@trigger.dev/database";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { telemetry } from "../telemetry.server";
export class DeleteJobService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(job: Job) {
// Make sure that all the latest versions are disabled
const latestVersions = await this.#prismaClient.jobAlias.findMany({
where: {
jobId: job.id,
name: "latest",
},
include: {
version: true,
},
});
const allDisabled = latestVersions.every((alias) => alias.version.status === "DISABLED");
if (!allDisabled) {
throw new Error("All latest versions must be disabled before deleting a job");
}
// Okay now we need to delete a job by setting the deletedAt field and enqueuing a job to cleanup the job
await this.#prismaClient.job.update({
where: {
id: job.id,
},
data: {
deletedAt: new Date(),
},
});
telemetry.project.deletedJob({
job,
});
}
}
@@ -34,7 +34,21 @@ export class RegisterJobService {
endpoint: Endpoint,
environment: AuthenticatedEnvironment,
metadata: JobMetadata
): Promise<JobVersion> {
): Promise<JobVersion | undefined> {
// Check the job doesn't already exist and is deleted
const existingJob = await this.#prismaClient.job.findUnique({
where: {
projectId_slug: {
projectId: environment.projectId,
slug: metadata.id,
},
},
});
if (existingJob && existingJob.deletedAt && !metadata.enabled) {
return;
}
const integrations = new Map<string, Integration>();
for (const [, jobIntegration] of Object.entries(metadata.integrations)) {
@@ -155,6 +169,7 @@ export class RegisterJobService {
},
update: {
title: metadata.name,
deletedAt: metadata.enabled ? null : undefined,
},
include: {
integrations: {
@@ -1,3 +1,4 @@
import { Job } from "@trigger.dev/database";
import { TriggerClient } from "@trigger.dev/sdk";
import { PostHog } from "posthog-node";
import { env } from "~/env.server";
@@ -149,6 +150,14 @@ class Telemetry {
},
});
},
deletedJob: ({ job }: { job: Job }) => {
this.#triggerClient?.sendEvent({
name: "job.deleted",
payload: {
id: job.id,
},
});
},
};
#capture(event: CaptureEvent) {
+4
View File
@@ -115,6 +115,10 @@ export function projectPath(organization: OrgForPath, project: ProjectForPath) {
return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`;
}
export function projectJobsPath(organization: OrgForPath, project: ProjectForPath) {
return projectPath(organization, project);
}
export function projectIntegrationsPath(organization: OrgForPath, project: ProjectForPath) {
return `${projectPath(organization, project)}/integrations`;
}
+1
View File
@@ -144,6 +144,7 @@ module.exports = {
},
devEnv: colors.pink,
liveEnv: colors.green,
uiBorder: slate[800],
},
borderRadius: {
lg: radius,
@@ -0,0 +1,84 @@
---
title: "Managing Jobs"
description: "Managing jobs in your codebase and the dashboard"
---
## Disabling jobs
To prevent a job from processing new runs, you can disable it by setting the `enabled` option:
```ts
client.defineJob({
id: "example-job",
name: "Example Job",
version: "0.1.0",
trigger: eventTrigger({ name: "example.event" }),
enabled: false,
run: async (payload, io, ctx) => {
// your job code here
},
});
```
If you omit the `enabled` option, it will default to `true`.
The job will only be disabled in environments that have seen the `enabled = false` value. So the job will remain enabled in production until the code with the `enabled = false` is deployed to production.
<Note>
Currently this is the only way to disable a job. If you'd like to disable a job in the Dashboard,
please reach out to us on [Discord](https://discord.gg/kA47vcd8P6) and let us know 👋
</Note>
Once a job is disabled no **new** runs will be created for that job, and it will still be visible in the Dashboard as disabled:
![Disabled Job](/images/disabled-job.png)
### In-progress runs
In-progress runs will be allowed to finish, even runs that are currently delayed from a call to `io.wait`. If you'd like to completely stop in-progress runs, you have two options:
- Set the `enabled` option to false and then `throw` an error at the top of your job `run` function.
```ts
client.defineJob({
id: "example-job",
name: "Example Job",
version: "0.1.0",
trigger: eventTrigger({ name: "example.event" }),
enabled: false,
run: async (payload, io, ctx) => {
throw new Error("Job disabled");
},
});
```
- Delete the job from your codebase. This will disable the job as well but also stop in progress runs.
### Disabling in production with env vars
You can easily disable jobs in production using env vars so you don't have to deploy new code to disable a job.
```ts
client.defineJob({
id: "example-job",
name: "Example Job",
version: "0.1.0",
trigger: eventTrigger({ name: "example.event" }),
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
run: async (payload, io, ctx) => {
// your job code here
},
});
```
Then you can disable the job in production by setting the `TRIGGER_JOBS_DISABLED` env var to `"true"`. And removing the env var will re-enable the job.
## Deleting jobs
Once you have disabled a job in all environments, you can delete it from the dashboard by navigating to the Job list page and clicking the "triple-dot" menu next to the job you want to delete:
![Job Menu](/images/job-triple-dot-menu.png)
This will bring up a dialog confirming that you want to delete the job and all of its history:
![Delete Job Dialog](/images/delete-job.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+34 -11
View File
@@ -63,7 +63,10 @@
"documentation/introduction",
{
"group": "Quick Starts",
"pages": ["documentation/quickstarts/nextjs", "documentation/quickstarts/supabase"]
"pages": [
"documentation/quickstarts/nextjs",
"documentation/quickstarts/supabase"
]
},
"documentation/guides/create-a-job",
"documentation/guides/video-walkthrough"
@@ -114,6 +117,7 @@
"documentation/guides/cli",
"documentation/guides/manual",
"documentation/guides/running-jobs",
"documentation/guides/jobs/managing",
{
"group": "Using the Dashboard",
"pages": [
@@ -167,7 +171,10 @@
},
{
"group": "Overview",
"pages": ["integrations/introduction", "integrations/create"]
"pages": [
"integrations/introduction",
"integrations/create"
]
},
{
"group": "Integrations",
@@ -180,23 +187,30 @@
"integrations/apis/github-tasks"
]
},
{
"group": "OpenAI",
"pages": ["integrations/apis/openai"]
"pages": [
"integrations/apis/openai"
]
},
"integrations/apis/plain",
{
"group": "Resend",
"pages": ["integrations/apis/resend"]
"pages": [
"integrations/apis/resend"
]
},
{
"group": "SendGrid",
"pages": ["integrations/apis/sendgrid"]
"pages": [
"integrations/apis/sendgrid"
]
},
{
"group": "Slack",
"pages": ["integrations/apis/slack"]
"pages": [
"integrations/apis/slack"
]
},
{
"group": "Supabase",
@@ -258,7 +272,10 @@
"sdk/dynamictrigger/constructor",
{
"group": "Instance methods",
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
"pages": [
"sdk/dynamictrigger/register",
"sdk/dynamictrigger/unregister"
]
}
]
},
@@ -269,7 +286,10 @@
"sdk/dynamicschedule/constructor",
{
"group": "Instance methods",
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
"pages": [
"sdk/dynamicschedule/register",
"sdk/dynamicschedule/unregister"
]
}
]
},
@@ -290,7 +310,10 @@
},
{
"group": "Overview",
"pages": ["examples/introduction", "examples/examples-repository"]
"pages": [
"examples/introduction",
"examples/examples-repository"
]
}
],
"footerSocials": {
@@ -303,4 +326,4 @@
"apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"
}
}
}
}
+3 -21
View File
@@ -49,27 +49,6 @@ client.defineJob({
});
```
```ts queue options
client.defineJob({
id: "github-integration-on-issue",
name: "GitHub Integration - On Issue",
version: "0.1.0",
trigger: github.triggers.repo({
event: events.onIssue,
owner: "triggerdotdev",
repo: "empty",
}),
queue: {
name: "my-queue",
maxConcurrent: 10, // only 10 runs can happen at the same time
},
run: async (payload, io, ctx) => {
await io.logger.info("This is a simple log info message");
return { payload, ctx };
},
});
```
</RequestExample>
# Constructor
@@ -114,6 +93,9 @@ client.defineJob({
Imports the specified integrations into the Job. The integrations will be available on the `io` object in the `run()` function with the same name as the key. For example:
<Snippet file="how-to-pass-integrations.mdx" />
</ParamField>
<ParamField body="enabled" type="boolean">
The `enabled` property is an optional property that specifies whether the Job is enabled or not. The Job will be enabled by default if you omit this property. When a job is disabled, no new runs will be triggered or resumed. In progress runs will continue to run until they are finished or delayed by using `io.wait`.
</ParamField>
<ParamField body="logLevel" type="log | error | warn | info | debug">
The `logLevel` property is an optional property that specifies the level of
logging for the Job. The level is inherited from the client if you omit this property.
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN "deletedAt" TIMESTAMP(3);
+2
View File
@@ -420,6 +420,8 @@ model Job {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
deletedAt DateTime?
@@unique([projectId, slug])
}
@@ -605,15 +605,6 @@ export class TriggerClient {
}
async #executeJob(body: RunJobBody, job: Job<Trigger<any>, any>): Promise<RunJobResponse> {
if (!job.enabled) {
return {
status: "ERROR",
error: {
message: "Job is disabled",
},
};
}
this.#internalLogger.debug("executing job", {
execution: body,
job: job.toJSON(),