From 6689a8d93ea7d4c2dcdd0dc8e4e9f0e2fe070c0f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 19:05:28 +0200 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20test=20alert=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 4 + server/auth/actions.ts | 1 + server/private/routers/alertRule/index.ts | 3 +- .../routers/alertRule/testSiteAlertRule.ts | 73 +++++++++++++ server/private/routers/external.ts | 8 ++ .../AlertRuleGraphEditor.tsx | 100 +++++++++++++----- 6 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 server/private/routers/alertRule/testSiteAlertRule.ts diff --git a/messages/en-US.json b/messages/en-US.json index aff3be28b..2dd438a12 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1804,6 +1804,10 @@ "alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.", "alertingHealthChecksBannerTitle": "Monitor Health & Resources", "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", + "alertingTestRule": "Test Alert Rule", + "alertingNoActionsTitle": "No actions configured", + "alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.", + "alertingNoActionsTestDescription": "Add at least one action before you can test this rule.", "standaloneHcTableTitle": "Health Checks", "standaloneHcSearchPlaceholder": "Search health checks…", "standaloneHcAddButton": "Create Health Check", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 741d7a057..4cb46f518 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -151,6 +151,7 @@ export enum ActionsEnum { createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", + testAlertRule = "testAlertRule", listAlertRules = "listAlertRules", listOrgLabels = "listOrgLabels", createOrgLabel = "createOrgLabel", diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts index 19e35f7dc..e80a9ba16 100644 --- a/server/private/routers/alertRule/index.ts +++ b/server/private/routers/alertRule/index.ts @@ -15,4 +15,5 @@ export * from "./createAlertRule"; export * from "./updateAlertRule"; export * from "./deleteAlertRule"; export * from "./listAlertRules"; -export * from "./getAlertRule"; \ No newline at end of file +export * from "./getAlertRule"; +export * from "./testSiteAlertRule"; diff --git a/server/private/routers/alertRule/testSiteAlertRule.ts b/server/private/routers/alertRule/testSiteAlertRule.ts new file mode 100644 index 000000000..149ac9313 --- /dev/null +++ b/server/private/routers/alertRule/testSiteAlertRule.ts @@ -0,0 +1,73 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } 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"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + event: z.enum(["site_offline", "site_online", "site_toggle"]) +}); + +export async function testSiteAlertRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { event } = parsedQuery.data; + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index fab026418..c94bce1df 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -808,6 +808,14 @@ authenticated.get( alertRule.listAlertRules ); +authenticated.get( + "/org/:orgId/test-site-alert-rule/:alertRuleId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.testAlertRule), + alertRule.testSiteAlertRule +); + authenticated.get( "/org/:orgId/alert-rule/:alertRuleId", verifyValidLicense, diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 7cac1960f..a10f8f3b3 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -6,7 +6,9 @@ import { AlertRuleSourceFields, AlertRuleTriggerFields } from "@app/components/alert-rule-editor/AlertRuleFields"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SettingsContainer } from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; import { Card, CardContent } from "@app/components/ui/card"; import { @@ -19,6 +21,7 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { buildFormSchema, @@ -27,19 +30,15 @@ import { type AlertRuleFormValues } from "@app/lib/alertRuleForm"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; import type { AxiosResponse } from "axios"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeft, Cog, Flag, Zap } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useMemo, useState, type ReactNode } from "react"; -import { useFieldArray, useForm, type Resolver } from "react-hook-form"; +import { Cog, Flag, Zap, ZapIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useRouter } from "next/navigation"; +import { useActionState, useMemo, useTransition, type ReactNode } from "react"; +import { useFieldArray, useForm, type Resolver } from "react-hook-form"; import { Badge } from "../ui/badge"; const FORM_ID = "alert-rule-form"; @@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({ const t = useTranslations(); const router = useRouter(); const api = createApiClient(useEnvContext()); - const [isSaving, setIsSaving] = useState(false); const schema = useMemo(() => buildFormSchema(t), [t]); const form = useForm({ resolver: zodResolver(schema) as Resolver, @@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({ name: "actions" }); - const onSubmit = form.handleSubmit(async (values) => { - setIsSaving(true); + const saveAlert = async () => { + const isValid = await form.trigger(); + if (!isValid) { + const values = form.getValues(); + if (values.actions.length === 0) { + toast({ + variant: "warning", + title: t("alertingNoActionsTitle"), + description: t("alertingNoActionsSaveDescription") + }); + } + return; + } + + const values = form.getValues(); + try { const payload = formValuesToApiPayload(values); if (isNew) { @@ -158,14 +170,37 @@ export default function AlertRuleGraphEditor({ description: formatAxiosError(e), variant: "destructive" }); - } finally { - setIsSaving(false); } - }); + // const submit = form.handleSubmit(async (values) => { + + // }); + + // await submit(); + }; + + const testAlert = async () => { + const isValid = await form.trigger(); + if (!isValid) { + const values = form.getValues(); + if (values.actions.length === 0) { + toast({ + variant: "warning", + title: t("alertingNoActionsTitle"), + description: t("alertingNoActionsTestDescription") + }); + } + return; + } + + const values = form.getValues(); + }; + + const [, formAction, isSaving] = useActionState(saveAlert, null); + const [isTestingAlert, startTransition] = useTransition(); return (
- +
@@ -263,14 +298,29 @@ export default function AlertRuleGraphEditor({ )} /> - +
+ + + +
From 3f305e4d5cb7b30bbeb386584e3040239f31c05f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 20:52:25 +0200 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=9A=A7=20process=20test=20alert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 22 +++++++++ server/private/routers/alertRule/index.ts | 2 +- ...{testSiteAlertRule.ts => testAlertRule.ts} | 48 ++++++++++++++++--- server/private/routers/external.ts | 6 +-- server/routers/alertRule/types.ts | 22 +++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 server/private/lib/alerts/processTestAlerts.ts rename server/private/routers/alertRule/{testSiteAlertRule.ts => testAlertRule.ts} (60%) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts new file mode 100644 index 000000000..acbd6fa5e --- /dev/null +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -0,0 +1,22 @@ +import logger from "@server/logger"; +import type { TestAlertContext } from "@server/routers/alertRule/types"; +import { sendAlertEmail } from "./sendAlertEmail"; + +export async function processTestAlerts(context: TestAlertContext) { + const emailActions = context.actions.filter( + (action) => action.type === "email" + ); + // Process email actions + for (const action of emailActions) { + try { + const recipients = await resolveEmailRecipients( + action.emailActionId + ); + if (recipients.length > 0) { + await sendAlertEmail(recipients, context); + } + } catch (err) { + logger.error(`processAlerts: failed to send alert email`, err); + } + } +} diff --git a/server/private/routers/alertRule/index.ts b/server/private/routers/alertRule/index.ts index e80a9ba16..762f707f8 100644 --- a/server/private/routers/alertRule/index.ts +++ b/server/private/routers/alertRule/index.ts @@ -16,4 +16,4 @@ export * from "./updateAlertRule"; export * from "./deleteAlertRule"; export * from "./listAlertRules"; export * from "./getAlertRule"; -export * from "./testSiteAlertRule"; +export * from "./testAlertRule"; diff --git a/server/private/routers/alertRule/testSiteAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts similarity index 60% rename from server/private/routers/alertRule/testSiteAlertRule.ts rename to server/private/routers/alertRule/testAlertRule.ts index 149ac9313..39a5c28a1 100644 --- a/server/private/routers/alertRule/testSiteAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -33,11 +33,44 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const querySchema = z.strictObject({ - event: z.enum(["site_offline", "site_online", "site_toggle"]) +export const SITE_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; +export const HC_EVENT_TYPES = [ + "health_check_healthy", + "health_check_unhealthy", + "health_check_toggle" +] as const; +export const RESOURCE_EVENT_TYPES = [ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" +] as const; + +const webhookActionSchema = z.strictObject({ + webhookUrl: z.string().url(), + config: z.string().optional(), + enabled: z.boolean().optional().default(true) }); -export async function testSiteAlertRule( +const bodySchema = z.strictObject({ + eventType: z.enum([ + ...HC_EVENT_TYPES, + ...SITE_EVENT_TYPES, + ...RESOURCE_EVENT_TYPES + ]), + // Email recipients (flat) + userIds: z.array(z.string().nonempty()).optional().default([]), + roleIds: z.array(z.number()).optional().default([]), + emails: z.array(z.email()).optional().default([]), + // Webhook actions + webhookActions: z.array(webhookActionSchema).optional().default([]) +}); + +export async function testAlertRule( req: Request, res: Response, next: NextFunction @@ -54,16 +87,17 @@ export async function testSiteAlertRule( } const { orgId } = parsedParams.data; - const parsedQuery = querySchema.safeParse(req.query); - if (!parsedQuery.success) { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedQuery.error).toString() + fromError(parsedBody.error).toString() ) ); } - const { event } = parsedQuery.data; + + // TODO: process alert rule } catch (error) { logger.error(error); return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index c94bce1df..0fd4cc023 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -808,12 +808,12 @@ authenticated.get( alertRule.listAlertRules ); -authenticated.get( - "/org/:orgId/test-site-alert-rule/:alertRuleId", +authenticated.post( + "/org/:orgId/alert-rule/test", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.testAlertRule), - alertRule.testSiteAlertRule + alertRule.testAlertRule ); authenticated.get( diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index ebffd3c5b..90c4e3163 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -124,3 +124,25 @@ export interface AlertContext { /** Human-readable context data included in emails and webhook payloads */ data: Record; } + +type EmailAlertAction = { + type: "email"; + userIds?: string[]; + roleIds?: string[]; + emails?: string[]; +}; + +type WebhookAlertAction = { + type: "webhook"; + webhookUrl: string; + enabled: boolean; + config?: string | undefined; +}; + +type AlertAction = EmailAlertAction | WebhookAlertAction; +export interface TestAlertContext { + eventType: AlertEventType; + actions: AlertAction[]; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} From 403b8a12e4c6f5205d32b10f77d5269de109898b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 7 Aug 2026 20:57:54 +0200 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=9A=A7=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 59 +++++++++++++++++-- server/routers/alertRule/types.ts | 5 +- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index acbd6fa5e..f598dc633 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -1,6 +1,11 @@ import logger from "@server/logger"; -import type { TestAlertContext } from "@server/routers/alertRule/types"; +import type { + EmailAlertAction, + TestAlertContext +} from "@server/routers/alertRule/types"; import { sendAlertEmail } from "./sendAlertEmail"; +import type { db, alertEmailRecipients, users, userOrgRoles } from "@server/db"; +import type { eq } from "drizzle-orm"; export async function processTestAlerts(context: TestAlertContext) { const emailActions = context.actions.filter( @@ -9,9 +14,7 @@ export async function processTestAlerts(context: TestAlertContext) { // Process email actions for (const action of emailActions) { try { - const recipients = await resolveEmailRecipients( - action.emailActionId - ); + const recipients = await resolveEmailRecipients(action); if (recipients.length > 0) { await sendAlertEmail(recipients, context); } @@ -20,3 +23,51 @@ export async function processTestAlerts(context: TestAlertContext) { } } } + +/** + * Resolves all email addresses for a given `emailActionId`. + * + * Recipients may be: + * - Direct users (by `userId`) + * - All users in a role (by `roleId`, resolved via `userOrgRoles`) + * - Direct external email addresses + */ +async function resolveEmailRecipients( + action: EmailAlertAction +): Promise { + const emailSet = new Set(); + + // for (const row of rows) { + // if (row.email) { + // emailSet.add(row.email); + // } + + // if (row.userId) { + // const [user] = await db + // .select({ email: users.email }) + // .from(users) + // .where(eq(users.userId, row.userId)) + // .limit(1); + // if (user?.email) { + // emailSet.add(user.email); + // } + // } + + // if (row.roleId) { + // // Find all users with this role via userOrgRoles + // const roleUsers = await db + // .select({ email: users.email }) + // .from(userOrgRoles) + // .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + // .where(eq(userOrgRoles.roleId, Number(row.roleId))); + + // for (const u of roleUsers) { + // if (u.email) { + // emailSet.add(u.email); + // } + // } + // } + // } + + return Array.from(emailSet); +} diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index 90c4e3163..99057b312 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -125,14 +125,14 @@ export interface AlertContext { data: Record; } -type EmailAlertAction = { +export type EmailAlertAction = { type: "email"; userIds?: string[]; roleIds?: string[]; emails?: string[]; }; -type WebhookAlertAction = { +export type WebhookAlertAction = { type: "webhook"; webhookUrl: string; enabled: boolean; @@ -143,6 +143,7 @@ type AlertAction = EmailAlertAction | WebhookAlertAction; export interface TestAlertContext { eventType: AlertEventType; actions: AlertAction[]; + orgId: string; /** Human-readable context data included in emails and webhook payloads */ data: Record; } From 899c47e9a37263b63917ee6297d2ff4aa2d8b63f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 11 Aug 2026 20:52:13 +0200 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=9A=A7=20write=20process=20test=20a?= =?UTF-8?q?lert=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index f598dc633..7aa1691f0 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -1,11 +1,15 @@ +import { db, userOrgRoles, users } from "@server/db"; import logger from "@server/logger"; import type { EmailAlertAction, - TestAlertContext + TestAlertContext, + WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { eq, inArray } from "drizzle-orm"; import { sendAlertEmail } from "./sendAlertEmail"; -import type { db, alertEmailRecipients, users, userOrgRoles } from "@server/db"; -import type { eq } from "drizzle-orm"; +import { decrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import { sendAlertWebhook } from "./sendAlertWebhook"; export async function processTestAlerts(context: TestAlertContext) { const emailActions = context.actions.filter( @@ -19,7 +23,38 @@ export async function processTestAlerts(context: TestAlertContext) { await sendAlertEmail(recipients, context); } } catch (err) { - logger.error(`processAlerts: failed to send alert email`, err); + logger.error(`processTestAlerts: failed to send alert email`, err); + } + } + + const webhookActions = context.actions.filter( + (action) => action.type === "webhook" + ); + const serverSecret = config.getRawConfig().server.secret!; + + for (const action of webhookActions) { + try { + let webhookConfig: WebhookAlertConfig = { authType: "none" }; + + if (action.config) { + try { + const decrypted = decrypt(action.config, serverSecret); + webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + } catch (err) { + logger.error( + `processTestAlerts: failed to decrypt webhook`, + err + ); + continue; + } + } + + await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + } catch (err) { + logger.error( + `processTestAlerts: failed to send alert webhook `, + err + ); } } } @@ -35,39 +70,31 @@ export async function processTestAlerts(context: TestAlertContext) { async function resolveEmailRecipients( action: EmailAlertAction ): Promise { - const emailSet = new Set(); + const emailList: string[] = []; - // for (const row of rows) { - // if (row.email) { - // emailSet.add(row.email); - // } + emailList.push(...(action.emails ?? [])); - // if (row.userId) { - // const [user] = await db - // .select({ email: users.email }) - // .from(users) - // .where(eq(users.userId, row.userId)) - // .limit(1); - // if (user?.email) { - // emailSet.add(user.email); - // } - // } + if (action.userIds && action.userIds?.length > 0) { + const userList = await db + .select({ email: users.email }) + .from(users) + .where(inArray(users.userId, action.userIds)); - // if (row.roleId) { - // // Find all users with this role via userOrgRoles - // const roleUsers = await db - // .select({ email: users.email }) - // .from(userOrgRoles) - // .innerJoin(users, eq(userOrgRoles.userId, users.userId)) - // .where(eq(userOrgRoles.roleId, Number(row.roleId))); + emailList.push( + ...userList.filter((u) => u.email !== null).map((u) => u.email!) + ); + } + if (action.roleIds && action.roleIds?.length > 0) { + const userList = await db + .select({ email: users.email }) + .from(userOrgRoles) + .innerJoin(users, eq(userOrgRoles.userId, users.userId)) + .where(inArray(userOrgRoles.roleId, action.roleIds.map(Number))); - // for (const u of roleUsers) { - // if (u.email) { - // emailSet.add(u.email); - // } - // } - // } - // } + emailList.push( + ...userList.filter((u) => u.email !== null).map((u) => u.email!) + ); + } - return Array.from(emailSet); + return [...new Set(emailList)]; } From 21032bc22b3e923de212a324c08cb29f94b9ee4f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 11 Aug 2026 22:25:26 +0200 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20=20test=20alert=20email=20wor?= =?UTF-8?q?ks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + server/emails/templates/AlertNotification.tsx | 33 +++++- .../private/lib/alerts/processTestAlerts.ts | 5 +- server/private/lib/alerts/sendAlertEmail.ts | 49 +++++--- .../routers/alertRule/testAlertRule.ts | 111 +++++++++++++++++- server/private/routers/external.ts | 2 +- server/routers/alertRule/types.ts | 4 +- .../AlertRuleGraphEditor.tsx | 28 +++++ src/lib/getRandomItemInArray.ts | 5 + 9 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 src/lib/getRandomItemInArray.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2dd438a12..05b40d27d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1695,6 +1695,8 @@ "alertingRuleSaved": "Alert rule saved", "alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.", "alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.", + "alertingTestAlertSent": "Test alert sent", + "alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.", "alertingEditRule": "Edit Alert Rule", "alertingCreateRule": "Create Alert Rule", "alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify", diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index ce30753da..c81cf60da 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -31,9 +31,24 @@ export type AlertNotificationProps = { orgId: string; data: Record; dashboardLink: string; + isTestAlert?: boolean; }; -function getEventMeta(eventType: AlertEventType): { +function getEventMeta( + eventType: AlertEventType, + isTestAlert: boolean = false +): { + heading: string; + previewText: string; + summary: string; + statusLabel: string | null; + statusColor: string | null; +} { + const meta = getBaseEventMeta(eventType); + return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta; +} + +function getBaseEventMeta(eventType: AlertEventType): { heading: string; previewText: string; summary: string; @@ -180,8 +195,14 @@ function formatDataItems( } export const AlertNotification = (props: AlertNotificationProps) => { - const { eventType, orgId, data, dashboardLink } = props; - const meta = getEventMeta(eventType); + const { + eventType, + orgId, + data, + dashboardLink, + isTestAlert = false + } = props; + const meta = getEventMeta(eventType, isTestAlert); const dataItems = formatDataItems(data); const isToggle = @@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => { Open your dashboard to view more details and manage your alert rules. + {isTestAlert && ( + + This is a test alert. No action is required, + and no real event has occurred. + + )} diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index 7aa1691f0..1a6c1d7c6 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -20,7 +20,10 @@ export async function processTestAlerts(context: TestAlertContext) { try { const recipients = await resolveEmailRecipients(action); if (recipients.length > 0) { - await sendAlertEmail(recipients, context); + await sendAlertEmail(recipients, { + ...context, + isTest: true + }); } } catch (err) { logger.error(`processTestAlerts: failed to send alert email`, err); diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 6f99b102c..0eef6fb5c 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -15,7 +15,24 @@ import { sendEmail } from "@server/emails"; import AlertNotification from "@server/emails/templates/AlertNotification"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { AlertContext } from "@server/routers/alertRule/types"; +import { + AlertContext, + type AlertEventType +} from "@server/routers/alertRule/types"; + +type EmailAlertContext = { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + 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; + isTest?: boolean; +}; /** * Sends an alert notification email to every address in `recipients`. @@ -27,7 +44,7 @@ import { AlertContext } from "@server/routers/alertRule/types"; */ export async function sendAlertEmail( recipients: string[], - context: AlertContext + context: EmailAlertContext ): Promise { if (recipients.length === 0) { return; @@ -46,7 +63,8 @@ export async function sendAlertEmail( eventType: context.eventType, orgId: context.orgId, data: context.data, - dashboardLink + dashboardLink, + isTestAlert: context.isTest }), { from, @@ -70,34 +88,35 @@ export async function sendAlertEmail( // Helpers // --------------------------------------------------------------------------- -function buildSubject(context: AlertContext): string { +function buildSubject(context: EmailAlertContext): string { + const prefix = context.isTest ? "[Test Alert]" : "[Alert]"; switch (context.eventType) { case "site_online": - return "[Alert] Site Back Online"; + return `${prefix} Site Back Online`; case "site_offline": - return "[Alert] Site Offline"; + return `${prefix} Site Offline`; case "site_toggle": - return "[Alert] Site Status Changed"; + return `${prefix} Site Status Changed`; case "health_check_healthy": - return "[Alert] Health Check Recovered"; + return `${prefix} Health Check Recovered`; case "health_check_unhealthy": - return "[Alert] Health Check Failing"; + return `${prefix} Health Check Failing`; case "health_check_toggle": - return "[Alert] Health Check Status Changed"; + return `${prefix} Health Check Status Changed`; case "resource_healthy": - return "[Alert] Resource Healthy"; + return `${prefix} Resource Healthy`; case "resource_unhealthy": - return "[Alert] Resource Unhealthy"; + return `${prefix} Resource Unhealthy`; case "resource_degraded": - return "[Alert] Resource Degraded"; + return `${prefix} Resource Degraded`; case "resource_toggle": - return "[Alert] Resource Status Changed"; + return `${prefix} Resource Status Changed`; default: { // Exhaustiveness fallback – should never be reached with a // well-typed caller, but keeps runtime behaviour predictable. const _exhaustive: never = context.eventType; void _exhaustive; - return "[Alert] Event Notification"; + return `${prefix} Event Notification`; } } } diff --git a/server/private/routers/alertRule/testAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts index 39a5c28a1..6104dbc6b 100644 --- a/server/private/routers/alertRule/testAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -27,7 +27,13 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; -import { ListAlertRulesResponse } from "@server/routers/alertRule/types"; +import { + ListAlertRulesResponse, + type AlertAction, + type EmailAlertAction +} from "@server/routers/alertRule/types"; +import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; +import { getRandomItemInArray } from "@app/lib/getRandomItemInArray"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -51,12 +57,12 @@ export const RESOURCE_EVENT_TYPES = [ ] as const; const webhookActionSchema = z.strictObject({ - webhookUrl: z.string().url(), + webhookUrl: z.url(), config: z.string().optional(), enabled: z.boolean().optional().default(true) }); -const bodySchema = z.strictObject({ +const bodySchema = z.object({ eventType: z.enum([ ...HC_EVENT_TYPES, ...SITE_EVENT_TYPES, @@ -97,7 +103,106 @@ export async function testAlertRule( ); } + const body = parsedBody.data; + const collectedActions: AlertAction[] = []; + if ( + body.emails.length > 0 || + body.roleIds.length > 0 || + body.userIds.length > 0 + ) { + collectedActions.push({ + type: "email", + emails: body.emails, + roleIds: body.roleIds, + userIds: body.userIds + }); + } + + for (const action of body.webhookActions) { + collectedActions.push({ + type: "webhook", + ...action + }); + } + + let data: Record = {}; + switch (body.eventType) { + case "site_toggle": + data = { + status: getRandomItemInArray(["online", "offline"]), + siteName: "Test Site Alert" + }; + break; + case "site_offline": + data = { + status: "offline", + siteName: "Test Site Alert" + }; + break; + case "site_online": + data = { + status: "online", + siteName: "Test Site Alert" + }; + break; + case "resource_toggle": + data = { + status: getRandomItemInArray([ + "healthy", + "unhealthy", + "degraded" + ]), + siteName: "Test Resource Alert" + }; + break; + case "resource_healthy": + data = { + status: "healthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_unhealthy": + data = { + status: "unhealthy", + siteName: "Test Resource Alert" + }; + break; + case "resource_degraded": + data = { + status: "degraded", + siteName: "Test Resource Alert" + }; + break; + case "health_check_toggle": + data = { + status: getRandomItemInArray(["healthy", "unhealthy"]), + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_healthy": + data = { + status: "healthy", + healthCheckName: "Test Health Check Alert" + }; + break; + case "health_check_unhealthy": + data = { + status: "unhealthy", + healthCheckName: "Test Health Check Alert" + }; + break; + + default: + break; + } + // TODO: process alert rule + await processTestAlerts({ + eventType: body.eventType, + orgId, + actions: collectedActions, + data + }); } catch (error) { logger.error(error); return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0fd4cc023..b880598cc 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -809,7 +809,7 @@ authenticated.get( ); authenticated.post( - "/org/:orgId/alert-rule/test", + "/org/:orgId/test-alert-rule", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.testAlertRule), diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index 99057b312..21baa7b2b 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -128,7 +128,7 @@ export interface AlertContext { export type EmailAlertAction = { type: "email"; userIds?: string[]; - roleIds?: string[]; + roleIds?: number[]; emails?: string[]; }; @@ -139,7 +139,7 @@ export type WebhookAlertAction = { config?: string | undefined; }; -type AlertAction = EmailAlertAction | WebhookAlertAction; +export type AlertAction = EmailAlertAction | WebhookAlertAction; export interface TestAlertContext { eventType: AlertEventType; actions: AlertAction[]; diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index a10f8f3b3..243ce82f8 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -189,10 +189,38 @@ export default function AlertRuleGraphEditor({ description: t("alertingNoActionsTestDescription") }); } + return; } const values = form.getValues(); + try { + const payload = formValuesToApiPayload(values); + if (isNew) { + const res = await api.post< + AxiosResponse + >(`/org/${orgId}/test-alert-rule`, payload); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } else { + await api.post( + `/org/${orgId}/alert-rule/${alertRuleId}`, + payload + ); + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } }; const [, formAction, isSaving] = useActionState(saveAlert, null); diff --git a/src/lib/getRandomItemInArray.ts b/src/lib/getRandomItemInArray.ts new file mode 100644 index 000000000..aa5a2e562 --- /dev/null +++ b/src/lib/getRandomItemInArray.ts @@ -0,0 +1,5 @@ +export function getRandomItemInArray(array: T[]) { + // Source - https://stackoverflow.com/a/4550514 + const randomElement = array[Math.floor(Math.random() * array.length)]; + return randomElement; +} From c6bd657ee62491e10b76798b419c18debce2e9be Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 18:48:14 +0200 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8=20send=20webhook=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../private/lib/alerts/processTestAlerts.ts | 16 ++++---- server/private/lib/alerts/sendAlertEmail.ts | 5 +-- server/private/lib/alerts/sendAlertWebhook.ts | 41 ++++++++++++++----- .../AlertRuleGraphEditor.tsx | 28 ++++--------- src/lib/queries.ts | 2 +- 5 files changed, 50 insertions(+), 42 deletions(-) diff --git a/server/private/lib/alerts/processTestAlerts.ts b/server/private/lib/alerts/processTestAlerts.ts index 1a6c1d7c6..f7fa47b20 100644 --- a/server/private/lib/alerts/processTestAlerts.ts +++ b/server/private/lib/alerts/processTestAlerts.ts @@ -7,15 +7,13 @@ import type { } from "@server/routers/alertRule/types"; import { eq, inArray } from "drizzle-orm"; import { sendAlertEmail } from "./sendAlertEmail"; -import { decrypt } from "@server/lib/crypto"; -import config from "@server/lib/config"; import { sendAlertWebhook } from "./sendAlertWebhook"; export async function processTestAlerts(context: TestAlertContext) { + // Process email actions const emailActions = context.actions.filter( (action) => action.type === "email" ); - // Process email actions for (const action of emailActions) { try { const recipients = await resolveEmailRecipients(action); @@ -30,10 +28,10 @@ export async function processTestAlerts(context: TestAlertContext) { } } + // Process webhook actions const webhookActions = context.actions.filter( (action) => action.type === "webhook" ); - const serverSecret = config.getRawConfig().server.secret!; for (const action of webhookActions) { try { @@ -41,8 +39,9 @@ export async function processTestAlerts(context: TestAlertContext) { if (action.config) { try { - const decrypted = decrypt(action.config, serverSecret); - webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig; + webhookConfig = JSON.parse( + action.config + ) as WebhookAlertConfig; } catch (err) { logger.error( `processTestAlerts: failed to decrypt webhook`, @@ -52,7 +51,10 @@ export async function processTestAlerts(context: TestAlertContext) { } } - await sendAlertWebhook(action.webhookUrl, webhookConfig, context); + await sendAlertWebhook(action.webhookUrl, webhookConfig, { + ...context, + isTest: true + }); } catch (err) { logger.error( `processTestAlerts: failed to send alert webhook `, diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 0eef6fb5c..ab7d49acc 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -15,10 +15,7 @@ import { sendEmail } from "@server/emails"; import AlertNotification from "@server/emails/templates/AlertNotification"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { - AlertContext, - type AlertEventType -} from "@server/routers/alertRule/types"; +import { type AlertEventType } from "@server/routers/alertRule/types"; type EmailAlertContext = { eventType: AlertEventType; diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 27e142cc4..d3a5d421b 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -14,13 +14,28 @@ import logger from "@server/logger"; import { AlertContext, - WebhookAlertConfig + WebhookAlertConfig, + type AlertEventType } from "@server/routers/alertRule/types"; const REQUEST_TIMEOUT_MS = 15_000; const MAX_RETRIES = 3; const RETRY_BASE_DELAY_MS = 500; +type WebhookAlertContext = { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + 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; + isTest?: boolean; +}; + /** * Sends a single webhook POST for an alert event. * @@ -40,14 +55,14 @@ const RETRY_BASE_DELAY_MS = 500; export async function sendAlertWebhook( url: string, webhookConfig: WebhookAlertConfig, - context: AlertContext + context: WebhookAlertContext ): Promise { const eventType = context.eventType; const timestamp = new Date().toISOString(); const status = deriveStatus(eventType, context.data); const data = { orgId: context.orgId, ...context.data }; - let body: string; + let body: Record; if (webhookConfig.useBodyTemplate && webhookConfig.bodyTemplate?.trim()) { body = renderTemplate(webhookConfig.bodyTemplate, { event: eventType, @@ -56,7 +71,11 @@ export async function sendAlertWebhook( data }); } else { - body = JSON.stringify({ event: eventType, timestamp, status, data }); + body = { event: eventType, timestamp, status, data }; + } + + if (body.data && context.isTest) { + body.data.test = true; } const headers = buildHeaders(webhookConfig); @@ -75,7 +94,7 @@ export async function sendAlertWebhook( response = await fetch(url, { method: webhookConfig.method ?? "POST", headers, - body, + body: JSON.stringify(body), signal: controller.signal }); } catch (err: unknown) { @@ -247,7 +266,10 @@ interface TemplateContext { * left untouched. * 3. The fixed top-level keys: event, timestamp, status. */ -function renderTemplate(template: string, ctx: TemplateContext): string { +function renderTemplate( + template: string, + ctx: TemplateContext +): Record { // Step 1 – expand {{data}} first so its contents are already serialised // and won't be touched by later passes. let rendered = template.replace(/\{\{data\}\}/g, JSON.stringify(ctx.data)); @@ -280,20 +302,19 @@ function renderTemplate(template: string, ctx: TemplateContext): string { // Validate the rendered result is valid JSON; if not, log a warning and // fall back to the default payload so the webhook still fires. try { - JSON.parse(rendered); - return rendered; + return JSON.parse(rendered); } catch { logger.warn( `sendAlertWebhook: body template produced invalid JSON for event ` + `"${ctx.event}" destined for a webhook. Falling back to default ` + `payload. Check that {{data}} is NOT wrapped in quotes in your template.` ); - return JSON.stringify({ + return { event: ctx.event, timestamp: ctx.timestamp, status: ctx.status, data: ctx.data - }); + }; } } diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 243ce82f8..a3e5e89cb 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -180,8 +180,9 @@ export default function AlertRuleGraphEditor({ const testAlert = async () => { const isValid = await form.trigger(); + const values = form.getValues(); + if (!isValid) { - const values = form.getValues(); if (values.actions.length === 0) { toast({ variant: "warning", @@ -193,27 +194,14 @@ export default function AlertRuleGraphEditor({ return; } - const values = form.getValues(); try { const payload = formValuesToApiPayload(values); - if (isNew) { - const res = await api.post< - AxiosResponse - >(`/org/${orgId}/test-alert-rule`, payload); - toast({ - title: t("alertingTestAlertSent"), - description: t("alertingTestAlertSentDescription") - }); - } else { - await api.post( - `/org/${orgId}/alert-rule/${alertRuleId}`, - payload - ); - toast({ - title: t("alertingTestAlertSent"), - description: t("alertingTestAlertSentDescription") - }); - } + await api.post(`/org/${orgId}/test-alert-rule`, payload); + + toast({ + title: t("alertingTestAlertSent"), + description: t("alertingTestAlertSentDescription") + }); } catch (e) { toast({ title: t("error"), diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 96024c3a0..e9e324c08 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1371,7 +1371,7 @@ export const approvalQueries = { }, refetchInterval: (query) => { if (query.state.data) { - return durationToMs(30, "seconds"); + return durationToMs(1.5, "minutes"); } return false; } From 4b61e12ca6ae837e4b7341222afb60ffb824d8b3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 18:56:56 +0200 Subject: [PATCH 07/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20trigger=20alert=20co?= =?UTF-8?q?rrectly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/alertRule/testAlertRule.ts | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/server/private/routers/alertRule/testAlertRule.ts b/server/private/routers/alertRule/testAlertRule.ts index 6104dbc6b..e870e9488 100644 --- a/server/private/routers/alertRule/testAlertRule.ts +++ b/server/private/routers/alertRule/testAlertRule.ts @@ -11,29 +11,17 @@ * This file is not licensed under the AGPLv3. */ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } 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"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; -import { - ListAlertRulesResponse, - type AlertAction, - type EmailAlertAction -} from "@server/routers/alertRule/types"; -import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; import { getRandomItemInArray } from "@app/lib/getRandomItemInArray"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts"; +import { type AlertAction } from "@server/routers/alertRule/types"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import type { TriggerSiteAlertResponse } from "../alertEvents"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -203,6 +191,14 @@ export async function testAlertRule( actions: collectedActions, data }); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Alert triggered successfully", + status: HttpCode.OK + }); } catch (error) { logger.error(error); return next( From 49b4fcf0634e74c55e520b2bb53e50411585acf5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Aug 2026 21:36:40 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/alert-rule-editor/AlertRuleGraphEditor.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index a3e5e89cb..9114df3ed 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -171,15 +171,10 @@ export default function AlertRuleGraphEditor({ variant: "destructive" }); } - // const submit = form.handleSubmit(async (values) => { - - // }); - - // await submit(); }; const testAlert = async () => { - const isValid = await form.trigger(); + const isValid = await form.trigger("actions"); const values = form.getValues(); if (!isValid) { From 1143404a65832ea5b84dbdabb056adbac89fab56 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:15:23 +0200 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=92=84alert=20rule=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alert-rule-editor/AlertRuleFields.tsx | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 7ae6d0a04..8780ea102 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -45,7 +45,14 @@ import { import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react"; +import { + Bell, + ChevronRightIcon, + ChevronsUpDown, + Globe, + Plus, + Trash2 +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState } from "react"; import type { Control, UseFormReturn } from "react-hook-form"; @@ -95,6 +102,7 @@ export function AddActionPanel({ const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id); const [selected, setSelected] = useState("notify"); + const [isPopoverOpen, setPopoverOpen] = useState(false); const isPremiumSelected = selected !== null && EXTERNAL_IDS.includes(selected as any); @@ -131,27 +139,48 @@ export function AddActionPanel({ if (!isBuiltInSelected) return; onAdd(selected as AlertRuleFormAction["type"]); setSelected(null); + setPopoverOpen(false); }; return ( -
- setSelected(v)} - /> - {isPremiumSelected && } - {!isPremiumSelected && ( - - )} +
+

Add new action

+ + + + + + setSelected(v)} + /> + + {isPremiumSelected && } + {!isPremiumSelected && ( + + )} + + + {/* */}
); } From 2878d5690cf10046960cc30b714b0572762de6d8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:19:55 +0200 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=92=AC=20update=20texts=20for=20hea?= =?UTF-8?q?ding=20&=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 ++ .../alert-rule-editor/AlertRuleFields.tsx | 14 +++----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 05b40d27d..7b0f0b4b3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1807,6 +1807,8 @@ "alertingHealthChecksBannerTitle": "Monitor Health & Resources", "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", "alertingTestRule": "Test Alert Rule", + "alertingAddActionHeading": "Add New Action", + "alertingSelectActionType": "Choose an action type", "alertingNoActionsTitle": "No actions configured", "alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.", "alertingNoActionsTestDescription": "Add at least one action before you can test this rule.", diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 8780ea102..53fa9cdc1 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -144,18 +144,11 @@ export function AddActionPanel({ return (
-

Add new action

+

{t("alertingAddActionHeading")}

- @@ -180,7 +173,6 @@ export function AddActionPanel({ )} - {/* */}
); } From 9bb413bb1e39cd8e9b9ed37e9827145e3aaa5bf0 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 13 Aug 2026 19:23:39 +0200 Subject: [PATCH 11/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/alert-rule-editor/AlertRuleFields.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 53fa9cdc1..147369a9e 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -160,8 +160,9 @@ export function AddActionPanel({ onChange={(v) => setSelected(v)} /> - {isPremiumSelected && } - {!isPremiumSelected && ( + {isPremiumSelected ? ( + + ) : (