mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-10 14:24:17 +00:00
Allow configuring the webhook body
This commit is contained in:
@@ -42,17 +42,23 @@ export async function sendAlertWebhook(
|
|||||||
webhookConfig: WebhookAlertConfig,
|
webhookConfig: WebhookAlertConfig,
|
||||||
context: AlertContext
|
context: AlertContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payload = {
|
const eventType = context.eventType;
|
||||||
event: context.eventType,
|
const timestamp = new Date().toISOString();
|
||||||
timestamp: new Date().toISOString(),
|
const status = deriveStatus(eventType, context.data);
|
||||||
status: deriveStatus(context.eventType, context.data),
|
const data = { orgId: context.orgId, ...context.data };
|
||||||
data: {
|
|
||||||
orgId: context.orgId,
|
let body: string;
|
||||||
...context.data
|
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);
|
const headers = buildHeaders(webhookConfig);
|
||||||
|
|
||||||
let lastError: Error | undefined;
|
let lastError: Error | undefined;
|
||||||
@@ -217,3 +223,52 @@ function buildHeaders(
|
|||||||
|
|
||||||
return headers;
|
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 }>;
|
headers?: Array<{ key: string; value: string }>;
|
||||||
/** HTTP method (default POST) */
|
/** HTTP method (default POST) */
|
||||||
method?: string;
|
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;
|
healthCheckId?: number;
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
/** Human-readable context data included in emails and webhook payloads */
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export interface WebhookAlertConfig {
|
|||||||
headers?: Array<{ key: string; value: string }>;
|
headers?: Array<{ key: string; value: string }>;
|
||||||
/** HTTP method (default POST) */
|
/** HTTP method (default POST) */
|
||||||
method?: string;
|
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";
|
} from "@app/components/ui/command";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -962,6 +965,69 @@ function WebhookActionFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<WebhookHeadersField index={index} control={control} form={form} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,7 +368,9 @@ export default function AlertRuleGraphEditor({
|
|||||||
customHeaderName:
|
customHeaderName:
|
||||||
"",
|
"",
|
||||||
customHeaderValue:
|
customHeaderValue:
|
||||||
""
|
"",
|
||||||
|
useBodyTemplate: false,
|
||||||
|
bodyTemplate: ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type AlertRuleFormAction =
|
|||||||
basicCredentials: string;
|
basicCredentials: string;
|
||||||
customHeaderName: string;
|
customHeaderName: string;
|
||||||
customHeaderValue: string;
|
customHeaderValue: string;
|
||||||
|
useBodyTemplate: boolean;
|
||||||
|
bodyTemplate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlertRuleFormValues = {
|
export type AlertRuleFormValues = {
|
||||||
@@ -130,6 +132,8 @@ export type AlertRuleApiResponse = {
|
|||||||
customHeaderValue?: string;
|
customHeaderValue?: string;
|
||||||
headers?: { key: string; value: string }[];
|
headers?: { key: string; value: string }[];
|
||||||
method?: string;
|
method?: string;
|
||||||
|
useBodyTemplate?: boolean;
|
||||||
|
bodyTemplate?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@@ -187,7 +191,9 @@ export function buildFormSchema(t: (k: string) => string) {
|
|||||||
bearerToken: z.string(),
|
bearerToken: z.string(),
|
||||||
basicCredentials: z.string(),
|
basicCredentials: z.string(),
|
||||||
customHeaderName: 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 ?? "",
|
bearerToken: cfg?.bearerToken ?? "",
|
||||||
basicCredentials: cfg?.basicCredentials ?? "",
|
basicCredentials: cfg?.basicCredentials ?? "",
|
||||||
customHeaderName: cfg?.customHeaderName ?? "",
|
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,
|
customHeaderName: action.customHeaderName || undefined,
|
||||||
customHeaderValue: action.customHeaderValue || undefined,
|
customHeaderValue: action.customHeaderValue || undefined,
|
||||||
headers: action.headers.filter((h) => h.key.trim()),
|
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