import { getFormProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { BellAlertIcon, BellSlashIcon, BookOpenIcon, EnvelopeIcon, GlobeAltIcon, LockClosedIcon, PlusIcon, TrashIcon, } from "@heroicons/react/20/solid"; import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { SlackIcon } from "@trigger.dev/companyicons"; import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; import assertNever from "assert-never"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels"; import { Feedback } from "~/components/Feedback"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { DetailCell } from "~/components/primitives/DetailCell"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, TableBody, TableCell, TableCellMenu, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { AlertChannelListPresenter, type AlertChannelListPresenterRecord, } from "~/presenters/v3/AlertChannelListPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { EnvironmentParamSchema, docsPath, v3BillingPath, v3NewProjectAlertPath, v3ProjectAlertsPath, } from "~/utils/pathBuilder"; import { alertsWorker } from "~/v3/alertsWorker.server"; export const meta: MetaFunction = () => { return [ { title: `Alerts | Trigger.dev`, }, ]; }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { throw new Response(undefined, { status: 404, statusText: "Project not found", }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { throw new Response(undefined, { status: 404, statusText: "Environment not found", }); } const presenter = new AlertChannelListPresenter(); const data = await presenter.call(project.id, environment.type); return typedjson(data); }; const schema = z.discriminatedUnion("action", [ z.object({ action: z.literal("delete"), id: z.string() }), z.object({ action: z.literal("disable"), id: z.string() }), z.object({ action: z.literal("enable"), id: z.string() }), ]); export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); if (request.method.toUpperCase() !== "POST") { return { status: 405, body: "Method Not Allowed" }; } const formData = await request.formData(); const submission = parseWithZod(formData, { schema }); if (submission.status !== "success") { return json(submission.reply()); } const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { return json(submission.reply({ formErrors: ["Project not found"] })); } switch (submission.value.action) { case "delete": { const alertChannel = await prisma.projectAlertChannel.delete({ where: { id: submission.value.id, projectId: project.id }, }); return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `Deleted ${alertChannel.name} alert` ); } case "disable": { const alertChannel = await prisma.projectAlertChannel.update({ where: { id: submission.value.id, projectId: project.id }, data: { enabled: false }, }); return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `Disabled ${alertChannel.name} alert` ); } case "enable": { const alertChannel = await prisma.projectAlertChannel.update({ where: { id: submission.value.id, projectId: project.id }, data: { enabled: true }, }); if (alertChannel.alertTypes.includes("ERROR_GROUP")) { await alertsWorker.enqueue({ id: `evaluateErrorAlerts:${project.id}`, job: "v3.evaluateErrorAlerts", payload: { projectId: project.id, scheduledAt: Date.now(), }, }); } return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, `Enabled ${alertChannel.name} alert` ); } } }; export default function Page() { const { alertChannels, limits } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const showSelfServe = useShowSelfServe(); const requiresUpgrade = limits.used >= limits.limit; return ( Alerts docs {alertChannels.length > 0 ? (
Project alerts {alertChannels.length > 0 && !requiresUpgrade && ( New alert )}
Name Alert types Channel Enabled Environments Actions {alertChannels.length > 0 ? ( alertChannels.map((alertChannel) => ( {alertChannel.name} {alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
{alertChannel.environmentTypes.map((environmentType) => ( ))}
{alertChannel.enabled ? ( ) : ( )} } className={ alertChannel.enabled ? "" : "group-hover/table-row:bg-background-bright/50" } />
)) ) : (
You haven't created any project alerts yet Get alerted when runs or deployments fail, or when deployments succeed in both Prod and Staging environments. New alert
)}
} content={`${Math.round((limits.used / limits.limit) * 100)}%`} />
{requiresUpgrade ? ( You've used all {limits.limit} of your available alerts. Upgrade your plan to enable more. ) : ( You've used {limits.used}/{limits.limit} of your alerts. )} {showSelfServe ? ( Upgrade ) : ( Request more} /> )}
) : environment.type === "DEVELOPMENT" ? ( ) : ( )}
); } function DeleteAlertChannelButton(props: { id: string }) { const lastSubmission = useActionData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "delete"; const [form] = useForm({ id: "delete-alert-channel", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, shouldRevalidate: "onSubmit", }); return (
); } function DisableAlertChannelButton(props: { id: string }) { const lastSubmission = useActionData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "delete"; const [form] = useForm({ id: "disable-alert-channel", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, shouldRevalidate: "onSubmit", }); return (
); } function EnableAlertChannelButton(props: { id: string }) { const lastSubmission = useActionData(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post" && navigation.formData?.get("action") === "delete"; const [form] = useForm({ id: "enable-alert-channel", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, shouldRevalidate: "onSubmit", }); return (
); } function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) { switch (alertChannel.properties?.type) { case "EMAIL": { return ( } leadingIconClassName="text-text-dimmed" label={"Email"} description={alertChannel.properties.email} boxClassName="group-hover/table-row:bg-background-bright" className="h-12" /> ); } case "WEBHOOK": { return ( Webhook } leadingIconClassName="text-text-dimmed" label={alertChannel.properties.url} description={ } iconButton secure={"•".repeat(alertChannel.properties.secret.length)} className="mt-1 w-80" /> } boxClassName="group-hover/table-row:bg-background-bright" /> ); } case "SLACK": { return ( } leadingIconClassName="text-text-dimmed" label={"Slack"} description={`#${alertChannel.properties.channelName}`} boxClassName="group-hover/table-row:bg-background-bright" /> ); } } return null; } export function alertTypeTitle(alertType: ProjectAlertType): string { switch (alertType) { case "TASK_RUN": return "Task run failure"; case "TASK_RUN_ATTEMPT": return "Task attempt failure"; case "DEPLOYMENT_FAILURE": return "Deployment failure"; case "DEPLOYMENT_SUCCESS": return "Deployment success"; case "ERROR_GROUP": return "Error group"; default: { throw new Error(`Unknown alertType: ${alertType}`); } } } export function AlertChannelTypeIcon({ channelType, className, }: { channelType: ProjectAlertChannelType; className: string; }) { switch (channelType) { case "EMAIL": return ; case "SLACK": return ; case "WEBHOOK": return ; default: { assertNever(channelType); } } }