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