test alert email works

This commit is contained in:
Fred KISSIE
2026-08-11 22:25:26 +02:00
parent 899c47e9a3
commit 21032bc22b
9 changed files with 214 additions and 25 deletions
+2
View File
@@ -1695,6 +1695,8 @@
"alertingRuleSaved": "Alert rule saved", "alertingRuleSaved": "Alert rule saved",
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", "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", "alertingEditRule": "Edit Alert Rule",
"alertingCreateRule": "Create Alert Rule", "alertingCreateRule": "Create Alert Rule",
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
+30 -3
View File
@@ -31,9 +31,24 @@ export type AlertNotificationProps = {
orgId: string; orgId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
dashboardLink: string; 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; heading: string;
previewText: string; previewText: string;
summary: string; summary: string;
@@ -180,8 +195,14 @@ function formatDataItems(
} }
export const AlertNotification = (props: AlertNotificationProps) => { export const AlertNotification = (props: AlertNotificationProps) => {
const { eventType, orgId, data, dashboardLink } = props; const {
const meta = getEventMeta(eventType); eventType,
orgId,
data,
dashboardLink,
isTestAlert = false
} = props;
const meta = getEventMeta(eventType, isTestAlert);
const dataItems = formatDataItems(data); const dataItems = formatDataItems(data);
const isToggle = const isToggle =
@@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => {
Open your dashboard to view more details and manage Open your dashboard to view more details and manage
your alert rules. your alert rules.
</EmailText> </EmailText>
{isTestAlert && (
<EmailText>
This is a test alert. No action is required,
and no real event has occurred.
</EmailText>
)}
<EmailSection> <EmailSection>
<ButtonLink href={dashboardLink}> <ButtonLink href={dashboardLink}>
@@ -20,7 +20,10 @@ export async function processTestAlerts(context: TestAlertContext) {
try { try {
const recipients = await resolveEmailRecipients(action); const recipients = await resolveEmailRecipients(action);
if (recipients.length > 0) { if (recipients.length > 0) {
await sendAlertEmail(recipients, context); await sendAlertEmail(recipients, {
...context,
isTest: true
});
} }
} catch (err) { } catch (err) {
logger.error(`processTestAlerts: failed to send alert email`, err); logger.error(`processTestAlerts: failed to send alert email`, err);
+34 -15
View File
@@ -15,7 +15,24 @@ import { sendEmail } from "@server/emails";
import AlertNotification from "@server/emails/templates/AlertNotification"; import AlertNotification from "@server/emails/templates/AlertNotification";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; 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<string, unknown>;
isTest?: boolean;
};
/** /**
* Sends an alert notification email to every address in `recipients`. * 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( export async function sendAlertEmail(
recipients: string[], recipients: string[],
context: AlertContext context: EmailAlertContext
): Promise<void> { ): Promise<void> {
if (recipients.length === 0) { if (recipients.length === 0) {
return; return;
@@ -46,7 +63,8 @@ export async function sendAlertEmail(
eventType: context.eventType, eventType: context.eventType,
orgId: context.orgId, orgId: context.orgId,
data: context.data, data: context.data,
dashboardLink dashboardLink,
isTestAlert: context.isTest
}), }),
{ {
from, from,
@@ -70,34 +88,35 @@ export async function sendAlertEmail(
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildSubject(context: AlertContext): string { function buildSubject(context: EmailAlertContext): string {
const prefix = context.isTest ? "[Test Alert]" : "[Alert]";
switch (context.eventType) { switch (context.eventType) {
case "site_online": case "site_online":
return "[Alert] Site Back Online"; return `${prefix} Site Back Online`;
case "site_offline": case "site_offline":
return "[Alert] Site Offline"; return `${prefix} Site Offline`;
case "site_toggle": case "site_toggle":
return "[Alert] Site Status Changed"; return `${prefix} Site Status Changed`;
case "health_check_healthy": case "health_check_healthy":
return "[Alert] Health Check Recovered"; return `${prefix} Health Check Recovered`;
case "health_check_unhealthy": case "health_check_unhealthy":
return "[Alert] Health Check Failing"; return `${prefix} Health Check Failing`;
case "health_check_toggle": case "health_check_toggle":
return "[Alert] Health Check Status Changed"; return `${prefix} Health Check Status Changed`;
case "resource_healthy": case "resource_healthy":
return "[Alert] Resource Healthy"; return `${prefix} Resource Healthy`;
case "resource_unhealthy": case "resource_unhealthy":
return "[Alert] Resource Unhealthy"; return `${prefix} Resource Unhealthy`;
case "resource_degraded": case "resource_degraded":
return "[Alert] Resource Degraded"; return `${prefix} Resource Degraded`;
case "resource_toggle": case "resource_toggle":
return "[Alert] Resource Status Changed"; return `${prefix} Resource Status Changed`;
default: { default: {
// Exhaustiveness fallback should never be reached with a // Exhaustiveness fallback should never be reached with a
// well-typed caller, but keeps runtime behaviour predictable. // well-typed caller, but keeps runtime behaviour predictable.
const _exhaustive: never = context.eventType; const _exhaustive: never = context.eventType;
void _exhaustive; void _exhaustive;
return "[Alert] Event Notification"; return `${prefix} Event Notification`;
} }
} }
} }
@@ -27,7 +27,13 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; 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({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -51,12 +57,12 @@ export const RESOURCE_EVENT_TYPES = [
] as const; ] as const;
const webhookActionSchema = z.strictObject({ const webhookActionSchema = z.strictObject({
webhookUrl: z.string().url(), webhookUrl: z.url(),
config: z.string().optional(), config: z.string().optional(),
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
}); });
const bodySchema = z.strictObject({ const bodySchema = z.object({
eventType: z.enum([ eventType: z.enum([
...HC_EVENT_TYPES, ...HC_EVENT_TYPES,
...SITE_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<string, any> = {};
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 // TODO: process alert rule
await processTestAlerts({
eventType: body.eventType,
orgId,
actions: collectedActions,
data
});
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
+1 -1
View File
@@ -809,7 +809,7 @@ authenticated.get(
); );
authenticated.post( authenticated.post(
"/org/:orgId/alert-rule/test", "/org/:orgId/test-alert-rule",
verifyValidLicense, verifyValidLicense,
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.testAlertRule), verifyUserHasAction(ActionsEnum.testAlertRule),
+2 -2
View File
@@ -128,7 +128,7 @@ export interface AlertContext {
export type EmailAlertAction = { export type EmailAlertAction = {
type: "email"; type: "email";
userIds?: string[]; userIds?: string[];
roleIds?: string[]; roleIds?: number[];
emails?: string[]; emails?: string[];
}; };
@@ -139,7 +139,7 @@ export type WebhookAlertAction = {
config?: string | undefined; config?: string | undefined;
}; };
type AlertAction = EmailAlertAction | WebhookAlertAction; export type AlertAction = EmailAlertAction | WebhookAlertAction;
export interface TestAlertContext { export interface TestAlertContext {
eventType: AlertEventType; eventType: AlertEventType;
actions: AlertAction[]; actions: AlertAction[];
@@ -189,10 +189,38 @@ export default function AlertRuleGraphEditor({
description: t("alertingNoActionsTestDescription") description: t("alertingNoActionsTestDescription")
}); });
} }
return; return;
} }
const values = form.getValues(); const values = form.getValues();
try {
const payload = formValuesToApiPayload(values);
if (isNew) {
const res = await api.post<
AxiosResponse<CreateAlertRuleResponse>
>(`/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); const [, formAction, isSaving] = useActionState(saveAlert, null);
+5
View File
@@ -0,0 +1,5 @@
export function getRandomItemInArray<T>(array: T[]) {
// Source - https://stackoverflow.com/a/4550514
const randomElement = array[Math.floor(Math.random() * array.length)];
return randomElement;
}