Merge pull request #3541 from fosrl/feat/test-alert-button

Feat: test alert rules
This commit is contained in:
Milo Schwartz
2026-08-19 10:33:16 -04:00
committed by GitHub
14 changed files with 574 additions and 74 deletions
@@ -0,0 +1,105 @@
import { db, userOrgRoles, users } from "@server/db";
import logger from "@server/logger";
import type {
EmailAlertAction,
TestAlertContext,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
import { eq, inArray } from "drizzle-orm";
import { sendAlertEmail } from "./sendAlertEmail";
import { sendAlertWebhook } from "./sendAlertWebhook";
export async function processTestAlerts(context: TestAlertContext) {
// Process email actions
const emailActions = context.actions.filter(
(action) => action.type === "email"
);
for (const action of emailActions) {
try {
const recipients = await resolveEmailRecipients(action);
if (recipients.length > 0) {
await sendAlertEmail(recipients, {
...context,
isTest: true
});
}
} catch (err) {
logger.error(`processTestAlerts: failed to send alert email`, err);
}
}
// Process webhook actions
const webhookActions = context.actions.filter(
(action) => action.type === "webhook"
);
for (const action of webhookActions) {
try {
let webhookConfig: WebhookAlertConfig = { authType: "none" };
if (action.config) {
try {
webhookConfig = JSON.parse(
action.config
) as WebhookAlertConfig;
} catch (err) {
logger.error(
`processTestAlerts: failed to decrypt webhook`,
err
);
continue;
}
}
await sendAlertWebhook(action.webhookUrl, webhookConfig, {
...context,
isTest: true
});
} catch (err) {
logger.error(
`processTestAlerts: failed to send alert webhook `,
err
);
}
}
}
/**
* Resolves all email addresses for a given `emailActionId`.
*
* Recipients may be:
* - Direct users (by `userId`)
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
* - Direct external email addresses
*/
async function resolveEmailRecipients(
action: EmailAlertAction
): Promise<string[]> {
const emailList: string[] = [];
emailList.push(...(action.emails ?? []));
if (action.userIds && action.userIds?.length > 0) {
const userList = await db
.select({ email: users.email })
.from(users)
.where(inArray(users.userId, action.userIds));
emailList.push(
...userList.filter((u) => u.email !== null).map((u) => u.email!)
);
}
if (action.roleIds && action.roleIds?.length > 0) {
const userList = await db
.select({ email: users.email })
.from(userOrgRoles)
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
.where(inArray(userOrgRoles.roleId, action.roleIds.map(Number)));
emailList.push(
...userList.filter((u) => u.email !== null).map((u) => u.email!)
);
}
return [...new Set(emailList)];
}
+31 -15
View File
@@ -15,7 +15,21 @@ 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 { 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`.
@@ -27,7 +41,7 @@ import { AlertContext } from "@server/routers/alertRule/types";
*/
export async function sendAlertEmail(
recipients: string[],
context: AlertContext
context: EmailAlertContext
): Promise<void> {
if (recipients.length === 0) {
return;
@@ -46,7 +60,8 @@ export async function sendAlertEmail(
eventType: context.eventType,
orgId: context.orgId,
data: context.data,
dashboardLink
dashboardLink,
isTestAlert: context.isTest
}),
{
from,
@@ -70,34 +85,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`;
}
}
}
+31 -10
View File
@@ -14,13 +14,28 @@
import logger from "@server/logger";
import {
AlertContext,
WebhookAlertConfig
WebhookAlertConfig,
type AlertEventType
} from "@server/routers/alertRule/types";
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500;
type WebhookAlertContext = {
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 a single webhook POST for an alert event.
*
@@ -40,14 +55,14 @@ const RETRY_BASE_DELAY_MS = 500;
export async function sendAlertWebhook(
url: string,
webhookConfig: WebhookAlertConfig,
context: AlertContext
context: WebhookAlertContext
): Promise<void> {
const eventType = context.eventType;
const timestamp = new Date().toISOString();
const status = deriveStatus(eventType, context.data);
const data = { orgId: context.orgId, ...context.data };
let body: string;
let body: Record<string, any>;
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
body = renderTemplate(webhookConfig.bodyTemplate, {
event: eventType,
@@ -56,7 +71,11 @@ export async function sendAlertWebhook(
data
});
} else {
body = JSON.stringify({ event: eventType, timestamp, status, data });
body = { event: eventType, timestamp, status, data };
}
if (body.data && context.isTest) {
body.data.test = true;
}
const headers = buildHeaders(webhookConfig);
@@ -75,7 +94,7 @@ export async function sendAlertWebhook(
response = await fetch(url, {
method: webhookConfig.method ?? "POST",
headers,
body,
body: JSON.stringify(body),
signal: controller.signal
});
} catch (err: unknown) {
@@ -247,7 +266,10 @@ interface TemplateContext {
* left untouched.
* 3. The fixed top-level keys: event, timestamp, status.
*/
function renderTemplate(template: string, ctx: TemplateContext): string {
function renderTemplate(
template: string,
ctx: TemplateContext
): Record<string, any> {
// Step 1 expand {{data}} first so its contents are already serialised
// and won't be touched by later passes.
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data));
@@ -280,20 +302,19 @@ function renderTemplate(template: string, ctx: TemplateContext): string {
// Validate the rendered result is valid JSON; if not, log a warning and
// fall back to the default payload so the webhook still fires.
try {
JSON.parse(rendered);
return rendered;
return JSON.parse(rendered);
} catch {
logger.warn(
`sendAlertWebhook: body template produced invalid JSON for event ` +
`"${ctx.event}" destined for a webhook. Falling back to default ` +
`payload. Check that {{data}} is NOT wrapped in quotes in your template.`
);
return JSON.stringify({
return {
event: ctx.event,
timestamp: ctx.timestamp,
status: ctx.status,
data: ctx.data
});
};
}
}
+2 -1
View File
@@ -15,4 +15,5 @@ export * from "./createAlertRule";
export * from "./updateAlertRule";
export * from "./deleteAlertRule";
export * from "./listAlertRules";
export * from "./getAlertRule";
export * from "./getAlertRule";
export * from "./testAlertRule";
@@ -0,0 +1,196 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
import response from "@server/lib/response";
import logger from "@server/logger";
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
import { type AlertAction } from "@server/routers/alertRule/types";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import type { TriggerSiteAlertResponse } from "../alertEvents";
import {
HC_EVENT_TYPES,
SITE_EVENT_TYPES,
RESOURCE_EVENT_TYPES
} from "./createAlertRule";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const webhookActionSchema = z.strictObject({
webhookUrl: z.url(),
config: z.string().optional(),
enabled: z.boolean().optional().default(true)
});
const bodySchema = z.object({
eventType: z.enum([
...HC_EVENT_TYPES,
...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
]),
// Email recipients (flat)
userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.number()).optional().default([]),
emails: z.array(z.email()).optional().default([]),
// Webhook actions
webhookActions: z.array(webhookActionSchema).optional().default([])
});
export async function testAlertRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
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
await processTestAlerts({
eventType: body.eventType,
orgId,
actions: collectedActions,
data
});
return response<TriggerSiteAlertResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Alert triggered successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+8
View File
@@ -766,6 +766,14 @@ authenticated.get(
alertRule.listAlertRules
);
authenticated.post(
"/org/:orgId/test-alert-rule",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.testAlertRule),
alertRule.testAlertRule
);
authenticated.get(
"/org/:orgId/alert-rule/:alertRuleId",
verifyValidLicense,