Add resource

This commit is contained in:
Owen
2026-04-20 17:48:44 -07:00
parent 0a70896080
commit f38069623b
16 changed files with 511 additions and 44 deletions

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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",

View File

@@ -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
);
}
}

View File

@@ -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 = [];
}

View File

@@ -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.

View File

@@ -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>;
}

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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,

View File

@@ -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")
);
}
}
}