mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 07:20:43 +02:00
✨ test alert email works
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user