Update websocket to be consistant with streaming

This commit is contained in:
Owen
2026-04-16 21:27:06 -07:00
parent f932cc7aca
commit 3645cc5759
8 changed files with 275 additions and 49 deletions

View File

@@ -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;
} }

View File

@@ -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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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
})) }))
); );

View File

@@ -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,

View File

@@ -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
})) }))
); );

View File

@@ -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>
); );

View File

@@ -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
}) })
}
: {})
}); });
} }
} }

View File

@@ -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;