mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-05 12:04:14 +00:00
510 lines
17 KiB
TypeScript
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
|
|
};
|
|
}
|