diff --git a/messages/en-US.json b/messages/en-US.json index 2dd438a12..05b40d27d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1695,6 +1695,8 @@ "alertingRuleSaved": "Alert rule saved", "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", "alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", + "alertingTestAlertSent": "Test alert sent", + "alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.", "alertingEditRule": "Edit Alert Rule", "alertingCreateRule": "Create Alert Rule", "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index ce30753da..c81cf60da 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -31,9 +31,24 @@ export type AlertNotificationProps = { orgId: string; data: Record; dashboardLink: string; + isTestAlert?: boolean; }; -function getEventMeta(eventType: AlertEventType): { +function getEventMeta( + eventType: AlertEventType, + isTestAlert: boolean = false +): { + heading: string; + previewText: string; + summary: string; + statusLabel: string | null; + statusColor: string | null; +} { + const meta = getBaseEventMeta(eventType); + return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta; +} + +function getBaseEventMeta(eventType: AlertEventType): { heading: string; previewText: string; summary: string; @@ -180,8 +195,14 @@ function formatDataItems( } export const AlertNotification = (props: AlertNotificationProps) => { - const { eventType, orgId, data, dashboardLink } = props; - const meta = getEventMeta(eventType); + const { + eventType, + orgId, + data, + dashboardLink, + isTestAlert = false + } = props; + const meta = getEventMeta(eventType, isTestAlert); const dataItems = formatDataItems(data); const isToggle = @@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => { Open your dashboard to view more details and manage your alert rules. + {isTestAlert && ( + + This is a test alert. No action is required, + and no real event has occurred. + + )} diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index 7aa1691f0..1a6c1d7c6 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -20,7 +20,10 @@ export async function processTestAlerts(context: TestAlertContext) { try { const recipients = await resolveEmailRecipients(action); if (recipients.length > 0) { - await sendAlertEmail(recipients, context); + await sendAlertEmail(recipients, { + ...context, + isTest: true + }); } } catch (err) { logger.error(`processTestAlerts: failed to send alert email`, err); diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 6f99b102c..0eef6fb5c 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -15,7 +15,24 @@ import { sendEmail } from "@server/emails"; import AlertNotification from "@server/emails/templates/AlertNotification"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { AlertContext } from "@server/routers/alertRule/types"; +import { + AlertContext, + type AlertEventType +} from "@server/routers/alertRule/types"; + +type EmailAlertContext = { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; + isTest?: boolean; +}; /** * Sends an alert notification email to every address in `recipients`. @@ -27,7 +44,7 @@ import { AlertContext } from "@server/routers/alertRule/types"; */ export async function sendAlertEmail( recipients: string[], - context: AlertContext + context: EmailAlertContext ): Promise { if (recipients.length === 0) { return; @@ -46,7 +63,8 @@ export async function sendAlertEmail( eventType: context.eventType, orgId: context.orgId, data: context.data, - dashboardLink + dashboardLink, + isTestAlert: context.isTest }), { from, @@ -70,34 +88,35 @@ export async function sendAlertEmail( // Helpers // --------------------------------------------------------------------------- -function buildSubject(context: AlertContext): string { +function buildSubject(context: EmailAlertContext): string { + const prefix = context.isTest ? "[Test Alert]" : "[Alert]"; switch (context.eventType) { case "site_online": - return "[Alert] Site Back Online"; + return `${prefix} Site Back Online`; case "site_offline": - return "[Alert] Site Offline"; + return `${prefix} Site Offline`; case "site_toggle": - return "[Alert] Site Status Changed"; + return `${prefix} Site Status Changed`; case "health_check_healthy": - return "[Alert] Health Check Recovered"; + return `${prefix} Health Check Recovered`; case "health_check_unhealthy": - return "[Alert] Health Check Failing"; + return `${prefix} Health Check Failing`; case "health_check_toggle": - return "[Alert] Health Check Status Changed"; + return `${prefix} Health Check Status Changed`; case "resource_healthy": - return "[Alert] Resource Healthy"; + return `${prefix} Resource Healthy`; case "resource_unhealthy": - return "[Alert] Resource Unhealthy"; + return `${prefix} Resource Unhealthy`; case "resource_degraded": - return "[Alert] Resource Degraded"; + return `${prefix} Resource Degraded`; case "resource_toggle": - return "[Alert] Resource Status Changed"; + return `${prefix} Resource Status Changed`; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. const _exhaustive: never = context.eventType; void _exhaustive; - return "[Alert] Event Notification"; + return `${prefix} Event Notification`; } } } diff --git a/server/private/routers/alertRule/testAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts index 39a5c28a1..6104dbc6b 100644 --- a/server/private/routers/alertRule/testAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -27,7 +27,13 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; -import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; +import { + ListAlertRulesResponse, + type AlertAction, + type EmailAlertAction +} from "@server/routers/alertRule/types"; +import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; +import { getRandomItemInArray } from "@app/lib/getRandomItemInArray"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -51,12 +57,12 @@ export const RESOURCE_EVENT_TYPES = [ ] as const; const webhookActionSchema = z.strictObject({ - webhookUrl: z.string().url(), + webhookUrl: z.url(), config: z.string().optional(), enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ +const bodySchema = z.object({ eventType: z.enum([ ...HC_EVENT_TYPES, ...SITE_EVENT_TYPES, @@ -97,7 +103,106 @@ export async function testAlertRule( ); } + const body = parsedBody.data; + const collectedActions: AlertAction[] = []; + if ( + body.emails.length > 0 || + body.roleIds.length > 0 || + body.userIds.length > 0 + ) { + collectedActions.push({ + type: "email", + emails: body.emails, + roleIds: body.roleIds, + userIds: body.userIds + }); + } + + for (const action of body.webhookActions) { + collectedActions.push({ + type: "webhook", + ...action + }); + } + + let data: Record = {}; + switch (body.eventType) { + case "site_toggle": + data = { + status: getRandomItemInArray(["online", "offline"]), + siteName: "Test Site Alert" + }; + break; + case "site_offline": + data = { + status: "offline", + siteName: "Test Site Alert" + }; + break; + case "site_online": + data = { + status: "online", + siteName: "Test Site Alert" + }; + break; + case "resource_toggle": + data = { + status: getRandomItemInArray([ + "healthy", + "unhealthy", + "degraded" + ]), + siteName: "Test Resource Alert" + }; + break; + case "resource_healthy": + data = { + status: "healthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_unhealthy": + data = { + status: "unhealthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_degraded": + data = { + status: "degraded", + siteName: "Test Resource Alert" + }; + break; + case "health_check_toggle": + data = { + status: getRandomItemInArray(["healthy", "unhealthy"]), + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_healthy": + data = { + status: "healthy", + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_unhealthy": + data = { + status: "unhealthy", + healthCheckName: "Test Health Check Alert" + }; + break; + + default: + break; + } + // TODO: process alert rule + await processTestAlerts({ + eventType: body.eventType, + orgId, + actions: collectedActions, + data + }); } catch (error) { logger.error(error); return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0fd4cc023..b880598cc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -809,7 +809,7 @@ authenticated.get( ); authenticated.post( - "/org/:orgId/alert-rule/test", + "/org/:orgId/test-alert-rule", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.testAlertRule), diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index 99057b312..21baa7b2b 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -128,7 +128,7 @@ export interface AlertContext { export type EmailAlertAction = { type: "email"; userIds?: string[]; - roleIds?: string[]; + roleIds?: number[]; emails?: string[]; }; @@ -139,7 +139,7 @@ export type WebhookAlertAction = { config?: string | undefined; }; -type AlertAction = EmailAlertAction | WebhookAlertAction; +export type AlertAction = EmailAlertAction | WebhookAlertAction; export interface TestAlertContext { eventType: AlertEventType; actions: AlertAction[]; diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index a10f8f3b3..243ce82f8 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -189,10 +189,38 @@ export default function AlertRuleGraphEditor({ description: t("alertingNoActionsTestDescription") }); } + return; } const values = form.getValues(); + try { + const payload = formValuesToApiPayload(values); + if (isNew) { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/test-alert-rule`, payload); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } else { + await api.post( + `/org/${orgId}/alert-rule/${alertRuleId}`, + payload + ); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } }; const [, formAction, isSaving] = useActionState(saveAlert, null); diff --git a/src/lib/getRandomItemInArray.ts b/src/lib/getRandomItemInArray.ts new file mode 100644 index 000000000..aa5a2e562 --- /dev/null +++ b/src/lib/getRandomItemInArray.ts @@ -0,0 +1,5 @@ +export function getRandomItemInArray(array: T[]) { + // Source - https://stackoverflow.com/a/4550514 + const randomElement = array[Math.floor(Math.random() * array.length)]; + return randomElement; +}