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