370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
|
import { parseWithZod } from "@conform-to/zod";
|
|
import {
|
|
EnvelopeIcon,
|
|
GlobeAltIcon,
|
|
HashtagIcon,
|
|
LockClosedIcon,
|
|
XMarkIcon,
|
|
} from "@heroicons/react/20/solid";
|
|
import { BellAlertIcon } from "@heroicons/react/24/solid";
|
|
import { useFetcher, useNavigate } from "@remix-run/react";
|
|
import { SlackIcon } from "@trigger.dev/companyicons";
|
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
import { z } from "zod";
|
|
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
|
import { InlineCode } from "~/components/code/InlineCode";
|
|
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
|
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
|
import { Fieldset } from "~/components/primitives/Fieldset";
|
|
import { FormError } from "~/components/primitives/FormError";
|
|
import { Header2, Header3 } from "~/components/primitives/Headers";
|
|
import { Hint } from "~/components/primitives/Hint";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { InputGroup } from "~/components/primitives/InputGroup";
|
|
import { Select, SelectItem } from "~/components/primitives/Select";
|
|
import { TextLink } from "~/components/primitives/TextLink";
|
|
import { useToast } from "~/components/primitives/Toast";
|
|
import { UnorderedList } from "~/components/primitives/UnorderedList";
|
|
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
|
import { useOrganization } from "~/hooks/useOrganizations";
|
|
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
|
|
import { cn } from "~/utils/cn";
|
|
import { organizationSlackIntegrationPath } from "~/utils/pathBuilder";
|
|
|
|
export const ErrorAlertsFormSchema = z.object({
|
|
emails: z.preprocess((i) => {
|
|
if (typeof i === "string") return i === "" ? [] : [i];
|
|
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
|
return [];
|
|
}, z.string().email().array()),
|
|
slackChannel: z.string().optional(),
|
|
slackIntegrationId: z.string().optional(),
|
|
webhooks: z.preprocess((i) => {
|
|
if (typeof i === "string") return i === "" ? [] : [i];
|
|
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
|
return [];
|
|
}, z.string().url().array()),
|
|
});
|
|
|
|
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
|
|
connectToSlackHref?: string;
|
|
formAction: string;
|
|
};
|
|
|
|
export function ConfigureErrorAlerts({
|
|
emails: existingEmails,
|
|
webhooks: existingWebhooks,
|
|
slackChannel: existingSlackChannel,
|
|
slack,
|
|
emailAlertsEnabled,
|
|
connectToSlackHref,
|
|
formAction,
|
|
}: ConfigureErrorAlertsProps) {
|
|
const organization = useOrganization();
|
|
const fetcher = useFetcher<{ ok?: boolean }>();
|
|
const navigate = useNavigate();
|
|
const toast = useToast();
|
|
const location = useOptimisticLocation();
|
|
const isSubmitting = fetcher.state !== "idle";
|
|
|
|
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
|
|
existingSlackChannel
|
|
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
|
|
: undefined
|
|
);
|
|
|
|
const selectedSlackChannel =
|
|
slack.status === "READY"
|
|
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
|
|
: undefined;
|
|
|
|
const closeHref = (() => {
|
|
const params = new URLSearchParams(location.search);
|
|
params.delete("alerts");
|
|
const qs = params.toString();
|
|
return qs ? `?${qs}` : location.pathname;
|
|
})();
|
|
|
|
const hasHandledSuccess = useRef(false);
|
|
useEffect(() => {
|
|
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
|
|
hasHandledSuccess.current = true;
|
|
toast.success("Alert settings saved");
|
|
navigate(closeHref, { replace: true });
|
|
}
|
|
}, [fetcher.state, fetcher.data, closeHref, navigate, toast]);
|
|
|
|
const emailFieldValues = useRef<string[]>(
|
|
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
|
|
);
|
|
|
|
const webhookFieldValues = useRef<string[]>(
|
|
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
|
|
);
|
|
|
|
const [form, fields] = useForm<z.infer<typeof ErrorAlertsFormSchema>>({
|
|
id: "configure-error-alerts",
|
|
onValidate({ formData }) {
|
|
return parseWithZod(formData, { schema: ErrorAlertsFormSchema });
|
|
},
|
|
shouldRevalidate: "onSubmit",
|
|
defaultValue: {
|
|
emails: emailFieldValues.current,
|
|
webhooks: webhookFieldValues.current,
|
|
},
|
|
});
|
|
const { emails, webhooks, slackChannel, slackIntegrationId } = fields;
|
|
|
|
const emailFields = emails.getFieldList();
|
|
const webhookFields = webhooks.getFieldList();
|
|
|
|
return (
|
|
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
|
|
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
|
<Header2 className="flex items-center gap-2">
|
|
<BellAlertIcon className="size-5 text-alerts" /> Configure alerts
|
|
</Header2>
|
|
<LinkButton
|
|
to={closeHref}
|
|
variant="minimal/small"
|
|
TrailingIcon={ExitIcon}
|
|
shortcut={{ key: "esc" }}
|
|
shortcutPosition="before-trailing-icon"
|
|
className="pl-1"
|
|
/>
|
|
</div>
|
|
|
|
<fetcher.Form method="post" action={formAction} {...getFormProps(form)} className="contents">
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
|
<Fieldset className="flex flex-col gap-4 p-4">
|
|
<div className="flex flex-col">
|
|
<Header3>Receive alerts when</Header3>
|
|
<UnorderedList variant="small/dimmed" className="mt-1">
|
|
<li>An error is seen for the first time</li>
|
|
<li>A resolved error re-occurs</li>
|
|
<li>An ignored error re-occurs based on settings you configured</li>
|
|
</UnorderedList>
|
|
</div>
|
|
|
|
{/* Email section */}
|
|
<div>
|
|
<Header3 className="mb-1">Email</Header3>
|
|
{emailAlertsEnabled ? (
|
|
<InputGroup>
|
|
{emailFields.map((emailField, index) => (
|
|
<Fragment key={emailField.key}>
|
|
<Input
|
|
{...getInputProps(emailField, { type: "email" })}
|
|
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
|
icon={EnvelopeIcon}
|
|
onChange={(e) => {
|
|
emailFieldValues.current[index] = e.target.value;
|
|
if (
|
|
emailFields.length === emailFieldValues.current.length &&
|
|
emailFieldValues.current.every((v) => v !== "")
|
|
) {
|
|
form.insert({ name: emails.name });
|
|
}
|
|
}}
|
|
/>
|
|
<FormError id={emailField.errorId}>{emailField.errors}</FormError>
|
|
</Fragment>
|
|
))}
|
|
</InputGroup>
|
|
) : (
|
|
<Callout variant="warning">
|
|
Email integration is not available. Please contact your organization
|
|
administrator.
|
|
</Callout>
|
|
)}
|
|
</div>
|
|
|
|
{/* Slack section */}
|
|
<div>
|
|
<Header3 className="mb-1">Slack</Header3>
|
|
|
|
<InputGroup fullWidth>
|
|
{slack.status === "READY" ? (
|
|
<>
|
|
<Select
|
|
name={slackChannel.name}
|
|
placeholder={<span className="text-text-dimmed">Select a Slack channel</span>}
|
|
heading="Filter channels…"
|
|
value={selectedSlackChannelValue ?? ""}
|
|
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 (
|
|
<span className="text-text-bright">
|
|
<SlackChannelTitle {...channel} />
|
|
</span>
|
|
);
|
|
}}
|
|
>
|
|
{(matches) => (
|
|
<>
|
|
<SelectItem
|
|
value=""
|
|
className="border-b border-grid-bright text-text-dimmed"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<XMarkIcon className="size-4" />
|
|
<span>No channel</span>
|
|
</div>
|
|
</SelectItem>
|
|
{matches?.map((channel) => (
|
|
<SelectItem
|
|
key={channel.id}
|
|
value={`${channel.id}/${channel.name}`}
|
|
className="text-text-bright"
|
|
>
|
|
<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>
|
|
)}
|
|
<Hint>
|
|
<TextLink to={organizationSlackIntegrationPath(organization)}>
|
|
Manage Slack connection
|
|
</TextLink>
|
|
</Hint>
|
|
<input
|
|
type="hidden"
|
|
name={slackIntegrationId.name}
|
|
value={slack.integrationId}
|
|
/>
|
|
</>
|
|
) : slack.status === "NOT_CONFIGURED" ? (
|
|
connectToSlackHref ? (
|
|
<LinkButton variant="tertiary/medium" to={connectToSlackHref} fullWidth>
|
|
<span className="flex items-center gap-2 text-text-bright">
|
|
<SlackIcon className="size-5" /> Connect to Slack
|
|
</span>
|
|
</LinkButton>
|
|
) : (
|
|
<Callout variant="info">
|
|
Slack is not connected. Connect Slack from the{" "}
|
|
<span className="font-medium text-text-bright">Alerts</span> page to enable
|
|
Slack notifications.
|
|
</Callout>
|
|
)
|
|
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
|
|
connectToSlackHref ? (
|
|
<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={`${connectToSlackHref}?reinstall=true`}
|
|
fullWidth
|
|
>
|
|
<span className="flex items-center gap-2 text-text-bright">
|
|
<SlackIcon className="size-5" /> Connect to Slack
|
|
</span>
|
|
</LinkButton>
|
|
</div>
|
|
) : (
|
|
<Callout variant="info">
|
|
The Slack integration in your workspace has been revoked or expired. Please
|
|
re-connect from the{" "}
|
|
<span className="font-medium text-text-bright">Alerts</span> page.
|
|
</Callout>
|
|
)
|
|
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
|
|
<Callout variant="warning">
|
|
Failed loading channels from Slack. Please try again later.
|
|
</Callout>
|
|
) : (
|
|
<Callout variant="warning">
|
|
Slack integration is not available. Please contact your organization
|
|
administrator.
|
|
</Callout>
|
|
)}
|
|
</InputGroup>
|
|
</div>
|
|
|
|
{/* Webhook section */}
|
|
<div>
|
|
<Header3 className="mb-1">Webhook</Header3>
|
|
<InputGroup>
|
|
{webhookFields.map((webhookField, index) => (
|
|
<Fragment key={webhookField.key}>
|
|
<Input
|
|
{...getInputProps(webhookField, { type: "url" })}
|
|
placeholder={
|
|
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
|
|
}
|
|
icon={GlobeAltIcon}
|
|
onChange={(e) => {
|
|
webhookFieldValues.current[index] = e.target.value;
|
|
if (
|
|
webhookFields.length === webhookFieldValues.current.length &&
|
|
webhookFieldValues.current.every((v) => v !== "")
|
|
) {
|
|
form.insert({ name: webhooks.name });
|
|
}
|
|
}}
|
|
/>
|
|
<FormError id={webhookField.errorId}>{webhookField.errors}</FormError>
|
|
</Fragment>
|
|
))}
|
|
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
|
|
</InputGroup>
|
|
</div>
|
|
|
|
<FormError>{form.errors}</FormError>
|
|
</Fieldset>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-grid-bright px-3 py-3">
|
|
<LinkButton variant="secondary/medium" to={closeHref}>
|
|
Cancel
|
|
</LinkButton>
|
|
<Button
|
|
variant="primary/medium"
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
isLoading={isSubmitting}
|
|
>
|
|
{isSubmitting ? "Saving…" : "Save"}
|
|
</Button>
|
|
</div>
|
|
</fetcher.Form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|