608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
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<typeof loader>();
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const showSelfServe = useShowSelfServe();
|
|
|
|
const requiresUpgrade = limits.used >= limits.limit;
|
|
|
|
return (
|
|
<PageContainer>
|
|
<NavBar>
|
|
<PageTitle title="Alerts" />
|
|
<PageAccessories>
|
|
<LinkButton
|
|
LeadingIcon={BookOpenIcon}
|
|
to={docsPath("v3/troubleshooting-alerts")}
|
|
variant="docs/small"
|
|
>
|
|
Alerts docs
|
|
</LinkButton>
|
|
</PageAccessories>
|
|
</NavBar>
|
|
<PageBody scrollable={false}>
|
|
{alertChannels.length > 0 ? (
|
|
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
|
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
|
<Header2 className="">Project alerts</Header2>
|
|
{alertChannels.length > 0 && !requiresUpgrade && (
|
|
<LinkButton
|
|
to={v3NewProjectAlertPath(organization, project, environment)}
|
|
variant="primary/small"
|
|
LeadingIcon={PlusIcon}
|
|
shortcut={{ key: "n" }}
|
|
>
|
|
New alert
|
|
</LinkButton>
|
|
)}
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Name</TableHeaderCell>
|
|
<TableHeaderCell>Alert types</TableHeaderCell>
|
|
<TableHeaderCell>Channel</TableHeaderCell>
|
|
<TableHeaderCell>Enabled</TableHeaderCell>
|
|
<TableHeaderCell>Environments</TableHeaderCell>
|
|
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{alertChannels.length > 0 ? (
|
|
alertChannels.map((alertChannel) => (
|
|
<TableRow key={alertChannel.id}>
|
|
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
|
{alertChannel.name}
|
|
</TableCell>
|
|
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
|
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
|
|
</TableCell>
|
|
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
|
|
<AlertChannelDetails alertChannel={alertChannel} />
|
|
</TableCell>
|
|
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
|
<EnabledStatus
|
|
enabled={alertChannel.enabled}
|
|
enabledIcon={BellAlertIcon}
|
|
disabledIcon={BellSlashIcon}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
|
<div className="flex items-center gap-3">
|
|
{alertChannel.environmentTypes.map((environmentType) => (
|
|
<EnvironmentCombo
|
|
key={environmentType}
|
|
environment={{ type: environmentType }}
|
|
className="text-xs"
|
|
/>
|
|
))}
|
|
</div>
|
|
</TableCell>
|
|
<TableCellMenu
|
|
isSticky
|
|
popoverContent={
|
|
<>
|
|
{alertChannel.enabled ? (
|
|
<DisableAlertChannelButton id={alertChannel.id} />
|
|
) : (
|
|
<EnableAlertChannelButton id={alertChannel.id} />
|
|
)}
|
|
<DeleteAlertChannelButton id={alertChannel.id} />
|
|
</>
|
|
}
|
|
className={
|
|
alertChannel.enabled
|
|
? ""
|
|
: "group-hover/table-row:bg-background-bright/50"
|
|
}
|
|
/>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6}>
|
|
<div className="flex flex-col items-center justify-center py-6">
|
|
<Header2 spacing className="text-text-bright">
|
|
You haven't created any project alerts yet
|
|
</Header2>
|
|
<Paragraph variant="small" className="mb-4">
|
|
Get alerted when runs or deployments fail, or when deployments succeed in
|
|
both Prod and Staging environments.
|
|
</Paragraph>
|
|
<LinkButton
|
|
to={v3NewProjectAlertPath(organization, project, environment)}
|
|
variant="primary/medium"
|
|
LeadingIcon={PlusIcon}
|
|
shortcut={{ key: "n" }}
|
|
>
|
|
New alert
|
|
</LinkButton>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
<div className="flex h-fit items-stretch gap-3">
|
|
<div className="flex w-full items-start justify-between">
|
|
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
|
<SimpleTooltip
|
|
button={
|
|
<div className="size-6">
|
|
<svg className="h-full w-full -rotate-90 overflow-visible">
|
|
<circle
|
|
className="fill-none stroke-grid-bright"
|
|
strokeWidth="4"
|
|
r="10"
|
|
cx="12"
|
|
cy="12"
|
|
/>
|
|
<circle
|
|
className={`fill-none ${
|
|
requiresUpgrade ? "stroke-error" : "stroke-success"
|
|
}`}
|
|
strokeWidth="4"
|
|
r="10"
|
|
cx="12"
|
|
cy="12"
|
|
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
|
strokeDashoffset="0"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
}
|
|
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
|
/>
|
|
<div className="flex w-full items-center justify-between gap-6">
|
|
{requiresUpgrade ? (
|
|
<Header3 className="text-error">
|
|
You've used all {limits.limit} of your available alerts. Upgrade your plan
|
|
to enable more.
|
|
</Header3>
|
|
) : (
|
|
<Header3>
|
|
You've used {limits.used}/{limits.limit} of your alerts.
|
|
</Header3>
|
|
)}
|
|
|
|
{showSelfServe ? (
|
|
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
|
Upgrade
|
|
</LinkButton>
|
|
) : (
|
|
<Feedback
|
|
defaultValue="enterprise"
|
|
button={<Button variant="secondary/small">Request more</Button>}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : environment.type === "DEVELOPMENT" ? (
|
|
<MainCenteredContainer className="max-w-md">
|
|
<AlertsNoneDev />
|
|
</MainCenteredContainer>
|
|
) : (
|
|
<MainCenteredContainer className="max-w-md">
|
|
<AlertsNoneDeployed />
|
|
</MainCenteredContainer>
|
|
)}
|
|
<Outlet />
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Form method="post" {...getFormProps(form)}>
|
|
<input type="hidden" name="id" value={props.id} />
|
|
<Button
|
|
name="action"
|
|
value="delete"
|
|
fullWidth
|
|
textAlignLeft
|
|
type="submit"
|
|
variant="small-menu-item"
|
|
LeadingIcon={TrashIcon}
|
|
leadingIconClassName="text-rose-500"
|
|
className="text-xs"
|
|
>
|
|
{isLoading ? "Deleting" : "Delete"}
|
|
</Button>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Form method="post" {...getFormProps(form)}>
|
|
<input type="hidden" name="id" value={props.id} />
|
|
|
|
<Button
|
|
name="action"
|
|
value="disable"
|
|
type="submit"
|
|
fullWidth
|
|
textAlignLeft
|
|
variant="small-menu-item"
|
|
LeadingIcon={BellSlashIcon}
|
|
leadingIconClassName="text-dimmed"
|
|
className="text-xs"
|
|
>
|
|
{isLoading ? "Disabling" : "Disable"}
|
|
</Button>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Form method="post" {...getFormProps(form)}>
|
|
<input type="hidden" name="id" value={props.id} />
|
|
|
|
<Button
|
|
name="action"
|
|
value="enable"
|
|
type="submit"
|
|
fullWidth
|
|
textAlignLeft
|
|
variant="small-menu-item"
|
|
LeadingIcon={BellAlertIcon}
|
|
leadingIconClassName="text-success"
|
|
className="text-xs"
|
|
>
|
|
{isLoading ? "Enabling" : "Enable"}
|
|
</Button>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
|
|
switch (alertChannel.properties?.type) {
|
|
case "EMAIL": {
|
|
return (
|
|
<DetailCell
|
|
leadingIcon={
|
|
<AlertChannelTypeIcon
|
|
channelType={alertChannel.type}
|
|
className="size-5 text-text-dimmed"
|
|
/>
|
|
}
|
|
leadingIconClassName="text-text-dimmed"
|
|
label={"Email"}
|
|
description={alertChannel.properties.email}
|
|
boxClassName="group-hover/table-row:bg-background-bright"
|
|
className="h-12"
|
|
/>
|
|
);
|
|
}
|
|
case "WEBHOOK": {
|
|
return (
|
|
<DetailCell
|
|
leadingIcon={
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<AlertChannelTypeIcon
|
|
channelType={alertChannel.type}
|
|
className="size-5 text-text-dimmed"
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
}
|
|
leadingIconClassName="text-text-dimmed"
|
|
label={alertChannel.properties.url}
|
|
description={
|
|
<ClipboardField
|
|
value={alertChannel.properties.secret}
|
|
variant="secondary/small"
|
|
icon={<LockClosedIcon className="size-4" />}
|
|
iconButton
|
|
secure={"•".repeat(alertChannel.properties.secret.length)}
|
|
className="mt-1 w-80"
|
|
/>
|
|
}
|
|
boxClassName="group-hover/table-row:bg-background-bright"
|
|
/>
|
|
);
|
|
}
|
|
case "SLACK": {
|
|
return (
|
|
<DetailCell
|
|
leadingIcon={
|
|
<AlertChannelTypeIcon
|
|
channelType={alertChannel.type}
|
|
className="size-5 text-text-dimmed"
|
|
/>
|
|
}
|
|
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 <EnvelopeIcon className={className} />;
|
|
case "SLACK":
|
|
return <SlackIcon className={className} />;
|
|
case "WEBHOOK":
|
|
return <GlobeAltIcon className={className} />;
|
|
default: {
|
|
assertNever(channelType);
|
|
}
|
|
}
|
|
}
|