diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..cc67267750 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -217,6 +217,35 @@ const CreateMonitorPage = () => { clearErrors(); }, [watchedType, clearErrors]); + useEffect(() => { + if (!existingMonitor) return; + + useEffect(() => { + if (!existingMonitor) return; + + const steps = (existingMonitor as any).escalations; + + if (Array.isArray(steps) && steps.length > 0) { + setEscalationSteps( + steps.map((s: any, i: number) => ({ + id: `init_${i}`, + delayMinutes: s.delay ?? 0, + email: s.email ?? "", + })) + ); + } else { + setEscalationSteps([ + { id: `step_${Date.now()}`, delayMinutes: 5, email: "" } + ]); + } + }, [existingMonitor]); + + if (escalation) { + setEscalationDelay(escalation.delay ?? 0); + setEscalationNotifications(escalation.notificationIds ?? []); + } + }, [existingMonitor]); + const generalSettingsConfig = useMemo( () => getGeneralSettingsConfig(watchedType, t), [watchedType, t] @@ -229,6 +258,10 @@ const CreateMonitorPage = () => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const { deleteFn, loading: isDeleting } = useDelete(); + const [escalationDelay, setEscalationDelay] = useState(0); + + const [escalationNotifications, setEscalationNotifications] = useState([]); + const handleDeleteClick = () => { setIsDeleteDialogOpen(true); }; @@ -252,11 +285,19 @@ const CreateMonitorPage = () => { }; const onSubmit = async (data: MonitorFormData) => { + const payload = { + ...data, + escalation: { + delay: escalationDelay, + notificationIds: escalationNotifications + } + }; + let result; if (isEditMode && monitorId) { - result = await patch(`/monitors/${monitorId}`, data); + result = await patch(`/monitors/${monitorId}`, payload); } else { - result = await post("/monitors", data); + result = await post("/monitors", payload); } if (result?.success) { @@ -731,6 +772,7 @@ const CreateMonitorPage = () => { width="100%" > {selectedNotifications.map((notification, index) => ( + { /> } /> + {/* Escalation Settings */} + + + + + { + const val = Number(e.target.value); + setEscalationDelay(isNaN(val) ? 0 : val); + }} + sx={{ flex: 1 }} + /> + + ({ + ...n, + name: n.notificationName, + }))} + value={(notifications ?? []) + .filter((n) => escalationNotifications.includes(n.id)) + .map((n) => ({ + ...n, + name: n.notificationName, + }))} + getOptionLabel={(option) => option.name} + onChange={(_, newValue) => { + setEscalationNotifications(newValue.map((n) => n.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + sx={{ flex: 2 }} + renderInput={(params) => ( + + )} + /> + + + + + } + /> {(watchedType === "http" || watchedType === "grpc" || @@ -776,6 +870,7 @@ const CreateMonitorPage = () => { name="ignoreTlsErrors" control={control} render={({ field }) => ( + { } /> )} - + =6.0.0" + } } } } diff --git a/package.json b/package.json index f5368f28c8..b55d83285c 100644 --- a/package.json +++ b/package.json @@ -24,5 +24,8 @@ "homepage": "https://github.com/bluewave-labs/Checkmate#readme", "devDependencies": { "husky": "^9.1.7" + }, + "dependencies": { + "nodemailer": "^8.0.5" } } diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 7f1136b7c1..e84d175d49 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -201,6 +201,12 @@ class MonitorController implements IMonitorController { createMonitor = async (req: Request, res: Response, next: NextFunction) => { try { + if (req.body.escalations) { + req.body.escalations = req.body.escalations.map((e: any) => ({ + delay: Number(e.delay), + email: String(e.email), + })); + } const validatedBody = createMonitorBodyValidation.parse(req.body); const userId = requireUserId(req.user?.id); diff --git a/server/src/service/emailService.ts b/server/src/service/emailService.ts new file mode 100644 index 0000000000..2ae20051f6 --- /dev/null +++ b/server/src/service/emailService.ts @@ -0,0 +1,24 @@ +import nodemailer from "nodemailer"; + +export class EmailService implements IEmailService { + private transporter = nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + async sendEmail(email: string, subject: string, body: string) { + await this.transporter.sendMail({ + from: process.env.SMTP_USER, + to: email, + subject, + html: body, + }); + + console.log("✅ Email sent to:", email); + } +} \ No newline at end of file diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..d37b171eab 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -109,6 +109,7 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { getHeartbeatJob = () => { return async (monitor: Monitor) => { + try { const monitorId = monitor.id; const teamId = monitor.teamId; diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ef725b80af..16b0ae4753 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -20,7 +20,10 @@ import type { import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "./bufferService.js"; +import { scheduleEscalationNotifications } from "@/utils/escalationScheduler"; +import { IEmailService } from "@/service/emailService"; const SERVICE_NAME = "StatusService"; +const activeEscalations = new Map>(); export interface IStatusService { updateRunningStats(monitor: Monitor, networkResponse: MonitorStatusResponse): Promise; @@ -47,19 +50,22 @@ export class StatusService implements IStatusService { private monitorsRepository: IMonitorsRepository; private monitorStatsRepository: IMonitorStatsRepository; private checksRepository: IChecksRepository; + private emailService: IEmailService; constructor( logger: ILogger, buffer: IBufferService, monitorsRepository: IMonitorsRepository, monitorStatsRepository: IMonitorStatsRepository, - checksRepository: IChecksRepository + checksRepository: IChecksRepository, + emailService: IEmailService ) { this.logger = logger; this.buffer = buffer; this.monitorsRepository = monitorsRepository; this.monitorStatsRepository = monitorStatsRepository; this.checksRepository = checksRepository; + this.emailService = emailService; } get serviceName() { @@ -236,13 +242,14 @@ export class StatusService implements IStatusService { let newStatus: MonitorStatus = status === true ? "up" : "down"; let statusChanged = false; - // Return early if not enough data points if (monitor.statusWindow.length < monitor.statusWindowSize) { monitor.status = newStatus; + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); + return { monitor: updated, - statusChanged: false, + statusChanged: prevStatus !== newStatus, prevStatus, code, timestamp: Date.now(), @@ -345,8 +352,38 @@ export class StatusService implements IStatusService { } } - // Apply the final status - monitor.status = newStatus; + if (prevStatus !== "down" && newStatus === "down") { + activeEscalations.get(monitor.id)?.cancel(); + + const handle = scheduleEscalationNotifications( + monitor.id, + monitor.escalations || [], + async (id) => { + const latest = await this.monitorsRepository.findById(id, monitor.teamId); + return latest?.status === "down"; + }, + async (email, message) => { + await this.emailService.sendEmail( + email, + `${monitor.name} still down`, + ` +

${monitor.name} is STILL DOWN

+

The issue has not been resolved.

+

This is an escalation notification.

+
+ ${message} + ` + ); + } + ); + + activeEscalations.set(monitor.id, handle); + } + if(prevStatus === "down" && newStatus !== "down") { + activeEscalations.get(monitor.id)?.cancel(); + activeEscalations.delete(monitor.id); + } + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); diff --git a/server/src/utils/escalationScheduler.ts b/server/src/utils/escalationScheduler.ts new file mode 100644 index 0000000000..15196365cc --- /dev/null +++ b/server/src/utils/escalationScheduler.ts @@ -0,0 +1,40 @@ +export type EscalationStep = { + delayMinutes: number; + email: string; +}; + +export function scheduleEscalationNotifications( + monitorId: string, + monitorName: string, + steps: EscalationStep[], + isMonitorDown: (monitorId: string) => Promise | boolean, + sendEmail: (email: string, subject: string, body: string) => Promise | void +) { + const timers: NodeJS.Timeout[] = []; + + for (const step of steps) { + const delayMs = step.delayMinutes * 60_000; + + const timer = setTimeout(async () => { + const stillDown = await Promise.resolve(isMonitorDown(monitorId)); + if (!stillDown) return; + + const subject = `[Checkmate] Monitor "${monitorName}" still down`; + const body = ` +Monitor: ${monitorName} +Status: Still down +Time elapsed: ${step.delayMinutes} minute(s) + +Please investigate the issue. +`; + + await Promise.resolve(sendEmail(step.email, subject, body)); + }, delayMs); + + timers.push(timer); + } + + return { + cancel: () => timers.forEach(clearTimeout), + }; +} \ No newline at end of file