diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 649993e46..0764cc6be 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -475,8 +475,10 @@ export const alertRules = pgTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" >() .notNull(), // Nullable depending on eventType diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 435a50e32..f44c0b200 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -467,8 +467,10 @@ export const alertRules = sqliteTable("alertRules", { .$type< | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy" + | "health_check_unhealthy" + | "health_check_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 8a0cf7631..41ea8f746 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -15,8 +15,10 @@ import { export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; interface Props { eventType: AlertEventType; @@ -50,6 +52,15 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Offline", statusColor: "#dc2626" }; + case "site_toggle": + return { + heading: "Site Status Changed", + previewText: "A site in your organization has changed status.", + summary: + "A site in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; case "health_check_healthy": return { heading: "Health Check Recovered", @@ -60,7 +71,7 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Healthy", statusColor: "#16a34a" }; - case "health_check_not_healthy": + case "health_check_unhealthy": return { heading: "Health Check Failing", previewText: @@ -70,6 +81,25 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Not Healthy", statusColor: "#dc2626" }; + case "health_check_toggle": + return { + heading: "Health Check Status Changed", + previewText: + "A health check in your organization has changed status.", + summary: + "A health check in your organization has changed status. Please review the details below and take action if needed.", + statusLabel: "Status Changed", + statusColor: "#f59e0b" + }; + default: + return { + heading: "Alert Notification", + previewText: "An alert event has occurred in your organization.", + summary: + "An alert event has occurred in your organization. Please review the details below and take action if needed.", + statusLabel: "Alert", + statusColor: "#f59e0b" + }; } } diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9ede25fe6..594e27aec 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert( } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `health_check_unhealthy` alert for the given health check. * * Call this after a health check has been detected as failing so that any * matching `alertRules` can dispatch their email and webhook actions. @@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert( ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "health_check_unhealthy", orgId, healthCheckId, data: { diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 9ede25fe6..594e27aec 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert( } /** - * Fire a `health_check_not_healthy` alert for the given health check. + * Fire a `health_check_unhealthy` alert for the given health check. * * Call this after a health check has been detected as failing so that any * matching `alertRules` can dispatch their email and webhook actions. @@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert( ): Promise { try { await processAlerts({ - eventType: "health_check_not_healthy", + eventType: "health_check_unhealthy", orgId, healthCheckId, data: { diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index cd78e6e87..afadfed62 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -72,10 +72,14 @@ function buildSubject(context: AlertContext): string { return "[Alert] Site Back Online"; case "site_offline": return "[Alert] Site Offline"; + case "site_toggle": + return "[Alert] Site Toggled"; case "health_check_healthy": return "[Alert] Health Check Recovered"; - case "health_check_not_healthy": + case "health_check_unhealthy": return "[Alert] Health Check Failing"; + case "health_check_toggle": + return "[Alert] Health Check Toggled"; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. @@ -84,4 +88,4 @@ function buildSubject(context: AlertContext): string { return "[Alert] Event Notification"; } } -} \ No newline at end of file +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index e79db2ef5..45f45c035 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -18,8 +18,10 @@ export type AlertEventType = | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; // --------------------------------------------------------------------------- // Webhook authentication config (stored as encrypted JSON in the DB) @@ -60,4 +62,4 @@ export interface AlertContext { healthCheckId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index 25ac64afb..eab6d674a 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -30,12 +30,12 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { and, eq } from "drizzle-orm"; -const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; -const HC_EVENT_TYPES = [ +export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const; +export const HC_EVENT_TYPES = [ "health_check_healthy", - "health_check_not_healthy" + "health_check_unhealthy", + "health_check_toggle" ] as const; const paramsSchema = z.strictObject({ @@ -52,10 +52,8 @@ const bodySchema = z .strictObject({ name: z.string().nonempty(), eventType: z.enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index 5d307316b..b9c5912a9 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -47,8 +47,10 @@ export type GetAlertRuleResponse = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; @@ -59,7 +61,7 @@ export type GetAlertRuleResponse = { recipients: { recipientId: number; userId: string | null; - roleId: string | null; + roleId: number | null; email: string | null; }[]; webhookActions: { @@ -177,24 +179,27 @@ export async function getAlertRule( healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), recipients, 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, - webhookUrl: w.webhookUrl, - enabled: w.enabled, - lastSentAt: w.lastSentAt ?? null, - config: parsedConfig - }; - }) + 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, + webhookUrl: w.webhookUrl, + enabled: w.enabled, + lastSentAt: w.lastSentAt ?? null, + config: parsedConfig + }; + }) }, success: true, error: false, @@ -207,4 +212,4 @@ export async function getAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 398156258..0ad62647d 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -31,12 +31,7 @@ import { OpenAPITags, registry } from "@server/openApi"; 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 HC_EVENT_TYPES = [ - "health_check_healthy", - "health_check_not_healthy" -] as const; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule"; const paramsSchema = z .object({ @@ -57,10 +52,8 @@ const bodySchema = z name: z.string().nonempty().optional(), eventType: z .enum([ - "site_online", - "site_offline", - "health_check_healthy", - "health_check_not_healthy" + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES ]) .optional(), enabled: z.boolean().optional(), diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 33944db39..74b8348fa 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -71,10 +71,14 @@ function triggerLabel( return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); - case "health_check_not_healthy": + case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); default: return rule.eventType; } diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 2037d50a8..678ee3c8c 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -888,7 +888,8 @@ export function AlertRuleSourceFields({ if (next === "site") { if ( curTrigger !== "site_online" && - curTrigger !== "site_offline" + curTrigger !== "site_offline" && + curTrigger !== "site_toggle" ) { setValue("trigger", "site_offline", { shouldValidate: true @@ -896,7 +897,8 @@ export function AlertRuleSourceFields({ } } else if ( curTrigger !== "health_check_healthy" && - curTrigger !== "health_check_unhealthy" + curTrigger !== "health_check_unhealthy" && + curTrigger !== "health_check_toggle" ) { setValue( "trigger", @@ -996,6 +998,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerSiteOffline")} + + {t("alertingTriggerSiteToggle")} + ) : ( <> @@ -1005,6 +1010,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerHcUnhealthy")} + + {t("alertingTriggerHcToggle")} + )} diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index e0e8e0a3e..094976b55 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -94,10 +94,14 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); + case "site_toggle": + return t("alertingTriggerSiteToggle"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); case "health_check_unhealthy": return t("alertingTriggerHcUnhealthy"); + case "health_check_toggle": + return t("alertingTriggerHcToggle"); default: return v.trigger; } diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index b38639f37..38a75dc80 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -13,14 +13,16 @@ export const tagSchema = z.object({ // --------------------------------------------------------------------------- // Form-layer types // NOTE: the form uses "health_check_unhealthy" internally; it maps to the -// backend's "health_check_not_healthy" at the API boundary. +// 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_unhealthy" + | "health_check_toggle"; export type AlertRuleFormAction = | { @@ -60,8 +62,10 @@ export type AlertRuleApiPayload = { eventType: | "site_online" | "site_offline" + | "site_toggle" | "health_check_healthy" - | "health_check_not_healthy"; + | "health_check_unhealthy" + | "health_check_toggle"; enabled: boolean; siteIds: number[]; healthCheckIds: number[]; @@ -111,26 +115,6 @@ export type AlertRuleApiResponse = { }[]; }; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function triggerToEventType( - trigger: AlertTrigger -): AlertRuleApiPayload["eventType"] { - if (trigger === "health_check_unhealthy") { - return "health_check_not_healthy"; - } - return trigger as AlertRuleApiPayload["eventType"]; -} - -function eventTypeToTrigger(eventType: string): AlertTrigger { - if (eventType === "health_check_not_healthy") { - return "health_check_unhealthy"; - } - return eventType as AlertTrigger; -} - // --------------------------------------------------------------------------- // Zod form schema (for react-hook-form validation) // --------------------------------------------------------------------------- @@ -138,7 +122,9 @@ function eventTypeToTrigger(eventType: string): AlertTrigger { export function buildFormSchema(t: (k: string) => string) { return z .object({ - name: z.string().min(1, { message: t("alertingErrorNameRequired") }), + name: z + .string() + .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), sourceType: z.enum(["site", "health_check"]), siteIds: z.array(z.number()), @@ -146,36 +132,37 @@ export function buildFormSchema(t: (k: string) => string) { trigger: z.enum([ "site_online", "site_offline", + "site_toggle", "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_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() - }) - ]) - ) + 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) { @@ -202,10 +189,15 @@ export function buildFormSchema(t: (k: string) => string) { path: ["healthCheckIds"] }); } - const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; + const siteTriggers: AlertTrigger[] = [ + "site_online", + "site_offline", + "site_toggle" + ]; const hcTriggers: AlertTrigger[] = [ "health_check_healthy", - "health_check_unhealthy" + "health_check_unhealthy", + "health_check_toggle" ]; if ( val.sourceType === "site" && @@ -286,7 +278,7 @@ export function defaultFormValues(): AlertRuleFormValues { export function apiResponseToFormValues( rule: AlertRuleApiResponse ): AlertRuleFormValues { - const trigger = eventTypeToTrigger(rule.eventType); + const trigger = rule.eventType; const sourceType = rule.eventType.startsWith("site_") ? "site" : "health_check"; @@ -318,7 +310,9 @@ export function apiResponseToFormValues( headers: cfg?.headers?.length ? cfg.headers : [{ key: "", value: "" }], - authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", + authType: + (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? + "none", bearerToken: cfg?.bearerToken ?? "", basicCredentials: cfg?.basicCredentials ?? "", customHeaderName: cfg?.customHeaderName ?? "", @@ -342,7 +336,7 @@ export function apiResponseToFormValues( sourceType, siteIds: rule.siteIds, healthCheckIds: rule.healthCheckIds, - trigger, + trigger: trigger as AlertTrigger, actions }; } @@ -354,7 +348,7 @@ export function apiResponseToFormValues( export function formValuesToApiPayload( values: AlertRuleFormValues ): AlertRuleApiPayload { - const eventType = triggerToEventType(values.trigger); + const eventType = values.trigger; // Collect all notify-type actions and merge their recipient lists const allUserIds: string[] = []; @@ -368,9 +362,7 @@ export function formValuesToApiPayload( 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) + ...action.emailTags.map((t) => t.text.trim()).filter(Boolean) ); } else if (action.type === "webhook") { webhookActions.push({