Add toggle types

This commit is contained in:
Owen
2026-04-20 17:37:01 -07:00
parent 5a09062070
commit 0a70896080
14 changed files with 159 additions and 115 deletions

View File

@@ -475,8 +475,10 @@ export const alertRules = pgTable("alertRules", {
.$type< .$type<
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy" | "health_check_unhealthy"
| "health_check_toggle"
>() >()
.notNull(), .notNull(),
// Nullable depending on eventType // Nullable depending on eventType

View File

@@ -467,8 +467,10 @@ export const alertRules = sqliteTable("alertRules", {
.$type< .$type<
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy" | "health_check_unhealthy"
| "health_check_toggle"
>() >()
.notNull(), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),

View File

@@ -15,8 +15,10 @@ import {
export type AlertEventType = export type AlertEventType =
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy"; | "health_check_unhealthy"
| "health_check_toggle";
interface Props { interface Props {
eventType: AlertEventType; eventType: AlertEventType;
@@ -50,6 +52,15 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Offline", statusLabel: "Offline",
statusColor: "#dc2626" 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": case "health_check_healthy":
return { return {
heading: "Health Check Recovered", heading: "Health Check Recovered",
@@ -60,7 +71,7 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Healthy", statusLabel: "Healthy",
statusColor: "#16a34a" statusColor: "#16a34a"
}; };
case "health_check_not_healthy": case "health_check_unhealthy":
return { return {
heading: "Health Check Failing", heading: "Health Check Failing",
previewText: previewText:
@@ -70,6 +81,25 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Not Healthy", statusLabel: "Not Healthy",
statusColor: "#dc2626" 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"
};
} }
} }

View File

@@ -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 * Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions. * matching `alertRules` can dispatch their email and webhook actions.
@@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert(
): Promise<void> { ): Promise<void> {
try { try {
await processAlerts({ await processAlerts({
eventType: "health_check_not_healthy", eventType: "health_check_unhealthy",
orgId, orgId,
healthCheckId, healthCheckId,
data: { data: {

View File

@@ -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 * Call this after a health check has been detected as failing so that any
* matching `alertRules` can dispatch their email and webhook actions. * matching `alertRules` can dispatch their email and webhook actions.
@@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert(
): Promise<void> { ): Promise<void> {
try { try {
await processAlerts({ await processAlerts({
eventType: "health_check_not_healthy", eventType: "health_check_unhealthy",
orgId, orgId,
healthCheckId, healthCheckId,
data: { data: {

View File

@@ -72,10 +72,14 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Site Back Online"; return "[Alert] Site Back Online";
case "site_offline": case "site_offline":
return "[Alert] Site Offline"; return "[Alert] Site Offline";
case "site_toggle":
return "[Alert] Site Toggled";
case "health_check_healthy": case "health_check_healthy":
return "[Alert] Health Check Recovered"; return "[Alert] Health Check Recovered";
case "health_check_not_healthy": case "health_check_unhealthy":
return "[Alert] Health Check Failing"; return "[Alert] Health Check Failing";
case "health_check_toggle":
return "[Alert] Health Check Toggled";
default: { default: {
// Exhaustiveness fallback should never be reached with a // Exhaustiveness fallback should never be reached with a
// well-typed caller, but keeps runtime behaviour predictable. // well-typed caller, but keeps runtime behaviour predictable.
@@ -84,4 +88,4 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Event Notification"; return "[Alert] Event Notification";
} }
} }
} }

View File

@@ -18,8 +18,10 @@
export type AlertEventType = export type AlertEventType =
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy"; | "health_check_unhealthy"
| "health_check_toggle";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Webhook authentication config (stored as encrypted JSON in the DB) // Webhook authentication config (stored as encrypted JSON in the DB)
@@ -60,4 +62,4 @@ export interface AlertContext {
healthCheckId?: number; healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */ /** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>; data: Record<string, unknown>;
} }

View File

@@ -30,12 +30,12 @@ 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 { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { and, eq } from "drizzle-orm";
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const; export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const;
const HC_EVENT_TYPES = [ export const HC_EVENT_TYPES = [
"health_check_healthy", "health_check_healthy",
"health_check_not_healthy" "health_check_unhealthy",
"health_check_toggle"
] as const; ] as const;
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -52,10 +52,8 @@ const bodySchema = z
.strictObject({ .strictObject({
name: z.string().nonempty(), name: z.string().nonempty(),
eventType: z.enum([ eventType: z.enum([
"site_online", ...HC_EVENT_TYPES,
"site_offline", ...SITE_EVENT_TYPES
"health_check_healthy",
"health_check_not_healthy"
]), ]),
enabled: z.boolean().optional().default(true), enabled: z.boolean().optional().default(true),
cooldownSeconds: z.number().int().nonnegative().optional().default(300), cooldownSeconds: z.number().int().nonnegative().optional().default(300),

View File

@@ -47,8 +47,10 @@ export type GetAlertRuleResponse = {
eventType: eventType:
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy"; | "health_check_unhealthy"
| "health_check_toggle";
enabled: boolean; enabled: boolean;
cooldownSeconds: number; cooldownSeconds: number;
lastTriggeredAt: number | null; lastTriggeredAt: number | null;
@@ -59,7 +61,7 @@ export type GetAlertRuleResponse = {
recipients: { recipients: {
recipientId: number; recipientId: number;
userId: string | null; userId: string | null;
roleId: string | null; roleId: number | null;
email: string | null; email: string | null;
}[]; }[];
webhookActions: { webhookActions: {
@@ -177,24 +179,27 @@ export async function getAlertRule(
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; let parsedConfig: WebhookAlertConfig | null = null;
if (w.config) { if (w.config) {
try { try {
const serverSecret = config.getRawConfig().server.secret!; const serverSecret =
const decrypted = decrypt(w.config, serverSecret); config.getRawConfig().server.secret!;
parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig; const decrypted = decrypt(w.config, serverSecret);
} catch { parsedConfig = JSON.parse(
// best-effort return null if decryption fails decrypted
} ) as WebhookAlertConfig;
} } catch {
return { // best-effort return null if decryption fails
webhookActionId: w.webhookActionId, }
webhookUrl: w.webhookUrl, }
enabled: w.enabled, return {
lastSentAt: w.lastSentAt ?? null, webhookActionId: w.webhookActionId,
config: parsedConfig webhookUrl: w.webhookUrl,
}; enabled: w.enabled,
}) lastSentAt: w.lastSentAt ?? null,
config: parsedConfig
};
})
}, },
success: true, success: true,
error: false, error: false,
@@ -207,4 +212,4 @@ export async function getAlertRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -31,12 +31,7 @@ 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 { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule";
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
const HC_EVENT_TYPES = [
"health_check_healthy",
"health_check_not_healthy"
] as const;
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -57,10 +52,8 @@ const bodySchema = z
name: z.string().nonempty().optional(), name: z.string().nonempty().optional(),
eventType: z eventType: z
.enum([ .enum([
"site_online", ...HC_EVENT_TYPES,
"site_offline", ...SITE_EVENT_TYPES
"health_check_healthy",
"health_check_not_healthy"
]) ])
.optional(), .optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),

View File

@@ -71,10 +71,14 @@ function triggerLabel(
return t("alertingTriggerSiteOnline"); return t("alertingTriggerSiteOnline");
case "site_offline": case "site_offline":
return t("alertingTriggerSiteOffline"); return t("alertingTriggerSiteOffline");
case "site_toggle":
return t("alertingTriggerSiteToggle");
case "health_check_healthy": case "health_check_healthy":
return t("alertingTriggerHcHealthy"); return t("alertingTriggerHcHealthy");
case "health_check_not_healthy": case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy"); return t("alertingTriggerHcUnhealthy");
case "health_check_toggle":
return t("alertingTriggerHcToggle");
default: default:
return rule.eventType; return rule.eventType;
} }

View File

@@ -888,7 +888,8 @@ export function AlertRuleSourceFields({
if (next === "site") { if (next === "site") {
if ( if (
curTrigger !== "site_online" && curTrigger !== "site_online" &&
curTrigger !== "site_offline" curTrigger !== "site_offline" &&
curTrigger !== "site_toggle"
) { ) {
setValue("trigger", "site_offline", { setValue("trigger", "site_offline", {
shouldValidate: true shouldValidate: true
@@ -896,7 +897,8 @@ export function AlertRuleSourceFields({
} }
} else if ( } else if (
curTrigger !== "health_check_healthy" && curTrigger !== "health_check_healthy" &&
curTrigger !== "health_check_unhealthy" curTrigger !== "health_check_unhealthy" &&
curTrigger !== "health_check_toggle"
) { ) {
setValue( setValue(
"trigger", "trigger",
@@ -996,6 +998,9 @@ export function AlertRuleTriggerFields({
<SelectItem value="site_offline"> <SelectItem value="site_offline">
{t("alertingTriggerSiteOffline")} {t("alertingTriggerSiteOffline")}
</SelectItem> </SelectItem>
<SelectItem value="site_toggle">
{t("alertingTriggerSiteToggle")}
</SelectItem>
</> </>
) : ( ) : (
<> <>
@@ -1005,6 +1010,9 @@ export function AlertRuleTriggerFields({
<SelectItem value="health_check_unhealthy"> <SelectItem value="health_check_unhealthy">
{t("alertingTriggerHcUnhealthy")} {t("alertingTriggerHcUnhealthy")}
</SelectItem> </SelectItem>
<SelectItem value="health_check_toggle">
{t("alertingTriggerHcToggle")}
</SelectItem>
</> </>
)} )}
</SelectContent> </SelectContent>

View File

@@ -94,10 +94,14 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
return t("alertingTriggerSiteOnline"); return t("alertingTriggerSiteOnline");
case "site_offline": case "site_offline":
return t("alertingTriggerSiteOffline"); return t("alertingTriggerSiteOffline");
case "site_toggle":
return t("alertingTriggerSiteToggle");
case "health_check_healthy": case "health_check_healthy":
return t("alertingTriggerHcHealthy"); return t("alertingTriggerHcHealthy");
case "health_check_unhealthy": case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy"); return t("alertingTriggerHcUnhealthy");
case "health_check_toggle":
return t("alertingTriggerHcToggle");
default: default:
return v.trigger; return v.trigger;
} }

View File

@@ -13,14 +13,16 @@ export const tagSchema = z.object({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Form-layer types // Form-layer types
// NOTE: the form uses "health_check_unhealthy" internally; it maps to the // 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 = export type AlertTrigger =
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy"; | "health_check_unhealthy"
| "health_check_toggle";
export type AlertRuleFormAction = export type AlertRuleFormAction =
| { | {
@@ -60,8 +62,10 @@ export type AlertRuleApiPayload = {
eventType: eventType:
| "site_online" | "site_online"
| "site_offline" | "site_offline"
| "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_not_healthy"; | "health_check_unhealthy"
| "health_check_toggle";
enabled: boolean; enabled: boolean;
siteIds: number[]; siteIds: number[];
healthCheckIds: 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) // Zod form schema (for react-hook-form validation)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -138,7 +122,9 @@ function eventTypeToTrigger(eventType: string): AlertTrigger {
export function buildFormSchema(t: (k: string) => string) { export function buildFormSchema(t: (k: string) => string) {
return z return z
.object({ .object({
name: z.string().min(1, { message: t("alertingErrorNameRequired") }), name: z
.string()
.min(1, { message: t("alertingErrorNameRequired") }),
enabled: z.boolean(), enabled: z.boolean(),
sourceType: z.enum(["site", "health_check"]), sourceType: z.enum(["site", "health_check"]),
siteIds: z.array(z.number()), siteIds: z.array(z.number()),
@@ -146,36 +132,37 @@ export function buildFormSchema(t: (k: string) => string) {
trigger: z.enum([ trigger: z.enum([
"site_online", "site_online",
"site_offline", "site_offline",
"site_toggle",
"health_check_healthy", "health_check_healthy",
"health_check_unhealthy" "health_check_unhealthy",
"health_check_toggle"
]), ]),
actions: z actions: z.array(
.array( z.discriminatedUnion("type", [
z.discriminatedUnion("type", [ z.object({
z.object({ type: z.literal("notify"),
type: z.literal("notify"), userTags: z.array(tagSchema),
userTags: z.array(tagSchema), roleTags: z.array(tagSchema),
roleTags: z.array(tagSchema), emailTags: z.array(tagSchema)
emailTags: z.array(tagSchema) }),
}), z.object({
z.object({ type: z.literal("webhook"),
type: z.literal("webhook"), url: z.string(),
url: z.string(), method: z.string(),
method: z.string(), headers: z.array(
headers: z.array( z.object({
z.object({ key: z.string(),
key: z.string(), value: z.string()
value: z.string() })
}) ),
), authType: z.enum(["none", "bearer", "basic", "custom"]),
authType: z.enum(["none", "bearer", "basic", "custom"]), bearerToken: z.string(),
bearerToken: z.string(), basicCredentials: z.string(),
basicCredentials: z.string(), customHeaderName: z.string(),
customHeaderName: z.string(), customHeaderValue: z.string()
customHeaderValue: z.string() })
}) ])
]) )
)
}) })
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
if (val.actions.length === 0) { if (val.actions.length === 0) {
@@ -202,10 +189,15 @@ export function buildFormSchema(t: (k: string) => string) {
path: ["healthCheckIds"] path: ["healthCheckIds"]
}); });
} }
const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; const siteTriggers: AlertTrigger[] = [
"site_online",
"site_offline",
"site_toggle"
];
const hcTriggers: AlertTrigger[] = [ const hcTriggers: AlertTrigger[] = [
"health_check_healthy", "health_check_healthy",
"health_check_unhealthy" "health_check_unhealthy",
"health_check_toggle"
]; ];
if ( if (
val.sourceType === "site" && val.sourceType === "site" &&
@@ -286,7 +278,7 @@ export function defaultFormValues(): AlertRuleFormValues {
export function apiResponseToFormValues( export function apiResponseToFormValues(
rule: AlertRuleApiResponse rule: AlertRuleApiResponse
): AlertRuleFormValues { ): AlertRuleFormValues {
const trigger = eventTypeToTrigger(rule.eventType); const trigger = rule.eventType;
const sourceType = rule.eventType.startsWith("site_") const sourceType = rule.eventType.startsWith("site_")
? "site" ? "site"
: "health_check"; : "health_check";
@@ -318,7 +310,9 @@ export function apiResponseToFormValues(
headers: cfg?.headers?.length headers: cfg?.headers?.length
? cfg.headers ? cfg.headers
: [{ key: "", value: "" }], : [{ key: "", value: "" }],
authType: (cfg?.authType as "none" | "bearer" | "basic" | "custom") ?? "none", authType:
(cfg?.authType as "none" | "bearer" | "basic" | "custom") ??
"none",
bearerToken: cfg?.bearerToken ?? "", bearerToken: cfg?.bearerToken ?? "",
basicCredentials: cfg?.basicCredentials ?? "", basicCredentials: cfg?.basicCredentials ?? "",
customHeaderName: cfg?.customHeaderName ?? "", customHeaderName: cfg?.customHeaderName ?? "",
@@ -342,7 +336,7 @@ export function apiResponseToFormValues(
sourceType, sourceType,
siteIds: rule.siteIds, siteIds: rule.siteIds,
healthCheckIds: rule.healthCheckIds, healthCheckIds: rule.healthCheckIds,
trigger, trigger: trigger as AlertTrigger,
actions actions
}; };
} }
@@ -354,7 +348,7 @@ export function apiResponseToFormValues(
export function formValuesToApiPayload( export function formValuesToApiPayload(
values: AlertRuleFormValues values: AlertRuleFormValues
): AlertRuleApiPayload { ): AlertRuleApiPayload {
const eventType = triggerToEventType(values.trigger); const eventType = values.trigger;
// Collect all notify-type actions and merge their recipient lists // Collect all notify-type actions and merge their recipient lists
const allUserIds: string[] = []; const allUserIds: string[] = [];
@@ -368,9 +362,7 @@ export function formValuesToApiPayload(
allUserIds.push(...action.userTags.map((t) => t.id)); allUserIds.push(...action.userTags.map((t) => t.id));
allRoleIds.push(...action.roleTags.map((t) => Number(t.id))); allRoleIds.push(...action.roleTags.map((t) => Number(t.id)));
allEmails.push( allEmails.push(
...action.emailTags ...action.emailTags.map((t) => t.text.trim()).filter(Boolean)
.map((t) => t.text.trim())
.filter(Boolean)
); );
} else if (action.type === "webhook") { } else if (action.type === "webhook") {
webhookActions.push({ webhookActions.push({