495 lines
18 KiB
TypeScript
495 lines
18 KiB
TypeScript
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
|
|
import { parseWithZod } from "@conform-to/zod";
|
|
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
|
|
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
|
import { type LoaderFunctionArgs } from "@remix-run/router";
|
|
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
|
import { SlackIcon } from "@trigger.dev/companyicons";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
|
import { z } from "zod";
|
|
import { InlineCode } from "~/components/code/InlineCode";
|
|
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
|
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
|
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
|
import { Fieldset } from "~/components/primitives/Fieldset";
|
|
import { FormButtons } from "~/components/primitives/FormButtons";
|
|
import { FormError } from "~/components/primitives/FormError";
|
|
import { Hint } from "~/components/primitives/Hint";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { InputGroup } from "~/components/primitives/InputGroup";
|
|
import { Label } from "~/components/primitives/Label";
|
|
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
|
import { Select, SelectItem } from "~/components/primitives/Select";
|
|
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
|
import { env } from "~/env.server";
|
|
import { useEnvironment } from "~/hooks/useEnvironment";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import { useProject } from "~/hooks/useProject";
|
|
import { redirectWithSuccessMessage } from "~/models/message.server";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { cn } from "~/utils/cn";
|
|
import {
|
|
EnvironmentParamSchema,
|
|
ProjectParamSchema,
|
|
v3ProjectAlertsPath,
|
|
} from "~/utils/pathBuilder";
|
|
import {
|
|
type CreateAlertChannelOptions,
|
|
CreateAlertChannelService,
|
|
} from "~/v3/services/alerts/createAlertChannel.server";
|
|
import {
|
|
assertSafeWebhookUrl,
|
|
UnsafeWebhookUrlError,
|
|
} from "~/v3/services/alerts/safeWebhookUrl.server";
|
|
|
|
const FormSchema = z
|
|
.object({
|
|
alertTypes: z
|
|
.array(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
|
|
.min(1)
|
|
.or(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
|
|
environmentTypes: z
|
|
.array(z.enum(["STAGING", "PRODUCTION", "PREVIEW"]))
|
|
.min(1)
|
|
.or(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])),
|
|
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
|
|
channelValue: z.string().nonempty(),
|
|
integrationId: z.string().optional(),
|
|
})
|
|
.refine(
|
|
(value) =>
|
|
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
|
|
{
|
|
message: "Must be a valid email address",
|
|
path: ["channelValue"],
|
|
}
|
|
)
|
|
.refine(
|
|
(value) =>
|
|
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
|
|
{
|
|
message: "Must be a valid URL",
|
|
path: ["channelValue"],
|
|
}
|
|
)
|
|
.refine(
|
|
(value) =>
|
|
value.type === "SLACK"
|
|
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
|
|
: true,
|
|
{
|
|
message: "Must select a Slack channel",
|
|
path: ["channelValue"],
|
|
}
|
|
);
|
|
|
|
function formDataToCreateAlertChannelOptions(
|
|
formData: z.infer<typeof FormSchema>
|
|
): CreateAlertChannelOptions {
|
|
switch (formData.type) {
|
|
case "WEBHOOK": {
|
|
return {
|
|
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
|
|
alertTypes: Array.isArray(formData.alertTypes)
|
|
? formData.alertTypes
|
|
: [formData.alertTypes],
|
|
environmentTypes: Array.isArray(formData.environmentTypes)
|
|
? formData.environmentTypes
|
|
: [formData.environmentTypes],
|
|
channel: {
|
|
type: "WEBHOOK",
|
|
url: formData.channelValue,
|
|
},
|
|
};
|
|
}
|
|
case "EMAIL": {
|
|
return {
|
|
name: `Email to ${formData.channelValue}`,
|
|
alertTypes: Array.isArray(formData.alertTypes)
|
|
? formData.alertTypes
|
|
: [formData.alertTypes],
|
|
environmentTypes: Array.isArray(formData.environmentTypes)
|
|
? formData.environmentTypes
|
|
: [formData.environmentTypes],
|
|
channel: {
|
|
type: "EMAIL",
|
|
email: formData.channelValue,
|
|
},
|
|
};
|
|
}
|
|
case "SLACK": {
|
|
const [channelId, channelName] = formData.channelValue.split("/");
|
|
|
|
return {
|
|
name: `Slack message to ${channelName}`,
|
|
alertTypes: Array.isArray(formData.alertTypes)
|
|
? formData.alertTypes
|
|
: [formData.alertTypes],
|
|
environmentTypes: Array.isArray(formData.environmentTypes)
|
|
? formData.environmentTypes
|
|
: [formData.environmentTypes],
|
|
channel: {
|
|
type: "SLACK",
|
|
channelId,
|
|
channelName,
|
|
integrationId: formData.integrationId,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function loader({ request, params }: LoaderFunctionArgs) {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
|
|
if (!project) {
|
|
throw new Response("Project not found", { status: 404 });
|
|
}
|
|
|
|
const presenter = new NewAlertChannelPresenter();
|
|
|
|
const results = await presenter.call(project.id);
|
|
|
|
const url = new URL(request.url);
|
|
const option = url.searchParams.get("option");
|
|
|
|
const emailAlertsEnabled =
|
|
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
|
|
|
|
return typedjson({
|
|
...results,
|
|
option: option === "slack" ? ("SLACK" as const) : undefined,
|
|
emailAlertsEnabled,
|
|
});
|
|
}
|
|
|
|
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: FormSchema });
|
|
|
|
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"] }));
|
|
}
|
|
|
|
// Validate the webhook URL before storing it, for an inline field error.
|
|
if (submission.value.type === "WEBHOOK") {
|
|
try {
|
|
await assertSafeWebhookUrl(submission.value.channelValue);
|
|
} catch (error) {
|
|
if (error instanceof UnsafeWebhookUrlError) {
|
|
return json(submission.reply({ fieldErrors: { channelValue: [error.message] } }));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const service = new CreateAlertChannelService();
|
|
const alertChannel = await service.call(
|
|
project.externalRef,
|
|
userId,
|
|
formDataToCreateAlertChannelOptions(submission.value)
|
|
);
|
|
|
|
if (!alertChannel) {
|
|
return json(submission.reply({ formErrors: ["Failed to create alert channel"] }));
|
|
}
|
|
|
|
return redirectWithSuccessMessage(
|
|
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
|
request,
|
|
`Created ${alertChannel.name} alert`
|
|
);
|
|
};
|
|
|
|
export default function Page() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
|
|
const lastSubmission = useActionData();
|
|
const navigation = useNavigation();
|
|
const navigate = useNavigate();
|
|
const organization = useOrganization();
|
|
const project = useProject();
|
|
const environment = useEnvironment();
|
|
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
|
|
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
|
|
|
|
const selectedSlackChannel = slack.channels?.find(
|
|
(s) => selectedSlackChannelValue === `${s.id}/${s.name}`
|
|
);
|
|
|
|
const isLoading =
|
|
navigation.state !== "idle" &&
|
|
navigation.formMethod === "post" &&
|
|
navigation.formData?.get("action") === "create";
|
|
|
|
const [
|
|
form,
|
|
{ channelValue, alertTypes, environmentTypes, type, integrationId: _integrationId },
|
|
] = useForm({
|
|
id: "create-alert",
|
|
// TODO: type this
|
|
lastResult: lastSubmission as any,
|
|
onValidate({ formData }) {
|
|
return parseWithZod(formData, { schema: FormSchema });
|
|
},
|
|
shouldRevalidate: "onSubmit",
|
|
});
|
|
|
|
useEffect(() => {
|
|
setIsOpen(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (navigation.state !== "idle") return;
|
|
if (lastSubmission !== undefined) return;
|
|
|
|
formRef.current?.reset();
|
|
}, [navigation.state, lastSubmission]);
|
|
|
|
return (
|
|
<Dialog
|
|
open={isOpen}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
navigate(v3ProjectAlertsPath(organization, project, environment));
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>New alert</DialogHeader>
|
|
<Form ref={formRef} method="post" {...getFormProps(form)}>
|
|
<Fieldset className="mt-2">
|
|
<InputGroup fullWidth>
|
|
<SegmentedControl
|
|
{...getInputProps(type, { type: "text" })}
|
|
options={[
|
|
{ label: "Email", value: "EMAIL" },
|
|
{ label: "Slack", value: "SLACK" },
|
|
{ label: "Webhook", value: "WEBHOOK" },
|
|
]}
|
|
onChange={(value) => {
|
|
setCurrentAlertChannel(value);
|
|
}}
|
|
fullWidth
|
|
defaultValue={currentAlertChannel ?? undefined}
|
|
/>
|
|
</InputGroup>
|
|
|
|
{currentAlertChannel === "EMAIL" ? (
|
|
emailAlertsEnabled ? (
|
|
<InputGroup fullWidth>
|
|
<Label>Email</Label>
|
|
<Input
|
|
{...getInputProps(channelValue, { type: "text" })}
|
|
placeholder="email@youremail.com"
|
|
type="email"
|
|
autoFocus
|
|
/>
|
|
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
|
</InputGroup>
|
|
) : (
|
|
<Callout variant="warning">
|
|
Email integration is not available. Please contact your organization
|
|
administrator.
|
|
</Callout>
|
|
)
|
|
) : currentAlertChannel === "SLACK" ? (
|
|
<InputGroup fullWidth>
|
|
{slack.status === "READY" ? (
|
|
<>
|
|
<Select
|
|
{...getSelectProps(channelValue)}
|
|
placeholder="Select a Slack channel"
|
|
heading="Filter channels…"
|
|
defaultValue={undefined}
|
|
dropdownIcon
|
|
variant="tertiary/medium"
|
|
items={slack.channels}
|
|
setValue={(value) => {
|
|
typeof value === "string" && setSelectedSlackChannelValue(value);
|
|
}}
|
|
filter={(channel, search) =>
|
|
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
|
}
|
|
text={(value) => {
|
|
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
|
if (!channel) return;
|
|
return <SlackChannelTitle {...channel} />;
|
|
}}
|
|
>
|
|
{(matches) => (
|
|
<>
|
|
{matches?.map((channel) => (
|
|
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
|
|
<SlackChannelTitle {...channel} />
|
|
</SelectItem>
|
|
))}
|
|
</>
|
|
)}
|
|
</Select>
|
|
{selectedSlackChannel && selectedSlackChannel.is_private && (
|
|
<Callout
|
|
variant="warning"
|
|
className={cn("text-sm", variantClasses.warning.textColor)}
|
|
>
|
|
To receive alerts in the{" "}
|
|
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
|
|
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
|
|
Slack and type:{" "}
|
|
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
|
|
</Callout>
|
|
)}
|
|
|
|
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
|
<input type="hidden" name="integrationId" value={slack.integrationId} />
|
|
</>
|
|
) : slack.status === "NOT_CONFIGURED" ? (
|
|
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
|
|
<span className="flex items-center gap-2 text-text-bright">
|
|
<SlackIcon className="size-5" /> Connect to Slack
|
|
</span>
|
|
</LinkButton>
|
|
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
|
|
<div className="flex flex-col gap-4">
|
|
<Callout variant="info">
|
|
The Slack integration in your workspace has been revoked or has expired.
|
|
Please re-connect your Slack workspace.
|
|
</Callout>
|
|
<LinkButton
|
|
variant="tertiary/large"
|
|
to={{
|
|
pathname: "connect-to-slack",
|
|
search: "?reinstall=true",
|
|
}}
|
|
fullWidth
|
|
>
|
|
<span className="flex items-center gap-2 text-text-bright">
|
|
<SlackIcon className="size-5" /> Connect to Slack
|
|
</span>
|
|
</LinkButton>
|
|
</div>
|
|
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
|
|
<div className="flex flex-col gap-4">
|
|
<Callout variant="warning">
|
|
Failed loading channels from Slack. Please try again later.
|
|
</Callout>
|
|
</div>
|
|
) : (
|
|
<Callout variant="warning">
|
|
Slack integration is not available. Please contact your organization
|
|
administrator.
|
|
</Callout>
|
|
)}
|
|
</InputGroup>
|
|
) : (
|
|
<InputGroup fullWidth>
|
|
<Label>URL</Label>
|
|
<Input
|
|
{...getInputProps(channelValue, { type: "text" })}
|
|
placeholder="https://foobar.com/webhooks"
|
|
type="url"
|
|
autoFocus
|
|
/>
|
|
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
|
|
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
|
|
</InputGroup>
|
|
)}
|
|
|
|
<InputGroup>
|
|
<Label>Alert me when</Label>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<CheckboxWithLabel
|
|
name={alertTypes.name}
|
|
id="TASK_RUN"
|
|
value="TASK_RUN"
|
|
variant="simple/small"
|
|
label="Task runs fail"
|
|
defaultChecked
|
|
className="pr-0"
|
|
/>
|
|
<InfoIconTooltip content="You'll receive an alert when a run completely fails." />
|
|
</div>
|
|
|
|
<CheckboxWithLabel
|
|
name={alertTypes.name}
|
|
id="DEPLOYMENT_FAILURE"
|
|
value="DEPLOYMENT_FAILURE"
|
|
variant="simple/small"
|
|
label="Deployments fail"
|
|
defaultChecked
|
|
/>
|
|
|
|
<CheckboxWithLabel
|
|
name={alertTypes.name}
|
|
id="DEPLOYMENT_SUCCESS"
|
|
value="DEPLOYMENT_SUCCESS"
|
|
variant="simple/small"
|
|
label="Deployments succeed"
|
|
defaultChecked
|
|
/>
|
|
|
|
<FormError id={alertTypes.errorId}>{alertTypes.errors}</FormError>
|
|
</InputGroup>
|
|
<InputGroup>
|
|
<Label>Environment</Label>
|
|
<input type="hidden" name={environmentTypes.name} value={environment.type} />
|
|
<EnvironmentCombo environment={{ type: environment.type }} />
|
|
<FormError id={environmentTypes.errorId}>{environmentTypes.errors}</FormError>
|
|
</InputGroup>
|
|
<FormError>{form.errors}</FormError>
|
|
<FormButtons
|
|
confirmButton={
|
|
<Button variant="primary/medium" disabled={isLoading} name="action" value="create">
|
|
{isLoading ? "Saving…" : "Save"}
|
|
</Button>
|
|
}
|
|
cancelButton={
|
|
<LinkButton
|
|
to={v3ProjectAlertsPath(organization, project, environment)}
|
|
variant="tertiary/medium"
|
|
>
|
|
Cancel
|
|
</LinkButton>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
|
|
<span>{name}</span>
|
|
</div>
|
|
);
|
|
}
|