diff --git a/messages/en-US.json b/messages/en-US.json index 856586907..80e1cb65f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1383,8 +1383,14 @@ "alertingTrigger": "When to alert", "alertingTriggerSiteOnline": "Site online", "alertingTriggerSiteOffline": "Site offline", + "alertingTriggerSiteToggle": "Site toggled", "alertingTriggerHcHealthy": "Health check healthy", "alertingTriggerHcUnhealthy": "Health check unhealthy", + "alertingTriggerHcToggle": "Health check toggled", + "alertingTriggerResourceHealthy": "Resource healthy", + "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingTriggerResourceToggle": "Resource toggled", + "alertingSourceResource": "Resource", "alertingSectionActions": "Actions", "alertingAddAction": "Add action", "alertingActionNotify": "Email", @@ -1411,12 +1417,15 @@ "alertingRolesSelected": "{count} roles selected", "alertingSummarySites": "Sites ({count})", "alertingSummaryHealthChecks": "Health checks ({count})", + "alertingSummaryResources": "Resources ({count})", "alertingErrorNameRequired": "Enter a name", "alertingErrorActionsMin": "Add at least one action", "alertingErrorPickSites": "Select at least one site", "alertingErrorPickHealthChecks": "Select at least one health check", + "alertingErrorPickResources": "Select at least one resource", "alertingErrorTriggerSite": "Choose a site trigger", "alertingErrorTriggerHealth": "Choose a health check trigger", + "alertingErrorTriggerResource": "Choose a resource trigger", "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingConfigureSource": "Configure Source", "alertingConfigureTrigger": "Configure Trigger", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 0764cc6be..f9126f291 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -21,6 +21,7 @@ import { exitNodes, sessions, clients, + resources, siteResources, targetHealthCheck, sites @@ -479,6 +480,9 @@ export const alertRules = pgTable("alertRules", { | "health_check_healthy" | "health_check_unhealthy" | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), // Nullable depending on eventType @@ -509,6 +513,15 @@ export const alertHealthChecks = pgTable("alertHealthChecks", { }) }); +export const alertResources = pgTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + // Separating channels by type avoids the mixed-shape problem entirely export const alertEmailActions = pgTable("alertEmailActions", { emailActionId: serial("emailActionId").primaryKey(), @@ -584,3 +597,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f44c0b200..f903d2955 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -13,6 +13,7 @@ import { domains, exitNodes, orgs, + resources, roles, sessions, siteResources, @@ -471,6 +472,9 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_healthy" | "health_check_unhealthy" | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), @@ -500,6 +504,15 @@ export const alertHealthChecks = sqliteTable("alertHealthChecks", { }) }); +export const alertResources = sqliteTable("alertResources", { + alertRuleId: integer("alertRuleId") + .notNull() + .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) +}); + export const alertEmailActions = sqliteTable("alertEmailActions", { emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), alertRuleId: integer("alertRuleId") @@ -561,3 +574,4 @@ export type EventStreamingDestination = InferSelectModel< export type EventStreamingCursor = InferSelectModel< typeof eventStreamingCursors >; +export type AlertResources = InferSelectModel; diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 41ea8f746..418924650 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -18,7 +18,10 @@ export type AlertEventType = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; interface Props { eventType: AlertEventType; @@ -91,6 +94,34 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Status Changed", statusColor: "#f59e0b" }; + case "resource_healthy": + return { + heading: "Resource Healthy", + previewText: "A resource in your organization is now healthy.", + summary: + "A resource in your organization has recovered and is now reporting a healthy status.", + statusLabel: "Healthy", + statusColor: "#16a34a" + }; + case "resource_unhealthy": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy. Please review the details below and take action if needed.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; + case "resource_toggle": + return { + heading: "Resource Status Changed", + previewText: + "A resource in your organization has changed status.", + summary: + "A resource 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", diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 594e27aec..5c9b168e8 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -19,73 +19,109 @@ import { processAlerts } from "../processAlerts"; // --------------------------------------------------------------------------- /** - * Fire a `health_check_healthy` alert for the given health check. + * Fire a `resource_healthy` alert for the given resource. * - * Call this after a previously-failing health check has recovered so that any + * Call this after a previously-unhealthy resource has recovered so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckHealthyAlert( +export async function fireResourceHealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_healthy", + eventType: "resource_healthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } /** - * Fire a `health_check_unhealthy` alert for the given health check. + * Fire a `resource_unhealthy` alert for the given resource. * - * Call this after a health check has been detected as failing so that any + * Call this after a resource has been detected as unhealthy so that any * matching `alertRules` can dispatch their email and webhook actions. * - * @param orgId - Organisation that owns the health check. - * @param healthCheckId - Numeric primary key of the health check. - * @param healthCheckName - Human-readable name shown in notifications (optional). - * @param extra - Any additional key/value pairs to include in the payload. + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireHealthCheckNotHealthyAlert( +export async function fireResourceUnhealthyAlert( orgId: string, - healthCheckId: number, - healthCheckName?: string | null, + resourceId: number, + resourceName?: string | null, extra?: Record ): Promise { try { await processAlerts({ - eventType: "health_check_unhealthy", + eventType: "resource_unhealthy", orgId, - healthCheckId, + resourceId, data: { - healthCheckId, - ...(healthCheckName != null ? { healthCheckName } : {}), + resourceId, + ...(resourceName != null ? { resourceName } : {}), ...extra } }); } catch (err) { logger.error( - `fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, + `fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`, err ); } } + +/** + * Fire a `resource_toggle` alert for the given resource. + * + * Call this when a resource's enabled/disabled status is toggled so that any + * matching `alertRules` can dispatch their email and webhook actions. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceToggleAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record +): Promise { + try { + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} \ No newline at end of file diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 04e3f90fd..2ec2eee98 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -17,6 +17,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions, @@ -97,6 +98,24 @@ export async function processAlerts(context: AlertContext): Promise { ) ); rules = rows.map((r) => r.alertRules); + } else if (context.resourceId != null) { + const rows = await db + .select() + .from(alertRules) + .leftJoin( + alertResources, + eq(alertResources.alertRuleId, alertRules.alertRuleId) + ) + .where( + and( + baseConditions, + or( + eq(alertResources.resourceId, context.resourceId), + isNull(alertResources.alertRuleId) + ) + ) + ); + rules = rows.map((r) => r.alertRules); } else { rules = []; } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index afadfed62..7a31d47b1 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -80,6 +80,12 @@ function buildSubject(context: AlertContext): string { return "[Alert] Health Check Failing"; case "health_check_toggle": return "[Alert] Health Check Toggled"; + case "resource_healthy": + return "[Alert] Resource Healthy"; + case "resource_unhealthy": + return "[Alert] Resource Unhealthy"; + case "resource_toggle": + return "[Alert] Resource Status Changed"; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts index 45f45c035..0679b7ece 100644 --- a/server/private/lib/alerts/types.ts +++ b/server/private/lib/alerts/types.ts @@ -21,7 +21,10 @@ export type AlertEventType = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; // --------------------------------------------------------------------------- // Webhook authentication config (stored as encrypted JSON in the DB) @@ -60,6 +63,8 @@ export interface AlertContext { siteId?: number; /** Set for health_check_* events */ healthCheckId?: number; + /** Set for resource_* events */ + resourceId?: number; /** Human-readable context data included in emails and webhook payloads */ data: Record; } diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index eab6d674a..b42750b87 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -37,6 +38,11 @@ export const HC_EVENT_TYPES = [ "health_check_unhealthy", "health_check_toggle" ] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" +] as const; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -53,7 +59,8 @@ const bodySchema = z name: z.string().nonempty(), eventType: z.enum([ ...HC_EVENT_TYPES, - ...SITE_EVENT_TYPES + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]), enabled: z.boolean().optional().default(true), cooldownSeconds: z.number().int().nonnegative().optional().default(300), @@ -63,6 +70,10 @@ const bodySchema = z .array(z.number().int().positive()) .optional() .default([]), + resourceIds: z + .array(z.number().int().positive()) + .optional() + .default([]), // Email recipients (flat) userIds: z.array(z.string().nonempty()).optional().default([]), roleIds: z.array(z.number()).optional().default([]), @@ -77,6 +88,9 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); if (isSiteEvent && val.siteIds.length === 0) { ctx.addIssue({ @@ -110,6 +124,46 @@ const bodySchema = z path: ["siteIds"] }); } + + if (isResourceEvent && val.resourceIds.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one resourceId is required for resource event types", + path: ["resourceIds"] + }); + } + + if (isResourceEvent && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } + + if (isSiteEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for site event types", + path: ["resourceIds"] + }); + } + + if (isHcEvent && val.resourceIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "resourceIds must not be set for health check event types", + path: ["resourceIds"] + }); + } }); export type CreateAlertRuleResponse = { @@ -169,6 +223,7 @@ export async function createAlertRule( cooldownSeconds, siteIds, healthCheckIds, + resourceIds, userIds, roleIds, emails, @@ -210,6 +265,16 @@ export async function createAlertRule( ); } + // Insert resource associations + if (resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId: rule.alertRuleId, + resourceId + })) + ); + } + // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index b9c5912a9..06a97e880 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { WebhookAlertConfig } from "@server/lib/alerts/types"; +import { WebhookAlertConfig } from "#private/lib/alerts/types"; const paramsSchema = z .object({ @@ -50,7 +51,10 @@ export type GetAlertRuleResponse = { | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; cooldownSeconds: number; lastTriggeredAt: number | null; @@ -58,6 +62,7 @@ export type GetAlertRuleResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -130,6 +135,12 @@ export async function getAlertRule( .from(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); + // Fetch resource associations + const resourceRows = await db + .select() + .from(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + // Resolve the single email action row for this rule, then collect all // recipients into a flat list. The emailAction pivot row is an internal // implementation detail and is not surfaced to callers. @@ -177,6 +188,7 @@ export async function getAlertRule( updatedAt: rule.updatedAt, siteIds: siteRows.map((r) => r.siteId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), + resourceIds: resourceRows.map((r) => r.resourceId), recipients, webhookActions: webhooks.map((w) => { let parsedConfig: WebhookAlertConfig | null = null; diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index e5e0053c9..7a6c11766 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { alertRules, alertSites, alertHealthChecks } from "@server/db"; +import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -55,6 +55,7 @@ export type ListAlertRulesResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }[]; pagination: { total: number; @@ -138,6 +139,14 @@ export async function listAlertRules( ) : []; + const resourceRows = + ruleIds.length > 0 + ? await db + .select() + .from(alertResources) + .where(inArray(alertResources.alertRuleId, ruleIds)) + : []; + // Index by alertRuleId for O(1) lookup when building the response const sitesByRule = new Map(); for (const row of siteRows) { @@ -153,6 +162,13 @@ export async function listAlertRules( healthChecksByRule.set(row.alertRuleId, existing); } + const resourcesByRule = new Map(); + for (const row of resourceRows) { + const existing = resourcesByRule.get(row.alertRuleId) ?? []; + existing.push(row.resourceId); + resourcesByRule.set(row.alertRuleId, existing); + } + return response(res, { data: { alertRules: list.map((rule) => ({ @@ -167,7 +183,8 @@ export async function listAlertRules( updatedAt: rule.updatedAt, siteIds: sitesByRule.get(rule.alertRuleId) ?? [], healthCheckIds: - healthChecksByRule.get(rule.alertRuleId) ?? [] + healthChecksByRule.get(rule.alertRuleId) ?? [], + resourceIds: resourcesByRule.get(rule.alertRuleId) ?? [] })), pagination: { total: count, diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 0ad62647d..cd07c0a2e 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -18,6 +18,7 @@ import { alertRules, alertSites, alertHealthChecks, + alertResources, alertEmailActions, alertEmailRecipients, alertWebhookActions @@ -31,7 +32,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"; -import { HC_EVENT_TYPES, SITE_EVENT_TYPES } from "./createAlertRule"; +import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule"; const paramsSchema = z .object({ @@ -53,7 +54,8 @@ const bodySchema = z eventType: z .enum([ ...HC_EVENT_TYPES, - ...SITE_EVENT_TYPES + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES ]) .optional(), enabled: z.boolean().optional(), @@ -61,6 +63,7 @@ const bodySchema = z // Source join tables - if provided the full set is replaced siteIds: z.array(z.number().int().positive()).optional(), healthCheckIds: z.array(z.number().int().positive()).optional(), + resourceIds: z.array(z.number().int().positive()).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(), @@ -77,6 +80,9 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); + const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( + val.eventType + ); if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { ctx.addIssue({ @@ -93,6 +99,22 @@ const bodySchema = z path: ["siteIds"] }); } + + if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "siteIds must not be set for resource event types", + path: ["siteIds"] + }); + } + + if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "healthCheckIds must not be set for resource event types", + path: ["healthCheckIds"] + }); + } }); export type UpdateAlertRuleResponse = { @@ -168,6 +190,7 @@ export async function updateAlertRule( cooldownSeconds, siteIds, healthCheckIds, + resourceIds, userIds, roleIds, emails, @@ -227,6 +250,22 @@ export async function updateAlertRule( } } + // --- Full-replace resource associations if resourceIds was provided --- + if (resourceIds !== undefined) { + await db + .delete(alertResources) + .where(eq(alertResources.alertRuleId, alertRuleId)); + + if (resourceIds.length > 0) { + await db.insert(alertResources).values( + resourceIds.map((resourceId) => ({ + alertRuleId, + resourceId + })) + ); + } + } + // --- Full-replace recipients if any recipient array was provided --- const recipientsProvided = userIds !== undefined || @@ -324,4 +363,4 @@ export async function updateAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} +} \ No newline at end of file diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 74b8348fa..dcb0bfe79 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -41,6 +41,7 @@ type AlertRuleRow = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; }; function ruleHref(orgId: string, ruleId: number) { @@ -53,10 +54,14 @@ function sourceSummary( ) { if ( rule.eventType === "site_online" || - rule.eventType === "site_offline" + rule.eventType === "site_offline" || + rule.eventType === "site_toggle" ) { return t("alertingSummarySites", { count: rule.siteIds.length }); } + if (rule.eventType.startsWith("resource_")) { + return t("alertingSummaryResources", { count: rule.resourceIds.length }); + } return t("alertingSummaryHealthChecks", { count: rule.healthCheckIds.length }); @@ -79,6 +84,12 @@ function triggerLabel( return t("alertingTriggerHcUnhealthy"); case "health_check_toggle": return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return rule.eventType; } diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 678ee3c8c..9ab5d7815 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -275,6 +275,93 @@ function HealthCheckMultiSelect({ ); } +function ResourceMultiSelect({ + orgId, + value, + onChange +}: { + orgId: string; + value: number[]; + onChange: (v: number[]) => void; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: resources = [] } = useQuery( + orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + ); + + const shown = useMemo(() => { + return resources; + }, [resources]); + + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); + } else { + onChange([...value, id]); + } + }; + + const summary = + value.length === 0 + ? t("alertingSelectResources") + : t("alertingResourcesSelected", { count: value.length }); + + return ( + + + + + + + + + + {t("alertingResourcesEmpty")} + + + {shown.map((r) => ( + toggle(r.resourceId)} + className="cursor-pointer" + > + + {r.name} + + ))} + + + + + + ); +} + export function ActionBlock({ orgId, index, @@ -895,6 +982,18 @@ export function AlertRuleSourceFields({ shouldValidate: true }); } + } else if (next === "resource") { + if ( + curTrigger !== "resource_healthy" && + curTrigger !== "resource_unhealthy" && + curTrigger !== "resource_toggle" + ) { + setValue( + "trigger", + "resource_unhealthy", + { shouldValidate: true } + ); + } } else if ( curTrigger !== "health_check_healthy" && curTrigger !== "health_check_unhealthy" && @@ -920,6 +1019,9 @@ export function AlertRuleSourceFields({ {t("alertingSourceHealthCheck")} + + {t("alertingSourceResource")} + @@ -942,6 +1044,22 @@ export function AlertRuleSourceFields({ )} /> + ) : sourceType === "resource" ? ( + ( + + {t("alertingPickResources")} + + + + )} + /> ) : ( + ) : sourceType === "resource" ? ( + <> + + {t("alertingTriggerResourceHealthy")} + + + {t("alertingTriggerResourceUnhealthy")} + + + {t("alertingTriggerResourceToggle")} + + ) : ( <> diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 094976b55..4dfd96863 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -82,6 +82,12 @@ function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) { } return t("alertingSummarySites", { count: v.siteIds.length }); } + if (v.sourceType === "resource") { + if (v.resourceIds.length === 0) { + return t("alertingNodeNotConfigured"); + } + return t("alertingSummaryResources", { count: v.resourceIds.length }); + } if (v.healthCheckIds.length === 0) { return t("alertingNodeNotConfigured"); } @@ -102,6 +108,12 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) { return t("alertingTriggerHcUnhealthy"); case "health_check_toggle": return t("alertingTriggerHcToggle"); + case "resource_healthy": + return t("alertingTriggerResourceHealthy"); + case "resource_unhealthy": + return t("alertingTriggerResourceUnhealthy"); + case "resource_toggle": + return t("alertingTriggerResourceToggle"); default: return v.trigger; } @@ -338,6 +350,8 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "siteIds" }) ?? []; const wHealthCheckIds = useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; + const wResourceIds = + useWatch({ control: form.control, name: "resourceIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? "site_offline"; @@ -351,6 +365,7 @@ export default function AlertRuleGraphEditor({ sourceType: wSourceType, siteIds: wSiteIds, healthCheckIds: wHealthCheckIds, + resourceIds: wResourceIds, trigger: wTrigger, actions: wActions }), @@ -360,6 +375,7 @@ export default function AlertRuleGraphEditor({ wSourceType, wSiteIds, wHealthCheckIds, + wResourceIds, wTrigger, wActions ] diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 38a75dc80..fd3b3004a 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -22,7 +22,10 @@ export type AlertTrigger = | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; export type AlertRuleFormAction = | { @@ -46,9 +49,10 @@ export type AlertRuleFormAction = export type AlertRuleFormValues = { name: string; enabled: boolean; - sourceType: "site" | "health_check"; + sourceType: "site" | "health_check" | "resource"; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; }; @@ -65,10 +69,14 @@ export type AlertRuleApiPayload = { | "site_toggle" | "health_check_healthy" | "health_check_unhealthy" - | "health_check_toggle"; + | "health_check_toggle" + | "resource_healthy" + | "resource_unhealthy" + | "resource_toggle"; enabled: boolean; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; userIds: string[]; roleIds: number[]; emails: string[]; @@ -92,6 +100,7 @@ export type AlertRuleApiResponse = { updatedAt: number; siteIds: number[]; healthCheckIds: number[]; + resourceIds: number[]; recipients: { recipientId: number; userId: string | null; @@ -126,16 +135,20 @@ export function buildFormSchema(t: (k: string) => string) { .string() .min(1, { message: t("alertingErrorNameRequired") }), enabled: z.boolean(), - sourceType: z.enum(["site", "health_check"]), + sourceType: z.enum(["site", "health_check", "resource"]), siteIds: z.array(z.number()), healthCheckIds: z.array(z.number()), + resourceIds: z.array(z.number()), trigger: z.enum([ "site_online", "site_offline", "site_toggle", "health_check_healthy", "health_check_unhealthy", - "health_check_toggle" + "health_check_toggle", + "resource_healthy", + "resource_unhealthy", + "resource_toggle" ]), actions: z.array( z.discriminatedUnion("type", [ @@ -189,6 +202,16 @@ export function buildFormSchema(t: (k: string) => string) { path: ["healthCheckIds"] }); } + if ( + val.sourceType === "resource" && + val.resourceIds.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("alertingErrorPickResources"), + path: ["resourceIds"] + }); + } const siteTriggers: AlertTrigger[] = [ "site_online", "site_offline", @@ -199,6 +222,11 @@ export function buildFormSchema(t: (k: string) => string) { "health_check_unhealthy", "health_check_toggle" ]; + const resourceTriggers: AlertTrigger[] = [ + "resource_healthy", + "resource_unhealthy", + "resource_toggle" + ]; if ( val.sourceType === "site" && !siteTriggers.includes(val.trigger) @@ -219,6 +247,16 @@ export function buildFormSchema(t: (k: string) => string) { 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 ( @@ -259,6 +297,7 @@ export function defaultFormValues(): AlertRuleFormValues { sourceType: "site", siteIds: [], healthCheckIds: [], + resourceIds: [], trigger: "site_offline", actions: [ { @@ -281,6 +320,8 @@ export function apiResponseToFormValues( 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) @@ -336,6 +377,7 @@ export function apiResponseToFormValues( sourceType, siteIds: rule.siteIds, healthCheckIds: rule.healthCheckIds, + resourceIds: rule.resourceIds ?? [], trigger: trigger as AlertTrigger, actions }; @@ -392,6 +434,7 @@ export function formValuesToApiPayload( enabled: values.enabled, siteIds: values.siteIds, healthCheckIds: values.healthCheckIds, + resourceIds: values.resourceIds, userIds: uniqueUserIds, roleIds: uniqueRoleIds, emails: uniqueEmails,