Openstatus www.openstatus.dev

๐Ÿ“Ÿ Pagerduty integration (#890)

* ๐Ÿšง wip

* ๐Ÿ”ฅ pagerduty integration

* ๐Ÿš€ fix build

* ๐Ÿš€ fix build

* ๐Ÿš€ fix build

* chore: small improvements

* ๐Ÿงน

---------

Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
Maximilian Kaske
and committed by
GitHub
efffb8d2 70cf4164

+671 -175
apps/docs/images/notification/pagerduty/pagerduty-1.png

This is a binary file and will not be displayed.

apps/docs/images/notification/pagerduty/pagerduty-2.png

This is a binary file and will not be displayed.

apps/docs/images/notification/pagerduty/pagerduty-3.png

This is a binary file and will not be displayed.

+1
apps/docs/mint.json
··· 93 93 "group": "Notification Channels", 94 94 "pages": [ 95 95 "synthetic/features/notification/discord", 96 + "synthetic/features/notification/pagerduty", 96 97 "synthetic/features/notification/phone-call", 97 98 "synthetic/features/notification/slack", 98 99 "synthetic/features/notification/sms",
+41
apps/docs/synthetic/features/notification/pagerduty.mdx
··· 1 + --- 2 + title: PagerDuty 3 + --- 4 + 5 + Get Notified on PagerDuty when we create an incident. 6 + 7 + ## How to connect PagerDuty 8 + 9 + Go to the Alerts Page . Select `PagerDuty` from the list of available integrations. 10 + 11 + 12 + 13 + <Frame caption="Connect to PagerDuty"> 14 + <img 15 + src="/images/notification/pagerduty/pagerduty-1.png" 16 + alt="Connect to PagerDuty" 17 + /> 18 + </Frame> 19 + 20 + You will be redirected to the PagerDuty website to authorize OpenStatus to send notifications to your account. 21 + 22 + <Frame caption="Connect to PagerDuty"> 23 + <img 24 + src="/images/notification/pagerduty/pagerduty-3.png" 25 + alt="Connect to PagerDuty" 26 + /> 27 + </Frame> 28 + 29 + 30 + Select the service you want to use to send notifications. You can create a new service if you don't have one. 31 + 32 + <Frame caption="Connect to PagerDuty"> 33 + <img 34 + src="/images/notification/pagerduty/pagerduty-2.png" 35 + alt="Connect to PagerDuty" 36 + /> 37 + </Frame> 38 + 39 + You are now connected to PagerDuty. Give your integration a name and save it. 40 + 41 + You will receive some notifications if we detect an incident
+1
apps/server/package.json
··· 19 19 "@openstatus/error": "workspace:*", 20 20 "@openstatus/notification-discord": "workspace:*", 21 21 "@openstatus/notification-emails": "workspace:*", 22 + "@openstatus/notification-pagerduty": "workspace:*", 22 23 "@openstatus/notification-slack": "workspace:*", 23 24 "@openstatus/notification-twillio-sms": "workspace:*", 24 25 "@openstatus/plans": "workspace:*",
+7 -3
apps/server/src/checker/alerting.ts
··· 16 16 statusCode, 17 17 message, 18 18 notifType, 19 + incidentId, 19 20 }: { 20 21 monitorId: string; 21 22 statusCode?: number; 22 23 message?: string; 23 24 notifType: "alert" | "recovery"; 25 + incidentId?: string; 24 26 }) => { 25 27 console.log(`๐Ÿ’Œ triggerAlerting for ${monitorId}`); 26 28 const notifications = await db ··· 28 30 .from(schema.notificationsToMonitors) 29 31 .innerJoin( 30 32 schema.notification, 31 - eq(schema.notification.id, schema.notificationsToMonitors.notificationId), 33 + eq(schema.notification.id, schema.notificationsToMonitors.notificationId) 32 34 ) 33 35 .innerJoin( 34 36 schema.monitor, 35 - eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), 37 + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId) 36 38 ) 37 39 .where(eq(schema.monitor.id, Number(monitorId))) 38 40 .all(); 39 41 for (const notif of notifications) { 40 42 console.log( 41 - `๐Ÿ’Œ sending notification for ${monitorId} and chanel ${notif.notification.provider} for ${notifType}`, 43 + `๐Ÿ’Œ sending notification for ${monitorId} and chanel ${notif.notification.provider} for ${notifType}` 42 44 ); 43 45 const monitor = selectMonitorSchema.parse(notif.monitor); 44 46 switch (notifType) { ··· 48 50 notification: selectNotificationSchema.parse(notif.notification), 49 51 statusCode, 50 52 message, 53 + incidentId, 51 54 }); 52 55 break; 53 56 case "recovery": ··· 56 59 notification: selectNotificationSchema.parse(notif.notification), 57 60 statusCode, 58 61 message, 62 + incidentId, 59 63 }); 60 64 break; 61 65 }
+11
apps/server/src/checker/utils.ts
··· 20 20 sendRecovery as sendSmsRecovery, 21 21 } from "@openstatus/notification-twillio-sms"; 22 22 23 + import { 24 + sendAlert as sendPagerdutyAlert, 25 + sendRecovery as sendPagerDutyRecovery, 26 + } from "@openstatus/notification-pagerduty"; 27 + 23 28 type SendNotification = ({ 24 29 monitor, 25 30 notification, 26 31 statusCode, 27 32 message, 33 + incidentId, 28 34 }: { 29 35 monitor: Monitor; 30 36 notification: Notification; 31 37 statusCode?: number; 32 38 message?: string; 39 + incidentId?: string; 33 40 }) => Promise<void>; 34 41 35 42 type Notif = { ··· 47 54 }, 48 55 discord: { sendAlert: sendDiscordAlert, sendRecovery: sendDiscordRecovery }, 49 56 sms: { sendAlert: sendSmsAlert, sendRecovery: sendSmsRecovery }, 57 + pagerduty: { 58 + sendAlert: sendPagerdutyAlert, 59 + sendRecovery: sendPagerDutyRecovery, 60 + }, 50 61 } satisfies Record<NotificationProvider, Notif>;
+5 -3
apps/web/.env.example
··· 19 19 DATABASE_AUTH_TOKEN=any-token 20 20 21 21 # JITSU - no need to touch on local development 22 - JITSU_HOST="https://your-jitsu-domain.com" 23 - JITSU_WRITE_KEY="jitsu-key:jitsu-secret" 22 + # JITSU_HOST="https://your-jitsu-domain.com" 23 + # JITSU_WRITE_KEY="jitsu-key:jitsu-secret" 24 24 25 25 # Solves 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', see https://github.com/nextauthjs/next-auth/issues/3580 26 26 # NODE_TLS_REJECT_UNAUTHORIZED="0" ··· 72 72 AUTH_GOOGLE_ID= 73 73 AUTH_GOOGLE_SECRET= 74 74 75 - PLAIN_API_KEY= 75 + PLAIN_API_KEY= 76 + 77 + PAGERDUTY_APP_ID=
+1
apps/web/package.json
··· 27 27 "@openstatus/notification-discord": "workspace:*", 28 28 "@openstatus/notification-emails": "workspace:*", 29 29 "@openstatus/notification-slack": "workspace:*", 30 + "@openstatus/notification-pagerduty": "workspace:*", 30 31 "@openstatus/plans": "workspace:*", 31 32 "@openstatus/react": "workspace:*", 32 33 "@openstatus/rum": "workspace:*",
apps/web/public/assets/changelog/pagerduty-integration.png

This is a binary file and will not be displayed.

+85
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx
··· 1 + import type { Workspace } from "@openstatus/db/src/schema"; 2 + import { getLimit } from "@openstatus/plans"; 3 + import { Button, Separator } from "@openstatus/ui"; 4 + import Link from "next/link"; 5 + 6 + // FIXME: create a Channel Component within the file to avoid code duplication 7 + 8 + interface ChannelTable { 9 + workspace: Workspace; 10 + disabled?: boolean; 11 + } 12 + 13 + export default function ChannelTable({ workspace, disabled }: ChannelTable) { 14 + const isPagerDutyAllowed = getLimit(workspace.plan, "pagerduty"); 15 + const isSMSAllowed = getLimit(workspace.plan, "sms"); 16 + return ( 17 + <div className="col-span-full w-full rounded-lg border border-border border-dashed bg-background p-8"> 18 + <h2 className="font-cal text-2xl">Channels</h2> 19 + <h3 className="text-muted-foreground">Connect all your channels</h3> 20 + <div className="mt-4 rounded-md border"> 21 + <Channel 22 + title="Discord" 23 + description="Send notifications to discord." 24 + href="./notifications/new/discord" 25 + disabled={disabled} 26 + /> 27 + <Separator /> 28 + <Channel 29 + title="Email" 30 + description="Send notifications by email." 31 + href="./notifications/new/email" 32 + disabled={disabled} 33 + /> 34 + <Separator /> 35 + <Channel 36 + title="PagerDuty" 37 + description="Send notifications to PagerDuty." 38 + href={`https://app.pagerduty.com/install/integration?app_id=PN76M56&redirect_url=${ 39 + process.env.NODE_ENV === "development" // FIXME: This sucks 40 + ? "http://localhost:3000" 41 + : "https://www.openstatus.dev" 42 + }/app/${workspace.slug}/notifications/new/pagerduty&version=2`} 43 + disabled={disabled || isPagerDutyAllowed} 44 + /> 45 + <Separator /> 46 + <Channel 47 + title="Slack" 48 + description="Send notifications to Slack." 49 + href="./notifications/new/slack" 50 + disabled={disabled} 51 + /> 52 + <Separator /> 53 + <Channel 54 + title="SMS" 55 + description="Send notifications to your phones." 56 + href="./notifications/new/sms" 57 + disabled={disabled || isSMSAllowed} 58 + /> 59 + </div> 60 + </div> 61 + ); 62 + } 63 + 64 + interface ChannelProps { 65 + title: string; 66 + description: string; 67 + href: string; 68 + disabled?: boolean; 69 + } 70 + 71 + function Channel({ title, description, href, disabled }: ChannelProps) { 72 + return ( 73 + <div className="flex items-center gap-4 px-4 py-3"> 74 + <div className="flex-1 space-y-1"> 75 + <p className="font-medium text-sm leading-none">{title}</p> 76 + <p className="text-muted-foreground text-sm">{description}</p> 77 + </div> 78 + <div> 79 + <Button disabled={disabled} asChild={!disabled}> 80 + {disabled ? "Create" : <Link href={href}>Create</Link>} 81 + </Button> 82 + </div> 83 + </div> 84 + ); 85 + }
-11
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx
··· 11 11 }: { 12 12 children: React.ReactNode; 13 13 }) { 14 - const isLimitReached = 15 - await api.notification.isNotificationLimitReached.query(); 16 14 return ( 17 15 <AppPageLayout> 18 16 <Header 19 17 title="Notifications" 20 18 description="Overview of all your notification channels." 21 - actions={ 22 - <ButtonWithDisableTooltip 23 - tooltip="You reached the limits" 24 - asChild={!isLimitReached} 25 - disabled={isLimitReached} 26 - > 27 - <Link href="./notifications/new">Create</Link> 28 - </ButtonWithDisableTooltip> 29 - } 30 19 /> 31 20 {children} 32 21 </AppPageLayout>
+11 -10
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx
··· 8 8 import { columns } from "@/components/data-table/notification/columns"; 9 9 import { DataTable } from "@/components/data-table/notification/data-table"; 10 10 import { api } from "@/trpc/server"; 11 + import ChannelTable from "./_components/channel-table"; 11 12 12 13 export default async function NotificationPage() { 14 + const workspace = await api.workspace.getWorkspace.query(); 13 15 const notifications = 14 16 await api.notification.getNotificationsByWorkspace.query(); 15 17 const isLimitReached = ··· 17 19 18 20 if (notifications.length === 0) { 19 21 return ( 20 - <EmptyState 21 - icon="bell" 22 - title="No notifications" 23 - description="Create your first notification channel" 24 - action={ 25 - <Button asChild> 26 - <Link href="./notifications/new">Create</Link> 27 - </Button> 28 - } 29 - /> 22 + <> 23 + <EmptyState 24 + icon="bell" 25 + title="No notifications" 26 + description="Create your first notification channel" 27 + /> 28 + <ChannelTable workspace={workspace} disabled={isLimitReached} /> 29 + </> 30 30 ); 31 31 } 32 32 ··· 34 34 <> 35 35 <DataTable columns={columns} data={notifications} /> 36 36 {isLimitReached ? <Limit /> : null} 37 + <ChannelTable workspace={workspace} disabled={isLimitReached} /> 37 38 </> 38 39 ); 39 40 }
+2 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx
··· 1 - import { NotificationForm } from "@/components/forms/notification/form"; 1 + import { NotificationForm } from "@/components/forms/notification-form"; 2 2 import { api } from "@/trpc/server"; 3 3 4 4 export default async function EditPage({ ··· 16 16 <NotificationForm 17 17 defaultValues={notification} 18 18 workspacePlan={workspace.plan} 19 + provider={notification.provider} 19 20 /> 20 21 ); 21 22 }
+41
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx
··· 1 + import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 + import { NotificationForm } from "@/components/forms/notification-form"; 3 + import { api } from "@/trpc/server"; 4 + import { notificationProviderSchema } from "@openstatus/db/src/schema"; 5 + import { getLimit } from "@openstatus/plans"; 6 + import { notFound } from "next/navigation"; 7 + 8 + export default async function ChannelPage({ 9 + params, 10 + }: { 11 + params: { channel: string }; 12 + }) { 13 + const validation = notificationProviderSchema 14 + .exclude(["pagerduty"]) 15 + .safeParse(params.channel); 16 + 17 + if (!validation.success) notFound(); 18 + 19 + const workspace = await api.workspace.getWorkspace.query(); 20 + 21 + const provider = validation.data; 22 + 23 + const allowed = 24 + provider === "sms" ? getLimit(workspace.plan, provider) : true; 25 + 26 + if (!allowed) return <ProFeatureAlert feature="SMS channel notification" />; 27 + 28 + const isLimitReached = 29 + await api.notification.isNotificationLimitReached.query(); 30 + 31 + if (isLimitReached) 32 + return <ProFeatureAlert feature="More notification channel" />; 33 + 34 + return ( 35 + <NotificationForm 36 + workspacePlan={workspace.plan} 37 + nextUrl="../" 38 + provider={provider} 39 + /> 40 + ); 41 + }
+4 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx
··· 8 8 }) { 9 9 return ( 10 10 <AppPageLayout> 11 - <Header title="Notifications" description="Create your notification" /> 11 + <Header 12 + title="Notifications" 13 + description="Add your a new notification channel " 14 + /> 12 15 {children} 13 16 </AppPageLayout> 14 17 );
-8
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/page.tsx
··· 1 - import { NotificationForm } from "@/components/forms/notification/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function EditPage() { 5 - const workspace = await api.workspace.getWorkspace.query(); 6 - 7 - return <NotificationForm workspacePlan={workspace.plan} nextUrl="./" />; 8 - }
+45
apps/web/src/app/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx
··· 1 + import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 + import { NotificationForm } from "@/components/forms/notification-form"; 3 + import { api } from "@/trpc/server"; 4 + import { PagerDutySchema } from "@openstatus/notification-pagerduty"; 5 + import { getLimit } from "@openstatus/plans"; 6 + import { z } from "zod"; 7 + 8 + // REMINDER: PagerDuty requires a different workflow, thus the separate page 9 + 10 + const searchParamsSchema = z.object({ 11 + config: z.string().optional(), 12 + }); 13 + 14 + export default async function PagerDutyPage({ 15 + searchParams, 16 + }: { 17 + searchParams: { [key: string]: string | string[] | undefined }; 18 + }) { 19 + const workspace = await api.workspace.getWorkspace.query(); 20 + const params = searchParamsSchema.parse(searchParams); 21 + 22 + if (!params.config) { 23 + return <div>Invalid data</div>; 24 + } 25 + 26 + const data = PagerDutySchema.parse(JSON.parse(params.config)); 27 + if (!data) { 28 + return <div>Invalid data</div>; 29 + } 30 + 31 + const allowed = getLimit(workspace.plan, "pagerduty"); 32 + 33 + if (!allowed) return <ProFeatureAlert feature="SMS channel notification" />; 34 + 35 + return ( 36 + <> 37 + <NotificationForm 38 + workspacePlan={workspace.plan} 39 + nextUrl="../" 40 + provider="pagerduty" 41 + callbackData={params.config} 42 + /> 43 + </> 44 + ); 45 + }
+9 -4
apps/web/src/components/billing/pro-feature-alert.tsx
··· 5 5 import { useParams } from "next/navigation"; 6 6 7 7 import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 8 + import type { WorkspacePlan } from "@openstatus/plans"; 8 9 9 10 interface Props { 10 11 feature: string; 12 + workspacePlan?: WorkspacePlan; 11 13 } 12 14 13 - export function ProFeatureAlert({ feature }: Props) { 15 + export function ProFeatureAlert({ feature, workspacePlan = "pro" }: Props) { 14 16 const params = useParams<{ workspaceSlug: string }>(); 15 17 return ( 16 18 <Alert> 17 19 <AlertTriangle className="h-4 w-4" /> 18 - <AlertTitle>{feature} is a Pro feature.</AlertTitle> 20 + <AlertTitle> 21 + {feature} is a <span className="capitalize">{workspacePlan}</span>{" "} 22 + feature. 23 + </AlertTitle> 19 24 <AlertDescription> 20 25 If you want to use{" "} 21 - <span className="underline decoration-dotted">{feature}</span>, please 22 - upgrade your plan. Go to{" "} 26 + <span className="lowercase underline decoration-dotted">{feature}</span> 27 + , please upgrade your plan. Go to{" "} 23 28 <Link 24 29 href={`/app/${params.workspaceSlug}/settings/billing`} 25 30 className="inline-flex items-center font-medium text-foreground underline underline-offset-4 hover:no-underline"
-12
apps/web/src/components/forms/monitor-form.tsx
··· 719 719 </div> 720 720 </form> 721 721 </Form> 722 - <DialogContent className="sm:max-w-2xl"> 723 - <DialogHeader> 724 - <DialogTitle>Add Notification</DialogTitle> 725 - <DialogDescription> 726 - Get alerted when your endpoint is down. 727 - </DialogDescription> 728 - </DialogHeader> 729 - <NotificationForm 730 - onSubmit={() => setOpenDialog(false)} 731 - workspacePlan={plan} 732 - /> 733 - </DialogContent> 734 722 <FailedPingAlertConfirmation 735 723 monitor={form.getValues()} 736 724 {...{ pingFailed, setPingFailed }}
+5 -30
apps/web/src/components/forms/monitor/section-notifications.tsx
··· 36 36 } 37 37 38 38 export function SectionNotifications({ form, plan, notifications }: Props) { 39 - const [openDialog, setOpenDialog] = React.useState(false); 40 - 41 - const notificationLimit = getLimit(plan, "notification-channels"); 42 - const notificationLimitReached = notifications 43 - ? notifications.length >= notificationLimit 44 - : false; 45 39 return ( 46 40 <div className="grid w-full gap-4"> 47 41 {/* <div className="grid gap-1"> ··· 86 80 ]) 87 81 : field.onChange( 88 82 field.value?.filter( 89 - (value) => value !== item.id, 90 - ), 83 + (value) => value !== item.id 84 + ) 91 85 ); 92 86 }} 93 87 > ··· 106 100 ))} 107 101 </div> 108 102 {!notifications || notifications.length === 0 ? ( 109 - <FormDescription>Missing notifications.</FormDescription> 103 + <FormDescription> 104 + Create some notifications first. 105 + </FormDescription> 110 106 ) : null} 111 107 <FormMessage /> 112 108 </FormItem> 113 109 ); 114 110 }} 115 111 /> 116 - <Dialog open={openDialog} onOpenChange={(val) => setOpenDialog(val)}> 117 - <div> 118 - <DialogTrigger asChild> 119 - <Button variant="outline" disabled={notificationLimitReached}> 120 - Add Notification Channel 121 - </Button> 122 - </DialogTrigger> 123 - </div> 124 - <DialogContent className="sm:max-w-2xl"> 125 - <DialogHeader> 126 - <DialogTitle>Add Notification</DialogTitle> 127 - <DialogDescription> 128 - Get alerted when your endpoint is down. 129 - </DialogDescription> 130 - </DialogHeader> 131 - <NotificationForm 132 - onSubmit={() => setOpenDialog(false)} 133 - workspacePlan={plan} 134 - /> 135 - </DialogContent> 136 - </Dialog> 137 112 </div> 138 113 ); 139 114 }
+25 -62
apps/web/src/components/forms/notification-form.tsx
··· 10 10 NotificationProvider, 11 11 WorkspacePlan, 12 12 } from "@openstatus/db/src/schema"; 13 - import { 14 - insertNotificationSchema, 15 - notificationProvider, 16 - notificationProviderSchema, 17 - } from "@openstatus/db/src/schema"; 13 + import { insertNotificationSchema } from "@openstatus/db/src/schema"; 18 14 import { sendTestDiscordMessage } from "@openstatus/notification-discord"; 19 15 import { sendTestSlackMessage } from "@openstatus/notification-slack"; 20 16 import { ··· 27 23 FormLabel, 28 24 FormMessage, 29 25 Input, 30 - Select, 31 - SelectContent, 32 - SelectItem, 33 - SelectTrigger, 34 - SelectValue, 35 26 } from "@openstatus/ui"; 36 27 37 28 import { LoadingAnimation } from "@/components/loading-animation"; ··· 85 76 sendTest: null, 86 77 plans: ["pro", "team"], 87 78 }; 79 + case "pagerduty": 80 + return { 81 + dataType: null, 82 + placeholder: "", 83 + setupDocLink: 84 + "https://docs.openstatus.dev/synthetic/features/notification/pagerduty", 85 + sendTest: null, 86 + plans: ["starter", "pro", "team"], 87 + }; 88 88 89 89 default: 90 90 return { ··· 102 102 onSubmit?: () => void; 103 103 workspacePlan: WorkspacePlan; 104 104 nextUrl?: string; 105 + provider: NotificationProvider; 106 + callbackData?: string; 105 107 } 106 108 107 109 export function NotificationForm({ ··· 109 111 onSubmit: onExternalSubmit, 110 112 workspacePlan, 111 113 nextUrl, 114 + provider, 115 + callbackData, 112 116 }: Props) { 113 117 const [isPending, startTransition] = useTransition(); 114 118 const [isTestPending, startTestTransition] = useTransition(); ··· 117 121 resolver: zodResolver(insertNotificationSchema), 118 122 defaultValues: { 119 123 ...defaultValues, 124 + provider, 120 125 name: defaultValues?.name || "", 121 126 data: getDefaultProviderData(defaultValues), 122 127 }, 123 128 }); 124 - const watchProvider = form.watch("provider"); 125 129 const watchWebhookUrl = form.watch("data"); 126 - const providerMetaData = useMemo( 127 - () => getProviderMetaData(watchProvider), 128 - [watchProvider], 129 - ); 130 + const providerMetaData = getProviderMetaData(provider); 130 131 131 132 async function onSubmit({ provider, data, ...rest }: InsertNotification) { 132 133 startTransition(async () => { 133 134 try { 135 + if (provider === "pagerduty") { 136 + if (callbackData) { 137 + data = callbackData; 138 + } 139 + } 134 140 if (data === "") { 135 141 form.setError("data", { message: "This field is required" }); 136 142 return; ··· 191 197 <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 192 198 <FormField 193 199 control={form.control} 194 - name="provider" 195 - render={({ field }) => ( 196 - <FormItem className="sm:col-span-1 sm:self-baseline"> 197 - <FormLabel>Provider</FormLabel> 198 - <Select 199 - onValueChange={(value) => 200 - field.onChange(notificationProviderSchema.parse(value)) 201 - } 202 - defaultValue={field.value} 203 - > 204 - <FormControl> 205 - <SelectTrigger className="capitalize"> 206 - <SelectValue placeholder="Select Provider" /> 207 - </SelectTrigger> 208 - </FormControl> 209 - <SelectContent> 210 - {notificationProvider.map((provider) => { 211 - const isIncluded = 212 - getProviderMetaData(provider).plans?.includes( 213 - workspacePlan, 214 - ); 215 - return ( 216 - <SelectItem 217 - key={provider} 218 - value={provider} 219 - className="capitalize" 220 - disabled={!isIncluded} 221 - > 222 - {provider} 223 - </SelectItem> 224 - ); 225 - })} 226 - </SelectContent> 227 - </Select> 228 - <FormDescription> 229 - What channel/provider to send a notification. 230 - </FormDescription> 231 - <FormMessage /> 232 - </FormItem> 233 - )} 234 - /> 235 - <FormField 236 - control={form.control} 237 200 name="name" 238 201 render={({ field }) => ( 239 202 <FormItem className="sm:col-span-1 sm:self-baseline"> ··· 248 211 </FormItem> 249 212 )} 250 213 /> 251 - {watchProvider && ( 214 + 215 + {providerMetaData.dataType && ( 252 216 <FormField 253 217 control={form.control} 254 218 name="data" ··· 256 220 <FormItem className="sm:col-span-full"> 257 221 {/* make the first letter capital */} 258 222 <div className="flex items-center justify-between"> 259 - <FormLabel>{toCapitalize(watchProvider)}</FormLabel> 223 + <FormLabel>{toCapitalize(provider)}</FormLabel> 260 224 </div> 261 225 <FormControl> 262 226 <Input ··· 277 241 className="underline hover:no-underline" 278 242 rel="noreferrer" 279 243 > 280 - How to setup your {toCapitalize(watchProvider)}{" "} 281 - webhook 244 + How to setup your {toCapitalize(provider)} webhook 282 245 </a> 283 246 )} 284 247 </FormDescription>
+8
apps/web/src/content/changelog/pagerduty-integration.mdx
··· 1 + --- 2 + title: PagerDuty Integration 3 + description: Get notified via PagerDuty if we detect an incident. 4 + image: /assets/changelog/pagerduty-integration.png 5 + publishedAt: 2024-06-25 6 + --- 7 + 8 + We've added a PagerDuty integration to the notification feature. This allows you to receive incident alerts through PagerDuty.
+2
apps/web/src/env.ts
··· 24 24 CLICKHOUSE_USERNAME: z.string(), 25 25 CLICKHOUSE_PASSWORD: z.string(), 26 26 PLAIN_API_KEY: z.string().optional(), 27 + PAGERDUTY_APP_ID: z.string().optional(), 27 28 }, 28 29 client: { 29 30 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), ··· 57 58 CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME, 58 59 CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, 59 60 PLAIN_API_KEY: process.env.PLAIN_API_KEY, 61 + PAGERDUTY_APP_ID: process.env.PAGERDUTY_APP_ID, 60 62 }, 61 63 skipValidation: true, 62 64 });
+4 -1
packages/analytics/src/index.ts
··· 11 11 }) 12 12 : emptyAnalytics; 13 13 14 - export const trackAnalytics = (args: AnalyticsEvents) => analytics.track(args); 14 + export const trackAnalytics = (args: AnalyticsEvents) => 15 + env.JITSU_HOST && env.JITSU_WRITE_KEY 16 + ? analytics.track(args) 17 + : emptyAnalytics.track(args);
+4
packages/api/src/env.ts
··· 8 8 TEAM_ID_VERCEL: z.string(), 9 9 VERCEL_AUTH_BEARER_TOKEN: z.string(), 10 10 TINY_BIRD_API_KEY: z.string(), 11 + JITSU_HOST: z.string().optional(), 12 + JITSU_WRITE_KEY: z.string().optional(), 11 13 }, 12 14 13 15 runtimeEnv: { ··· 16 18 TEAM_ID_VERCEL: process.env.TEAM_ID_VERCEL, 17 19 VERCEL_AUTH_BEARER_TOKEN: process.env.VERCEL_AUTH_BEARER_TOKEN, 18 20 TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, 21 + JITSU_HOST: process.env.JITSU_HOST, 22 + JITSU_WRITE_KEY: process.env.JITSU_WRITE_KEY, 19 23 }, 20 24 skipValidation: process.env.NODE_ENV === "test", 21 25 });
-1
packages/api/src/router/monitor.ts
··· 19 19 notification, 20 20 notificationsToMonitors, 21 21 page, 22 - selectMaintenanceSchema, 23 22 selectMonitorSchema, 24 23 selectMonitorTagSchema, 25 24 selectNotificationSchema,
+14 -12
packages/api/src/router/notification.ts
··· 13 13 import { SchemaError } from "@openstatus/error"; 14 14 import { trackNewNotification } from "../analytics"; 15 15 import { createTRPCRouter, protectedProcedure } from "../trpc"; 16 + import { env } from "../env"; 16 17 17 18 export const notificationRouter = createTRPCRouter({ 18 19 create: protectedProcedure ··· 22 23 23 24 const notificationLimit = getLimit( 24 25 opts.ctx.workspace.plan, 25 - "notification-channels", 26 + "notification-channels" 26 27 ); 27 28 28 29 const notificationNumber = ( ··· 40 41 } 41 42 42 43 const _data = NotificationDataSchema.safeParse(JSON.parse(props.data)); 43 - 44 44 if (!_data.success) { 45 45 throw new TRPCError({ 46 46 code: "BAD_REQUEST", ··· 54 54 .returning() 55 55 .get(); 56 56 57 - await trackNewNotification(opts.ctx.user, { 58 - provider: _notification.provider, 59 - }); 57 + if (env.JITSU_HOST !== undefined && env.JITSU_WRITE_KEY !== undefined) { 58 + await trackNewNotification(opts.ctx.user, { 59 + provider: _notification.provider, 60 + }); 61 + } 60 62 61 63 return _notification; 62 64 }), ··· 83 85 .where( 84 86 and( 85 87 eq(notification.id, opts.input.id), 86 - eq(notification.workspaceId, opts.ctx.workspace.id), 87 - ), 88 + eq(notification.workspaceId, opts.ctx.workspace.id) 89 + ) 88 90 ) 89 91 .returning() 90 92 .get(); ··· 98 100 .where( 99 101 and( 100 102 eq(notification.id, opts.input.id), 101 - eq(notification.id, opts.input.id), 102 - ), 103 + eq(notification.id, opts.input.id) 104 + ) 103 105 ) 104 106 .run(); 105 107 }), ··· 114 116 and( 115 117 eq(notification.id, opts.input.id), 116 118 eq(notification.id, opts.input.id), 117 - eq(notification.workspaceId, opts.ctx.workspace.id), 118 - ), 119 + eq(notification.workspaceId, opts.ctx.workspace.id) 120 + ) 119 121 ) 120 122 .get(); 121 123 ··· 135 137 isNotificationLimitReached: protectedProcedure.query(async (opts) => { 136 138 const notificationLimit = getLimit( 137 139 opts.ctx.workspace.plan, 138 - "notification-channels", 140 + "notification-channels" 139 141 ); 140 142 const notificationNumbers = ( 141 143 await opts.ctx.db.query.notification.findMany({
+1
packages/db/src/schema/notifications/constants.ts
··· 3 3 "discord", 4 4 "slack", 5 5 "sms", 6 + "pagerduty", 6 7 ] as const;
+6 -3
packages/db/src/schema/notifications/validation.ts
··· 13 13 return String(val); 14 14 }, z.string()) 15 15 .default("{}"), 16 - }, 16 + } 17 17 ); 18 18 19 19 // we need to extend, otherwise data can be `null` or `undefined` - default is not 20 20 export const insertNotificationSchema = createInsertSchema(notification).extend( 21 21 { 22 22 data: z.string().default("{}"), 23 - }, 23 + } 24 24 ); 25 25 26 26 export type InsertNotification = z.infer<typeof insertNotificationSchema>; ··· 28 28 export type NotificationProvider = z.infer<typeof notificationProviderSchema>; 29 29 30 30 const phoneRegex = new RegExp( 31 - /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/, 31 + /^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/ 32 32 ); 33 33 34 34 export const phoneSchema = z.string().regex(phoneRegex, "Invalid Number!"); ··· 40 40 z.object({ email: emailSchema }), 41 41 z.object({ slack: urlSchema }), 42 42 z.object({ discord: urlSchema }), 43 + z.object({ 44 + pagerduty: z.string(), 45 + }), 43 46 ]);
+8 -3
packages/notifications/discord/src/index.ts
··· 20 20 notification, 21 21 statusCode, 22 22 message, 23 + incidentId, 23 24 }: { 24 25 monitor: Monitor; 25 26 notification: Notification; 26 27 statusCode?: number; 27 28 message?: string; 29 + incidentId?: string; 28 30 }) => { 29 31 const notificationData = JSON.parse(notification.data); 30 32 const { discord: webhookUrl } = notificationData; // webhook url ··· 37 39 Your monitor with url ${monitor.url} is down with ${ 38 40 statusCode ? `status code ${statusCode}` : `error message ${message}` 39 41 }.`, 40 - webhookUrl, 42 + webhookUrl 41 43 ); 42 44 } catch (err) { 43 45 console.error(err); ··· 52 54 statusCode, 53 55 // biome-ignore lint/correctness/noUnusedVariables: <explanation> 54 56 message, 57 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 58 + incidentId, 55 59 }: { 56 60 monitor: Monitor; 57 61 notification: Notification; 58 62 statusCode?: number; 59 63 message?: string; 64 + incidentId?: string; 60 65 }) => { 61 66 const notificationData = JSON.parse(notification.data); 62 67 const { discord: webhookUrl } = notificationData; // webhook url ··· 65 70 try { 66 71 await postToWebhook( 67 72 `Your monitor ${name}|${monitor.url} is up again ๐ŸŽ‰`, 68 - webhookUrl, 73 + webhookUrl 69 74 ); 70 75 } catch (err) { 71 76 console.error(err); ··· 80 85 try { 81 86 await postToWebhook( 82 87 "This is a test notification from OpenStatus. \nIf you see this, it means that your webhook is working! ๐ŸŽ‰", 83 - webhookUrl, 88 + webhookUrl 84 89 ); 85 90 return true; 86 91 } catch (_err) {
+6
packages/notifications/email/src/index.ts
··· 8 8 notification, 9 9 statusCode, 10 10 message, 11 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 + incidentId, 11 13 }: { 12 14 monitor: Monitor; 13 15 notification: Notification; 14 16 statusCode?: number; 15 17 message?: string; 18 + incidentId?: string; 16 19 }) => { 17 20 // FIXME: 18 21 const config = EmailConfigurationSchema.parse(JSON.parse(notification.data)); ··· 56 59 statusCode, 57 60 // biome-ignore lint/correctness/noUnusedVariables: <explanation> 58 61 message, 62 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 63 + incidentId, 59 64 }: { 60 65 monitor: Monitor; 61 66 notification: Notification; 62 67 statusCode?: number; 63 68 message?: string; 69 + incidentId?: string; 64 70 }) => { 65 71 // FIXME: 66 72 const config = EmailConfigurationSchema.parse(JSON.parse(notification.data));
+1
packages/notifications/pagerduty/.env.example
··· 1 + PAGERDUTY_APP_ID=your_auth_token
+19
packages/notifications/pagerduty/package.json
··· 1 + { 2 + "name": "@openstatus/notification-pagerduty", 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.11.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 + }
+29
packages/notifications/pagerduty/src/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + PAGERDUTY_APP_ID: z.string(), 7 + }, 8 + 9 + /** 10 + * What object holds the environment variables at runtime. This is usually 11 + * `process.env` or `import.meta.env`. 12 + */ 13 + runtimeEnv: process.env, 14 + 15 + /** 16 + * By default, this library will feed the environment variables directly to 17 + * the Zod validator. 18 + * 19 + * This means that if you have an empty string for a value that is supposed 20 + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag 21 + * it as a type mismatch violation. Additionally, if you have an empty string 22 + * for a value that is supposed to be a string with a default value (e.g. 23 + * `DOMAIN=` in an ".env" file), the default value will never be applied. 24 + * 25 + * In order to solve these issues, we recommend that all new projects 26 + * explicitly specify this option as true. 27 + */ 28 + skipValidation: true, 29 + });
+92
packages/notifications/pagerduty/src/index.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + 3 + import { 4 + PagerDutySchema, 5 + resolveEventPayloadSchema, 6 + triggerEventPayloadSchema, 7 + } from "./schema/config"; 8 + 9 + export const sendAlert = async ({ 10 + monitor, 11 + notification, 12 + statusCode, 13 + message, 14 + incidentId, 15 + }: { 16 + monitor: Monitor; 17 + notification: Notification; 18 + statusCode?: number; 19 + message?: string; 20 + incidentId?: string; 21 + }) => { 22 + const notificationData = PagerDutySchema.parse(JSON.parse(notification.data)); 23 + const { name } = monitor; 24 + 25 + const event = triggerEventPayloadSchema.parse({ 26 + rounting_key: notificationData.integration_keys[0].integration_key, 27 + dedup_key: `${monitor.id}}-${incidentId}`, 28 + event_action: "trigger", 29 + payload: { 30 + summary: `${name} is down`, 31 + source: "Open Status", 32 + severity: "error", 33 + timestamp: new Date().toISOString(), 34 + custom_details: { 35 + statusCode, 36 + message, 37 + }, 38 + }, 39 + }); 40 + 41 + try { 42 + for await (const integrationKey of notificationData.integration_keys) { 43 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 44 + const { integration_key, type } = integrationKey; 45 + 46 + await fetch("https://events.pagerduty.com/v2/enqueue", { 47 + method: "POST", 48 + body: JSON.stringify(event), 49 + }); 50 + } 51 + } catch (err) { 52 + console.log(err); 53 + // Do something 54 + } 55 + }; 56 + 57 + export const sendRecovery = async ({ 58 + monitor, 59 + notification, 60 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 61 + statusCode, 62 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 63 + message, 64 + incidentId, 65 + }: { 66 + monitor: Monitor; 67 + notification: Notification; 68 + statusCode?: number; 69 + message?: string; 70 + incidentId?: string; 71 + }) => { 72 + const notificationData = PagerDutySchema.parse(JSON.parse(notification.data)); 73 + 74 + try { 75 + for await (const integrationKey of notificationData.integration_keys) { 76 + const event = resolveEventPayloadSchema.parse({ 77 + rounting_key: integrationKey.integration_key, 78 + dedup_key: `${monitor.id}}-${incidentId}`, 79 + event_action: "resolve", 80 + }); 81 + await fetch("https://events.pagerduty.com/v2/enqueue", { 82 + method: "POST", 83 + body: JSON.stringify(event), 84 + }); 85 + } 86 + } catch (err) { 87 + console.log(err); 88 + // Do something 89 + } 90 + }; 91 + 92 + export { PagerDutySchema };
+78
packages/notifications/pagerduty/src/schema/config.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const PagerDutySchema = z.object({ 4 + integration_keys: z.array( 5 + z.object({ 6 + integration_key: z.string(), 7 + name: z.string(), 8 + id: z.string(), 9 + type: z.string(), 10 + }) 11 + ), 12 + account: z.object({ subdomain: z.string(), name: z.string() }), 13 + }); 14 + 15 + export const actionSchema = z.union([ 16 + z.literal("trigger"), 17 + z.literal("acknowledge"), 18 + z.literal("resolve"), 19 + ]); 20 + 21 + export const severitySchema = z.union([ 22 + z.literal("critical"), 23 + z.literal("error"), 24 + z.literal("warning"), 25 + z.literal("info"), 26 + ]); 27 + 28 + export const imageSchema = z.object({ 29 + src: z.string(), 30 + href: z.string().optional(), 31 + alt: z.string().optional(), 32 + }); 33 + 34 + export const linkSchema = z.object({ 35 + href: z.string(), 36 + text: z.string().optional(), 37 + }); 38 + 39 + const baseEventPayloadSchema = z.object({ 40 + rounting_key: z.string(), 41 + dedup_key: z.string(), 42 + }); 43 + 44 + export const triggerEventPayloadSchema = baseEventPayloadSchema.merge( 45 + z.object({ 46 + event_action: z.literal("trigger"), 47 + payload: z.object({ 48 + summary: z.string(), 49 + source: z.string(), 50 + severity: severitySchema, 51 + timestamp: z.string().optional(), 52 + component: z.string().optional(), 53 + group: z.string().optional(), 54 + class: z.string().optional(), 55 + custom_details: z.any().optional(), 56 + }), 57 + images: z.array(imageSchema).optional(), 58 + links: z.array(linkSchema).optional(), 59 + }) 60 + ); 61 + 62 + export const acknowledgeEventPayloadSchema = baseEventPayloadSchema.merge( 63 + z.object({ 64 + event_action: z.literal("acknowledge"), 65 + }) 66 + ); 67 + 68 + export const resolveEventPayloadSchema = baseEventPayloadSchema.merge( 69 + z.object({ 70 + event_action: z.literal("resolve"), 71 + }) 72 + ); 73 + 74 + export const eventPayloadV2Schema = z.discriminatedUnion("event_action", [ 75 + triggerEventPayloadSchema, 76 + acknowledgeEventPayloadSchema, 77 + resolveEventPayloadSchema, 78 + ]);
+4
packages/notifications/pagerduty/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+9 -3
packages/notifications/slack/src/index.ts
··· 18 18 notification, 19 19 statusCode, 20 20 message, 21 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 22 + incidentId, 21 23 }: { 22 24 monitor: Monitor; 23 25 notification: Notification; 24 26 statusCode?: number; 25 27 message?: string; 28 + incidentId?: string; 26 29 }) => { 27 30 const notificationData = JSON.parse(notification.data); 28 31 const { slack: webhookUrl } = notificationData; // webhook url ··· 55 58 }, 56 59 ], 57 60 }, 58 - webhookUrl, 61 + webhookUrl 59 62 ); 60 63 } catch (err) { 61 64 console.log(err); ··· 70 73 statusCode, 71 74 // biome-ignore lint/correctness/noUnusedVariables: <explanation> 72 75 message, 76 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 77 + incidentId, 73 78 }: { 74 79 monitor: Monitor; 75 80 notification: Notification; 76 81 statusCode?: number; 77 82 message?: string; 83 + incidentId?: string; 78 84 }) => { 79 85 const notificationData = JSON.parse(notification.data); 80 86 const { slack: webhookUrl } = notificationData; // webhook url ··· 104 110 }, 105 111 ], 106 112 }, 107 - webhookUrl, 113 + webhookUrl 108 114 ); 109 115 } catch (err) { 110 116 console.log(err); ··· 126 132 }, 127 133 ], 128 134 }, 129 - webhookUrl, 135 + webhookUrl 130 136 ); 131 137 return true; 132 138 } catch (_err) {
+13 -7
packages/notifications/twillio-sms/src/index.ts
··· 8 8 notification, 9 9 statusCode, 10 10 message, 11 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 + incidentId, 11 13 }: { 12 14 monitor: Monitor; 13 15 notification: Notification; 14 16 statusCode?: number; 15 17 message?: string; 18 + incidentId?: string; 16 19 }) => { 17 20 const notificationData = SmsConfigurationSchema.parse( 18 - JSON.parse(notification.data), 21 + JSON.parse(notification.data) 19 22 ); 20 23 const { name } = monitor; 21 24 ··· 26 29 "Body", 27 30 `Your monitor ${name} / ${monitor.url} is down with ${ 28 31 statusCode ? `status code ${statusCode}` : `error: ${message}` 29 - }`, 32 + }` 30 33 ); 31 34 32 35 try { ··· 37 40 body, 38 41 headers: { 39 42 Authorization: `Basic ${btoa( 40 - `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}`, 43 + `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}` 41 44 )}`, 42 45 }, 43 - }, 46 + } 44 47 ); 45 48 } catch (err) { 46 49 console.log(err); ··· 55 58 statusCode, 56 59 // biome-ignore lint/correctness/noUnusedVariables: <explanation> 57 60 message, 61 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 62 + incidentId, 58 63 }: { 59 64 monitor: Monitor; 60 65 notification: Notification; 61 66 statusCode?: number; 62 67 message?: string; 68 + incidentId?: string; 63 69 }) => { 64 70 const notificationData = SmsConfigurationSchema.parse( 65 - JSON.parse(notification.data), 71 + JSON.parse(notification.data) 66 72 ); 67 73 const { name } = monitor; 68 74 ··· 79 85 body, 80 86 headers: { 81 87 Authorization: `Basic ${btoa( 82 - `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}`, 88 + `${env.TWILLIO_ACCOUNT_ID}:${env.TWILLIO_AUTH_TOKEN}` 83 89 )}`, 84 90 }, 85 - }, 91 + } 86 92 ); 87 93 } catch (err) { 88 94 console.log(err);
+4
packages/plans/src/config.ts
··· 30 30 "white-label": false, 31 31 notifications: true, 32 32 sms: false, 33 + pagerduty: false, 33 34 "notification-channels": 1, 34 35 members: 1, 35 36 "audit-log": false, ··· 53 54 "password-protection": true, 54 55 "white-label": false, 55 56 notifications: true, 57 + pagerduty: true, 56 58 sms: true, 57 59 "notification-channels": 10, 58 60 members: "Unlimited", ··· 114 116 "white-label": false, 115 117 notifications: true, 116 118 sms: true, 119 + pagerduty: true, 117 120 "notification-channels": 20, 118 121 members: "Unlimited", 119 122 "audit-log": true, ··· 174 177 "white-label": true, 175 178 notifications: true, 176 179 sms: true, 180 + pagerduty: true, 177 181 "notification-channels": 50, 178 182 members: "Unlimited", 179 183 "audit-log": true,
+1
packages/plans/src/types.ts
··· 19 19 "white-label": boolean; 20 20 // alerts 21 21 notifications: boolean; 22 + pagerduty: boolean; 22 23 sms: boolean; 23 24 "notification-channels": number; 24 25 // collaboration
+74
pnpm-lock.yaml
··· 169 169 '@openstatus/notification-emails': 170 170 specifier: workspace:* 171 171 version: link:../../packages/notifications/email 172 + '@openstatus/notification-pagerduty': 173 + specifier: workspace:* 174 + version: link:../../packages/notifications/pagerduty 172 175 '@openstatus/notification-slack': 173 176 specifier: workspace:* 174 177 version: link:../../packages/notifications/slack ··· 278 281 '@openstatus/notification-emails': 279 282 specifier: workspace:* 280 283 version: link:../../packages/notifications/email 284 + '@openstatus/notification-pagerduty': 285 + specifier: workspace:* 286 + version: link:../../packages/notifications/pagerduty 281 287 '@openstatus/notification-slack': 282 288 specifier: workspace:* 283 289 version: link:../../packages/notifications/slack ··· 806 812 specifier: 5.5.2 807 813 version: 5.5.2 808 814 815 + packages/notifications/pagerduty: 816 + dependencies: 817 + '@openstatus/db': 818 + specifier: workspace:* 819 + version: link:../../db 820 + '@t3-oss/env-core': 821 + specifier: 0.7.1 822 + version: 0.7.1(typescript@5.4.5)(zod@3.22.4) 823 + '@types/validator': 824 + specifier: 13.11.6 825 + version: 13.11.6 826 + validator: 827 + specifier: 13.11.0 828 + version: 13.11.0 829 + zod: 830 + specifier: 3.22.4 831 + version: 3.22.4 832 + devDependencies: 833 + '@openstatus/tsconfig': 834 + specifier: workspace:* 835 + version: link:../../tsconfig 836 + '@types/node': 837 + specifier: 20.8.0 838 + version: 20.8.0 839 + '@types/react': 840 + specifier: 18.2.64 841 + version: 18.2.64 842 + '@types/react-dom': 843 + specifier: 18.2.21 844 + version: 18.2.21 845 + typescript: 846 + specifier: 5.4.5 847 + version: 5.4.5 848 + 809 849 packages/notifications/slack: 810 850 dependencies: 811 851 '@openstatus/db': ··· 4105 4145 '@types/prop-types@15.7.12': 4106 4146 resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} 4107 4147 4148 + '@types/react-dom@18.2.21': 4149 + resolution: {integrity: sha512-gnvBA/21SA4xxqNXEwNiVcP0xSGHh/gi1VhWv9Bl46a0ItbTT5nFY+G9VSQpaG/8N/qdJpJ+vftQ4zflTtnjLw==} 4150 + 4108 4151 '@types/react-dom@18.3.0': 4109 4152 resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} 4153 + 4154 + '@types/react@18.2.64': 4155 + resolution: {integrity: sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==} 4110 4156 4111 4157 '@types/react@18.3.3': 4112 4158 resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} ··· 4120 4166 '@types/rss@0.0.32': 4121 4167 resolution: {integrity: sha512-2oKNqKyUY4RSdvl5eZR1n2Q9yvw3XTe3mQHsFPn9alaNBxfPnbXBtGP8R0SV8pK1PrVnLul0zx7izbm5/gF5Qw==} 4122 4168 4169 + '@types/scheduler@0.23.0': 4170 + resolution: {integrity: sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==} 4171 + 4123 4172 '@types/through@0.0.32': 4124 4173 resolution: {integrity: sha512-7XsfXIsjdfJM2wFDRAtEWp3zb2aVPk5QeyZxGlVK57q4u26DczMHhJmlhr0Jqv0THwxam/L8REXkj8M2I/lcvw==} 4125 4174 ··· 7806 7855 engines: {node: '>=14.17'} 7807 7856 hasBin: true 7808 7857 7858 + typescript@5.4.5: 7859 + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} 7860 + engines: {node: '>=14.17'} 7861 + hasBin: true 7862 + 7809 7863 typescript@5.5.2: 7810 7864 resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} 7811 7865 engines: {node: '>=14.17'} ··· 11371 11425 optionalDependencies: 11372 11426 typescript: 5.4.4 11373 11427 11428 + '@t3-oss/env-core@0.7.1(typescript@5.4.5)(zod@3.22.4)': 11429 + dependencies: 11430 + zod: 3.22.4 11431 + optionalDependencies: 11432 + typescript: 5.4.5 11433 + 11374 11434 '@t3-oss/env-core@0.7.1(typescript@5.5.2)(zod@3.23.8)': 11375 11435 dependencies: 11376 11436 zod: 3.23.8 ··· 11613 11673 11614 11674 '@types/prop-types@15.7.12': {} 11615 11675 11676 + '@types/react-dom@18.2.21': 11677 + dependencies: 11678 + '@types/react': 18.3.3 11679 + 11616 11680 '@types/react-dom@18.3.0': 11617 11681 dependencies: 11618 11682 '@types/react': 18.3.3 11683 + 11684 + '@types/react@18.2.64': 11685 + dependencies: 11686 + '@types/prop-types': 15.7.12 11687 + '@types/scheduler': 0.23.0 11688 + csstype: 3.1.3 11619 11689 11620 11690 '@types/react@18.3.3': 11621 11691 dependencies: ··· 11632 11702 '@types/resolve@1.20.4': {} 11633 11703 11634 11704 '@types/rss@0.0.32': {} 11705 + 11706 + '@types/scheduler@0.23.0': {} 11635 11707 11636 11708 '@types/through@0.0.32': 11637 11709 dependencies: ··· 16022 16094 type@2.7.2: {} 16023 16095 16024 16096 typescript@5.4.4: {} 16097 + 16098 + typescript@5.4.5: {} 16025 16099 16026 16100 typescript@5.5.2: {} 16027 16101