From 7c2de7bda7d2fed4678457282b70c783707fceda Mon Sep 17 00:00:00 2001 From: KolyCode <114193056+KolyCode@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:12:32 -0400 Subject: [PATCH] FINISH ASSIGNMENT --- SETUP_GUIDE.md | 351 ++++++++++++++++++ client/src/Components/inputs/AutoComplete.tsx | 13 +- client/src/Hooks/useMonitorForm.ts | 3 + client/src/Pages/CreateMonitor/index.tsx | 91 +++++ client/src/Types/Monitor.ts | 3 + client/src/Validation/monitor.ts | 27 +- client/src/locales/en.json | 33 ++ server/package-lock.json | 2 +- server/src/config/services.ts | 1 + server/src/db/models/Monitor.ts | 13 + .../monitors/MongoMonitorsRepository.ts | 6 + .../src/service/business/incidentService.ts | 43 +++ .../SuperSimpleQueueHelper.ts | 10 + .../infrastructure/notificationsService.ts | 92 ++++- server/src/types/monitor.ts | 3 + server/src/types/notificationMessage.ts | 2 +- server/src/validation/monitorValidation.ts | 39 +- server/test/escalationService.test.ts | 191 ++++++++++ 18 files changed, 915 insertions(+), 8 deletions(-) create mode 100644 SETUP_GUIDE.md create mode 100644 server/test/escalationService.test.ts diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000000..1bef1b0e53 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,351 @@ +# Checkmate Local Development Setup Guide + +This is a comprehensive guide to set up and run Checkmate locally for development purposes. + +## Prerequisites + +Make sure you have the following installed: + +- **Node.js** (v16+) — [Download](https://nodejs.org/) +- **npm** — Comes with Node.js +- **Docker** — [Download](https://www.docker.com/products/docker-desktop) +- **Git** — [Download](https://git-scm.com/) + +Check your installations: + +```bash +node --version # Should be v16 or higher +npm --version +docker --version +git --version +``` + +## Quick Setup Overview + +This guide will walk you through 5 main steps: + +1. ✅ Clone the Repository (already done if you're reading this) +2. 🐳 Set Up MongoDB Docker Container +3. 🔙 Set Up Backend (Node.js/Express Server) +4. 🎨 Set Up Frontend (React/Vite Client) +5. 🚀 Start the Application + +--- + +## Step 1: Clone the Repository + +```bash +git clone https://github.com/bluewave-labs/Checkmate.git +cd Checkmate +``` + +**Note:** If you're already in the project directory, this step is complete. + +--- + +## Step 2: Set Up MongoDB Docker Container + +MongoDB is the database that Checkmate uses. We'll run it in a Docker container. + +### 2.1: Build Docker Images + +Navigate to the Docker dev directory: + +```bash +cd docker/dev +./build_images.sh +``` + +Navigate back to the root: + +```bash +cd ../.. +``` + +### 2.2: Run MongoDB Container + +```bash +docker run -d -p 27017:27017 -v uptime_mongo_data:/data/db --name uptime_database_mongo mongo:6.0 +``` + +**Verify MongoDB is running:** + +```bash +docker ps | grep uptime_database_mongo +``` + +You should see the MongoDB container listed. If you need to stop it later: + +```bash +docker stop uptime_database_mongo +docker start uptime_database_mongo +``` + +--- + +## Step 3: Set Up Backend (Server) + +The backend is a Node.js/Express application running on port `52345`. + +### 3.1: Navigate to Server Directory + +```bash +cd server +``` + +### 3.2: Install Dependencies + +```bash +npm install +``` + +### 3.3: Create `.env` File + +Create a `.env` file in the `server` directory with the following content: + +```env +CLIENT_HOST="http://localhost:5173" +JWT_SECRET="my_secret_key_change_this" +DB_CONNECTION_STRING="mongodb://localhost:27017/uptime_db" +TOKEN_TTL="99d" +ORIGIN="localhost" +LOG_LEVEL="debug" +``` + +**Environment Variables Explained:** + +| Variable | Description | +|----------|-------------| +| `CLIENT_HOST` | Frontend URL (default: http://localhost:5173) | +| `JWT_SECRET` | Secret key for JWT tokens (⚠️ change in production) | +| `DB_CONNECTION_STRING` | MongoDB connection URL | +| `ORIGIN` | Origin for CORS purposes | +| `TOKEN_TTL` | Token time to live (in vercel/ms format) | +| `LOG_LEVEL` | Debug level: debug, info, warn, error | + +### 3.4: Start Backend Server + +```bash +npm run dev +``` + +The backend server will start at **http://localhost:52345**. + +You'll see output like: +``` +✓ Server is running at http://localhost:52345 +``` + +**Alternative commands for backend:** + +```bash +npm run build # TypeScript compile + path alias resolution +npm run test # Run tests with coverage +npm run lint # ESLint check +npm run lint-fix # Auto-fix lint issues +npm run format # Prettier formatting +``` + +--- + +## Step 4: Set Up Frontend (Client) + +The frontend is a React/Vite application running on port `5173`. + +### 4.1: Open New Terminal & Navigate to Client Directory + +Keep the backend running and open a **new terminal window**: + +```bash +cd client +``` + +### 4.2: Install Dependencies + +```bash +npm install +``` + +### 4.3: Create `.env` File + +Create a `.env` file in the `client` directory: + +```env +VITE_APP_API_BASE_URL="http://localhost:52345/api/v1" +VITE_APP_LOG_LEVEL="debug" +``` + +**Environment Variables Explained:** + +| Variable | Description | +|----------|-------------| +| `VITE_APP_API_BASE_URL` | Backend API URL | +| `VITE_APP_LOG_LEVEL` | Log level: none, error, warn, debug, info | + +### 4.4: Start Frontend Server + +```bash +npm run dev +``` + +The frontend will start at **http://localhost:5173**. + +You'll see output like: +``` +✓ ready in 1234 ms +Local: http://localhost:5173/ +``` + +**Alternative commands for frontend:** + +```bash +npm run build # TypeScript check + production build +npm run lint # ESLint check +npm run format # Prettier formatting +npm run format-check # Check formatting +``` + +--- + +## Step 5: Access the Application + +You now have everything running! Access the application at: + +- **Frontend**: [http://localhost:5173](http://localhost:5173) +- **Backend API**: [http://localhost:52345](http://localhost:52345) +- **API Documentation**: [http://localhost:52345/api-docs](http://localhost:52345/api-docs) (Swagger UI) + +--- + +## Verification Checklist + +After setup, verify everything is working: + +- [ ] MongoDB container is running: `docker ps | grep uptime_database_mongo` +- [ ] Backend server is running at http://localhost:52345 +- [ ] Frontend is running at http://localhost:5173 +- [ ] Frontend loads without errors +- [ ] API documentation is accessible at http://localhost:52345/api-docs + +--- + +## Troubleshooting + +### Port Already in Use + +If you get "port already in use" error: + +```bash +# Find process using the port (example for port 5173) +lsof -i :5173 + +# Kill the process +kill -9 + +# Or change the port in .env file +``` + +**Common ports used:** + +- Frontend: `5173` +- Backend: `52345` +- MongoDB: `27017` +- Redis: `6379` + +### MongoDB Connection Issues + +Check if MongoDB container is running: + +```bash +docker ps | grep mongo + +# If not running, start it +docker start uptime_database_mongo + +# Check logs +docker logs uptime_database_mongo +``` + +### Module Not Found Errors + +Make sure you ran `npm install` in both directories: + +```bash +cd server && npm install && cd ../client && npm install +``` + +### Hot Reload Not Working + +Restart the dev server: + +```bash +# Stop with Ctrl+C +# Then run again +npm run dev +``` + +--- + +## Development Workflow + +### Running Tests (Backend) + +```bash +cd server +npm test # Run all tests +npm test -- --grep "pattern" # Run specific tests +``` + +### Linting & Formatting + +```bash +# Client +cd client +npm run lint +npm run format + +# Server +cd server +npm run lint-fix +npm run format +``` + +### Stopping Services + +To stop everything: + +```bash +# In backend terminal: Press Ctrl+C +# In frontend terminal: Press Ctrl+C + +# Stop MongoDB +docker stop uptime_database_mongo +``` + +--- + +## Next Steps + +1. **Explore the codebase** — Check out the architecture in `CLAUDE.md` +2. **Read API docs** — Visit http://localhost:52345/api-docs +3. **Pick an issue** — Look for [`good-first-issue`](https://github.com/bluewave-labs/checkmate/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) labels +4. **Join Discord** — [Checkmate Discord](https://discord.com/invite/NAb6H3UTjK) + +--- + +## Additional Resources + +- **Full Documentation**: [https://docs.checkmate.so](https://docs.checkmate.so) +- **Architecture Guide**: [CLAUDE.md](./CLAUDE.md) +- **Contributing Guidelines**: [CONTRIBUTING.md](./CONTRIBUTING.md) +- **Code of Conduct**: [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) + +--- + +## Need Help? + +- 💬 Ask on [Discord](https://discord.com/invite/NAb6H3UTjK) +- 📝 Check [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) +- 🐛 Report bugs on [GitHub Issues](https://github.com/bluewave-labs/Checkmate/issues) + +Happy contributing! 🚀 diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index c98e8ff325..8bdc4ba007 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -24,6 +24,17 @@ export const AutoCompleteInput = ({ }: AutoCompleteInputProps) => { const theme = useTheme(); const multiple = props.multiple; + const getOptionDisplayLabel = (option: any) => { + if (typeof props.getOptionLabel === "function") { + return props.getOptionLabel(option); + } + + if (typeof option === "string") { + return option; + } + + return option?.label ?? option?.name ?? option?.notificationName ?? option?.address ?? ""; + }; const defaultRenderInput = (params: any) => ( {multiple && } - {option.name} + {getOptionDisplayLabel(option)} ); diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..39c22fdf58 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -17,6 +17,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({ geoCheckEnabled: data?.geoCheckEnabled ?? false, geoCheckLocations: data?.geoCheckLocations || [], geoCheckInterval: data?.geoCheckInterval || 300000, + escalationEnabled: data?.escalationEnabled ?? false, + escalationThresholdMinutes: data?.escalationThresholdMinutes || 60, + escalationRecipient: data?.escalationRecipient || "", }); export const useMonitorForm = ({ diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..e1fa8a53c7 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -765,6 +765,97 @@ const CreateMonitorPage = () => { } /> + ( + + + field.onChange(e.target.checked)} + /> + {t("pages.createMonitor.form.escalation.option.enable.label")} + + {field.value && ( + + ( + { + const value = e.target.value; + thresholdField.onChange(value === "" ? undefined : Number(value)); + }} + type="number" + fieldLabel={t("pages.createMonitor.form.escalation.option.threshold.label")} + placeholder="60" + fullWidth + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + inputProps={{ min: 1, step: 1 }} + /> + )} + /> + { + // Filter to only email notifications + const emailNotifications = (notifications ?? []).filter((n) => n.type === "email"); + const notificationOptions = emailNotifications.map((n) => ({ + id: n.id, + notificationName: n.notificationName, + address: n.address, + })); + + return ( + n.address === recipientField.value) || null + : null + } + getOptionLabel={(option) => option.notificationName} + onChange={(_: unknown, newValue: any) => { + // Store the email address, not the object + recipientField.onChange(newValue?.address || ""); + }} + isOptionEqualToValue={(option: any, value: any) => { + // Both option and value are objects with address property + // value is the currently selected value object (or null) + if (!value) return false; + return option.id === value.id; + }} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + + )} + + )} + /> + } + /> + {(watchedType === "http" || watchedType === "grpc" || watchedType === "websocket") && ( diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 053b517d1d..9af9f54aac 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -76,6 +76,9 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + escalationEnabled?: boolean; + escalationThresholdMinutes?: number; + escalationRecipient?: string; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fed..79e1c0592b 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -27,7 +27,32 @@ const baseSchema = z.object({ .number() .min(300000, "Interval must be at least 5 minutes") .optional(), -}); + escalationEnabled: z.boolean().default(false).optional(), + escalationThresholdMinutes: z + .number() + .int("Threshold must be a whole number") + .min(1, "Threshold must be at least 1 minute") + .default(60) + .optional(), + escalationRecipient: z + .string() + .email("Please enter a valid email address") + .optional() + .or(z.literal("")), +}) + .refine( + (data: any) => { + // If escalation is enabled, recipient must be provided + if (data.escalationEnabled && !data.escalationRecipient) { + return false; + } + return true; + }, + { + message: "Escalation recipient email is required when escalation is enabled", + path: ["escalationRecipient"], + } + ); // HTTP monitor schema const httpSchema = baseSchema.extend({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 92a21939f3..73447b509e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -543,6 +543,24 @@ "description": "Select the notification channels you want to use", "title": "Notifications" }, + "escalation": { + "title": "Escalation settings", + "description": "Configure automatic escalation for incidents lasting beyond a threshold duration", + "option": { + "enable": { + "label": "Enable escalation" + }, + "threshold": { + "label": "Escalation threshold (minutes)", + "description": "Send escalation notification after incident exceeds this duration" + }, + "recipient": { + "label": "Escalation notification channel", + "placeholder": "Select an email notification", + "description": "Select the email notification channel to receive escalation alerts" + } + } + }, "type": { "description": "Select the type of check to perform", "optionDockerDescription": "Use Docker to monitor if a container is running.", @@ -951,6 +969,21 @@ "description": "Select the type of notification channel to create.", "optionType": "Type", "title": "Channel type" + }, + "escalation": { + "title": "Escalation settings", + "description": "Configure escalation notifications for long-running incidents.", + "threshold": { + "title": "Escalation threshold", + "description": "Set how long an incident must be active before escalation is triggered.", + "label": "Minutes" + }, + "recipient": { + "title": "Escalation recipient", + "description": "Email address to receive escalation alerts.", + "label": "Recipient email", + "placeholder": "escalation@example.com" + } } }, "table": { diff --git a/server/package-lock.json b/server/package-lock.json index 5d9d920ee5..6e1b9aa53c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14932,7 +14932,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b31c8a5e91..d75f6dcab4 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -234,6 +234,7 @@ export const initializeServices = async ({ const notificationsService = new NotificationsService( notificationsRepository, monitorsRepository, + incidentsRepository, webhookProvider, emailProvider, slackProvider, diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..884961f686 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -351,6 +351,19 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + escalationEnabled: { + type: Boolean, + default: false, + }, + escalationThresholdMinutes: { + type: Number, + default: 60, + min: 1, + }, + escalationRecipient: { + type: String, + trim: true, + }, recentChecks: { type: [checkSnapshotSchema], default: [], diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..1bf1582f0e 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -391,6 +391,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + escalationEnabled: doc.escalationEnabled ?? false, + escalationThresholdMinutes: doc.escalationThresholdMinutes ?? 60, + escalationRecipient: doc.escalationRecipient ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -450,6 +453,9 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + escalationEnabled: doc.escalationEnabled ?? false, + escalationThresholdMinutes: doc.escalationThresholdMinutes ?? 60, + escalationRecipient: doc.escalationRecipient ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aacc..fd882e7704 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -263,4 +263,47 @@ export class IncidentService implements IIncidentService { throw error; } }; + + checkForEscalations = async (monitor: Monitor): Promise<{ incident: Incident; notificationIds: string[] } | null> => { + try { + const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); + + if (!activeIncident) { + return null; + } + + const notificationIds = monitor.notifications ?? []; + if (notificationIds.length === 0) { + return null; + } + + const incidentStartTime = new Date(activeIncident.startTime).getTime(); + const currentTime = Date.now(); + const durationMinutes = (currentTime - incidentStartTime) / (1000 * 60); + + // Get escalations that are enabled and have exceeded threshold + const escalationNotifications: string[] = []; + + // This is a simple check - we would need to filter notifications + // with escalation enabled in the calling code (notificationsService) + // For now, we return the incident and let the service decide which notifications escalate + if (durationMinutes > 0) { + return { + incident: activeIncident, + notificationIds, + }; + } + + return null; + } catch (error: unknown) { + this.logger.error({ + service: SERVICE_NAME, + method: "checkForEscalations", + message: error instanceof Error ? error.message : "Unknown error", + details: { monitorId: monitor.id }, + stack: error instanceof Error ? error.stack : undefined, + }); + return null; + } + }; } diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index b6908127b2..b5fc8244b8 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -177,6 +177,16 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { stack: error instanceof Error ? error.stack : undefined, }); }); + + // Step 8. Check for escalations (best effort, don't wait) + this.notificationsService.handleEscalations(statusChangeResult.monitor).catch((error: unknown) => { + this.logger.warn({ + message: `Error checking escalations for job ${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..e50d3d05bd 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 { NotificationMessage } from "@/types/notificationMessage.js"; -import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; +import { IMonitorsRepository, INotificationsRepository, IIncidentsRepository } 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,7 +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; - + handleEscalations: (monitor: Monitor) => Promise; sendTestNotification: (notification: Partial) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; } @@ -26,6 +26,7 @@ export class NotificationsService implements INotificationsService { private notificationsRepository: INotificationsRepository; private monitorsRepository: IMonitorsRepository; + private incidentsRepository: IIncidentsRepository; private webhookProvider: INotificationProvider; private emailProvider: INotificationProvider; private slackProvider: INotificationProvider; @@ -40,6 +41,7 @@ export class NotificationsService implements INotificationsService { constructor( notificationsRepository: INotificationsRepository, monitorsRepository: IMonitorsRepository, + incidentsRepository: IIncidentsRepository, webhookProvider: INotificationProvider, emailProvider: INotificationProvider, slackProvider: INotificationProvider, @@ -53,6 +55,7 @@ export class NotificationsService implements INotificationsService { ) { this.notificationsRepository = notificationsRepository; this.monitorsRepository = monitorsRepository; + this.incidentsRepository = incidentsRepository; this.webhookProvider = webhookProvider; this.emailProvider = emailProvider; this.slackProvider = slackProvider; @@ -197,4 +200,89 @@ export class NotificationsService implements INotificationsService { await this.monitorsRepository.removeNotificationFromMonitors(id); return deleted; }; + + handleEscalations = async (monitor: Monitor): Promise => { + try { + // Check if escalation is enabled on the monitor + if (!monitor.escalationEnabled || !monitor.escalationRecipient) { + return true; + } + + // Get the active incident + const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); + if (!activeIncident) { + return true; + } + + // Check incident duration and send escalation + const incidentStartTime = new Date(activeIncident.startTime).getTime(); + const currentTime = Date.now(); + const durationMinutes = (currentTime - incidentStartTime) / (1000 * 60); + const thresholdMinutes = monitor.escalationThresholdMinutes || 60; + + // Only send escalation if threshold is exceeded + if (durationMinutes < thresholdMinutes) { + return true; + } + + // Build escalation message + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + + const escalationMessage: NotificationMessage = { + type: "escalation", + severity: "critical", + monitor: { + id: monitor.id, + name: monitor.name, + url: `${clientHost}/monitor/${monitor.id}`, + type: monitor.type, + status: monitor.status || "unknown", + }, + content: { + title: `Escalation: ${monitor.name} - Incident Duration Exceeded`, + summary: `The incident for "${monitor.name}" has been active for more than ${thresholdMinutes} minutes and requires attention.`, + details: [ + `Incident Duration: ${Math.round(durationMinutes)} minutes`, + `Escalation Threshold: ${thresholdMinutes} minutes`, + `Started: ${new Date(activeIncident.startTime).toLocaleString()}`, + ], + timestamp: new Date(), + }, + clientHost, + metadata: { + teamId: monitor.teamId, + notificationReason: "incident_escalation", + }, + }; + + // Create a virtual notification object for the email provider + const escalationNotification = { + type: "email" as const, + address: monitor.escalationRecipient, + notificationName: `Escalation - ${monitor.name}`, + }; + + const result = await this.emailProvider.sendMessage!(escalationNotification as any, escalationMessage); + + if (!result) { + this.logger.warn({ + message: `Failed to send escalation notification for monitor: ${monitor.id}`, + service: SERVICE_NAME, + method: "handleEscalations", + }); + return false; + } + + return true; + } catch (error: unknown) { + this.logger.error({ + service: SERVICE_NAME, + method: "handleEscalations", + message: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + }); + return false; + } + }; } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..3826ee8087 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -53,6 +53,9 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + escalationEnabled?: boolean; + escalationThresholdMinutes?: number; + escalationRecipient?: string; recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; 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..466dd270f4 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -78,7 +78,23 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), -}); + escalationEnabled: z.boolean().optional(), + escalationThresholdMinutes: z.number().int().min(1).optional(), + escalationRecipient: z.string().email().optional().or(z.literal("")), +}) + .refine( + (data) => { + // If escalation is enabled, recipient must be provided + if (data.escalationEnabled && !data.escalationRecipient) { + return false; + } + return true; + }, + { + message: "Escalation recipient email is required when escalation is enabled", + path: ["escalationRecipient"], + } + ); export const editMonitorBodyValidation = z.object({ name: z.string().optional(), @@ -107,7 +123,23 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), -}); + escalationEnabled: z.boolean().optional(), + escalationThresholdMinutes: z.number().int().min(1).optional(), + escalationRecipient: z.string().email().optional().or(z.literal("")), +}) + .refine( + (data) => { + // If escalation is enabled, recipient must be provided + if (data.escalationEnabled && !data.escalationRecipient) { + return false; + } + return true; + }, + { + message: "Escalation recipient email is required when escalation is enabled", + path: ["escalationRecipient"], + } + ); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -160,6 +192,9 @@ const importedMonitorSchema = z.object({ geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + escalationEnabled: z.boolean().default(false), + escalationThresholdMinutes: z.number().int().min(1).default(60).optional(), + escalationRecipient: z.string().email().optional().or(z.literal("")), createdAt: z.string().optional(), updatedAt: z.string().optional(), }); diff --git a/server/test/escalationService.test.ts b/server/test/escalationService.test.ts new file mode 100644 index 0000000000..16241ceba3 --- /dev/null +++ b/server/test/escalationService.test.ts @@ -0,0 +1,191 @@ +import { describe, it, beforeEach, expect, jest } from "@jest/globals"; +import { NotificationsService } from "@/service/infrastructure/notificationsService.js"; +import type { Monitor } from "@/types/index.js"; + +describe("Escalation Feature - Monitor Level", () => { + let notificationsService: NotificationsService; + let incidentsRepositoryStub: any; + let emailProviderStub: any; + let settingsServiceStub: any; + let loggerStub: any; + + beforeEach(() => { + incidentsRepositoryStub = { + findActiveByMonitorId: jest.fn(), + }; + + emailProviderStub = { + sendMessage: jest.fn().mockResolvedValue(true), + }; + + settingsServiceStub = { + getSettings: jest.fn().mockReturnValue({ + clientHost: "http://localhost:5173", + }), + }; + + loggerStub = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + notificationsService = new NotificationsService( + {}, + {}, + incidentsRepositoryStub, + {}, + emailProviderStub, + {}, + {}, + {}, + {}, + {}, + settingsServiceStub, + loggerStub, + {} + ) as any; + }); + + describe("handleEscalations", () => { + it("should return true when escalation is not enabled on monitor", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + escalationEnabled: false, + }; + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(true); + expect(emailProviderStub.sendMessage).not.toHaveBeenCalled(); + }); + + it("should return true when escalation recipient is not set", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + escalationEnabled: true, + escalationRecipient: "", + }; + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(true); + expect(emailProviderStub.sendMessage).not.toHaveBeenCalled(); + }); + + it("should return true when no active incident exists", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + escalationEnabled: true, + escalationRecipient: "escalation@example.com", + }; + + incidentsRepositoryStub.findActiveByMonitorId.mockResolvedValue(null); + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(true); + expect(emailProviderStub.sendMessage).not.toHaveBeenCalled(); + }); + + it("should send escalation when incident duration exceeds threshold", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + status: "down", + escalationEnabled: true, + escalationRecipient: "escalation@example.com", + escalationThresholdMinutes: 60, + }; + + const activeIncident = { + id: "incident1", + startTime: new Date(Date.now() - 120 * 60 * 1000), // 120 minutes ago + status: true, + }; + + incidentsRepositoryStub.findActiveByMonitorId.mockResolvedValue(activeIncident); + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(true); + expect(emailProviderStub.sendMessage).toHaveBeenCalled(); + }); + + it("should not send escalation if incident duration is below threshold", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + status: "down", + escalationEnabled: true, + escalationRecipient: "escalation@example.com", + escalationThresholdMinutes: 60, + }; + + const activeIncident = { + id: "incident1", + startTime: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago + status: true, + }; + + incidentsRepositoryStub.findActiveByMonitorId.mockResolvedValue(activeIncident); + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(true); + expect(emailProviderStub.sendMessage).not.toHaveBeenCalled(); + }); + + it("should respect custom escalation thresholds on monitor", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + status: "down", + escalationEnabled: true, + escalationRecipient: "escalation@example.com", + escalationThresholdMinutes: 20, // Custom threshold of 20 minutes + }; + + const activeIncident = { + id: "incident1", + startTime: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago + status: true, + }; + + incidentsRepositoryStub.findActiveByMonitorId.mockResolvedValue(activeIncident); + + await notificationsService.handleEscalations(monitor as Monitor); + + // Should send because 30 >= 20 + expect(emailProviderStub.sendMessage).toHaveBeenCalled(); + }); + + it("should handle errors gracefully", async () => { + const monitor: Partial = { + id: "1", + teamId: "1", + name: "Test Monitor", + type: "http", + escalationEnabled: true, + escalationRecipient: "escalation@example.com", + }; + + incidentsRepositoryStub.findActiveByMonitorId.mockRejectedValue(new Error("Database error")); + + const result = await notificationsService.handleEscalations(monitor as Monitor); + expect(result).toBe(false); + expect(loggerStub.error).toHaveBeenCalled(); + }); + }); +});