Openstatus www.openstatus.dev

๐Ÿšจ Alerting (#326)

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿšง

* ๐Ÿš€build

* ๐Ÿšง

* ๐Ÿš€ email alert

* ๐Ÿง‘โ€๐ŸŽจ

* ๐Ÿš€

* feat: improve form and ux

* feat: new form

* ๐Ÿš€

* ๐Ÿซฅ data in table

---------

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

authored by

Thibault Le Ouay
mxkaske
and committed by
GitHub
9ec01396 c26cf5db

+3507 -990
+3 -2
apps/web/package.json
··· 17 17 "@openstatus/db": "workspace:*", 18 18 "@openstatus/emails": "workspace:*", 19 19 "@openstatus/plans": "workspace:*", 20 + "@openstatus/notification-emails": "workspace:*", 20 21 "@openstatus/tinybird": "workspace:*", 22 + "@openstatus/ui": "workspace:*", 21 23 "@openstatus/upstash": "workspace:*", 22 - "@openstatus/ui": "workspace:*", 23 24 "@openstatus/vercel": "workspace:*", 24 25 "@sentry/integrations": "7.65.0", 25 26 "@sentry/nextjs": "7.65.0", ··· 58 59 "rehype-react": "7.2.0", 59 60 "remark-parse": "10.0.2", 60 61 "remark-rehype": "10.1.0", 61 - "resend": "0.15.3", 62 + "resend": "1.1.0", 62 63 "shiki": "0.14.3", 63 64 "stripe": "12.17.0", 64 65 "superjson": "1.13.1",
+3
apps/web/src/app/api/checker/cron/_cron.ts
··· 57 57 headers: row.headers, 58 58 body: row.body, 59 59 cronTimestamp: timestamp, 60 + status: row.status, 60 61 pageIds: allPages.map((p) => String(p.pageId)), 61 62 }; 62 63 ··· 79 80 body: row.body, 80 81 headers: row.headers, 81 82 pageIds: allPages.map((p) => String(p.pageId)), 83 + status: row.status, 82 84 }; 83 85 84 86 const result = c.publishJSON({ ··· 100 102 cronTimestamp: timestamp, 101 103 method: "GET", 102 104 pageIds: ["openstatus"], 105 + status: "active", 103 106 }; 104 107 105 108 // TODO: fetch + try - catch + retry once
+55
apps/web/src/app/api/checker/regions/_checker.ts
··· 2 2 import { nanoid } from "nanoid"; 3 3 import type { z } from "zod"; 4 4 5 + import { db, eq, schema } from "@openstatus/db"; 6 + import { selectNotificationSchema } from "@openstatus/db/src/schema"; 5 7 import { 6 8 publishPingResponse, 7 9 tbIngestPingResponse, ··· 11 13 import { env } from "@/env"; 12 14 import type { Payload } from "../schema"; 13 15 import { payloadSchema } from "../schema"; 16 + import { providerToFunction } from "../utils"; 14 17 15 18 export const monitorSchema = tbIngestPingResponse.pick({ 16 19 url: true, ··· 85 88 const endTime = Date.now(); 86 89 const latency = endTime - startTime; 87 90 await monitor(res, result.data, region, latency); 91 + if (res.ok) { 92 + if (result.data?.status === "error") { 93 + await updateMonitorStatus({ 94 + monitorId: result.data.monitorId, 95 + status: "active", 96 + }); 97 + } 98 + } 88 99 } catch (e) { 89 100 console.error(e); 90 101 // if on the third retry we still get an error, we should report it ··· 95 106 region, 96 107 -1, 97 108 ); 109 + if (result.data?.status !== "error") { 110 + await triggerAlerting({ monitorId: result.data.monitorId }); 111 + await updateMonitorStatus({ 112 + monitorId: result.data.monitorId, 113 + status: "error", 114 + }); 115 + } 116 + // Here we do the alerting} 98 117 } 99 118 } 100 119 }; ··· 121 140 122 141 return res; 123 142 }; 143 + 144 + const triggerAlerting = async ({ monitorId }: { monitorId: string }) => { 145 + const notifications = await db 146 + .select() 147 + .from(schema.notificationsToMonitors) 148 + .innerJoin( 149 + schema.notification, 150 + eq(schema.notification.id, schema.notificationsToMonitors.notificationId), 151 + ) 152 + .innerJoin( 153 + schema.monitor, 154 + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), 155 + ) 156 + .where(eq(schema.monitor.id, Number(monitorId))) 157 + .all(); 158 + for (const notif of notifications) { 159 + await providerToFunction[notif.notification.provider]({ 160 + monitor: notif.monitor, 161 + notification: selectNotificationSchema.parse(notif.notification), 162 + }); 163 + } 164 + }; 165 + 166 + const updateMonitorStatus = async ({ 167 + monitorId, 168 + status, 169 + }: { 170 + monitorId: string; 171 + status: z.infer<typeof schema.statusSchema>; 172 + }) => { 173 + await db 174 + .update(schema.monitor) 175 + .set({ status }) 176 + .where(eq(schema.monitor.id, Number(monitorId))) 177 + .run(); 178 + };
+2 -1
apps/web/src/app/api/checker/schema.ts
··· 1 1 import { z } from "zod"; 2 2 3 - import { METHODS } from "@openstatus/db/src/schema"; 3 + import { METHODS, status } from "@openstatus/db/src/schema"; 4 4 5 5 export const payloadSchema = z.object({ 6 6 workspaceId: z.string(), ··· 11 11 url: z.string(), 12 12 cronTimestamp: z.number(), 13 13 pageIds: z.array(z.string()), 14 + status: z.enum(status), 14 15 }); 15 16 16 17 export type Payload = z.infer<typeof payloadSchema>;
+40
apps/web/src/app/api/checker/utils.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import type { 4 + basicMonitorSchema, 5 + providerName, 6 + selectNotificationSchema, 7 + } from "@openstatus/db/src/schema"; 8 + import { send as sendEmail } from "@openstatus/notification-emails"; 9 + 10 + type ProviderName = (typeof providerName)[number]; 11 + 12 + type sendNotificationType = ({ 13 + monitor, 14 + notification, 15 + }: { 16 + monitor: z.infer<typeof basicMonitorSchema>; 17 + notification: z.infer<typeof selectNotificationSchema>; 18 + }) => Promise<void>; 19 + 20 + export const providerToFunction = { 21 + email: sendEmail, 22 + slack: async ({ 23 + monitor, 24 + notification, 25 + }: { 26 + monitor: any; 27 + notification: any; 28 + }) => { 29 + /* TODO: implement */ 30 + }, 31 + discord: async ({ 32 + monitor, 33 + notification, 34 + }: { 35 + monitor: any; 36 + notification: any; 37 + }) => { 38 + /* TODO: implement */ 39 + }, 40 + } satisfies Record<ProviderName, sendNotificationType>;
+22 -5
apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 import * as z from "zod"; 3 3 4 - import { Tabs, TabsContent, TabsList, TabsTrigger } from "@openstatus/ui"; 5 - 6 4 import { Header } from "@/components/dashboard/header"; 7 5 import { MonitorForm } from "@/components/forms/montitor-form"; 8 6 import { api } from "@/trpc/server"; ··· 28 26 } 29 27 30 28 const { id } = search.data; 29 + const { workspaceSlug } = params; 31 30 32 31 const monitor = id && (await api.monitor.getMonitorByID.query({ id })); 33 32 const workspace = await api.workspace.getWorkspace.query({ 34 - slug: params.workspaceSlug, 33 + slug: workspaceSlug, 35 34 }); 36 35 36 + const monitorNotifications = id 37 + ? await api.monitor.getAllNotificationsForMonitor.query({ 38 + id, 39 + }) 40 + : []; 41 + 42 + const notifications = 43 + await api.notification.getNotificationsByWorkspace.query({ 44 + workspaceSlug, 45 + }); 46 + 37 47 return ( 38 48 <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 39 49 <Header ··· 42 52 /> 43 53 <div className="col-span-full"> 44 54 <MonitorForm 45 - workspaceSlug={params.workspaceSlug} 46 - defaultValues={monitor || undefined} 55 + defaultValues={ 56 + monitor 57 + ? { 58 + ...monitor, 59 + notifications: monitorNotifications?.map(({ id }) => id), 60 + } 61 + : undefined 62 + } 47 63 plan={workspace?.plan} 64 + {...{ workspaceSlug, notifications }} 48 65 /> 49 66 </div> 50 67 </div>
+20
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/_components/empty-state.tsx
··· 1 + import Link from "next/link"; 2 + 3 + import { Button } from "@openstatus/ui"; 4 + 5 + import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; 6 + 7 + export function EmptyState() { 8 + return ( 9 + <DefaultEmptyState 10 + icon="bell" 11 + title="No notifications" 12 + description="Create your first notification channel" 13 + action={ 14 + <Button asChild> 15 + <Link href="./notifications/edit">Create</Link> 16 + </Button> 17 + } 18 + /> 19 + ); 20 + }
+19
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/loading.tsx
··· 1 + import { Skeleton } from "@openstatus/ui"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { SkeletonForm } from "@/components/forms/skeleton-form"; 5 + 6 + export default function Loading() { 7 + return ( 8 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 + <div className="col-span-full flex w-full justify-between"> 10 + <Header.Skeleton> 11 + <Skeleton className="h-9 w-20" /> 12 + </Header.Skeleton> 13 + </div> 14 + <div className="col-span-full"> 15 + <SkeletonForm /> 16 + </div> 17 + </div> 18 + ); 19 + }
+51
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx
··· 1 + import { notFound } from "next/navigation"; 2 + import * as z from "zod"; 3 + 4 + import { Header } from "@/components/dashboard/header"; 5 + import { NotificationForm } from "@/components/forms/notification-form"; 6 + import { api } from "@/trpc/server"; 7 + 8 + /** 9 + * allowed URL search params 10 + */ 11 + const searchParamsSchema = z.object({ 12 + id: z.coerce.number().optional(), 13 + }); 14 + 15 + export default async function EditPage({ 16 + params, 17 + searchParams, 18 + }: { 19 + params: { workspaceSlug: string }; 20 + searchParams: { [key: string]: string | string[] | undefined }; 21 + }) { 22 + const search = searchParamsSchema.safeParse(searchParams); 23 + 24 + if (!search.success) { 25 + return notFound(); 26 + } 27 + 28 + const { id } = search.data; 29 + 30 + const notification = 31 + id && (await api.notification.getNotificationById.query({ id })); 32 + 33 + return ( 34 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 35 + <Header 36 + title="Notification" 37 + description={ 38 + notification 39 + ? "Update your notification channel" 40 + : "Create your notification channel" 41 + } 42 + /> 43 + <div className="col-span-full"> 44 + <NotificationForm 45 + workspaceSlug={params.workspaceSlug} 46 + defaultValues={notification || undefined} 47 + /> 48 + </div> 49 + </div> 50 + ); 51 + }
+19
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/loading.tsx
··· 1 + import { Skeleton } from "@openstatus/ui"; 2 + 3 + import { Header } from "@/components/dashboard/header"; 4 + import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 5 + 6 + export default function Loading() { 7 + return ( 8 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 9 + <div className="col-span-full flex w-full justify-between"> 10 + <Header.Skeleton> 11 + <Skeleton className="h-9 w-20" /> 12 + </Header.Skeleton> 13 + </div> 14 + <div className="col-span-full w-full"> 15 + <DataTableSkeleton /> 16 + </div> 17 + </div> 18 + ); 19 + }
+48
apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx
··· 1 + import * as React from "react"; 2 + import Link from "next/link"; 3 + 4 + import { Button } from "@openstatus/ui"; 5 + 6 + import { Header } from "@/components/dashboard/header"; 7 + import { HelpCallout } from "@/components/dashboard/help-callout"; 8 + import { columns } from "@/components/data-table/notification/columns"; 9 + import { DataTable } from "@/components/data-table/notification/data-table"; 10 + import { api } from "@/trpc/server"; 11 + import { EmptyState } from "./_components/empty-state"; 12 + 13 + export default async function MonitorPage({ 14 + params, 15 + }: { 16 + params: { workspaceSlug: string }; 17 + }) { 18 + const notifications = 19 + await api.notification.getNotificationsByWorkspace.query({ 20 + workspaceSlug: params.workspaceSlug, 21 + }); 22 + 23 + return ( 24 + <div className="grid min-h-full grid-cols-1 grid-rows-[auto,1fr,auto] gap-6 md:grid-cols-2 md:gap-8"> 25 + <Header 26 + title="Notifications" 27 + description="Overview of all your notification channels." 28 + actions={ 29 + <Button asChild> 30 + <Link href="./notifications/edit">Create</Link> 31 + </Button> 32 + } 33 + /> 34 + {notifications && notifications.length > 0 ? ( 35 + <div className="col-span-full"> 36 + <DataTable columns={columns} data={notifications} /> 37 + </div> 38 + ) : ( 39 + <div className="col-span-full"> 40 + <EmptyState /> 41 + </div> 42 + )} 43 + <div className="mt-8 md:mt-12"> 44 + <HelpCallout /> 45 + </div> 46 + </div> 47 + ); 48 + }
+40
apps/web/src/components/data-table/notification/columns.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + 5 + import type { Notification } from "@openstatus/db/src/schema"; 6 + import { Badge } from "@openstatus/ui"; 7 + 8 + import { DataTableRowActions } from "./data-table-row-actions"; 9 + 10 + export const columns: ColumnDef<Notification>[] = [ 11 + { 12 + accessorKey: "name", 13 + header: "Name", 14 + }, 15 + { 16 + accessorKey: "provider", 17 + header: "Provider", 18 + cell: ({ row }) => { 19 + return ( 20 + <Badge variant="secondary" className="capitalize"> 21 + {row.getValue("provider")} 22 + </Badge> 23 + ); 24 + }, 25 + }, 26 + // { 27 + // accessorKey: "data", 28 + // header: "Data", 29 + // }, 30 + { 31 + id: "actions", 32 + cell: ({ row }) => { 33 + return ( 34 + <div className="text-right"> 35 + <DataTableRowActions row={row} /> 36 + </div> 37 + ); 38 + }, 39 + }, 40 + ];
+108
apps/web/src/components/data-table/notification/data-table-row-actions.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import Link from "next/link"; 5 + import { useRouter } from "next/navigation"; 6 + import type { Row } from "@tanstack/react-table"; 7 + import { MoreHorizontal } from "lucide-react"; 8 + 9 + import { selectNotificationSchema } from "@openstatus/db/src/schema"; 10 + import { 11 + AlertDialog, 12 + AlertDialogAction, 13 + AlertDialogCancel, 14 + AlertDialogContent, 15 + AlertDialogDescription, 16 + AlertDialogFooter, 17 + AlertDialogHeader, 18 + AlertDialogTitle, 19 + AlertDialogTrigger, 20 + Button, 21 + DropdownMenu, 22 + DropdownMenuContent, 23 + DropdownMenuItem, 24 + DropdownMenuTrigger, 25 + } from "@openstatus/ui"; 26 + 27 + import { LoadingAnimation } from "@/components/loading-animation"; 28 + import { useToastAction } from "@/hooks/use-toast-action"; 29 + import { api } from "@/trpc/client"; 30 + 31 + interface DataTableRowActionsProps<TData> { 32 + row: Row<TData>; 33 + } 34 + 35 + export function DataTableRowActions<TData>({ 36 + row, 37 + }: DataTableRowActionsProps<TData>) { 38 + const notification = selectNotificationSchema.parse(row.original); 39 + const router = useRouter(); 40 + const { toast } = useToastAction(); 41 + const [alertOpen, setAlertOpen] = React.useState(false); 42 + const [isPending, startTransition] = React.useTransition(); 43 + 44 + async function onDelete() { 45 + startTransition(async () => { 46 + console.log({ notification }); 47 + try { 48 + if (!notification.id) return; 49 + await api.notification.deleteNotification.mutate({ 50 + id: notification.id, 51 + }); 52 + toast("deleted"); 53 + router.refresh(); 54 + setAlertOpen(false); 55 + } catch { 56 + toast("error"); 57 + } 58 + }); 59 + } 60 + 61 + return ( 62 + <AlertDialog open={alertOpen} onOpenChange={(value) => setAlertOpen(value)}> 63 + <DropdownMenu> 64 + <DropdownMenuTrigger asChild> 65 + <Button 66 + variant="ghost" 67 + className="data-[state=open]:bg-accent h-8 w-8 p-0" 68 + > 69 + <span className="sr-only">Open menu</span> 70 + <MoreHorizontal className="h-4 w-4" /> 71 + </Button> 72 + </DropdownMenuTrigger> 73 + <DropdownMenuContent align="end"> 74 + <Link href={`./notifications/edit?id=${notification.id}`}> 75 + <DropdownMenuItem>Edit</DropdownMenuItem> 76 + </Link> 77 + <AlertDialogTrigger asChild> 78 + <DropdownMenuItem className="text-destructive focus:bg-destructive focus:text-background"> 79 + Delete 80 + </DropdownMenuItem> 81 + </AlertDialogTrigger> 82 + </DropdownMenuContent> 83 + </DropdownMenu> 84 + <AlertDialogContent> 85 + <AlertDialogHeader> 86 + <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 87 + <AlertDialogDescription> 88 + This action cannot be undone. This will permanently delete the 89 + notification. 90 + </AlertDialogDescription> 91 + </AlertDialogHeader> 92 + <AlertDialogFooter> 93 + <AlertDialogCancel>Cancel</AlertDialogCancel> 94 + <AlertDialogAction 95 + onClick={(e) => { 96 + e.preventDefault(); 97 + onDelete(); 98 + }} 99 + disabled={isPending} 100 + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 101 + > 102 + {!isPending ? "Delete" : <LoadingAnimation />} 103 + </AlertDialogAction> 104 + </AlertDialogFooter> 105 + </AlertDialogContent> 106 + </AlertDialog> 107 + ); 108 + }
+81
apps/web/src/components/data-table/notification/data-table.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import type { ColumnDef } from "@tanstack/react-table"; 5 + import { 6 + flexRender, 7 + getCoreRowModel, 8 + useReactTable, 9 + } from "@tanstack/react-table"; 10 + 11 + import { 12 + Table, 13 + TableBody, 14 + TableCell, 15 + TableHead, 16 + TableHeader, 17 + TableRow, 18 + } from "@openstatus/ui"; 19 + 20 + interface DataTableProps<TData, TValue> { 21 + columns: ColumnDef<TData, TValue>[]; 22 + data: TData[]; 23 + } 24 + 25 + export function DataTable<TData, TValue>({ 26 + columns, 27 + data, 28 + }: DataTableProps<TData, TValue>) { 29 + const table = useReactTable({ 30 + data, 31 + columns, 32 + getCoreRowModel: getCoreRowModel(), 33 + }); 34 + 35 + return ( 36 + <div className="rounded-md border"> 37 + <Table> 38 + <TableHeader className="bg-muted/50"> 39 + {table.getHeaderGroups().map((headerGroup) => ( 40 + <TableRow key={headerGroup.id} className="hover:bg-transparent"> 41 + {headerGroup.headers.map((header) => { 42 + return ( 43 + <TableHead key={header.id}> 44 + {header.isPlaceholder 45 + ? null 46 + : flexRender( 47 + header.column.columnDef.header, 48 + header.getContext(), 49 + )} 50 + </TableHead> 51 + ); 52 + })} 53 + </TableRow> 54 + ))} 55 + </TableHeader> 56 + <TableBody> 57 + {table.getRowModel().rows?.length ? ( 58 + table.getRowModel().rows.map((row) => ( 59 + <TableRow 60 + key={row.id} 61 + data-state={row.getIsSelected() && "selected"} 62 + > 63 + {row.getVisibleCells().map((cell) => ( 64 + <TableCell key={cell.id}> 65 + {flexRender(cell.column.columnDef.cell, cell.getContext())} 66 + </TableCell> 67 + ))} 68 + </TableRow> 69 + )) 70 + ) : ( 71 + <TableRow> 72 + <TableCell colSpan={columns.length} className="h-24 text-center"> 73 + No results. 74 + </TableCell> 75 + </TableRow> 76 + )} 77 + </TableBody> 78 + </Table> 79 + </div> 80 + ); 81 + }
+197 -157
apps/web/src/components/forms/incident-form.tsx
··· 13 13 StatusEnum, 14 14 } from "@openstatus/db/src/schema"; 15 15 import { 16 + Accordion, 17 + AccordionContent, 18 + AccordionItem, 19 + AccordionTrigger, 16 20 Button, 17 21 Checkbox, 18 22 DateTimePicker, ··· 116 120 e.preventDefault(); 117 121 form.handleSubmit(onSubmit)(e); 118 122 }} 119 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 123 + className="grid w-full gap-6" 120 124 > 121 - <FormField 122 - control={form.control} 123 - name="title" 124 - render={({ field }) => ( 125 - <FormItem className="sm:col-span-4"> 126 - <FormLabel>Title</FormLabel> 127 - <FormControl> 128 - <Input placeholder="" {...field} /> 129 - </FormControl> 130 - <FormDescription>The title of your page.</FormDescription> 131 - <FormMessage /> 132 - </FormItem> 133 - )} 134 - /> 135 - <FormField 136 - control={form.control} 137 - name="status" 138 - render={({ field }) => ( 139 - <FormItem className="col-span-full space-y-1"> 140 - <FormLabel>Status</FormLabel> 141 - <FormDescription>Select the current status.</FormDescription> 142 - <FormMessage /> 143 - <RadioGroup 144 - onValueChange={(value) => 145 - field.onChange(StatusEnum.parse(value)) 146 - } // value is a string 147 - defaultValue={field.value} 148 - className="grid grid-cols-2 gap-4 sm:grid-cols-4 sm:gap-8" 149 - > 150 - {availableStatus.map((status) => { 151 - const { value, label, icon } = statusDict[status]; 152 - const Icon = Icons[icon]; 153 - return ( 154 - <FormItem key={value}> 155 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 156 - <FormControl> 157 - <RadioGroupItem value={value} className="sr-only" /> 158 - </FormControl> 159 - <div className="border-border text-muted-foreground flex w-full items-center justify-center rounded-lg border p-2 px-6 py-3 text-center"> 160 - <Icon className="mr-2 h-4 w-4" /> 161 - {label} 162 - </div> 163 - </FormLabel> 164 - </FormItem> 165 - ); 166 - })} 167 - </RadioGroup> 168 - </FormItem> 169 - )} 170 - /> 171 - {/* include update on creation */} 172 - {!defaultValues ? ( 173 - <div className="bg-accent/40 border-border col-span-full -m-3 grid gap-6 rounded-lg border p-3 sm:grid-cols-6"> 125 + <div className="grid gap-4 sm:grid-cols-3"> 126 + <div className="my-1.5 flex flex-col gap-2"> 127 + <p className="text-sm font-semibold leading-none">Inform</p> 128 + <p className="text-muted-foreground text-sm"> 129 + Keep your users informed about what just happened. 130 + </p> 131 + </div> 132 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-3"> 174 133 <FormField 175 134 control={form.control} 176 - name="message" 135 + name="title" 177 136 render={({ field }) => ( 178 137 <FormItem className="sm:col-span-4"> 179 - <FormLabel>Message</FormLabel> 180 - <Tabs defaultValue="write"> 181 - <TabsList> 182 - <TabsTrigger value="write">Write</TabsTrigger> 183 - <TabsTrigger value="preview">Preview</TabsTrigger> 184 - </TabsList> 185 - <TabsContent value="write"> 186 - <FormControl> 187 - <Textarea 188 - placeholder="We are encountering..." 189 - className="h-auto w-full resize-none" 190 - rows={9} 191 - {...field} 192 - /> 193 - </FormControl> 194 - </TabsContent> 195 - <TabsContent value="preview"> 196 - <Preview md={form.getValues("message")} /> 197 - </TabsContent> 198 - </Tabs> 199 - <FormDescription> 200 - Tell your user what&apos;s happening. Supports markdown. 201 - </FormDescription> 138 + <FormLabel>Title</FormLabel> 139 + <FormControl> 140 + <Input placeholder="Downtime..." {...field} /> 141 + </FormControl> 142 + <FormDescription>The title of your outage.</FormDescription> 202 143 <FormMessage /> 203 144 </FormItem> 204 145 )} 205 146 /> 206 147 <FormField 207 148 control={form.control} 208 - name="date" 149 + name="status" 209 150 render={({ field }) => ( 210 - <FormItem className="flex flex-col sm:col-span-full"> 211 - <FormLabel>Date</FormLabel> 212 - <DateTimePicker 213 - date={field.value ? new Date(field.value) : new Date()} 214 - setDate={(date) => { 215 - field.onChange(date); 216 - }} 217 - /> 218 - <FormDescription> 219 - The date and time when the incident took place. 220 - </FormDescription> 151 + <FormItem className="col-span-full space-y-1"> 152 + <FormLabel>Status</FormLabel> 153 + <FormDescription>Select the current status.</FormDescription> 154 + <FormMessage /> 155 + <RadioGroup 156 + onValueChange={(value) => 157 + field.onChange(StatusEnum.parse(value)) 158 + } // value is a string 159 + defaultValue={field.value} 160 + className="grid grid-cols-2 gap-4 sm:grid-cols-4" 161 + > 162 + {availableStatus.map((status) => { 163 + const { value, label, icon } = statusDict[status]; 164 + const Icon = Icons[icon]; 165 + return ( 166 + <FormItem key={value}> 167 + <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 168 + <FormControl> 169 + <RadioGroupItem 170 + value={value} 171 + className="sr-only" 172 + /> 173 + </FormControl> 174 + <div className="border-border text-muted-foreground flex w-full items-center justify-center rounded-lg border px-3 py-2 text-center text-sm"> 175 + <Icon className="mr-2 h-4 w-4 shrink-0" /> 176 + <span className="truncate">{label}</span> 177 + </div> 178 + </FormLabel> 179 + </FormItem> 180 + ); 181 + })} 182 + </RadioGroup> 183 + </FormItem> 184 + )} 185 + /> 186 + <FormField 187 + control={form.control} 188 + name="monitors" 189 + render={() => ( 190 + <FormItem className="sm:col-span-full"> 191 + <div className="mb-4"> 192 + <FormLabel>Monitors</FormLabel> 193 + <FormDescription> 194 + Select the monitors that you want to refer the incident 195 + to. 196 + </FormDescription> 197 + </div> 198 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 199 + {monitors?.map((item) => ( 200 + <FormField 201 + key={item.id} 202 + control={form.control} 203 + name="monitors" 204 + render={({ field }) => { 205 + return ( 206 + <FormItem 207 + key={item.id} 208 + className="flex flex-row items-start space-x-3 space-y-0" 209 + > 210 + <FormControl> 211 + <Checkbox 212 + checked={field.value?.includes(item.id)} 213 + onCheckedChange={(checked) => { 214 + return checked 215 + ? field.onChange([ 216 + ...(field.value || []), 217 + item.id, 218 + ]) 219 + : field.onChange( 220 + field.value?.filter( 221 + (value) => value !== item.id, 222 + ), 223 + ); 224 + }} 225 + /> 226 + </FormControl> 227 + <div className="grid gap-1.5 leading-none"> 228 + <div className="flex items-center gap-2"> 229 + <FormLabel className="font-normal"> 230 + {item.name} 231 + </FormLabel> 232 + <span 233 + className={cn( 234 + "rounded-full p-1", 235 + item.active 236 + ? "bg-green-500" 237 + : "bg-red-500", 238 + )} 239 + ></span> 240 + </div> 241 + <p className="text-muted-foreground truncate text-sm"> 242 + {item.description} 243 + </p> 244 + </div> 245 + </FormItem> 246 + ); 247 + }} 248 + /> 249 + ))} 250 + </div> 221 251 <FormMessage /> 222 252 </FormItem> 223 253 )} 224 254 /> 225 255 </div> 226 - ) : null} 227 - <FormField 228 - control={form.control} 229 - name="monitors" 230 - render={() => ( 231 - <FormItem className="sm:col-span-full"> 232 - <div className="mb-4"> 233 - <FormLabel>Monitors</FormLabel> 234 - <FormDescription> 235 - Select the monitors that you want to refer the incident to. 236 - </FormDescription> 237 - </div> 238 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 239 - {monitors?.map((item) => ( 240 - <FormField 241 - key={item.id} 242 - control={form.control} 243 - name="monitors" 244 - render={({ field }) => { 245 - return ( 246 - <FormItem 247 - key={item.id} 248 - className="flex flex-row items-start space-x-3 space-y-0" 249 - > 250 - <FormControl> 251 - <Checkbox 252 - checked={field.value?.includes(item.id)} 253 - onCheckedChange={(checked) => { 254 - return checked 255 - ? field.onChange([ 256 - ...(field.value || []), 257 - item.id, 258 - ]) 259 - : field.onChange( 260 - field.value?.filter( 261 - (value) => value !== item.id, 262 - ), 263 - ); 264 - }} 265 - /> 266 - </FormControl> 267 - <div className="grid gap-1.5 leading-none"> 268 - <div className="flex items-center gap-2"> 269 - <FormLabel className="font-normal"> 270 - {item.name} 271 - </FormLabel> 272 - <span 273 - className={cn( 274 - "rounded-full p-1", 275 - item.active ? "bg-green-500" : "bg-red-500", 276 - )} 277 - ></span> 278 - </div> 279 - <p className="text-muted-foreground truncate text-sm"> 280 - {item.description} 281 - </p> 282 - </div> 256 + </div> 257 + {/* include update on creation */} 258 + {!defaultValues ? ( 259 + <Accordion type="single" defaultValue="message" collapsible> 260 + <AccordionItem value="message"> 261 + <AccordionTrigger>Message</AccordionTrigger> 262 + <AccordionContent> 263 + <div className="grid gap-4 sm:grid-cols-3"> 264 + <div className="my-1.5 flex flex-col gap-2"> 265 + <p className="text-sm font-semibold leading-none"> 266 + Incident Update 267 + </p> 268 + <p className="text-muted-foreground text-sm"> 269 + What is actually going wrong? 270 + </p> 271 + </div> 272 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 273 + <FormField 274 + control={form.control} 275 + name="message" 276 + render={({ field }) => ( 277 + <FormItem className="sm:col-span-4"> 278 + <FormLabel>Message</FormLabel> 279 + <Tabs defaultValue="write"> 280 + <TabsList> 281 + <TabsTrigger value="write">Write</TabsTrigger> 282 + <TabsTrigger value="preview">Preview</TabsTrigger> 283 + </TabsList> 284 + <TabsContent value="write"> 285 + <FormControl> 286 + <Textarea 287 + placeholder="We are encountering..." 288 + className="h-auto w-full resize-none" 289 + rows={9} 290 + {...field} 291 + /> 292 + </FormControl> 293 + </TabsContent> 294 + <TabsContent value="preview"> 295 + <Preview md={form.getValues("message")} /> 296 + </TabsContent> 297 + </Tabs> 298 + <FormDescription> 299 + Tell your user what&apos;s happening. Supports 300 + markdown. 301 + </FormDescription> 302 + <FormMessage /> 303 + </FormItem> 304 + )} 305 + /> 306 + <FormField 307 + control={form.control} 308 + name="date" 309 + render={({ field }) => ( 310 + <FormItem className="flex flex-col sm:col-span-full"> 311 + <FormLabel>Date</FormLabel> 312 + <DateTimePicker 313 + date={ 314 + field.value ? new Date(field.value) : new Date() 315 + } 316 + setDate={(date) => { 317 + field.onChange(date); 318 + }} 319 + /> 320 + <FormDescription> 321 + The date and time when the incident took place. 322 + </FormDescription> 323 + <FormMessage /> 283 324 </FormItem> 284 - ); 285 - }} 286 - /> 287 - ))} 288 - </div> 289 - <FormMessage /> 290 - </FormItem> 291 - )} 292 - /> 293 - <div className="sm:col-span-full"> 325 + )} 326 + /> 327 + </div> 328 + </div> 329 + </AccordionContent> 330 + </AccordionItem> 331 + </Accordion> 332 + ) : null} 333 + <div className="flex sm:justify-end"> 294 334 <Button className="w-full sm:w-auto" size="lg"> 295 335 {!isPending ? "Confirm" : <LoadingAnimation />} 296 336 </Button>
+103 -89
apps/web/src/components/forms/incident-update-form.tsx
··· 90 90 e.preventDefault(); 91 91 form.handleSubmit(onSubmit)(e); 92 92 }} 93 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 93 + className="grid w-full gap-6" 94 94 > 95 - <FormField 96 - control={form.control} 97 - name="status" 98 - render={({ field }) => ( 99 - <FormItem className="col-span-full space-y-1"> 100 - <FormLabel>Status</FormLabel> 101 - <FormDescription>Select the current status.</FormDescription> 102 - <FormMessage /> 103 - <RadioGroup 104 - onValueChange={(value) => 105 - field.onChange(StatusEnum.parse(value)) 106 - } // value is a string 107 - defaultValue={field.value} 108 - className="grid grid-cols-2 gap-4 sm:grid-cols-4 sm:gap-8" 109 - > 110 - {availableStatus.map((status) => { 111 - const { value, label, icon } = statusDict[status]; 112 - const Icon = Icons[icon]; 113 - return ( 114 - <FormItem key={value}> 115 - <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 116 - <FormControl> 117 - <RadioGroupItem value={value} className="sr-only" /> 118 - </FormControl> 119 - <div className="border-border text-muted-foreground flex w-full items-center justify-center rounded-lg border p-2 px-6 py-3 text-center"> 120 - <Icon className="mr-2 h-4 w-4" /> 121 - {label} 122 - </div> 123 - </FormLabel> 124 - </FormItem> 125 - ); 126 - })} 127 - </RadioGroup> 128 - </FormItem> 129 - )} 130 - /> 131 - <FormField 132 - control={form.control} 133 - name="message" 134 - render={({ field }) => ( 135 - <FormItem className="sm:col-span-4"> 136 - <FormLabel>Message</FormLabel> 137 - <Tabs defaultValue="write"> 138 - <TabsList> 139 - <TabsTrigger value="write">Write</TabsTrigger> 140 - <TabsTrigger value="preview">Preview</TabsTrigger> 141 - </TabsList> 142 - <TabsContent value="write"> 143 - <FormControl> 144 - <Textarea 145 - placeholder="We are encountering..." 146 - className="h-auto w-full resize-none" 147 - rows={9} 148 - {...field} 149 - /> 150 - </FormControl> 151 - </TabsContent> 152 - <TabsContent value="preview"> 153 - <Preview md={form.getValues("message")} /> 154 - </TabsContent> 155 - </Tabs> 156 - <FormDescription> 157 - Tell your user what&apos;s happening. Supports markdown. 158 - </FormDescription> 159 - <FormMessage /> 160 - </FormItem> 161 - )} 162 - /> 163 - <FormField 164 - control={form.control} 165 - name="date" 166 - render={({ field }) => ( 167 - <FormItem className="flex flex-col sm:col-span-full"> 168 - <FormLabel>Date</FormLabel> 169 - <DateTimePicker 170 - date={new Date(field.value)} 171 - setDate={(date) => { 172 - field.onChange(date); 173 - }} 174 - /> 175 - <FormDescription> 176 - The date and time when the incident took place. 177 - </FormDescription> 178 - <FormMessage /> 179 - </FormItem> 180 - )} 181 - /> 182 - <div className="sm:col-span-full"> 95 + <div className="grid gap-4 sm:grid-cols-3"> 96 + <div className="my-1.5 flex flex-col gap-2"> 97 + <p className="text-sm font-semibold leading-none">Inform</p> 98 + <p className="text-muted-foreground text-sm"> 99 + Keep your users informed about what just happened. 100 + </p> 101 + </div> 102 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-3"> 103 + <FormField 104 + control={form.control} 105 + name="status" 106 + render={({ field }) => ( 107 + <FormItem className="space-y-1 sm:col-span-full"> 108 + <FormLabel>Status</FormLabel> 109 + <FormDescription>Select the current status.</FormDescription> 110 + <FormMessage /> 111 + <RadioGroup 112 + onValueChange={(value) => 113 + field.onChange(StatusEnum.parse(value)) 114 + } // value is a string 115 + defaultValue={field.value} 116 + className="grid grid-cols-2 gap-4 sm:grid-cols-4" 117 + > 118 + {availableStatus.map((status) => { 119 + const { value, label, icon } = statusDict[status]; 120 + const Icon = Icons[icon]; 121 + return ( 122 + <FormItem key={value}> 123 + <FormLabel className="[&:has([data-state=checked])>div]:border-primary [&:has([data-state=checked])>div]:text-foreground"> 124 + <FormControl> 125 + <RadioGroupItem 126 + value={value} 127 + className="sr-only" 128 + /> 129 + </FormControl> 130 + <div className="border-border text-muted-foreground flex w-full items-center justify-center rounded-lg border px-3 py-2 text-center text-sm"> 131 + <Icon className="mr-2 h-4 w-4 shrink-0" /> 132 + <span className="truncate">{label}</span> 133 + </div> 134 + </FormLabel> 135 + </FormItem> 136 + ); 137 + })} 138 + </RadioGroup> 139 + </FormItem> 140 + )} 141 + /> 142 + <FormField 143 + control={form.control} 144 + name="message" 145 + render={({ field }) => ( 146 + <FormItem className="sm:col-span-full"> 147 + <FormLabel>Message</FormLabel> 148 + <Tabs defaultValue="write"> 149 + <TabsList> 150 + <TabsTrigger value="write">Write</TabsTrigger> 151 + <TabsTrigger value="preview">Preview</TabsTrigger> 152 + </TabsList> 153 + <TabsContent value="write"> 154 + <FormControl> 155 + <Textarea 156 + placeholder="We are encountering..." 157 + className="h-auto w-full resize-none" 158 + rows={9} 159 + {...field} 160 + /> 161 + </FormControl> 162 + </TabsContent> 163 + <TabsContent value="preview"> 164 + <Preview md={form.getValues("message")} /> 165 + </TabsContent> 166 + </Tabs> 167 + <FormDescription> 168 + Tell your user what&apos;s happening. Supports markdown. 169 + </FormDescription> 170 + <FormMessage /> 171 + </FormItem> 172 + )} 173 + /> 174 + <FormField 175 + control={form.control} 176 + name="date" 177 + render={({ field }) => ( 178 + <FormItem className="flex flex-col sm:col-span-2"> 179 + <FormLabel>Date</FormLabel> 180 + <DateTimePicker 181 + className="w-full" 182 + date={new Date(field.value)} 183 + setDate={(date) => { 184 + field.onChange(date); 185 + }} 186 + /> 187 + <FormDescription> 188 + The date and time when the incident took place. 189 + </FormDescription> 190 + <FormMessage /> 191 + </FormItem> 192 + )} 193 + /> 194 + </div> 195 + </div> 196 + <div className="flex sm:justify-end"> 183 197 <Button className="w-full sm:w-auto" size="lg"> 184 198 {!isPending ? "Confirm" : <LoadingAnimation />} 185 199 </Button>
+484 -317
apps/web/src/components/forms/montitor-form.tsx
··· 1 1 "use client"; 2 2 3 - import { METHODS } from "http"; 4 3 import * as React from "react"; 5 4 import { useRouter } from "next/navigation"; 6 5 import { zodResolver } from "@hookform/resolvers/zod"; ··· 8 7 import { useFieldArray, useForm } from "react-hook-form"; 9 8 import * as z from "zod"; 10 9 10 + import type { selectNotificationSchema } from "@openstatus/db/src/schema"; 11 11 import { 12 12 insertMonitorSchema, 13 13 periodicityEnum, 14 14 } from "@openstatus/db/src/schema"; 15 15 import { allPlans } from "@openstatus/plans"; 16 16 import { 17 + Accordion, 18 + AccordionContent, 19 + AccordionItem, 20 + AccordionTrigger, 17 21 Button, 22 + Checkbox, 18 23 Command, 19 24 CommandEmpty, 20 25 CommandGroup, 21 26 CommandInput, 22 27 CommandItem, 28 + Dialog, 29 + DialogContent, 30 + DialogDescription, 31 + DialogHeader, 32 + DialogTitle, 33 + DialogTrigger, 23 34 Form, 24 35 FormControl, 25 36 FormDescription, ··· 38 49 SelectValue, 39 50 Switch, 40 51 Textarea, 41 - toast, 42 52 Tooltip, 43 53 TooltipContent, 44 54 TooltipProvider, 45 55 TooltipTrigger, 46 56 } from "@openstatus/ui"; 47 57 58 + import { LoadingAnimation } from "@/components/loading-animation"; 48 59 import { regionsDict } from "@/data/regions-dictionary"; 49 60 import { useToastAction } from "@/hooks/use-toast-action"; 50 61 import useUpdateSearchParams from "@/hooks/use-update-search-params"; 51 62 import { cn } from "@/lib/utils"; 52 63 import { api } from "@/trpc/client"; 53 - import { LoadingAnimation } from "../loading-animation"; 64 + import { NotificationForm } from "./notification-form"; 54 65 55 66 const cronJobs = [ 56 67 { value: "1m", label: "1 minute" }, ··· 80 91 interface Props { 81 92 defaultValues?: MonitorProps; 82 93 workspaceSlug: string; 83 - plan?: "free" | "pro"; // HOTFIX - We can think of returning `workspace` instead of `workspaceSlug` 94 + plan?: "free" | "pro"; 95 + notifications?: z.infer<typeof selectNotificationSchema>[]; // HOTFIX - We can think of returning `workspace` instead of `workspaceSlug` 84 96 } 85 97 86 98 export function MonitorForm({ 87 99 defaultValues, 88 100 workspaceSlug, 89 101 plan = "free", 102 + notifications, 90 103 }: Props) { 91 104 const form = useForm<MonitorProps>({ 92 105 resolver: zodResolver(mergedSchema), // too much - we should only validate the values we ask inside of the form! ··· 103 116 : [{ key: "", value: "" }], 104 117 body: defaultValues?.body ?? "", 105 118 method: defaultValues?.method ?? "GET", 119 + notifications: defaultValues?.notifications ?? [], 106 120 }, 107 121 }); 108 122 const router = useRouter(); 109 123 const [isPending, startTransition] = React.useTransition(); 110 124 const [isTestPending, startTestTransition] = React.useTransition(); 125 + const [openDialog, setOpenDialog] = React.useState(false); 111 126 const { toast } = useToastAction(); 112 127 const watchMethod = form.watch("method"); 113 128 const updateSearchParams = useUpdateSearchParams(); ··· 187 202 const limit = allPlans[plan].limits.periodicity; 188 203 189 204 return ( 190 - <Form {...form}> 191 - <form 192 - onSubmit={form.handleSubmit(onSubmit)} 193 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 194 - > 195 - <FormField 196 - control={form.control} 197 - name="name" 198 - render={({ field }) => ( 199 - <FormItem className="sm:col-span-3"> 200 - <FormLabel>Name</FormLabel> 201 - <FormControl> 202 - <Input placeholder="Documenso" {...field} /> 203 - </FormControl> 204 - <FormDescription>Displayed on the status page.</FormDescription> 205 - <FormMessage /> 206 - </FormItem> 207 - )} 208 - /> 209 - <FormField 210 - control={form.control} 211 - name="description" 212 - render={({ field }) => ( 213 - <FormItem className="sm:col-span-5"> 214 - <FormLabel>Description</FormLabel> 215 - <FormControl> 216 - <Input 217 - placeholder="Determines the api health of our services." 218 - {...field} 219 - /> 220 - </FormControl> 221 - <FormDescription> 222 - Provide your users with information about it.{" "} 223 - </FormDescription> 224 - <FormMessage /> 225 - </FormItem> 226 - )} 227 - /> 228 - <FormField 229 - control={form.control} 230 - name="method" 231 - render={({ field }) => ( 232 - <FormItem className="sm:col-span-1 sm:col-start-1 sm:self-baseline"> 233 - <FormLabel>Method</FormLabel> 234 - <Select 235 - onValueChange={(value) => { 236 - field.onChange(methodsEnum.parse(value)); 237 - form.resetField("body", { defaultValue: "" }); 238 - }} 239 - defaultValue={field.value} 240 - > 241 - <FormControl> 242 - <SelectTrigger> 243 - <SelectValue placeholder="Select" /> 244 - </SelectTrigger> 245 - </FormControl> 246 - <SelectContent> 247 - {methods.map((method) => ( 248 - <SelectItem key={method} value={method}> 249 - {method} 250 - </SelectItem> 251 - ))} 252 - </SelectContent> 253 - </Select> 254 - <FormMessage /> 255 - </FormItem> 256 - )} 257 - /> 258 - <FormField 259 - control={form.control} 260 - name="url" 261 - render={({ field }) => ( 262 - <FormItem className="sm:col-span-4"> 263 - <FormLabel>URL</FormLabel> 264 - <FormControl> 265 - {/* <InputWithAddons 205 + <Dialog open={openDialog} onOpenChange={(val) => setOpenDialog(val)}> 206 + <Form {...form}> 207 + <form 208 + onSubmit={form.handleSubmit(onSubmit)} 209 + className="grid w-full gap-6" 210 + > 211 + <div className="grid gap-4 sm:grid-cols-3"> 212 + <div className="my-1.5 flex flex-col gap-2"> 213 + <p className="text-sm font-semibold leading-none"> 214 + Endpoint Check 215 + </p> 216 + <p className="text-muted-foreground text-sm"> 217 + The easiest way to get started. 218 + </p> 219 + </div> 220 + <div className="grid gap-6 sm:col-span-2"> 221 + <FormField 222 + control={form.control} 223 + name="name" 224 + render={({ field }) => ( 225 + <FormItem> 226 + <FormLabel>Name</FormLabel> 227 + <FormControl> 228 + <Input placeholder="Documenso" {...field} /> 229 + </FormControl> 230 + <FormDescription> 231 + Displayed on the status page. 232 + </FormDescription> 233 + <FormMessage /> 234 + </FormItem> 235 + )} 236 + /> 237 + <FormField 238 + control={form.control} 239 + name="url" 240 + render={({ field }) => ( 241 + <FormItem> 242 + <FormLabel>URL</FormLabel> 243 + <FormControl> 244 + {/* <InputWithAddons 266 245 leading="https://" 267 246 placeholder="documenso.com/api/health" 268 247 {...field} 269 248 /> */} 270 - <Input 271 - placeholder="https://documenso.com/api/health" 272 - {...field} 273 - /> 274 - </FormControl> 275 - <FormDescription> 276 - Here is the URL you want to monitor.{" "} 277 - </FormDescription> 278 - <FormMessage /> 279 - </FormItem> 280 - )} 281 - /> 282 - <div className="space-y-2 sm:col-span-full"> 283 - {/* TODO: add FormDescription for latest key/value */} 284 - <FormLabel>Request Header</FormLabel> 285 - {fields.map((field, index) => ( 286 - <div key={field.id} className="grid grid-cols-6 gap-6"> 287 - <FormField 288 - control={form.control} 289 - name={`headers.${index}.key`} 290 - render={({ field }) => ( 291 - <FormItem className="col-span-2"> 292 - <FormControl> 293 - <Input placeholder="key" {...field} /> 249 + <Input 250 + placeholder="https://documenso.com/api/health" 251 + {...field} 252 + /> 294 253 </FormControl> 254 + <FormDescription> 255 + Here is the URL you want to monitor.{" "} 256 + </FormDescription> 257 + <FormMessage /> 295 258 </FormItem> 296 259 )} 297 260 /> 298 - <div className="col-span-4 flex items-center space-x-2"> 299 - <FormField 300 - control={form.control} 301 - name={`headers.${index}.value`} 302 - render={({ field }) => ( 303 - <FormItem className="w-full"> 304 - <FormControl> 305 - <Input placeholder="value" {...field} /> 306 - </FormControl> 307 - </FormItem> 308 - )} 309 - /> 310 - <Button 311 - size="icon" 312 - variant="ghost" 313 - type="button" 314 - onClick={() => remove(Number(field.id))} 315 - > 316 - <X className="h-4 w-4" /> 317 - </Button> 318 - </div> 319 261 </div> 320 - ))} 321 - <div> 322 - <Button 323 - type="button" 324 - variant="outline" 325 - size="sm" 326 - onClick={() => append({ key: "", value: "" })} 327 - > 328 - Add Custom Header 329 - </Button> 330 262 </div> 331 - </div> 332 - {watchMethod === "POST" && ( 333 - <div className="sm:col-span-4 sm:col-start-1"> 334 - <FormField 335 - control={form.control} 336 - name="body" 337 - render={({ field }) => ( 338 - <FormItem> 339 - <div className="flex items-end justify-between"> 340 - <FormLabel>Body</FormLabel> 341 - <TooltipProvider> 342 - <Tooltip> 343 - <TooltipTrigger asChild> 344 - <Button 345 - type="button" 346 - variant="ghost" 347 - size="icon" 348 - onClick={onPrettifyJSON} 263 + <Accordion type="single" collapsible> 264 + <AccordionItem value="http-request-settings"> 265 + <AccordionTrigger>HTTP Request Settings</AccordionTrigger> 266 + <AccordionContent> 267 + <div className="grid gap-4 sm:grid-cols-3"> 268 + <div className="my-1.5 flex flex-col gap-2"> 269 + <p className="text-sm font-semibold leading-none"> 270 + Custom Request 271 + </p> 272 + <p className="text-muted-foreground text-sm"> 273 + If your endpoint is secured, add additional configuration 274 + to the request we send. 275 + </p> 276 + </div> 277 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-4"> 278 + <FormField 279 + control={form.control} 280 + name="method" 281 + render={({ field }) => ( 282 + <FormItem className="sm:col-span-1 sm:self-baseline"> 283 + <FormLabel>Method</FormLabel> 284 + <Select 285 + onValueChange={(value) => { 286 + field.onChange(methodsEnum.parse(value)); 287 + form.resetField("body", { defaultValue: "" }); 288 + }} 289 + defaultValue={field.value} 349 290 > 350 - <Wand2 className="h-4 w-4" /> 351 - </Button> 352 - </TooltipTrigger> 353 - <TooltipContent> 354 - <p>Prettify JSON</p> 355 - </TooltipContent> 356 - </Tooltip> 357 - </TooltipProvider> 291 + <FormControl> 292 + <SelectTrigger> 293 + <SelectValue placeholder="Select" /> 294 + </SelectTrigger> 295 + </FormControl> 296 + <SelectContent> 297 + {methods.map((method) => ( 298 + <SelectItem key={method} value={method}> 299 + {method} 300 + </SelectItem> 301 + ))} 302 + </SelectContent> 303 + </Select> 304 + <FormMessage /> 305 + </FormItem> 306 + )} 307 + /> 308 + <div className="space-y-2 sm:col-span-full"> 309 + <FormLabel>Request Header</FormLabel> 310 + {fields.map((field, index) => ( 311 + <div key={field.id} className="grid grid-cols-6 gap-6"> 312 + <FormField 313 + control={form.control} 314 + name={`headers.${index}.key`} 315 + render={({ field }) => ( 316 + <FormItem className="col-span-2"> 317 + <FormControl> 318 + <Input placeholder="key" {...field} /> 319 + </FormControl> 320 + </FormItem> 321 + )} 322 + /> 323 + <div className="col-span-4 flex items-center space-x-2"> 324 + <FormField 325 + control={form.control} 326 + name={`headers.${index}.value`} 327 + render={({ field }) => ( 328 + <FormItem className="w-full"> 329 + <FormControl> 330 + <Input placeholder="value" {...field} /> 331 + </FormControl> 332 + </FormItem> 333 + )} 334 + /> 335 + <Button 336 + size="icon" 337 + variant="ghost" 338 + type="button" 339 + onClick={() => remove(Number(field.id))} 340 + > 341 + <X className="h-4 w-4" /> 342 + </Button> 343 + </div> 344 + </div> 345 + ))} 346 + <div> 347 + <Button 348 + type="button" 349 + variant="outline" 350 + onClick={() => append({ key: "", value: "" })} 351 + > 352 + Add Custom Header 353 + </Button> 354 + </div> 355 + </div> 356 + {watchMethod === "POST" && ( 357 + <div className="sm:col-span-full"> 358 + <FormField 359 + control={form.control} 360 + name="body" 361 + render={({ field }) => ( 362 + <FormItem> 363 + <div className="flex items-end justify-between"> 364 + <FormLabel>Body</FormLabel> 365 + <TooltipProvider> 366 + <Tooltip> 367 + <TooltipTrigger asChild> 368 + <Button 369 + type="button" 370 + variant="ghost" 371 + size="icon" 372 + onClick={onPrettifyJSON} 373 + > 374 + <Wand2 className="h-4 w-4" /> 375 + </Button> 376 + </TooltipTrigger> 377 + <TooltipContent> 378 + <p>Prettify JSON</p> 379 + </TooltipContent> 380 + </Tooltip> 381 + </TooltipProvider> 382 + </div> 383 + <FormControl> 384 + <Textarea 385 + rows={8} 386 + placeholder='{ "hello": "world" }' 387 + {...field} 388 + /> 389 + </FormControl> 390 + <FormDescription> 391 + Write your json payload. 392 + </FormDescription> 393 + <FormMessage /> 394 + </FormItem> 395 + )} 396 + /> 397 + </div> 398 + )} 399 + </div> 400 + </div> 401 + </AccordionContent> 402 + </AccordionItem> 403 + <AccordionItem value="advanced-settings"> 404 + <AccordionTrigger>Advanced Settings</AccordionTrigger> 405 + <AccordionContent> 406 + <div className="grid gap-4 sm:grid-cols-3"> 407 + <div className="my-1.5 flex flex-col gap-2"> 408 + <p className="text-sm font-semibold leading-none"> 409 + More Configurations 410 + </p> 411 + <p className="text-muted-foreground text-sm"> 412 + Make it your own. Contact us if you wish for more and we 413 + will implement it! 414 + </p> 415 + </div> 416 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 417 + <FormField 418 + control={form.control} 419 + name="periodicity" 420 + render={({ field }) => ( 421 + <FormItem className="sm:col-span-1 sm:self-baseline"> 422 + <FormLabel>Frequency</FormLabel> 423 + <Select 424 + onValueChange={(value) => 425 + field.onChange(periodicityEnum.parse(value)) 426 + } 427 + defaultValue={field.value} 428 + > 429 + <FormControl> 430 + <SelectTrigger> 431 + <SelectValue placeholder="How often should it check your endpoint?" /> 432 + </SelectTrigger> 433 + </FormControl> 434 + <SelectContent> 435 + {cronJobs.map(({ label, value }) => ( 436 + <SelectItem 437 + key={value} 438 + value={value} 439 + disabled={!limit.includes(value)} 440 + > 441 + {label} 442 + </SelectItem> 443 + ))} 444 + </SelectContent> 445 + </Select> 446 + <FormDescription> 447 + Frequency of how often your endpoint will be pinged. 448 + </FormDescription> 449 + <FormMessage /> 450 + </FormItem> 451 + )} 452 + /> 453 + <FormField 454 + control={form.control} 455 + name="regions" 456 + render={({ field }) => ( 457 + <FormItem className="sm:col-span-1 sm:self-baseline"> 458 + <FormLabel>Regions</FormLabel> 459 + <Popover> 460 + <PopoverTrigger asChild> 461 + <FormControl> 462 + <Button 463 + variant="outline" 464 + role="combobox" 465 + className={cn( 466 + "h-10 w-full justify-between", 467 + !field.value && "text-muted-foreground", 468 + )} 469 + > 470 + {/* This is a hotfix */} 471 + {field.value?.length === 1 && 472 + field.value[0].length > 0 473 + ? regionsDict[ 474 + field 475 + .value[0] as keyof typeof regionsDict 476 + ].location 477 + : "Select region"} 478 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 479 + </Button> 480 + </FormControl> 481 + </PopoverTrigger> 482 + <PopoverContent className="w-full p-0"> 483 + <Command> 484 + <CommandInput placeholder="Select a region..." /> 485 + <CommandEmpty>No regions found.</CommandEmpty> 486 + <CommandGroup className="max-h-[150px] overflow-y-scroll"> 487 + {Object.keys(regionsDict).map((region) => { 488 + const { code, location } = 489 + regionsDict[ 490 + region as keyof typeof regionsDict 491 + ]; 492 + const isSelected = 493 + field.value?.includes(code); 494 + return ( 495 + <CommandItem 496 + value={code} 497 + key={code} 498 + onSelect={() => { 499 + form.setValue("regions", [code]); // TODO: allow more than one to be selected in the future 500 + }} 501 + > 502 + <Check 503 + className={cn( 504 + "mr-2 h-4 w-4", 505 + isSelected 506 + ? "opacity-100" 507 + : "opacity-0", 508 + )} 509 + /> 510 + {location} 511 + </CommandItem> 512 + ); 513 + })} 514 + </CommandGroup> 515 + </Command> 516 + </PopoverContent> 517 + </Popover> 518 + <FormDescription> 519 + Select your region. Leave blank for random picked 520 + regions. 521 + </FormDescription> 522 + <FormMessage /> 523 + </FormItem> 524 + )} 525 + /> 526 + <FormField 527 + control={form.control} 528 + name="description" 529 + render={({ field }) => ( 530 + <FormItem className="sm:col-span-2"> 531 + <FormLabel>Description</FormLabel> 532 + <FormControl> 533 + <Input 534 + placeholder="Determines the api health of our services." 535 + {...field} 536 + /> 537 + </FormControl> 538 + <FormDescription> 539 + Provide your users with information about it. 540 + </FormDescription> 541 + <FormMessage /> 542 + </FormItem> 543 + )} 544 + /> 545 + <FormField 546 + control={form.control} 547 + name="active" 548 + render={({ field }) => ( 549 + <FormItem className="flex flex-row items-center justify-between sm:col-span-full"> 550 + <div className="space-y-0.5"> 551 + <FormLabel>Active</FormLabel> 552 + <FormDescription> 553 + This will start ping your endpoint on based on the 554 + selected frequence. 555 + </FormDescription> 556 + </div> 557 + <FormControl> 558 + <Switch 559 + checked={field.value || false} 560 + onCheckedChange={(value) => field.onChange(value)} 561 + /> 562 + </FormControl> 563 + <FormMessage /> 564 + </FormItem> 565 + )} 566 + /> 358 567 </div> 359 - <FormControl> 360 - <Textarea 361 - rows={8} 362 - placeholder='{ "hello": "world" }' 363 - {...field} 568 + </div> 569 + </AccordionContent> 570 + </AccordionItem> 571 + <AccordionItem value="notification-settings"> 572 + <AccordionTrigger>Notification Settings</AccordionTrigger> 573 + <AccordionContent> 574 + <div className="grid gap-4 sm:grid-cols-3"> 575 + <div className="my-1.5 flex flex-col gap-2"> 576 + <p className="text-sm font-semibold leading-none">Alerts</p> 577 + <p className="text-muted-foreground text-sm"> 578 + How do you want to get informed if things break? 579 + </p> 580 + </div> 581 + <div className="grid gap-6 sm:col-span-2"> 582 + <FormField 583 + control={form.control} 584 + name="notifications" 585 + render={() => ( 586 + <FormItem> 587 + <div className="mb-4"> 588 + <FormLabel className="text-base"> 589 + Notifications 590 + </FormLabel> 591 + <FormDescription> 592 + Select the notification channels you want to be 593 + informed. 594 + </FormDescription> 595 + </div> 596 + {notifications?.map((item) => ( 597 + <FormField 598 + key={item.id} 599 + control={form.control} 600 + name="notifications" 601 + render={({ field }) => { 602 + return ( 603 + <FormItem 604 + key={item.id} 605 + className="flex flex-row items-start space-x-3 space-y-0" 606 + > 607 + <FormControl> 608 + <Checkbox 609 + checked={field.value?.includes(item.id)} 610 + onCheckedChange={(checked) => { 611 + return checked 612 + ? field.onChange([ 613 + ...(field.value || []), 614 + item.id, 615 + ]) 616 + : field.onChange( 617 + field.value?.filter( 618 + (value) => value !== item.id, 619 + ), 620 + ); 621 + }} 622 + /> 623 + </FormControl> 624 + <div className="space-y-1 leading-none"> 625 + <FormLabel className="font-normal"> 626 + {item.name} 627 + </FormLabel> 628 + <FormDescription> 629 + {item.provider} 630 + </FormDescription> 631 + </div> 632 + </FormItem> 633 + ); 634 + }} 635 + /> 636 + ))} 637 + <FormMessage /> 638 + <div className="sm:col-span-2 sm:col-start-1"> 639 + <DialogTrigger asChild> 640 + <Button variant="outline"> 641 + Add Notifications 642 + </Button> 643 + </DialogTrigger> 644 + </div> 645 + </FormItem> 646 + )} 364 647 /> 365 - </FormControl> 366 - <FormDescription>Write your json payload.</FormDescription> 367 - <FormMessage /> 368 - </FormItem> 648 + </div> 649 + </div> 650 + </AccordionContent> 651 + </AccordionItem> 652 + </Accordion> 653 + <div className="flex flex-col gap-6 sm:col-span-full sm:flex-row sm:justify-end"> 654 + <Button 655 + type="button" 656 + variant="secondary" 657 + className="w-full sm:w-auto" 658 + size="lg" 659 + onClick={sendTestPing} 660 + > 661 + {!isTestPending ? ( 662 + "Test Request" 663 + ) : ( 664 + <LoadingAnimation variant="inverse" /> 369 665 )} 370 - /> 666 + </Button> 667 + <Button className="w-full sm:w-auto" size="lg" disabled={isPending}> 668 + {!isPending ? "Confirm" : <LoadingAnimation />} 669 + </Button> 371 670 </div> 372 - )} 373 - <div className="sm:col-span-2 sm:col-start-1"> 374 - <Button 375 - type="button" 376 - variant="default" 377 - className="w-full md:w-auto" 378 - size="lg" 379 - onClick={sendTestPing} 380 - > 381 - {!isTestPending ? "Test Request" : <LoadingAnimation />} 382 - </Button> 383 - </div> 384 - <FormField 385 - control={form.control} 386 - name="periodicity" 387 - render={({ field }) => ( 388 - <FormItem className="sm:col-span-3 sm:col-start-1 sm:self-baseline"> 389 - <FormLabel>Frequency</FormLabel> 390 - <Select 391 - onValueChange={(value) => 392 - field.onChange(periodicityEnum.parse(value)) 393 - } 394 - defaultValue={field.value} 395 - > 396 - <FormControl> 397 - <SelectTrigger> 398 - <SelectValue placeholder="How often should it check your endpoint?" /> 399 - </SelectTrigger> 400 - </FormControl> 401 - <SelectContent> 402 - {cronJobs.map(({ label, value }) => ( 403 - <SelectItem 404 - key={value} 405 - value={value} 406 - disabled={!limit.includes(value)} 407 - > 408 - {label} 409 - </SelectItem> 410 - ))} 411 - </SelectContent> 412 - </Select> 413 - <FormDescription> 414 - Frequency of how often your endpoint will be pinged. 415 - </FormDescription> 416 - <FormMessage /> 417 - </FormItem> 418 - )} 419 - /> 420 - <FormField 421 - control={form.control} 422 - name="regions" 423 - render={({ field }) => ( 424 - <FormItem className="sm:col-span-3 sm:self-baseline"> 425 - <FormLabel>Regions</FormLabel> 426 - <Popover> 427 - <PopoverTrigger asChild> 428 - <FormControl> 429 - <Button 430 - variant="outline" 431 - role="combobox" 432 - className={cn( 433 - "h-10 w-full justify-between", 434 - !field.value && "text-muted-foreground", 435 - )} 436 - > 437 - {/* This is a hotfix */} 438 - {field.value?.length === 1 && field.value[0].length > 0 439 - ? regionsDict[ 440 - field.value[0] as keyof typeof regionsDict 441 - ].location 442 - : "Select region"} 443 - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 444 - </Button> 445 - </FormControl> 446 - </PopoverTrigger> 447 - <PopoverContent className="w-full p-0"> 448 - <Command> 449 - <CommandInput placeholder="Select a region..." /> 450 - <CommandEmpty>No regions found.</CommandEmpty> 451 - <CommandGroup className="max-h-[150px] overflow-y-scroll"> 452 - {Object.keys(regionsDict).map((region) => { 453 - const { code, location } = 454 - regionsDict[region as keyof typeof regionsDict]; 455 - const isSelected = field.value?.includes(code); 456 - return ( 457 - <CommandItem 458 - value={code} 459 - key={code} 460 - onSelect={() => { 461 - form.setValue("regions", [code]); // TODO: allow more than one to be selected in the future 462 - }} 463 - > 464 - <Check 465 - className={cn( 466 - "mr-2 h-4 w-4", 467 - isSelected ? "opacity-100" : "opacity-0", 468 - )} 469 - /> 470 - {location} 471 - </CommandItem> 472 - ); 473 - })} 474 - </CommandGroup> 475 - </Command> 476 - </PopoverContent> 477 - </Popover> 478 - <FormDescription> 479 - Select your region. Leave blank for random picked regions. 480 - </FormDescription> 481 - <FormMessage /> 482 - </FormItem> 483 - )} 484 - /> 485 - <FormField 486 - control={form.control} 487 - name="active" 488 - render={({ field }) => ( 489 - <FormItem className="flex flex-row items-center justify-between sm:col-span-4"> 490 - <div className="space-y-0.5"> 491 - <FormLabel>Active</FormLabel> 492 - <FormDescription> 493 - This will start ping your endpoint on based on the selected 494 - frequence. 495 - </FormDescription> 496 - </div> 497 - <FormControl> 498 - <Switch 499 - checked={field.value || false} 500 - onCheckedChange={(value) => field.onChange(value)} 501 - /> 502 - </FormControl> 503 - <FormMessage /> 504 - </FormItem> 505 - )} 671 + </form> 672 + </Form> 673 + <DialogContent className="sm:max-w-2xl"> 674 + <DialogHeader> 675 + <DialogTitle>Add Notification</DialogTitle> 676 + <DialogDescription> 677 + Get alerted when your endpoint is down. 678 + </DialogDescription> 679 + </DialogHeader> 680 + <NotificationForm 681 + onSubmit={() => setOpenDialog(false)} 682 + {...{ workspaceSlug }} 506 683 /> 507 - <div className="sm:col-span-full"> 508 - {/* 509 - * We could think of having a 'double confirmation' one, 510 - * to check if the endpoint works and approve afterwards 511 - * and confirm anyways even if endpoint failed 512 - */} 513 - <Button className="w-full sm:w-auto" size="lg" disabled={isPending}> 514 - {!isPending ? "Confirm" : <LoadingAnimation />} 515 - </Button> 516 - </div> 517 - </form> 518 - </Form> 684 + </DialogContent> 685 + </Dialog> 519 686 ); 520 687 }
+190
apps/web/src/components/forms/notification-form.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { zodResolver } from "@hookform/resolvers/zod"; 6 + import { useForm } from "react-hook-form"; 7 + 8 + import type { Notification } from "@openstatus/db/src/schema"; 9 + import { 10 + insertNotificationSchema, 11 + providerEnum, 12 + providerName, 13 + } from "@openstatus/db/src/schema"; 14 + import { 15 + Button, 16 + Form, 17 + FormControl, 18 + FormDescription, 19 + FormField, 20 + FormItem, 21 + FormLabel, 22 + FormMessage, 23 + Input, 24 + Select, 25 + SelectContent, 26 + SelectItem, 27 + SelectTrigger, 28 + SelectValue, 29 + } from "@openstatus/ui"; 30 + 31 + import { LoadingAnimation } from "@/components/loading-animation"; 32 + import { useToastAction } from "@/hooks/use-toast-action"; 33 + import { api } from "@/trpc/client"; 34 + 35 + /** 36 + * TODO: based on the providers `data` structure, create dynamic form inputs 37 + * e.g. Provider: Email will need an `email` input field and 38 + * we store it like `data: { email: "" }` 39 + * But Provider: Slack will maybe require `webhook` and `channel` and 40 + * we store it like `data: { webhook: "", channel: "" }` 41 + */ 42 + 43 + interface Props { 44 + defaultValues?: Notification; 45 + workspaceSlug: string; 46 + onSubmit?: () => void; 47 + } 48 + 49 + export function NotificationForm({ 50 + workspaceSlug, 51 + defaultValues, 52 + onSubmit: onExternalSubmit, 53 + }: Props) { 54 + const [isPending, startTransition] = useTransition(); 55 + const { toast } = useToastAction(); 56 + const router = useRouter(); 57 + const form = useForm<Notification>({ 58 + resolver: zodResolver(insertNotificationSchema), 59 + defaultValues: { 60 + ...defaultValues, 61 + data: 62 + defaultValues?.provider === "email" 63 + ? JSON.parse(defaultValues?.data).email 64 + : "", 65 + }, 66 + }); 67 + 68 + async function onSubmit({ provider, data, ...rest }: Notification) { 69 + startTransition(async () => { 70 + try { 71 + if (defaultValues) { 72 + await api.notification.updateNotification.mutate({ 73 + provider: "email", 74 + data: JSON.stringify({ email: data }), 75 + ...rest, 76 + }); 77 + } else { 78 + await api.notification.createNotification.mutate({ 79 + workspaceSlug, 80 + provider: "email", 81 + data: JSON.stringify({ email: data }), 82 + ...rest, 83 + }); 84 + } 85 + router.refresh(); 86 + toast("saved"); 87 + } catch { 88 + toast("error"); 89 + } finally { 90 + onExternalSubmit?.(); 91 + } 92 + }); 93 + } 94 + 95 + return ( 96 + <Form {...form}> 97 + <form 98 + onSubmit={form.handleSubmit(onSubmit)} 99 + className="grid w-full gap-6" 100 + > 101 + <div className="grid gap-4 sm:grid-cols-3"> 102 + <div className="my-1.5 flex flex-col gap-2"> 103 + <p className="text-sm font-semibold leading-none">Alerts</p> 104 + <p className="text-muted-foreground text-sm"> 105 + Select the notification channels you want to be informed. 106 + </p> 107 + </div> 108 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 109 + <FormField 110 + control={form.control} 111 + name="provider" 112 + render={({ field }) => ( 113 + <FormItem className="sm:col-span-1 sm:self-baseline"> 114 + <FormLabel>Provider</FormLabel> 115 + <Select 116 + onValueChange={(value) => 117 + field.onChange(providerEnum.parse(value)) 118 + } 119 + defaultValue={field.value} 120 + > 121 + <FormControl> 122 + <SelectTrigger className="capitalize"> 123 + <SelectValue placeholder="Select Provider" /> 124 + </SelectTrigger> 125 + </FormControl> 126 + <SelectContent> 127 + {providerName.map((provider) => ( 128 + <SelectItem 129 + key={provider} 130 + value={provider} 131 + disabled={provider !== "email"} // only allow email for now 132 + className="capitalize" 133 + > 134 + {provider} 135 + </SelectItem> 136 + ))} 137 + </SelectContent> 138 + </Select> 139 + <FormDescription> 140 + What channel/provider to send a notification. 141 + </FormDescription> 142 + <FormMessage /> 143 + </FormItem> 144 + )} 145 + /> 146 + <FormField 147 + control={form.control} 148 + name="name" 149 + render={({ field }) => ( 150 + <FormItem className="sm:col-span-1 sm:self-baseline"> 151 + <FormLabel>Name</FormLabel> 152 + <FormControl> 153 + <Input placeholder="Dev Team" {...field} /> 154 + </FormControl> 155 + <FormDescription> 156 + Define a name for the channel. 157 + </FormDescription> 158 + <FormMessage /> 159 + </FormItem> 160 + )} 161 + /> 162 + <FormField 163 + control={form.control} 164 + name="data" 165 + render={({ field }) => ( 166 + <FormItem className="sm:col-span-full"> 167 + <FormLabel>Email</FormLabel> 168 + <FormControl> 169 + <Input 170 + type="email" 171 + placeholder="dev@documenso.com" 172 + {...field} 173 + /> 174 + </FormControl> 175 + <FormDescription>The data required.</FormDescription> 176 + <FormMessage /> 177 + </FormItem> 178 + )} 179 + /> 180 + </div> 181 + </div> 182 + <div className="flex sm:justify-end"> 183 + <Button className="w-full sm:w-auto" size="lg" disabled={isPending}> 184 + {!isPending ? "Confirm" : <LoadingAnimation />} 185 + </Button> 186 + </div> 187 + </form> 188 + </Form> 189 + ); 190 + }
+26 -17
apps/web/src/components/forms/skeleton-form.tsx
··· 2 2 3 3 export function SkeletonForm() { 4 4 return ( 5 - <div className="grid w-full grid-cols-1 items-center space-y-6 sm:grid-cols-6"> 6 - <div className="space-y-2 sm:col-span-4"> 7 - <Skeleton className="h-6 w-24" /> 8 - <Skeleton className="h-9 w-full" /> 9 - <Skeleton className="h-5 w-64" /> 5 + <div className="grid w-full gap-6"> 6 + <div className="col-span-full grid gap-4 sm:grid-cols-3"> 7 + <div className="flex flex-col gap-2"> 8 + <Skeleton className="h-6 w-24" /> 9 + <Skeleton className="h-6 w-40" /> 10 + </div> 11 + <div className="grid w-full gap-6 sm:col-span-2"> 12 + <div className="space-y-2"> 13 + <Skeleton className="h-6 w-24" /> 14 + <Skeleton className="h-9 w-full" /> 15 + <Skeleton className="h-5 w-64" /> 16 + </div> 17 + <div className="space-y-2"> 18 + <Skeleton className="h-6 w-24" /> 19 + <Skeleton className="h-9 w-full" /> 20 + <Skeleton className="h-5 w-64" /> 21 + </div> 22 + <div className="space-y-2"> 23 + <Skeleton className="h-6 w-24" /> 24 + <Skeleton className="h-9 w-full" /> 25 + <Skeleton className="h-5 w-64" /> 26 + </div> 27 + </div> 10 28 </div> 11 - <div className="space-y-2 sm:col-span-5"> 12 - <Skeleton className="h-6 w-24" /> 13 - <Skeleton className="h-9 w-full" /> 14 - <Skeleton className="h-5 w-64" /> 15 - </div> 16 - <div className="space-y-2 sm:col-span-3"> 17 - <Skeleton className="h-6 w-24" /> 18 - <Skeleton className="h-9 w-full" /> 19 - <Skeleton className="h-5 w-64" /> 20 - </div> 21 - <div className="space-y-2 sm:col-span-full"> 22 - <Skeleton className="h-9 w-20" /> 29 + <Skeleton className="h-9 w-full" /> 30 + <div className="flex space-y-2 sm:col-span-full sm:justify-end"> 31 + <Skeleton className="h-9 w-full sm:w-28" /> 23 32 </div> 24 33 </div> 25 34 );
+185 -147
apps/web/src/components/forms/status-page-form.tsx
··· 12 12 import type { allMonitorsExtendedSchema } from "@openstatus/db/src/schema"; 13 13 import { insertPageSchemaWithMonitors } from "@openstatus/db/src/schema"; 14 14 import { 15 + Accordion, 16 + AccordionContent, 17 + AccordionItem, 18 + AccordionTrigger, 15 19 Button, 16 20 Checkbox, 17 21 Form, ··· 185 189 } 186 190 } 187 191 }} 188 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 192 + className="grid w-full gap-6" 189 193 > 190 - <FormField 191 - control={form.control} 192 - name="title" 193 - render={({ field }) => ( 194 - <FormItem className="sm:col-span-4"> 195 - <FormLabel>Title</FormLabel> 196 - <FormControl> 197 - <Input placeholder="Documenso Status" {...field} /> 198 - </FormControl> 199 - <FormDescription>The title of your page.</FormDescription> 200 - <FormMessage /> 201 - </FormItem> 202 - )} 203 - /> 204 - <FormField 205 - control={form.control} 206 - name="description" 207 - render={({ field }) => ( 208 - <FormItem className="sm:col-span-5"> 209 - <FormLabel>Description</FormLabel> 210 - <FormControl> 211 - <Input 212 - placeholder="Stay informed about our api and website health." 213 - {...field} 214 - /> 215 - </FormControl> 216 - <FormDescription> 217 - Provide your users informations about it. 218 - </FormDescription> 219 - <FormMessage /> 220 - </FormItem> 221 - )} 222 - /> 223 - <FormField 224 - control={form.control} 225 - name="slug" 226 - render={({ field }) => ( 227 - <FormItem className="sm:col-span-3"> 228 - <FormLabel>Slug</FormLabel> 229 - <FormControl> 230 - <InputWithAddons 231 - placeholder="documenso" 232 - trailing={".openstatus.dev"} 233 - {...field} 234 - /> 235 - </FormControl> 236 - <FormDescription> 237 - The subdomain for your status page. At least 3 chars. 238 - </FormDescription> 239 - <FormMessage /> 240 - </FormItem> 241 - )} 242 - /> 243 - <FormField 244 - control={form.control} 245 - name="icon" 246 - render={({ field }) => ( 247 - <FormItem className="sm:col-span-3"> 248 - <FormLabel>Favicon</FormLabel> 249 - <FormControl> 250 - <> 251 - {!field.value && ( 252 - <Input 253 - type="file" 254 - accept="image/x-icon,image/png" 255 - ref={inputFileRef} 256 - onChange={(e) => handleChange(e.target.files)} 194 + <div className="grid gap-4 sm:grid-cols-3"> 195 + <div className="my-1.5 flex flex-col gap-2"> 196 + <p className="text-sm font-semibold leading-none">Endpoint Check</p> 197 + <p className="text-muted-foreground text-sm"> 198 + The easiest way to get started. 199 + </p> 200 + </div> 201 + <div className="grid gap-6 sm:col-span-2"> 202 + <FormField 203 + control={form.control} 204 + name="title" 205 + render={({ field }) => ( 206 + <FormItem> 207 + <FormLabel>Title</FormLabel> 208 + <FormControl> 209 + <Input placeholder="Documenso Status" {...field} /> 210 + </FormControl> 211 + <FormDescription>The title of your page.</FormDescription> 212 + <FormMessage /> 213 + </FormItem> 214 + )} 215 + /> 216 + <FormField 217 + control={form.control} 218 + name="slug" 219 + render={({ field }) => ( 220 + <FormItem> 221 + <FormLabel>Slug</FormLabel> 222 + <FormControl> 223 + <InputWithAddons 224 + placeholder="documenso" 225 + trailing={".openstatus.dev"} 226 + {...field} 257 227 /> 258 - )} 259 - {field.value && ( 260 - <div className="flex items-center"> 261 - <div className="border-border h-10 w-10 rounded-sm border p-1"> 262 - <Image 263 - src={field.value} 264 - width={64} 265 - height={64} 266 - alt="Favicon" 267 - /> 268 - </div> 269 - <Button 270 - variant="link" 271 - onClick={() => { 272 - form.setValue("icon", ""); 273 - }} 274 - > 275 - Remove 276 - </Button> 277 - </div> 278 - )} 279 - </> 280 - </FormControl> 281 - <FormDescription>Your status page favicon</FormDescription> 282 - <FormMessage /> 283 - </FormItem> 284 - )} 285 - /> 286 - <FormField 287 - control={form.control} 288 - name="monitors" 289 - render={() => ( 290 - <FormItem className="sm:col-span-full"> 291 - <div className="mb-4"> 292 - <FormLabel className="text-base">Monitor</FormLabel> 293 - <FormDescription> 294 - Select the monitors you want to display. 295 - </FormDescription> 296 - </div> 297 - {allMonitors?.map((item) => ( 298 - <FormField 299 - key={item.id} 300 - control={form.control} 301 - name="monitors" 302 - render={({ field }) => { 303 - return ( 304 - <FormItem 305 - key={item.id} 306 - className="flex flex-row items-start space-x-3 space-y-0" 307 - > 228 + </FormControl> 229 + <FormDescription> 230 + The subdomain for your status page. At least 3 chars. 231 + </FormDescription> 232 + <FormMessage /> 233 + </FormItem> 234 + )} 235 + /> 236 + <FormField 237 + control={form.control} 238 + name="monitors" 239 + render={() => ( 240 + <FormItem> 241 + <div className="mb-4"> 242 + <FormLabel className="text-base">Monitor</FormLabel> 243 + <FormDescription> 244 + Select the monitors you want to display. 245 + </FormDescription> 246 + </div> 247 + {allMonitors?.map((item) => ( 248 + <FormField 249 + key={item.id} 250 + control={form.control} 251 + name="monitors" 252 + render={({ field }) => { 253 + return ( 254 + <FormItem 255 + key={item.id} 256 + className="flex flex-row items-start space-x-3 space-y-0" 257 + > 258 + <FormControl> 259 + <Checkbox 260 + checked={field.value?.includes(item.id)} 261 + onCheckedChange={(checked) => { 262 + return checked 263 + ? field.onChange([ 264 + ...(field.value || []), 265 + item.id, 266 + ]) 267 + : field.onChange( 268 + field.value?.filter( 269 + (value) => value !== item.id, 270 + ), 271 + ); 272 + }} 273 + /> 274 + </FormControl> 275 + <div className="space-y-1 leading-none"> 276 + <FormLabel className="font-normal"> 277 + {item.name} 278 + </FormLabel> 279 + <FormDescription className="truncate"> 280 + {item.url} 281 + </FormDescription> 282 + </div> 283 + </FormItem> 284 + ); 285 + }} 286 + /> 287 + ))} 288 + <FormMessage /> 289 + </FormItem> 290 + )} 291 + /> 292 + </div> 293 + </div> 294 + <Accordion type="single" collapsible> 295 + <AccordionItem value="advanced-settings"> 296 + <AccordionTrigger>Advanced Settings</AccordionTrigger> 297 + <AccordionContent> 298 + <div className="grid gap-4 sm:grid-cols-3"> 299 + <div className="my-1.5 flex flex-col gap-2"> 300 + <p className="text-sm font-semibold leading-none"> 301 + More Configurations 302 + </p> 303 + <p className="text-muted-foreground text-sm"> 304 + Make it your own. Contact us if you wish for more and we 305 + will implement it! 306 + </p> 307 + </div> 308 + <div className="grid gap-6 sm:col-span-2 sm:grid-cols-2"> 309 + <FormField 310 + control={form.control} 311 + name="description" 312 + render={({ field }) => ( 313 + <FormItem className="sm:col-span-full"> 314 + <FormLabel>Description</FormLabel> 308 315 <FormControl> 309 - <Checkbox 310 - checked={field.value?.includes(item.id)} 311 - onCheckedChange={(checked) => { 312 - return checked 313 - ? field.onChange([ 314 - ...(field.value || []), 315 - item.id, 316 - ]) 317 - : field.onChange( 318 - field.value?.filter( 319 - (value) => value !== item.id, 320 - ), 321 - ); 322 - }} 316 + <Input 317 + placeholder="Stay informed about our api and website health." 318 + {...field} 323 319 /> 324 320 </FormControl> 325 - <div className="space-y-1 leading-none"> 326 - <FormLabel className="font-normal"> 327 - {item.name} 328 - </FormLabel> 329 - <FormDescription>{item.url}</FormDescription> 330 - </div> 321 + <FormDescription> 322 + Provide your users informations about it. 323 + </FormDescription> 324 + <FormMessage /> 325 + </FormItem> 326 + )} 327 + /> 328 + <FormField 329 + control={form.control} 330 + name="icon" 331 + render={({ field }) => ( 332 + <FormItem> 333 + <FormLabel>Favicon</FormLabel> 334 + <FormControl> 335 + <> 336 + {!field.value && ( 337 + <Input 338 + type="file" 339 + accept="image/x-icon,image/png" 340 + ref={inputFileRef} 341 + onChange={(e) => handleChange(e.target.files)} 342 + /> 343 + )} 344 + {field.value && ( 345 + <div className="flex items-center"> 346 + <div className="border-border h-10 w-10 rounded-sm border p-1"> 347 + <Image 348 + src={field.value} 349 + width={64} 350 + height={64} 351 + alt="Favicon" 352 + /> 353 + </div> 354 + <Button 355 + variant="link" 356 + onClick={() => { 357 + form.setValue("icon", ""); 358 + }} 359 + > 360 + Remove 361 + </Button> 362 + </div> 363 + )} 364 + </> 365 + </FormControl> 366 + <FormDescription> 367 + Your status page favicon 368 + </FormDescription> 369 + <FormMessage /> 331 370 </FormItem> 332 - ); 333 - }} 334 - /> 335 - ))} 336 - <FormMessage /> 337 - </FormItem> 338 - )} 339 - /> 340 - <div className="sm:col-span-full"> 371 + )} 372 + /> 373 + </div> 374 + </div> 375 + </AccordionContent> 376 + </AccordionItem> 377 + </Accordion> 378 + <div className="flex sm:justify-end"> 341 379 <Button className="w-full sm:w-auto" size="lg"> 342 380 {!isPending ? "Confirm" : <LoadingAnimation />} 343 381 </Button>
+44
apps/web/src/components/modals/notification-dialog.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + 5 + import { 6 + Button, 7 + Dialog, 8 + DialogContent, 9 + DialogDescription, 10 + DialogHeader, 11 + DialogTitle, 12 + DialogTrigger, 13 + } from "@openstatus/ui"; 14 + 15 + import { NotificationForm } from "@/components/forms/notification-form"; 16 + 17 + export const NotificationDialog = ({ 18 + workspaceSlug, 19 + }: { 20 + workspaceSlug: string; 21 + }) => { 22 + const [open, setOpen] = useState(false); 23 + return ( 24 + <Dialog open={open} onOpenChange={(val) => setOpen(val)}> 25 + <DialogTrigger asChild> 26 + <Button variant="default" size="sm"> 27 + Add Notifications 28 + </Button> 29 + </DialogTrigger> 30 + <DialogContent className="sm:max-w-2xl"> 31 + <DialogHeader> 32 + <DialogTitle>Add Notification</DialogTitle> 33 + <DialogDescription> 34 + Get alerted when your endpoint is down. 35 + </DialogDescription> 36 + </DialogHeader> 37 + <NotificationForm 38 + onSubmit={() => setOpen(false)} 39 + {...{ workspaceSlug }} 40 + /> 41 + </DialogContent> 42 + </Dialog> 43 + ); 44 + };
+6
apps/web/src/config/pages.ts
··· 23 23 icon: "panel-top", 24 24 }, 25 25 { 26 + title: "Notifications", 27 + description: "Where you can see all the notifications.", 28 + href: "/notifications", 29 + icon: "bell", 30 + }, 31 + { 26 32 title: "Incidents", 27 33 description: "War room where you handle the incidents.", 28 34 href: "/incidents",
+4
packages/api/src/edge.ts
··· 2 2 import { incidentRouter } from "./router/incident"; 3 3 import { integrationRouter } from "./router/integration"; 4 4 import { monitorRouter } from "./router/monitor"; 5 + import { notificationRouter } from "./router/notification"; 5 6 import { pageRouter } from "./router/page"; 7 + import { userRouter } from "./router/user"; 6 8 import { workspaceRouter } from "./router/workspace"; 7 9 import { createTRPCRouter } from "./trpc"; 8 10 ··· 14 16 incident: incidentRouter, 15 17 domain: domainRouter, 16 18 integration: integrationRouter, 19 + user: userRouter, 20 + notification: notificationRouter, 17 21 });
+85 -51
packages/api/src/router/monitor.ts
··· 2 2 import { z } from "zod"; 3 3 4 4 import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 - import { and, eq, sql } from "@openstatus/db"; 5 + import { and, eq, inArray, sql } from "@openstatus/db"; 6 6 import { 7 7 allMonitorsExtendedSchema, 8 8 insertMonitorSchema, 9 9 METHODS, 10 10 monitor, 11 11 monitorsToPages, 12 + notification, 13 + notificationsToMonitors, 12 14 periodicityEnum, 13 15 selectMonitorExtendedSchema, 16 + selectNotificationSchema, 14 17 } from "@openstatus/db/src/schema"; 15 18 import { allPlans } from "@openstatus/plans"; 16 19 ··· 56 59 }); 57 60 } 58 61 // FIXME: this is a hotfix 59 - const { regions, headers, ...data } = opts.input.data; 62 + const { regions, headers, notifications, ...data } = opts.input.data; 60 63 61 64 const newMonitor = await opts.ctx.db 62 65 .insert(monitor) ··· 69 72 .returning() 70 73 .get(); 71 74 75 + if (notifications && notifications.length > 0) { 76 + const allNotifications = await opts.ctx.db.query.notification.findMany({ 77 + where: inArray(notification.id, notifications), 78 + }); 79 + const values = allNotifications.map((notification) => ({ 80 + monitorId: newMonitor.id, 81 + notificationId: notification.id, 82 + })); 83 + await opts.ctx.db.insert(notificationsToMonitors).values(values).run(); 84 + } 85 + 72 86 // TODO: check, do we have to await for the two calls? Will slow down user response for our analytics 73 87 await analytics.identify(result.user.id, { 74 88 userId: result.user.id, ··· 94 108 if (!result) return; 95 109 96 110 const _monitor = selectMonitorExtendedSchema.parse(result.monitor); 97 - _monitor.headers; 98 111 return _monitor; 99 112 }), 100 113 101 - // TODO: delete 102 - updateMonitorDescription: protectedProcedure 103 - .input( 104 - z.object({ 105 - id: z.number(), 106 - description: z.string(), 107 - }), 108 - ) 109 - .mutation(async (opts) => { 110 - // We make sure user as access to this workspace and the monitor 111 - if (!opts.input.id) return; 112 - const result = await hasUserAccessToMonitor({ 113 - monitorId: opts.input.id, 114 - ctx: opts.ctx, 115 - }); 116 - if (!result) return; 117 - 118 - await opts.ctx.db 119 - .update(monitor) 120 - .set({ description: opts.input.description }) 121 - .where(eq(monitor.id, opts.input.id)) 122 - .run(); 123 - }), 124 114 updateMonitor: protectedProcedure 125 115 .input(insertMonitorSchema) 126 116 .mutation(async (opts) => { ··· 145 135 message: "You reached your cron job limits.", 146 136 }); 147 137 } 148 - const { regions, headers, ...data } = opts.input; 149 - await opts.ctx.db 138 + const { regions, headers, notifications, ...data } = opts.input; 139 + const currentMonitor = await opts.ctx.db 150 140 .update(monitor) 151 141 .set({ 152 142 ...data, ··· 157 147 .where(eq(monitor.id, opts.input.id)) 158 148 .returning() 159 149 .get(); 160 - }), 161 - // TODO: delete 162 - updateMonitorPeriodicity: protectedProcedure 163 - .input( 164 - z.object({ 165 - id: z.number(), 166 - data: insertMonitorSchema.pick({ periodicity: true }), 167 - }), 168 - ) 169 - .mutation(async (opts) => { 170 - const result = await hasUserAccessToMonitor({ 171 - monitorId: opts.input.id, 172 - ctx: opts.ctx, 173 - }); 174 - if (!result) return; 175 150 176 - await opts.ctx.db 177 - .update(monitor) 178 - .set({ 179 - periodicity: opts.input.data.periodicity, 180 - updatedAt: new Date(), 181 - }) 182 - .where(eq(monitor.id, opts.input.id)) 183 - .run(); 151 + // TODO: optimize! 152 + const currentMonitorNotifications = await opts.ctx.db 153 + .select() 154 + .from(notificationsToMonitors) 155 + .where(eq(notificationsToMonitors.monitorId, currentMonitor.id)) 156 + .all(); 157 + 158 + const currentMonitorNotificationsIds = currentMonitorNotifications.map( 159 + ({ notificationId }) => notificationId, 160 + ); 161 + 162 + const removedNotifications = currentMonitorNotificationsIds.filter( 163 + (x) => !notifications?.includes(x), 164 + ); 165 + 166 + const addedNotifications = notifications?.filter( 167 + (x) => !currentMonitorNotificationsIds?.includes(x), 168 + ); 169 + 170 + if (addedNotifications && addedNotifications.length > 0) { 171 + const values = addedNotifications.map((notificationId) => ({ 172 + monitorId: currentMonitor.id, 173 + notificationId, 174 + })); 175 + 176 + await opts.ctx.db.insert(notificationsToMonitors).values(values).run(); 177 + } 178 + 179 + if (removedNotifications && removedNotifications.length > 0) { 180 + await opts.ctx.db 181 + .delete(notificationsToMonitors) 182 + .where( 183 + and( 184 + eq(notificationsToMonitors.monitorId, currentMonitor.id), 185 + inArray( 186 + notificationsToMonitors.notificationId, 187 + removedNotifications, 188 + ), 189 + ), 190 + ) 191 + .run(); 192 + } 184 193 }), 185 194 186 195 deleteMonitor: protectedProcedure ··· 196 205 .where(eq(monitor.id, result.monitor.id)) 197 206 .run(); 198 207 }), 208 + 199 209 getMonitorsByWorkspace: protectedProcedure 200 210 .input(z.object({ workspaceSlug: z.string() })) 201 211 .query(async (opts) => { ··· 289 299 .all(); 290 300 if (monitors.length === 0) return 0; 291 301 return monitors[0].count; 302 + }), 303 + 304 + // TODO: return the notifications inside of the `getMonitorByID` like we do for the monitors on a status page 305 + getAllNotificationsForMonitor: protectedProcedure 306 + .input(z.object({ id: z.number() })) 307 + // .output(selectMonitorSchema) 308 + .query(async (opts) => { 309 + if (!opts.input.id) return; 310 + const result = await hasUserAccessToMonitor({ 311 + monitorId: opts.input.id, 312 + ctx: opts.ctx, 313 + }); 314 + if (!result) return null; 315 + 316 + const data = await opts.ctx.db 317 + .select() 318 + .from(notificationsToMonitors) 319 + .innerJoin( 320 + notification, 321 + eq(notificationsToMonitors.notificationId, notification.id), 322 + ) 323 + .where(eq(notificationsToMonitors.monitorId, opts.input.id)) 324 + .all(); 325 + return data.map((d) => selectNotificationSchema.parse(d.notification)); 292 326 }), 293 327 });
+118
packages/api/src/router/notification.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { and, eq } from "@openstatus/db"; 4 + import { 5 + allNotifications, 6 + insertNotificationSchema, 7 + notification, 8 + notificationsToMonitors, 9 + selectNotificationSchema, 10 + } from "@openstatus/db/src/schema"; 11 + 12 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 13 + import { hasUserAccessToNotification, hasUserAccessToWorkspace } from "./utils"; 14 + 15 + export const notificationRouter = createTRPCRouter({ 16 + createNotification: protectedProcedure 17 + .input( 18 + insertNotificationSchema.extend({ 19 + workspaceSlug: z.string(), 20 + }), 21 + ) 22 + .mutation(async (opts) => { 23 + const { workspaceSlug, ...data } = opts.input; 24 + 25 + const result = await hasUserAccessToWorkspace({ 26 + workspaceSlug, 27 + ctx: opts.ctx, 28 + }); 29 + if (!result) return; 30 + 31 + const _notification = await opts.ctx.db 32 + .insert(notification) 33 + .values({ ...data, workspaceId: result.workspace.id }) 34 + .returning() 35 + .get(); 36 + 37 + return _notification; 38 + }), 39 + 40 + updateNotification: protectedProcedure 41 + .input(insertNotificationSchema) 42 + .mutation(async (opts) => { 43 + if (!opts.input.id) return; 44 + const result = await hasUserAccessToNotification({ 45 + notificationId: opts.input.id, 46 + ctx: opts.ctx, 47 + }); 48 + if (!result) return; 49 + 50 + const { ...data } = opts.input; 51 + return await opts.ctx.db 52 + .update(notification) 53 + .set({ ...data, updatedAt: new Date() }) 54 + .where(eq(notification.id, opts.input.id)) 55 + .returning() 56 + .get(); 57 + }), 58 + 59 + deleteNotification: protectedProcedure 60 + .input(z.object({ id: z.number() })) 61 + .mutation(async (opts) => { 62 + console.log({ id: opts.input.id }); 63 + const result = await hasUserAccessToNotification({ 64 + notificationId: opts.input.id, 65 + ctx: opts.ctx, 66 + }); 67 + if (!result) return; 68 + 69 + await opts.ctx.db 70 + .delete(notification) 71 + .where(eq(notification.id, result.notification.id)) 72 + .run(); 73 + }), 74 + 75 + getNotificationById: protectedProcedure 76 + .input(z.object({ id: z.number() })) 77 + .query(async (opts) => { 78 + // if (!opts.input.id) return; 79 + // const result = await hasUserAccessToMonitor({ 80 + // monitorId: opts.input.id, 81 + // ctx: opts.ctx, 82 + // }); 83 + // if (!result) return; 84 + 85 + const _notification = await opts.ctx.db 86 + .select() 87 + .from(notification) 88 + .where(eq(notification.id, opts.input.id)) 89 + .get(); 90 + 91 + return selectNotificationSchema.parse(_notification); 92 + }), 93 + 94 + getNotificationsByWorkspace: protectedProcedure 95 + .input(z.object({ workspaceSlug: z.string() })) 96 + .query(async (opts) => { 97 + // Check if user has access to workspace 98 + const data = await hasUserAccessToWorkspace({ 99 + workspaceSlug: opts.input.workspaceSlug, 100 + ctx: opts.ctx, 101 + }); 102 + 103 + if (!data) return; 104 + 105 + const notifications = await opts.ctx.db 106 + .select() 107 + .from(notification) 108 + .where(eq(notification.workspaceId, data.workspace.id)) 109 + .all(); 110 + 111 + try { 112 + return allNotifications.parse(notifications); 113 + } catch (e) { 114 + console.log(e); 115 + } 116 + return; 117 + }), 118 + });
+1
packages/api/src/router/page.ts
··· 56 56 .values({ workspaceId: data.workspace.id, ...pageInput }) 57 57 .returning() 58 58 .get(); 59 + 59 60 if (monitors && monitors.length > 0) { 60 61 // We should make sure the user has access to the monitors 61 62 const allMonitors = await opts.ctx.db.query.monitor.findMany({
+15
packages/api/src/router/user.ts
··· 1 + import { eq } from "@openstatus/db"; 2 + import { user } from "@openstatus/db/src/schema"; 3 + 4 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 5 + 6 + export const userRouter = createTRPCRouter({ 7 + getCurrentUser: protectedProcedure.query(async (opts) => { 8 + const currentUser = await opts.ctx.db 9 + .select() 10 + .from(user) 11 + .where(eq(user.tenantId, opts.ctx.auth.userId)) 12 + .get(); 13 + return currentUser; 14 + }), 15 + });
+45
packages/api/src/router/utils.ts
··· 1 1 import { eq } from "@openstatus/db"; 2 2 import { 3 3 monitor, 4 + notification, 4 5 user, 5 6 usersToWorkspaces, 6 7 workspace, ··· 97 98 monitor: currentMonitor, 98 99 }; 99 100 }; 101 + 102 + export const hasUserAccessToNotification = async ({ 103 + notificationId, 104 + ctx, 105 + }: { 106 + notificationId: number; 107 + ctx: Context; 108 + }) => { 109 + if (!ctx.auth?.userId) return; 110 + 111 + const currentNotification = await ctx.db 112 + .select() 113 + .from(notification) 114 + .where(eq(notification.id, notificationId)) 115 + .get(); 116 + if (!currentNotification || !currentNotification.workspaceId) return; 117 + 118 + // TODO: we should use hasUserAccess and pass `workspaceId` instead of `workspaceSlug` 119 + const currentUser = ctx.db 120 + .select() 121 + .from(user) 122 + .where(eq(user.tenantId, ctx.auth.userId)) 123 + .as("currentUser"); 124 + 125 + const result = await ctx.db 126 + .select() 127 + .from(usersToWorkspaces) 128 + .where(eq(usersToWorkspaces.workspaceId, currentNotification.workspaceId)) 129 + .innerJoin(currentUser, eq(usersToWorkspaces.userId, currentUser.id)) 130 + .get(); 131 + 132 + if (!result || !result.users_to_workspaces) return; 133 + 134 + const currentWorkspace = await ctx.db.query.workspace.findFirst({ 135 + where: eq(workspace.id, result.users_to_workspaces.workspaceId), 136 + }); 137 + 138 + if (!currentWorkspace) return; 139 + return { 140 + workspace: currentWorkspace, 141 + user: result.currentUser, 142 + notification: currentNotification, 143 + }; 144 + };
+22
packages/db/drizzle/0008_overjoyed_sunset_bain.sql
··· 1 + CREATE TABLE `notification` ( 2 + `id` integer PRIMARY KEY NOT NULL, 3 + `name` text NOT NULL, 4 + `provider` text NOT NULL, 5 + `data` text DEFAULT '{}', 6 + `workspace_id` integer, 7 + `created_at` integer DEFAULT (strftime('%s', 'now')), 8 + `updated_at` integer DEFAULT (strftime('%s', 'now')), 9 + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action 10 + ); 11 + --> statement-breakpoint 12 + CREATE TABLE `notifications_to_monitors` ( 13 + `monitor_id` integer NOT NULL, 14 + `notification_id` integer NOT NULL, 15 + PRIMARY KEY(`monitor_id`, `notification_id`), 16 + FOREIGN KEY (`monitor_id`) REFERENCES `monitor`(`id`) ON UPDATE no action ON DELETE cascade, 17 + FOREIGN KEY (`notification_id`) REFERENCES `notification`(`id`) ON UPDATE no action ON DELETE cascade 18 + ); 19 + --> statement-breakpoint 20 + ALTER TABLE `monitor` DROP COLUMN `status`; 21 + --> statement-breakpoint 22 + ALTER TABLE `monitor` ADD COLUMN `status` text(2) DEFAULT 'active' NOT NULL;
+959
packages/db/drizzle/meta/0008_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "72f58965-4d96-43e5-9101-3ceb67dac648", 5 + "prevId": "23bfc90d-3bb1-4077-81d8-8f2e874f9c62", 6 + "tables": { 7 + "incident": { 8 + "name": "incident", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status": { 18 + "name": "status", 19 + "type": "text(4)", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "title": { 25 + "name": "title", 26 + "type": "text(256)", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "workspace_id": { 32 + "name": "workspace_id", 33 + "type": "integer", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "created_at": { 39 + "name": "created_at", 40 + "type": "integer", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false, 44 + "default": "(strftime('%s', 'now'))" 45 + }, 46 + "updated_at": { 47 + "name": "updated_at", 48 + "type": "integer", 49 + "primaryKey": false, 50 + "notNull": false, 51 + "autoincrement": false, 52 + "default": "(strftime('%s', 'now'))" 53 + } 54 + }, 55 + "indexes": {}, 56 + "foreignKeys": { 57 + "incident_workspace_id_workspace_id_fk": { 58 + "name": "incident_workspace_id_workspace_id_fk", 59 + "tableFrom": "incident", 60 + "tableTo": "workspace", 61 + "columnsFrom": [ 62 + "workspace_id" 63 + ], 64 + "columnsTo": [ 65 + "id" 66 + ], 67 + "onDelete": "no action", 68 + "onUpdate": "no action" 69 + } 70 + }, 71 + "compositePrimaryKeys": {}, 72 + "uniqueConstraints": {} 73 + }, 74 + "incident_update": { 75 + "name": "incident_update", 76 + "columns": { 77 + "id": { 78 + "name": "id", 79 + "type": "integer", 80 + "primaryKey": true, 81 + "notNull": true, 82 + "autoincrement": false 83 + }, 84 + "status": { 85 + "name": "status", 86 + "type": "text(4)", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false 90 + }, 91 + "date": { 92 + "name": "date", 93 + "type": "integer", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "message": { 99 + "name": "message", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "incident_id": { 106 + "name": "incident_id", 107 + "type": "integer", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "autoincrement": false 111 + }, 112 + "created_at": { 113 + "name": "created_at", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": false, 117 + "autoincrement": false, 118 + "default": "(strftime('%s', 'now'))" 119 + }, 120 + "updated_at": { 121 + "name": "updated_at", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": false, 125 + "autoincrement": false, 126 + "default": "(strftime('%s', 'now'))" 127 + } 128 + }, 129 + "indexes": {}, 130 + "foreignKeys": { 131 + "incident_update_incident_id_incident_id_fk": { 132 + "name": "incident_update_incident_id_incident_id_fk", 133 + "tableFrom": "incident_update", 134 + "tableTo": "incident", 135 + "columnsFrom": [ 136 + "incident_id" 137 + ], 138 + "columnsTo": [ 139 + "id" 140 + ], 141 + "onDelete": "cascade", 142 + "onUpdate": "no action" 143 + } 144 + }, 145 + "compositePrimaryKeys": {}, 146 + "uniqueConstraints": {} 147 + }, 148 + "incidents_to_monitors": { 149 + "name": "incidents_to_monitors", 150 + "columns": { 151 + "monitor_id": { 152 + "name": "monitor_id", 153 + "type": "integer", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "incident_id": { 159 + "name": "incident_id", 160 + "type": "integer", 161 + "primaryKey": false, 162 + "notNull": true, 163 + "autoincrement": false 164 + } 165 + }, 166 + "indexes": {}, 167 + "foreignKeys": { 168 + "incidents_to_monitors_monitor_id_monitor_id_fk": { 169 + "name": "incidents_to_monitors_monitor_id_monitor_id_fk", 170 + "tableFrom": "incidents_to_monitors", 171 + "tableTo": "monitor", 172 + "columnsFrom": [ 173 + "monitor_id" 174 + ], 175 + "columnsTo": [ 176 + "id" 177 + ], 178 + "onDelete": "cascade", 179 + "onUpdate": "no action" 180 + }, 181 + "incidents_to_monitors_incident_id_incident_id_fk": { 182 + "name": "incidents_to_monitors_incident_id_incident_id_fk", 183 + "tableFrom": "incidents_to_monitors", 184 + "tableTo": "incident", 185 + "columnsFrom": [ 186 + "incident_id" 187 + ], 188 + "columnsTo": [ 189 + "id" 190 + ], 191 + "onDelete": "cascade", 192 + "onUpdate": "no action" 193 + } 194 + }, 195 + "compositePrimaryKeys": { 196 + "incidents_to_monitors_monitor_id_incident_id_pk": { 197 + "columns": [ 198 + "incident_id", 199 + "monitor_id" 200 + ] 201 + } 202 + }, 203 + "uniqueConstraints": {} 204 + }, 205 + "integration": { 206 + "name": "integration", 207 + "columns": { 208 + "id": { 209 + "name": "id", 210 + "type": "integer", 211 + "primaryKey": true, 212 + "notNull": true, 213 + "autoincrement": false 214 + }, 215 + "name": { 216 + "name": "name", 217 + "type": "text(256)", 218 + "primaryKey": false, 219 + "notNull": true, 220 + "autoincrement": false 221 + }, 222 + "workspace_id": { 223 + "name": "workspace_id", 224 + "type": "integer", 225 + "primaryKey": false, 226 + "notNull": false, 227 + "autoincrement": false 228 + }, 229 + "credential": { 230 + "name": "credential", 231 + "type": "text", 232 + "primaryKey": false, 233 + "notNull": false, 234 + "autoincrement": false 235 + }, 236 + "external_id": { 237 + "name": "external_id", 238 + "type": "text", 239 + "primaryKey": false, 240 + "notNull": true, 241 + "autoincrement": false 242 + }, 243 + "created_at": { 244 + "name": "created_at", 245 + "type": "integer", 246 + "primaryKey": false, 247 + "notNull": false, 248 + "autoincrement": false, 249 + "default": "(strftime('%s', 'now'))" 250 + }, 251 + "updated_at": { 252 + "name": "updated_at", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": false, 256 + "autoincrement": false, 257 + "default": "(strftime('%s', 'now'))" 258 + }, 259 + "data": { 260 + "name": "data", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + } 266 + }, 267 + "indexes": {}, 268 + "foreignKeys": { 269 + "integration_workspace_id_workspace_id_fk": { 270 + "name": "integration_workspace_id_workspace_id_fk", 271 + "tableFrom": "integration", 272 + "tableTo": "workspace", 273 + "columnsFrom": [ 274 + "workspace_id" 275 + ], 276 + "columnsTo": [ 277 + "id" 278 + ], 279 + "onDelete": "no action", 280 + "onUpdate": "no action" 281 + } 282 + }, 283 + "compositePrimaryKeys": {}, 284 + "uniqueConstraints": {} 285 + }, 286 + "page": { 287 + "name": "page", 288 + "columns": { 289 + "id": { 290 + "name": "id", 291 + "type": "integer", 292 + "primaryKey": true, 293 + "notNull": true, 294 + "autoincrement": false 295 + }, 296 + "workspace_id": { 297 + "name": "workspace_id", 298 + "type": "integer", 299 + "primaryKey": false, 300 + "notNull": true, 301 + "autoincrement": false 302 + }, 303 + "title": { 304 + "name": "title", 305 + "type": "text", 306 + "primaryKey": false, 307 + "notNull": true, 308 + "autoincrement": false 309 + }, 310 + "description": { 311 + "name": "description", 312 + "type": "text", 313 + "primaryKey": false, 314 + "notNull": true, 315 + "autoincrement": false 316 + }, 317 + "icon": { 318 + "name": "icon", 319 + "type": "text(256)", 320 + "primaryKey": false, 321 + "notNull": false, 322 + "autoincrement": false, 323 + "default": "''" 324 + }, 325 + "slug": { 326 + "name": "slug", 327 + "type": "text(256)", 328 + "primaryKey": false, 329 + "notNull": true, 330 + "autoincrement": false 331 + }, 332 + "custom_domain": { 333 + "name": "custom_domain", 334 + "type": "text(256)", 335 + "primaryKey": false, 336 + "notNull": true, 337 + "autoincrement": false 338 + }, 339 + "published": { 340 + "name": "published", 341 + "type": "integer", 342 + "primaryKey": false, 343 + "notNull": false, 344 + "autoincrement": false, 345 + "default": false 346 + }, 347 + "created_at": { 348 + "name": "created_at", 349 + "type": "integer", 350 + "primaryKey": false, 351 + "notNull": false, 352 + "autoincrement": false, 353 + "default": "(strftime('%s', 'now'))" 354 + }, 355 + "updated_at": { 356 + "name": "updated_at", 357 + "type": "integer", 358 + "primaryKey": false, 359 + "notNull": false, 360 + "autoincrement": false, 361 + "default": "(strftime('%s', 'now'))" 362 + } 363 + }, 364 + "indexes": { 365 + "page_slug_unique": { 366 + "name": "page_slug_unique", 367 + "columns": [ 368 + "slug" 369 + ], 370 + "isUnique": true 371 + } 372 + }, 373 + "foreignKeys": { 374 + "page_workspace_id_workspace_id_fk": { 375 + "name": "page_workspace_id_workspace_id_fk", 376 + "tableFrom": "page", 377 + "tableTo": "workspace", 378 + "columnsFrom": [ 379 + "workspace_id" 380 + ], 381 + "columnsTo": [ 382 + "id" 383 + ], 384 + "onDelete": "cascade", 385 + "onUpdate": "no action" 386 + } 387 + }, 388 + "compositePrimaryKeys": {}, 389 + "uniqueConstraints": {} 390 + }, 391 + "monitor": { 392 + "name": "monitor", 393 + "columns": { 394 + "id": { 395 + "name": "id", 396 + "type": "integer", 397 + "primaryKey": true, 398 + "notNull": true, 399 + "autoincrement": false 400 + }, 401 + "job_type": { 402 + "name": "job_type", 403 + "type": "text(3)", 404 + "primaryKey": false, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'other'" 408 + }, 409 + "periodicity": { 410 + "name": "periodicity", 411 + "type": "text(6)", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false, 415 + "default": "'other'" 416 + }, 417 + "status": { 418 + "name": "status", 419 + "type": "text(2)", 420 + "primaryKey": false, 421 + "notNull": true, 422 + "autoincrement": false, 423 + "default": "'active'" 424 + }, 425 + "active": { 426 + "name": "active", 427 + "type": "integer", 428 + "primaryKey": false, 429 + "notNull": false, 430 + "autoincrement": false, 431 + "default": false 432 + }, 433 + "regions": { 434 + "name": "regions", 435 + "type": "text", 436 + "primaryKey": false, 437 + "notNull": true, 438 + "autoincrement": false, 439 + "default": "''" 440 + }, 441 + "url": { 442 + "name": "url", 443 + "type": "text(512)", 444 + "primaryKey": false, 445 + "notNull": true, 446 + "autoincrement": false 447 + }, 448 + "name": { 449 + "name": "name", 450 + "type": "text(256)", 451 + "primaryKey": false, 452 + "notNull": true, 453 + "autoincrement": false, 454 + "default": "''" 455 + }, 456 + "description": { 457 + "name": "description", 458 + "type": "text", 459 + "primaryKey": false, 460 + "notNull": true, 461 + "autoincrement": false, 462 + "default": "''" 463 + }, 464 + "headers": { 465 + "name": "headers", 466 + "type": "text", 467 + "primaryKey": false, 468 + "notNull": false, 469 + "autoincrement": false, 470 + "default": "''" 471 + }, 472 + "body": { 473 + "name": "body", 474 + "type": "text", 475 + "primaryKey": false, 476 + "notNull": false, 477 + "autoincrement": false, 478 + "default": "''" 479 + }, 480 + "method": { 481 + "name": "method", 482 + "type": "text(2)", 483 + "primaryKey": false, 484 + "notNull": false, 485 + "autoincrement": false, 486 + "default": "'GET'" 487 + }, 488 + "workspace_id": { 489 + "name": "workspace_id", 490 + "type": "integer", 491 + "primaryKey": false, 492 + "notNull": false, 493 + "autoincrement": false 494 + }, 495 + "created_at": { 496 + "name": "created_at", 497 + "type": "integer", 498 + "primaryKey": false, 499 + "notNull": false, 500 + "autoincrement": false, 501 + "default": "(strftime('%s', 'now'))" 502 + }, 503 + "updated_at": { 504 + "name": "updated_at", 505 + "type": "integer", 506 + "primaryKey": false, 507 + "notNull": false, 508 + "autoincrement": false, 509 + "default": "(strftime('%s', 'now'))" 510 + } 511 + }, 512 + "indexes": {}, 513 + "foreignKeys": { 514 + "monitor_workspace_id_workspace_id_fk": { 515 + "name": "monitor_workspace_id_workspace_id_fk", 516 + "tableFrom": "monitor", 517 + "tableTo": "workspace", 518 + "columnsFrom": [ 519 + "workspace_id" 520 + ], 521 + "columnsTo": [ 522 + "id" 523 + ], 524 + "onDelete": "no action", 525 + "onUpdate": "no action" 526 + } 527 + }, 528 + "compositePrimaryKeys": {}, 529 + "uniqueConstraints": {} 530 + }, 531 + "monitors_to_pages": { 532 + "name": "monitors_to_pages", 533 + "columns": { 534 + "monitor_id": { 535 + "name": "monitor_id", 536 + "type": "integer", 537 + "primaryKey": false, 538 + "notNull": true, 539 + "autoincrement": false 540 + }, 541 + "page_id": { 542 + "name": "page_id", 543 + "type": "integer", 544 + "primaryKey": false, 545 + "notNull": true, 546 + "autoincrement": false 547 + } 548 + }, 549 + "indexes": {}, 550 + "foreignKeys": { 551 + "monitors_to_pages_monitor_id_monitor_id_fk": { 552 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 553 + "tableFrom": "monitors_to_pages", 554 + "tableTo": "monitor", 555 + "columnsFrom": [ 556 + "monitor_id" 557 + ], 558 + "columnsTo": [ 559 + "id" 560 + ], 561 + "onDelete": "cascade", 562 + "onUpdate": "no action" 563 + }, 564 + "monitors_to_pages_page_id_page_id_fk": { 565 + "name": "monitors_to_pages_page_id_page_id_fk", 566 + "tableFrom": "monitors_to_pages", 567 + "tableTo": "page", 568 + "columnsFrom": [ 569 + "page_id" 570 + ], 571 + "columnsTo": [ 572 + "id" 573 + ], 574 + "onDelete": "cascade", 575 + "onUpdate": "no action" 576 + } 577 + }, 578 + "compositePrimaryKeys": { 579 + "monitors_to_pages_monitor_id_page_id_pk": { 580 + "columns": [ 581 + "monitor_id", 582 + "page_id" 583 + ] 584 + } 585 + }, 586 + "uniqueConstraints": {} 587 + }, 588 + "user": { 589 + "name": "user", 590 + "columns": { 591 + "id": { 592 + "name": "id", 593 + "type": "integer", 594 + "primaryKey": true, 595 + "notNull": true, 596 + "autoincrement": false 597 + }, 598 + "tenant_id": { 599 + "name": "tenant_id", 600 + "type": "text(256)", 601 + "primaryKey": false, 602 + "notNull": false, 603 + "autoincrement": false 604 + }, 605 + "first_name": { 606 + "name": "first_name", 607 + "type": "text", 608 + "primaryKey": false, 609 + "notNull": false, 610 + "autoincrement": false, 611 + "default": "''" 612 + }, 613 + "last_name": { 614 + "name": "last_name", 615 + "type": "text", 616 + "primaryKey": false, 617 + "notNull": false, 618 + "autoincrement": false, 619 + "default": "''" 620 + }, 621 + "email": { 622 + "name": "email", 623 + "type": "text", 624 + "primaryKey": false, 625 + "notNull": false, 626 + "autoincrement": false, 627 + "default": "''" 628 + }, 629 + "photo_url": { 630 + "name": "photo_url", 631 + "type": "text", 632 + "primaryKey": false, 633 + "notNull": false, 634 + "autoincrement": false, 635 + "default": "''" 636 + }, 637 + "created_at": { 638 + "name": "created_at", 639 + "type": "integer", 640 + "primaryKey": false, 641 + "notNull": false, 642 + "autoincrement": false, 643 + "default": "(strftime('%s', 'now'))" 644 + }, 645 + "updated_at": { 646 + "name": "updated_at", 647 + "type": "integer", 648 + "primaryKey": false, 649 + "notNull": false, 650 + "autoincrement": false, 651 + "default": "(strftime('%s', 'now'))" 652 + } 653 + }, 654 + "indexes": { 655 + "user_tenant_id_unique": { 656 + "name": "user_tenant_id_unique", 657 + "columns": [ 658 + "tenant_id" 659 + ], 660 + "isUnique": true 661 + } 662 + }, 663 + "foreignKeys": {}, 664 + "compositePrimaryKeys": {}, 665 + "uniqueConstraints": {} 666 + }, 667 + "users_to_workspaces": { 668 + "name": "users_to_workspaces", 669 + "columns": { 670 + "user_id": { 671 + "name": "user_id", 672 + "type": "integer", 673 + "primaryKey": false, 674 + "notNull": true, 675 + "autoincrement": false 676 + }, 677 + "workspace_id": { 678 + "name": "workspace_id", 679 + "type": "integer", 680 + "primaryKey": false, 681 + "notNull": true, 682 + "autoincrement": false 683 + } 684 + }, 685 + "indexes": {}, 686 + "foreignKeys": { 687 + "users_to_workspaces_user_id_user_id_fk": { 688 + "name": "users_to_workspaces_user_id_user_id_fk", 689 + "tableFrom": "users_to_workspaces", 690 + "tableTo": "user", 691 + "columnsFrom": [ 692 + "user_id" 693 + ], 694 + "columnsTo": [ 695 + "id" 696 + ], 697 + "onDelete": "no action", 698 + "onUpdate": "no action" 699 + }, 700 + "users_to_workspaces_workspace_id_workspace_id_fk": { 701 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 702 + "tableFrom": "users_to_workspaces", 703 + "tableTo": "workspace", 704 + "columnsFrom": [ 705 + "workspace_id" 706 + ], 707 + "columnsTo": [ 708 + "id" 709 + ], 710 + "onDelete": "no action", 711 + "onUpdate": "no action" 712 + } 713 + }, 714 + "compositePrimaryKeys": { 715 + "users_to_workspaces_user_id_workspace_id_pk": { 716 + "columns": [ 717 + "user_id", 718 + "workspace_id" 719 + ] 720 + } 721 + }, 722 + "uniqueConstraints": {} 723 + }, 724 + "workspace": { 725 + "name": "workspace", 726 + "columns": { 727 + "id": { 728 + "name": "id", 729 + "type": "integer", 730 + "primaryKey": true, 731 + "notNull": true, 732 + "autoincrement": false 733 + }, 734 + "slug": { 735 + "name": "slug", 736 + "type": "text", 737 + "primaryKey": false, 738 + "notNull": true, 739 + "autoincrement": false 740 + }, 741 + "name": { 742 + "name": "name", 743 + "type": "text", 744 + "primaryKey": false, 745 + "notNull": false, 746 + "autoincrement": false 747 + }, 748 + "stripe_id": { 749 + "name": "stripe_id", 750 + "type": "text(256)", 751 + "primaryKey": false, 752 + "notNull": false, 753 + "autoincrement": false 754 + }, 755 + "subscription_id": { 756 + "name": "subscription_id", 757 + "type": "text", 758 + "primaryKey": false, 759 + "notNull": false, 760 + "autoincrement": false 761 + }, 762 + "plan": { 763 + "name": "plan", 764 + "type": "text(2)", 765 + "primaryKey": false, 766 + "notNull": false, 767 + "autoincrement": false 768 + }, 769 + "ends_at": { 770 + "name": "ends_at", 771 + "type": "integer", 772 + "primaryKey": false, 773 + "notNull": false, 774 + "autoincrement": false 775 + }, 776 + "paid_until": { 777 + "name": "paid_until", 778 + "type": "integer", 779 + "primaryKey": false, 780 + "notNull": false, 781 + "autoincrement": false 782 + }, 783 + "created_at": { 784 + "name": "created_at", 785 + "type": "integer", 786 + "primaryKey": false, 787 + "notNull": false, 788 + "autoincrement": false, 789 + "default": "(strftime('%s', 'now'))" 790 + }, 791 + "updated_at": { 792 + "name": "updated_at", 793 + "type": "integer", 794 + "primaryKey": false, 795 + "notNull": false, 796 + "autoincrement": false, 797 + "default": "(strftime('%s', 'now'))" 798 + } 799 + }, 800 + "indexes": { 801 + "workspace_slug_unique": { 802 + "name": "workspace_slug_unique", 803 + "columns": [ 804 + "slug" 805 + ], 806 + "isUnique": true 807 + }, 808 + "workspace_stripe_id_unique": { 809 + "name": "workspace_stripe_id_unique", 810 + "columns": [ 811 + "stripe_id" 812 + ], 813 + "isUnique": true 814 + } 815 + }, 816 + "foreignKeys": {}, 817 + "compositePrimaryKeys": {}, 818 + "uniqueConstraints": {} 819 + }, 820 + "notification": { 821 + "name": "notification", 822 + "columns": { 823 + "id": { 824 + "name": "id", 825 + "type": "integer", 826 + "primaryKey": true, 827 + "notNull": true, 828 + "autoincrement": false 829 + }, 830 + "name": { 831 + "name": "name", 832 + "type": "text", 833 + "primaryKey": false, 834 + "notNull": true, 835 + "autoincrement": false 836 + }, 837 + "provider": { 838 + "name": "provider", 839 + "type": "text", 840 + "primaryKey": false, 841 + "notNull": true, 842 + "autoincrement": false 843 + }, 844 + "data": { 845 + "name": "data", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": false, 849 + "autoincrement": false, 850 + "default": "'{}'" 851 + }, 852 + "workspace_id": { 853 + "name": "workspace_id", 854 + "type": "integer", 855 + "primaryKey": false, 856 + "notNull": false, 857 + "autoincrement": false 858 + }, 859 + "created_at": { 860 + "name": "created_at", 861 + "type": "integer", 862 + "primaryKey": false, 863 + "notNull": false, 864 + "autoincrement": false, 865 + "default": "(strftime('%s', 'now'))" 866 + }, 867 + "updated_at": { 868 + "name": "updated_at", 869 + "type": "integer", 870 + "primaryKey": false, 871 + "notNull": false, 872 + "autoincrement": false, 873 + "default": "(strftime('%s', 'now'))" 874 + } 875 + }, 876 + "indexes": {}, 877 + "foreignKeys": { 878 + "notification_workspace_id_workspace_id_fk": { 879 + "name": "notification_workspace_id_workspace_id_fk", 880 + "tableFrom": "notification", 881 + "tableTo": "workspace", 882 + "columnsFrom": [ 883 + "workspace_id" 884 + ], 885 + "columnsTo": [ 886 + "id" 887 + ], 888 + "onDelete": "no action", 889 + "onUpdate": "no action" 890 + } 891 + }, 892 + "compositePrimaryKeys": {}, 893 + "uniqueConstraints": {} 894 + }, 895 + "notifications_to_monitors": { 896 + "name": "notifications_to_monitors", 897 + "columns": { 898 + "monitor_id": { 899 + "name": "monitor_id", 900 + "type": "integer", 901 + "primaryKey": false, 902 + "notNull": true, 903 + "autoincrement": false 904 + }, 905 + "notificationId": { 906 + "name": "notificationId", 907 + "type": "integer", 908 + "primaryKey": false, 909 + "notNull": true, 910 + "autoincrement": false 911 + } 912 + }, 913 + "indexes": {}, 914 + "foreignKeys": { 915 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 916 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 917 + "tableFrom": "notifications_to_monitors", 918 + "tableTo": "monitor", 919 + "columnsFrom": [ 920 + "monitor_id" 921 + ], 922 + "columnsTo": [ 923 + "id" 924 + ], 925 + "onDelete": "no action", 926 + "onUpdate": "no action" 927 + }, 928 + "notifications_to_monitors_notificationId_notification_id_fk": { 929 + "name": "notifications_to_monitors_notificationId_notification_id_fk", 930 + "tableFrom": "notifications_to_monitors", 931 + "tableTo": "notification", 932 + "columnsFrom": [ 933 + "notificationId" 934 + ], 935 + "columnsTo": [ 936 + "id" 937 + ], 938 + "onDelete": "no action", 939 + "onUpdate": "no action" 940 + } 941 + }, 942 + "compositePrimaryKeys": { 943 + "notifications_to_monitors_monitor_id_notificationId_pk": { 944 + "columns": [ 945 + "monitor_id", 946 + "notificationId" 947 + ] 948 + } 949 + }, 950 + "uniqueConstraints": {} 951 + } 952 + }, 953 + "enums": {}, 954 + "_meta": { 955 + "schemas": {}, 956 + "tables": {}, 957 + "columns": {} 958 + } 959 + }
+7
packages/db/drizzle/meta/_journal.json
··· 57 57 "when": 1694362217174, 58 58 "tag": "0007_complex_frog_thor", 59 59 "breakpoints": true 60 + }, 61 + { 62 + "idx": 8, 63 + "version": "5", 64 + "when": 1695756345957, 65 + "tag": "0008_overjoyed_sunset_bain", 66 + "breakpoints": true 60 67 } 61 68 ] 62 69 }
+1
packages/db/src/schema/index.ts
··· 5 5 export * from "./user"; 6 6 export * from "./workspace"; 7 7 export * from "./shared"; 8 + export * from "./notification";
+11 -4
packages/db/src/schema/monitor.ts
··· 9 9 import { z } from "zod"; 10 10 11 11 import { monitorsToIncidents } from "./incident"; 12 + import { notificationsToMonitors } from "./notification"; 12 13 import { page } from "./page"; 13 14 import { workspace } from "./workspace"; 14 15 ··· 36 37 37 38 export const periodicity = ["1m", "5m", "10m", "30m", "1h", "other"] as const; 38 39 export const METHODS = ["GET", "POST"] as const; 39 - 40 + export const status = ["active", "error"] as const; 41 + export const statusSchema = z.enum(status); 40 42 export const RegionEnum = z.enum(availableRegions); 41 43 42 44 export const monitor = sqliteTable("monitor", { ··· 47 49 periodicity: text("periodicity", ["1m", "5m", "10m", "30m", "1h", "other"]) 48 50 .default("other") 49 51 .notNull(), 50 - status: text("status", ["active", "inactive"]).default("inactive").notNull(), 52 + status: text("status", status).default("active").notNull(), 51 53 active: integer("active", { mode: "boolean" }).default(false), 52 54 53 55 regions: text("regions").default("").notNull(), ··· 77 79 fields: [monitor.workspaceId], 78 80 references: [workspace.id], 79 81 }), 82 + monitorsToNotifications: many(notificationsToMonitors), 80 83 })); 81 84 82 85 export const monitorsToPages = sqliteTable( ··· 113 116 export const insertMonitorSchema = createInsertSchema(monitor, { 114 117 periodicity: periodicityEnum, 115 118 url: z.string().url(), 116 - status: z.enum(["active", "inactive"]).default("inactive"), 119 + status: z.enum(status).default("active"), 117 120 active: z.boolean().default(false), 118 121 regions: z.array(RegionEnum).default([]).optional(), 119 122 method: z.enum(METHODS).default("GET"), ··· 121 124 headers: z 122 125 .array(z.object({ key: z.string(), value: z.string() })) 123 126 .default([]), 127 + }).extend({ 128 + notifications: z.array(z.number()).optional(), 124 129 }); 125 130 131 + export const basicMonitorSchema = createSelectSchema(monitor); 132 + 126 133 // Schema for selecting a Monitor - can be used to validate API responses 127 134 export const selectMonitorSchema = createSelectSchema(monitor, { 128 135 periodicity: periodicityEnum, 129 - status: z.enum(["active", "inactive"]).default("inactive"), 136 + status: z.enum(status).default("active"), 130 137 jobType: z.enum(["website", "cron", "other"]).default("other"), 131 138 active: z.boolean().default(false), 132 139 regions: z
+90
packages/db/src/schema/notification.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { 3 + integer, 4 + primaryKey, 5 + sqliteTable, 6 + text, 7 + } from "drizzle-orm/sqlite-core"; 8 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 9 + import * as z from "zod"; 10 + 11 + import { monitor } from "./monitor"; 12 + import { workspace } from "./workspace"; 13 + 14 + export const providerName = ["email", "discord", "slack"] as const; 15 + 16 + export const notification = sqliteTable("notification", { 17 + id: integer("id").primaryKey(), 18 + name: text("name").notNull(), 19 + provider: text("provider", { enum: providerName }).notNull(), 20 + data: text("data").default("{}"), 21 + workspaceId: integer("workspace_id").references(() => workspace.id), 22 + createdAt: integer("created_at", { mode: "timestamp" }).default( 23 + sql`(strftime('%s', 'now'))`, 24 + ), 25 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 26 + sql`(strftime('%s', 'now'))`, 27 + ), 28 + }); 29 + 30 + export const notificationsToMonitors = sqliteTable( 31 + "notifications_to_monitors", 32 + { 33 + monitorId: integer("monitor_id") 34 + .notNull() 35 + .references(() => monitor.id, { onDelete: "cascade" }), 36 + notificationId: integer("notification_id") 37 + .notNull() 38 + .references(() => notification.id, { onDelete: "cascade" }), 39 + }, 40 + (t) => ({ 41 + pk: primaryKey(t.monitorId, t.notificationId), 42 + }), 43 + ); 44 + 45 + export const notificationsToMonitorsRelation = relations( 46 + notificationsToMonitors, 47 + ({ one }) => ({ 48 + monitor: one(monitor, { 49 + fields: [notificationsToMonitors.monitorId], 50 + references: [monitor.id], 51 + }), 52 + notification: one(notification, { 53 + fields: [notificationsToMonitors.notificationId], 54 + references: [notification.id], 55 + }), 56 + }), 57 + ); 58 + 59 + export const notificationRelations = relations( 60 + notification, 61 + ({ one, many }) => ({ 62 + workspace: one(workspace, { 63 + fields: [notification.workspaceId], 64 + references: [workspace.id], 65 + }), 66 + monitor: many(notificationsToMonitors), 67 + }), 68 + ); 69 + 70 + export const providerEnum = z.enum(providerName); 71 + 72 + export const selectNotificationSchema = createSelectSchema(notification).extend( 73 + { 74 + data: z 75 + .preprocess((val) => { 76 + return String(val); 77 + }, z.string()) 78 + .default(""), 79 + }, 80 + ); 81 + 82 + export const insertNotificationSchema = createInsertSchema(notification).extend( 83 + { 84 + data: z.string().default("").optional(), 85 + }, 86 + ); 87 + 88 + export const allNotifications = z.array(selectNotificationSchema); 89 + 90 + export type Notification = z.infer<typeof selectNotificationSchema>;
+69
packages/emails/emails/alert.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Body, 5 + Button, 6 + Column, 7 + Container, 8 + Head, 9 + Heading, 10 + Html, 11 + Preview, 12 + Row, 13 + Section, 14 + Tailwind, 15 + Text, 16 + } from "@react-email/components"; 17 + import { z } from "zod"; 18 + 19 + export const EmailDataSchema = z.object({ 20 + monitorName: z.string(), 21 + monitorUrl: z.string().url(), 22 + recipientName: z.string(), 23 + }); 24 + 25 + const Alert = ({ data }: { data: z.infer<typeof EmailDataSchema> }) => { 26 + return ( 27 + <Html> 28 + <Head> 29 + <title>New incident detected ๐Ÿšจ</title> 30 + <Preview>New incident detected : {data.monitorName} ๐Ÿšจ</Preview> 31 + <Body className="mx-auto my-auto bg-white font-sans"> 32 + <Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]"> 33 + <Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black"> 34 + New incident detected! 35 + </Heading> 36 + <Text className="text-[14px] leading-[24px] text-black"> 37 + Hello {data.recipientName}, <br /> 38 + We have detected a new incident. 39 + </Text> 40 + 41 + <Section className="my-[30px] rounded border border-solid border-gray-200 bg-gray-100 p-2"> 42 + <Row> 43 + <Column className="text-lg">Monitor</Column> 44 + <Column>{data.monitorName}</Column> 45 + </Row> 46 + <Row className="mt-2"> 47 + <Column className="text-lg">URL</Column> 48 + <Column>{data.monitorUrl}</Column> 49 + </Row> 50 + </Section> 51 + 52 + <Section className="mb-[32px] mt-[32px] text-center"> 53 + <Button 54 + pX={20} 55 + pY={12} 56 + className="rounded bg-[#000000] text-center text-[14px] font-semibold text-white no-underline" 57 + href="https://www.openstatus.dev/app" 58 + > 59 + See incident 60 + </Button> 61 + </Section> 62 + </Container> 63 + </Body> 64 + </Head> 65 + </Html> 66 + ); 67 + }; 68 + 69 + export { Alert };
+9 -2
packages/emails/index.ts
··· 1 1 import type { ReactElement } from "react"; 2 2 import { Resend } from "resend"; 3 3 4 + import { Alert, EmailDataSchema } from "./emails/alert"; 4 5 import { validateEmailNotDisposable } from "./emails/utils/utils"; 5 6 import WaitingList from "./emails/waiting-list"; 6 7 import WelcomeEmail from "./emails/welcome"; 7 8 import { env } from "./env"; 8 9 9 - export { WelcomeEmail, WaitingList, validateEmailNotDisposable }; 10 + export { 11 + WelcomeEmail, 12 + WaitingList, 13 + validateEmailNotDisposable, 14 + Alert, 15 + EmailDataSchema, 16 + }; 10 17 11 - const resend = new Resend(env.RESEND_API_KEY); 18 + export const resend = new Resend(env.RESEND_API_KEY); 12 19 13 20 export interface Emails { 14 21 react: ReactElement;
+1 -1
packages/emails/package.json
··· 17 17 "@react-email/tailwind": "0.0.8", 18 18 "@t3-oss/env-core": "0.6.0", 19 19 "react-email": "1.9.4", 20 - "resend": "0.15.3", 20 + "resend": "1.1.0", 21 21 "zod": "3.21.4" 22 22 }, 23 23 "devDependencies": {
+1
packages/notifications/email/.env.example
··· 1 + RESEND_API_KEY=1
+1
packages/notifications/email/README.md
··· 1 + #
+11
packages/notifications/email/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + RESEND_API_KEY: z.string().min(1), 7 + }, 8 + runtimeEnv: { 9 + RESEND_API_KEY: process.env.RESEND_API_KEY, 10 + }, 11 + });
+24
packages/notifications/email/package.json
··· 1 + { 2 + "name": "@openstatus/notification-emails", 3 + "version": "0.0.0", 4 + "main": "src/index.ts", 5 + "description": "Log drains Vercel integration.", 6 + "dependencies": { 7 + "@openstatus/db": "workspace:^", 8 + "@openstatus/emails": "workspace:^", 9 + "@openstatus/tinybird": "workspace:*", 10 + "@react-email/components": "0.0.7", 11 + "@react-email/render": "^0.0.7", 12 + "@t3-oss/env-core": "0.6.0", 13 + "resend": "1.1.0", 14 + "zod": "3.21.4" 15 + }, 16 + "devDependencies": { 17 + "@openstatus/tsconfig": "workspace:*", 18 + "@types/node": "20.3.1", 19 + "@types/react": "18.2.12", 20 + "@types/react-dom": "18.2.5", 21 + "next": "13.4.12", 22 + "typescript": "5.1.6" 23 + } 24 + }
+41
packages/notifications/email/src/index.ts
··· 1 + import type { z } from "zod"; 2 + 3 + import type { 4 + basicMonitorSchema, 5 + selectNotificationSchema, 6 + } from "@openstatus/db/src/schema"; 7 + 8 + import { env } from "../env"; 9 + import { EmailConfigurationSchema } from "./schema/config"; 10 + 11 + export const send = async ({ 12 + monitor, 13 + notification, 14 + }: { 15 + monitor: z.infer<typeof basicMonitorSchema>; 16 + notification: z.infer<typeof selectNotificationSchema>; 17 + }) => { 18 + const config = EmailConfigurationSchema.parse(notification.data); 19 + const { email } = config; 20 + 21 + const res = await fetch("https://api.resend.com/emails", { 22 + method: "POST", 23 + headers: { 24 + "Content-Type": "application/json", 25 + Authorization: `Bearer ${env.RESEND_API_KEY}`, 26 + }, 27 + body: JSON.stringify({ 28 + to: email, 29 + from: "Notifications <ping@openstatus.dev>", 30 + 31 + subject: `Your monitor ${monitor.name} is down ๐Ÿšจ`, 32 + html: `Hey, <br/> Your monitor ${monitor.name} is down. <br/> <br/> OpenStatus`, 33 + }), 34 + }); 35 + 36 + if (res.ok) { 37 + const data = await res.json(); 38 + console.log(data); 39 + // return NextResponse.json(data); 40 + } 41 + };
+5
packages/notifications/email/src/schema/config.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const EmailConfigurationSchema = z.object({ 4 + email: z.string().email(), 5 + });
+4
packages/notifications/email/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+7 -1
packages/ui/src/components/date-time-picker.tsx
··· 16 16 interface DateTimePickerProps { 17 17 date: Date; 18 18 setDate: (date: Date) => void; 19 + className?: string; 19 20 } 20 21 21 - export function DateTimePicker({ date, setDate }: DateTimePickerProps) { 22 + export function DateTimePicker({ 23 + date, 24 + setDate, 25 + className, 26 + }: DateTimePickerProps) { 22 27 const [selectedDateTime, setSelectedDateTime] = React.useState<DateTime>( 23 28 DateTime.fromJSDate(date), 24 29 ); ··· 52 57 className={cn( 53 58 "w-[280px] justify-start text-left font-normal", 54 59 !date && "text-muted-foreground", 60 + className, 55 61 )} 56 62 suppressHydrationWarning // because timestamp is not same, server and client 57 63 >
+130
packages/ui/src/components/multi-select.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { Command as CommandPrimitive } from "cmdk"; 5 + import { X } from "lucide-react"; 6 + 7 + import { Badge } from "./badge"; 8 + import { Command, CommandGroup, CommandItem } from "./command"; 9 + 10 + type Option = Record<"value" | "label", string | number>; 11 + 12 + export interface MultiSelectProps { 13 + options?: Option[]; 14 + onChange?: (values: Option[]) => void; 15 + placeholder?: string; 16 + } 17 + 18 + export const MultiSelect = ({ 19 + onChange, 20 + options = [], 21 + ...props 22 + }: MultiSelectProps) => { 23 + const inputRef = React.useRef<HTMLInputElement>(null); 24 + const [open, setOpen] = React.useState(false); 25 + const [selected, setSelected] = React.useState<Option[]>([]); 26 + const [inputValue, setInputValue] = React.useState(""); 27 + 28 + const handleUnselect = React.useCallback((option: Option) => { 29 + setSelected((prev) => prev.filter((s) => s.value !== option.value)); 30 + }, []); 31 + 32 + const handleKeyDown = React.useCallback( 33 + (e: React.KeyboardEvent<HTMLDivElement>) => { 34 + const input = inputRef.current; 35 + if (input) { 36 + if (e.key === "Delete" || e.key === "Backspace") { 37 + if (input.value === "") { 38 + setSelected((prev) => { 39 + const newSelected = [...prev]; 40 + newSelected.pop(); 41 + return newSelected; 42 + }); 43 + } 44 + } 45 + // This is not a default behaviour of the <input /> field 46 + if (e.key === "Escape") { 47 + input.blur(); 48 + } 49 + } 50 + }, 51 + [], 52 + ); 53 + 54 + const selectables = options.filter((option) => !selected.includes(option)); 55 + 56 + React.useEffect(() => { 57 + onChange?.(selected); 58 + // eslint-disable-next-line react-hooks/exhaustive-deps 59 + }, [selected]); 60 + 61 + return ( 62 + <Command 63 + onKeyDown={handleKeyDown} 64 + className="overflow-visible bg-transparent" 65 + > 66 + <div className="border-input ring-offset-background focus-within:ring-ring group rounded-md border px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-offset-2"> 67 + <div className="flex flex-wrap gap-1"> 68 + {selected.map((option) => { 69 + return ( 70 + <Badge key={option.value} variant="secondary"> 71 + {option.label} 72 + <button 73 + className="ring-offset-background focus:ring-ring ml-1 rounded-full outline-none focus:ring-2 focus:ring-offset-2" 74 + onKeyDown={(e) => { 75 + if (e.key === "Enter") { 76 + handleUnselect(option); 77 + } 78 + }} 79 + onMouseDown={(e) => { 80 + e.preventDefault(); 81 + e.stopPropagation(); 82 + }} 83 + onClick={() => handleUnselect(option)} 84 + > 85 + <X className="text-muted-foreground hover:text-foreground h-3 w-3" /> 86 + </button> 87 + </Badge> 88 + ); 89 + })} 90 + {/* Avoid having the "Search" Icon */} 91 + <CommandPrimitive.Input 92 + ref={inputRef} 93 + value={inputValue} 94 + onValueChange={setInputValue} 95 + onBlur={() => setOpen(false)} 96 + onFocus={() => setOpen(true)} 97 + className="placeholder:text-muted-foreground ml-2 flex-1 bg-transparent outline-none" 98 + {...props} 99 + /> 100 + </div> 101 + </div> 102 + <div className="relative mt-2"> 103 + {open && selectables.length > 0 ? ( 104 + <div className="bg-popover text-popover-foreground animate-in absolute top-0 z-10 w-full rounded-md border shadow-md outline-none"> 105 + <CommandGroup className="h-full overflow-auto"> 106 + {selectables.map((option) => { 107 + return ( 108 + <CommandItem 109 + key={option.value} 110 + onMouseDown={(e) => { 111 + e.preventDefault(); 112 + e.stopPropagation(); 113 + }} 114 + onSelect={(value) => { 115 + setInputValue(""); 116 + setSelected((prev) => [...prev, option]); 117 + }} 118 + className={"cursor-pointer"} 119 + > 120 + {option.label} 121 + </CommandItem> 122 + ); 123 + })} 124 + </CommandGroup> 125 + </div> 126 + ) : null} 127 + </div> 128 + </Command> 129 + ); 130 + };
+37 -168
packages/ui/src/index.tsx
··· 1 - export { 2 - Accordion, 3 - AccordionItem, 4 - AccordionTrigger, 5 - AccordionContent, 6 - } from "./components/accordion"; 7 - export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"; 8 - export { 9 - AlertDialog, 10 - AlertDialogTrigger, 11 - AlertDialogContent, 12 - AlertDialogHeader, 13 - AlertDialogFooter, 14 - AlertDialogTitle, 15 - AlertDialogDescription, 16 - AlertDialogAction, 17 - AlertDialogCancel, 18 - } from "./components/alert-dialog"; 19 - export { Alert, AlertTitle, AlertDescription } from "./components/alert"; 20 - export { Badge, badgeVariants } from "./components/badge"; 21 - export { ButtonWithDisableTooltip } from "./components/button-with-disable-tooltip"; 22 - export { Button, buttonVariants } from "./components/button"; 23 - export { Calendar } from "./components/calendar"; 24 - export { 25 - Card, 26 - CardHeader, 27 - CardFooter, 28 - CardTitle, 29 - CardDescription, 30 - CardContent, 31 - } from "./components/card"; 32 - export { Checkbox } from "./components/checkbox"; 33 - export { 34 - Command, 35 - CommandDialog, 36 - CommandInput, 37 - CommandList, 38 - CommandEmpty, 39 - CommandGroup, 40 - CommandItem, 41 - CommandShortcut, 42 - CommandSeparator, 43 - } from "./components/command"; 44 - 45 - export { 46 - ContextMenu, 47 - ContextMenuTrigger, 48 - ContextMenuContent, 49 - ContextMenuItem, 50 - ContextMenuCheckboxItem, 51 - ContextMenuRadioItem, 52 - ContextMenuLabel, 53 - ContextMenuSeparator, 54 - ContextMenuShortcut, 55 - ContextMenuGroup, 56 - ContextMenuPortal, 57 - ContextMenuSub, 58 - ContextMenuSubContent, 59 - ContextMenuSubTrigger, 60 - ContextMenuRadioGroup, 61 - } from "./components/context-menu"; 62 - 63 - export { DatePicker } from "./components/date-picker"; 64 - export { 65 - Dialog, 66 - DialogTrigger, 67 - DialogContent, 68 - DialogHeader, 69 - DialogFooter, 70 - DialogTitle, 71 - DialogDescription, 72 - } from "./components/dialog"; 73 - export { 74 - DropdownMenu, 75 - DropdownMenuTrigger, 76 - DropdownMenuContent, 77 - DropdownMenuItem, 78 - DropdownMenuCheckboxItem, 79 - DropdownMenuRadioItem, 80 - DropdownMenuLabel, 81 - DropdownMenuSeparator, 82 - DropdownMenuShortcut, 83 - DropdownMenuGroup, 84 - DropdownMenuPortal, 85 - DropdownMenuSub, 86 - DropdownMenuSubContent, 87 - DropdownMenuSubTrigger, 88 - DropdownMenuRadioGroup, 89 - } from "./components/dropdown-menu"; 90 - 91 - export { 92 - useFormField, 93 - Form, 94 - FormItem, 95 - FormLabel, 96 - FormControl, 97 - FormDescription, 98 - FormMessage, 99 - FormField, 100 - } from "./components/form"; 101 - export { 102 - HoverCard, 103 - HoverCardTrigger, 104 - HoverCardContent, 105 - } from "./components/hover-card"; 106 - export { InputWithAddons } from "./components/input-with-addons"; 107 - export { Input } from "./components/input"; 108 - export { Label } from "./components/label"; 109 - export { Popover, PopoverTrigger, PopoverContent } from "./components/popover"; 110 - export { RadioGroup, RadioGroupItem } from "./components/radio-group"; 111 - export { 112 - Select, 113 - SelectGroup, 114 - SelectValue, 115 - SelectTrigger, 116 - SelectContent, 117 - SelectLabel, 118 - SelectItem, 119 - SelectSeparator, 120 - } from "./components/select"; 121 - export { Separator } from "./components/separator"; 122 - export { Skeleton } from "./components/skeleton"; 123 - export { Switch } from "./components/switch"; 124 - export { 125 - Table, 126 - TableHeader, 127 - TableBody, 128 - TableFooter, 129 - TableHead, 130 - TableRow, 131 - TableCell, 132 - TableCaption, 133 - } from "./components/table"; 134 - 135 - export { Textarea } from "./components/textarea"; 136 - export { 137 - type ToastProps, 138 - type ToastActionElement, 139 - ToastProvider, 140 - ToastViewport, 141 - Toast, 142 - ToastTitle, 143 - ToastDescription, 144 - ToastClose, 145 - ToastAction, 146 - } from "./components/toast"; 147 - export { Toaster } from "./components/toaster"; 148 - export { toast, useToast } from "./components/use-toast"; 149 - export { 150 - Tooltip, 151 - TooltipTrigger, 152 - TooltipContent, 153 - TooltipProvider, 154 - } from "./components/tooltip"; 155 - export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/tabs"; 156 - export { DateTimePicker } from "./components/date-time-picker"; 157 - export { 158 - Sheet, 159 - SheetTrigger, 160 - SheetClose, 161 - SheetContent, 162 - SheetHeader, 163 - SheetFooter, 164 - SheetTitle, 165 - SheetDescription, 166 - } from "./components/sheet"; 167 - 168 - export type { ToastType } from "./components/use-toast"; 1 + export * from "./components/accordion"; 2 + export * from "./components/avatar"; 3 + export * from "./components/alert-dialog"; 4 + export * from "./components/alert"; 5 + export * from "./components/badge"; 6 + export * from "./components/button-with-disable-tooltip"; 7 + export * from "./components/button"; 8 + export * from "./components/calendar"; 9 + export * from "./components/card"; 10 + export * from "./components/checkbox"; 11 + export * from "./components/command"; 12 + export * from "./components/context-menu"; 13 + export * from "./components/date-picker"; 14 + export * from "./components/dialog"; 15 + export * from "./components/dropdown-menu"; 16 + export * from "./components/form"; 17 + export * from "./components/hover-card"; 18 + export * from "./components/input-with-addons"; 19 + export * from "./components/input"; 20 + export * from "./components/label"; 21 + export * from "./components/popover"; 22 + export * from "./components/radio-group"; 23 + export * from "./components/select"; 24 + export * from "./components/separator"; 25 + export * from "./components/skeleton"; 26 + export * from "./components/switch"; 27 + export * from "./components/table"; 28 + export * from "./components/textarea"; 29 + export * from "./components/toast"; 30 + export * from "./components/toaster"; 31 + export * from "./components/use-toast"; 32 + export * from "./components/tooltip"; 33 + export * from "./components/tabs"; 34 + export * from "./components/date-time-picker"; 35 + export * from "./components/sheet"; 36 + export * from "./components/use-toast"; 37 + export * from "./components/multi-select";
+63 -28
pnpm-lock.yaml
··· 115 115 '@openstatus/emails': 116 116 specifier: workspace:* 117 117 version: link:../../packages/emails 118 + '@openstatus/notification-emails': 119 + specifier: workspace:* 120 + version: link:../../packages/notifications/email 118 121 '@openstatus/plans': 119 122 specifier: workspace:* 120 123 version: link:../../packages/plans ··· 242 245 specifier: 10.1.0 243 246 version: 10.1.0 244 247 resend: 245 - specifier: 0.15.3 246 - version: 0.15.3 248 + specifier: 1.1.0 249 + version: 1.1.0 247 250 shiki: 248 251 specifier: 0.14.3 249 252 version: 0.14.3 ··· 487 490 specifier: 1.9.4 488 491 version: 1.9.4 489 492 resend: 490 - specifier: 0.15.3 491 - version: 0.15.3 493 + specifier: 1.1.0 494 + version: 1.1.0 492 495 zod: 493 496 specifier: 3.21.4 494 497 version: 3.21.4 ··· 546 549 specifier: 5.1.6 547 550 version: 5.1.6 548 551 552 + packages/notifications/email: 553 + dependencies: 554 + '@openstatus/db': 555 + specifier: workspace:^ 556 + version: link:../../db 557 + '@openstatus/emails': 558 + specifier: workspace:^ 559 + version: link:../../emails 560 + '@openstatus/tinybird': 561 + specifier: workspace:* 562 + version: link:../../tinybird 563 + '@react-email/components': 564 + specifier: 0.0.7 565 + version: 0.0.7 566 + '@react-email/render': 567 + specifier: ^0.0.7 568 + version: 0.0.7 569 + '@t3-oss/env-core': 570 + specifier: 0.6.0 571 + version: 0.6.0(typescript@5.1.6)(zod@3.21.4) 572 + resend: 573 + specifier: 1.1.0 574 + version: 1.1.0 575 + zod: 576 + specifier: 3.21.4 577 + version: 3.21.4 578 + devDependencies: 579 + '@openstatus/tsconfig': 580 + specifier: workspace:* 581 + version: link:../../tsconfig 582 + '@types/node': 583 + specifier: 20.3.1 584 + version: 20.3.1 585 + '@types/react': 586 + specifier: 18.2.12 587 + version: 18.2.12 588 + '@types/react-dom': 589 + specifier: 18.2.5 590 + version: 18.2.5 591 + next: 592 + specifier: 13.4.12 593 + version: 13.4.12(@babel/core@7.22.20)(react-dom@18.2.0)(react@18.2.0) 594 + typescript: 595 + specifier: 5.1.6 596 + version: 5.1.6 597 + 549 598 packages/plans: 550 599 dependencies: 551 600 '@openstatus/db': ··· 5423 5472 /@types/react-dom@18.2.5: 5424 5473 resolution: {integrity: sha512-sRQsOS/sCLnpQhR4DSKGTtWFE3FZjpQa86KPVbhUqdYMRZ9FEFcfAytKhR/vUG2rH1oFbOOej6cuD7MFSobDRQ==} 5425 5474 dependencies: 5426 - '@types/react': 18.2.21 5475 + '@types/react': 18.2.12 5427 5476 dev: true 5428 5477 5429 5478 /@types/react-dom@18.2.7: ··· 5437 5486 '@types/prop-types': 15.7.6 5438 5487 '@types/scheduler': 0.16.3 5439 5488 csstype: 3.1.2 5440 - 5441 - /@types/react@18.2.21: 5442 - resolution: {integrity: sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==} 5443 - dependencies: 5444 - '@types/prop-types': 15.7.6 5445 - '@types/scheduler': 0.16.3 5446 - csstype: 3.1.2 5447 - dev: true 5448 5489 5449 5490 /@types/resolve@1.20.2: 5450 5491 resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} ··· 6072 6113 engines: {node: '>=4'} 6073 6114 dev: false 6074 6115 6075 - /axios@1.4.0: 6076 - resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} 6077 - dependencies: 6078 - follow-redirects: 1.15.2 6079 - form-data: 4.0.0 6080 - proxy-from-env: 1.1.0 6081 - transitivePeerDependencies: 6082 - - debug 6083 - dev: false 6084 - 6085 6116 /axios@1.5.0: 6086 6117 resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==} 6087 6118 dependencies: ··· 12889 12920 resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} 12890 12921 dev: false 12891 12922 12892 - /resend@0.15.3: 12893 - resolution: {integrity: sha512-dzXHBaOjOS3ufBGjqiJjMe7LnproEn/jiAyBjg6IBtp56MCfih0O4IS4JeBcfj3y2Afm+tZITfoZAylKWLEJOg==} 12923 + /resend@1.1.0: 12924 + resolution: {integrity: sha512-it8TIDVT+/gAiJsUlv2tdHuvzwCCv4Zwu+udDqIm/dIuByQwe68TtFDcPccxqpSVVrNCBxxXLzsdT1tsV+P3GA==} 12925 + engines: {node: '>=18'} 12894 12926 dependencies: 12895 12927 '@react-email/render': 0.0.7 12896 - axios: 1.4.0 12897 - transitivePeerDependencies: 12898 - - debug 12928 + type-fest: 3.13.0 12899 12929 dev: false 12900 12930 12901 12931 /resolve-from@4.0.0: ··· 14178 14208 /type-fest@2.19.0: 14179 14209 resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} 14180 14210 engines: {node: '>=12.20'} 14211 + dev: false 14212 + 14213 + /type-fest@3.13.0: 14214 + resolution: {integrity: sha512-Gur3yQGM9qiLNs0KPP7LPgeRbio2QTt4xXouobMCarR0/wyW3F+F/+OWwshg3NG0Adon7uQfSZBpB46NfhoF1A==} 14215 + engines: {node: '>=14.16'} 14181 14216 dev: false 14182 14217 14183 14218 /type-fest@3.13.1: