mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 23:40:40 +02:00
✨ send webhook action
This commit is contained in:
@@ -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 `,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user