From 299fd469ddd78f5550006e32b103697edb87a47a Mon Sep 17 00:00:00 2001 From: Roshan-P-Patel <71948692+Roshan-P-Patel@users.noreply.github.com> Date: Sat, 11 Apr 2026 21:16:08 -0400 Subject: [PATCH] Changes to add escalation --- client/src/Hooks/useMonitorForm.ts | 2 + client/src/Pages/CreateMonitor/index.tsx | 71 +++++++++++++++++-- client/src/Types/Monitor.ts | 2 + client/src/Validation/monitor.ts | 23 +++++- client/src/locales/en.json | 14 ++++ server/src/db/models/Incident.ts | 5 ++ server/src/db/models/Monitor.ts | 9 +++ .../incidents/IIncidentsRepository.ts | 1 + .../incidents/MongoIncidentRepository.ts | 6 ++ .../monitors/MongoMonitorsRepository.ts | 12 +++- .../src/service/business/incidentService.ts | 1 + .../SuperSimpleQueue/SuperSimpleQueue.ts | 2 + .../SuperSimpleQueueHelper.ts | 53 ++++++++++++++ .../notificationMessageBuilder.ts | 40 ++++++++++- .../notificationProviders/email.ts | 2 + .../infrastructure/notificationsService.ts | 25 ++++--- server/src/types/incident.ts | 1 + server/src/types/monitor.ts | 2 + server/src/types/notificationMessage.ts | 2 +- server/src/validation/monitorValidation.ts | 28 ++++++++ 20 files changed, 283 insertions(+), 18 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..3efc94f15c 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -12,6 +12,8 @@ const getBaseDefaults = (data?: Monitor | null) => ({ description: data?.description || "", interval: data?.interval || 60000, notifications: data?.notifications || [], + escalationDelayMinutes: data?.escalationDelayMinutes ?? undefined, + escalationNotificationId: data?.escalationNotificationId ?? undefined, statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, geoCheckEnabled: data?.geoCheckEnabled ?? false, diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..fa8f05af4a 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -209,6 +209,14 @@ const CreateMonitorPage = () => { }, [defaults, form]); const watchedType = watch("type") as MonitorType; + const notificationOptions = useMemo( + () => + (notifications ?? []).map((notification) => ({ + ...notification, + name: notification.notificationName, + })), + [notifications] + ); const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -705,11 +713,6 @@ const CreateMonitorPage = () => { name="notifications" control={control} render={({ field }) => { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); const selectedNotifications = notificationOptions.filter((n) => (field.value ?? []).includes(n.id) ); @@ -765,6 +768,64 @@ const CreateMonitorPage = () => { } /> + + ( + { + const value = e.target.value; + field.onChange(value === "" ? undefined : Number(value)); + }} + type="number" + fieldLabel={t("pages.createMonitor.form.escalation.option.delay.label")} + placeholder={t("pages.createMonitor.form.escalation.option.delay.placeholder")} + fullWidth + inputProps={{ min: 1, step: 1 }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ( + + )} + /> + + } + /> + {(watchedType === "http" || watchedType === "grpc" || watchedType === "websocket") && ( diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 053b517d1d..e554ec0c66 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -60,6 +60,8 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + escalationDelayMinutes?: number; + escalationNotificationId?: string; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fed..64c0dccaee 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -21,6 +21,8 @@ const baseSchema = z.object({ .number({ message: "Threshold percentage is required" }) .min(1, "Incident percentage must be at least 1") .max(100, "Incident percentage must be at most 100"), + escalationDelayMinutes: z.number().int().min(1, "Escalation delay must be at least 1 minute").optional(), + escalationNotificationId: z.string().min(1, "Escalation notification channel is required").optional(), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z @@ -133,7 +135,26 @@ export const monitorSchema = z.discriminatedUnion("type", [ pagespeedSchema, hardwareSchema, websocketSchema, -]); +]).superRefine((data, ctx) => { + const hasDelay = data.escalationDelayMinutes !== undefined && data.escalationDelayMinutes !== null; + const hasChannel = Boolean(data.escalationNotificationId); + + if (hasDelay && !hasChannel) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Escalation notification channel is required", + path: ["escalationNotificationId"], + }); + } + + if (hasChannel && !hasDelay) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Escalation delay must be at least 1 minute", + path: ["escalationDelayMinutes"], + }); + } +}); export type MonitorFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 92a21939f3..89dda6b375 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -543,6 +543,20 @@ "description": "Select the notification channels you want to use", "title": "Notifications" }, + "escalation": { + "description": "Send a second notification to a chosen channel after the first alert has been sent.", + "option": { + "delay": { + "label": "Escalation delay (minutes)", + "placeholder": "e.g. 15" + }, + "channel": { + "label": "Escalation channel", + "placeholder": "Select a notification channel" + } + }, + "title": "Escalation" + }, "type": { "description": "Select the type of check to perform", "optionDockerDescription": "Use Docker to monitor if a container is running.", diff --git a/server/src/db/models/Incident.ts b/server/src/db/models/Incident.ts index 82e2b5eb2b..b16419d05f 100644 --- a/server/src/db/models/Incident.ts +++ b/server/src/db/models/Incident.ts @@ -7,6 +7,7 @@ type IncidentDocumentBase = Omit( type: String, default: null, }, + escalationSentAt: { + type: Date, + default: null, + }, }, { timestamps: true } ); diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..5b984330cd 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -284,6 +284,15 @@ const MonitorSchema = new Schema( ref: "Notification", }, ], + escalationDelayMinutes: { + type: Number, + default: null, + }, + escalationNotificationId: { + type: Schema.Types.ObjectId, + ref: "Notification", + default: null, + }, secret: { type: String, }, diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index c4fcef2ae0..6a632c7a7a 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -7,6 +7,7 @@ export interface IIncidentsRepository { findById(incidentId: string, teamId: string): Promise; findActiveByIncidentId(incidentId: string, teamId: string): Promise; findActiveByMonitorId(monitorId: string, teamId: string): Promise; + findActive(): Promise; findByTeamId( teamId: string, startDate: Date | undefined, diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index 096ba3d37b..e0f48ab015 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -60,6 +60,7 @@ class MongoIncidentRepository implements IIncidentsRepository { resolvedBy: doc.resolvedBy ? this.toStringId(doc.resolvedBy) : null, resolvedByEmail: doc.resolvedByEmail ?? null, comment: doc.comment ?? null, + escalationSentAt: doc.escalationSentAt ? this.toDateString(doc.escalationSentAt) : null, createdAt: this.toDateString(doc.createdAt), updatedAt: this.toDateString(doc.updatedAt), }; @@ -115,6 +116,11 @@ class MongoIncidentRepository implements IIncidentsRepository { return this.toEntity(incident); }; + findActive = async (): Promise => { + const incidents = await IncidentModel.find({ status: true, escalationSentAt: null }); + return this.mapDocuments(incidents); + }; + findByTeamId = async ( teamId: string, startDate: Date | undefined, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..6de4a72d3c 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -293,7 +293,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; removeNotificationFromMonitors = async (notificationId: string): Promise => { - await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); + await MonitorModel.updateMany( + { $or: [{ notifications: notificationId }, { escalationNotificationId: new mongoose.Types.ObjectId(notificationId) }] }, + { + $pull: { notifications: notificationId }, + $unset: { escalationNotificationId: "" }, + } + ); }; updateNotifications = async ( @@ -374,6 +380,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + escalationDelayMinutes: doc.escalationDelayMinutes ?? undefined, + escalationNotificationId: doc.escalationNotificationId ? toStringId(doc.escalationNotificationId) : undefined, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -433,6 +441,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + escalationDelayMinutes: doc.escalationDelayMinutes ?? undefined, + escalationNotificationId: doc.escalationNotificationId ? toStringId(doc.escalationNotificationId) : undefined, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aacc..399a1915f6 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -90,6 +90,7 @@ export class IncidentService implements IIncidentService { status: true, statusCode, message, + escalationSentAt: null, }; return await this.incidentsRepository.create(incident); } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index f4fd5b1e64..a9ed9c45aa 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -91,6 +91,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { this.scheduler.addTemplate("monitor-job", this.helper.getHeartbeatJob()); this.scheduler.addTemplate("geo-check-job", this.helper.getHeartbeatGeoJob()); + this.scheduler.addTemplate("escalation-sweep-job", this.helper.getEscalationSweepJob()); this.scheduler.addTemplate("cleanup-orphaned", this.helper.getCleanupOrphanedJob()); this.scheduler.addTemplate("cleanup-retention-job", this.helper.getCleanupRetentionJob()); const monitors = await this.monitorsRepository.findAll(); @@ -105,6 +106,7 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { } this.scheduler.addJob({ id: "cleanup-orphaned", template: "cleanup-orphaned", active: true }); + this.scheduler.addJob({ id: "escalation-sweep", template: "escalation-sweep-job", active: true, repeat: 60 * 1000 }); this.scheduler.addJob({ id: "cleanup-retention", template: "cleanup-retention-job", active: true, repeat: 24 * 60 * 60 * 1000 }); return true; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..d5675186c2 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -28,6 +28,7 @@ export interface ISuperSimpleQueueHelper { readonly serviceName: string; getHeartbeatJob(): (monitor: Monitor) => Promise; getHeartbeatGeoJob(): (monitor: Monitor) => Promise; + getEscalationSweepJob(): () => Promise; getCleanupOrphanedJob(): () => Promise; getCleanupRetentionJob(): () => Promise; isInMaintenanceWindow(monitorId: string, teamId: string): Promise; @@ -354,6 +355,58 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { }; }; + getEscalationSweepJob = () => { + return async () => { + try { + const activeIncidents = await this.incidentsRepository.findActive(); + if (!activeIncidents.length) { + return; + } + + for (const incident of activeIncidents) { + try { + if (incident.escalationSentAt) { + continue; + } + + const monitor = await this.monitorsRepository.findById(incident.monitorId, incident.teamId); + if (!monitor.escalationNotificationId || !monitor.escalationDelayMinutes) { + continue; + } + + const incidentStartTime = new Date(incident.startTime).getTime(); + const delayInMs = monitor.escalationDelayMinutes * 60 * 1000; + if (Date.now() - incidentStartTime < delayInMs) { + continue; + } + + const sent = await this.notificationsService.sendEscalationNotification(monitor, incident); + if (sent) { + await this.incidentsRepository.updateById(incident.id, incident.teamId, { + escalationSentAt: new Date().toISOString(), + }); + } + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getEscalationSweepJob", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + } catch (error: unknown) { + this.logger.warn({ + message: error instanceof Error ? error.message : "Unknown error", + service: SERVICE_NAME, + method: "getEscalationSweepJob", + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; + } + }; + }; + async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); // Check for active maintenance window: diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 934163b2a9..4c223ed405 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -1,4 +1,4 @@ -import type { HardwareStatusPayload, Monitor, MonitorStatusResponse } from "@/types/index.js"; +import type { HardwareStatusPayload, Incident, Monitor, MonitorStatusResponse } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { NotificationMessage, @@ -15,6 +15,7 @@ export interface INotificationMessageBuilder { decision: MonitorActionDecision, clientHost: string ): NotificationMessage; + buildEscalationMessage(monitor: Monitor, incident: Incident, clientHost: string, delayMinutes: number): NotificationMessage; extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[]; } @@ -52,6 +53,43 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { }; } + buildEscalationMessage(monitor: Monitor, incident: Incident, clientHost: string, delayMinutes: number): NotificationMessage { + return { + type: "escalation", + severity: "critical", + monitor: { + id: monitor.id, + name: monitor.name, + url: monitor.url, + type: monitor.type, + status: monitor.status, + }, + content: { + title: `Escalation: ${monitor.name}`, + summary: `Monitor "${monitor.name}" is still down after ${delayMinutes} minute(s).`, + details: [ + `URL: ${monitor.url}`, + `Status: Down`, + `Type: ${monitor.type}`, + `Incident started: ${new Date(incident.startTime).toISOString()}`, + `Escalation delay: ${delayMinutes} minute(s)`, + `Dashboard: ${clientHost}`, + ], + incident: { + id: incident.id, + url: `${clientHost}/incidents/${incident.id}`, + createdAt: new Date(incident.startTime), + }, + timestamp: new Date(), + }, + clientHost, + metadata: { + teamId: monitor.teamId, + notificationReason: "escalation", + }, + }; + } + private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType { // Down status has highest priority (critical) if (monitor.status === "down") { diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index b3686651cc..e92a6c5459 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -87,6 +87,8 @@ export class EmailProvider implements INotificationProvider { return `Monitor ${message.monitor.name} threshold exceeded`; case "threshold_resolved": return `Monitor ${message.monitor.name} thresholds resolved`; + case "escalation": + return `Escalation: Monitor ${message.monitor.name} is still down`; default: return `Alert: ${message.monitor.name}`; } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index c75477c88c..51ec7007a0 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,4 +1,4 @@ -import type { Monitor, MonitorStatusResponse, Notification } from "@/types/index.js"; +import type { Incident, Monitor, MonitorStatusResponse, Notification } from "@/types/index.js"; import type { NotificationMessage } from "@/types/notificationMessage.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; @@ -14,6 +14,7 @@ export interface INotificationsService { updateById(id: string, teamId: string, updateData: Partial): Promise; deleteById: (id: string, teamId: string) => Promise; handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise; + sendEscalationNotification: (monitor: Monitor, incident: Incident) => Promise; sendTestNotification: (notification: Partial) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; @@ -65,13 +66,7 @@ export class NotificationsService implements INotificationsService { this.notificationMessageBuilder = notificationMessageBuilder; } - private send = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision, - notificationMessage: NotificationMessage | undefined - ): Promise => { + private send = async (notification: Notification, notificationMessage: NotificationMessage | undefined): Promise => { if (!notificationMessage) { this.logger.warn({ message: "Notification message not provided", @@ -116,7 +111,7 @@ export class NotificationsService implements INotificationsService { const clientHost = settings.clientHost || "Host not defined"; const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); - const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage)); + const tasks = notifications.map((notification) => this.send(notification, notificationMessage)); const outcomes = await Promise.all(tasks); const succeeded = outcomes.filter(Boolean).length; @@ -141,6 +136,18 @@ export class NotificationsService implements INotificationsService { return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; + sendEscalationNotification = async (monitor: Monitor, incident: Incident) => { + if (!monitor.escalationNotificationId || !monitor.escalationDelayMinutes) { + return false; + } + + const notification = await this.notificationsRepository.findById(monitor.escalationNotificationId, monitor.teamId); + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + const notificationMessage = this.notificationMessageBuilder.buildEscalationMessage(monitor, incident, clientHost, monitor.escalationDelayMinutes); + return await this.send(notification, notificationMessage); + }; + sendTestNotification = async (notification: Partial) => { switch (notification.type) { case "email": diff --git a/server/src/types/incident.ts b/server/src/types/incident.ts index 6b076ff835..92cbf38de0 100644 --- a/server/src/types/incident.ts +++ b/server/src/types/incident.ts @@ -16,6 +16,7 @@ export interface Incident { resolvedBy?: string | null; resolvedByEmail?: string | null; comment?: string | null; + escalationSentAt?: string | null; createdAt: string; updatedAt: string; } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..d2d3a78247 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -37,6 +37,8 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + escalationDelayMinutes?: number; + escalationNotificationId?: string; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/types/notificationMessage.ts b/server/src/types/notificationMessage.ts index f06ff1bd9a..7408e01c4b 100644 --- a/server/src/types/notificationMessage.ts +++ b/server/src/types/notificationMessage.ts @@ -3,7 +3,7 @@ * Part of notification system unification effort */ -export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test"; +export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "escalation" | "test"; export type NotificationSeverity = "critical" | "warning" | "info" | "success"; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef2..e7b338599c 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -67,6 +67,8 @@ export const createMonitorBodyValidation = z.object({ diskAlertThreshold: z.number().optional(), tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), + escalationDelayMinutes: z.number().int().min(1).optional(), + escalationNotificationId: z.string().optional(), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -78,6 +80,17 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), +}).superRefine((data, ctx) => { + const hasDelay = data.escalationDelayMinutes !== undefined && data.escalationDelayMinutes !== null; + const hasChannel = Boolean(data.escalationNotificationId); + + if (hasDelay && !hasChannel) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Escalation notification channel is required", path: ["escalationNotificationId"] }); + } + + if (hasChannel && !hasDelay) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Escalation delay must be at least 1 minute", path: ["escalationDelayMinutes"] }); + } }); export const editMonitorBodyValidation = z.object({ @@ -89,6 +102,8 @@ export const editMonitorBodyValidation = z.object({ description: z.union([z.string(), z.literal("")]).optional(), interval: z.number().optional(), notifications: z.array(z.string()).optional(), + escalationDelayMinutes: z.number().int().min(1).optional(), + escalationNotificationId: z.string().optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -107,6 +122,17 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), +}).superRefine((data, ctx) => { + const hasDelay = data.escalationDelayMinutes !== undefined && data.escalationDelayMinutes !== null; + const hasChannel = Boolean(data.escalationNotificationId); + + if (hasDelay && !hasChannel) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Escalation notification channel is required", path: ["escalationNotificationId"] }); + } + + if (hasChannel && !hasDelay) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Escalation delay must be at least 1 minute", path: ["escalationDelayMinutes"] }); + } }); export const pauseMonitorParamValidation = z.object({ @@ -144,6 +170,8 @@ const importedMonitorSchema = z.object({ interval: z.number().default(60000), uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), + escalationDelayMinutes: z.number().int().min(1).optional(), + escalationNotificationId: z.string().optional(), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5),