diff --git a/messages/en-US.json b/messages/en-US.json index 7a3fde1d4..b2f750cb1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1321,10 +1321,78 @@ "sidebarGeneral": "Manage", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", + "sidebarAlerting": "Alerting", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", + "alertingTitle": "Alerting rules", + "alertingDescription": "Define sources, triggers, and actions for notifications. Rules are stored locally in this browser until server-side alerting is available.", + "alertingRules": "Alert rules", + "alertingSearchRules": "Search rules…", + "alertingAddRule": "Create rule", + "alertingColumnSource": "Source", + "alertingColumnTrigger": "Trigger", + "alertingColumnActions": "Actions", + "alertingColumnEnabled": "Enabled", + "alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.", + "alertingDeleteRule": "Delete alert rule", + "alertingRuleDeleted": "Alert rule deleted", + "alertingRuleSaved": "Alert rule saved", + "alertingEditRule": "Edit alert rule", + "alertingCreateRule": "Create alert rule", + "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.", + "alertingRuleNamePlaceholder": "Production site down", + "alertingRuleEnabled": "Rule enabled", + "alertingSectionSource": "Source", + "alertingSourceType": "Source type", + "alertingSourceSite": "Site", + "alertingSourceHealthCheck": "Health check", + "alertingPickSites": "Sites", + "alertingPickHealthChecks": "Health checks", + "alertingSectionTrigger": "Trigger", + "alertingTrigger": "When to alert", + "alertingTriggerSiteOnline": "Site online", + "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerHcHealthy": "Health check healthy", + "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingSectionActions": "Actions", + "alertingAddAction": "Add action", + "alertingActionNotify": "Notify", + "alertingActionSms": "SMS", + "alertingActionWebhook": "Webhook", + "alertingActionType": "Action type", + "alertingNotifyUsers": "Users", + "alertingNotifyRoles": "Roles", + "alertingNotifyEmails": "Email addresses", + "alertingEmailPlaceholder": "Add email and press Enter", + "alertingSmsNumbers": "Phone numbers", + "alertingSmsPlaceholder": "Add number and press Enter", + "alertingWebhookMethod": "HTTP method", + "alertingWebhookSecret": "Signing secret (optional)", + "alertingWebhookSecretPlaceholder": "HMAC secret", + "alertingWebhookHeaders": "Headers", + "alertingAddHeader": "Add header", + "alertingSelectSites": "Select sites…", + "alertingSitesSelected": "{count} sites selected", + "alertingSelectHealthChecks": "Select health checks…", + "alertingHealthChecksSelected": "{count} health checks selected", + "alertingNoHealthChecks": "No targets with health checks enabled", + "alertingSelectUsers": "Select users…", + "alertingUsersSelected": "{count} users selected", + "alertingSelectRoles": "Select roles…", + "alertingRolesSelected": "{count} roles selected", + "alertingSummarySites": "Sites ({count})", + "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingErrorNameRequired": "Enter a name", + "alertingErrorActionsMin": "Add at least one action", + "alertingErrorPickSites": "Select at least one site", + "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorTriggerSite": "Choose a site trigger", + "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", + "alertingErrorSmsPhones": "Add at least one phone number", + "alertingErrorWebhookUrl": "Enter a valid webhook URL", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx new file mode 100644 index 000000000..3d100bed2 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -0,0 +1,24 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import { getTranslations } from "next-intl/server"; + +type AlertingPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function AlertingPage(props: AlertingPageProps) { + const params = await props.params; + const t = await getTranslations(); + + return ( + <> + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 66e6cdad0..4d3fd027c 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { + BellRing, Boxes, Building2, Cable, @@ -219,6 +220,11 @@ export const orgNavSections = ( title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", icon: + }, + { + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: } ] }, diff --git a/src/components/AlertRuleCredenza.tsx b/src/components/AlertRuleCredenza.tsx new file mode 100644 index 000000000..141011cb1 --- /dev/null +++ b/src/components/AlertRuleCredenza.tsx @@ -0,0 +1,1431 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Separator } from "@app/components/ui/separator"; +import { Switch } from "@app/components/ui/switch"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { toast } from "@app/hooks/useToast"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { + type AlertRule, + type AlertTrigger, + isoNow, + newRuleId, + upsertRule +} from "@app/lib/alertRulesLocalStorage"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronsUpDown, Plus, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import type { Control, UseFormReturn } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; +import { useDebounce } from "use-debounce"; +import { z } from "zod"; + +const FORM_ID = "alert-rule-form"; + +const tagSchema = z.object({ + id: z.string(), + text: z.string() +}); + +type FormAction = + | { + type: "notify"; + userIds: string[]; + roleIds: number[]; + emailTags: Tag[]; + } + | { type: "sms"; phoneTags: Tag[] } + | { + type: "webhook"; + url: string; + method: string; + headers: { key: string; value: string }[]; + secret: string; + }; + +export type AlertRuleFormValues = { + name: string; + enabled: boolean; + sourceType: "site" | "health_check"; + siteIds: number[]; + targetIds: number[]; + trigger: AlertTrigger; + actions: FormAction[]; +}; + +function buildFormSchema(t: (k: string) => string) { + return z + .object({ + name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + enabled: z.boolean(), + sourceType: z.enum(["site", "health_check"]), + siteIds: z.array(z.number()), + targetIds: z.array(z.number()), + trigger: z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" + ]), + actions: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emailTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("sms"), + phoneTags: z.array(tagSchema) + }), + z.object({ + type: z.literal("webhook"), + url: z.string(), + method: z.string(), + headers: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + secret: z.string() + }) + ]) + ) + .min(1, { message: t("alertingErrorActionsMin") }) + }) + .superRefine((val, ctx) => { + if (val.sourceType === "site" && val.siteIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickSites"), + path: ["siteIds"] + }); + } + if ( + val.sourceType === "health_check" && + val.targetIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickHealthChecks"), + path: ["targetIds"] + }); + } + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline" + ]; + const hcTriggers: AlertTrigger[] = [ + "health_check_healthy", + "health_check_unhealthy" + ]; + if ( + val.sourceType === "site" && + !siteTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerSite"), + path: ["trigger"] + }); + } + if ( + val.sourceType === "health_check" && + !hcTriggers.includes(val.trigger) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorTriggerHealth"), + path: ["trigger"] + }); + } + val.actions.forEach((a, i) => { + if (a.type === "notify") { + if ( + a.userIds.length === 0 && + a.roleIds.length === 0 && + a.emailTags.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorNotifyRecipients"), + path: ["actions", i, "userIds"] + }); + } + } + if (a.type === "sms" && a.phoneTags.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorSmsPhones"), + path: ["actions", i, "phoneTags"] + }); + } + if (a.type === "webhook") { + try { + // eslint-disable-next-line no-new + new URL(a.url.trim()); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorWebhookUrl"), + path: ["actions", i, "url"] + }); + } + } + }); + }); +} + +function defaultFormValues(): AlertRuleFormValues { + return { + name: "", + enabled: true, + sourceType: "site", + siteIds: [], + targetIds: [], + trigger: "site_offline", + actions: [ + { + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + } + ] + }; +} + +function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { + const actions: FormAction[] = rule.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify", + userIds: a.userIds.map(String), + roleIds: [...a.roleIds], + emailTags: a.emails.map((e) => ({ id: e, text: e })) + }; + } + if (a.type === "sms") { + return { + type: "sms", + phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) + }; + } + return { + type: "webhook", + url: a.url, + method: a.method, + headers: + a.headers.length > 0 + ? a.headers.map((h) => ({ ...h })) + : [{ key: "", value: "" }], + secret: a.secret ?? "" + }; + }); + return { + name: rule.name, + enabled: rule.enabled, + sourceType: rule.source.type, + siteIds: + rule.source.type === "site" ? [...rule.source.siteIds] : [], + targetIds: + rule.source.type === "health_check" + ? [...rule.source.targetIds] + : [], + trigger: rule.trigger, + actions + }; +} + +function formValuesToRule( + v: AlertRuleFormValues, + id: string, + createdAt: string +): AlertRule { + const source = + v.sourceType === "site" + ? { type: "site" as const, siteIds: v.siteIds } + : { + type: "health_check" as const, + targetIds: v.targetIds + }; + const actions = v.actions.map((a) => { + if (a.type === "notify") { + return { + type: "notify" as const, + userIds: a.userIds, + roleIds: a.roleIds, + emails: a.emailTags.map((t) => t.text.trim()).filter(Boolean) + }; + } + if (a.type === "sms") { + return { + type: "sms" as const, + phoneNumbers: a.phoneTags + .map((t) => t.text.trim()) + .filter(Boolean) + }; + } + return { + type: "webhook" as const, + url: a.url.trim(), + method: a.method, + headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), + secret: a.secret.trim() || undefined + }; + }); + return { + id, + name: v.name.trim(), + enabled: v.enabled, + createdAt, + updatedAt: isoNow(), + source, + trigger: v.trigger, + actions + }; +} + +type TargetRow = { + targetId: number; + resourceName: string; + ip: string; + port: number; +}; + +function useHealthCheckOptions(orgId: string) { + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, perPage: 10_000 }) + ); + return useMemo(() => { + const rows: TargetRow[] = []; + for (const r of resources) { + for (const t of r.targets) { + const ext = t as typeof t & { hcEnabled?: boolean }; + if (ext.hcEnabled === true) { + rows.push({ + targetId: t.targetId, + resourceName: r.name, + ip: t.ip, + port: t.port + }); + } + } + } + return rows; + }, [resources]); +} + +type AlertRuleCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + rule: AlertRule | null; + onSaved: () => void; +}; + +export default function AlertRuleCredenza({ + open, + setOpen, + orgId, + rule, + onSaved +}: AlertRuleCredenzaProps) { + const t = useTranslations(); + const schema = useMemo(() => buildFormSchema(t), [t]); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultFormValues() + }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "actions" + }); + + const sourceType = form.watch("sourceType"); + const trigger = form.watch("trigger"); + + const ruleKey = rule?.id ?? "__new__"; + useEffect(() => { + if (!open) return; + if (rule) { + form.reset(ruleToFormValues(rule)); + } else { + form.reset(defaultFormValues()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reset when opening or switching create/edit target + }, [open, ruleKey]); + + useEffect(() => { + if (sourceType === "site") { + if ( + trigger !== "site_online" && + trigger !== "site_offline" + ) { + form.setValue("trigger", "site_offline"); + } + } else if ( + trigger !== "health_check_healthy" && + trigger !== "health_check_unhealthy" + ) { + form.setValue("trigger", "health_check_unhealthy"); + } + }, [sourceType, trigger, form]); + + const onSubmit = form.handleSubmit((values) => { + const id = rule?.id ?? newRuleId(); + const createdAt = rule?.createdAt ?? isoNow(); + const next = formValuesToRule(values, id, createdAt); + upsertRule(orgId, next); + toast({ title: t("alertingRuleSaved") }); + onSaved(); + setOpen(false); + }); + + return ( + + + + + {rule + ? t("alertingEditRule") + : t("alertingCreateRule")} + + + {t("alertingRuleCredenzaDescription")} + + + +
+ + ( + + {t("name")} + + + + + + )} + /> + ( + + + {t("alertingRuleEnabled")} + + + + + + )} + /> + +
+

+ {t("alertingSectionSource")} +

+ ( + + + {t("alertingSourceType")} + + + + + )} + /> + {sourceType === "site" ? ( + ( + + + {t("alertingPickSites")} + + + + + )} + /> + ) : ( + ( + + + {t( + "alertingPickHealthChecks" + )} + + + + + )} + /> + )} +
+ + + +
+

+ {t("alertingSectionTrigger")} +

+ ( + + + {t("alertingTrigger")} + + + + + )} + /> +
+ + + +
+
+

+ {t("alertingSectionActions")} +

+ { + if (type === "notify") { + append({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } else if (type === "sms") { + append({ + type: "sms", + phoneTags: [] + }); + } else { + append({ + type: "webhook", + url: "", + method: "POST", + headers: [ + { key: "", value: "" } + ], + secret: "" + }); + } + }} + /> +
+ {fields.map((f, index) => ( + remove(index)} + canRemove={fields.length > 1} + /> + ))} +
+ + +
+ + + + + + +
+
+ ); +} + +function DropdownAddAction({ + onPick +}: { + onPick: (type: "notify" | "sms" | "webhook") => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + return ( + + + + + +
+ + + +
+
+
+ ); +} + +function SiteMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: sites = [] } = useQuery( + orgQueries.sites({ orgId, query: debounced, perPage: 500 }) + ); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectSites") + : t("alertingSitesSelected", { count: value.length }); + return ( + + + + + + + + + {t("siteNotFound")} + + {sites.map((s) => ( + toggle(s.siteId)} + className="cursor-pointer" + > + + {s.name} + + ))} + + + + + + ); +} + +function HealthCheckMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const rows = useHealthCheckOptions(orgId); + const filtered = useMemo(() => { + const qq = q.trim().toLowerCase(); + if (!qq) return rows; + return rows.filter( + (r) => + r.resourceName.toLowerCase().includes(qq) || + `${r.ip}:${r.port}`.includes(qq) + ); + }, [rows, q]); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectHealthChecks") + : t("alertingHealthChecksSelected", { count: value.length }); + return ( + + + + + + + + + + {t("alertingNoHealthChecks")} + + + {filtered.map((r) => ( + toggle(r.targetId)} + className="cursor-pointer" + > + + + {r.resourceName} · {r.ip}:{r.port} + + + ))} + + + + + + ); +} + +function ActionBlock({ + orgId, + index, + control, + form, + onRemove, + canRemove +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; + onRemove: () => void; + canRemove: boolean; +}) { + const t = useTranslations(); + const type = form.watch(`actions.${index}.type`); + return ( +
+ {canRemove && ( + + )} + ( + + {t("alertingActionType")} + + + )} + /> + {type === "notify" && ( + + )} + {type === "sms" && ( + + )} + {type === "webhook" && ( + + )} +
+ ); +} + +function NotifyActionFields({ + orgId, + index, + control, + form +}: { + orgId: string; + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [emailActiveIdx, setEmailActiveIdx] = useState(null); + const userIds = form.watch(`actions.${index}.userIds`) ?? []; + const roleIds = form.watch(`actions.${index}.roleIds`) ?? []; + const emailTags = form.watch(`actions.${index}.emailTags`) ?? []; + + return ( +
+ + {t("alertingNotifyUsers")} + + form.setValue(`actions.${index}.userIds`, ids) + } + /> + + + {t("alertingNotifyRoles")} + + form.setValue(`actions.${index}.roleIds`, ids) + } + /> + + ( + + {t("alertingNotifyEmails")} + + { + const next = + typeof updater === "function" + ? updater(emailTags) + : updater; + form.setValue( + `actions.${index}.emailTags`, + next + ); + }} + activeTagIndex={emailActiveIdx} + setActiveTagIndex={setEmailActiveIdx} + placeholder={t( + "alertingEmailPlaceholder" + )} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> +
+ ); +} + +function SmsActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const [phoneActiveIdx, setPhoneActiveIdx] = useState(null); + const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? []; + return ( + ( + + {t("alertingSmsNumbers")} + + { + const next = + typeof updater === "function" + ? updater(phoneTags) + : updater; + form.setValue( + `actions.${index}.phoneTags`, + next + ); + }} + activeTagIndex={phoneActiveIdx} + setActiveTagIndex={setPhoneActiveIdx} + placeholder={t("alertingSmsPlaceholder")} + delimiterList={[",", "Enter"]} + /> + + + + )} + /> + ); +} + +function WebhookActionFields({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + return ( +
+ ( + + URL + + + + + + )} + /> + ( + + {t("alertingWebhookMethod")} + + + + )} + /> + ( + + {t("alertingWebhookSecret")} + + + + + + )} + /> + +
+ ); +} + +function WebhookHeadersField({ + index, + control, + form +}: { + index: number; + control: Control; + form: UseFormReturn; +}) { + const t = useTranslations(); + const headers = + form.watch(`actions.${index}.headers` as const) ?? []; + return ( +
+ {t("alertingWebhookHeaders")} + {headers.map((_, hi) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} + +
+ ); +} + +function UserMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: string[]; + onChange: (v: string[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + const { data: users = [] } = useQuery(orgQueries.users({ orgId })); + const shown = useMemo(() => { + const qq = debounced.trim().toLowerCase(); + if (!qq) return users.slice(0, 200); + return users + .filter((u) => { + const label = getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }).toLowerCase(); + return ( + label.includes(qq) || + (u.email ?? "").toLowerCase().includes(qq) + ); + }) + .slice(0, 200); + }, [users, debounced]); + const toggle = (id: string) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectUsers") + : t("alertingUsersSelected", { count: value.length }); + return ( + + + + + + + + + {t("noResults")} + + {shown.map((u) => { + const uid = String(u.id); + return ( + toggle(uid)} + className="cursor-pointer" + > + + {getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + })} + + ); + })} + + + + + + ); +} + +function RoleMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const { data: roles = [] } = useQuery(orgQueries.roles({ orgId })); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + const summary = + value.length === 0 + ? t("alertingSelectRoles") + : t("alertingRolesSelected", { count: value.length }); + return ( + + + + + + + + + {roles.map((r) => ( + toggle(r.roleId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx new file mode 100644 index 000000000..5a6b0f060 --- /dev/null +++ b/src/components/AlertingRulesTable.tsx @@ -0,0 +1,297 @@ +"use client"; + +import AlertRuleCredenza from "@app/components/AlertRuleCredenza"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { + DataTable, + ExtendedColumnDef +} from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { + type AlertRule, + deleteRule, + isoNow, + loadRules, + upsertRule +} from "@app/lib/alertRulesLocalStorage"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import moment from "moment"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { Badge } from "@app/components/ui/badge"; + +type AlertingRulesTableProps = { + orgId: string; +}; + +function sourceSummary(rule: AlertRule, t: (k: string, o?: Record) => string) { + if (rule.source.type === "site") { + return t("alertingSummarySites", { + count: rule.source.siteIds.length + }); + } + return t("alertingSummaryHealthChecks", { + count: rule.source.targetIds.length + }); +} + +function triggerLabel(rule: AlertRule, t: (k: string) => string) { + switch (rule.trigger) { + case "site_online": + return t("alertingTriggerSiteOnline"); + case "site_offline": + return t("alertingTriggerSiteOffline"); + case "health_check_healthy": + return t("alertingTriggerHcHealthy"); + case "health_check_unhealthy": + return t("alertingTriggerHcUnhealthy"); + default: + return rule.trigger; + } +} + +function actionBadges(rule: AlertRule, t: (k: string) => string) { + return rule.actions.map((a, i) => { + if (a.type === "notify") { + return ( + + {t("alertingActionNotify")} + + ); + } + if (a.type === "sms") { + return ( + + {t("alertingActionSms")} + + ); + } + return ( + + {t("alertingActionWebhook")} + + ); + }); +} + +export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { + const t = useTranslations(); + const [rows, setRows] = useState([]); + const [credenzaOpen, setCredenzaOpen] = useState(false); + const [credenzaRule, setCredenzaRule] = useState(null); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshFromStorage = useCallback(() => { + setRows(loadRules(orgId)); + }, [orgId]); + + useEffect(() => { + refreshFromStorage(); + }, [refreshFromStorage]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((r) => setTimeout(r, 200)); + refreshFromStorage(); + } finally { + setIsRefreshing(false); + } + }; + + const setEnabled = (rule: AlertRule, enabled: boolean) => { + upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() }); + refreshFromStorage(); + }; + + const confirmDelete = async () => { + if (!selected) return; + deleteRule(orgId, selected.id); + refreshFromStorage(); + setDeleteOpen(false); + setSelected(null); + toast({ title: t("alertingRuleDeleted") }); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ) + }, + { + id: "source", + friendlyName: t("alertingColumnSource"), + header: () => {t("alertingColumnSource")}, + cell: ({ row }) => ( + {sourceSummary(row.original, t)} + ) + }, + { + id: "trigger", + friendlyName: t("alertingColumnTrigger"), + header: () => ( + {t("alertingColumnTrigger")} + ), + cell: ({ row }) => {triggerLabel(row.original, t)} + }, + { + id: "actionsCol", + friendlyName: t("alertingColumnActions"), + header: () => ( + {t("alertingColumnActions")} + ), + cell: ({ row }) => ( +
+ {actionBadges(row.original, t)} +
+ ) + }, + { + accessorKey: "enabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + {t("alertingColumnEnabled")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + setEnabled(r, v)} + /> + ); + } + }, + { + accessorKey: "createdAt", + friendlyName: t("createdAt"), + header: () => ( + {t("createdAt")} + ), + cell: ({ row }) => ( + {moment(row.original.createdAt).format("lll")} + ) + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setCredenzaRule(r); + setCredenzaOpen(true); + }} + > + {t("edit")} + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + +
+ ); + } + } + ]; + + return ( + <> + { + setCredenzaOpen(v); + if (!v) setCredenzaRule(null); + }} + orgId={orgId} + rule={credenzaRule} + onSaved={refreshFromStorage} + /> + {selected && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("alertingDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={confirmDelete} + string={selected.name} + title={t("alertingDeleteRule")} + /> + )} + { + setCredenzaRule(null); + setCredenzaOpen(true); + }} + onRefresh={refreshData} + isRefreshing={isRefreshing} + addButtonText={t("alertingAddRule")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + /> + + ); +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts new file mode 100644 index 000000000..2e26cee71 --- /dev/null +++ b/src/lib/alertRulesLocalStorage.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +const STORAGE_PREFIX = "pangolin:alert-rules:"; + +export const webhookHeaderEntrySchema = z.object({ + key: z.string(), + value: z.string() +}); + +export const alertActionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emails: z.array(z.string()) + }), + z.object({ + type: z.literal("sms"), + phoneNumbers: z.array(z.string()) + }), + z.object({ + type: z.literal("webhook"), + url: z.string().url(), + method: z.string().min(1), + headers: z.array(webhookHeaderEntrySchema), + secret: z.string().optional() + }) +]); + +export const alertSourceSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("site"), + siteIds: z.array(z.number()) + }), + z.object({ + type: z.literal("health_check"), + targetIds: z.array(z.number()) + }) +]); + +export const alertTriggerSchema = z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" +]); + +export const alertRuleSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255), + enabled: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + source: alertSourceSchema, + trigger: alertTriggerSchema, + actions: z.array(alertActionSchema).min(1) +}); + +export type AlertRule = z.infer; +export type AlertAction = z.infer; +export type AlertTrigger = z.infer; + +function storageKey(orgId: string) { + return `${STORAGE_PREFIX}${orgId}`; +} + +export function loadRules(orgId: string): AlertRule[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = localStorage.getItem(storageKey(orgId)); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + const out: AlertRule[] = []; + for (const item of parsed) { + const r = alertRuleSchema.safeParse(item); + if (r.success) { + out.push(r.data); + } + } + return out; + } catch { + return []; + } +} + +export function saveRules(orgId: string, rules: AlertRule[]) { + if (typeof window === "undefined") { + return; + } + localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); +} + +export function upsertRule(orgId: string, rule: AlertRule) { + const rules = loadRules(orgId); + const i = rules.findIndex((r) => r.id === rule.id); + if (i >= 0) { + rules[i] = rule; + } else { + rules.push(rule); + } + saveRules(orgId, rules); +} + +export function deleteRule(orgId: string, ruleId: string) { + const rules = loadRules(orgId).filter((r) => r.id !== ruleId); + saveRules(orgId, rules); +} + +export function newRuleId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function isoNow() { + return new Date().toISOString(); +}