mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-20 03:02:58 +02:00
Merge pull request #3541 from fosrl/feat/test-alert-button
Feat: test alert rules
This commit is contained in:
@@ -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)];
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user