Add resource

This commit is contained in:
Owen
2026-04-20 17:48:44 -07:00
parent 0a70896080
commit f38069623b
16 changed files with 511 additions and 44 deletions

View File

@@ -1383,8 +1383,14 @@
"alertingTrigger": "When to alert", "alertingTrigger": "When to alert",
"alertingTriggerSiteOnline": "Site online", "alertingTriggerSiteOnline": "Site online",
"alertingTriggerSiteOffline": "Site offline", "alertingTriggerSiteOffline": "Site offline",
"alertingTriggerSiteToggle": "Site toggled",
"alertingTriggerHcHealthy": "Health check healthy", "alertingTriggerHcHealthy": "Health check healthy",
"alertingTriggerHcUnhealthy": "Health check unhealthy", "alertingTriggerHcUnhealthy": "Health check unhealthy",
"alertingTriggerHcToggle": "Health check toggled",
"alertingTriggerResourceHealthy": "Resource healthy",
"alertingTriggerResourceUnhealthy": "Resource unhealthy",
"alertingTriggerResourceToggle": "Resource toggled",
"alertingSourceResource": "Resource",
"alertingSectionActions": "Actions", "alertingSectionActions": "Actions",
"alertingAddAction": "Add action", "alertingAddAction": "Add action",
"alertingActionNotify": "Email", "alertingActionNotify": "Email",
@@ -1411,12 +1417,15 @@
"alertingRolesSelected": "{count} roles selected", "alertingRolesSelected": "{count} roles selected",
"alertingSummarySites": "Sites ({count})", "alertingSummarySites": "Sites ({count})",
"alertingSummaryHealthChecks": "Health checks ({count})", "alertingSummaryHealthChecks": "Health checks ({count})",
"alertingSummaryResources": "Resources ({count})",
"alertingErrorNameRequired": "Enter a name", "alertingErrorNameRequired": "Enter a name",
"alertingErrorActionsMin": "Add at least one action", "alertingErrorActionsMin": "Add at least one action",
"alertingErrorPickSites": "Select at least one site", "alertingErrorPickSites": "Select at least one site",
"alertingErrorPickHealthChecks": "Select at least one health check", "alertingErrorPickHealthChecks": "Select at least one health check",
"alertingErrorPickResources": "Select at least one resource",
"alertingErrorTriggerSite": "Choose a site trigger", "alertingErrorTriggerSite": "Choose a site trigger",
"alertingErrorTriggerHealth": "Choose a health check trigger", "alertingErrorTriggerHealth": "Choose a health check trigger",
"alertingErrorTriggerResource": "Choose a resource trigger",
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email", "alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
"alertingConfigureSource": "Configure Source", "alertingConfigureSource": "Configure Source",
"alertingConfigureTrigger": "Configure Trigger", "alertingConfigureTrigger": "Configure Trigger",

View File

@@ -21,6 +21,7 @@ import {
exitNodes, exitNodes,
sessions, sessions,
clients, clients,
resources,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
sites sites
@@ -479,6 +480,9 @@ export const alertRules = pgTable("alertRules", {
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle"
>() >()
.notNull(), .notNull(),
// Nullable depending on eventType // 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 // Separating channels by type avoids the mixed-shape problem entirely
export const alertEmailActions = pgTable("alertEmailActions", { export const alertEmailActions = pgTable("alertEmailActions", {
emailActionId: serial("emailActionId").primaryKey(), emailActionId: serial("emailActionId").primaryKey(),
@@ -584,3 +597,4 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel< export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>;

View File

@@ -13,6 +13,7 @@ import {
domains, domains,
exitNodes, exitNodes,
orgs, orgs,
resources,
roles, roles,
sessions, sessions,
siteResources, siteResources,
@@ -471,6 +472,9 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle" | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle"
>() >()
.notNull(), .notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), 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", { export const alertEmailActions = sqliteTable("alertEmailActions", {
emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }), emailActionId: integer("emailActionId").primaryKey({ autoIncrement: true }),
alertRuleId: integer("alertRuleId") alertRuleId: integer("alertRuleId")
@@ -561,3 +574,4 @@ export type EventStreamingDestination = InferSelectModel<
export type EventStreamingCursor = InferSelectModel< export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>;

View File

@@ -18,7 +18,10 @@ export type AlertEventType =
| "site_toggle" | "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle"; | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
interface Props { interface Props {
eventType: AlertEventType; eventType: AlertEventType;
@@ -91,6 +94,34 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Status Changed", statusLabel: "Status Changed",
statusColor: "#f59e0b" 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: default:
return { return {
heading: "Alert Notification", heading: "Alert Notification",

View File

@@ -19,72 +19,108 @@ 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. * matching `alertRules` can dispatch their email and webhook actions.
* *
* @param orgId - Organisation that owns the health check. * @param orgId - Organisation that owns the resource.
* @param healthCheckId - Numeric primary key of the health check. * @param resourceId - Numeric primary key of the resource.
* @param healthCheckName - Human-readable name shown in notifications (optional). * @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload. * @param extra - Any additional key/value pairs to include in the payload.
*/ */
export async function fireHealthCheckHealthyAlert( export async function fireResourceHealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, resourceId: number,
healthCheckName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown> extra?: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
await processAlerts({ await processAlerts({
eventType: "health_check_healthy", eventType: "resource_healthy",
orgId, orgId,
healthCheckId, resourceId,
data: { data: {
healthCheckId, resourceId,
...(healthCheckName != null ? { healthCheckName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }
}); });
} catch (err) { } catch (err) {
logger.error( logger.error(
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`, `fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
err 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. * matching `alertRules` can dispatch their email and webhook actions.
* *
* @param orgId - Organisation that owns the health check. * @param orgId - Organisation that owns the resource.
* @param healthCheckId - Numeric primary key of the health check. * @param resourceId - Numeric primary key of the resource.
* @param healthCheckName - Human-readable name shown in notifications (optional). * @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload. * @param extra - Any additional key/value pairs to include in the payload.
*/ */
export async function fireHealthCheckNotHealthyAlert( export async function fireResourceUnhealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, resourceId: number,
healthCheckName?: string | null, resourceName?: string | null,
extra?: Record<string, unknown> extra?: Record<string, unknown>
): Promise<void> { ): Promise<void> {
try { try {
await processAlerts({ await processAlerts({
eventType: "health_check_unhealthy", eventType: "resource_unhealthy",
orgId, orgId,
healthCheckId, resourceId,
data: { data: {
healthCheckId, resourceId,
...(healthCheckName != null ? { healthCheckName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }
}); });
} catch (err) { } catch (err) {
logger.error( 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<string, unknown>
): Promise<void> {
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 err
); );
} }

View File

@@ -17,6 +17,7 @@ import {
alertRules, alertRules,
alertSites, alertSites,
alertHealthChecks, alertHealthChecks,
alertResources,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions, alertWebhookActions,
@@ -97,6 +98,24 @@ export async function processAlerts(context: AlertContext): Promise<void> {
) )
); );
rules = rows.map((r) => r.alertRules); 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 { } else {
rules = []; rules = [];
} }

View File

@@ -80,6 +80,12 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Health Check Failing"; return "[Alert] Health Check Failing";
case "health_check_toggle": case "health_check_toggle":
return "[Alert] Health Check Toggled"; 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: { 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.

View File

@@ -21,7 +21,10 @@ export type AlertEventType =
| "site_toggle" | "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "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) // Webhook authentication config (stored as encrypted JSON in the DB)
@@ -60,6 +63,8 @@ export interface AlertContext {
siteId?: number; siteId?: number;
/** Set for health_check_* events */ /** Set for health_check_* events */
healthCheckId?: number; healthCheckId?: number;
/** Set for resource_* events */
resourceId?: 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

@@ -18,6 +18,7 @@ import {
alertRules, alertRules,
alertSites, alertSites,
alertHealthChecks, alertHealthChecks,
alertResources,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -37,6 +38,11 @@ export const HC_EVENT_TYPES = [
"health_check_unhealthy", "health_check_unhealthy",
"health_check_toggle" "health_check_toggle"
] as const; ] as const;
export const RESOURCE_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
] as const;
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -53,7 +59,8 @@ const bodySchema = z
name: z.string().nonempty(), name: z.string().nonempty(),
eventType: z.enum([ eventType: z.enum([
...HC_EVENT_TYPES, ...HC_EVENT_TYPES,
...SITE_EVENT_TYPES ...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
]), ]),
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),
@@ -63,6 +70,10 @@ const bodySchema = z
.array(z.number().int().positive()) .array(z.number().int().positive())
.optional() .optional()
.default([]), .default([]),
resourceIds: z
.array(z.number().int().positive())
.optional()
.default([]),
// Email recipients (flat) // Email recipients (flat)
userIds: z.array(z.string().nonempty()).optional().default([]), userIds: z.array(z.string().nonempty()).optional().default([]),
roleIds: z.array(z.number()).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( const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType val.eventType
); );
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.siteIds.length === 0) { if (isSiteEvent && val.siteIds.length === 0) {
ctx.addIssue({ ctx.addIssue({
@@ -110,6 +124,46 @@ const bodySchema = z
path: ["siteIds"] 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 = { export type CreateAlertRuleResponse = {
@@ -169,6 +223,7 @@ export async function createAlertRule(
cooldownSeconds, cooldownSeconds,
siteIds, siteIds,
healthCheckIds, healthCheckIds,
resourceIds,
userIds, userIds,
roleIds, roleIds,
emails, 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 // Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails). // were supplied (userIds, roleIds, or raw emails).
const hasRecipients = const hasRecipients =

View File

@@ -18,6 +18,7 @@ import {
alertRules, alertRules,
alertSites, alertSites,
alertHealthChecks, alertHealthChecks,
alertResources,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { WebhookAlertConfig } from "@server/lib/alerts/types"; import { WebhookAlertConfig } from "#private/lib/alerts/types";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -50,7 +51,10 @@ export type GetAlertRuleResponse = {
| "site_toggle" | "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle"; | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
enabled: boolean; enabled: boolean;
cooldownSeconds: number; cooldownSeconds: number;
lastTriggeredAt: number | null; lastTriggeredAt: number | null;
@@ -58,6 +62,7 @@ export type GetAlertRuleResponse = {
updatedAt: number; updatedAt: number;
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
recipients: { recipients: {
recipientId: number; recipientId: number;
userId: string | null; userId: string | null;
@@ -130,6 +135,12 @@ export async function getAlertRule(
.from(alertHealthChecks) .from(alertHealthChecks)
.where(eq(alertHealthChecks.alertRuleId, alertRuleId)); .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 // Resolve the single email action row for this rule, then collect all
// recipients into a flat list. The emailAction pivot row is an internal // recipients into a flat list. The emailAction pivot row is an internal
// implementation detail and is not surfaced to callers. // implementation detail and is not surfaced to callers.
@@ -177,6 +188,7 @@ export async function getAlertRule(
updatedAt: rule.updatedAt, updatedAt: rule.updatedAt,
siteIds: siteRows.map((r) => r.siteId), siteIds: siteRows.map((r) => r.siteId),
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId), healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
resourceIds: resourceRows.map((r) => r.resourceId),
recipients, recipients,
webhookActions: webhooks.map((w) => { webhookActions: webhooks.map((w) => {
let parsedConfig: WebhookAlertConfig | null = null; let parsedConfig: WebhookAlertConfig | null = null;

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -55,6 +55,7 @@ export type ListAlertRulesResponse = {
updatedAt: number; updatedAt: number;
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
}[]; }[];
pagination: { pagination: {
total: number; 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 // Index by alertRuleId for O(1) lookup when building the response
const sitesByRule = new Map<number, number[]>(); const sitesByRule = new Map<number, number[]>();
for (const row of siteRows) { for (const row of siteRows) {
@@ -153,6 +162,13 @@ export async function listAlertRules(
healthChecksByRule.set(row.alertRuleId, existing); healthChecksByRule.set(row.alertRuleId, existing);
} }
const resourcesByRule = new Map<number, number[]>();
for (const row of resourceRows) {
const existing = resourcesByRule.get(row.alertRuleId) ?? [];
existing.push(row.resourceId);
resourcesByRule.set(row.alertRuleId, existing);
}
return response<ListAlertRulesResponse>(res, { return response<ListAlertRulesResponse>(res, {
data: { data: {
alertRules: list.map((rule) => ({ alertRules: list.map((rule) => ({
@@ -167,7 +183,8 @@ export async function listAlertRules(
updatedAt: rule.updatedAt, updatedAt: rule.updatedAt,
siteIds: sitesByRule.get(rule.alertRuleId) ?? [], siteIds: sitesByRule.get(rule.alertRuleId) ?? [],
healthCheckIds: healthCheckIds:
healthChecksByRule.get(rule.alertRuleId) ?? [] healthChecksByRule.get(rule.alertRuleId) ?? [],
resourceIds: resourcesByRule.get(rule.alertRuleId) ?? []
})), })),
pagination: { pagination: {
total: count, total: count,

View File

@@ -18,6 +18,7 @@ import {
alertRules, alertRules,
alertSites, alertSites,
alertHealthChecks, alertHealthChecks,
alertResources,
alertEmailActions, alertEmailActions,
alertEmailRecipients, alertEmailRecipients,
alertWebhookActions alertWebhookActions
@@ -31,7 +32,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"; import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -53,7 +54,8 @@ const bodySchema = z
eventType: z eventType: z
.enum([ .enum([
...HC_EVENT_TYPES, ...HC_EVENT_TYPES,
...SITE_EVENT_TYPES ...SITE_EVENT_TYPES,
...RESOURCE_EVENT_TYPES
]) ])
.optional(), .optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -61,6 +63,7 @@ const bodySchema = z
// Source join tables - if provided the full set is replaced // Source join tables - if provided the full set is replaced
siteIds: z.array(z.number().int().positive()).optional(), siteIds: z.array(z.number().int().positive()).optional(),
healthCheckIds: 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 // Recipient arrays - if any are provided the full recipient set is replaced
userIds: z.array(z.string().nonempty()).optional(), userIds: z.array(z.string().nonempty()).optional(),
roleIds: z.array(z.number()).optional(), roleIds: z.array(z.number()).optional(),
@@ -77,6 +80,9 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType val.eventType
); );
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) { if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
ctx.addIssue({ ctx.addIssue({
@@ -93,6 +99,22 @@ const bodySchema = z
path: ["siteIds"] 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 = { export type UpdateAlertRuleResponse = {
@@ -168,6 +190,7 @@ export async function updateAlertRule(
cooldownSeconds, cooldownSeconds,
siteIds, siteIds,
healthCheckIds, healthCheckIds,
resourceIds,
userIds, userIds,
roleIds, roleIds,
emails, 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 --- // --- Full-replace recipients if any recipient array was provided ---
const recipientsProvided = const recipientsProvided =
userIds !== undefined || userIds !== undefined ||

View File

@@ -41,6 +41,7 @@ type AlertRuleRow = {
updatedAt: number; updatedAt: number;
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
}; };
function ruleHref(orgId: string, ruleId: number) { function ruleHref(orgId: string, ruleId: number) {
@@ -53,10 +54,14 @@ function sourceSummary(
) { ) {
if ( if (
rule.eventType === "site_online" || rule.eventType === "site_online" ||
rule.eventType === "site_offline" rule.eventType === "site_offline" ||
rule.eventType === "site_toggle"
) { ) {
return t("alertingSummarySites", { count: rule.siteIds.length }); return t("alertingSummarySites", { count: rule.siteIds.length });
} }
if (rule.eventType.startsWith("resource_")) {
return t("alertingSummaryResources", { count: rule.resourceIds.length });
}
return t("alertingSummaryHealthChecks", { return t("alertingSummaryHealthChecks", {
count: rule.healthCheckIds.length count: rule.healthCheckIds.length
}); });
@@ -79,6 +84,12 @@ function triggerLabel(
return t("alertingTriggerHcUnhealthy"); return t("alertingTriggerHcUnhealthy");
case "health_check_toggle": case "health_check_toggle":
return t("alertingTriggerHcToggle"); return t("alertingTriggerHcToggle");
case "resource_healthy":
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default: default:
return rule.eventType; return rule.eventType;
} }

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("alertingSelectResources")}
value={q}
onValueChange={setQ}
/>
<CommandList>
<CommandEmpty>
{t("alertingResourcesEmpty")}
</CommandEmpty>
<CommandGroup>
{shown.map((r) => (
<CommandItem
key={r.resourceId}
value={`${r.resourceId}:${r.name}`}
onSelect={() => toggle(r.resourceId)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(r.resourceId)}
className="mr-2 pointer-events-none shrink-0"
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{r.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function ActionBlock({ export function ActionBlock({
orgId, orgId,
index, index,
@@ -895,6 +982,18 @@ export function AlertRuleSourceFields({
shouldValidate: true shouldValidate: true
}); });
} }
} else if (next === "resource") {
if (
curTrigger !== "resource_healthy" &&
curTrigger !== "resource_unhealthy" &&
curTrigger !== "resource_toggle"
) {
setValue(
"trigger",
"resource_unhealthy",
{ shouldValidate: true }
);
}
} else if ( } else if (
curTrigger !== "health_check_healthy" && curTrigger !== "health_check_healthy" &&
curTrigger !== "health_check_unhealthy" && curTrigger !== "health_check_unhealthy" &&
@@ -920,6 +1019,9 @@ export function AlertRuleSourceFields({
<SelectItem value="health_check"> <SelectItem value="health_check">
{t("alertingSourceHealthCheck")} {t("alertingSourceHealthCheck")}
</SelectItem> </SelectItem>
<SelectItem value="resource">
{t("alertingSourceResource")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -942,6 +1044,22 @@ export function AlertRuleSourceFields({
</FormItem> </FormItem>
)} )}
/> />
) : sourceType === "resource" ? (
<FormField
control={control}
name="resourceIds"
render={({ field }) => (
<FormItem>
<FormLabel>{t("alertingPickResources")}</FormLabel>
<ResourceMultiSelect
orgId={orgId}
value={field.value}
onChange={field.onChange}
/>
<FormMessage />
</FormItem>
)}
/>
) : ( ) : (
<FormField <FormField
control={control} control={control}
@@ -1002,6 +1120,18 @@ export function AlertRuleTriggerFields({
{t("alertingTriggerSiteToggle")} {t("alertingTriggerSiteToggle")}
</SelectItem> </SelectItem>
</> </>
) : sourceType === "resource" ? (
<>
<SelectItem value="resource_healthy">
{t("alertingTriggerResourceHealthy")}
</SelectItem>
<SelectItem value="resource_unhealthy">
{t("alertingTriggerResourceUnhealthy")}
</SelectItem>
<SelectItem value="resource_toggle">
{t("alertingTriggerResourceToggle")}
</SelectItem>
</>
) : ( ) : (
<> <>
<SelectItem value="health_check_healthy"> <SelectItem value="health_check_healthy">

View File

@@ -82,6 +82,12 @@ function summarizeSource(v: AlertRuleFormValues, t: AlertRuleT) {
} }
return t("alertingSummarySites", { count: v.siteIds.length }); 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) { if (v.healthCheckIds.length === 0) {
return t("alertingNodeNotConfigured"); return t("alertingNodeNotConfigured");
} }
@@ -102,6 +108,12 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
return t("alertingTriggerHcUnhealthy"); return t("alertingTriggerHcUnhealthy");
case "health_check_toggle": case "health_check_toggle":
return t("alertingTriggerHcToggle"); return t("alertingTriggerHcToggle");
case "resource_healthy":
return t("alertingTriggerResourceHealthy");
case "resource_unhealthy":
return t("alertingTriggerResourceUnhealthy");
case "resource_toggle":
return t("alertingTriggerResourceToggle");
default: default:
return v.trigger; return v.trigger;
} }
@@ -338,6 +350,8 @@ export default function AlertRuleGraphEditor({
useWatch({ control: form.control, name: "siteIds" }) ?? []; useWatch({ control: form.control, name: "siteIds" }) ?? [];
const wHealthCheckIds = const wHealthCheckIds =
useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
const wResourceIds =
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
const wTrigger = const wTrigger =
useWatch({ control: form.control, name: "trigger" }) ?? useWatch({ control: form.control, name: "trigger" }) ??
"site_offline"; "site_offline";
@@ -351,6 +365,7 @@ export default function AlertRuleGraphEditor({
sourceType: wSourceType, sourceType: wSourceType,
siteIds: wSiteIds, siteIds: wSiteIds,
healthCheckIds: wHealthCheckIds, healthCheckIds: wHealthCheckIds,
resourceIds: wResourceIds,
trigger: wTrigger, trigger: wTrigger,
actions: wActions actions: wActions
}), }),
@@ -360,6 +375,7 @@ export default function AlertRuleGraphEditor({
wSourceType, wSourceType,
wSiteIds, wSiteIds,
wHealthCheckIds, wHealthCheckIds,
wResourceIds,
wTrigger, wTrigger,
wActions wActions
] ]

View File

@@ -22,7 +22,10 @@ export type AlertTrigger =
| "site_toggle" | "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle"; | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
export type AlertRuleFormAction = export type AlertRuleFormAction =
| { | {
@@ -46,9 +49,10 @@ export type AlertRuleFormAction =
export type AlertRuleFormValues = { export type AlertRuleFormValues = {
name: string; name: string;
enabled: boolean; enabled: boolean;
sourceType: "site" | "health_check"; sourceType: "site" | "health_check" | "resource";
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
trigger: AlertTrigger; trigger: AlertTrigger;
actions: AlertRuleFormAction[]; actions: AlertRuleFormAction[];
}; };
@@ -65,10 +69,14 @@ export type AlertRuleApiPayload = {
| "site_toggle" | "site_toggle"
| "health_check_healthy" | "health_check_healthy"
| "health_check_unhealthy" | "health_check_unhealthy"
| "health_check_toggle"; | "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_toggle";
enabled: boolean; enabled: boolean;
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
userIds: string[]; userIds: string[];
roleIds: number[]; roleIds: number[];
emails: string[]; emails: string[];
@@ -92,6 +100,7 @@ export type AlertRuleApiResponse = {
updatedAt: number; updatedAt: number;
siteIds: number[]; siteIds: number[];
healthCheckIds: number[]; healthCheckIds: number[];
resourceIds: number[];
recipients: { recipients: {
recipientId: number; recipientId: number;
userId: string | null; userId: string | null;
@@ -126,16 +135,20 @@ export function buildFormSchema(t: (k: string) => string) {
.string() .string()
.min(1, { message: t("alertingErrorNameRequired") }), .min(1, { message: t("alertingErrorNameRequired") }),
enabled: z.boolean(), enabled: z.boolean(),
sourceType: z.enum(["site", "health_check"]), sourceType: z.enum(["site", "health_check", "resource"]),
siteIds: z.array(z.number()), siteIds: z.array(z.number()),
healthCheckIds: z.array(z.number()), healthCheckIds: z.array(z.number()),
resourceIds: z.array(z.number()),
trigger: z.enum([ trigger: z.enum([
"site_online", "site_online",
"site_offline", "site_offline",
"site_toggle", "site_toggle",
"health_check_healthy", "health_check_healthy",
"health_check_unhealthy", "health_check_unhealthy",
"health_check_toggle" "health_check_toggle",
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
]), ]),
actions: z.array( actions: z.array(
z.discriminatedUnion("type", [ z.discriminatedUnion("type", [
@@ -189,6 +202,16 @@ export function buildFormSchema(t: (k: string) => string) {
path: ["healthCheckIds"] path: ["healthCheckIds"]
}); });
} }
if (
val.sourceType === "resource" &&
val.resourceIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorPickResources"),
path: ["resourceIds"]
});
}
const siteTriggers: AlertTrigger[] = [ const siteTriggers: AlertTrigger[] = [
"site_online", "site_online",
"site_offline", "site_offline",
@@ -199,6 +222,11 @@ export function buildFormSchema(t: (k: string) => string) {
"health_check_unhealthy", "health_check_unhealthy",
"health_check_toggle" "health_check_toggle"
]; ];
const resourceTriggers: AlertTrigger[] = [
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
];
if ( if (
val.sourceType === "site" && val.sourceType === "site" &&
!siteTriggers.includes(val.trigger) !siteTriggers.includes(val.trigger)
@@ -219,6 +247,16 @@ export function buildFormSchema(t: (k: string) => string) {
path: ["trigger"] 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) => { val.actions.forEach((a, i) => {
if (a.type === "notify") { if (a.type === "notify") {
if ( if (
@@ -259,6 +297,7 @@ export function defaultFormValues(): AlertRuleFormValues {
sourceType: "site", sourceType: "site",
siteIds: [], siteIds: [],
healthCheckIds: [], healthCheckIds: [],
resourceIds: [],
trigger: "site_offline", trigger: "site_offline",
actions: [ actions: [
{ {
@@ -281,6 +320,8 @@ export function apiResponseToFormValues(
const trigger = rule.eventType; const trigger = rule.eventType;
const sourceType = rule.eventType.startsWith("site_") const sourceType = rule.eventType.startsWith("site_")
? "site" ? "site"
: rule.eventType.startsWith("resource_")
? "resource"
: "health_check"; : "health_check";
// Collect notify recipients into a single notify action (if any) // Collect notify recipients into a single notify action (if any)
@@ -336,6 +377,7 @@ export function apiResponseToFormValues(
sourceType, sourceType,
siteIds: rule.siteIds, siteIds: rule.siteIds,
healthCheckIds: rule.healthCheckIds, healthCheckIds: rule.healthCheckIds,
resourceIds: rule.resourceIds ?? [],
trigger: trigger as AlertTrigger, trigger: trigger as AlertTrigger,
actions actions
}; };
@@ -392,6 +434,7 @@ export function formValuesToApiPayload(
enabled: values.enabled, enabled: values.enabled,
siteIds: values.siteIds, siteIds: values.siteIds,
healthCheckIds: values.healthCheckIds, healthCheckIds: values.healthCheckIds,
resourceIds: values.resourceIds,
userIds: uniqueUserIds, userIds: uniqueUserIds,
roleIds: uniqueRoleIds, roleIds: uniqueRoleIds,
emails: uniqueEmails, emails: uniqueEmails,