diff --git a/.gitignore b/.gitignore index 924ba9d16..8ea6e109c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ packages/db/src/drizzle/meta/0000_snapshot.json packages/db/src/drizzle/meta/_journal.json packages/db/src/drizzle/meta packages/db/src/drizzle/*.sql +!packages/db/src/drizzle/0001_alarms.sql dist diff --git a/apps/dashboard/app/(main)/settings/notifications/page.tsx b/apps/dashboard/app/(main)/settings/notifications/page.tsx index cbc5a65b4..600f598c2 100644 --- a/apps/dashboard/app/(main)/settings/notifications/page.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/page.tsx @@ -1,39 +1,263 @@ "use client"; -import { BellIcon } from "@phosphor-icons/react"; +import { + BellIcon, + CheckCircleIcon, + PencilSimpleIcon, + PaperPlaneTiltIcon, + PlusIcon, + TrashIcon, + WarningCircleIcon, +} from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { toast } from "sonner"; +import { AlarmDialog } from "@/components/alarms/alarm-dialog"; +import { EmptyState } from "@/components/empty-state"; import { RightSidebar } from "@/components/right-sidebar"; -import { ComingSoon } from "../_components/settings-section"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DeleteDialog } from "@/components/ui/delete-dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { useAlarms, useDeleteAlarm, useTestAlarm, useUpdateAlarm } from "@/hooks/use-alarms"; +import { useWebsitesLight } from "@/hooks/use-websites"; +import { SettingsSection } from "../_components/settings-section"; + +const channelBadgeVariants: Record = { + slack: "default", + discord: "gray", + email: "green", + webhook: "amber", +}; export default function NotificationsSettingsPage() { + const { alarms, isLoading } = useAlarms(); + const { websites } = useWebsitesLight(); + const updateAlarm = useUpdateAlarm(); + const deleteAlarm = useDeleteAlarm(); + const testAlarm = useTestAlarm(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAlarmId, setEditingAlarmId] = useState(null); + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [testingId, setTestingId] = useState(null); + const searchParams = useSearchParams(); + + const editingAlarm = alarms.find((alarm) => alarm.id === editingAlarmId) ?? null; + const deleteTarget = alarms.find((alarm) => alarm.id === deleteTargetId) ?? null; + + const websiteLookup = new Map( + websites.map((site) => [site.id, site.name || site.domain]) + ); + + const isDialogForcedOpen = searchParams.get("alarmDialog") === "open"; + + useEffect(() => { + if (isDialogForcedOpen) { + setDialogOpen(true); + } + }, [isDialogForcedOpen]); + + const handleToggle = async (alarmId: string, enabled: boolean) => { + try { + await updateAlarm.mutateAsync({ id: alarmId, enabled }); + toast.success(enabled ? "Alarm enabled" : "Alarm disabled"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update alarm"; + toast.error(message); + } + }; + + const handleDelete = async () => { + if (!deleteTargetId) { + return; + } + try { + await deleteAlarm.mutateAsync({ id: deleteTargetId }); + toast.success("Alarm deleted"); + setDeleteTargetId(null); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete alarm"; + toast.error(message); + } + }; + + const handleTest = async (alarmId: string) => { + setTestingId(alarmId); + try { + const result = await testAlarm.mutateAsync({ id: alarmId }); + const successes = result.results.filter((entry) => entry.success).length; + if (successes > 0) { + toast.success(`Test sent to ${successes} channel(s).`); + } else { + toast.error("Test notification failed."); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send test notification"; + toast.error(message); + } finally { + setTestingId(null); + } + }; + return (
- - } - title="Notifications Coming Soon" - /> + +
+
+
+

Notification alarms

+

+ Manage your alerting rules and channels. +

+
+ +
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : alarms.length === 0 ? ( + setDialogOpen(true) }} + description="Add your first alarm to receive uptime and analytics notifications." + icon={} + title="No alarms yet" + variant="minimal" + /> + ) : ( +
+ {alarms.map((alarm) => ( +
+
+
+
+

{alarm.name}

+ {alarm.enabled ? ( + + ) : ( + + )} +
+ {alarm.description && ( +

+ {alarm.description} +

+ )} +
+ {alarm.triggerType} + {alarm.triggerType === "uptime" && alarm.triggerConditions?.failureThreshold && ( + + {alarm.triggerConditions.failureThreshold} failures + + )} + {alarm.websiteId && ( + + {websiteLookup.get(alarm.websiteId) ?? "Website"} + + )} + {alarm.notificationChannels.map((channel) => ( + + {channel} + + ))} +
+
+ +
+
+ handleToggle(alarm.id, checked)} + /> + + {alarm.enabled ? "Enabled" : "Disabled"} + +
+ + + +
+
+
+ ))} +
+ )} +
+
- +
-

• Traffic spike alerts

-

• Goal completion notifications

-

• Error rate warnings

-

• Weekly digest emails

+

• Connect Slack, Discord, Email, or webhook channels

+

• Assign alarms to specific websites

+

• Test delivery before enabling

+

• Adjust failure thresholds to avoid noise

- - +
+ + { + if (!open) { + setEditingAlarmId(null); + } + setDialogOpen(open); + }} + onSaved={() => setEditingAlarmId(null)} + open={dialogOpen} + /> + + setDeleteTargetId(null)} + onConfirm={handleDelete} + isDeleting={deleteAlarm.isPending} + isOpen={!!deleteTargetId} + title="Delete alarm" + itemName={deleteTarget?.name} + />
); } diff --git a/apps/dashboard/app/(main)/websites/[id]/pulse/_components/alarm-assignments.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/alarm-assignments.tsx new file mode 100644 index 000000000..e5cb0089a --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/pulse/_components/alarm-assignments.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { + BellIcon, + CheckCircleIcon, + PlusIcon, + XCircleIcon, +} from "@phosphor-icons/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlarmDialog } from "@/components/alarms/alarm-dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useAlarms, useUpdateAlarm, type Alarm } from "@/hooks/use-alarms"; + +interface AlarmAssignmentsProps { + websiteId: string; +} + +export function AlarmAssignments({ websiteId }: AlarmAssignmentsProps) { + const { alarms, isLoading } = useAlarms(); + const updateAlarm = useUpdateAlarm(); + const [selectedAlarmId, setSelectedAlarmId] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + + const assigned = alarms.filter((alarm) => alarm.websiteId === websiteId); + const available = alarms.filter((alarm) => !alarm.websiteId); + + const handleAssign = async (alarmId: string) => { + if (!alarmId) { + return; + } + try { + await updateAlarm.mutateAsync({ id: alarmId, websiteId }); + toast.success("Alarm assigned to website"); + setSelectedAlarmId(""); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to assign alarm"; + toast.error(message); + } + }; + + const handleUnassign = async (alarm: Alarm) => { + try { + await updateAlarm.mutateAsync({ id: alarm.id, websiteId: null }); + toast.success("Alarm removed from website"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to remove alarm"; + toast.error(message); + } + }; + + return ( +
+
+
+
+ +

Assigned alarms

+
+

+ Attach alarms to this website to receive uptime notifications. +

+
+ +
+ +
+ + + {assigned.length === 0 ? ( +
+ No alarms assigned yet. +
+ ) : ( +
+ {assigned.map((alarm) => ( +
+
+
+

{alarm.name}

+ {alarm.enabled ? ( + + ) : ( + + )} +
+
+ {alarm.notificationChannels.map((channel) => ( + + {channel} + + ))} +
+
+ +
+ ))} +
+ )} +
+ + setDialogOpen(false)} + open={dialogOpen} + /> +
+ ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx b/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx index ae6b67f77..5f79a1962 100644 --- a/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/pulse/page.tsx @@ -31,6 +31,7 @@ import { useWebsite } from "@/hooks/use-websites"; import { orpc } from "@/lib/orpc"; import { fromNow, localDayjs } from "@/lib/time"; import { WebsitePageHeader } from "../_components/website-page-header"; +import { AlarmAssignments } from "./_components/alarm-assignments"; import { RecentActivity } from "./_components/recent-activity"; import { UptimeHeatmap } from "./_components/uptime-heatmap"; @@ -359,6 +360,10 @@ export default function PulsePage() { isLoading={isLoadingUptime} /> + +
+ +
) : (
diff --git a/apps/dashboard/app/(main)/websites/[id]/uptime/page.tsx b/apps/dashboard/app/(main)/websites/[id]/uptime/page.tsx new file mode 100644 index 000000000..dad2e4b9a --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/uptime/page.tsx @@ -0,0 +1 @@ +export { default } from "../pulse/page"; diff --git a/apps/dashboard/components/alarms/alarm-dialog.tsx b/apps/dashboard/components/alarms/alarm-dialog.tsx new file mode 100644 index 000000000..b059465ef --- /dev/null +++ b/apps/dashboard/components/alarms/alarm-dialog.tsx @@ -0,0 +1,644 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { + BellIcon, + LightningIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useEffect, useMemo } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { TagsChat } from "@/components/ui/tags"; +import { Textarea } from "@/components/ui/textarea"; +import { useWebsitesLight } from "@/hooks/use-websites"; +import { useCreateAlarm, useUpdateAlarm, type Alarm } from "@/hooks/use-alarms"; +import { cn } from "@/lib/utils"; + +const alarmChannelOptions = [ + { value: "slack", label: "Slack" }, + { value: "discord", label: "Discord" }, + { value: "email", label: "Email" }, + { value: "webhook", label: "Webhook" }, +] as const; + +const triggerTypeOptions = [ + { value: "uptime", label: "Uptime" }, + { value: "traffic_spike", label: "Traffic Spike" }, + { value: "error_rate", label: "Error Rate" }, + { value: "goal", label: "Goal" }, + { value: "custom", label: "Custom" }, +] as const; + +const alarmFormSchema = z + .object({ + name: z.string().min(1, "Name is required").max(200), + description: z.string().max(500).optional(), + enabled: z.boolean(), + websiteId: z.string().optional().nullable(), + triggerType: z.enum([ + "uptime", + "traffic_spike", + "error_rate", + "goal", + "custom", + ]), + failureThreshold: z + .number() + .int() + .min(1, "Must be at least 1") + .max(20, "Must be 20 or fewer") + .optional(), + notificationChannels: z + .array(z.enum(["slack", "discord", "email", "webhook"])) + .min(1, "Select at least one notification channel"), + slackWebhookUrl: z.string().url().optional().nullable(), + discordWebhookUrl: z.string().url().optional().nullable(), + emailAddresses: z.array(z.string().email()).default([]), + webhookUrl: z.string().url().optional().nullable(), + webhookHeaders: z.array( + z.object({ + key: z.string().min(1, "Header key required"), + value: z.string().min(1, "Header value required"), + }) + ), + }) + .superRefine((data, ctx) => { + if (data.notificationChannels.includes("slack") && !data.slackWebhookUrl) { + ctx.addIssue({ + code: "custom", + message: "Slack webhook URL is required", + path: ["slackWebhookUrl"], + }); + } + if ( + data.notificationChannels.includes("discord") && + !data.discordWebhookUrl + ) { + ctx.addIssue({ + code: "custom", + message: "Discord webhook URL is required", + path: ["discordWebhookUrl"], + }); + } + if (data.notificationChannels.includes("email") && data.emailAddresses.length === 0) { + ctx.addIssue({ + code: "custom", + message: "Add at least one email address", + path: ["emailAddresses"], + }); + } + if (data.notificationChannels.includes("webhook") && !data.webhookUrl) { + ctx.addIssue({ + code: "custom", + message: "Webhook URL is required", + path: ["webhookUrl"], + }); + } + if (data.triggerType === "uptime" && !data.failureThreshold) { + ctx.addIssue({ + code: "custom", + message: "Set a failure threshold for uptime alarms", + path: ["failureThreshold"], + }); + } + }); + +type AlarmFormValues = z.infer; + +const defaultValues: AlarmFormValues = { + name: "", + description: "", + enabled: true, + websiteId: null, + triggerType: "uptime", + failureThreshold: 3, + notificationChannels: ["email"], + slackWebhookUrl: null, + discordWebhookUrl: null, + emailAddresses: [], + webhookUrl: null, + webhookHeaders: [], +}; + +function mapHeadersToArray(headers: Record | null): Array<{ key: string; value: string }> { + if (!headers) { + return []; + } + return Object.entries(headers).map(([key, value]) => ({ key, value })); +} + +function mapHeadersToRecord(headers: Array<{ key: string; value: string }>): Record { + return headers.reduce>((acc, header) => { + const trimmedKey = header.key.trim(); + const trimmedValue = header.value.trim(); + if (trimmedKey && trimmedValue) { + acc[trimmedKey] = trimmedValue; + } + return acc; + }, {}); +} + +interface AlarmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + alarm?: Alarm | null; + defaultWebsiteId?: string; + lockWebsite?: boolean; + onSaved?: () => void; +} + +export function AlarmDialog({ + open, + onOpenChange, + alarm, + defaultWebsiteId, + lockWebsite = false, + onSaved, +}: AlarmDialogProps) { + const { activeOrganization } = useOrganizationsContext(); + const { websites } = useWebsitesLight(); + const createAlarm = useCreateAlarm(); + const updateAlarm = useUpdateAlarm(); + + const initialValues = useMemo(() => { + if (!alarm) { + return { + ...defaultValues, + websiteId: defaultWebsiteId ?? null, + }; + } + return { + name: alarm.name, + description: alarm.description ?? "", + enabled: alarm.enabled, + websiteId: alarm.websiteId ?? null, + triggerType: alarm.triggerType, + failureThreshold: alarm.triggerConditions?.failureThreshold ?? 3, + notificationChannels: alarm.notificationChannels, + slackWebhookUrl: alarm.slackWebhookUrl ?? null, + discordWebhookUrl: alarm.discordWebhookUrl ?? null, + emailAddresses: alarm.emailAddresses, + webhookUrl: alarm.webhookUrl ?? null, + webhookHeaders: mapHeadersToArray(alarm.webhookHeaders ?? null), + }; + }, [alarm, defaultWebsiteId]); + + const form = useForm({ + resolver: zodResolver(alarmFormSchema), + defaultValues: initialValues, + }); + + const { reset } = form; + + useEffect(() => { + if (open) { + reset(initialValues); + } + }, [initialValues, open, reset]); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "webhookHeaders", + }); + + const channelValues = form.watch("notificationChannels"); + const triggerType = form.watch("triggerType"); + + const isSaving = createAlarm.isPending || updateAlarm.isPending; + + const handleSubmit = async (values: AlarmFormValues) => { + if (!activeOrganization?.id) { + toast.error("Select a workspace to save alarms."); + return; + } + + const payload = { + organizationId: activeOrganization.id, + websiteId: values.websiteId ?? undefined, + name: values.name.trim(), + description: values.description?.trim() || undefined, + enabled: values.enabled, + notificationChannels: values.notificationChannels, + slackWebhookUrl: values.slackWebhookUrl ?? undefined, + discordWebhookUrl: values.discordWebhookUrl ?? undefined, + emailAddresses: values.emailAddresses, + webhookUrl: values.webhookUrl ?? undefined, + webhookHeaders: mapHeadersToRecord(values.webhookHeaders), + triggerType: values.triggerType, + triggerConditions: { + failureThreshold: + values.triggerType === "uptime" ? values.failureThreshold : undefined, + }, + }; + + try { + if (alarm) { + await updateAlarm.mutateAsync({ id: alarm.id, ...payload }); + toast.success("Alarm updated"); + } else { + await createAlarm.mutateAsync(payload); + toast.success("Alarm created"); + } + onSaved?.(); + onOpenChange(false); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save alarm"; + toast.error(message); + } + }; + + return ( + + + + {alarm ? "Edit Alarm" : "Create Alarm"} + + Configure notification channels and triggers for this alarm. + + +
+ +
+
+
+

Enabled

+

+ Turn alarms on or off. +

+
+ ( + + + + + + )} + /> +
+ + ( + + Alarm name + + + + + + )} + /> + + ( + + Description + +