diff --git a/apps/web/package.json b/apps/web/package.json index 0d7849ac..00667ac9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,9 +17,10 @@ "@openstatus/db": "workspace:*", "@openstatus/emails": "workspace:*", "@openstatus/plans": "workspace:*", + "@openstatus/notification-emails": "workspace:*", "@openstatus/tinybird": "workspace:*", - "@openstatus/upstash": "workspace:*", "@openstatus/ui": "workspace:*", + "@openstatus/upstash": "workspace:*", "@openstatus/vercel": "workspace:*", "@sentry/integrations": "7.65.0", "@sentry/nextjs": "7.65.0", @@ -58,7 +59,7 @@ "rehype-react": "7.2.0", "remark-parse": "10.0.2", "remark-rehype": "10.1.0", - "resend": "0.15.3", + "resend": "1.1.0", "shiki": "0.14.3", "stripe": "12.17.0", "superjson": "1.13.1", diff --git a/apps/web/src/app/api/checker/cron/_cron.ts b/apps/web/src/app/api/checker/cron/_cron.ts index db4f313e..5148f12d 100644 --- a/apps/web/src/app/api/checker/cron/_cron.ts +++ b/apps/web/src/app/api/checker/cron/_cron.ts @@ -57,6 +57,7 @@ export const cron = async ({ headers: row.headers, body: row.body, cronTimestamp: timestamp, + status: row.status, pageIds: allPages.map((p) => String(p.pageId)), }; @@ -79,6 +80,7 @@ export const cron = async ({ body: row.body, headers: row.headers, pageIds: allPages.map((p) => String(p.pageId)), + status: row.status, }; const result = c.publishJSON({ @@ -100,6 +102,7 @@ export const cron = async ({ cronTimestamp: timestamp, method: "GET", pageIds: ["openstatus"], + status: "active", }; // TODO: fetch + try - catch + retry once diff --git a/apps/web/src/app/api/checker/regions/_checker.ts b/apps/web/src/app/api/checker/regions/_checker.ts index f24d933c..e512955c 100644 --- a/apps/web/src/app/api/checker/regions/_checker.ts +++ b/apps/web/src/app/api/checker/regions/_checker.ts @@ -2,6 +2,8 @@ import { Receiver } from "@upstash/qstash/cloudflare"; import { nanoid } from "nanoid"; import type { z } from "zod"; +import { db, eq, schema } from "@openstatus/db"; +import { selectNotificationSchema } from "@openstatus/db/src/schema"; import { publishPingResponse, tbIngestPingResponse, @@ -11,6 +13,7 @@ import { import { env } from "@/env"; import type { Payload } from "../schema"; import { payloadSchema } from "../schema"; +import { providerToFunction } from "../utils"; export const monitorSchema = tbIngestPingResponse.pick({ url: true, @@ -85,6 +88,14 @@ export const checker = async (request: Request, region: string) => { const endTime = Date.now(); const latency = endTime - startTime; await monitor(res, result.data, region, latency); + if (res.ok) { + if (result.data?.status === "error") { + await updateMonitorStatus({ + monitorId: result.data.monitorId, + status: "active", + }); + } + } } catch (e) { console.error(e); // if on the third retry we still get an error, we should report it @@ -95,6 +106,14 @@ export const checker = async (request: Request, region: string) => { region, -1, ); + if (result.data?.status !== "error") { + await triggerAlerting({ monitorId: result.data.monitorId }); + await updateMonitorStatus({ + monitorId: result.data.monitorId, + status: "error", + }); + } + // Here we do the alerting} } } }; @@ -121,3 +140,39 @@ export const ping = async ( return res; }; + +const triggerAlerting = async ({ monitorId }: { monitorId: string }) => { + const notifications = await db + .select() + .from(schema.notificationsToMonitors) + .innerJoin( + schema.notification, + eq(schema.notification.id, schema.notificationsToMonitors.notificationId), + ) + .innerJoin( + schema.monitor, + eq(schema.monitor.id, schema.notificationsToMonitors.monitorId), + ) + .where(eq(schema.monitor.id, Number(monitorId))) + .all(); + for (const notif of notifications) { + await providerToFunction[notif.notification.provider]({ + monitor: notif.monitor, + notification: selectNotificationSchema.parse(notif.notification), + }); + } +}; + +const updateMonitorStatus = async ({ + monitorId, + status, +}: { + monitorId: string; + status: z.infer; +}) => { + await db + .update(schema.monitor) + .set({ status }) + .where(eq(schema.monitor.id, Number(monitorId))) + .run(); +}; diff --git a/apps/web/src/app/api/checker/schema.ts b/apps/web/src/app/api/checker/schema.ts index b5dad10b..d5875ca6 100644 --- a/apps/web/src/app/api/checker/schema.ts +++ b/apps/web/src/app/api/checker/schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { METHODS } from "@openstatus/db/src/schema"; +import { METHODS, status } from "@openstatus/db/src/schema"; export const payloadSchema = z.object({ workspaceId: z.string(), @@ -11,6 +11,7 @@ export const payloadSchema = z.object({ url: z.string(), cronTimestamp: z.number(), pageIds: z.array(z.string()), + status: z.enum(status), }); export type Payload = z.infer; diff --git a/apps/web/src/app/api/checker/utils.ts b/apps/web/src/app/api/checker/utils.ts new file mode 100644 index 00000000..4b073c14 --- /dev/null +++ b/apps/web/src/app/api/checker/utils.ts @@ -0,0 +1,40 @@ +import type { z } from "zod"; + +import type { + basicMonitorSchema, + providerName, + selectNotificationSchema, +} from "@openstatus/db/src/schema"; +import { send as sendEmail } from "@openstatus/notification-emails"; + +type ProviderName = (typeof providerName)[number]; + +type sendNotificationType = ({ + monitor, + notification, +}: { + monitor: z.infer; + notification: z.infer; +}) => Promise; + +export const providerToFunction = { + email: sendEmail, + slack: async ({ + monitor, + notification, + }: { + monitor: any; + notification: any; + }) => { + /* TODO: implement */ + }, + discord: async ({ + monitor, + notification, + }: { + monitor: any; + notification: any; + }) => { + /* TODO: implement */ + }, +} satisfies Record; diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx index 43bed916..f6284fc7 100644 --- a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/monitors/edit/page.tsx @@ -1,8 +1,6 @@ import { notFound } from "next/navigation"; import * as z from "zod"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@openstatus/ui"; - import { Header } from "@/components/dashboard/header"; import { MonitorForm } from "@/components/forms/montitor-form"; import { api } from "@/trpc/server"; @@ -28,12 +26,24 @@ export default async function EditPage({ } const { id } = search.data; + const { workspaceSlug } = params; const monitor = id && (await api.monitor.getMonitorByID.query({ id })); const workspace = await api.workspace.getWorkspace.query({ - slug: params.workspaceSlug, + slug: workspaceSlug, }); + const monitorNotifications = id + ? await api.monitor.getAllNotificationsForMonitor.query({ + id, + }) + : []; + + const notifications = + await api.notification.getNotificationsByWorkspace.query({ + workspaceSlug, + }); + return (
id), + } + : undefined + } plan={workspace?.plan} + {...{ workspaceSlug, notifications }} />
diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/_components/empty-state.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/_components/empty-state.tsx new file mode 100644 index 00000000..a228a1ac --- /dev/null +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/_components/empty-state.tsx @@ -0,0 +1,20 @@ +import Link from "next/link"; + +import { Button } from "@openstatus/ui"; + +import { EmptyState as DefaultEmptyState } from "@/components/dashboard/empty-state"; + +export function EmptyState() { + return ( + + Create + + } + /> + ); +} diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/loading.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/loading.tsx new file mode 100644 index 00000000..41fdbbaa --- /dev/null +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@openstatus/ui"; + +import { Header } from "@/components/dashboard/header"; +import { SkeletonForm } from "@/components/forms/skeleton-form"; + +export default function Loading() { + return ( +
+
+ + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx new file mode 100644 index 00000000..6b9be41d --- /dev/null +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/edit/page.tsx @@ -0,0 +1,51 @@ +import { notFound } from "next/navigation"; +import * as z from "zod"; + +import { Header } from "@/components/dashboard/header"; +import { NotificationForm } from "@/components/forms/notification-form"; +import { api } from "@/trpc/server"; + +/** + * allowed URL search params + */ +const searchParamsSchema = z.object({ + id: z.coerce.number().optional(), +}); + +export default async function EditPage({ + params, + searchParams, +}: { + params: { workspaceSlug: string }; + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const search = searchParamsSchema.safeParse(searchParams); + + if (!search.success) { + return notFound(); + } + + const { id } = search.data; + + const notification = + id && (await api.notification.getNotificationById.query({ id })); + + return ( +
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/loading.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/loading.tsx new file mode 100644 index 00000000..5fab49f4 --- /dev/null +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@openstatus/ui"; + +import { Header } from "@/components/dashboard/header"; +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; + +export default function Loading() { + return ( +
+
+ + + +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx new file mode 100644 index 00000000..cf5e5ba7 --- /dev/null +++ b/apps/web/src/app/app/(dashboard)/[workspaceSlug]/notifications/page.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import Link from "next/link"; + +import { Button } from "@openstatus/ui"; + +import { Header } from "@/components/dashboard/header"; +import { HelpCallout } from "@/components/dashboard/help-callout"; +import { columns } from "@/components/data-table/notification/columns"; +import { DataTable } from "@/components/data-table/notification/data-table"; +import { api } from "@/trpc/server"; +import { EmptyState } from "./_components/empty-state"; + +export default async function MonitorPage({ + params, +}: { + params: { workspaceSlug: string }; +}) { + const notifications = + await api.notification.getNotificationsByWorkspace.query({ + workspaceSlug: params.workspaceSlug, + }); + + return ( +
+
+ Create + + } + /> + {notifications && notifications.length > 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/data-table/notification/columns.tsx b/apps/web/src/components/data-table/notification/columns.tsx new file mode 100644 index 00000000..05a5f87b --- /dev/null +++ b/apps/web/src/components/data-table/notification/columns.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; + +import type { Notification } from "@openstatus/db/src/schema"; +import { Badge } from "@openstatus/ui"; + +import { DataTableRowActions } from "./data-table-row-actions"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "provider", + header: "Provider", + cell: ({ row }) => { + return ( + + {row.getValue("provider")} + + ); + }, + }, + // { + // accessorKey: "data", + // header: "Data", + // }, + { + id: "actions", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, +]; diff --git a/apps/web/src/components/data-table/notification/data-table-row-actions.tsx b/apps/web/src/components/data-table/notification/data-table-row-actions.tsx new file mode 100644 index 00000000..d6f03ca0 --- /dev/null +++ b/apps/web/src/components/data-table/notification/data-table-row-actions.tsx @@ -0,0 +1,108 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { Row } from "@tanstack/react-table"; +import { MoreHorizontal } from "lucide-react"; + +import { selectNotificationSchema } from "@openstatus/db/src/schema"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@openstatus/ui"; + +import { LoadingAnimation } from "@/components/loading-animation"; +import { useToastAction } from "@/hooks/use-toast-action"; +import { api } from "@/trpc/client"; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const notification = selectNotificationSchema.parse(row.original); + const router = useRouter(); + const { toast } = useToastAction(); + const [alertOpen, setAlertOpen] = React.useState(false); + const [isPending, startTransition] = React.useTransition(); + + async function onDelete() { + startTransition(async () => { + console.log({ notification }); + try { + if (!notification.id) return; + await api.notification.deleteNotification.mutate({ + id: notification.id, + }); + toast("deleted"); + router.refresh(); + setAlertOpen(false); + } catch { + toast("error"); + } + }); + } + + return ( + setAlertOpen(value)}> + + + + + + + Edit + + + + Delete + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the + notification. + + + + Cancel + { + e.preventDefault(); + onDelete(); + }} + disabled={isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {!isPending ? "Delete" : } + + + + + ); +} diff --git a/apps/web/src/components/data-table/notification/data-table.tsx b/apps/web/src/components/data-table/notification/data-table.tsx new file mode 100644 index 00000000..bbe2814b --- /dev/null +++ b/apps/web/src/components/data-table/notification/data-table.tsx @@ -0,0 +1,81 @@ +"use client"; + +import * as React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@openstatus/ui"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/forms/incident-form.tsx b/apps/web/src/components/forms/incident-form.tsx index b51db5d1..38cc3285 100644 --- a/apps/web/src/components/forms/incident-form.tsx +++ b/apps/web/src/components/forms/incident-form.tsx @@ -13,6 +13,10 @@ import { StatusEnum, } from "@openstatus/db/src/schema"; import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, Button, Checkbox, DateTimePicker, @@ -116,181 +120,217 @@ export function IncidentForm({ e.preventDefault(); form.handleSubmit(onSubmit)(e); }} - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" + className="grid w-full gap-6" > - ( - - Title - - - - The title of your page. - - - )} - /> - ( - - Status - Select the current status. - - - field.onChange(StatusEnum.parse(value)) - } // value is a string - defaultValue={field.value} - className="grid grid-cols-2 gap-4 sm:grid-cols-4 sm:gap-8" - > - {availableStatus.map((status) => { - const { value, label, icon } = statusDict[status]; - const Icon = Icons[icon]; - return ( - - - - - -
- - {label} -
-
-
- ); - })} -
-
- )} - /> - {/* include update on creation */} - {!defaultValues ? ( -
+
+
+

Inform

+

+ Keep your users informed about what just happened. +

+
+
( - Message - - - Write - Preview - - - -