Openstatus www.openstatus.dev

๐Ÿ“Ÿ OpsGenie (#1160)

* ๐Ÿšง OpsGenie

* ๐Ÿšง OpsGenie

* ๐Ÿšง opsgenie

* ci: apply automated fixes

* refactor: notification section provider form

* feat: test alert button

* fix: missing pnpm lock file

* ๐Ÿšง OpsGenie

* ci: apply automated fixes

* ๐Ÿ“ opsgenie docs

* chore: add server action test alert

* ci: apply automated fixes

* ๐Ÿ“ typo

* ๐Ÿงช should fix test

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
autofix-ci[bot]
Maximilian Kaske
and committed by
GitHub
9a1d7152 4ccfd939

+993 -255
apps/docs/src/assets/notification/opsgenie/opsgenie-1.png

This is a binary file and will not be displayed.

apps/docs/src/assets/notification/opsgenie/opsgenie-2.png

This is a binary file and will not be displayed.

apps/docs/src/assets/notification/opsgenie/opsgenie-3.png

This is a binary file and will not be displayed.

apps/docs/src/assets/notification/opsgenie/opsgenie-4.png

This is a binary file and will not be displayed.

apps/docs/src/assets/notification/opsgenie/opsgenie-5.png

This is a binary file and will not be displayed.

+69
apps/docs/src/content/docs/alerting/providers/opsgenie.mdx
··· 1 + --- 2 + title: OpsGenie 3 + description: "How to set up OpsGenie notifications in OpenStatus to get alerts when your synthetic check fail" 4 + 5 + --- 6 + import { Image} from 'astro:assets'; 7 + import OpsGenie1 from '../../../../assets/notification/opsgenie/opsgenie-1.png'; 8 + import OpsGenie2 from '../../../../assets/notification/opsgenie/opsgenie-2.png'; 9 + import OpsGenie3 from '../../../../assets/notification/opsgenie/opsgenie-3.png'; 10 + import OpsGenie4 from '../../../../assets/notification/opsgenie/opsgenie-4.png'; 11 + import OpsGenie5 from '../../../../assets/notification/opsgenie/opsgenie-5.png'; 12 + 13 + Get Notified on OpsGenie when we create an incident. 14 + 15 + ## How to connect OpsGenie 16 + 17 + ### Create an OpsGenie Integration 18 + 19 + First we need to create an integration in OpsGenie. 20 + 21 + Go to your team in OpsGenie and select `Integrations` from the menu. 22 + 23 + 24 + <Image 25 + src={OpsGenie1} 26 + alt="OpsGenie integration page" 27 + /> 28 + In the integrations page, search for `API` and select it. 29 + 30 + 31 + <Image 32 + src={OpsGenie2} 33 + alt="Connect to OpsGenie" 34 + /> 35 + 36 + Give your integration a name and save it. 37 + Copy your API key and save it. You will need it to connect OpenStatus to OpsGenie. 38 + 39 + <Image 40 + src={OpsGenie3} 41 + alt="Connect to OpsGenie" 42 + /> 43 + 44 + 45 + ### Connect your OpenStatus account to OpsGenie 46 + 47 + 48 + Go to the Alerts Page . Select `OpsGenie` from the list of available integrations. 49 + 50 + 51 + <Image 52 + src={OpsGenie4} 53 + alt="OpenStatus Notifications Page" 54 + /> 55 + 56 + 57 + Give you integration a name and paste the API key you copied from OpsGenie and select the region of your OpsGenie account. 58 + 59 + <Image 60 + src={OpsGenie5} 61 + alt="Connect to OpsGenie in OpenStatus" 62 + /> 63 + 64 + Select the service you want to use to send notifications. You can create a new service if you don't have one. 65 + 66 + 67 + You are now connected to OpsGenie. 68 + 69 + You will receive some notifications if we detect an incident
+1
apps/server/package.json
··· 21 21 "@openstatus/error": "workspace:*", 22 22 "@openstatus/notification-discord": "workspace:*", 23 23 "@openstatus/notification-emails": "workspace:*", 24 + "@openstatus/notification-opsgenie": "workspace:*", 24 25 "@openstatus/notification-pagerduty": "workspace:*", 25 26 "@openstatus/notification-slack": "workspace:*", 26 27 "@openstatus/notification-twillio-sms": "workspace:*",
+11 -1
apps/server/src/routes/checker/utils.ts
··· 30 30 sendAlert as sendPagerdutyAlert, 31 31 } from "@openstatus/notification-pagerduty"; 32 32 33 + import { 34 + sendAlert as sendOpsGenieAlert, 35 + sendDegraded as sendOpsGenieDegraded, 36 + sendRecovery as sendOpsGenieRecovery, 37 + } from "@openstatus/notification-opsgenie"; 38 + 33 39 type SendNotification = ({ 34 40 monitor, 35 41 notification, ··· 73 79 sendRecovery: sendSmsRecovery, 74 80 sendDegraded: sendSmsDegraded, 75 81 }, 76 - 82 + opsgenie: { 83 + sendAlert: sendOpsGenieAlert, 84 + sendRecovery: sendOpsGenieRecovery, 85 + sendDegraded: sendOpsGenieDegraded, 86 + }, 77 87 pagerduty: { 78 88 sendAlert: sendPagerdutyAlert, 79 89 sendRecovery: sendPagerDutyRecovery,
+26 -3
apps/server/src/routes/v1/statusReports/delete.test.ts
··· 1 1 import { expect, test } from "bun:test"; 2 2 3 3 import { app } from "@/index"; 4 + import { StatusReportSchema } from "./schema"; 4 5 5 6 test("delete the status report", async () => { 6 - const res = await app.request("/v1/status_report/3", { 7 + // Create a status report we will delete 8 + const date = new Date(); 9 + date.setMilliseconds(0); 10 + 11 + const res = await app.request("/v1/status_report", { 12 + method: "POST", 13 + headers: { 14 + "x-openstatus-key": "1", 15 + "content-type": "application/json", 16 + }, 17 + body: JSON.stringify({ 18 + status: "investigating", 19 + title: "New Status Report", 20 + message: "Message", 21 + monitorIds: [1], 22 + date: date.toISOString(), 23 + pageId: 1, 24 + }), 25 + }); 26 + 27 + const result = StatusReportSchema.safeParse(await res.json()); 28 + 29 + const del = await app.request(`/v1/status_report/${result.data?.id}`, { 7 30 method: "DELETE", 8 31 headers: { 9 32 "x-openstatus-key": "1", 10 33 }, 11 34 }); 12 35 13 - expect(res.status).toBe(200); 14 - expect(await res.json()).toMatchObject({}); 36 + expect(del.status).toBe(200); 37 + expect(await del.json()).toMatchObject({}); 15 38 }); 16 39 17 40 test("no auth key should return 401", async () => {
+1
apps/web/package.json
··· 26 26 "@openstatus/notification-emails": "workspace:*", 27 27 "@openstatus/notification-pagerduty": "workspace:*", 28 28 "@openstatus/notification-slack": "workspace:*", 29 + "@openstatus/notification-opsgenie": "workspace:*", 29 30 "@openstatus/react": "workspace:*", 30 31 "@openstatus/tinybird": "workspace:*", 31 32 "@openstatus/tracker": "workspace:*",
+9 -4
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx
··· 12 12 } 13 13 14 14 export default function ChannelTable({ workspace, disabled }: ChannelTable) { 15 - const isPagerDutyAllowed = getLimit(workspace.limits, "pagerduty"); 16 - const isSMSAllowed = getLimit(workspace.limits, "sms"); 17 15 return ( 18 16 <div className="col-span-full w-full rounded-lg border border-border border-dashed bg-background p-8"> 19 17 <h2 className="font-cal text-2xl">Channels</h2> ··· 41 39 ? "http://localhost:3000" 42 40 : "https://www.openstatus.dev" 43 41 }/app/${workspace.slug}/notifications/new/pagerduty&version=2`} 44 - disabled={disabled || !isPagerDutyAllowed} 42 + disabled={disabled || !workspace.limits.pagerduty} 45 43 /> 46 44 <Separator /> 47 45 <Channel ··· 55 53 title="SMS" 56 54 description="Send notifications to your phones." 57 55 href="./notifications/new/sms" 58 - disabled={disabled || !isSMSAllowed} 56 + disabled={disabled || !workspace.limits.sms} 57 + /> 58 + <Separator /> 59 + <Channel 60 + title="OpsGenie" 61 + description="Send notifications to OpsGenie." 62 + href="./notifications/new/opsgenie" 63 + disabled={disabled || !workspace.limits.opsgenie} 59 64 /> 60 65 </div> 61 66 </div>
+4 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/layout.tsx
··· 24 24 25 25 return ( 26 26 <AppPageWithSidebarLayout id="notifications"> 27 - <Header title={notification.name} description={notification.provider} /> 27 + <Header 28 + title={notification.name} 29 + description={<span className="font-mono">{notification.provider}</span>} 30 + /> 28 31 {children} 29 32 </AppPageWithSidebarLayout> 30 33 );
+2 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx
··· 29 29 const isLimitReached = 30 30 await api.notification.isNotificationLimitReached.query(); 31 31 32 - if (isLimitReached) 32 + if (isLimitReached) { 33 33 return <ProFeatureAlert feature="More notification channel" />; 34 + } 34 35 35 36 return ( 36 37 <NotificationForm
-81
apps/web/src/components/forms/notification/config.ts
··· 1 - import type { 2 - InsertNotification, 3 - NotificationProvider, 4 - } from "@openstatus/db/src/schema"; 5 - import { allPlans } from "@openstatus/db/src/schema/plan/config"; 6 - import { workspacePlans } from "@openstatus/db/src/schema/workspaces/constants"; 7 - import { sendTestDiscordMessage } from "@openstatus/notification-discord"; 8 - import { sendTestSlackMessage } from "@openstatus/notification-slack"; 9 - export function getDefaultProviderData(defaultValues?: InsertNotification) { 10 - if (!defaultValues?.provider) return ""; // FIXME: input can empty - needs to be undefined 11 - return JSON.parse(defaultValues?.data || "{}")[defaultValues?.provider]; 12 - } 13 - 14 - export function setProviderData(provider: NotificationProvider, data: string) { 15 - return { [provider]: data }; 16 - } 17 - 18 - export function getProviderMetaData(provider: NotificationProvider) { 19 - switch (provider) { 20 - case "email": 21 - return { 22 - label: "Email", 23 - dataType: "email", 24 - placeholder: "dev@documenso.com", 25 - setupDocLink: null, 26 - sendTest: null, 27 - plans: workspacePlans, 28 - }; 29 - 30 - case "slack": 31 - return { 32 - label: "Slack", 33 - dataType: "url", 34 - placeholder: "https://hooks.slack.com/services/xxx...", 35 - setupDocLink: 36 - "https://api.slack.com/messaging/webhooks#getting_started", 37 - sendTest: sendTestSlackMessage, 38 - plans: workspacePlans, 39 - }; 40 - 41 - case "discord": 42 - return { 43 - label: "Discord", 44 - dataType: "url", 45 - placeholder: "https://discord.com/api/webhooks/{channelId}/xxx...", 46 - setupDocLink: "https://support.discord.com/hc/en-us/articles/228383668", 47 - sendTest: sendTestDiscordMessage, 48 - plans: workspacePlans, 49 - }; 50 - case "sms": 51 - return { 52 - label: "SMS", 53 - dataType: "tel", 54 - placeholder: "+123456789", 55 - setupDocLink: null, 56 - sendTest: null, 57 - plans: workspacePlans.filter((plan) => allPlans[plan].limits.sms), 58 - }; 59 - 60 - case "pagerduty": 61 - return { 62 - label: "PagerDuty", 63 - dataType: null, 64 - placeholder: "", 65 - setupDocLink: 66 - "https://docs.openstatus.dev/synthetic/features/notification/pagerduty", 67 - sendTest: null, 68 - plans: workspacePlans.filter((plan) => allPlans[plan].limits.pagerduty), 69 - }; 70 - 71 - default: 72 - return { 73 - label: "Webhook", 74 - dataType: "url", 75 - placeholder: "xxxx", 76 - setupDocLink: `https://docs.openstatus.dev/integrations/${provider}`, 77 - send: null, 78 - plans: workspacePlans, 79 - }; 80 - } 81 - }
+15 -14
apps/web/src/components/forms/notification/form.tsx
··· 7 7 8 8 import type { 9 9 InsertNotification, 10 + InsertNotificationWithData, 10 11 Monitor, 11 12 NotificationProvider, 12 13 WorkspacePlan, 13 14 } from "@openstatus/db/src/schema"; 14 - import { insertNotificationSchema } from "@openstatus/db/src/schema"; 15 + import { InsertNotificationWithDataSchema } from "@openstatus/db/src/schema"; 15 16 import { Badge, Form } from "@openstatus/ui"; 16 17 17 18 import { ··· 24 25 import { api } from "@/trpc/client"; 25 26 import { TRPCClientError } from "@trpc/client"; 26 27 import { SaveButton } from "../shared/save-button"; 27 - import { getDefaultProviderData, setProviderData } from "./config"; 28 28 import { General } from "./general"; 29 29 import { SectionConnect } from "./section-connect"; 30 30 ··· 51 51 }: Props) { 52 52 const [isPending, startTransition] = useTransition(); 53 53 const router = useRouter(); 54 - const form = useForm<InsertNotification>({ 55 - resolver: zodResolver(insertNotificationSchema), 54 + const form = useForm<InsertNotificationWithData>({ 55 + resolver: zodResolver(InsertNotificationWithDataSchema), 56 56 defaultValues: { 57 57 ...defaultValues, 58 58 provider, 59 59 name: defaultValues?.name || "", 60 - data: getDefaultProviderData(defaultValues), 60 + data: JSON.parse(defaultValues?.data || "{}"), 61 61 }, 62 62 }); 63 63 64 - async function onSubmit({ provider, data, ...rest }: InsertNotification) { 64 + async function onSubmit({ 65 + provider, 66 + data, 67 + ...rest 68 + }: InsertNotificationWithData) { 69 + console.log({ provider, data, ...rest }); 65 70 startTransition(async () => { 66 71 try { 67 72 if (provider === "pagerduty") { 68 73 if (callbackData) { 69 - data = callbackData; 74 + data.pagerduty = callbackData; 70 75 } 71 76 } 72 - if (data === "") { 73 - form.setError("data", { message: "This field is required" }); 74 - return; 75 - } 76 77 if (defaultValues) { 77 78 await api.notification.update.mutate({ 78 79 provider, 79 - data: JSON.stringify(setProviderData(provider, data)), 80 + data: JSON.stringify(data), 80 81 ...rest, 81 82 }); 82 83 } else { 83 84 await api.notification.create.mutate({ 84 85 provider, 85 - data: JSON.stringify(setProviderData(provider, data)), 86 + data: JSON.stringify(data), 86 87 ...rest, 87 88 }); 88 89 } ··· 103 104 return ( 104 105 <Form {...form}> 105 106 <form 106 - onSubmit={form.handleSubmit(onSubmit)} 107 + onSubmit={form.handleSubmit(onSubmit, console.log)} 107 108 id="notification-form" // we use a form id to connect the submit button to the form (as we also have the form nested inside of `MonitorForm`) 108 109 className="flex flex-col gap-4" 109 110 >
+37 -75
apps/web/src/components/forms/notification/general.tsx
··· 1 1 "use client"; 2 2 3 - import { useMemo, useTransition } from "react"; 3 + import { useMemo } from "react"; 4 4 import type { UseFormReturn } from "react-hook-form"; 5 5 6 6 import type { 7 - InsertNotification, 7 + InsertNotificationWithData, 8 8 WorkspacePlan, 9 9 } from "@openstatus/db/src/schema"; 10 10 import { 11 - Button, 12 11 FormControl, 13 12 FormDescription, 14 13 FormField, ··· 18 17 Input, 19 18 } from "@openstatus/ui"; 20 19 21 - import { LoadingAnimation } from "@/components/loading-animation"; 22 - import { toastAction } from "@/lib/toast"; 23 20 import { SectionHeader } from "../shared/section-header"; 24 - import { getProviderMetaData } from "./config"; 21 + import { SectionDiscord } from "./provider/section-discord"; 22 + import { SectionEmail } from "./provider/section-email"; 23 + import { SectionOpsGenie } from "./provider/section-opsgenie"; 24 + import { SectionPagerDuty } from "./provider/section-pagerduty"; 25 + import { SectionSlack } from "./provider/section-slack"; 26 + import { SectionSms } from "./provider/section-sms"; 27 + 28 + const LABELS = { 29 + slack: "Slack", 30 + discord: "Discord", 31 + sms: "SMS", 32 + pagerduty: "PagerDuty", 33 + opsgenie: "OpsGenie", 34 + email: "Email", 35 + }; 25 36 26 37 interface Props { 27 - form: UseFormReturn<InsertNotification>; 38 + form: UseFormReturn<InsertNotificationWithData>; 28 39 plan: WorkspacePlan; 29 40 } 30 41 31 42 export function General({ form, plan }: Props) { 32 - const [isTestPending, startTestTransition] = useTransition(); 33 43 const watchProvider = form.watch("provider"); 34 - const watchWebhookUrl = form.watch("data"); 35 - const providerMetaData = useMemo( 36 - () => getProviderMetaData(watchProvider), 37 - [watchProvider], 38 - ); 39 44 40 - async function sendTestWebhookPing() { 41 - const webhookUrl = form.getValues("data"); 42 - if (!webhookUrl) return; 43 - startTestTransition(async () => { 44 - const isSuccessfull = await providerMetaData.sendTest?.(webhookUrl); 45 - if (isSuccessfull) { 46 - toastAction("test-success"); 47 - } else { 48 - toastAction("test-error"); 49 - } 50 - }); 45 + function renderProviderSection() { 46 + switch (watchProvider) { 47 + case "slack": 48 + return <SectionSlack form={form} />; 49 + case "discord": 50 + return <SectionDiscord form={form} />; 51 + case "sms": 52 + return <SectionSms form={form} />; 53 + case "pagerduty": 54 + return <SectionPagerDuty form={form} plan={plan} />; 55 + case "opsgenie": 56 + return <SectionOpsGenie form={form} plan={plan} />; 57 + case "email": 58 + return <SectionEmail form={form} />; 59 + default: 60 + return <div>No provider selected</div>; 61 + } 51 62 } 52 63 53 64 return ( 54 65 <div className="grid gap-4 sm:grid-cols-3 sm:gap-6"> 55 66 <SectionHeader 56 67 title="Alert" 57 - description={`Update your ${providerMetaData.label} settings`} 68 + description={`Update your ${LABELS[watchProvider]} settings`} 58 69 /> 59 70 <div className="grid gap-4 sm:col-span-2 sm:grid-cols-2"> 60 71 <FormField ··· 71 82 </FormItem> 72 83 )} 73 84 /> 74 - {providerMetaData.dataType && ( 75 - <FormField 76 - control={form.control} 77 - name="data" 78 - render={({ field }) => ( 79 - <FormItem className="sm:col-span-full"> 80 - <FormLabel>{providerMetaData.label}</FormLabel> 81 - <FormControl> 82 - <Input 83 - type={providerMetaData.dataType} 84 - placeholder={providerMetaData.placeholder} 85 - {...field} 86 - disabled={!providerMetaData.plans?.includes(plan)} 87 - /> 88 - </FormControl> 89 - <FormDescription className="flex items-center justify-between"> 90 - The data is required. 91 - {providerMetaData.setupDocLink && ( 92 - <a 93 - href={providerMetaData.setupDocLink} 94 - target="_blank" 95 - className="underline hover:no-underline" 96 - rel="noreferrer" 97 - > 98 - How to setup your {providerMetaData.label} webhook 99 - </a> 100 - )} 101 - </FormDescription> 102 - <FormMessage /> 103 - </FormItem> 104 - )} 105 - /> 106 - )} 107 - <div className="col-span-full text-right"> 108 - {providerMetaData.sendTest && ( 109 - <Button 110 - type="button" 111 - variant="secondary" 112 - className="w-full sm:w-auto" 113 - disabled={!watchWebhookUrl || isTestPending} 114 - onClick={sendTestWebhookPing} 115 - > 116 - {!isTestPending ? ( 117 - "Test Webhook" 118 - ) : ( 119 - <LoadingAnimation variant="inverse" /> 120 - )} 121 - </Button> 122 - )} 123 - </div> 85 + {renderProviderSection()} 124 86 </div> 125 87 </div> 126 88 );
+11
apps/web/src/components/forms/notification/provider/actions.ts
··· 1 + "use server"; 2 + 3 + import { sendTest } from "@openstatus/notification-opsgenie"; 4 + 5 + export async function sendOpsGenieTestAlert( 6 + apiKey: string, 7 + region: "us" | "eu", 8 + ) { 9 + const isSuccessfull = await sendTest({ apiKey, region }); 10 + return isSuccessfull; 11 + }
+90
apps/web/src/components/forms/notification/provider/section-discord.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 7 + import { 8 + Button, 9 + FormControl, 10 + FormDescription, 11 + FormField, 12 + FormItem, 13 + FormLabel, 14 + FormMessage, 15 + Input, 16 + } from "@openstatus/ui"; 17 + 18 + import { LoadingAnimation } from "@/components/loading-animation"; 19 + import { toastAction } from "@/lib/toast"; 20 + import { sendTestDiscordMessage } from "@openstatus/notification-discord"; 21 + 22 + interface Props { 23 + form: UseFormReturn<InsertNotificationWithData>; 24 + } 25 + 26 + export function SectionDiscord({ form }: Props) { 27 + const [isTestPending, startTestTransition] = useTransition(); 28 + const watchUrl = form.watch("data.discord"); 29 + 30 + async function sendTestWebhookPing() { 31 + if (!watchUrl) return; 32 + startTestTransition(async () => { 33 + const isSuccessfull = await sendTestDiscordMessage(watchUrl); 34 + if (isSuccessfull) { 35 + toastAction("test-success"); 36 + } else { 37 + toastAction("test-error"); 38 + } 39 + }); 40 + } 41 + 42 + return ( 43 + <> 44 + <FormField 45 + control={form.control} 46 + name="data.discord" 47 + render={({ field }) => ( 48 + <FormItem className="sm:col-span-full"> 49 + <FormLabel>Webhook URL</FormLabel> 50 + <FormControl> 51 + <Input 52 + type="url" 53 + placeholder="https://discord.com/api/webhooks/{channelId}/xxx..." 54 + required 55 + {...field} 56 + /> 57 + </FormControl> 58 + <FormDescription className="flex items-center justify-between"> 59 + The data is required. 60 + <a 61 + href={"https://support.discord.com/hc/en-us/articles/228383668"} 62 + target="_blank" 63 + className="underline hover:no-underline" 64 + rel="noreferrer" 65 + > 66 + How to setup your Discord webhook 67 + </a> 68 + </FormDescription> 69 + <FormMessage /> 70 + </FormItem> 71 + )} 72 + /> 73 + <div className="col-span-full text-right"> 74 + <Button 75 + type="button" 76 + variant="secondary" 77 + className="w-full sm:w-auto" 78 + disabled={isTestPending || !watchUrl} 79 + onClick={sendTestWebhookPing} 80 + > 81 + {!isTestPending ? ( 82 + "Test Webhook" 83 + ) : ( 84 + <LoadingAnimation variant="inverse" /> 85 + )} 86 + </Button> 87 + </div> 88 + </> 89 + ); 90 + }
+44
apps/web/src/components/forms/notification/provider/section-email.tsx
··· 1 + "use client"; 2 + 3 + import type { UseFormReturn } from "react-hook-form"; 4 + 5 + import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 6 + import { 7 + FormControl, 8 + FormDescription, 9 + FormField, 10 + FormItem, 11 + FormLabel, 12 + FormMessage, 13 + Input, 14 + } from "@openstatus/ui"; 15 + 16 + interface Props { 17 + form: UseFormReturn<InsertNotificationWithData>; 18 + } 19 + 20 + export function SectionEmail({ form }: Props) { 21 + return ( 22 + <FormField 23 + control={form.control} 24 + name="data.email" 25 + render={({ field }) => ( 26 + <FormItem className="sm:col-span-full"> 27 + <FormLabel>Email</FormLabel> 28 + <FormControl> 29 + <Input 30 + type="email" 31 + placeholder="dev@documenso.com" 32 + required 33 + {...field} 34 + /> 35 + </FormControl> 36 + <FormDescription className="flex items-center justify-between"> 37 + The email is required. 38 + </FormDescription> 39 + <FormMessage /> 40 + </FormItem> 41 + )} 42 + /> 43 + ); 44 + }
+124
apps/web/src/components/forms/notification/provider/section-opsgenie.tsx
··· 1 + "use client"; 2 + 3 + import type { UseFormReturn } from "react-hook-form"; 4 + 5 + import { LoadingAnimation } from "@/components/loading-animation"; 6 + import { toastAction } from "@/lib/toast"; 7 + import type { 8 + InsertNotificationWithData, 9 + WorkspacePlan, 10 + } from "@openstatus/db/src/schema"; 11 + import { 12 + Button, 13 + FormControl, 14 + FormDescription, 15 + FormField, 16 + FormItem, 17 + FormLabel, 18 + FormMessage, 19 + Input, 20 + Select, 21 + SelectContent, 22 + SelectItem, 23 + SelectTrigger, 24 + SelectValue, 25 + } from "@openstatus/ui"; 26 + import { useTransition } from "react"; 27 + 28 + import { sendOpsGenieTestAlert } from "./actions"; 29 + 30 + interface Props { 31 + form: UseFormReturn<InsertNotificationWithData>; 32 + plan: WorkspacePlan; 33 + } 34 + 35 + export function SectionOpsGenie({ form, plan }: Props) { 36 + const [isTestPending, startTestTransition] = useTransition(); 37 + const watchApiKey = form.watch("data.opsgenie.apiKey"); 38 + const watchRegion = form.watch("data.opsgenie.region"); 39 + 40 + async function sendTestAlert() { 41 + if (!watchApiKey || !watchRegion) return; 42 + startTestTransition(async () => { 43 + const isSuccessfull = await sendOpsGenieTestAlert( 44 + watchApiKey, 45 + watchRegion, 46 + ); 47 + if (isSuccessfull) { 48 + toastAction("test-success"); 49 + } else { 50 + toastAction("test-error"); 51 + } 52 + }); 53 + } 54 + 55 + return ( 56 + <> 57 + <FormField 58 + control={form.control} 59 + name="data.opsgenie.apiKey" 60 + render={({ field }) => ( 61 + <FormItem className="sm:col-span-full"> 62 + <FormLabel>API Key</FormLabel> 63 + <FormControl> 64 + <Input placeholder={"xxx-yyy-zzz"} {...field} /> 65 + </FormControl> 66 + <FormDescription className="flex items-center justify-between"> 67 + The API key is required. 68 + </FormDescription> 69 + <FormMessage /> 70 + </FormItem> 71 + )} 72 + /> 73 + <FormField 74 + control={form.control} 75 + name="data.opsgenie.region" 76 + render={({ field }) => ( 77 + <FormItem className="sm:col-span-full"> 78 + <FormLabel>Region</FormLabel> 79 + <FormControl> 80 + <Select onValueChange={field.onChange} defaultValue={field.value}> 81 + <FormControl> 82 + <SelectTrigger> 83 + <SelectValue placeholder="Select a region" /> 84 + </SelectTrigger> 85 + </FormControl> 86 + <SelectContent> 87 + <SelectItem value="us">US</SelectItem> 88 + <SelectItem value="eu">EU</SelectItem> 89 + </SelectContent> 90 + </Select> 91 + </FormControl> 92 + <FormDescription className="flex items-center justify-between"> 93 + The region is required. 94 + <a 95 + href="https://docs.openstatus.dev/synthetic/features/notification/opsgenie" 96 + target="_blank" 97 + className="underline hover:no-underline" 98 + rel="noreferrer" 99 + > 100 + How to setup your OpsGenie. 101 + </a> 102 + </FormDescription> 103 + <FormMessage /> 104 + </FormItem> 105 + )} 106 + /> 107 + <div className="col-span-full text-right"> 108 + <Button 109 + type="button" 110 + variant="secondary" 111 + className="w-full sm:w-auto" 112 + disabled={isTestPending || !watchApiKey || !watchRegion} 113 + onClick={sendTestAlert} 114 + > 115 + {!isTestPending ? ( 116 + "Test Alert" 117 + ) : ( 118 + <LoadingAnimation variant="inverse" /> 119 + )} 120 + </Button> 121 + </div> 122 + </> 123 + ); 124 + }
+17
apps/web/src/components/forms/notification/provider/section-pagerduty.tsx
··· 1 + "use client"; 2 + 3 + import type { UseFormReturn } from "react-hook-form"; 4 + 5 + import type { 6 + InsertNotificationWithData, 7 + WorkspacePlan, 8 + } from "@openstatus/db/src/schema"; 9 + 10 + interface Props { 11 + form: UseFormReturn<InsertNotificationWithData>; 12 + plan: WorkspacePlan; 13 + } 14 + 15 + export function SectionPagerDuty({ form }: Props) { 16 + return null; 17 + }
+92
apps/web/src/components/forms/notification/provider/section-slack.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import type { UseFormReturn } from "react-hook-form"; 5 + 6 + import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 7 + import { 8 + Button, 9 + FormControl, 10 + FormDescription, 11 + FormField, 12 + FormItem, 13 + FormLabel, 14 + FormMessage, 15 + Input, 16 + } from "@openstatus/ui"; 17 + 18 + import { LoadingAnimation } from "@/components/loading-animation"; 19 + import { toastAction } from "@/lib/toast"; 20 + import { sendTestSlackMessage } from "@openstatus/notification-slack"; 21 + 22 + interface Props { 23 + form: UseFormReturn<InsertNotificationWithData>; 24 + } 25 + 26 + export function SectionSlack({ form }: Props) { 27 + const [isTestPending, startTestTransition] = useTransition(); 28 + const watchUrl = form.watch("data.slack"); 29 + 30 + async function sendTestWebhookPing() { 31 + if (!watchUrl) return; 32 + startTestTransition(async () => { 33 + const isSuccessfull = await sendTestSlackMessage(watchUrl); 34 + if (isSuccessfull) { 35 + toastAction("test-success"); 36 + } else { 37 + toastAction("test-error"); 38 + } 39 + }); 40 + } 41 + 42 + return ( 43 + <> 44 + <FormField 45 + control={form.control} 46 + name="data.slack" 47 + render={({ field }) => ( 48 + <FormItem className="sm:col-span-full"> 49 + <FormLabel>Webhook URL</FormLabel> 50 + <FormControl> 51 + <Input 52 + type="url" 53 + placeholder="https://hooks.slack.com/services/xxx..." 54 + required 55 + {...field} 56 + /> 57 + </FormControl> 58 + <FormDescription className="flex items-center justify-between"> 59 + The data is required. 60 + <a 61 + href={ 62 + "https://api.slack.com/messaging/webhooks#getting_started" 63 + } 64 + target="_blank" 65 + className="underline hover:no-underline" 66 + rel="noreferrer" 67 + > 68 + How to setup your Slack webhook 69 + </a> 70 + </FormDescription> 71 + <FormMessage /> 72 + </FormItem> 73 + )} 74 + /> 75 + <div className="col-span-full text-right"> 76 + <Button 77 + type="button" 78 + variant="secondary" 79 + className="w-full sm:w-auto" 80 + disabled={isTestPending || !watchUrl} 81 + onClick={sendTestWebhookPing} 82 + > 83 + {!isTestPending ? ( 84 + "Test Webhook" 85 + ) : ( 86 + <LoadingAnimation variant="inverse" /> 87 + )} 88 + </Button> 89 + </div> 90 + </> 91 + ); 92 + }
+39
apps/web/src/components/forms/notification/provider/section-sms.tsx
··· 1 + "use client"; 2 + 3 + import type { UseFormReturn } from "react-hook-form"; 4 + 5 + import type { InsertNotificationWithData } from "@openstatus/db/src/schema"; 6 + import { 7 + FormControl, 8 + FormDescription, 9 + FormField, 10 + FormItem, 11 + FormLabel, 12 + FormMessage, 13 + Input, 14 + } from "@openstatus/ui"; 15 + 16 + interface Props { 17 + form: UseFormReturn<InsertNotificationWithData>; 18 + } 19 + 20 + export function SectionSms({ form }: Props) { 21 + return ( 22 + <FormField 23 + control={form.control} 24 + name="data.sms" 25 + render={({ field }) => ( 26 + <FormItem className="sm:col-span-full"> 27 + <FormLabel>Phone Number</FormLabel> 28 + <FormControl> 29 + <Input type="tel" placeholder="+1234567890" required {...field} /> 30 + </FormControl> 31 + <FormDescription className="flex items-center justify-between"> 32 + The phone number is required. 33 + </FormDescription> 34 + <FormMessage /> 35 + </FormItem> 36 + )} 37 + /> 38 + ); 39 + }
+5 -2
apps/web/src/components/forms/notification/section-connect.tsx
··· 3 3 import * as React from "react"; 4 4 import type { UseFormReturn } from "react-hook-form"; 5 5 6 - import type { InsertNotification, Monitor } from "@openstatus/db/src/schema"; 6 + import type { 7 + InsertNotificationWithData, 8 + Monitor, 9 + } from "@openstatus/db/src/schema"; 7 10 import { 8 11 FormControl, 9 12 FormDescription, ··· 16 19 import { CheckboxLabel } from "../shared/checkbox-label"; 17 20 18 21 interface Props { 19 - form: UseFormReturn<InsertNotification>; 22 + form: UseFormReturn<InsertNotificationWithData>; 20 23 monitors?: Monitor[]; 21 24 } 22 25
+16
packages/api/src/router/notification.ts
··· 40 40 }); 41 41 } 42 42 43 + const limitedProviders = ["sms", "pagerduty", "opsgenie"]; 44 + if (limitedProviders.includes(props.provider)) { 45 + const isAllowed = 46 + opts.ctx.workspace.limits[ 47 + props.provider as "sms" | "pagerduty" | "opsgenie" 48 + ]; 49 + 50 + if (!isAllowed) { 51 + throw new TRPCError({ 52 + code: "FORBIDDEN", 53 + message: "Upgrade to use the notification channel.", 54 + }); 55 + } 56 + } 57 + 43 58 const _data = NotificationDataSchema.safeParse(JSON.parse(props.data)); 59 + 44 60 if (!_data.success) { 45 61 throw new TRPCError({ 46 62 code: "BAD_REQUEST",
+1
packages/db/src/schema/notifications/constants.ts
··· 4 4 "slack", 5 5 "sms", 6 6 "pagerduty", 7 + "opsgenie", 7 8 ] as const;
+54
packages/db/src/schema/notifications/validation.ts
··· 36 36 export const emailSchema = z.string().email(); 37 37 export const urlSchema = z.string().url(); 38 38 39 + export const webhookDataSchema = z.object({ webhook: urlSchema }); 39 40 export const emailDataSchema = z.object({ email: emailSchema }); 40 41 export const phoneDataSchema = z.object({ sms: phoneSchema }); 41 42 export const slackDataSchema = z.object({ slack: urlSchema }); 42 43 export const discordDataSchema = z.object({ discord: urlSchema }); 43 44 export const pagerdutyDataSchema = z.object({ pagerduty: z.string() }); 45 + export const opsgenieDataSchema = z.object({ 46 + opsgenie: z.object({ 47 + apiKey: z.string(), 48 + region: z.enum(["us", "eu"]), 49 + }), 50 + }); 44 51 45 52 export const NotificationDataSchema = z.union([ 46 53 emailDataSchema, ··· 48 55 slackDataSchema, 49 56 discordDataSchema, 50 57 pagerdutyDataSchema, 58 + opsgenieDataSchema, 51 59 ]); 60 + 61 + export const InsertNotificationWithDataSchema = z.discriminatedUnion( 62 + "provider", 63 + [ 64 + insertNotificationSchema.merge( 65 + z.object({ 66 + provider: z.literal("email"), 67 + data: emailDataSchema, 68 + }), 69 + ), 70 + insertNotificationSchema.merge( 71 + z.object({ 72 + provider: z.literal("sms"), 73 + data: phoneDataSchema, 74 + }), 75 + ), 76 + insertNotificationSchema.merge( 77 + z.object({ 78 + provider: z.literal("slack"), 79 + data: slackDataSchema, 80 + }), 81 + ), 82 + insertNotificationSchema.merge( 83 + z.object({ 84 + provider: z.literal("discord"), 85 + data: discordDataSchema, 86 + }), 87 + ), 88 + insertNotificationSchema.merge( 89 + z.object({ 90 + provider: z.literal("pagerduty"), 91 + data: pagerdutyDataSchema, 92 + }), 93 + ), 94 + insertNotificationSchema.merge( 95 + z.object({ 96 + provider: z.literal("opsgenie"), 97 + data: opsgenieDataSchema, 98 + }), 99 + ), 100 + ], 101 + ); 102 + 103 + export type InsertNotificationWithData = z.infer< 104 + typeof InsertNotificationWithDataSchema 105 + >;
+4
packages/db/src/schema/plan/config.ts
··· 35 35 notifications: true, 36 36 sms: false, 37 37 pagerduty: false, 38 + opsgenie: false, 38 39 "notification-channels": 1, 39 40 members: 1, 40 41 "audit-log": false, ··· 64 65 "white-label": false, 65 66 notifications: true, 66 67 pagerduty: true, 68 + opsgenie: true, 67 69 sms: true, 68 70 "notification-channels": 10, 69 71 members: "Unlimited", ··· 131 133 notifications: true, 132 134 sms: true, 133 135 pagerduty: true, 136 + opsgenie: true, 134 137 "notification-channels": 20, 135 138 members: "Unlimited", 136 139 "audit-log": true, ··· 197 200 notifications: true, 198 201 sms: true, 199 202 pagerduty: true, 203 + opsgenie: true, 200 204 "notification-channels": 50, 201 205 members: "Unlimited", 202 206 "audit-log": true,
+1
packages/db/src/schema/plan/schema.ts
··· 37 37 */ 38 38 notifications: z.boolean().default(true), 39 39 pagerduty: z.boolean().default(false), 40 + opsgenie: z.boolean().default(false), 40 41 sms: z.boolean().default(false), 41 42 "notification-channels": z.number().default(1), 42 43 /**
+2 -1
packages/notifications/discord/package.json
··· 3 3 "version": "1.0.0", 4 4 "main": "src/index.ts", 5 5 "dependencies": { 6 - "@openstatus/db": "workspace:*" 6 + "@openstatus/db": "workspace:*", 7 + "zod": "3.22.4" 7 8 }, 8 9 "devDependencies": { 9 10 "@openstatus/tsconfig": "workspace:*",
+4 -3
packages/notifications/discord/src/index.ts
··· 1 1 import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + import { DataSchema } from "./schema"; 2 3 3 4 const postToWebhook = async (content: string, webhookUrl: string) => { 4 5 await fetch(webhookUrl, { ··· 29 30 incidentId?: string; 30 31 cronTimestamp: number; 31 32 }) => { 32 - const notificationData = JSON.parse(notification.data); 33 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 33 34 const { discord: webhookUrl } = notificationData; // webhook url 34 35 const { name } = monitor; 35 36 ··· 61 62 incidentId?: string; 62 63 cronTimestamp: number; 63 64 }) => { 64 - const notificationData = JSON.parse(notification.data); 65 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 65 66 const { discord: webhookUrl } = notificationData; // webhook url 66 67 const { name } = monitor; 67 68 ··· 93 94 incidentId?: string; 94 95 cronTimestamp: number; 95 96 }) => { 96 - const notificationData = JSON.parse(notification.data); 97 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 97 98 const { discord: webhookUrl } = notificationData; // webhook url 98 99 const { name } = monitor; 99 100
+5
packages/notifications/discord/src/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const DataSchema = z.object({ 4 + discord: z.string(), 5 + });
+19
packages/notifications/opsgenie/package.json
··· 1 + { 2 + "name": "@openstatus/notification-opsgenie", 3 + "version": "0.0.0", 4 + "main": "src/index.ts", 5 + "dependencies": { 6 + "@openstatus/db": "workspace:*", 7 + "@t3-oss/env-core": "0.7.1", 8 + "@types/validator": "13.11.6", 9 + "validator": "13.12.0", 10 + "zod": "3.22.4" 11 + }, 12 + "devDependencies": { 13 + "@openstatus/tsconfig": "workspace:*", 14 + "@types/node": "20.8.0", 15 + "@types/react": "18.2.64", 16 + "@types/react-dom": "18.2.21", 17 + "typescript": "5.4.5" 18 + } 19 + }
+183
packages/notifications/opsgenie/src/index.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + import { OpsGeniePayloadAlert, OpsGenieSchema } from "./schema"; 3 + 4 + export const sendAlert = async ({ 5 + monitor, 6 + notification, 7 + statusCode, 8 + message, 9 + incidentId, 10 + cronTimestamp, 11 + }: { 12 + monitor: Monitor; 13 + notification: Notification; 14 + statusCode?: number; 15 + message?: string; 16 + incidentId?: string; 17 + cronTimestamp: number; 18 + }) => { 19 + const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 20 + const { name } = monitor; 21 + 22 + const event = OpsGeniePayloadAlert.parse({ 23 + alias: `${monitor.id}}-${incidentId}`, 24 + message: `${name} is down`, 25 + description: message, 26 + details: { 27 + message, 28 + status: statusCode, 29 + severity: "down", 30 + }, 31 + }); 32 + 33 + const url = 34 + opsgenie.region === "eu" 35 + ? "https://api.eu.opsgenie.com/v2/alerts" 36 + : "https://api.opsgenie.com/v2/alerts"; 37 + try { 38 + await fetch(url, { 39 + method: "POST", 40 + body: JSON.stringify(event), 41 + headers: { 42 + "Content-Type": "application/json", 43 + Authorization: `GenieKey ${opsgenie.apiKey}`, 44 + }, 45 + }); 46 + } catch (err) { 47 + console.log(err); 48 + // Do something 49 + } 50 + }; 51 + 52 + export const sendDegraded = async ({ 53 + monitor, 54 + notification, 55 + statusCode, 56 + message, 57 + incidentId, 58 + }: { 59 + monitor: Monitor; 60 + notification: Notification; 61 + statusCode?: number; 62 + message?: string; 63 + incidentId?: string; 64 + cronTimestamp: number; 65 + }) => { 66 + const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 67 + const { name } = monitor; 68 + 69 + const event = OpsGeniePayloadAlert.parse({ 70 + alias: `${monitor.id}}-${incidentId}`, 71 + message: `${name} is down`, 72 + description: message, 73 + details: { 74 + message, 75 + status: statusCode, 76 + severity: "degraded", 77 + }, 78 + }); 79 + 80 + const url = 81 + opsgenie.region === "eu" 82 + ? "https://api.eu.opsgenie.com/v2/alerts" 83 + : "https://api.opsgenie.com/v2/alerts"; 84 + try { 85 + await fetch(url, { 86 + method: "POST", 87 + body: JSON.stringify(event), 88 + headers: { 89 + "Content-Type": "application/json", 90 + Authorization: `GenieKey ${opsgenie.apiKey}`, 91 + }, 92 + }); 93 + } catch (err) { 94 + console.log(err); 95 + // Do something 96 + 97 + // Do something 98 + } 99 + }; 100 + 101 + export const sendRecovery = async ({ 102 + monitor, 103 + notification, 104 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 105 + statusCode, 106 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 107 + message, 108 + incidentId, 109 + }: { 110 + monitor: Monitor; 111 + notification: Notification; 112 + statusCode?: number; 113 + message?: string; 114 + incidentId?: string; 115 + cronTimestamp: number; 116 + }) => { 117 + const { opsgenie } = OpsGenieSchema.parse(JSON.parse(notification.data)); 118 + 119 + const url = 120 + opsgenie.region === "eu" 121 + ? `https://api.eu.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close` 122 + : `https://api.opsgenie.com/v2/alerts/${monitor.id}}-${incidentId}/close`; 123 + 124 + const event = OpsGeniePayloadAlert.parse({ 125 + alias: `${monitor.id}}-${incidentId}`, 126 + message: `${monitor.name} has recovered`, 127 + description: message, 128 + details: { 129 + message, 130 + status: statusCode, 131 + }, 132 + }); 133 + try { 134 + await fetch(url, { 135 + method: "POST", 136 + body: JSON.stringify(event), 137 + headers: { 138 + "Content-Type": "application/json", 139 + Authorization: `GenieKey ${opsgenie.apiKey}`, 140 + }, 141 + }); 142 + } catch (err) { 143 + console.log(err); 144 + // Do something 145 + } 146 + }; 147 + export const sendTest = async (props: { 148 + apiKey: string; 149 + region: "eu" | "us"; 150 + }) => { 151 + const { apiKey, region } = props; 152 + 153 + const url = 154 + region === "eu" 155 + ? "https://api.eu.opsgenie.com/v2/alerts" 156 + : "https://api.opsgenie.com/v2/alerts"; 157 + 158 + const alert = OpsGeniePayloadAlert.parse({ 159 + alias: "test-openstatus", 160 + message: "Test Alert <OpenStatus>", 161 + description: 162 + "If you can read this, your OpsGenie integration is functioning correctly! Please ignore this alert and delete it.", 163 + }); 164 + 165 + try { 166 + const res = await fetch(url, { 167 + method: "POST", 168 + body: JSON.stringify(alert), 169 + headers: { 170 + "Content-Type": "application/json", 171 + Authorization: `GenieKey ${apiKey}`, 172 + "Access-Control-Allow-Origin": "*", 173 + }, 174 + }); 175 + console.log(await res.json()); 176 + return true; 177 + } catch (err) { 178 + console.log(err); 179 + return false; 180 + } 181 + }; 182 + 183 + export { OpsGenieSchema };
+26
packages/notifications/opsgenie/src/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const OpsGenieSchema = z.object({ 4 + opsgenie: z.object({ 5 + apiKey: z.string(), 6 + region: z.enum(["eu", "us"]), 7 + }), 8 + }); 9 + 10 + export const OpsGeniePayloadAlert = z.object({ 11 + message: z.string(), 12 + alias: z.string(), 13 + description: z.string(), 14 + source: z.string().default("OpenStatus"), 15 + details: z 16 + .object({ 17 + message: z.string(), 18 + status: z.number().optional(), 19 + severity: z.enum(["degraded", "down"]), 20 + }) 21 + .optional(), 22 + }); 23 + 24 + export const OpsGenieCloseAlert = z.object({ 25 + source: z.string().default("OpenStatus"), 26 + });
+4
packages/notifications/opsgenie/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+2 -1
packages/notifications/slack/package.json
··· 3 3 "version": "0.0.0", 4 4 "main": "src/index.ts", 5 5 "dependencies": { 6 - "@openstatus/db": "workspace:*" 6 + "@openstatus/db": "workspace:*", 7 + "zod": "3.22.4" 7 8 }, 8 9 "devDependencies": { 9 10 "@openstatus/tsconfig": "workspace:*",
+4 -3
packages/notifications/slack/src/index.ts
··· 1 1 import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + import { DataSchema } from "./schema"; 2 3 3 4 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 4 5 const postToWebhook = async (body: any, webhookUrl: string) => { ··· 29 30 incidentId?: string; 30 31 cronTimestamp: number; 31 32 }) => { 32 - const notificationData = JSON.parse(notification.data); 33 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 33 34 const { slack: webhookUrl } = notificationData; // webhook url 34 35 const { name } = monitor; 35 36 ··· 88 89 incidentId?: string; 89 90 cronTimestamp: number; 90 91 }) => { 91 - const notificationData = JSON.parse(notification.data); 92 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 92 93 const { slack: webhookUrl } = notificationData; // webhook url 93 94 const { name } = monitor; 94 95 ··· 139 140 message?: string; 140 141 cronTimestamp: number; 141 142 }) => { 142 - const notificationData = JSON.parse(notification.data); 143 + const notificationData = DataSchema.parse(JSON.parse(notification.data)); 143 144 const { slack: webhookUrl } = notificationData; // webhook url 144 145 const { name } = monitor; 145 146
+5
packages/notifications/slack/src/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const DataSchema = z.object({ 4 + slack: z.string(), 5 + });
+66 -65
pnpm-lock.yaml
··· 142 142 '@openstatus/notification-emails': 143 143 specifier: workspace:* 144 144 version: link:../../packages/notifications/email 145 + '@openstatus/notification-opsgenie': 146 + specifier: workspace:* 147 + version: link:../../packages/notifications/opsgenie 145 148 '@openstatus/notification-pagerduty': 146 149 specifier: workspace:* 147 150 version: link:../../packages/notifications/pagerduty ··· 248 251 '@openstatus/notification-emails': 249 252 specifier: workspace:* 250 253 version: link:../../packages/notifications/email 254 + '@openstatus/notification-opsgenie': 255 + specifier: workspace:* 256 + version: link:../../packages/notifications/opsgenie 251 257 '@openstatus/notification-pagerduty': 252 258 specifier: workspace:* 253 259 version: link:../../packages/notifications/pagerduty ··· 277 283 version: 1.1.3(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 278 284 '@sentry/nextjs': 279 285 specifier: 8.46.0 280 - version: 8.46.0(@opentelemetry/core@1.30.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.21.5)) 286 + version: 8.46.0(@opentelemetry/core@1.30.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.21.5)) 281 287 '@stripe/stripe-js': 282 288 specifier: 2.1.6 283 289 version: 2.1.6 ··· 304 310 version: 11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2) 305 311 '@trpc/next': 306 312 specifier: 11.0.0-rc.666 307 - version: 11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/react-query@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2) 313 + version: 11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/react-query@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2) 308 314 '@trpc/react-query': 309 315 specifier: 11.0.0-rc.666 310 316 version: 11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2) ··· 352 358 version: 5.0.7 353 359 next: 354 360 specifier: 15.1.1 355 - version: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 361 + version: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 356 362 next-auth: 357 363 specifier: 5.0.0-beta.25 358 - version: 5.0.0-beta.25(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 364 + version: 5.0.0-beta.25(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 359 365 next-plausible: 360 366 specifier: 3.12.4 361 - version: 3.12.4(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 367 + version: 3.12.4(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 362 368 next-themes: 363 369 specifier: 0.2.1 364 - version: 0.2.1(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 370 + version: 0.2.1(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 365 371 nuqs: 366 372 specifier: 2.2.3 367 - version: 2.2.3(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 373 + version: 2.2.3(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 368 374 random-word-slugs: 369 375 specifier: 0.1.7 370 376 version: 0.1.7 ··· 434 440 version: 0.2.0(@content-collections/core@0.7.3(typescript@5.6.2))(acorn@8.14.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 435 441 '@content-collections/next': 436 442 specifier: 0.2.4 437 - version: 0.2.4(@content-collections/core@0.7.3(typescript@5.6.2))(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) 443 + version: 0.2.4(@content-collections/core@0.7.3(typescript@5.6.2))(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) 438 444 '@openstatus/tsconfig': 439 445 specifier: workspace:* 440 446 version: link:../../packages/tsconfig ··· 721 727 '@openstatus/db': 722 728 specifier: workspace:* 723 729 version: link:../../db 730 + zod: 731 + specifier: 3.22.4 732 + version: 3.22.4 724 733 devDependencies: 725 734 '@openstatus/tsconfig': 726 735 specifier: workspace:* ··· 769 778 specifier: 5.6.2 770 779 version: 5.6.2 771 780 781 + packages/notifications/opsgenie: 782 + dependencies: 783 + '@openstatus/db': 784 + specifier: workspace:* 785 + version: link:../../db 786 + '@t3-oss/env-core': 787 + specifier: 0.7.1 788 + version: 0.7.1(typescript@5.4.5)(zod@3.22.4) 789 + '@types/validator': 790 + specifier: 13.11.6 791 + version: 13.11.6 792 + validator: 793 + specifier: 13.12.0 794 + version: 13.12.0 795 + zod: 796 + specifier: 3.22.4 797 + version: 3.22.4 798 + devDependencies: 799 + '@openstatus/tsconfig': 800 + specifier: workspace:* 801 + version: link:../../tsconfig 802 + '@types/node': 803 + specifier: 20.8.0 804 + version: 20.8.0 805 + '@types/react': 806 + specifier: 18.2.64 807 + version: 18.2.64 808 + '@types/react-dom': 809 + specifier: 18.2.21 810 + version: 18.2.21 811 + typescript: 812 + specifier: 5.4.5 813 + version: 5.4.5 814 + 772 815 packages/notifications/pagerduty: 773 816 dependencies: 774 817 '@openstatus/db': ··· 808 851 '@openstatus/db': 809 852 specifier: workspace:* 810 853 version: link:../../db 854 + zod: 855 + specifier: 3.22.4 856 + version: 3.22.4 811 857 devDependencies: 812 858 '@openstatus/tsconfig': 813 859 specifier: workspace:* ··· 11417 11463 - acorn 11418 11464 - supports-color 11419 11465 11420 - '@content-collections/next@0.2.4(@content-collections/core@0.7.3(typescript@5.6.2))(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': 11466 + '@content-collections/next@0.2.4(@content-collections/core@0.7.3(typescript@5.6.2))(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': 11421 11467 dependencies: 11422 11468 '@content-collections/core': 0.7.3(typescript@5.6.2) 11423 11469 '@content-collections/integrations': 0.2.1(@content-collections/core@0.7.3(typescript@5.6.2)) 11424 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 11470 + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 11425 11471 11426 11472 '@cspotcode/source-map-support@0.8.1': 11427 11473 dependencies: ··· 13996 14042 '@sentry/types': 8.9.2 13997 14043 '@sentry/utils': 8.9.2 13998 14044 13999 - '@sentry/nextjs@8.46.0(@opentelemetry/core@1.30.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.21.5))': 14045 + '@sentry/nextjs@8.46.0(@opentelemetry/core@1.30.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.21.5))': 14000 14046 dependencies: 14001 14047 '@opentelemetry/api': 1.9.0 14002 14048 '@opentelemetry/semantic-conventions': 1.28.0 ··· 14009 14055 '@sentry/vercel-edge': 8.46.0 14010 14056 '@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.21.5)) 14011 14057 chalk: 3.0.0 14012 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14058 + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14013 14059 resolve: 1.22.8 14014 14060 rollup: 3.29.5 14015 14061 stacktrace-parser: 0.1.10 ··· 14629 14675 '@trpc/server': 11.0.0-rc.666(typescript@5.6.2) 14630 14676 typescript: 5.6.2 14631 14677 14632 - '@trpc/next@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/react-query@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2)': 14678 + '@trpc/next@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/react-query@11.0.0-rc.666(@tanstack/react-query@5.62.8(react@19.0.0))(@trpc/client@11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2))(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.6.2)': 14633 14679 dependencies: 14634 14680 '@trpc/client': 11.0.0-rc.666(@trpc/server@11.0.0-rc.666(typescript@5.6.2))(typescript@5.6.2) 14635 14681 '@trpc/server': 11.0.0-rc.666(typescript@5.6.2) 14636 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14682 + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14637 14683 react: 19.0.0 14638 14684 react-dom: 19.0.0(react@19.0.0) 14639 14685 typescript: 5.6.2 ··· 17527 17573 17528 17574 jest-worker@27.5.1: 17529 17575 dependencies: 17530 - '@types/node': 20.14.8 17576 + '@types/node': 22.10.2 17531 17577 merge-stream: 2.0.0 17532 17578 supports-color: 8.1.1 17533 17579 ··· 18600 18646 18601 18647 netmask@2.0.2: {} 18602 18648 18603 - next-auth@5.0.0-beta.25(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): 18604 - dependencies: 18605 - '@auth/core': 0.37.2 18606 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18607 - react: 19.0.0 18608 - 18609 18649 next-auth@5.0.0-beta.25(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): 18610 18650 dependencies: 18611 18651 '@auth/core': 0.37.2 18612 18652 next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18613 18653 react: 19.0.0 18614 18654 18615 - next-plausible@3.12.4(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 18616 - dependencies: 18617 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18618 - react: 19.0.0 18619 - react-dom: 19.0.0(react@19.0.0) 18620 - 18621 - next-themes@0.2.1(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 18655 + next-plausible@3.12.4(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 18622 18656 dependencies: 18623 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18657 + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18624 18658 react: 19.0.0 18625 18659 react-dom: 19.0.0(react@19.0.0) 18626 18660 ··· 18656 18690 - '@babel/core' 18657 18691 - babel-plugin-macros 18658 18692 18659 - next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 18660 - dependencies: 18661 - '@next/env': 15.1.1 18662 - '@swc/counter': 0.1.3 18663 - '@swc/helpers': 0.5.15 18664 - busboy: 1.6.0 18665 - caniuse-lite: 1.0.30001689 18666 - postcss: 8.4.31 18667 - react: 19.0.0 18668 - react-dom: 19.0.0(react@19.0.0) 18669 - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) 18670 - optionalDependencies: 18671 - '@next/swc-darwin-arm64': 15.1.1 18672 - '@next/swc-darwin-x64': 15.1.1 18673 - '@next/swc-linux-arm64-gnu': 15.1.1 18674 - '@next/swc-linux-arm64-musl': 15.1.1 18675 - '@next/swc-linux-x64-gnu': 15.1.1 18676 - '@next/swc-linux-x64-musl': 15.1.1 18677 - '@next/swc-win32-arm64-msvc': 15.1.1 18678 - '@next/swc-win32-x64-msvc': 15.1.1 18679 - '@opentelemetry/api': 1.9.0 18680 - sharp: 0.33.5 18681 - transitivePeerDependencies: 18682 - - '@babel/core' 18683 - - babel-plugin-macros 18684 - 18685 18693 next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): 18686 18694 dependencies: 18687 18695 '@next/env': 15.1.1 ··· 18781 18789 dependencies: 18782 18790 boolbase: 1.0.0 18783 18791 18784 - nuqs@2.2.3(next@15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): 18792 + nuqs@2.2.3(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0): 18785 18793 dependencies: 18786 18794 mitt: 3.0.1 18787 18795 react: 19.0.0 18788 18796 optionalDependencies: 18789 - next: 15.1.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18797 + next: 15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 18790 18798 18791 18799 oauth4webapi@2.10.4: {} 18792 18800 ··· 20334 20342 optionalDependencies: 20335 20343 '@babel/core': 7.24.5 20336 20344 20337 - styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): 20338 - dependencies: 20339 - client-only: 0.0.1 20340 - react: 19.0.0 20341 - optionalDependencies: 20342 - '@babel/core': 7.26.0 20343 - 20344 20345 sucrase@3.34.0: 20345 20346 dependencies: 20346 20347 '@jridgewell/gen-mapping': 0.3.5 ··· 20672 20673 '@tsconfig/node14': 1.0.3 20673 20674 '@tsconfig/node16': 1.0.4 20674 20675 '@types/node': 20.14.8 20675 - acorn: 8.14.0 20676 + acorn: 8.11.3 20676 20677 acorn-walk: 8.3.2 20677 20678 arg: 4.1.3 20678 20679 create-require: 1.1.1