Files
pangolin/src/lib/alertRuleForm.ts
2026-04-25 15:34:04 -07:00

510 lines
17 KiB
TypeScript

import type { Tag } from "@app/components/tags/tag-input";
import { z } from "zod";
// ---------------------------------------------------------------------------
// Shared primitive schemas
// ---------------------------------------------------------------------------
export const tagSchema = z.object({
id: z.string(),
text: z.string()
});
// ---------------------------------------------------------------------------
// Form-layer types
// NOTE: the form uses "health_check_unhealthy" internally; it maps to the
// backend's "health_check_unhealthy" at the API boundary.
// ---------------------------------------------------------------------------
export type AlertTrigger =
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
export type AlertRuleFormAction =
| {
type: "notify";
userTags: Tag[];
roleTags: Tag[];
emailTags: Tag[];
}
| {
type: "webhook";
url: string;
method: string;
headers: { key: string; value: string }[];
authType: "none" | "bearer" | "basic" | "custom";
bearerToken: string;
basicCredentials: string;
customHeaderName: string;
customHeaderValue: string;
};
export type AlertRuleFormValues = {
name: string;
enabled: boolean;
cooldownSeconds: number;
sourceType: "site" | "health_check" | "resource";
allSites: boolean;
siteIds: number[];
allHealthChecks: boolean;
healthCheckIds: number[];
allResources: boolean;
resourceIds: number[];
trigger: AlertTrigger;
actions: AlertRuleFormAction[];
};
// ---------------------------------------------------------------------------
// API boundary types
// ---------------------------------------------------------------------------
export type AlertRuleApiPayload = {
name: string;
cooldownSeconds: number;
eventType:
| "site_online"
| "site_offline"
| "site_toggle"
| "health_check_healthy"
| "health_check_unhealthy"
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
enabled: boolean;
allSites: boolean;
siteIds: number[];
allHealthChecks: boolean;
healthCheckIds: number[];
allResources: boolean;
resourceIds: number[];
userIds: string[];
roleIds: number[];
emails: string[];
webhookActions: {
webhookUrl: string;
enabled: boolean;
config?: string;
}[];
};
// Shape of what GET /org/:orgId/alert-rule/:alertRuleId returns
export type AlertRuleApiResponse = {
alertRuleId: number;
orgId: string;
name: string;
eventType: string;
enabled: boolean;
cooldownSeconds: number;
lastTriggeredAt: number | null;
createdAt: number;
updatedAt: number;
siteIds: number[];
healthCheckIds: number[];
resourceIds: number[];
recipients: {
recipientId: number;
userId: string | null;
roleId: number | null;
email: string | null;
}[];
webhookActions: {
webhookActionId: number;
webhookUrl: string;
enabled: boolean;
lastSentAt: number | null;
config: {
authType: string;
bearerToken?: string;
basicCredentials?: string;
customHeaderName?: string;
customHeaderValue?: string;
headers?: { key: string; value: string }[];
method?: string;
} | null;
}[];
};
// ---------------------------------------------------------------------------
// Zod form schema (for react-hook-form validation)
// ---------------------------------------------------------------------------
export function buildFormSchema(t: (k: string) => string) {
return z
.object({
name: z
.string()
.min(1, { message: t("alertingErrorNameRequired") }),
enabled: z.boolean(),
cooldownSeconds: z.number().int().nonnegative().default(0),
sourceType: z.enum(["site", "health_check", "resource"]),
allSites: z.boolean().default(true),
siteIds: z.array(z.number()).default([]),
allHealthChecks: z.boolean().default(true),
healthCheckIds: z.array(z.number()).default([]),
allResources: z.boolean().default(true),
resourceIds: z.array(z.number()).default([]),
trigger: z.enum([
"site_online",
"site_offline",
"site_toggle",
"health_check_healthy",
"health_check_unhealthy",
"health_check_toggle",
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
]),
actions: z.array(
z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userTags: z.array(tagSchema),
roleTags: z.array(tagSchema),
emailTags: z.array(tagSchema)
}),
z.object({
type: z.literal("webhook"),
url: z.string(),
method: z.string(),
headers: z.array(
z.object({
key: z.string(),
value: z.string()
})
),
authType: z.enum(["none", "bearer", "basic", "custom"]),
bearerToken: z.string(),
basicCredentials: z.string(),
customHeaderName: z.string(),
customHeaderValue: z.string()
})
])
)
})
.superRefine((val, ctx) => {
if (val.actions.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorActionsMin"),
path: ["actions"]
});
}
if (
val.sourceType === "site" &&
!val.allSites &&
val.siteIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickSites"),
path: ["siteIds"]
});
}
if (
val.sourceType === "health_check" &&
!val.allHealthChecks &&
val.healthCheckIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickHealthChecks"),
path: ["healthCheckIds"]
});
}
if (
val.sourceType === "resource" &&
!val.allResources &&
val.resourceIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickResources"),
path: ["resourceIds"]
});
}
const siteTriggers: AlertTrigger[] = [
"site_online",
"site_offline",
"site_toggle"
];
const hcTriggers: AlertTrigger[] = [
"health_check_healthy",
"health_check_unhealthy",
"health_check_toggle"
];
const resourceTriggers: AlertTrigger[] = [
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
];
if (
val.sourceType === "site" &&
!siteTriggers.includes(val.trigger)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorTriggerSite"),
path: ["trigger"]
});
}
if (
val.sourceType === "health_check" &&
!hcTriggers.includes(val.trigger)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorTriggerHealth"),
path: ["trigger"]
});
}
if (
val.sourceType === "resource" &&
!resourceTriggers.includes(val.trigger)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorTriggerResource"),
path: ["trigger"]
});
}
val.actions.forEach((a, i) => {
if (a.type === "notify") {
if (
a.userTags.length === 0 &&
a.roleTags.length === 0 &&
a.emailTags.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorNotifyRecipients"),
path: ["actions", i, "userTags"]
});
}
}
if (a.type === "webhook") {
try {
new URL(a.url.trim());
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorWebhookUrl"),
path: ["actions", i, "url"]
});
}
}
});
});
}
// ---------------------------------------------------------------------------
// defaultFormValues
// ---------------------------------------------------------------------------
export function defaultFormValues(): AlertRuleFormValues {
return {
name: "",
enabled: true,
cooldownSeconds: 0,
sourceType: "site",
allSites: true,
siteIds: [],
allHealthChecks: true,
healthCheckIds: [],
allResources: true,
resourceIds: [],
trigger: "site_toggle",
actions: []
};
}
// ---------------------------------------------------------------------------
// List/API row semantics: empty ID arrays mean "all" for that source kind
// ---------------------------------------------------------------------------
export function alertRuleAllSitesSelected(
eventType: string,
siteIds: number[]
): boolean {
const siteEvent =
eventType === "site_online" ||
eventType === "site_offline" ||
eventType === "site_toggle";
return siteEvent && siteIds.length === 0;
}
export function alertRuleAllResourcesSelected(
eventType: string,
resourceIds: number[] | undefined
): boolean {
return (
eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0
);
}
export function alertRuleAllHealthChecksSelected(
eventType: string,
healthCheckIds: number[]
): boolean {
if (
eventType === "site_online" ||
eventType === "site_offline" ||
eventType === "site_toggle" ||
eventType.startsWith("resource_")
) {
return false;
}
return healthCheckIds.length === 0;
}
// ---------------------------------------------------------------------------
// API response → form values
// ---------------------------------------------------------------------------
export function apiResponseToFormValues(
rule: AlertRuleApiResponse
): AlertRuleFormValues {
const trigger = rule.eventType;
const sourceType = rule.eventType.startsWith("site_")
? "site"
: rule.eventType.startsWith("resource_")
? "resource"
: "health_check";
// Collect notify recipients into a single notify action (if any)
const userTags = rule.recipients
.filter((r) => r.userId != null)
.map((r) => ({ id: r.userId!, text: r.userId! }));
const roleTags = rule.recipients
.filter((r) => r.roleId != null)
.map((r) => ({ id: String(r.roleId!), text: String(r.roleId!) }));
const emailTags = rule.recipients
.filter((r) => r.email != null)
.map((r) => ({ id: r.email!, text: r.email! }));
const actions: AlertRuleFormAction[] = [];
if (userTags.length > 0 || roleTags.length > 0 || emailTags.length > 0) {
actions.push({ type: "notify", userTags, roleTags, emailTags });
}
// Each webhook action becomes its own form webhook action
for (const w of rule.webhookActions) {
const cfg = w.config;
actions.push({
type: "webhook",
url: w.webhookUrl,
method: cfg?.method ?? "POST",
headers: cfg?.headers?.length
? 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 ?? ""
});
}
const allSites = alertRuleAllSitesSelected(rule.eventType, rule.siteIds);
const allHealthChecks = alertRuleAllHealthChecksSelected(
rule.eventType,
rule.healthCheckIds
);
const allResources = alertRuleAllResourcesSelected(
rule.eventType,
rule.resourceIds
);
return {
name: rule.name,
enabled: rule.enabled,
cooldownSeconds: rule.cooldownSeconds ?? 0,
sourceType,
allSites,
siteIds: rule.siteIds,
allHealthChecks,
healthCheckIds: rule.healthCheckIds,
allResources,
resourceIds: rule.resourceIds ?? [],
trigger: trigger as AlertTrigger,
actions
};
}
// ---------------------------------------------------------------------------
// Form values → API payload
// ---------------------------------------------------------------------------
export function formValuesToApiPayload(
values: AlertRuleFormValues
): AlertRuleApiPayload {
const eventType = values.trigger;
// Collect all notify-type actions and merge their recipient lists
const allUserIds: string[] = [];
const allRoleIds: number[] = [];
const allEmails: string[] = [];
const webhookActions: AlertRuleApiPayload["webhookActions"] = [];
for (const action of values.actions) {
if (action.type === "notify") {
allUserIds.push(...action.userTags.map((t) => t.id));
allRoleIds.push(...action.roleTags.map((t) => Number(t.id)));
allEmails.push(
...action.emailTags.map((t) => t.text.trim()).filter(Boolean)
);
} else if (action.type === "webhook") {
webhookActions.push({
webhookUrl: action.url.trim(),
enabled: true,
config: JSON.stringify({
authType: action.authType,
bearerToken: action.bearerToken || undefined,
basicCredentials: action.basicCredentials || undefined,
customHeaderName: action.customHeaderName || undefined,
customHeaderValue: action.customHeaderValue || undefined,
headers: action.headers.filter((h) => h.key.trim()),
method: action.method
})
});
}
}
// Deduplicate
const uniqueUserIds = [...new Set(allUserIds)];
const uniqueRoleIds: number[] = [...new Set(allRoleIds)];
const uniqueEmails = [...new Set(allEmails)];
return {
name: values.name.trim(),
eventType,
enabled: values.enabled,
cooldownSeconds: values.cooldownSeconds,
allSites: values.allSites,
siteIds: values.allSites ? [] : values.siteIds,
allHealthChecks: values.allHealthChecks,
healthCheckIds: values.allHealthChecks ? [] : values.healthCheckIds,
allResources: values.allResources,
resourceIds: values.allResources ? [] : values.resourceIds,
userIds: uniqueUserIds,
roleIds: uniqueRoleIds,
emails: uniqueEmails,
webhookActions
};
}