diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index f9126f291..1f8085c35 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -488,6 +488,9 @@ export const alertRules = pgTable("alertRules", { // Nullable depending on eventType enabled: boolean("enabled").notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: boolean("allSites").notNull().default(false), + allHealthChecks: boolean("allHealthChecks").notNull().default(false), + allResources: boolean("allResources").notNull().default(false), lastTriggeredAt: bigint("lastTriggeredAt", { mode: "number" }), // nullable createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index f903d2955..a3168360f 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -479,6 +479,9 @@ export const alertRules = sqliteTable("alertRules", { .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), + allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), + allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), lastTriggeredAt: integer("lastTriggeredAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() diff --git a/server/private/lib/alerts/processAlerts.ts b/server/private/lib/alerts/processAlerts.ts index 2ec2eee98..5e098a1f2 100644 --- a/server/private/lib/alerts/processAlerts.ts +++ b/server/private/lib/alerts/processAlerts.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import { db } from "@server/db"; import { alertRules, @@ -49,11 +49,9 @@ export async function processAlerts(context: AlertContext): Promise { // ------------------------------------------------------------------ // 1. Find matching alert rules // ------------------------------------------------------------------ - // Rules with no junction-table entries match ALL sites / health checks. - // Rules with junction entries match only those specific IDs. - // We implement this with a LEFT JOIN: a NULL join result means the rule - // has no scope restrictions (match all); a non-NULL result that satisfies - // the id equality filter means an explicit match. + // Rules with allSites / allHealthChecks / allResources set to true match + // ANY event of that type. Rules without these flags set match only the + // specific IDs listed in the junction tables. const baseConditions = and( eq(alertRules.orgId, context.orgId), eq(alertRules.eventType, context.eventType), @@ -74,12 +72,20 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertSites.siteId, context.siteId), - isNull(alertSites.alertRuleId) + eq(alertRules.allSites, true), + eq(alertSites.siteId, context.siteId) ) ) ); - rules = rows.map((r) => r.alertRules); + // Deduplicate in case a rule matched on multiple junction rows + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else if (context.healthCheckId != null) { const rows = await db .select() @@ -92,12 +98,19 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertHealthChecks.healthCheckId, context.healthCheckId), - isNull(alertHealthChecks.alertRuleId) + eq(alertRules.allHealthChecks, true), + eq(alertHealthChecks.healthCheckId, context.healthCheckId) ) ) ); - rules = rows.map((r) => r.alertRules); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else if (context.resourceId != null) { const rows = await db .select() @@ -110,12 +123,19 @@ export async function processAlerts(context: AlertContext): Promise { and( baseConditions, or( - eq(alertResources.resourceId, context.resourceId), - isNull(alertResources.alertRuleId) + eq(alertRules.allResources, true), + eq(alertResources.resourceId, context.resourceId) ) ) ); - rules = rows.map((r) => r.alertRules); + const seen = new Set(); + rules = rows + .map((r) => r.alertRules) + .filter((r) => { + if (seen.has(r.alertRuleId)) return false; + seen.add(r.alertRuleId); + return true; + }); } else { rules = []; } diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index ff7321c77..8a31327ab 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -246,6 +246,9 @@ export async function createAlertRule( eventType, enabled, cooldownSeconds, + allSites, + allHealthChecks, + allResources, createdAt: now, updatedAt: now }) diff --git a/server/private/routers/alertRule/updateAlertRule.ts b/server/private/routers/alertRule/updateAlertRule.ts index 4b2c2df87..358661ac9 100644 --- a/server/private/routers/alertRule/updateAlertRule.ts +++ b/server/private/routers/alertRule/updateAlertRule.ts @@ -217,8 +217,11 @@ export async function updateAlertRule( enabled, cooldownSeconds, siteIds, + allSites, healthCheckIds, + allHealthChecks, resourceIds, + allResources, userIds, roleIds, emails, @@ -233,8 +236,10 @@ export async function updateAlertRule( if (name !== undefined) updateData.name = name; if (eventType !== undefined) updateData.eventType = eventType; if (enabled !== undefined) updateData.enabled = enabled; - if (cooldownSeconds !== undefined) - updateData.cooldownSeconds = cooldownSeconds; + if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds; + if (allSites !== undefined) updateData.allSites = allSites; + if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks; + if (allResources !== undefined) updateData.allResources = allResources; await db .update(alertRules) @@ -247,12 +252,14 @@ export async function updateAlertRule( ); // --- Full-replace site associations if siteIds was provided --- - if (siteIds !== undefined) { + if (siteIds !== undefined || allSites !== undefined) { await db .delete(alertSites) .where(eq(alertSites.alertRuleId, alertRuleId)); - if (siteIds.length > 0) { + // Only insert junction rows when allSites is not true + const effectiveAllSites = allSites ?? false; + if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) { await db.insert(alertSites).values( siteIds.map((siteId) => ({ alertRuleId, @@ -263,12 +270,13 @@ export async function updateAlertRule( } // --- Full-replace health check associations if healthCheckIds was provided --- - if (healthCheckIds !== undefined) { + if (healthCheckIds !== undefined || allHealthChecks !== undefined) { await db .delete(alertHealthChecks) .where(eq(alertHealthChecks.alertRuleId, alertRuleId)); - if (healthCheckIds.length > 0) { + const effectiveAllHealthChecks = allHealthChecks ?? false; + if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) { await db.insert(alertHealthChecks).values( healthCheckIds.map((healthCheckId) => ({ alertRuleId, @@ -279,12 +287,13 @@ export async function updateAlertRule( } // --- Full-replace resource associations if resourceIds was provided --- - if (resourceIds !== undefined) { + if (resourceIds !== undefined || allResources !== undefined) { await db .delete(alertResources) .where(eq(alertResources.alertRuleId, alertRuleId)); - if (resourceIds.length > 0) { + const effectiveAllResources = allResources ?? false; + if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) { await db.insert(alertResources).values( resourceIds.map((resourceId) => ({ alertRuleId, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a31e5179e..7d4a724ea 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -230,6 +230,7 @@ export async function createTarget( .values({ orgId: resource.orgId, targetId: newTarget[0].targetId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null,