Skip to content

Commit 1be6033

Browse files
committed
Configure alerts WIP
1 parent c132a2e commit 1be6033

File tree

9 files changed

+600
-13
lines changed

9 files changed

+600
-13
lines changed
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
2+
import { parse } from "@conform-to/zod";
3+
import {
4+
EnvelopeIcon,
5+
GlobeAltIcon,
6+
HashtagIcon,
7+
LockClosedIcon,
8+
XMarkIcon,
9+
} from "@heroicons/react/20/solid";
10+
import { useFetcher } from "@remix-run/react";
11+
import { SlackIcon } from "@trigger.dev/companyicons";
12+
import { Fragment, useRef, useState } from "react";
13+
import { z } from "zod";
14+
import { Button, LinkButton } from "~/components/primitives/Buttons";
15+
import { Callout, variantClasses } from "~/components/primitives/Callout";
16+
import { Fieldset } from "~/components/primitives/Fieldset";
17+
import { FormError } from "~/components/primitives/FormError";
18+
import { Header2, Header3 } from "~/components/primitives/Headers";
19+
import { Hint } from "~/components/primitives/Hint";
20+
import { InlineCode } from "~/components/code/InlineCode";
21+
import { Input } from "~/components/primitives/Input";
22+
import { InputGroup } from "~/components/primitives/InputGroup";
23+
import { Paragraph } from "~/components/primitives/Paragraph";
24+
import { Select, SelectItem } from "~/components/primitives/Select";
25+
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
26+
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
27+
import { cn } from "~/utils/cn";
28+
import { ExitIcon } from "~/assets/icons/ExitIcon";
29+
30+
export const ErrorAlertsFormSchema = z.object({
31+
emails: z.preprocess((i) => {
32+
if (typeof i === "string") return i === "" ? [] : [i];
33+
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
34+
return [];
35+
}, z.string().email().array()),
36+
slackChannel: z.string().optional(),
37+
slackIntegrationId: z.string().optional(),
38+
webhooks: z.preprocess((i) => {
39+
if (typeof i === "string") return i === "" ? [] : [i];
40+
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
41+
return [];
42+
}, z.string().url().array()),
43+
});
44+
45+
type ConfigureErrorAlertsProps = ErrorAlertChannelData;
46+
47+
export function ConfigureErrorAlerts({
48+
emails: existingEmails,
49+
webhooks: existingWebhooks,
50+
slackChannel: existingSlackChannel,
51+
slack,
52+
emailAlertsEnabled,
53+
}: ConfigureErrorAlertsProps) {
54+
const fetcher = useFetcher();
55+
const location = useOptimisticLocation();
56+
const isSubmitting = fetcher.state !== "idle";
57+
58+
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
59+
existingSlackChannel
60+
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
61+
: undefined
62+
);
63+
64+
const selectedSlackChannel =
65+
slack.status === "READY"
66+
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
67+
: undefined;
68+
69+
const closeHref = (() => {
70+
const params = new URLSearchParams(location.search);
71+
params.delete("alerts");
72+
const qs = params.toString();
73+
return qs ? `?${qs}` : location.pathname;
74+
})();
75+
76+
const emailFieldValues = useRef<string[]>(
77+
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
78+
);
79+
80+
const webhookFieldValues = useRef<string[]>(
81+
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
82+
);
83+
84+
const [form, { emails, webhooks, slackChannel, slackIntegrationId }] = useForm({
85+
id: "configure-error-alerts",
86+
onValidate({ formData }) {
87+
return parse(formData, { schema: ErrorAlertsFormSchema });
88+
},
89+
shouldRevalidate: "onSubmit",
90+
defaultValue: {
91+
emails: emailFieldValues.current,
92+
webhooks: webhookFieldValues.current,
93+
},
94+
});
95+
96+
const emailFields = useFieldList(form.ref, emails);
97+
const webhookFields = useFieldList(form.ref, webhooks);
98+
99+
return (
100+
<div className="flex h-full flex-col overflow-hidden border-l border-grid-bright">
101+
<div className="flex items-center justify-between border-b border-grid-bright px-4 py-3">
102+
<Header2>Configure alerts</Header2>
103+
<LinkButton
104+
to={closeHref}
105+
variant="minimal/small"
106+
TrailingIcon={ExitIcon}
107+
shortcut={{ key: "esc" }}
108+
shortcutPosition="before-trailing-icon"
109+
className="pl-1"
110+
/>
111+
</div>
112+
113+
<div className="flex-1 overflow-y-auto">
114+
<fetcher.Form method="post" {...form.props}>
115+
<Fieldset className="p-4">
116+
<Paragraph variant="small" className="text-text-dimmed">
117+
You'll receive alerts when:
118+
</Paragraph>
119+
<ul className="list-disc space-y-1 pl-5 text-xs text-text-dimmed">
120+
<li>A new issue is seen for the first time</li>
121+
<li>A resolved issue re-occurs</li>
122+
<li>An ignored issue re-occurs depending on the settings you configured</li>
123+
</ul>
124+
125+
{/* Email section */}
126+
<div className="mt-6">
127+
<Header3 className="mb-3 flex items-center gap-1.5">
128+
<EnvelopeIcon className="size-4 text-text-dimmed" />
129+
Email
130+
</Header3>
131+
{emailAlertsEnabled ? (
132+
<InputGroup>
133+
{emailFields.map((emailField, index) => (
134+
<Fragment key={emailField.key}>
135+
<Input
136+
{...conform.input(emailField, { type: "email" })}
137+
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
138+
icon={EnvelopeIcon}
139+
onChange={(e) => {
140+
emailFieldValues.current[index] = e.target.value;
141+
if (
142+
emailFields.length === emailFieldValues.current.length &&
143+
emailFieldValues.current.every((v) => v !== "")
144+
) {
145+
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
146+
}
147+
}}
148+
/>
149+
<FormError id={emailField.errorId}>{emailField.error}</FormError>
150+
</Fragment>
151+
))}
152+
</InputGroup>
153+
) : (
154+
<Callout variant="warning">
155+
Email integration is not available. Please contact your organization
156+
administrator.
157+
</Callout>
158+
)}
159+
</div>
160+
161+
{/* Slack section */}
162+
<div className="mt-6">
163+
<Header3 className="mb-3 flex items-center gap-1.5">
164+
<SlackIcon className="size-4" />
165+
Slack
166+
</Header3>
167+
<InputGroup fullWidth>
168+
{slack.status === "READY" ? (
169+
<>
170+
<Select
171+
name={slackChannel.name}
172+
placeholder="Select a Slack channel"
173+
heading="Filter channels…"
174+
defaultValue={selectedSlackChannelValue}
175+
dropdownIcon
176+
variant="tertiary/medium"
177+
items={slack.channels}
178+
setValue={(value) => {
179+
typeof value === "string" && setSelectedSlackChannelValue(value);
180+
}}
181+
filter={(channel, search) =>
182+
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
183+
}
184+
text={(value) => {
185+
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
186+
if (!channel) return;
187+
return <SlackChannelTitle {...channel} />;
188+
}}
189+
>
190+
{(matches) => (
191+
<>
192+
{matches?.map((channel) => (
193+
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
194+
<SlackChannelTitle {...channel} />
195+
</SelectItem>
196+
))}
197+
</>
198+
)}
199+
</Select>
200+
{selectedSlackChannel && selectedSlackChannel.is_private && (
201+
<Callout
202+
variant="warning"
203+
className={cn("text-sm", variantClasses.warning.textColor)}
204+
>
205+
To receive alerts in the{" "}
206+
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
207+
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
208+
Slack and type:{" "}
209+
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
210+
</Callout>
211+
)}
212+
<input
213+
type="hidden"
214+
name={slackIntegrationId.name}
215+
value={slack.integrationId}
216+
/>
217+
</>
218+
) : slack.status === "NOT_CONFIGURED" ? (
219+
<Callout variant="info">
220+
Slack is not connected. Connect Slack from the{" "}
221+
<span className="font-medium text-text-bright">Alerts</span> page to enable
222+
Slack notifications.
223+
</Callout>
224+
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
225+
<Callout variant="info">
226+
The Slack integration in your workspace has been revoked or expired. Please
227+
re-connect from the <span className="font-medium text-text-bright">Alerts</span>{" "}
228+
page.
229+
</Callout>
230+
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
231+
<Callout variant="warning">
232+
Failed loading channels from Slack. Please try again later.
233+
</Callout>
234+
) : (
235+
<Callout variant="warning">
236+
Slack integration is not available. Please contact your organization
237+
administrator.
238+
</Callout>
239+
)}
240+
</InputGroup>
241+
</div>
242+
243+
{/* Webhook section */}
244+
<div className="mt-6">
245+
<Header3 className="mb-3 flex items-center gap-1.5">
246+
<GlobeAltIcon className="size-4 text-text-dimmed" />
247+
Webhook
248+
</Header3>
249+
<InputGroup>
250+
{webhookFields.map((webhookField, index) => (
251+
<Fragment key={webhookField.key}>
252+
<Input
253+
{...conform.input(webhookField, { type: "url" })}
254+
placeholder={
255+
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
256+
}
257+
icon={GlobeAltIcon}
258+
onChange={(e) => {
259+
webhookFieldValues.current[index] = e.target.value;
260+
if (
261+
webhookFields.length === webhookFieldValues.current.length &&
262+
webhookFieldValues.current.every((v) => v !== "")
263+
) {
264+
requestIntent(form.ref.current ?? undefined, list.append(webhooks.name));
265+
}
266+
}}
267+
/>
268+
<FormError id={webhookField.errorId}>{webhookField.error}</FormError>
269+
</Fragment>
270+
))}
271+
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
272+
</InputGroup>
273+
</div>
274+
275+
<FormError>{form.error}</FormError>
276+
</Fieldset>
277+
</fetcher.Form>
278+
</div>
279+
280+
<div className="border-t border-grid-bright px-4 py-3">
281+
<Button
282+
variant="primary/medium"
283+
type="submit"
284+
form="configure-error-alerts"
285+
disabled={isSubmitting}
286+
fullWidth
287+
>
288+
{isSubmitting ? "Saving…" : "Save"}
289+
</Button>
290+
</div>
291+
</div>
292+
);
293+
}
294+
295+
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
296+
return (
297+
<div className="flex items-center gap-1.5">
298+
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
299+
<span>{name}</span>
300+
</div>
301+
);
302+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
2+
import {
3+
ProjectAlertEmailProperties,
4+
ProjectAlertSlackProperties,
5+
ProjectAlertWebhookProperties,
6+
} from "~/models/projectAlert.server";
7+
import { BasePresenter } from "./basePresenter.server";
8+
import { NewAlertChannelPresenter } from "./NewAlertChannelPresenter.server";
9+
import { env } from "~/env.server";
10+
11+
export type ErrorAlertChannelData = Awaited<ReturnType<ErrorAlertChannelPresenter["call"]>>;
12+
13+
export class ErrorAlertChannelPresenter extends BasePresenter {
14+
public async call(projectId: string, environmentType: RuntimeEnvironmentType) {
15+
const channels = await this._prisma.projectAlertChannel.findMany({
16+
where: {
17+
projectId,
18+
alertTypes: { has: "ERROR_GROUP" },
19+
environmentTypes: { has: environmentType },
20+
},
21+
orderBy: { createdAt: "asc" },
22+
});
23+
24+
const emails: Array<{ id: string; email: string }> = [];
25+
const webhooks: Array<{ id: string; url: string }> = [];
26+
let slackChannel: { id: string; channelId: string; channelName: string } | null = null;
27+
28+
for (const channel of channels) {
29+
switch (channel.type) {
30+
case "EMAIL": {
31+
const parsed = ProjectAlertEmailProperties.safeParse(channel.properties);
32+
if (parsed.success) {
33+
emails.push({ id: channel.id, email: parsed.data.email });
34+
}
35+
break;
36+
}
37+
case "SLACK": {
38+
const parsed = ProjectAlertSlackProperties.safeParse(channel.properties);
39+
if (parsed.success) {
40+
slackChannel = {
41+
id: channel.id,
42+
channelId: parsed.data.channelId,
43+
channelName: parsed.data.channelName,
44+
};
45+
}
46+
break;
47+
}
48+
case "WEBHOOK": {
49+
const parsed = ProjectAlertWebhookProperties.safeParse(channel.properties);
50+
if (parsed.success) {
51+
webhooks.push({ id: channel.id, url: parsed.data.url });
52+
}
53+
break;
54+
}
55+
}
56+
}
57+
58+
const slackPresenter = new NewAlertChannelPresenter(this._prisma, this._replica);
59+
const slackResult = await slackPresenter.call(projectId);
60+
61+
const emailAlertsEnabled =
62+
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
63+
64+
return {
65+
emails,
66+
webhooks,
67+
slackChannel,
68+
slack: slackResult.slack,
69+
emailAlertsEnabled,
70+
};
71+
}
72+
}

0 commit comments

Comments
 (0)