Making the alerts work

This commit is contained in:
Owen
2026-04-21 21:13:31 -07:00
parent 320543f7f8
commit c9caa44c06
11 changed files with 76 additions and 19 deletions

View File

@@ -1469,6 +1469,8 @@
"alertingConfigureTrigger": "Configure Trigger", "alertingConfigureTrigger": "Configure Trigger",
"alertingConfigureActions": "Configure Actions", "alertingConfigureActions": "Configure Actions",
"alertingBackToRules": "Back to Rules", "alertingBackToRules": "Back to Rules",
"alertingRuleCooldown": "Cooldown (seconds)",
"alertingRuleCooldownDescription": "Minimum time between repeated alerts for the same rule. Set to 0 to fire every time.",
"alertingDraftBadge": "Draft - save to store this rule", "alertingDraftBadge": "Draft - save to store this rule",
"alertingSidebarHint": "Click a step on the canvas to edit it here.", "alertingSidebarHint": "Click a step on the canvas to edit it here.",
"alertingGraphCanvasTitle": "Rule Flow", "alertingGraphCanvasTitle": "Rule Flow",

View File

@@ -11,7 +11,7 @@ export async function createNextServer() {
// const app = next({ dev }); // const app = next({ dev });
const app = next({ const app = next({
dev: process.env.ENVIRONMENT !== "prod", dev: process.env.ENVIRONMENT !== "prod",
turbopack: true turbopack: false
}); });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();

View File

@@ -74,7 +74,7 @@ export async function fireHealthCheckHealthyAlert(
* @param healthCheckName - Human-readable name shown in notifications (optional). * @param healthCheckName - 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 fireHealthCheckUnhealthyAlert(
orgId: string, orgId: string,
healthCheckId: number, healthCheckId: number,
healthCheckName?: string | null, healthCheckName?: string | null,

View File

@@ -23,7 +23,7 @@ import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckNotHealthyAlert fireHealthCheckUnhealthyAlert
} from "#private/lib/alerts/events/healthCheckEvents"; } from "#private/lib/alerts/events/healthCheckEvents";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -106,7 +106,7 @@ export async function triggerHealthCheckAlert(
healthCheck.name ?? undefined healthCheck.name ?? undefined
); );
} else { } else {
await fireHealthCheckNotHealthyAlert( await fireHealthCheckUnhealthyAlert(
orgId, orgId,
healthCheckId, healthCheckId,
healthCheck.name ?? undefined healthCheck.name ?? undefined

View File

@@ -63,7 +63,7 @@ const bodySchema = z
...RESOURCE_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(0),
// Source join tables - which is required depends on eventType // Source join tables - which is required depends on eventType
siteIds: z.array(z.number().int().positive()).optional().default([]), siteIds: z.array(z.number().int().positive()).optional().default([]),
allSites: z.boolean().optional().default(false), allSites: z.boolean().optional().default(false),

View File

@@ -39,7 +39,7 @@ const bodySchema = z.strictObject({
hcMethod: z.string().default("GET"), hcMethod: z.string().default("GET"),
hcInterval: z.number().int().positive().default(30), hcInterval: z.number().int().positive().default(30),
hcUnhealthyInterval: z.number().int().positive().default(30), hcUnhealthyInterval: z.number().int().positive().default(30),
hcTimeout: z.number().int().positive().default(5), hcTimeout: z.number().int().positive().default(1),
hcHeaders: z.string().optional().nullable(), hcHeaders: z.string().optional().nullable(),
hcFollowRedirects: z.boolean().default(true), hcFollowRedirects: z.boolean().default(true),
hcStatus: z.number().int().optional().nullable(), hcStatus: z.number().int().optional().nullable(),

View File

@@ -31,8 +31,8 @@ const createTargetSchema = z.strictObject({
hcMode: z.string().optional().nullable(), hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(),
hcPort: z.int().positive().optional().nullable(), hcPort: z.int().positive().optional().nullable(),
hcInterval: z.int().positive().min(5).optional().nullable(), hcInterval: z.int().positive().min(1).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(),
hcTimeout: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(),
hcHeaders: z hcHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() })) .array(z.strictObject({ name: z.string(), value: z.string() }))

View File

@@ -8,12 +8,13 @@ import {
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db"; import { Newt } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and, ne } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckNotHealthyAlert fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts"; } from "#dynamic/lib/alerts";
import { fireResourceHealthyAlert, fireResourceUnhealthyAlert } from "@server/private/lib/alerts/events/resourceEvents";
interface TargetHealthStatus { interface TargetHealthStatus {
status: string; status: string;
@@ -96,10 +97,12 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
targetHealthCheckId: targetHealthCheck.targetHealthCheckId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceOrgId: resources.orgId, resourceOrgId: resources.orgId,
resourceId: resources.resourceId, resourceId: resources.resourceId,
resourceName: resources.name,
name: targetHealthCheck.name, name: targetHealthCheck.name,
hcStatus: targetHealthCheck.hcHealth hcHealth: targetHealthCheck.hcHealth
}) })
.from(targetHealthCheck) .from(targetHealthCheck)
.innerJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
.innerJoin( .innerJoin(
targets, targets,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
@@ -108,7 +111,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
resources, resources,
eq(targets.resourceId, resources.resourceId) eq(targets.resourceId, resources.resourceId)
) )
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where( .where(
and( and(
eq(targetHealthCheck.targetHealthCheckId, targetIdNum), eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
@@ -126,7 +128,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
} }
// check if the status has changed // check if the status has changed
if (targetCheck.hcStatus === healthStatus.status) { if (targetCheck.hcHealth === healthStatus.status) {
logger.debug( logger.debug(
`Health status for target ${targetId} is already ${healthStatus.status}, skipping update` `Health status for target ${targetId} is already ${healthStatus.status}, skipping update`
); );
@@ -178,7 +180,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
.where( .where(
and( and(
eq(targets.resourceId, targetCheck.resourceId), eq(targets.resourceId, targetCheck.resourceId),
eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated ne(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated
) )
); );
@@ -200,17 +202,31 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
status: status, status: status,
timestamp: Math.floor(Date.now() / 1000) timestamp: Math.floor(Date.now() / 1000)
}); });
if (status === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
} else if (status === "healthy") {
await fireResourceHealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
}
} }
// because we are checking above if there was a change we can fire the alert here because it changed // because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") { if (healthStatus.status === "unhealthy") {
await fireHealthCheckHealthyAlert( await fireHealthCheckUnhealthyAlert(
orgId, orgId,
targetCheck.targetHealthCheckId, targetCheck.targetHealthCheckId,
targetCheck.name targetCheck.name
); );
} else if (healthStatus.status === "healthy") { } else if (healthStatus.status === "healthy") {
await fireHealthCheckNotHealthyAlert( await fireHealthCheckHealthyAlert(
orgId, orgId,
targetCheck.targetHealthCheckId, targetCheck.targetHealthCheckId,
targetCheck.name targetCheck.name

View File

@@ -32,8 +32,8 @@ const updateTargetBodySchema = z
hcMode: z.string().optional().nullable(), hcMode: z.string().optional().nullable(),
hcHostname: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(),
hcPort: z.int().positive().optional().nullable(), hcPort: z.int().positive().optional().nullable(),
hcInterval: z.int().positive().min(5).optional().nullable(), hcInterval: z.int().positive().min(1).optional().nullable(),
hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(),
hcTimeout: z.int().positive().min(1).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(),
hcHeaders: z hcHeaders: z
.array(z.strictObject({ name: z.string(), value: z.string() })) .array(z.strictObject({ name: z.string(), value: z.string() }))

View File

@@ -12,6 +12,7 @@ import { Card, CardContent } from "@app/components/ui/card";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -205,6 +206,38 @@ export default function AlertRuleGraphEditor({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="cooldownSeconds"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("alertingRuleCooldown")}
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
step={1}
{...field}
value={field.value}
onChange={(e) =>
field.onChange(
Number(
e.target
.value
)
)
}
/>
</FormControl>
<FormDescription>
{t("alertingRuleCooldownDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

View File

@@ -49,6 +49,7 @@ export type AlertRuleFormAction =
export type AlertRuleFormValues = { export type AlertRuleFormValues = {
name: string; name: string;
enabled: boolean; enabled: boolean;
cooldownSeconds: number;
sourceType: "site" | "health_check" | "resource"; sourceType: "site" | "health_check" | "resource";
allSites: boolean; allSites: boolean;
siteIds: number[]; siteIds: number[];
@@ -66,6 +67,7 @@ export type AlertRuleFormValues = {
export type AlertRuleApiPayload = { export type AlertRuleApiPayload = {
name: string; name: string;
cooldownSeconds: number;
eventType: eventType:
| "site_online" | "site_online"
| "site_offline" | "site_offline"
@@ -141,6 +143,7 @@ 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(),
cooldownSeconds: z.number().int().nonnegative().default(0),
sourceType: z.enum(["site", "health_check", "resource"]), sourceType: z.enum(["site", "health_check", "resource"]),
allSites: z.boolean().default(true), allSites: z.boolean().default(true),
siteIds: z.array(z.number()).default([]), siteIds: z.array(z.number()).default([]),
@@ -309,6 +312,7 @@ export function defaultFormValues(): AlertRuleFormValues {
return { return {
name: "", name: "",
enabled: true, enabled: true,
cooldownSeconds: 0,
sourceType: "site", sourceType: "site",
allSites: true, allSites: true,
siteIds: [], siteIds: [],
@@ -422,6 +426,7 @@ export function apiResponseToFormValues(
return { return {
name: rule.name, name: rule.name,
enabled: rule.enabled, enabled: rule.enabled,
cooldownSeconds: rule.cooldownSeconds ?? 0,
sourceType, sourceType,
allSites, allSites,
siteIds: rule.siteIds, siteIds: rule.siteIds,
@@ -483,6 +488,7 @@ export function formValuesToApiPayload(
name: values.name.trim(), name: values.name.trim(),
eventType, eventType,
enabled: values.enabled, enabled: values.enabled,
cooldownSeconds: values.cooldownSeconds,
allSites: values.allSites, allSites: values.allSites,
siteIds: values.allSites ? [] : values.siteIds, siteIds: values.allSites ? [] : values.siteIds,
allHealthChecks: values.allHealthChecks, allHealthChecks: values.allHealthChecks,