send webhook action

This commit is contained in:
Fred KISSIE
2026-08-12 18:48:14 +02:00
parent 21032bc22b
commit c6bd657ee6
5 changed files with 50 additions and 42 deletions
@@ -7,15 +7,13 @@ import type {
} from "@server/routers/alertRule/types"; } from "@server/routers/alertRule/types";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { sendAlertEmail } from "./sendAlertEmail"; import { sendAlertEmail } from "./sendAlertEmail";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendAlertWebhook } from "./sendAlertWebhook"; import { sendAlertWebhook } from "./sendAlertWebhook";
export async function processTestAlerts(context: TestAlertContext) { export async function processTestAlerts(context: TestAlertContext) {
// Process email actions
const emailActions = context.actions.filter( const emailActions = context.actions.filter(
(action) => action.type === "email" (action) => action.type === "email"
); );
// Process email actions
for (const action of emailActions) { for (const action of emailActions) {
try { try {
const recipients = await resolveEmailRecipients(action); const recipients = await resolveEmailRecipients(action);
@@ -30,10 +28,10 @@ export async function processTestAlerts(context: TestAlertContext) {
} }
} }
// Process webhook actions
const webhookActions = context.actions.filter( const webhookActions = context.actions.filter(
(action) => action.type === "webhook" (action) => action.type === "webhook"
); );
const serverSecret = config.getRawConfig().server.secret!;
for (const action of webhookActions) { for (const action of webhookActions) {
try { try {
@@ -41,8 +39,9 @@ export async function processTestAlerts(context: TestAlertContext) {
if (action.config) { if (action.config) {
try { try {
const decrypted = decrypt(action.config, serverSecret); webhookConfig = JSON.parse(
webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; action.config
) as WebhookAlertConfig;
} catch (err) { } catch (err) {
logger.error( logger.error(
`processTestAlerts: failed to decrypt webhook`, `processTestAlerts: failed to decrypt webhook`,
@@ -52,7 +51,10 @@ export async function processTestAlerts(context: TestAlertContext) {
} }
} }
await sendAlertWebhook(action.webhookUrl, webhookConfig, context); await sendAlertWebhook(action.webhookUrl, webhookConfig, {
...context,
isTest: true
});
} catch (err) { } catch (err) {
logger.error( logger.error(
`processTestAlerts: failed to send alert webhook `, `processTestAlerts: failed to send alert webhook `,
+1 -4
View File
@@ -15,10 +15,7 @@ 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 { import { type AlertEventType } from "@server/routers/alertRule/types";
AlertContext,
type AlertEventType
} from "@server/routers/alertRule/types";
type EmailAlertContext = { type EmailAlertContext = {
eventType: AlertEventType; eventType: AlertEventType;
+31 -10
View File
@@ -14,13 +14,28 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
AlertContext, AlertContext,
WebhookAlertConfig WebhookAlertConfig,
type AlertEventType
} from "@server/routers/alertRule/types"; } from "@server/routers/alertRule/types";
const REQUEST_TIMEOUT_MS = 15_000; const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500; 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. * Sends a single webhook POST for an alert event.
* *
@@ -40,14 +55,14 @@ const RETRY_BASE_DELAY_MS = 500;
export async function sendAlertWebhook( export async function sendAlertWebhook(
url: string, url: string,
webhookConfig: WebhookAlertConfig, webhookConfig: WebhookAlertConfig,
context: AlertContext context: WebhookAlertContext
): Promise<void> { ): Promise<void> {
const eventType = context.eventType; const eventType = context.eventType;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const status = deriveStatus(eventType, context.data); const status = deriveStatus(eventType, context.data);
const data = { orgId: context.orgId, ...context.data }; const data = { orgId: context.orgId, ...context.data };
let body: string; let body: Record<string, any>;
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
body = renderTemplate(webhookConfig.bodyTemplate, { body = renderTemplate(webhookConfig.bodyTemplate, {
event: eventType, event: eventType,
@@ -56,7 +71,11 @@ export async function sendAlertWebhook(
data data
}); });
} else { } 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); const headers = buildHeaders(webhookConfig);
@@ -75,7 +94,7 @@ export async function sendAlertWebhook(
response = await fetch(url, { response = await fetch(url, {
method: webhookConfig.method ?? "POST", method: webhookConfig.method ?? "POST",
headers, headers,
body, body: JSON.stringify(body),
signal: controller.signal signal: controller.signal
}); });
} catch (err: unknown) { } catch (err: unknown) {
@@ -247,7 +266,10 @@ interface TemplateContext {
* left untouched. * left untouched.
* 3. The fixed top-level keys: event, timestamp, status. * 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 // Step 1 expand {{data}} first so its contents are already serialised
// and won't be touched by later passes. // and won't be touched by later passes.
let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)); 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 // Validate the rendered result is valid JSON; if not, log a warning and
// fall back to the default payload so the webhook still fires. // fall back to the default payload so the webhook still fires.
try { try {
JSON.parse(rendered); return JSON.parse(rendered);
return rendered;
} catch { } catch {
logger.warn( logger.warn(
`sendAlertWebhook: body template produced invalid JSON for event ` + `sendAlertWebhook: body template produced invalid JSON for event ` +
`"${ctx.event}" destined for a webhook. Falling back to default ` + `"${ctx.event}" destined for a webhook. Falling back to default ` +
`payload. Check that {{data}} is NOT wrapped in quotes in your template.` `payload. Check that {{data}} is NOT wrapped in quotes in your template.`
); );
return JSON.stringify({ return {
event: ctx.event, event: ctx.event,
timestamp: ctx.timestamp, timestamp: ctx.timestamp,
status: ctx.status, status: ctx.status,
data: ctx.data data: ctx.data
}); };
} }
} }
@@ -180,8 +180,9 @@ export default function AlertRuleGraphEditor({
const testAlert = async () => { const testAlert = async () => {
const isValid = await form.trigger(); const isValid = await form.trigger();
const values = form.getValues();
if (!isValid) { if (!isValid) {
const values = form.getValues();
if (values.actions.length === 0) { if (values.actions.length === 0) {
toast({ toast({
variant: "warning", variant: "warning",
@@ -193,27 +194,14 @@ export default function AlertRuleGraphEditor({
return; return;
} }
const values = form.getValues();
try { try {
const payload = formValuesToApiPayload(values); const payload = formValuesToApiPayload(values);
if (isNew) { await api.post(`/org/${orgId}/test-alert-rule`, payload);
const res = await api.post<
AxiosResponse<CreateAlertRuleResponse> toast({
>(`/org/${orgId}/test-alert-rule`, payload); title: t("alertingTestAlertSent"),
toast({ description: t("alertingTestAlertSentDescription")
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) { } catch (e) {
toast({ toast({
title: t("error"), title: t("error"),
+1 -1
View File
@@ -1371,7 +1371,7 @@ export const approvalQueries = {
}, },
refetchInterval: (query) => { refetchInterval: (query) => {
if (query.state.data) { if (query.state.data) {
return durationToMs(30, "seconds"); return durationToMs(1.5, "minutes");
} }
return false; return false;
} }