diff --git a/messages/en-US.json b/messages/en-US.json index 8a9f864c4..13f73a3ad 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1376,6 +1376,18 @@ "alertingPickSites": "Sites", "alertingPickHealthChecks": "Health checks", "alertingPickResources": "Resources", + "alertingAllSites": "All sites", + "alertingAllSitesDescription": "Alert fires for any site", + "alertingSpecificSites": "Specific sites", + "alertingSpecificSitesDescription": "Choose specific sites to watch", + "alertingAllHealthChecks": "All health checks", + "alertingAllHealthChecksDescription": "Alert fires for any health check", + "alertingSpecificHealthChecks": "Specific health checks", + "alertingSpecificHealthChecksDescription": "Choose specific health checks to watch", + "alertingAllResources": "All resources", + "alertingAllResourcesDescription": "Alert fires for any resource", + "alertingSpecificResources": "Specific resources", + "alertingSpecificResourcesDescription": "Choose specific resources to watch", "alertingSelectResources": "Select resources…", "alertingResourcesSelected": "{count} resources selected", "alertingResourcesEmpty": "No resources with targets in the first 10 results.", diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index b42750b87..ff7321c77 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -66,14 +66,17 @@ const bodySchema = z cooldownSeconds: z.number().int().nonnegative().optional().default(300), // Source join tables - which is required depends on eventType siteIds: z.array(z.number().int().positive()).optional().default([]), + allSites: z.boolean().optional().default(false), healthCheckIds: z .array(z.number().int().positive()) .optional() .default([]), + allHealthChecks: z.boolean().optional().default(false), resourceIds: z .array(z.number().int().positive()) .optional() .default([]), + allResources: z.boolean().optional().default(false), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), roleIds: z.array(z.number()).optional().default([]), @@ -92,19 +95,19 @@ const bodySchema = z val.eventType ); - if (isSiteEvent && val.siteIds.length === 0) { + if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one siteId is required for site event types", + message: "At least one siteId is required for site event types when allSites is false", path: ["siteIds"] }); } - if (isHcEvent && val.healthCheckIds.length === 0) { + if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "At least one healthCheckId is required for health check event types", + "At least one healthCheckId is required for health check event types when allHealthChecks is false", path: ["healthCheckIds"] }); } @@ -125,10 +128,10 @@ const bodySchema = z }); } - if (isResourceEvent && val.resourceIds.length === 0) { + if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one resourceId is required for resource event types", + message: "At least one resourceId is required for resource event types when allResources is false", path: ["resourceIds"] }); } @@ -222,8 +225,11 @@ export async function createAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, resourceIds, + allResources, userIds, roleIds, emails, @@ -245,8 +251,8 @@ export async function createAlertRule( }) .returning(); - // Insert site associations - if (siteIds.length > 0) { + // Insert site associations (skipped when allSites=true — empty junction = match all) + if (!allSites && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId: rule.alertRuleId, @@ -255,8 +261,8 @@ export async function createAlertRule( ); } - // Insert health check associations - if (healthCheckIds.length > 0) { + // Insert health check associations (skipped when allHealthChecks=true) + if (!allHealthChecks && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId: rule.alertRuleId, @@ -265,8 +271,8 @@ export async function createAlertRule( ); } - // Insert resource associations - if (resourceIds.length > 0) { + // Insert resource associations (skipped when allResources=true) + if (!allResources && resourceIds.length > 0) { await db.insert(alertResources).values( resourceIds.map((resourceId) => ({ alertRuleId: rule.alertRuleId, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index cd07c0a2e..4b2c2df87 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -33,6 +33,7 @@ import { and, eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; +import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode"; const paramsSchema = z .object({ @@ -62,8 +63,11 @@ const bodySchema = z cooldownSeconds: z.number().int().nonnegative().optional(), // Source join tables - if provided the full set is replaced siteIds: z.array(z.number().int().positive()).optional(), + allSites: z.boolean().optional(), healthCheckIds: z.array(z.number().int().positive()).optional(), + allHealthChecks: z.boolean().optional(), resourceIds: z.array(z.number().int().positive()).optional(), + allResources: z.boolean().optional(), // Recipient arrays - if any are provided the full recipient set is replaced userIds: z.array(z.string().nonempty()).optional(), roleIds: z.array(z.number()).optional(), @@ -84,6 +88,30 @@ const bodySchema = z val.eventType ); + if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one siteId is required for site event types when allSites is false", + path: ["siteIds"] + }); + } + + if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one healthCheckId is required for health check event types when allHealthChecks is false", + path: ["healthCheckIds"] + }); + } + + if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types when allResources is false", + path: ["resourceIds"] + }); + } + if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -363,4 +391,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index b040530dd..2b57724cc 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -35,6 +35,7 @@ import { RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; +import { StrategySelect } from "@app/components/StrategySelect"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { @@ -957,6 +958,58 @@ export function AlertRuleSourceFields({ const t = useTranslations(); const { setValue, getValues } = useFormContext(); const sourceType = useWatch({ control, name: "sourceType" }); + const allSites = useWatch({ control, name: "allSites" }); + const allHealthChecks = useWatch({ control, name: "allHealthChecks" }); + const allResources = useWatch({ control, name: "allResources" }); + + const siteStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllSites"), + description: t("alertingAllSitesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificSites"), + description: t("alertingSpecificSitesDescription") + } + ], + [t] + ); + + const healthCheckStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllHealthChecks"), + description: t("alertingAllHealthChecksDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificHealthChecks"), + description: t("alertingSpecificHealthChecksDescription") + } + ], + [t] + ); + + const resourceStrategyOptions = useMemo( + () => [ + { + id: "all" as const, + title: t("alertingAllResources"), + description: t("alertingAllResourcesDescription") + }, + { + id: "specific" as const, + title: t("alertingSpecificResources"), + description: t("alertingSpecificResourcesDescription") + } + ], + [t] + ); + return (
{sourceType === "site" ? ( - ( - - {t("alertingPickSites")} - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("siteIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allSites && ( + ( + + + {t("alertingPickSites")} + + + + + )} + /> )} - /> + ) : sourceType === "resource" ? ( - ( - - {t("alertingPickResources")} - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("resourceIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allResources && ( + ( + + + {t("alertingPickResources")} + + + + + )} + /> )} - /> + ) : ( - ( - - - {t("alertingPickHealthChecks")} - - - - + <> + ( + + { + field.onChange(v === "all"); + if (v === "all") { + setValue("healthCheckIds", []); + } + }} + cols={2} + /> + + + )} + /> + {!allHealthChecks && ( + ( + + + {t("alertingPickHealthChecks")} + + + + + )} + /> )} - /> + )}
); diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 4c07c14e2..f7f96e927 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -50,8 +50,11 @@ export type AlertRuleFormValues = { name: string; enabled: boolean; sourceType: "site" | "health_check" | "resource"; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; resourceIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; @@ -74,8 +77,11 @@ export type AlertRuleApiPayload = { | "resource_unhealthy" | "resource_toggle"; enabled: boolean; + allSites: boolean; siteIds: number[]; + allHealthChecks: boolean; healthCheckIds: number[]; + allResources: boolean; resourceIds: number[]; userIds: string[]; roleIds: number[]; @@ -136,8 +142,11 @@ export function buildFormSchema(t: (k: string) => string) { .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), sourceType: z.enum(["site", "health_check", "resource"]), + allSites: z.boolean(), siteIds: z.array(z.number()), + allHealthChecks: z.boolean(), healthCheckIds: z.array(z.number()), + allResources: z.boolean(), resourceIds: z.array(z.number()), trigger: z.enum([ "site_online", @@ -185,7 +194,11 @@ export function buildFormSchema(t: (k: string) => string) { path: ["actions"] }); } - if (val.sourceType === "site" && val.siteIds.length === 0) { + if ( + val.sourceType === "site" && + !val.allSites && + val.siteIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("alertingErrorPickSites"), @@ -194,6 +207,7 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "health_check" && + !val.allHealthChecks && val.healthCheckIds.length === 0 ) { ctx.addIssue({ @@ -204,6 +218,7 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "resource" && + !val.allResources && val.resourceIds.length === 0 ) { ctx.addIssue({ @@ -295,8 +310,11 @@ export function defaultFormValues(): AlertRuleFormValues { name: "", enabled: true, sourceType: "site", + allSites: true, siteIds: [], + allHealthChecks: true, healthCheckIds: [], + allResources: true, resourceIds: [], trigger: "site_toggle", actions: [ @@ -371,12 +389,21 @@ export function apiResponseToFormValues( }); } + const allSites = sourceType === "site" && rule.siteIds.length === 0; + const allHealthChecks = + sourceType === "health_check" && rule.healthCheckIds.length === 0; + const allResources = + sourceType === "resource" && (rule.resourceIds?.length ?? 0) === 0; + return { name: rule.name, enabled: rule.enabled, sourceType, + allSites, siteIds: rule.siteIds, + allHealthChecks, healthCheckIds: rule.healthCheckIds, + allResources, resourceIds: rule.resourceIds ?? [], trigger: trigger as AlertTrigger, actions @@ -432,9 +459,12 @@ export function formValuesToApiPayload( name: values.name.trim(), eventType, enabled: values.enabled, - siteIds: values.siteIds, - healthCheckIds: values.healthCheckIds, - resourceIds: values.resourceIds, + 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,