diff --git a/apps/webapp/app/components/ActiveBadge.tsx b/apps/webapp/app/components/ActiveBadge.tsx new file mode 100644 index 000000000..cb7aef6a0 --- /dev/null +++ b/apps/webapp/app/components/ActiveBadge.tsx @@ -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 ( + + Active + + ); + case false: + return ( + + Disabled + + ); + } +} + +export function MissingIntegrationBadge({ + className, + badgeSize = "normal", +}: { + className?: string; + badgeSize?: keyof typeof variant; +}) { + return ( + + Missing Integration + + ); +} + +export function NewBadge({ + className, + badgeSize = "normal", +}: { + className?: string; + badgeSize?: keyof typeof variant; +}) { + return ( + New! + ); +} diff --git a/apps/webapp/app/components/JobsStatusTable.tsx b/apps/webapp/app/components/JobsStatusTable.tsx new file mode 100644 index 000000000..dbf66e2ab --- /dev/null +++ b/apps/webapp/app/components/JobsStatusTable.tsx @@ -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 ( + + + + Env + Last Run + Version + Status + + + + {environments.map((environment, index) => ( + + + + + + {environment.lastRun ? : "Never Run"} + + {environment.version} + + + + + ))} + +
+ ); +} diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 6bbfb516d..e74d49def 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -13,7 +13,7 @@ export function EnvironmentLabel({ return ( + + To disable a job, you need to set the enabled property to{" "} + false. + + + Set enabled to false + + } + /> + + + + + Run the @trigger.dev/cli dev command + + } + /> + + + If you aren't already running the dev command, run it now. + + + + + ); +} + export function HowToUseApiKeysAndEndpoints() { return ( <> diff --git a/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx b/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx new file mode 100644 index 000000000..6717358e1 --- /dev/null +++ b/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx @@ -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 ( +
+
+ {title} + ID: {slug} +
+ + + + {canDelete + ? "Are you sure you want to delete this Job?" + : "You can't delete this Job until all env are disabled"} + + + {canDelete ? ( + <> + This will permanently delete the Job {title} + . 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.{" "} + + Learn how to disable a Job + + . + + )} + + {canDelete ? ( + + + + ) : ( + + )} +
+ ); +} diff --git a/apps/webapp/app/components/jobs/JobStatusBadge.tsx b/apps/webapp/app/components/jobs/JobStatusBadge.tsx new file mode 100644 index 000000000..a9de0c146 --- /dev/null +++ b/apps/webapp/app/components/jobs/JobStatusBadge.tsx @@ -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 ; + } + + if (hasIntegrationsRequiringAction) { + return ; + } + + if (!hasRuns) { + return ; + } + + return ; +} diff --git a/apps/webapp/app/components/jobs/JobsTable.tsx b/apps/webapp/app/components/jobs/JobsTable.tsx index fb69c5575..7b15074d2 100644 --- a/apps/webapp/app/components/jobs/JobsTable.tsx +++ b/apps/webapp/app/components/jobs/JobsTable.tsx @@ -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 Integrations Properties Last run + Status Go to page @@ -43,13 +47,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul jobs.map((job) => { const path = jobPath(organization, project, job); return ( - + @@ -145,13 +143,39 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul )} - - {job.lastRun === undefined && ( - - New Job! - - )} - + + + + + + + + + + + + + + + + + + ); }) diff --git a/apps/webapp/app/components/navigation/NavBar.tsx b/apps/webapp/app/components/navigation/NavBar.tsx index 2ddc5a07a..6529cb4f2 100644 --- a/apps/webapp/app/components/navigation/NavBar.tsx +++ b/apps/webapp/app/components/navigation/NavBar.tsx @@ -11,7 +11,7 @@ import { docsRoot } from "~/utils/pathBuilder"; export function NavBar() { return ( -
+
diff --git a/apps/webapp/app/components/navigation/ProjectSideMenu.tsx b/apps/webapp/app/components/navigation/ProjectSideMenu.tsx index 1b1a6f8e1..c147329f7 100644 --- a/apps/webapp/app/components/navigation/ProjectSideMenu.tsx +++ b/apps/webapp/app/components/navigation/ProjectSideMenu.tsx @@ -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" )} >
diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index b7281d819..c45aad0e6 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -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 & { diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 7bbe51829..1dcc15745 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -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: diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index c553a977f..fd6cabc6c 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -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< +
{children} - - - Close + +
+ + + Close +
diff --git a/apps/webapp/app/components/primitives/FormTitle.tsx b/apps/webapp/app/components/primitives/FormTitle.tsx index 6825187f5..02a33ae16 100644 --- a/apps/webapp/app/components/primitives/FormTitle.tsx +++ b/apps/webapp/app/components/primitives/FormTitle.tsx @@ -21,7 +21,7 @@ export function FormTitle({
diff --git a/apps/webapp/app/components/primitives/NamedIcon.tsx b/apps/webapp/app/components/primitives/NamedIcon.tsx index 34fe6eb7d..817877f80 100644 --- a/apps/webapp/app/components/primitives/NamedIcon.tsx +++ b/apps/webapp/app/components/primitives/NamedIcon.tsx @@ -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) => , envelope: (className: string) => , environment: (className: string) => , + eye: (className: string) => , globe: (className: string) => , "hand-raised": (className: string) => ( @@ -129,6 +132,7 @@ const icons = { ), property: (className: string) => , + pulse: (className: string) => , "qr-code": (className: string) => , refresh: (className: string) => , sapling: (className: string) => , @@ -139,6 +143,7 @@ const icons = { star: (className: string) => , stop: (className: string) => , team: (className: string) => , + "trash-can": (className: string) => , tree: (className: string) => , trees: (className: string) => , trigger: (className: string) => , diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 4a0842ebd..e3fab7ed7 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -13,7 +13,7 @@ type WithChildren = { export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) { return ( -
+
{children}
); diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 05707cf8b..357b1c54e 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -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) { + return ( + + + + ); +} + export { Popover, PopoverTrigger, @@ -98,4 +115,5 @@ export { PopoverSectionHeader, PopoverArrowTrigger, PopoverMenuItem, + PopoverVerticalEllipseTrigger, }; diff --git a/apps/webapp/app/components/primitives/Sheet.tsx b/apps/webapp/app/components/primitives/Sheet.tsx index d6fc3ecaf..5432f8007 100644 --- a/apps/webapp/app/components/primitives/Sheet.tsx +++ b/apps/webapp/app/components/primitives/Sheet.tsx @@ -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} >
-
+
Close @@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes) => (
) => (
-
{children}
+
{children}
); diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index ab688061a..c7240256a 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -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( - ({ className, containerClassName, children }, ref) => { + ({ className, containerClassName, children, fullWidth }, ref) => { return (
- +
{children}
@@ -37,7 +39,7 @@ export const TableHeader = forwardRef return ( {children} @@ -53,7 +55,7 @@ type TableBodyProps = { export const TableBody = forwardRef( ({ className, children }, ref) => { return ( - + {children} ); @@ -63,12 +65,13 @@ export const TableBody = forwardRef( type TableRowProps = { className?: string; children: ReactNode; + disabled?: boolean; }; export const TableRow = forwardRef( - ({ className, children }, ref) => { + ({ className, disabled, children }, ref) => { return ( - + {children} ); @@ -103,7 +106,7 @@ export const TableHeaderCell = forwardRef) => void; + hasAction?: boolean; }; export const TableCell = forwardRef( - ({ 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( } 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( ( } ); +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) => void; } ->(({ className, to, children, onClick }, ref) => { +>(({ className, to, children, isSticky, onClick }, ref) => { return ( - + {children} ); }); +export const TableCellMenu = forwardRef< + HTMLTableCellElement, + { + className?: string; + children?: ReactNode; + isSticky?: boolean; + onClick?: (event: React.MouseEvent) => void; + } +>(({ className, children, isSticky, onClick }, ref) => { + const [isOpen, setIsOpen] = useState(false); + return ( + + setIsOpen(open)}> + + +
{children}
+
+
+
+ ); +}); + type TableBlankRowProps = { className?: string; colSpan: number; diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index e5a79af21..dc52ad22f 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -12,7 +12,7 @@ export type TabsProps = { export function Tabs({ tabs, className }: TabsProps) { return ( -
+
{tabs.map((tab, index) => ( {({ isActive, isPending }) => ( diff --git a/apps/webapp/app/components/runs/RunsTable.tsx b/apps/webapp/app/components/runs/RunsTable.tsx index 87d060e1a..7f185e3c3 100644 --- a/apps/webapp/app/components/runs/RunsTable.tsx +++ b/apps/webapp/app/components/runs/RunsTable.tsx @@ -109,7 +109,7 @@ export function RunsTable({ {run.createdAt ? : "–"} - + ); }) diff --git a/apps/webapp/app/components/stories/Badges.stories.tsx b/apps/webapp/app/components/stories/Badges.stories.tsx index 16a2469d4..93a7f566f 100644 --- a/apps/webapp/app/components/stories/Badges.stories.tsx +++ b/apps/webapp/app/components/stories/Badges.stories.tsx @@ -20,7 +20,6 @@ function BadgesExample() {
Default Outline - Green
); } diff --git a/apps/webapp/app/components/stories/Button.stories.tsx b/apps/webapp/app/components/stories/Button.stories.tsx index 3cf689332..3e3dd3979 100644 --- a/apps/webapp/app/components/stories/Button.stories.tsx +++ b/apps/webapp/app/components/stories/Button.stories.tsx @@ -253,13 +253,11 @@ function ButtonList({ primary }: { primary: string }) { Large buttons
-
+
-
-
+
diff --git a/apps/webapp/app/hooks/useJobs.tsx b/apps/webapp/app/hooks/useJobs.tsx index f3feff4a1..80115f12e 100644 --- a/apps/webapp/app/hooks/useJobs.tsx +++ b/apps/webapp/app/hooks/useJobs.tsx @@ -8,6 +8,10 @@ export type ProjectJob = UseDataFunctionReturn["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({ id: jobsMatchId, diff --git a/apps/webapp/app/models/message.server.ts b/apps/webapp/app/models/message.server.ts index 01bd1edd3..d1278c1c2 100644 --- a/apps/webapp/app/models/message.server.ts +++ b/apps/webapp/app/models/message.server.ts @@ -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, diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 891f4c57c..da9db9a54 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -49,6 +49,7 @@ export function getOrganizations({ userId }: { userId: User["id"] }) { jobs: { where: { internal: false, + deletedAt: null, }, }, }, diff --git a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts index fa3c453d3..3dea16ff0 100644 --- a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts @@ -63,6 +63,7 @@ export class IntegrationClientPresenter { slug: projectSlug, }, internal: false, + deletedAt: null, }, }, }, diff --git a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts index 09c6cd927..45fe63547 100644 --- a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts @@ -71,6 +71,7 @@ export class IntegrationsPresenter { slug: projectSlug, }, internal: false, + deletedAt: null, }, }, }, diff --git a/apps/webapp/app/presenters/JobListPresenter.server.ts b/apps/webapp/app/presenters/JobListPresenter.server.ts index 0c3917d93..3c997c646 100644 --- a/apps/webapp/app/presenters/JobListPresenter.server.ts +++ b/apps/webapp/app/presenters/JobListPresenter.server.ts @@ -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); diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index 93877d363..fe55f354a 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -80,6 +80,7 @@ export class ProjectPresenter { }, where: { internal: false, + deletedAt: null, }, orderBy: [{ title: "asc" }], }, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx index 8c487f691..f62347a0b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx @@ -73,8 +73,6 @@ export default function Page() { const { filterText, setFilterText, filteredItems } = useFilterJobs(jobs); - const { width, height } = useWindowSize(); - return ( @@ -104,7 +102,6 @@ export default function Page() { )}
- Jobs
- {jobs.length === 1 && jobs.every((r) => r.lastRun === undefined) && ( - - )} + {jobs.length === 1 && + jobs.every((r) => r.lastRun === undefined) && + jobs.every((i) => i.hasIntegrationsRequiringAction === false) && ( + + )} ) : ( @@ -194,7 +193,7 @@ function ExampleJobs() { {example.icon} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route.tsx index 23bcac69f..1c653370a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route.tsx @@ -355,7 +355,7 @@ function ConnectedIntegrationsList({ - + ); })} @@ -454,7 +454,7 @@ function IntegrationsWithMissingFields({ integration={integration} organizationId={organizationId} button={ - + } callbackUrl={callbackUrl} existingIntegration={client} @@ -482,7 +482,7 @@ function AddIntegrationConnection({ icon?: string; }) { return ( -
+
+ + {(open) => ( +
+
+
+ Environments + +
+ +
+ + Disable this Job in all environments before deleting + + + + + + + + + + + + +
+
+ + + +
+ )} +
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx index d2c563f73..166ab1e74 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route.tsx @@ -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() { } /> )} + + } + /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx index c1aeacfad..8d19ed9a4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx @@ -103,7 +103,7 @@ export default function Page() { Members -
    +
      {members.map((member) => (
    • { + 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}` + ); + } +}; diff --git a/apps/webapp/app/services/endpoints/indexEndpoint.server.ts b/apps/webapp/app/services/endpoints/indexEndpoint.server.ts index c0fe2a8ee..066bf85be 100644 --- a/apps/webapp/app/services/endpoints/indexEndpoint.server.ts +++ b/apps/webapp/app/services/endpoints/indexEndpoint.server.ts @@ -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, diff --git a/apps/webapp/app/services/jobs/deleteJob.server.ts b/apps/webapp/app/services/jobs/deleteJob.server.ts new file mode 100644 index 000000000..975f10f46 --- /dev/null +++ b/apps/webapp/app/services/jobs/deleteJob.server.ts @@ -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, + }); + } +} diff --git a/apps/webapp/app/services/jobs/registerJob.server.ts b/apps/webapp/app/services/jobs/registerJob.server.ts index e92610752..c66a6e157 100644 --- a/apps/webapp/app/services/jobs/registerJob.server.ts +++ b/apps/webapp/app/services/jobs/registerJob.server.ts @@ -34,7 +34,21 @@ export class RegisterJobService { endpoint: Endpoint, environment: AuthenticatedEnvironment, metadata: JobMetadata - ): Promise { + ): Promise { + // 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(); 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: { diff --git a/apps/webapp/app/services/telemetry.server.ts b/apps/webapp/app/services/telemetry.server.ts index 464d4f8b8..6f476feee 100644 --- a/apps/webapp/app/services/telemetry.server.ts +++ b/apps/webapp/app/services/telemetry.server.ts @@ -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) { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index e9ea222b3..6c51c6634 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -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`; } diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 98770b0ee..eba7a5640 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -144,6 +144,7 @@ module.exports = { }, devEnv: colors.pink, liveEnv: colors.green, + uiBorder: slate[800], }, borderRadius: { lg: radius, diff --git a/docs/documentation/guides/jobs/managing.mdx b/docs/documentation/guides/jobs/managing.mdx new file mode 100644 index 000000000..3323a0425 --- /dev/null +++ b/docs/documentation/guides/jobs/managing.mdx @@ -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. + + + 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 👋 + + +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) diff --git a/docs/images/delete-job.png b/docs/images/delete-job.png new file mode 100644 index 000000000..0bb1abf2f Binary files /dev/null and b/docs/images/delete-job.png differ diff --git a/docs/images/disabled-job.png b/docs/images/disabled-job.png new file mode 100644 index 000000000..74824bafd Binary files /dev/null and b/docs/images/disabled-job.png differ diff --git a/docs/images/job-triple-dot-menu.png b/docs/images/job-triple-dot-menu.png new file mode 100644 index 000000000..80a9adeac Binary files /dev/null and b/docs/images/job-triple-dot-menu.png differ diff --git a/docs/mint.json b/docs/mint.json index 95571328d..e7a977b1a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -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" } } -} +} \ No newline at end of file diff --git a/docs/sdk/job.mdx b/docs/sdk/job.mdx index a597620ec..a9bc27f81 100644 --- a/docs/sdk/job.mdx +++ b/docs/sdk/job.mdx @@ -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 }; - }, -}); -``` - # 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: + + 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`. + 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. diff --git a/packages/database/prisma/migrations/20230823124049_add_deleted_at_to_jobs/migration.sql b/packages/database/prisma/migrations/20230823124049_add_deleted_at_to_jobs/migration.sql new file mode 100644 index 000000000..091492ed2 --- /dev/null +++ b/packages/database/prisma/migrations/20230823124049_add_deleted_at_to_jobs/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Job" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7bf609470..f41f67b04 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -420,6 +420,8 @@ model Job { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + deletedAt DateTime? + @@unique([projectId, slug]) } diff --git a/packages/trigger-sdk/src/triggerClient.ts b/packages/trigger-sdk/src/triggerClient.ts index 9881d78da..a6d774354 100644 --- a/packages/trigger-sdk/src/triggerClient.ts +++ b/packages/trigger-sdk/src/triggerClient.ts @@ -605,15 +605,6 @@ export class TriggerClient { } async #executeJob(body: RunJobBody, job: Job, any>): Promise { - if (!job.enabled) { - return { - status: "ERROR", - error: { - message: "Job is disabled", - }, - }; - } - this.#internalLogger.debug("executing job", { execution: body, job: job.toJSON(),