From b37d2d91d4babc5aad4fb7e310c4190f10f589d7 Mon Sep 17 00:00:00 2001 From: Ian Maksimovic Date: Wed, 8 Apr 2026 23:48:18 -0400 Subject: [PATCH] notification escalation --- client/src/Hooks/useMonitorForm.ts | 21 ++- client/src/Pages/CreateMonitor/index.tsx | 167 +++++++++++------- client/src/Types/Monitor.ts | 7 +- client/src/Validation/monitor.ts | 7 +- client/src/locales/en.json | 8 +- server/src/config/services.ts | 1 + .../src/controllers/notificationController.ts | 2 +- server/src/db/models/Incident.ts | 29 +++ server/src/db/models/Monitor.ts | 16 +- .../incidents/MongoIncidentRepository.ts | 5 + .../monitors/MongoMonitorsRepository.ts | 48 ++++- .../src/service/business/incidentService.ts | 2 +- .../SuperSimpleQueueHelper.ts | 14 +- .../infrastructure/notificationsService.ts | 96 ++++++++-- server/src/types/incident.ts | 6 + server/src/types/monitor.ts | 7 +- server/src/validation/incidentValidation.ts | 5 + server/src/validation/monitorValidation.ts | 15 +- 18 files changed, 353 insertions(+), 103 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..7015c7da81 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -7,11 +7,30 @@ interface UseMonitorFormOptions { defaultType?: MonitorType; } +const normalizeNotifications = (notifications?: unknown[]) => { + if (!Array.isArray(notifications)) { + return []; + } + return notifications.map((notification) => { + if (typeof notification === "string") { + return { channelId: notification, delayMinutes: 0 }; + } + const item = notification as { + channelId?: unknown; + delayMinutes?: unknown; + }; + return { + channelId: typeof item.channelId === "string" ? item.channelId : "", + delayMinutes: typeof item.delayMinutes === "number" ? item.delayMinutes : 0, + }; + }); +}; + const getBaseDefaults = (data?: Monitor | null) => ({ name: data?.name || "", description: data?.description || "", interval: data?.interval || 60000, - notifications: data?.notifications || [], + notifications: normalizeNotifications(data?.notifications), 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..10492e9535 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -705,71 +705,108 @@ 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) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - - {(watchedType === "http" || - watchedType === "grpc" || - watchedType === "websocket") && ( - ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((notification) => + (field.value ?? []).some( + (item: { channelId: string; delayMinutes: number }) => + item.channelId === notification.id + ) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof notificationOptions) => { + const nextValues = newValue.map((notification) => { + const existing = (field.value ?? []).find( + (item: { channelId: string; delayMinutes: number }) => + item.channelId === notification.id + ); + return { + channelId: notification.id, + delayMinutes: existing?.delayMinutes ?? 0, + }; + }); + field.onChange(nextValues); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, index) => { + const selectedRule = (field.value ?? []).find( + (item: { channelId: string; delayMinutes: number }) => + item.channelId === notification.id + ); + const delayMinutes = selectedRule?.delayMinutes ?? 0; + return ( + + + {notification.notificationName} + + { + const value = Number(event.target.value) || 0; + field.onChange( + (field.value ?? []).map( + (item: { channelId: string; delayMinutes: number }) => + item.channelId === notification.id + ? { ...item, delayMinutes: value } + : item + ) + ); + }} + /> + { + field.onChange( + (field.value ?? []).filter( + (item: { channelId: string }) => + item.channelId !== notification.id + ) + ); + }} + aria-label="Remove notification" + > + + + {index < selectedNotifications.length - 1 && } + + ); + })} + + )} + + ); + }} + /> + } + /> + {(watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + notification.channelId); if (notifications.length === 0) { throw new AppError({ message: "No notifications", status: 400 }); diff --git a/server/src/db/models/Incident.ts b/server/src/db/models/Incident.ts index 82e2b5eb2b..ca28e7568e 100644 --- a/server/src/db/models/Incident.ts +++ b/server/src/db/models/Incident.ts @@ -9,6 +9,11 @@ type IncidentDocumentBase = Omit( type: String, default: null, }, + escalatedNotificationIds: { + type: [String], + default: [], + }, + acknowledged: { + type: Boolean, + default: false, + index: true, + }, + acknowledgedBy: { + type: Schema.Types.ObjectId, + ref: "User", + default: null, + }, + acknowledgedAt: { + type: Date, + default: null, + }, + acknowledgedByEmail: { + type: String, + default: null, + }, }, { timestamps: true } ); @@ -83,6 +110,8 @@ IncidentSchema.index({ status: 1, startTime: -1 }); IncidentSchema.index({ resolutionType: 1, status: 1 }); IncidentSchema.index({ resolvedBy: 1, status: 1 }); IncidentSchema.index({ createdAt: -1 }); +IncidentSchema.index({ acknowledged: 1, status: 1 }); +IncidentSchema.index({ acknowledgedBy: 1, status: 1 }); const IncidentModel = model("Incident", IncidentSchema); diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..3d383e4b2e 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,5 +1,5 @@ import { Schema, model, Types } from "mongoose"; -import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; +import type { Monitor, MonitorMatchMethod, CheckSnapshot, EscalationNotification } from "@/types/monitor.js"; import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; import type { CheckAudits, @@ -22,7 +22,7 @@ type MonitorDocumentBase = Omit< > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; - notifications: Types.ObjectId[]; + notifications: EscalationNotification[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; }; @@ -280,8 +280,16 @@ const MonitorSchema = new Schema( }, notifications: [ { - type: Schema.Types.ObjectId, - ref: "Notification", + delayMinutes: { + type: Number, + required: true, + min: 0, + }, + channelId: { + type: Schema.Types.ObjectId, + ref: "Notification", + required: true, + }, }, ], secret: { diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index 096ba3d37b..e56e5ca082 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -60,6 +60,11 @@ class MongoIncidentRepository implements IIncidentsRepository { resolvedBy: doc.resolvedBy ? this.toStringId(doc.resolvedBy) : null, resolvedByEmail: doc.resolvedByEmail ?? null, comment: doc.comment ?? null, + acknowledged: doc.acknowledged, + acknowledgedBy: doc.acknowledgedBy ? this.toStringId(doc.acknowledgedBy) : null, + acknowledgedAt: doc.acknowledgedAt ? this.toDateString(doc.acknowledgedAt) : null, + acknowledgedByEmail: doc.acknowledgedByEmail ?? null, + escalatedNotificationIds: doc.escalatedNotificationIds ?? [], createdAt: this.toDateString(doc.createdAt), updatedAt: this.toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..213d3ffa6a 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -17,7 +17,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { if (!monitors.length) { return []; } - const payload = monitors.map((monitor) => ({ ...monitor, notifications: undefined })); + const payload = monitors.map((monitor) => ({ ...monitor })); try { const inserted = await MonitorModel.insertMany(payload, { ordered: false }); return this.mapDocuments(inserted); @@ -293,7 +293,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; removeNotificationFromMonitors = async (notificationId: string): Promise => { - await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); + await MonitorModel.updateMany({ "notifications.channelId": notificationId }, { $pull: { notifications: { channelId: notificationId } } }); }; updateNotifications = async ( @@ -315,13 +315,19 @@ class MongoMonitorsRepository implements IMonitorsRepository { let update; switch (action) { case "add": - update = { $addToSet: { notifications: { $each: notificationObjectIds } } }; + update = { + $addToSet: { + notifications: { + $each: notificationObjectIds.map((id) => ({ channelId: id, delayMinutes: 0 })), + }, + }, + }; break; case "remove": - update = { $pull: { notifications: { $in: notificationObjectIds } } }; + update = { $pull: { notifications: { channelId: { $in: notificationObjectIds } } } }; break; case "set": - update = { $set: { notifications: notificationObjectIds } }; + update = { $set: { notifications: notificationObjectIds.map((id) => ({ channelId: id, delayMinutes: 0 })) } }; break; default: throw new AppError({ message: `Invalid action: ${action}`, status: 400 }); @@ -350,7 +356,19 @@ class MongoMonitorsRepository implements IMonitorsRepository { return value instanceof Date ? value.toISOString() : value; }; - const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification)); + const notificationObjects = (doc.notifications ?? []).map((notification: unknown) => { + if (notification && typeof notification === "object" && "channelId" in notification) { + const item = notification as { channelId?: unknown; delayMinutes?: unknown }; + return { + channelId: toStringId(item.channelId), + delayMinutes: typeof item.delayMinutes === "number" ? item.delayMinutes : 0, + }; + } + return { + channelId: toStringId(notification), + delayMinutes: 0, + }; + }); return { id: toStringId(doc._id), @@ -373,7 +391,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { isActive: doc.isActive, interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, - notifications: notificationIds, + notifications: notificationObjects, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -409,7 +427,19 @@ class MongoMonitorsRepository implements IMonitorsRepository { return value instanceof Date ? value.toISOString() : value; }; - const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); + const notificationObjects = (doc.notifications ?? []).map((notification: unknown) => { + if (notification && typeof notification === "object" && "channelId" in notification) { + const item = notification as { channelId?: unknown; delayMinutes?: unknown }; + return { + channelId: toStringId(item.channelId), + delayMinutes: typeof item.delayMinutes === "number" ? item.delayMinutes : 0, + }; + } + return { + channelId: toStringId(notification), + delayMinutes: 0, + }; + }); return { id: toStringId(doc._id), @@ -432,7 +462,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { isActive: doc.isActive, interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, - notifications: notificationIds, + notifications: notificationObjects, 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..61a77a09ad 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -105,7 +105,7 @@ export class IncidentService implements IIncidentService { return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); } - return null; + return activeIncident; }; private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..4f4b938d77 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -169,14 +169,26 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { } // Step 7. Handle incidents (best effort, don't wait) - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => { + const activeIncident = await this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision, status).catch((error: unknown) => { this.logger.warn({ message: `Error handling incident for job ${monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, service: SERVICE_NAME, method: "getMonitorJob", stack: error instanceof Error ? error.stack : undefined, }); + return null; }); + + if (activeIncident && (statusChangeResult.monitor.status === "down" || statusChangeResult.monitor.status === "breached")) { + this.notificationsService.handleEscalationNotifications(statusChangeResult.monitor, status, activeIncident).catch((error: unknown) => { + this.logger.error({ + message: `Error sending escalation notifications for job ${statusChangeResult.monitor.id}: ${error instanceof Error ? error.message : "Unknown error"}`, + service: SERVICE_NAME, + method: "getMonitorJob", + stack: error instanceof Error ? error.stack : undefined, + }); + }); + } } catch (error: unknown) { this.logger.warn({ message: error instanceof Error ? error.message : "Unknown error", diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index c75477c88c..9938443453 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,6 +1,6 @@ -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 { IIncidentsRepository, IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { ISettingsService } from "@/service/system/settingsService.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; + handleEscalationNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, incident: Incident) => Promise; sendTestNotification: (notification: Partial) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; @@ -25,6 +26,7 @@ export class NotificationsService implements INotificationsService { static SERVICE_NAME = SERVICE_NAME; private notificationsRepository: INotificationsRepository; + private incidentsRepository: IIncidentsRepository; private monitorsRepository: IMonitorsRepository; private webhookProvider: INotificationProvider; private emailProvider: INotificationProvider; @@ -40,6 +42,7 @@ export class NotificationsService implements INotificationsService { constructor( notificationsRepository: INotificationsRepository, monitorsRepository: IMonitorsRepository, + incidentsRepository: IIncidentsRepository, webhookProvider: INotificationProvider, emailProvider: INotificationProvider, slackProvider: INotificationProvider, @@ -51,6 +54,7 @@ export class NotificationsService implements INotificationsService { logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder ) { + this.incidentsRepository = incidentsRepository; this.notificationsRepository = notificationsRepository; this.monitorsRepository = monitorsRepository; this.webhookProvider = webhookProvider; @@ -107,8 +111,17 @@ export class NotificationsService implements INotificationsService { } }; - private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { - const notificationIds = monitor.notifications ?? []; + private sendNotificationObjects = async ( + notificationObjects: Array<{ delayMinutes: number; channelId: string }>, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => { + if (!notificationObjects.length) { + return [] as string[]; + } + + const notificationIds = notificationObjects.map((notification) => notification.channelId); const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); // Build notification message once for all notifications @@ -116,20 +129,31 @@ 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 outcomes = await Promise.all( + notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage)) + ); - const outcomes = await Promise.all(tasks); - const succeeded = outcomes.filter(Boolean).length; - const failed = outcomes.length - succeeded; - if (failed > 0) { + const sentNotificationIds = notifications + .filter((_, index) => outcomes[index]) + .map((notification) => notification.id); + + const succeededCount = sentNotificationIds.length; + const failedCount = notifications.length - succeededCount; + if (failedCount > 0) { this.logger.warn({ - message: `Notification send completed with ${succeeded} success, ${failed} failure(s)`, + message: `Notification send completed with ${succeededCount} success, ${failedCount} failure(s)`, service: SERVICE_NAME, - method: "sendNotifications", + method: "sendNotificationObjects", }); } - // Return true if all notifications succeeded - return succeeded === notifications.length; + + return sentNotificationIds; + }; + + private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + const immediateNotifications = (monitor.notifications ?? []).filter((notification) => notification.delayMinutes <= 0); + const sentChannelIds = await this.sendNotificationObjects(immediateNotifications, monitor, monitorStatusResponse, decision); + return sentChannelIds.length > 0; }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { @@ -137,10 +161,54 @@ export class NotificationsService implements INotificationsService { return false; } - // Send notifications based on decision + // Send only immediate notifications on status change. return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; + handleEscalationNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, incident: Incident) => { + if (!incident || !incident.status || incident.acknowledged) { + return false; + } + + const incidentStart = new Date(incident.startTime).getTime(); + if (Number.isNaN(incidentStart)) { + return false; + } + + const dueNotifications = (monitor.notifications ?? []).filter((notification) => { + if (notification.delayMinutes <= 0) { + return false; + } + if (incident.escalatedNotificationIds?.includes(notification.channelId)) { + return false; + } + return Date.now() - incidentStart >= notification.delayMinutes * 60 * 1000; + }); + + if (!dueNotifications.length) { + return false; + } + + const escalationDecision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: monitor.status === "breached" ? "threshold_breach" : "status_down", + notificationReason: "status_change", + }; + + const sentChannelIds = await this.sendNotificationObjects(dueNotifications, monitor, monitorStatusResponse, escalationDecision); + + if (!sentChannelIds.length) { + return false; + } + + const updatedEscalatedChannelIds = Array.from(new Set([...(incident.escalatedNotificationIds ?? []), ...sentChannelIds])); + await this.incidentsRepository.updateById(incident.id, incident.teamId, { escalatedNotificationIds: updatedEscalatedChannelIds }); + + return true; + }; + 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..c0c068591d 100644 --- a/server/src/types/incident.ts +++ b/server/src/types/incident.ts @@ -1,4 +1,5 @@ // export type IncidentResolutionType = "automatic" | "manual" | null; +import type { Types } from "mongoose"; export const IncidentResolutionTypes = ["automatic", "manual", null] as const; export type IncidentResolutionType = (typeof IncidentResolutionTypes)[number]; @@ -18,6 +19,11 @@ export interface Incident { comment?: string | null; createdAt: string; updatedAt: string; + acknowledged: boolean; + acknowledgedBy?: string | null; + acknowledgedAt: string | null; + acknowledgedByEmail?: string | null; + escalatedNotificationIds: string[]; } export interface IncidentSummaryTopMonitor { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..f5e81e9908 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -15,6 +15,11 @@ export type MonitorStatus = (typeof MonitorStatuses)[number]; export const MonitorMatchMethods = ["equal", "include", "regex"] as const; export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export interface EscalationNotification { + delayMinutes: number; + channelId: string; +} + export interface Monitor { id: string; userId: string; @@ -36,7 +41,7 @@ export interface Monitor { isActive: boolean; interval: number; uptimePercentage?: number; - notifications: string[]; + notifications: EscalationNotification[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/validation/incidentValidation.ts b/server/src/validation/incidentValidation.ts index acbc857dc0..855fd9bd21 100644 --- a/server/src/validation/incidentValidation.ts +++ b/server/src/validation/incidentValidation.ts @@ -13,8 +13,13 @@ export const getIncidentsByTeamQueryValidation = z.object({ status: booleanCoercion.optional(), monitorId: z.string().optional(), resolutionType: z.enum(["manual", "automatic"]).optional(), + acknowledged: booleanCoercion.optional(), }); export const getIncidentSummaryQueryValidation = z.object({ limit: z.coerce.number().int().min(1).optional(), }); + +export const acknowledgeIncidentValidation = z.object({ + comment: z.string().max(500).optional(), +}); diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef2..0cf6ba8b4e 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -66,7 +66,10 @@ export const createMonitorBodyValidation = z.object({ memoryAlertThreshold: z.number().optional(), diskAlertThreshold: z.number().optional(), tempAlertThreshold: z.number().optional(), - notifications: z.array(z.string()).optional(), + notifications: z.array(z.object({ + delayMinutes: z.number().min(0), + channelId: z.string().min(1), + })).optional(), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -88,7 +91,10 @@ export const editMonitorBodyValidation = z.object({ statusWindowThreshold: z.number().min(1).max(100).default(60), description: z.union([z.string(), z.literal("")]).optional(), interval: z.number().optional(), - notifications: z.array(z.string()).optional(), + notifications: z.array(z.object({ + delayMinutes: z.number().min(0), + channelId: z.string().min(1), + })).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -143,7 +149,10 @@ const importedMonitorSchema = z.object({ isActive: z.boolean().default(true), interval: z.number().default(60000), uptimePercentage: z.number().optional(), - notifications: z.array(z.string()).default([]), + notifications: z.array(z.object({ + delayMinutes: z.number().min(0), + channelId: z.string().min(1), + })).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5),