Skip to content

Commit 0977c56

Browse files
matt-aitkensamejr
andauthored
Errors (versions) (#3187)
- Added versions filtering on the Errors list and page - Added errors stacked bars to the graph on the individual error page --------- Co-authored-by: James Ritchie <james@trigger.dev>
1 parent 2ba77d8 commit 0977c56

File tree

54 files changed

+5172
-374
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+5172
-374
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function SlackMonoIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
4+
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" />
5+
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" />
6+
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.527 2.527 0 0 1-2.521-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.522 2.522v6.312z" />
7+
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.522 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.521-2.522v-2.522h2.521zm0-1.27a2.527 2.527 0 0 1-2.521-2.522 2.528 2.528 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.522h-6.313z" />
8+
</svg>
9+
);
10+
}
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
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, useNavigate } from "@remix-run/react";
11+
import { SlackIcon } from "@trigger.dev/companyicons";
12+
import { Fragment, useEffect, 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 { useToast } from "~/components/primitives/Toast";
17+
import { Fieldset } from "~/components/primitives/Fieldset";
18+
import { FormError } from "~/components/primitives/FormError";
19+
import { Header2, Header3 } from "~/components/primitives/Headers";
20+
import { Hint } from "~/components/primitives/Hint";
21+
import { InlineCode } from "~/components/code/InlineCode";
22+
import { Input } from "~/components/primitives/Input";
23+
import { InputGroup } from "~/components/primitives/InputGroup";
24+
import { Paragraph } from "~/components/primitives/Paragraph";
25+
import { Select, SelectItem } from "~/components/primitives/Select";
26+
import { UnorderedList } from "~/components/primitives/UnorderedList";
27+
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
28+
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
29+
import { useOrganization } from "~/hooks/useOrganizations";
30+
import { cn } from "~/utils/cn";
31+
import { organizationSlackIntegrationPath } from "~/utils/pathBuilder";
32+
import { ExitIcon } from "~/assets/icons/ExitIcon";
33+
import { TextLink } from "~/components/primitives/TextLink";
34+
import { BellAlertIcon } from "@heroicons/react/24/solid";
35+
36+
export const ErrorAlertsFormSchema = z.object({
37+
emails: z.preprocess((i) => {
38+
if (typeof i === "string") return i === "" ? [] : [i];
39+
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
40+
return [];
41+
}, z.string().email().array()),
42+
slackChannel: z.string().optional(),
43+
slackIntegrationId: z.string().optional(),
44+
webhooks: z.preprocess((i) => {
45+
if (typeof i === "string") return i === "" ? [] : [i];
46+
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
47+
return [];
48+
}, z.string().url().array()),
49+
});
50+
51+
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
52+
connectToSlackHref?: string;
53+
formAction: string;
54+
};
55+
56+
export function ConfigureErrorAlerts({
57+
emails: existingEmails,
58+
webhooks: existingWebhooks,
59+
slackChannel: existingSlackChannel,
60+
slack,
61+
emailAlertsEnabled,
62+
connectToSlackHref,
63+
formAction,
64+
}: ConfigureErrorAlertsProps) {
65+
const organization = useOrganization();
66+
const fetcher = useFetcher<{ ok?: boolean }>();
67+
const navigate = useNavigate();
68+
const toast = useToast();
69+
const location = useOptimisticLocation();
70+
const isSubmitting = fetcher.state !== "idle";
71+
72+
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
73+
existingSlackChannel
74+
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
75+
: undefined
76+
);
77+
78+
const selectedSlackChannel =
79+
slack.status === "READY"
80+
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
81+
: undefined;
82+
83+
const closeHref = (() => {
84+
const params = new URLSearchParams(location.search);
85+
params.delete("alerts");
86+
const qs = params.toString();
87+
return qs ? `?${qs}` : location.pathname;
88+
})();
89+
90+
const hasHandledSuccess = useRef(false);
91+
useEffect(() => {
92+
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
93+
hasHandledSuccess.current = true;
94+
toast.success("Alert settings saved");
95+
navigate(closeHref, { replace: true });
96+
}
97+
}, [fetcher.state, fetcher.data, closeHref, navigate, toast]);
98+
99+
const emailFieldValues = useRef<string[]>(
100+
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
101+
);
102+
103+
const webhookFieldValues = useRef<string[]>(
104+
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
105+
);
106+
107+
const [form, { emails, webhooks, slackChannel, slackIntegrationId }] = useForm({
108+
id: "configure-error-alerts",
109+
onValidate({ formData }) {
110+
return parse(formData, { schema: ErrorAlertsFormSchema });
111+
},
112+
shouldRevalidate: "onSubmit",
113+
defaultValue: {
114+
emails: emailFieldValues.current,
115+
webhooks: webhookFieldValues.current,
116+
},
117+
});
118+
119+
const emailFields = useFieldList(form.ref, emails);
120+
const webhookFields = useFieldList(form.ref, webhooks);
121+
122+
return (
123+
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
124+
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
125+
<Header2 className="flex items-center gap-2">
126+
<BellAlertIcon className="size-5 text-alerts" /> Configure alerts
127+
</Header2>
128+
<LinkButton
129+
to={closeHref}
130+
variant="minimal/small"
131+
TrailingIcon={ExitIcon}
132+
shortcut={{ key: "esc" }}
133+
shortcutPosition="before-trailing-icon"
134+
className="pl-1"
135+
/>
136+
</div>
137+
138+
<fetcher.Form
139+
method="post"
140+
action={formAction}
141+
{...form.props}
142+
className="contents"
143+
>
144+
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
145+
<Fieldset className="flex flex-col gap-4 p-4">
146+
<div className="flex flex-col">
147+
<Header3>Receive alerts when</Header3>
148+
<UnorderedList variant="small/dimmed" className="mt-1">
149+
<li>An error is seen for the first time</li>
150+
<li>A resolved error re-occurs</li>
151+
<li>An ignored error re-occurs based on settings you configured</li>
152+
</UnorderedList>
153+
</div>
154+
155+
{/* Email section */}
156+
<div>
157+
<Header3 className="mb-1">Email</Header3>
158+
{emailAlertsEnabled ? (
159+
<InputGroup>
160+
{emailFields.map((emailField, index) => (
161+
<Fragment key={emailField.key}>
162+
<Input
163+
{...conform.input(emailField, { type: "email" })}
164+
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
165+
icon={EnvelopeIcon}
166+
onChange={(e) => {
167+
emailFieldValues.current[index] = e.target.value;
168+
if (
169+
emailFields.length === emailFieldValues.current.length &&
170+
emailFieldValues.current.every((v) => v !== "")
171+
) {
172+
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
173+
}
174+
}}
175+
/>
176+
<FormError id={emailField.errorId}>{emailField.error}</FormError>
177+
</Fragment>
178+
))}
179+
</InputGroup>
180+
) : (
181+
<Callout variant="warning">
182+
Email integration is not available. Please contact your organization
183+
administrator.
184+
</Callout>
185+
)}
186+
</div>
187+
188+
{/* Slack section */}
189+
<div>
190+
<Header3 className="mb-1">Slack</Header3>
191+
192+
<InputGroup fullWidth>
193+
{slack.status === "READY" ? (
194+
<>
195+
<Select
196+
name={slackChannel.name}
197+
placeholder={<span className="text-text-dimmed">Select a Slack channel</span>}
198+
heading="Filter channels…"
199+
defaultValue={selectedSlackChannelValue}
200+
dropdownIcon
201+
variant="tertiary/medium"
202+
items={slack.channels}
203+
setValue={(value) => {
204+
typeof value === "string" && setSelectedSlackChannelValue(value);
205+
}}
206+
filter={(channel, search) =>
207+
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
208+
}
209+
text={(value) => {
210+
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
211+
if (!channel) return;
212+
return (
213+
<span className="text-text-bright">
214+
<SlackChannelTitle {...channel} />
215+
</span>
216+
);
217+
}}
218+
>
219+
{(matches) => (
220+
<>
221+
{matches?.map((channel) => (
222+
<SelectItem
223+
key={channel.id}
224+
value={`${channel.id}/${channel.name}`}
225+
className="text-text-bright"
226+
>
227+
<SlackChannelTitle {...channel} />
228+
</SelectItem>
229+
))}
230+
</>
231+
)}
232+
</Select>
233+
{selectedSlackChannel && selectedSlackChannel.is_private && (
234+
<Callout
235+
variant="warning"
236+
className={cn("text-sm", variantClasses.warning.textColor)}
237+
>
238+
To receive alerts in the{" "}
239+
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
240+
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
241+
Slack and type:{" "}
242+
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
243+
</Callout>
244+
)}
245+
<Hint>
246+
<TextLink to={organizationSlackIntegrationPath(organization)}>
247+
Manage Slack connection
248+
</TextLink>
249+
</Hint>
250+
<input
251+
type="hidden"
252+
name={slackIntegrationId.name}
253+
value={slack.integrationId}
254+
/>
255+
</>
256+
) : slack.status === "NOT_CONFIGURED" ? (
257+
connectToSlackHref ? (
258+
<LinkButton variant="tertiary/medium" to={connectToSlackHref} fullWidth>
259+
<span className="flex items-center gap-2 text-text-bright">
260+
<SlackIcon className="size-5" /> Connect to Slack
261+
</span>
262+
</LinkButton>
263+
) : (
264+
<Callout variant="info">
265+
Slack is not connected. Connect Slack from the{" "}
266+
<span className="font-medium text-text-bright">Alerts</span> page to enable
267+
Slack notifications.
268+
</Callout>
269+
)
270+
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
271+
connectToSlackHref ? (
272+
<div className="flex flex-col gap-4">
273+
<Callout variant="info">
274+
The Slack integration in your workspace has been revoked or has expired.
275+
Please re-connect your Slack workspace.
276+
</Callout>
277+
<LinkButton
278+
variant="tertiary/large"
279+
to={`${connectToSlackHref}?reinstall=true`}
280+
fullWidth
281+
>
282+
<span className="flex items-center gap-2 text-text-bright">
283+
<SlackIcon className="size-5" /> Connect to Slack
284+
</span>
285+
</LinkButton>
286+
</div>
287+
) : (
288+
<Callout variant="info">
289+
The Slack integration in your workspace has been revoked or expired. Please
290+
re-connect from the{" "}
291+
<span className="font-medium text-text-bright">Alerts</span> page.
292+
</Callout>
293+
)
294+
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
295+
<Callout variant="warning">
296+
Failed loading channels from Slack. Please try again later.
297+
</Callout>
298+
) : (
299+
<Callout variant="warning">
300+
Slack integration is not available. Please contact your organization
301+
administrator.
302+
</Callout>
303+
)}
304+
</InputGroup>
305+
</div>
306+
307+
{/* Webhook section */}
308+
<div>
309+
<Header3 className="mb-1">Webhook</Header3>
310+
<InputGroup>
311+
{webhookFields.map((webhookField, index) => (
312+
<Fragment key={webhookField.key}>
313+
<Input
314+
{...conform.input(webhookField, { type: "url" })}
315+
placeholder={
316+
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
317+
}
318+
icon={GlobeAltIcon}
319+
onChange={(e) => {
320+
webhookFieldValues.current[index] = e.target.value;
321+
if (
322+
webhookFields.length === webhookFieldValues.current.length &&
323+
webhookFieldValues.current.every((v) => v !== "")
324+
) {
325+
requestIntent(form.ref.current ?? undefined, list.append(webhooks.name));
326+
}
327+
}}
328+
/>
329+
<FormError id={webhookField.errorId}>{webhookField.error}</FormError>
330+
</Fragment>
331+
))}
332+
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
333+
</InputGroup>
334+
</div>
335+
336+
<FormError>{form.error}</FormError>
337+
</Fieldset>
338+
</div>
339+
340+
<div className="flex items-center justify-between border-t border-grid-bright px-3 py-3">
341+
<LinkButton variant="secondary/medium" to={closeHref}>
342+
Cancel
343+
</LinkButton>
344+
<Button
345+
variant="primary/medium"
346+
type="submit"
347+
disabled={isSubmitting}
348+
isLoading={isSubmitting}
349+
>
350+
{isSubmitting ? "Saving…" : "Save"}
351+
</Button>
352+
</div>
353+
</fetcher.Form>
354+
</div>
355+
);
356+
}
357+
358+
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
359+
return (
360+
<div className="flex items-center gap-1.5">
361+
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
362+
<span>{name}</span>
363+
</div>
364+
);
365+
}

0 commit comments

Comments
 (0)