mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-31 13:06:32 +00:00
Update websocket to be consistant with streaming
This commit is contained in:
@@ -55,7 +55,7 @@ export async function sendAlertWebhook(
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method: "POST",
|
method: webhookConfig.method ?? "POST",
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
@@ -128,5 +128,13 @@ function buildHeaders(webhookConfig: WebhookAlertConfig): Record<string, string>
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (webhookConfig.headers) {
|
||||||
|
for (const { key, value } of webhookConfig.headers) {
|
||||||
|
if (key.trim()) {
|
||||||
|
headers[key.trim()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,10 @@ export interface WebhookAlertConfig {
|
|||||||
customHeaderName?: string;
|
customHeaderName?: string;
|
||||||
/** Custom header value – used when authType === "custom" */
|
/** Custom header value – used when authType === "custom" */
|
||||||
customHeaderValue?: string;
|
customHeaderValue?: string;
|
||||||
|
/** Extra headers to send with every webhook request */
|
||||||
|
headers?: Array<{ key: string; value: string }>;
|
||||||
|
/** HTTP method (default POST) */
|
||||||
|
method?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
||||||
const HC_EVENT_TYPES = [
|
const HC_EVENT_TYPES = [
|
||||||
@@ -247,11 +249,12 @@ export async function createAlertRule(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (webhookActions.length > 0) {
|
if (webhookActions.length > 0) {
|
||||||
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
await db.insert(alertWebhookActions).values(
|
await db.insert(alertWebhookActions).values(
|
||||||
webhookActions.map((wa) => ({
|
webhookActions.map((wa) => ({
|
||||||
alertRuleId: rule.alertRuleId,
|
alertRuleId: rule.alertRuleId,
|
||||||
webhookUrl: wa.webhookUrl,
|
webhookUrl: wa.webhookUrl,
|
||||||
config: wa.config ?? null,
|
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||||
enabled: wa.enabled
|
enabled: wa.enabled
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { WebhookAlertConfig } from "@server/lib/alerts/types";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -64,6 +67,7 @@ export type GetAlertRuleResponse = {
|
|||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastSentAt: number | null;
|
lastSentAt: number | null;
|
||||||
|
config: WebhookAlertConfig | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,12 +176,25 @@ export async function getAlertRule(
|
|||||||
siteIds: siteRows.map((r) => r.siteId),
|
siteIds: siteRows.map((r) => r.siteId),
|
||||||
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
|
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
|
||||||
recipients,
|
recipients,
|
||||||
webhookActions: webhooks.map((w) => ({
|
webhookActions: webhooks.map((w) => {
|
||||||
|
let parsedConfig: WebhookAlertConfig | null = null;
|
||||||
|
if (w.config) {
|
||||||
|
try {
|
||||||
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
|
const decrypted = decrypt(w.config, serverSecret);
|
||||||
|
parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig;
|
||||||
|
} catch {
|
||||||
|
// best-effort – return null if decryption fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
webhookActionId: w.webhookActionId,
|
webhookActionId: w.webhookActionId,
|
||||||
webhookUrl: w.webhookUrl,
|
webhookUrl: w.webhookUrl,
|
||||||
enabled: w.enabled,
|
enabled: w.enabled,
|
||||||
lastSentAt: w.lastSentAt ?? null
|
lastSentAt: w.lastSentAt ?? null,
|
||||||
}))
|
config: parsedConfig
|
||||||
|
};
|
||||||
|
})
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
||||||
const HC_EVENT_TYPES = [
|
const HC_EVENT_TYPES = [
|
||||||
@@ -302,11 +304,12 @@ export async function updateAlertRule(
|
|||||||
.where(eq(alertWebhookActions.alertRuleId, alertRuleId));
|
.where(eq(alertWebhookActions.alertRuleId, alertRuleId));
|
||||||
|
|
||||||
if (webhookActions.length > 0) {
|
if (webhookActions.length > 0) {
|
||||||
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
await db.insert(alertWebhookActions).values(
|
await db.insert(alertWebhookActions).values(
|
||||||
webhookActions.map((wa) => ({
|
webhookActions.map((wa) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
webhookUrl: wa.webhookUrl,
|
webhookUrl: wa.webhookUrl,
|
||||||
config: wa.config ?? null,
|
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||||
enabled: wa.enabled
|
enabled: wa.enabled
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
|
import {
|
||||||
|
RadioGroup,
|
||||||
|
RadioGroupItem
|
||||||
|
} from "@app/components/ui/radio-group";
|
||||||
|
import { Label } from "@app/components/ui/label";
|
||||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import {
|
import {
|
||||||
@@ -322,8 +327,12 @@ export function ActionBlock({
|
|||||||
type: "webhook",
|
type: "webhook",
|
||||||
url: "",
|
url: "",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: [{ key: "", value: "" }],
|
headers: [],
|
||||||
secret: ""
|
authType: "none",
|
||||||
|
bearerToken: "",
|
||||||
|
basicCredentials: "",
|
||||||
|
customHeaderName: "",
|
||||||
|
customHeaderValue: ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -580,26 +589,187 @@ function WebhookActionFields({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Authentication */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="font-medium text-sm block">
|
||||||
|
{t("httpDestAuthTitle")}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAuthDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={control}
|
control={control}
|
||||||
name={`actions.${index}.secret`}
|
name={`actions.${index}.authType`}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("alertingWebhookSecret")}</FormLabel>
|
<FormControl>
|
||||||
|
<RadioGroup
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{/* None */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="none"
|
||||||
|
id={`auth-none-${index}`}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor={`auth-none-${index}`}
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestAuthNoneTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAuthNoneDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bearer */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="bearer"
|
||||||
|
id={`auth-bearer-${index}`}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor={`auth-bearer-${index}`}
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestAuthBearerTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAuthBearerDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{field.value === "bearer" && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.bearerToken`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...f}
|
||||||
type="password"
|
placeholder={t("httpDestAuthBearerPlaceholder")}
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder={t(
|
|
||||||
"alertingWebhookSecretPlaceholder"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="basic"
|
||||||
|
id={`auth-basic-${index}`}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor={`auth-basic-${index}`}
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestAuthBasicTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAuthBasicDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{field.value === "basic" && (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.basicCredentials`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
placeholder={t("httpDestAuthBasicPlaceholder")}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom */}
|
||||||
|
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="custom"
|
||||||
|
id={`auth-custom-${index}`}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor={`auth-custom-${index}`}
|
||||||
|
className="cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
{t("httpDestAuthCustomTitle")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{t("httpDestAuthCustomDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{field.value === "custom" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.customHeaderName`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
placeholder={t("httpDestAuthCustomHeaderNamePlaceholder")}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={`actions.${index}.customHeaderValue`}
|
||||||
|
render={({ field: f }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...f}
|
||||||
|
placeholder={t("httpDestAuthCustomHeaderValuePlaceholder")}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<WebhookHeadersField index={index} control={control} form={form} />
|
<WebhookHeadersField index={index} control={control} form={form} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ export type AlertRuleFormAction =
|
|||||||
url: string;
|
url: string;
|
||||||
method: string;
|
method: string;
|
||||||
headers: { key: string; value: string }[];
|
headers: { key: string; value: string }[];
|
||||||
secret: string;
|
authType: "none" | "bearer" | "basic" | "custom";
|
||||||
|
bearerToken: string;
|
||||||
|
basicCredentials: string;
|
||||||
|
customHeaderName: string;
|
||||||
|
customHeaderValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlertRuleFormValues = {
|
export type AlertRuleFormValues = {
|
||||||
@@ -95,6 +99,15 @@ export type AlertRuleApiResponse = {
|
|||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastSentAt: number | null;
|
lastSentAt: number | null;
|
||||||
|
config: {
|
||||||
|
authType: string;
|
||||||
|
bearerToken?: string;
|
||||||
|
basicCredentials?: string;
|
||||||
|
customHeaderName?: string;
|
||||||
|
customHeaderValue?: string;
|
||||||
|
headers?: { key: string; value: string }[];
|
||||||
|
method?: string;
|
||||||
|
} | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,7 +168,11 @@ export function buildFormSchema(t: (k: string) => string) {
|
|||||||
value: z.string()
|
value: z.string()
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
secret: z.string()
|
authType: z.enum(["none", "bearer", "basic", "custom"]),
|
||||||
|
bearerToken: z.string(),
|
||||||
|
basicCredentials: z.string(),
|
||||||
|
customHeaderName: z.string(),
|
||||||
|
customHeaderValue: z.string()
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -293,12 +310,19 @@ export function apiResponseToFormValues(
|
|||||||
|
|
||||||
// Each webhook action becomes its own form webhook action
|
// Each webhook action becomes its own form webhook action
|
||||||
for (const w of rule.webhookActions) {
|
for (const w of rule.webhookActions) {
|
||||||
|
const cfg = w.config;
|
||||||
actions.push({
|
actions.push({
|
||||||
type: "webhook",
|
type: "webhook",
|
||||||
url: w.webhookUrl,
|
url: w.webhookUrl,
|
||||||
method: "POST",
|
method: cfg?.method ?? "POST",
|
||||||
headers: [{ key: "", value: "" }],
|
headers: cfg?.headers?.length
|
||||||
secret: ""
|
? cfg.headers
|
||||||
|
: [{ key: "", value: "" }],
|
||||||
|
authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none",
|
||||||
|
bearerToken: cfg?.bearerToken ?? "",
|
||||||
|
basicCredentials: cfg?.basicCredentials ?? "",
|
||||||
|
customHeaderName: cfg?.customHeaderName ?? "",
|
||||||
|
customHeaderValue: cfg?.customHeaderValue ?? ""
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,18 +376,15 @@ export function formValuesToApiPayload(
|
|||||||
webhookActions.push({
|
webhookActions.push({
|
||||||
webhookUrl: action.url.trim(),
|
webhookUrl: action.url.trim(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
// Encode any headers / secret as config JSON if present
|
|
||||||
...(action.secret.trim() ||
|
|
||||||
action.headers.some((h) => h.key.trim())
|
|
||||||
? {
|
|
||||||
config: JSON.stringify({
|
config: JSON.stringify({
|
||||||
secret: action.secret.trim() || undefined,
|
authType: action.authType,
|
||||||
headers: action.headers.filter(
|
bearerToken: action.bearerToken || undefined,
|
||||||
(h) => h.key.trim()
|
basicCredentials: action.basicCredentials || undefined,
|
||||||
)
|
customHeaderName: action.customHeaderName || undefined,
|
||||||
|
customHeaderValue: action.customHeaderValue || undefined,
|
||||||
|
headers: action.headers.filter((h) => h.key.trim()),
|
||||||
|
method: action.method
|
||||||
})
|
})
|
||||||
}
|
|
||||||
: {})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import z from "zod";
|
|||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
import { StatusHistoryResponse } from "@server/middlewares/statusHistory";
|
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user