mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-06 20:37:45 +00:00
Allow configuring the webhook body
This commit is contained in:
@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
|
||||
webhookConfig: WebhookAlertConfig,
|
||||
context: AlertContext
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
event: context.eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: deriveStatus(context.eventType, context.data),
|
||||
data: {
|
||||
orgId: context.orgId,
|
||||
...context.data
|
||||
}
|
||||
};
|
||||
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;
|
||||
if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) {
|
||||
body = renderTemplate(webhookConfig.bodyTemplate, {
|
||||
event: eventType,
|
||||
timestamp,
|
||||
status,
|
||||
data
|
||||
});
|
||||
} else {
|
||||
body = JSON.stringify({ event: eventType, timestamp, status, data });
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers = buildHeaders(webhookConfig);
|
||||
|
||||
let lastError: Error | undefined;
|
||||
@@ -217,3 +223,52 @@ function buildHeaders(
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Body template rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TemplateContext {
|
||||
event: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a body template with {{event}}, {{timestamp}}, {{status}}, and
|
||||
* {{data}} placeholders, mirroring the logic in HttpLogDestination.
|
||||
*
|
||||
* {{data}} is replaced first (as raw JSON) so that any literal "{{…}}"
|
||||
* strings inside data values are not re-expanded.
|
||||
*/
|
||||
function renderTemplate(template: string, ctx: TemplateContext): string {
|
||||
const rendered = template
|
||||
.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data))
|
||||
.replace(/\{\{event\}\}/g, escapeJsonString(ctx.event))
|
||||
.replace(/\{\{timestamp\}\}/g, escapeJsonString(ctx.timestamp))
|
||||
.replace(/\{\{status\}\}/g, escapeJsonString(ctx.status));
|
||||
|
||||
// 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;
|
||||
} 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({
|
||||
event: ctx.event,
|
||||
timestamp: ctx.timestamp,
|
||||
status: ctx.status,
|
||||
data: ctx.data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJsonString(value: string): string {
|
||||
return JSON.stringify(value).slice(1, -1);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface WebhookAlertConfig {
|
||||
headers?: Array<{ key: string; value: string }>;
|
||||
/** HTTP method (default POST) */
|
||||
method?: string;
|
||||
/** Whether to use a custom body template */
|
||||
useBodyTemplate?: boolean;
|
||||
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
|
||||
bodyTemplate?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -60,4 +64,4 @@ export interface AlertContext {
|
||||
healthCheckId?: number;
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
|
||||
headers?: Array<{ key: string; value: string }>;
|
||||
/** HTTP method (default POST) */
|
||||
method?: string;
|
||||
/** Whether to use a custom body template */
|
||||
useBodyTemplate?: boolean;
|
||||
/** Mustache-style body template with {{event}}, {{timestamp}}, {{status}}, {{data}} placeholders */
|
||||
bodyTemplate?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -962,6 +965,69 @@ function WebhookActionFields({
|
||||
/>
|
||||
</div>
|
||||
<WebhookHeadersField index={index} control={control} form={form} />
|
||||
{/* Body Template */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="font-medium text-sm block">
|
||||
{t("httpDestBodyTemplateTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("httpDestBodyTemplateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.useBodyTemplate`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-3">
|
||||
<FormControl>
|
||||
<Switch
|
||||
id={`body-template-${index}`}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<Label
|
||||
htmlFor={`body-template-${index}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("httpDestEnableBodyTemplate")}
|
||||
</Label>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{useWatch({
|
||||
control,
|
||||
name: `actions.${index}.useBodyTemplate`
|
||||
}) && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.bodyTemplate`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("httpDestBodyTemplateLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={
|
||||
'{{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "status": "{{status}}",\n "data": {{data}}\n}}'
|
||||
}
|
||||
className="font-mono text-xs min-h-45 resize-y"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("httpDestBodyTemplateHint")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
|
||||
customHeaderName:
|
||||
"",
|
||||
customHeaderValue:
|
||||
""
|
||||
"",
|
||||
useBodyTemplate: false,
|
||||
bodyTemplate: ""
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
|
||||
basicCredentials: string;
|
||||
customHeaderName: string;
|
||||
customHeaderValue: string;
|
||||
useBodyTemplate: boolean;
|
||||
bodyTemplate: string;
|
||||
};
|
||||
|
||||
export type AlertRuleFormValues = {
|
||||
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
|
||||
customHeaderValue?: string;
|
||||
headers?: { key: string; value: string }[];
|
||||
method?: string;
|
||||
useBodyTemplate?: boolean;
|
||||
bodyTemplate?: string;
|
||||
} | null;
|
||||
}[];
|
||||
};
|
||||
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
|
||||
bearerToken: z.string(),
|
||||
basicCredentials: z.string(),
|
||||
customHeaderName: z.string(),
|
||||
customHeaderValue: z.string()
|
||||
customHeaderValue: z.string(),
|
||||
useBodyTemplate: z.boolean().default(false),
|
||||
bodyTemplate: z.string().default("")
|
||||
})
|
||||
])
|
||||
)
|
||||
@@ -415,7 +421,9 @@ export function apiResponseToFormValues(
|
||||
bearerToken: cfg?.bearerToken ?? "",
|
||||
basicCredentials: cfg?.basicCredentials ?? "",
|
||||
customHeaderName: cfg?.customHeaderName ?? "",
|
||||
customHeaderValue: cfg?.customHeaderValue ?? ""
|
||||
customHeaderValue: cfg?.customHeaderValue ?? "",
|
||||
useBodyTemplate: cfg?.useBodyTemplate ?? false,
|
||||
bodyTemplate: cfg?.bodyTemplate ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +487,11 @@ export function formValuesToApiPayload(
|
||||
customHeaderName: action.customHeaderName || undefined,
|
||||
customHeaderValue: action.customHeaderValue || undefined,
|
||||
headers: action.headers.filter((h) => h.key.trim()),
|
||||
method: action.method
|
||||
method: action.method,
|
||||
useBodyTemplate: action.useBodyTemplate || undefined,
|
||||
bodyTemplate: action.useBodyTemplate
|
||||
? action.bodyTemplate || undefined
|
||||
: undefined
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user