From 4f95a6ee6a9fb9488ad3e8264b03001b31a89119 Mon Sep 17 00:00:00 2001 From: annagracehaukoos Date: Sat, 11 Apr 2026 23:09:00 -0400 Subject: [PATCH 1/2] completed the assignment coding requirements --- client/src/Hooks/useMonitorForm.ts | 1 + client/src/Pages/Auth/Register/index.tsx | 10 ++- client/src/Pages/CreateMonitor/index.tsx | 86 +++++++++++++++++++ client/src/Types/Monitor.ts | 1 + client/src/Validation/monitor.ts | 6 ++ client/src/locales/en.json | 3 + server/.enves | 27 ++++++ server/src/db/models/Monitor.ts | 17 ++++ .../monitors/MongoMonitorsRepository.ts | 4 + .../SuperSimpleQueueHelper.ts | 13 +++ .../notificationMessageBuilder.ts | 36 ++++++++ .../infrastructure/notificationsService.ts | 4 +- .../service/infrastructure/statusService.ts | 23 +++-- server/src/types/monitor.ts | 3 + server/src/types/notificationMessage.ts | 2 +- server/src/validation/monitorValidation.ts | 8 ++ 16 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 server/.enves diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..963c1608b3 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -14,6 +14,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({ notifications: data?.notifications || [], statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, + escalationDelay: data?.escalationDelay || 0, geoCheckEnabled: data?.geoCheckEnabled ?? false, geoCheckLocations: data?.geoCheckLocations || [], geoCheckInterval: data?.geoCheckInterval || 300000, diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index a2a4390235..0ee68ef274 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -73,7 +73,15 @@ const RegisterPage = () => { if (token) { payload.token = token; } - const result = await post("/auth/register", payload); + const response = await fetch("http://localhost:52345/api/v1/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), +}); + +const result = await response.json(); if (result?.success) { dispatch(setAuthState(result)); diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..5c4ff30dec 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -765,6 +765,92 @@ const CreateMonitorPage = () => { } /> + + ( + field.onChange(Number(e.target.value))} + type="number" + fieldLabel="Escalate after (minutes)" + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + Escalation notification channels + { + 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") && ( diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 053b517d1d..33cef73b52 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -48,6 +48,7 @@ export interface Monitor { statusWindow: boolean[]; statusWindowSize: number; statusWindowThreshold: number; + escalationDelay?: number; type: MonitorType; ignoreTlsErrors: boolean; useAdvancedMatching: boolean; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fed..f2c50563e2 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -21,6 +21,12 @@ 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"), + escalationDelay: z + .number() + .min(0, "Escalation delay must be at least 0") + .max(60, "Escalation delay must be at most 60 minutes") + .optional(), + escalationNotifications: z.array(z.string()).default([]), geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 92a21939f3..bbd692b9e5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -535,6 +535,9 @@ }, "percentage": { "label": "What percentage of checks in the sliding window fail/succeed before monitor status changes?" + }, + "escalationDelay": { + "label": "Escalation delay (minutes)" } }, "title": "Incidents" diff --git a/server/.enves b/server/.enves new file mode 100644 index 0000000000..b3adc56617 --- /dev/null +++ b/server/.enves @@ -0,0 +1,27 @@ +# ============================================== +# Checkmate Server Configuration +# ============================================== +# Copy this file to .env and fill in your values + +# Server Configuration +# -------------------- +NODE_ENV=development +LOG_LEVEL=debug + +# Database +# -------- +DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db + +# JWT Authentication +# ------------------ +JWT_SECRET=my_secret +TOKEN_TTL=99d +CLIENT_HOST=http://localhost:5173 + +# Client Configuration +# -------------------- +CLIENT_HOST=http://localhost:5173 + +# Optional Configuration +# ---------------------- +ORIGIN=localhost diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..eb1d0eb654 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -23,8 +23,11 @@ type MonitorDocumentBase = Omit< statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; notifications: Types.ObjectId[]; + escalationNotifications: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; + escalationDelay?: number; + _escalationStart?: number; }; interface MonitorDocument extends MonitorDocumentBase { @@ -274,6 +277,20 @@ const MonitorSchema = new Schema( type: Number, default: 60000, }, + escalationDelay: { + type: Number, + default: 0, + }, + escalationNotifications: [ + { + type: Schema.Types.ObjectId, + ref: "Notification", + }, + ], + _escalationStart: { + type: Number, + default: undefined, + }, uptimePercentage: { type: Number, default: undefined, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..e155c4793b 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -351,6 +351,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification)); + const escalationNotificationIds = (doc.escalationNotifications ?? []).map((notification) => toStringId(notification)); return { id: toStringId(doc._id), @@ -374,6 +375,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + escalationNotifications: escalationNotificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -410,6 +412,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); + const escalationNotificationIds = (doc.escalationNotifications ?? []).map((notification: unknown) => toStringId(notification)); return { id: toStringId(doc._id), @@ -433,6 +436,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + escalationNotifications: escalationNotificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..55ce6d8294 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -39,6 +39,7 @@ export interface MonitorActionDecision { shouldSendNotification: boolean; incidentReason: "status_down" | "threshold_breach" | null; notificationReason: "status_change" | "threshold_breach" | null; + isEscalation?: boolean; thresholdBreaches?: { cpu?: boolean; memory?: boolean; @@ -428,9 +429,19 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { shouldSendNotification: false, incidentReason: null, notificationReason: null, + isEscalation: false, }; if (!statusChanged) { + // Check if monitor is still down and escalation delay has been met + if (monitor.status === "down" && monitor._escalationStart && monitor.escalationDelay) { + const elapsed = Date.now() - monitor._escalationStart; + if (elapsed >= monitor.escalationDelay) { + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + decision.isEscalation = true; + } + } return decision; } @@ -451,6 +462,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { decision.shouldResolveIncident = true; decision.shouldSendNotification = true; decision.notificationReason = "status_change"; + // Reset escalation when monitor recovers + monitor._escalationStart = undefined; } return decision; diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts index 934163b2a9..3efbf0ba54 100644 --- a/server/src/service/infrastructure/notificationMessageBuilder.ts +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -53,6 +53,11 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { } private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType { + // Check for escalation first + if (decision.isEscalation && monitor.status === "down") { + return "escalation"; + } + // Down status has highest priority (critical) if (monitor.status === "down") { return "monitor_down"; @@ -80,6 +85,7 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { private determineSeverity(type: NotificationType): NotificationSeverity { switch (type) { case "monitor_down": + case "escalation": return "critical"; case "threshold_breach": return "warning"; @@ -97,6 +103,8 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { switch (type) { case "monitor_down": return this.buildMonitorDownContent(monitor, monitorStatusResponse); + case "escalation": + return this.buildEscalationContent(monitor, monitorStatusResponse); case "monitor_up": return this.buildMonitorUpContent(monitor); case "threshold_breach": @@ -131,6 +139,34 @@ export class NotificationMessageBuilder implements INotificationMessageBuilder { }; } + private buildEscalationContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): NotificationContent { + const title = `ESCALATION: Monitor Still Down: ${monitor.name}`; + const summary = `Monitor "${monitor.name}" continues to be down. Escalation alert triggered.`; + const details = [ + `URL: ${monitor.url}`, + `Status: Down (Escalation Alert)`, + `Type: ${monitor.type}`, + `Escalation Delay: ${monitor.escalationDelay}ms`, + ]; + + // Add response code if available + if (monitorStatusResponse.code) { + details.push(`Response Code: ${monitorStatusResponse.code}`); + } + + // Add error message if available + if (monitorStatusResponse.message) { + details.push(`Error: ${monitorStatusResponse.message}`); + } + + return { + title, + summary, + details, + timestamp: new Date(), + }; + } + private buildMonitorUpContent(monitor: Monitor): NotificationContent { const title = `Monitor Recovered: ${monitor.name}`; const summary = `Monitor "${monitor.name}" is back up and operational.`; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index c75477c88c..1c2bdf1a8f 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -108,7 +108,9 @@ export class NotificationsService implements INotificationsService { }; private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { - const notificationIds = monitor.notifications ?? []; + const notificationIds = decision.isEscalation + ? (monitor.escalationNotifications?.length ? monitor.escalationNotifications : monitor.notifications ?? []) + : monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); // Build notification message once for all notifications diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ef725b80af..9537b5d612 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -191,9 +191,13 @@ export class StatusService implements IStatusService { >, check: Check ): Promise => { + console.log("updateMonitorStatus HIT"); try { const { monitorId, teamId, status, code } = statusResponse; const monitor = await this.monitorsRepository.findById(monitorId, teamId); + const now = Date.now(); + const m = monitor as any; + m._escalationStart ??= null; // Update running stats this.updateRunningStats(monitor, statusResponse); @@ -253,16 +257,19 @@ export class StatusService implements IStatusService { const failures = monitor.statusWindow.filter((s) => s === false).length; const failureRate = (failures / monitor.statusWindow.length) * 100; - // If threshold has been met and the monitor is not already down, mark down: - if (failureRate >= monitor.statusWindowThreshold && monitor.status !== "down") { - newStatus = "down"; - statusChanged = true; + // If threshold has been met and the monitor is not already down, mark down: + if (failureRate >= monitor.statusWindowThreshold) { + if (!m._escalationStart) { + m._escalationStart = now; } - // If the failure rate is below the threshold and the monitor is down, recover: - else if (failureRate < monitor.statusWindowThreshold && monitor.status === "down") { - newStatus = "up"; - statusChanged = true; + const elapsed = now - m._escalationStart; + if (elapsed >= (monitor.escalationDelay ?? 0)) { + newStatus = "down"; + statusChanged = monitor.status !== "down"; } + } else { + m._escalationStart = undefined; + } // Evaluate hardware threshold breaches (only for hardware monitors) let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..2e8cf23e5e 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -37,6 +37,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + escalationNotifications: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; @@ -56,6 +57,8 @@ export interface Monitor { recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; + escalationDelay?: number; + _escalationStart?: number; } export interface MonitorsSummary { diff --git a/server/src/types/notificationMessage.ts b/server/src/types/notificationMessage.ts index f06ff1bd9a..eb62f8762c 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" | "test" | "escalation"; export type NotificationSeverity = "critical" | "warning" | "info" | "success"; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef2..b57a867230 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -62,6 +62,8 @@ export const createMonitorBodyValidation = z.object({ port: z.number().optional(), isActive: z.boolean().optional(), interval: z.number().optional(), + escalationDelay: z.number().min(0).max(60).optional(), + escalationNotifications: z.array(z.string()).optional(), cpuAlertThreshold: z.number().optional(), memoryAlertThreshold: z.number().optional(), diskAlertThreshold: z.number().optional(), @@ -88,6 +90,8 @@ 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(), + escalationDelay: z.number().min(0).max(60).optional(), + escalationNotifications: z.array(z.string()).optional(), notifications: z.array(z.string()).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), @@ -132,6 +136,10 @@ const importedMonitorSchema = z.object({ statusWindow: z.array(z.boolean()).default([]), statusWindowSize: z.number().min(1).max(20).default(5), statusWindowThreshold: z.number().min(1).max(100).default(60), + escalationDelay: z.number().min(0).max(60).default(0), + escalationNotifications: z.array(z.string()).default([]), + escalationDelay: z.number().min(0).max(60).default(0), + escalationNotifications: z.array(z.string()).default([]), type: z.enum(MonitorTypes, "Invalid monitor type"), ignoreTlsErrors: z.boolean().default(false), useAdvancedMatching: z.boolean().default(false), From 0ad6acc1bf7cac29db9e4aaa7a65dd3f51e1cb2f Mon Sep 17 00:00:00 2001 From: annagracehaukoos Date: Sat, 11 Apr 2026 23:26:07 -0400 Subject: [PATCH 2/2] fixed some errors --- client/src/Hooks/useMonitorForm.ts | 1 + client/src/Pages/Auth/Register/index.tsx | 2 +- client/src/Pages/CreateMonitor/index.tsx | 4 ++-- client/src/Types/Monitor.ts | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963c1608b3..57a95d4d2e 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -15,6 +15,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({ statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, escalationDelay: data?.escalationDelay || 0, + escalationNotifications: data?.escalationNotifications || [], geoCheckEnabled: data?.geoCheckEnabled ?? false, geoCheckLocations: data?.geoCheckLocations || [], geoCheckInterval: data?.geoCheckInterval || 300000, diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 0ee68ef274..3e52b4ee35 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -24,7 +24,7 @@ interface InviteVerifyResponse { const RegisterPage = () => { const { t } = useTranslation(); const { schema, defaults } = useRegisterForm(); - const { post, loading } = usePost(); + const { loading } = usePost(); const dispatch = useDispatch(); const navigate = useNavigate(); const { token } = useParams<{ token?: string }>(); diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5c4ff30dec..e345f1906c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, Controller, type Resolver } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; @@ -199,7 +199,7 @@ const CreateMonitorPage = () => { }); const form = useForm({ - resolver: zodResolver(schema), + resolver: zodResolver(schema) as unknown as Resolver, defaultValues: defaults, }); const { control, watch, handleSubmit, clearErrors } = form; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 33cef73b52..d0887d63af 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -61,6 +61,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + escalationNotifications?: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number;