diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 44b051580..5ddbf2a06 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -26,7 +26,7 @@ export const initializeControllers = (services: InitializedSerivces) => { controllers.queueController = new QueueController(services.jobQueue); controllers.logController = new LogController(services.logger); controllers.statusPageController = new StatusPageController(services.db); - controllers.notificationController = new NotificationController(services.notificationService, services.db, services.monitorsRepository); + controllers.notificationController = new NotificationController(services.notificationsService, services.db, services.monitorsRepository); controllers.diagnosticController = new DiagnosticController(services.diagnosticService); controllers.incidentController = new IncidentController(services.incidentService); diff --git a/server/src/config/services.ts b/server/src/config/services.ts index bbb42984e..9ae5ecb12 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -5,9 +5,17 @@ import MongoDB from "../db/MongoDB.js"; import NetworkService from "../service/infrastructure/networkService.js"; import EmailService from "../service/infrastructure/emailService.js"; import BufferService from "../service/infrastructure/bufferService.js"; -import StatusService from "../service/infrastructure/statusService.js"; -import NotificationUtils from "../service/infrastructure/notificationUtils.js"; -import NotificationService from "../service/infrastructure/notificationService.js"; +import { + NotificationsService, + StatusService, + WebhookProvider, + SlackProvider, + EmailProvider, + DiscordProvider, + PagerDutyProvider, + MatrixProvider, + INotificationsService, +} from "@/service/index.js"; import ErrorService from "../service/infrastructure/errorService.js"; import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js"; @@ -50,7 +58,7 @@ import StatusPage from "../db/models/StatusPage.js"; import Team from "../db/models/Team.js"; import MaintenanceWindow from "../db/models/MaintenanceWindow.js"; import MonitorStats from "../db/models/MonitorStats.js"; -import Notification from "../db/models/Notification.js"; +import NotificationModel from "../db/models/Notification.js"; import RecoveryToken from "../db/models/RecoveryToken.js"; import AppSettings from "../db/models/AppSettings.js"; import Incident from "../db/models/Incident.js"; @@ -75,6 +83,7 @@ import { MongoInvitesRepository, MongoRecoveryTokensRepository, MongoSettingsRepository, + MongoNotificationsRepository, IMonitorsRepository, IChecksRepository, IMonitorStatsRepository, @@ -83,6 +92,7 @@ import { IInvitesRepository, IRecoveryTokensRepository, ISettingsRepository, + INotificationsRepository, } from "@/repositories/index.js"; export type InitializedSerivces = { @@ -95,7 +105,6 @@ export type InitializedSerivces = { emailService: any; bufferService: any; statusService: any; - notificationService: any; jobQueue: any; userService: any; checkService: any; @@ -106,6 +115,7 @@ export type InitializedSerivces = { incidentService: any; errorService: any; logger: any; + notificationsService: INotificationsService; // Repositories monitorsRepository: IMonitorsRepository; @@ -116,6 +126,7 @@ export type InitializedSerivces = { invitesRepository: IInvitesRepository; recoveryTokensRepository: IRecoveryTokensRepository; settingsRepository: ISettingsRepository; + notificationsRepository: INotificationsRepository; }; export const initializeServices = async ({ @@ -141,7 +152,7 @@ export const initializeServices = async ({ const statusPageModule = new StatusPageModule({ StatusPage, NormalizeData, stringService, AppSettings }); const userModule = new UserModule({ User, Team, GenerateAvatarImage, ParseBoolean, stringService }); const maintenanceWindowModule = new MaintenanceWindowModule({ MaintenanceWindow }); - const notificationModule = new NotificationModule({ Notification, Monitor }); + const notificationModule = new NotificationModule({ Notification: NotificationModel, Monitor }); const recoveryModule = new RecoveryModule({ User, RecoveryToken, crypto, stringService }); const settingsModule = new SettingsModule({ AppSettings }); const incidentModule = new IncidentModule({ logger, Incident, Monitor, User }); @@ -171,7 +182,7 @@ export const initializeServices = async ({ const invitesRepository = new MongoInvitesRepository(); const recoveryTokensRepository = new MongoRecoveryTokensRepository(); const settingsRepository = new MongoSettingsRepository(); - + const notificationsRepository = new MongoNotificationsRepository(); const networkService = new NetworkService({ axios, got, @@ -206,31 +217,34 @@ export const initializeServices = async ({ checksRepository, }); - const bufferService = new BufferService({ db, logger, envSettings, incidentService, checkService }); + const bufferService = new BufferService({ logger, incidentService, checkService }); - const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService, monitorsRepository }); + const statusService = new StatusService({ db, logger, buffer: bufferService, monitorsRepository }); - const notificationUtils = new NotificationUtils({ - stringService, - emailService, - settingsService, - }); + const webhookProvider = new WebhookProvider(logger); + const slackProvider = new SlackProvider(logger); + const emailProvider = new EmailProvider(emailService, logger); + const discordProvider = new DiscordProvider(logger); + const pagerDutyProvider = new PagerDutyProvider(logger); + const matrixProvider = new MatrixProvider(logger); - const notificationService = new NotificationService({ - emailService, - db, - logger, - networkService, - stringService, - notificationUtils, - }); + const notificationsService = new NotificationsService( + notificationsRepository, + webhookProvider, + emailProvider, + slackProvider, + discordProvider, + pagerDutyProvider, + matrixProvider, + logger + ); const superSimpleQueueHelper = new SuperSimpleQueueHelper({ db, logger, networkService, statusService, - notificationService, + notificationsService, checkService, buffer: bufferService, }); @@ -296,7 +310,6 @@ export const initializeServices = async ({ emailService, bufferService, statusService, - notificationService, jobQueue: superSimpleQueue, userService, checkService, @@ -307,6 +320,7 @@ export const initializeServices = async ({ incidentService, errorService, logger, + notificationsService, // Repositories monitorsRepository, @@ -317,6 +331,7 @@ export const initializeServices = async ({ invitesRepository, recoveryTokensRepository, settingsRepository, + notificationsRepository, }; Object.values(services).forEach((service) => { diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 6f4a7f164..1569f0986 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -1,18 +1,20 @@ import { Request, Response, NextFunction } from "express"; +import { Notification } from "@/types/index.js"; import { createNotificationBodyValidation } from "@/validation/joi.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorsRepository } from "@/repositories/index.js"; +import { INotificationsService } from "@/service/index.js"; const SERVICE_NAME = "NotificationController"; class NotificationController { static SERVICE_NAME = SERVICE_NAME; private db: any; - private notificationService: any; + private notificationsService: INotificationsService; private monitorsRepository: IMonitorsRepository; - constructor(notificationService: any, db: any, monitorsRepository: IMonitorsRepository) { - this.notificationService = notificationService; + constructor(notificationsService: INotificationsService, db: any, monitorsRepository: IMonitorsRepository) { + this.notificationsService = notificationsService; this.db = db; this.monitorsRepository = monitorsRepository; } @@ -23,9 +25,8 @@ class NotificationController { testNotification = async (req: Request, res: Response, next: NextFunction) => { try { - const notification = req.body; - - const success = await this.notificationService.sendTestNotification(notification); + const notification: Notification = req.body; + const success = await this.notificationsService.sendTestNotification(notification); if (!success) { throw new AppError({ message: "Sending notification failed", status: 500 }); @@ -177,7 +178,7 @@ class NotificationController { throw new AppError({ message: "No notifications", status: 400 }); } - const result = await this.notificationService.testAllNotifications(notifications); + const result = await this.notificationsService.testAllNotifications(notifications); if (!result) { throw new AppError({ message: "Failed to send all notifications", status: 500 }); diff --git a/server/src/db/models/Notification.js b/server/src/db/models/Notification.js deleted file mode 100755 index 72c716a26..000000000 --- a/server/src/db/models/Notification.js +++ /dev/null @@ -1,74 +0,0 @@ -import mongoose from "mongoose"; - -const NotificationSchema = mongoose.Schema( - { - userId: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - immutable: true, - required: true, - }, - teamId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Team", - immutable: true, - required: true, - }, - type: { - type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"], - }, - notificationName: { - type: String, - required: true, - }, - address: { - type: String, - }, - phone: { - type: String, - }, - // Matrix-specific fields - homeserverUrl: { - type: String, - }, - roomId: { - type: String, - }, - accessToken: { - type: String, - }, - }, - { - timestamps: true, - } -); - -NotificationSchema.pre("save", function (next) { - if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) { - this.cpuAlertThreshold = this.alertThreshold; - } - if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) { - this.memoryAlertThreshold = this.alertThreshold; - } - if (!this.diskAlertThreshold || this.isModified("alertThreshold")) { - this.diskAlertThreshold = this.alertThreshold; - } - if (!this.tempAlertThreshold || this.isModified("alertThreshold")) { - this.tempAlertThreshold = this.alertThreshold; - } - next(); -}); - -NotificationSchema.pre("findOneAndUpdate", function (next) { - const update = this.getUpdate(); - if (update.alertThreshold) { - update.cpuAlertThreshold = update.alertThreshold; - update.memoryAlertThreshold = update.alertThreshold; - update.diskAlertThreshold = update.alertThreshold; - update.tempAlertThreshold = update.alertThreshold; - } - next(); -}); - -export default mongoose.model("Notification", NotificationSchema); diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts new file mode 100755 index 000000000..97e2d43fb --- /dev/null +++ b/server/src/db/models/Notification.ts @@ -0,0 +1,50 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Notification, NotificationChannel } from "@/types/notification.js"; + +interface NotificationDocument extends Omit { + _id: Types.ObjectId; + userId: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const NotificationSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: "User", + immutable: true, + required: true, + }, + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + type: { + type: String, + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as NotificationChannel[], + required: true, + }, + notificationName: { + type: String, + required: true, + }, + address: { type: String }, + phone: { type: String }, + homeserverUrl: { type: String }, + roomId: { type: String }, + accessToken: { type: String }, + }, + { + timestamps: true, + } +); + +const NotificationModel = model("Notification", NotificationSchema); + +export type { NotificationDocument }; +export { NotificationModel }; +export default NotificationModel; diff --git a/server/src/db/models/User.ts b/server/src/db/models/User.ts index e00250340..6aa3835a6 100644 --- a/server/src/db/models/User.ts +++ b/server/src/db/models/User.ts @@ -3,7 +3,7 @@ import bcrypt from "bcryptjs"; import type { User, UserProfileImage, UserRole } from "@/types/index.js"; import { MonitorModel } from "@/db/models/index.js"; import Team from "./Team.js"; -import Notification from "./Notification.js"; +import NotificationModel from "./Notification.js"; type UserDocumentBase = Omit & { teamId?: Types.ObjectId; @@ -76,7 +76,7 @@ UserSchema.pre("findOneAndDelete", async function (next) { await Team.deleteOne({ _id: userToDelete.teamId }); await MonitorModel.deleteMany({ userId: userToDelete._id }); await this.model.deleteMany({ teamId: userToDelete.teamId, _id: { $ne: userToDelete._id } }); - await Notification.deleteMany({ teamId: userToDelete.teamId }); + await NotificationModel.deleteMany({ teamId: userToDelete.teamId }); } next(); } catch (error) { diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index 59ed8125d..18a3a3b19 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -21,3 +21,6 @@ export { default as AppSettingsModel } from "@/db/models/AppSettings.js"; export * from "@/db/models/RecoveryToken.js"; export { default as RecoveryTokenModel } from "@/db/models/RecoveryToken.js"; + +export * from "@/db/models/Notification.js"; +export { default as NotificationModel } from "@/db/models/Notification.js"; diff --git a/server/src/db/modules/notificationModule.js b/server/src/db/modules/notificationModule.js index 3c6edb54a..f45900184 100755 --- a/server/src/db/modules/notificationModule.js +++ b/server/src/db/modules/notificationModule.js @@ -1,5 +1,3 @@ -// import Notification from "../../models/Notification.js"; -// import Monitor from "../../models/Monitor.js"; const SERVICE_NAME = "notificationModule"; class NotificationModule { diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 09a686595..1a3199081 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -21,3 +21,6 @@ export { default as MongoRecoveryTokensRepository } from "@/repositories/recover export * from "@/repositories/settings/ISettingsRepository.js"; export { default as MongoSettingsRepository } from "@/repositories/settings/MongoSettingsRepository.js"; + +export * from "@/repositories/notifications/INotificationsRepository.js"; +export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; diff --git a/server/src/repositories/notifications/INotificationsRepository.ts b/server/src/repositories/notifications/INotificationsRepository.ts new file mode 100644 index 000000000..8bb65769a --- /dev/null +++ b/server/src/repositories/notifications/INotificationsRepository.ts @@ -0,0 +1,8 @@ +import type { Notification } from "@/types/index.js"; +export interface INotificationsRepository { + // create + // fetch + findNotificationsByIds(ids: string[]): Promise; + // update + // delete +} diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts new file mode 100644 index 000000000..01a514f1c --- /dev/null +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -0,0 +1,46 @@ +import mongoose from "mongoose"; +import { NotificationModel, type NotificationDocument } from "@/db/models/index.js"; +import { INotificationsRepository } from "@/repositories/index.js"; +import type { Notification } from "@/types/index.js"; + +class MongoNotificationsRepository implements INotificationsRepository { + private mapDocuments = (documents: NotificationDocument[]): Notification[] => { + if (!documents?.length) { + return []; + } + return documents.map((doc) => this.toEntity(doc)); + }; + + private toEntity = (doc: NotificationDocument): Notification => { + const toStringId = (value: mongoose.Types.ObjectId | string): string => { + return value instanceof mongoose.Types.ObjectId ? value.toString() : value; + }; + + const toDateString = (value: Date | string): string => { + return value instanceof Date ? value.toISOString() : value; + }; + + return { + id: toStringId(doc._id), + userId: toStringId(doc.userId), + teamId: toStringId(doc.teamId), + type: doc.type, + notificationName: doc.notificationName, + address: doc.address ?? undefined, + phone: doc.phone ?? undefined, + homeserverUrl: doc.homeserverUrl ?? undefined, + roomId: doc.roomId ?? undefined, + accessToken: doc.accessToken ?? undefined, + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + findNotificationsByIds = async (ids: string[]) => { + const mongoIds = ids.map((id) => new mongoose.Types.ObjectId(id)); + const documents = await NotificationModel.find({ _id: { $in: mongoIds } }); + return this.mapDocuments(documents); + }; +} + +export default MongoNotificationsRepository; diff --git a/server/src/service/business/incidentService.js b/server/src/service/business/incidentService.js index 71e60d279..db89caa68 100644 --- a/server/src/service/business/incidentService.js +++ b/server/src/service/business/incidentService.js @@ -305,7 +305,7 @@ class IncidentService { const groupedByMonitor = {}; for (const item of createItems) { - if (!item.monitor || !item.monitor._id || !item.check || !item.check._id) { + if (!item.monitor || !item.monitor.id || !item.check || !item.check.id) { this.logger.warn({ service: this.SERVICE_NAME, method: "processIncidentsFromBuffer", @@ -315,7 +315,7 @@ class IncidentService { continue; } - const monitorId = item.monitor._id.toString(); + const monitorId = item.monitor.id.toString(); if (!groupedByMonitor[monitorId]) { groupedByMonitor[monitorId] = []; } @@ -341,20 +341,20 @@ class IncidentService { for (const item of items) { checksToAddToIncidents.push({ incidentId, - checkId: item.check._id.toString(), + checkId: item.check.id.toString(), }); } } else { const firstItem = items[0]; const incidentData = { - monitorId: firstItem.monitor._id, + monitorId: firstItem.monitor.id, teamId: firstItem.monitor.teamId, type: firstItem.monitor.type, startTime: new Date(), status: true, message: firstItem.check.message || null, statusCode: firstItem.check.statusCode || null, - checks: [firstItem.check._id], + checks: [firstItem.check.id], }; newIncidentsToCreate.push({ @@ -380,7 +380,7 @@ class IncidentService { for (const remainingItem of item.remainingChecks) { checksToAddToIncidents.push({ incidentId, - checkId: remainingItem.check._id.toString(), + checkId: remainingItem.check.id, }); } } diff --git a/server/src/service/index.ts b/server/src/service/index.ts index a094f8b01..7f7d0f14a 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -1,2 +1,13 @@ export * from "@/service/business/monitorService.js"; export * from "@/service/infrastructure/networkService.js"; +export * from "@/service/infrastructure/notificationProviders/discord.js"; +export * from "@/service/infrastructure/notificationProviders/email.js"; +export * from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +export * from "@/service/infrastructure/notificationProviders/pagerduty.js"; +export * from "@/service/infrastructure/notificationProviders/matrix.js"; +export * from "@/service/infrastructure/notificationProviders/slack.js"; +export * from "@/service/infrastructure/notificationProviders/webhook.js"; +export * from "@/service/infrastructure/notificationsService.js"; +export * from "@/service/infrastructure/statusService.js"; +export * from "@/service/infrastructure/notificationProviders/webhook.js"; +export * from "@/service/infrastructure/notificationProviders/matrix.js"; diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 891635d56..bbd3a11f0 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -1,15 +1,15 @@ const SERVICE_NAME = "JobQueueHelper"; import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; -import { INetworkService } from "@/service/index.js"; +import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; class SuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; private db: any; private logger: any; private networkService: INetworkService; - private statusService: any; - private notificationService: any; + private statusService: IStatusService; + private notificationsService: INotificationsService; private checkService: any; private buffer: any; @@ -18,15 +18,15 @@ class SuperSimpleQueueHelper { logger, networkService, statusService, - notificationService, + notificationsService, checkService, buffer, }: { db: any; logger: any; networkService: INetworkService; - statusService: any; - notificationService: any; + statusService: IStatusService; + notificationsService: INotificationsService; checkService: any; buffer: any; }) { @@ -34,9 +34,9 @@ class SuperSimpleQueueHelper { this.logger = logger; this.networkService = networkService; this.statusService = statusService; - this.notificationService = notificationService; this.checkService = checkService; this.buffer = buffer; + this.notificationsService = notificationsService; } get serviceName() { @@ -78,13 +78,9 @@ class SuperSimpleQueueHelper { // Step 4. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); - this.notificationService - .handleNotifications({ - ...status, - monitor: statusChangeResult.monitor, - prevStatus: statusChangeResult.prevStatus, - statusChanged: statusChangeResult.statusChanged, - }) + // Step 5 handle notifications (best effort, continue even in event of failure, don't wait) + this.notificationsService + .handleNotifications(monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) .catch((error: any) => { this.logger.error({ message: error.message, diff --git a/server/src/service/infrastructure/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 5a809eea5..0363aa362 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -6,7 +6,6 @@ const SERVICE_NAME = "BufferService"; class BufferService { static SERVICE_NAME = SERVICE_NAME; private BUFFER_TIMEOUT: number; - private db: any; private logger: any; private incidentService: any; private SERVICE_NAME: string; @@ -15,21 +14,8 @@ class BufferService { private bufferTimer: NodeJS.Timeout | null = null; private checksService: any; - constructor({ - db, - logger, - envSettings, - incidentService, - checkService, - }: { - db: any; - logger: any; - envSettings: any; - incidentService: any; - checkService: any; - }) { + constructor({ logger, incidentService, checkService }: { logger: any; incidentService: any; checkService: any }) { this.BUFFER_TIMEOUT = config.NODE_ENV === "development" ? 10 : 1000 * 60 * 1; // 1 minute - this.db = db; this.logger = logger; this.incidentService = incidentService; this.checksService = checkService; diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts new file mode 100644 index 000000000..2d363d6b3 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -0,0 +1,6 @@ +import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js"; + +export interface INotificationProvider { + sendAlert: (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => Promise; + sendTestAlert(notification: Notification): Promise; +} diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts new file mode 100644 index 000000000..ad1f2f28a --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -0,0 +1,72 @@ +const SERVICE_NAME = "DiscordProvider"; +import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class DiscordProvider implements INotificationProvider { + private logger: any; + + constructor(logger: any) { + this.logger = logger; + } + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { discordPayload } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + return discordPayload; + }; + + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + let body; + if (monitor.type === "hardware") { + body = this.getHardwareContent(monitor, monitorStatusResponse); + } else { + body = buildDiscordBody(monitor, monitorStatusResponse); + } + + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: body, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Discord alert failed", + service: SERVICE_NAME, + method: "sendAlert", + stack: err?.stack, + }); + return false; + } + }; + sendTestAlert = async (notification: Notification) => { + if (!notification.address) { + return false; + } + try { + await got.post(notification.address, { + json: { content: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Discord test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + }; +} diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts new file mode 100644 index 000000000..16e48f5c1 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -0,0 +1,61 @@ +const SERVICE_NAME = "EmailProvider"; +import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js"; + +export class EmailProvider implements INotificationProvider { + private emailService: any; + private logger: any; + + constructor(emailService: any, logger: any) { + this.emailService = emailService; + this.logger = logger; + } + + private buildHardwareEmail = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); + return html; + }; + + async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + const subject = `Monitor ${monitor.name} infrastructure alerts`; + let html; + if (monitor.type === "hardware") { + html = this.buildHardwareEmail(monitor, monitorStatusResponse); + } else { + html = await buildEmail(this.emailService, monitor); + } + + if (!notification.address) { + return false; + } + + const messageId = await this.emailService.sendEmail(notification.address, subject, html); + if (!messageId) { + this.logger.warn({ + message: "Email alert failed", + service: SERVICE_NAME, + method: "sendAlert", + }); + return false; + } + return true; + } + + async sendTestAlert(notification: Notification): Promise { + const subject = "Test notification"; + const html = await buildTestEmail(this.emailService); + + const messageId = await this.emailService.sendEmail(notification.address, subject, html); + if (!messageId) { + this.logger.warn({ + message: "Email test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + }); + return false; + } + return true; + } +} diff --git a/server/src/service/infrastructure/notificationProviders/matrix.js b/server/src/service/infrastructure/notificationProviders/matrix.js deleted file mode 100644 index a8b33d4b8..000000000 --- a/server/src/service/infrastructure/notificationProviders/matrix.js +++ /dev/null @@ -1,34 +0,0 @@ -const SERVICE_NAME = "Matrix"; - -class Matrix { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ networkService, logger }) { - this.networkService = networkService; - this.logger = logger; - } - - get serviceName() { - return Matrix.SERVICE_NAME; - } - - async send({ friendlyName, homeserverUrl, accessToken, roomId, message, monitorName }) { - const title = `Checkmate status for ${monitorName}`; - const formattedMessage = `## ${title}\n${message}`; - try { - await this.networkService.requestMatrix({ - homeserverUrl, - accessToken, - roomId, - message: formattedMessage, - }); - this.logger.info(`Successfully sent Matrix notification for ${friendlyName}`); - return true; - } catch (error) { - this.logger.error(`Failed to send Matrix notification for ${friendlyName}: ${error.message}`); - return false; - } - } -} - -export default Matrix; diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts new file mode 100644 index 000000000..8914dbf80 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -0,0 +1,99 @@ +const SERVICE_NAME = "MatrixProvider"; +import got from "got"; +import type { INotificationProvider } from "@/service/index.js"; +import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; +import { + buildHardwareAlerts, + buildHardwareWebhookBody, + buildWebhookBody, + getTestMessage, +} from "@/service/infrastructure/notificationProviders/utils.js"; + +export class MatrixProvider implements INotificationProvider { + private logger: any; + + constructor(logger: any) { + this.logger = logger; + } + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const body = buildHardwareWebhookBody(alertsToSend, monitor); + return body; + }; + + private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const body = buildWebhookBody(monitor, monitorStatusResponse); + return body; + }; + + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { homeserverUrl, accessToken, roomId } = notification; + + let content; + if (monitor.type === "hardware") { + content = this.getHardwareContent(monitor, monitorStatusResponse); + } else { + content = this.getContent(monitor, monitorStatusResponse); + } + + const title = `Checkmate status for ${monitor.name}`; + + const formattedMessage = `## ${title}\n${content}`; + + const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; + const body = { + msgtype: "m.text", + body: formattedMessage, + format: "org.matrix.custom.html", + formatted_body: formattedMessage, + }; + try { + await got.post(url, { + json: body, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Matrix alert failed", + service: SERVICE_NAME, + method: "sendAlert", + stack: err?.stack, + }); + return false; + } + }; + + sendTestAlert = async (notification: Notification) => { + const { homeserverUrl, accessToken, roomId } = notification; + if (!homeserverUrl || !accessToken || !roomId) { + return false; + } + const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`; + const body = { + msgtype: "m.text", + body: getTestMessage(), + }; + try { + await got.post(url, { + json: body, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Matrix test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + }; +} diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts new file mode 100644 index 000000000..f65e50c68 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -0,0 +1,91 @@ +const SERVICE_NAME = "PagerDutyProvider"; +import got from "got"; +import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import { + buildHardwareAlerts, + buildHardwareWebhookBody, + buildWebhookBody, + getTestMessage, +} from "@/service/infrastructure/notificationProviders/utils.js"; + +export class PagerDutyProvider implements INotificationProvider { + private logger: any; + + constructor(logger: any) { + this.logger = logger; + } + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const body = buildHardwareWebhookBody(alertsToSend, monitor); + return body; + }; + + private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const body = buildWebhookBody(monitor, monitorStatusResponse); + return body; + }; + + async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + let body; + if (monitor.type === "hardware") { + body = this.getHardwareContent(monitor, monitorStatusResponse); + } else { + body = this.getContent(monitor, monitorStatusResponse); + } + + try { + await got.post("https://events.pagerduty.com/v2/enqueue", { + json: { + routing_key: notification.address, + event_action: "trigger", + payload: { + summary: body, + severity: "critical", + source: monitor.url, + timestamp: new Date().toISOString(), + }, + }, + }); + + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "PagerDuty alert failed", + service: SERVICE_NAME, + method: "sendAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendTestAlert(notification: Notification): Promise { + try { + await got.post("https://events.pagerduty.com/v2/enqueue", { + json: { + routing_key: notification.address, + event_action: "trigger", + payload: { + summary: getTestMessage(), + severity: "info", + source: "checkmate", + timestamp: new Date().toISOString(), + }, + }, + responseType: "json", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "PagerDuty test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } +} diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts new file mode 100644 index 000000000..2aeb442eb --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -0,0 +1,85 @@ +const SERVICE_NAME = "SlackProvider"; +import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import { + buildHardwareAlerts, + buildHardwareWebhookBody, + buildWebhookBody, + getTestMessage, +} from "@/service/infrastructure/notificationProviders/utils.js"; +import got, { HTTPError } from "got"; + +export class SlackProvider implements INotificationProvider { + private logger: any; + + constructor(logger: any) { + this.logger = logger; + } + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const body = buildHardwareWebhookBody(alertsToSend, monitor); + return body; + }; + + private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const body = buildWebhookBody(monitor, monitorStatusResponse); + return body; + }; + + async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + let body; + if (monitor.type === "hardware") { + body = this.getHardwareContent(monitor, monitorStatusResponse); + } else { + body = this.getContent(monitor, monitorStatusResponse); + } + + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: body }, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Slack alert failed", + service: SERVICE_NAME, + method: "sendAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendTestAlert(notification: Notification): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as HTTPError; + this.logger.warn({ + message: "Slack test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } +} diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts new file mode 100644 index 000000000..a7e954e55 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -0,0 +1,202 @@ +import { Monitor, HardwareStatusPayload, MonitorStatusResponse } from "@/types/index.js"; + +const getTime = (): { localTimeZone: string; localTime: string; utcTime: string } => { + const now = new Date(); + const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const localTime = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZone: localTimeZone, + timeZoneName: "short", + }).format(now); + const utcTime = now.toUTCString(); + return { localTimeZone, localTime, utcTime }; +}; + +export const buildHardwareAlerts = ( + clientHost: string, + monitor: Monitor, + networkResponse: MonitorStatusResponse +): { alertsToSend: string[]; discordPayload: any } => { + const thresholds = monitor.thresholds || {}; + const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + + const payload = networkResponse?.payload as HardwareStatusPayload; + const metrics = payload.data || {}; + const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; + + const alerts: Record = { + cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, + memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, + disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false, + }; + + const alertsToSend = []; + const discordEmbeds = []; + const monitorInfoFields = [ + { name: "Monitor", value: monitor.name, inline: true }, + { name: "URL", value: monitor.url, inline: false }, + ]; + const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor.id}` }; + const formatDiscordAlert = { + cpu: () => ({ + title: "CPU alert", + description: `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + color: 15548997, + fields: [...monitorInfoFields, goToIncidentField], + footer: { text: "Checkmate" }, + }), + + memory: () => ({ + title: "Memory alert", + description: `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, + color: 15548997, + fields: [...monitorInfoFields, goToIncidentField], + footer: { text: "Checkmate" }, + }), + + disk: () => ({ + title: "Disk alert", + description: `Your current disk usage is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + color: 15548997, + footer: { text: "Checkmate" }, + fields: [ + ...monitorInfoFields, + ...(Array.isArray(disk) ? disk : []).map((d: any, idx: number) => ({ + name: `Disk ${idx}`, + value: `${(d?.usage_percent * 100).toFixed(0)}%`, + inline: true, + })), + goToIncidentField, + ], + }), + }; + const alertTypes = ["cpu", "memory", "disk"] as const; + const alertThresholdKeyMap: Record<(typeof alertTypes)[number], "cpuAlertThreshold" | "memoryAlertThreshold" | "diskAlertThreshold"> = { + cpu: "cpuAlertThreshold", + memory: "memoryAlertThreshold", + disk: "diskAlertThreshold", + }; + for (const type of alertTypes) { + const thresholdKey = alertThresholdKeyMap[type]; + // Iterate over each alert type to see if any need to be decremented + if (alerts[type] === true) { + const nextValue = ((monitor[thresholdKey] ?? monitor.alertThreshold) as number) - 1; + monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered + + if (monitor[thresholdKey] <= 0) { + // If threshold drops below 0, reset and send notification + monitor[thresholdKey] = monitor.alertThreshold; + + const formatAlert = { + cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + memory: () => + `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, + disk: () => + `Your current disk usage: ${disk + .map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`) + .join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + }; + alertsToSend.push(formatAlert[type]()); + discordEmbeds.push(formatDiscordAlert[type]()); + } + } + } + const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null; + return { alertsToSend, discordPayload }; +}; + +export const buildHardwareNotificationMessage = (clientHost: string, alerts: any, monitor: Monitor) => { + const alertsHeader = [`Monitor: ${monitor.name}`, `URL: ${monitor.url}`]; + const alertFooter = [`Go to incident: ${clientHost}/infrastructure/${monitor.id}`]; + const alertText = alerts.length > 0 ? [...alertsHeader, ...alerts, ...alertFooter] : []; + return alertText.map((alert) => alert).join("\n"); +}; + +export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): string => { + const content = alerts.map((alert) => alert).join("\n"); + return content; +}; + +export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => { + const thresholds = monitor.thresholds || {}; + const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + + const payload = networkResponse?.payload as HardwareStatusPayload; + const metrics = payload.data || {}; + const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; + + const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; + if (cpuBreach && ((monitor.cpuAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + return true; + } + + const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; + if (memoryBreach && ((monitor.memoryAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + return true; + } + + const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); + if (diskBreach && ((monitor.diskAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + return true; + } + + return false; +}; + +export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { status, code } = monitorStatusResponse; + const { localTimeZone, localTime, utcTime } = getTime(); + return `Monitor: ${monitor.name}\nLocal Time (${localTimeZone}): ${localTime}\nUTC Time: ${utcTime}\nStatus: ${status ? "UP" : "DOWN"}\nStatus Code: ${code}\n\u200B\n`; +}; + +export const buildHardwareEmail = async (emailService: any, monitor: Monitor, alerts: string[]) => { + const template = "hardwareIncidentTemplate"; + const context = { monitor: monitor.name, url: monitor.url, alerts }; + const html = await emailService.buildEmail(template, context); + return html; +}; + +export const buildEmail = async (emailService: any, monitor: Monitor): Promise => { + const template = monitor.status === false ? "serverIsUpTemplate" : "serverIsDownTemplate"; + const context = { monitor: monitor.name, url: monitor.url }; + const html = await emailService.buildEmail(template, context); + return html; +}; + +export const buildDiscordBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { localTime, utcTime } = getTime(); + let body = { + embeds: [ + { + title: `Monitor name: ${monitor.name}`, + color: monitorStatusResponse.status ? 5763719 : 15548997, + + fields: [ + { name: "Monitor", value: monitor.name, inline: false }, + { name: "Status", value: monitorStatusResponse.status ? "Up" : "Down", inline: false }, + { name: "Status Code", value: String(monitorStatusResponse.code), inline: false }, + { name: "Time", value: `${localTime} (Local) / ${utcTime} (UTC)`, inline: false }, + { name: "URL", value: monitor.url, inline: false }, + ], + footer: { text: "Checkmate" }, + }, + ], + }; + return body; +}; + +export const buildTestEmail = async (emailService: any) => { + const context = { testName: "Monitoring System" }; + const html = await emailService.buildEmail("testEmailTemplate", context); + return html; +}; + +export const getTestMessage = () => { + return "This is a test notification from Checkmate"; +}; diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts new file mode 100644 index 000000000..9e743ae53 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -0,0 +1,84 @@ +const SERVICE_NAME = "WebhookProvider"; +import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import { + buildHardwareAlerts, + buildHardwareWebhookBody, + buildWebhookBody, + getTestMessage, +} from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class WebhookProvider implements INotificationProvider { + private logger: any; + + constructor(logger: any) { + this.logger = logger; + } + private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const body = buildHardwareWebhookBody(alertsToSend, monitor); + return body; + }; + + private getContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): string => { + const body = buildWebhookBody(monitor, monitorStatusResponse); + return body; + }; + + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + let body; + if (monitor.type === "hardware") { + body = this.getHardwareContent(monitor, monitorStatusResponse); + } else { + body = this.getContent(monitor, monitorStatusResponse); + } + + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: body }, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Webhook alert failed", + service: SERVICE_NAME, + method: "sendAlert", + stack: err?.stack, + }); + return false; + } + }; + + sendTestAlert = async (notification: Notification) => { + if (!notification.address) { + return false; + } + try { + await got.post(notification.address, { + json: { text: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Webhook test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + }; +} diff --git a/server/src/service/infrastructure/notificationService.js b/server/src/service/infrastructure/notificationService.js deleted file mode 100644 index 8c6fb063f..000000000 --- a/server/src/service/infrastructure/notificationService.js +++ /dev/null @@ -1,128 +0,0 @@ -const SERVICE_NAME = "NotificationService"; -import Matrix from "./notificationProviders/matrix.js"; - -class NotificationService { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ emailService, db, logger, networkService, stringService, notificationUtils }) { - this.emailService = emailService; - this.db = db; - this.logger = logger; - this.networkService = networkService; - this.stringService = stringService; - this.notificationUtils = notificationUtils; - } - - get serviceName() { - return NotificationService.SERVICE_NAME; - } - - sendNotification = async ({ notification, subject, content, html, discordContent = null, webhookBody = null }) => { - const { type, address } = notification; - - if (type === "email") { - const messageId = await this.emailService.sendEmail(address, subject, html); - if (!messageId) return false; - return true; - } - - // Create a body for webhooks - let body = { text: content }; - if (type === "discord") { - body = !discordContent ? { content } : discordContent; - } - if (type === "webhook") { - body = !webhookBody ? { content } : webhookBody; - } - if (type === "slack" || type === "discord" || type === "webhook") { - const response = await this.networkService.requestWebhook(type, address, body); - return response.status; - } - if (type === "pager_duty") { - const response = await this.networkService.requestPagerDuty({ - message: content, - monitorUrl: subject, - routingKey: address, - }); - - return response; - } - if (type === "matrix") { - const { friendlyName, homeserverUrl, accessToken, roomId } = notification; - const monitorName = subject; - const message = content; - const matrix = new Matrix({ networkService: this.networkService, logger: this.logger }); - const success = await matrix.send({ friendlyName, homeserverUrl, accessToken, roomId, message, monitorName }); - return success; - } - }; - - async handleNotifications(networkResponse) { - const { monitor, statusChanged, prevStatus } = networkResponse; - const { type } = monitor; - if (type !== "hardware" && statusChanged === false) return false; - // if prevStatus is undefined, monitor is resuming, we're done - if (type !== "hardware" && prevStatus === undefined) return false; - - const notificationIDs = networkResponse.monitor?.notifications ?? []; - if (notificationIDs.length === 0) return false; - if (networkResponse.monitor.type === "hardware") { - const thresholds = networkResponse?.monitor?.thresholds; - - if (thresholds === undefined) return false; // No thresholds set, we're done - const metrics = networkResponse?.payload?.data ?? null; - if (metrics === null) return false; // No metrics, we're done - - const [alerts, discordContent] = await this.notificationUtils.buildHardwareAlerts(networkResponse); - if (alerts.length === 0) return false; - - const { subject, html } = await this.notificationUtils.buildHardwareEmail(networkResponse, alerts); - const content = await this.notificationUtils.buildHardwareNotificationMessage(alerts, monitor); - const webhookBody = await this.notificationUtils.buildHardwareWebhookBody(alerts, monitor); - const success = await this.notifyAll({ notificationIDs, subject, html, content, discordContent, webhookBody }); - return success; - } - - // Status monitors - const { subject, html } = await this.notificationUtils.buildStatusEmail(networkResponse); - const [content, discordContent] = await this.notificationUtils.buildWebhookMessage(networkResponse); - const success = this.notifyAll({ notificationIDs, subject, html, content, discordContent }); - return success; - } - - async notifyAll({ notificationIDs, subject, html, content, discordContent = null, webhookBody = null }) { - const notifications = await this.db.notificationModule.getNotificationsByIds(notificationIDs); - // Map each notification to a test promise - const promises = notifications.map(async (notification) => { - try { - await this.sendNotification({ notification, subject, content, html, discordContent, webhookBody }); - return true; - } catch (err) { - return false; - } - }); - - const results = await Promise.all(promises); - return results.every((r) => r === true); - } - - async getTestNotification() { - const html = await this.notificationUtils.buildTestEmail(); - const content = "This is a test notification"; - const subject = "Test Notification"; - return { subject, html, content }; - } - - async testAllNotifications(notificationIDs) { - const { subject, html, content } = await this.getTestNotification(); - return this.notifyAll({ notificationIDs, subject, html, content }); - } - - async sendTestNotification(notification) { - const { subject, html, content } = await this.getTestNotification(); - const success = await this.sendNotification({ notification, subject, content, html }); - return success; - } -} - -export default NotificationService; diff --git a/server/src/service/infrastructure/notificationUtils.js b/server/src/service/infrastructure/notificationUtils.js deleted file mode 100644 index 106c8f349..000000000 --- a/server/src/service/infrastructure/notificationUtils.js +++ /dev/null @@ -1,205 +0,0 @@ -const SERVICE_NAME = "NotificationUtils"; - -class NotificationUtils { - static SERVICE_NAME = SERVICE_NAME; - - constructor({ stringService, emailService, settingsService }) { - this.stringService = stringService; - this.emailService = emailService; - this.settingsService = settingsService; - } - - get serviceName() { - return NotificationUtils.SERVICE_NAME; - } - - buildTestEmail = async () => { - const context = { testName: "Monitoring System" }; - const html = await this.emailService.buildEmail("testEmailTemplate", context); - return html; - }; - - buildStatusEmail = async (networkResponse) => { - const { monitor, status, prevStatus } = networkResponse; - const template = prevStatus === false ? "serverIsUpTemplate" : "serverIsDownTemplate"; - const context = { monitor: monitor.name, url: monitor.url }; - const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`; - const html = await this.emailService.buildEmail(template, context); - return { subject, html }; - }; - - buildWebhookMessage = (networkResponse) => { - const { monitor, status, code, timestamp } = networkResponse; - // Format timestamp using the local system timezone - const formatTime = (timestamp) => { - const date = new Date(timestamp); - - // Get timezone abbreviation and format the date - const timeZoneAbbr = date.toLocaleTimeString("en-US", { timeZoneName: "short" }).split(" ").pop(); - - // Format the date with readable format - return ( - date - .toLocaleString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }) - .replace(/(\d+)\/(\d+)\/(\d+),\s/, "$3-$1-$2 ") + - " " + - timeZoneAbbr - ); - }; - - // Get formatted time - const formattedTime = timestamp ? formatTime(timestamp) : formatTime(new Date().getTime()); - - // Create different messages based on status with extra spacing - let messageText; - if (status === true) { - messageText = this.stringService.monitorUpAlert - .replace("{monitorName}", monitor.name) - .replace("{time}", formattedTime) - .replace("{code}", code || "Unknown"); - } else { - messageText = this.stringService.monitorDownAlert - .replace("{monitorName}", monitor.name) - .replace("{time}", formattedTime) - .replace("{code}", code || "Unknown"); - } - const dn = this.stringService.discordNotification; - let discordMessageText = { - embeds: [ - { - title: status - ? dn.uptimeAlert.replace("{monitorName}", monitor?.name ?? dn.unknown) - : dn.downtimeAlert.replace("{monitorName}", monitor?.name ?? dn.unknown), - color: status ? 5763719 : 15548997, - - fields: [ - { name: dn.monitor, value: monitor?.name ?? dn.unknown, inline: true }, - { name: dn.status, value: status ? dn.up : dn.down, inline: true }, - { name: dn.statusCode, value: String(code ?? dn.unknown), inline: true }, - { name: dn.time, value: formattedTime, inline: true }, - { name: dn.url, value: monitor?.url ?? dn.unknown, inline: false }, - ], - footer: { text: dn.checkmate }, - }, - ], - }; - return [messageText, discordMessageText]; - }; - - buildHardwareAlerts = async (networkResponse) => { - const monitor = networkResponse?.monitor; - const thresholds = networkResponse?.monitor?.thresholds; - const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; - - const metrics = networkResponse?.payload?.data; - const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; - - const { clientHost } = this.settingsService.getSettings(); - - const alerts = { - cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, - memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, - disk: disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold) ?? false, - }; - - const alertsToSend = []; - const discordEmbeds = []; - const monitorInfoFields = [ - { name: "Monitor", value: monitor.name, inline: true }, - { name: "URL", value: monitor.url, inline: false }, - ]; - const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor._id}` }; - const formatDiscordAlert = { - cpu: () => ({ - title: "CPU alert", - description: `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - color: 15548997, - fields: [...monitorInfoFields, goToIncidentField], - footer: { text: "Checkmate" }, - }), - - memory: () => ({ - title: "Memory alert", - description: `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - color: 15548997, - fields: [...monitorInfoFields, goToIncidentField], - footer: { text: "Checkmate" }, - }), - - disk: () => ({ - title: "Disk alert", - description: `Your current disk usage is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - color: 15548997, - footer: { text: "Checkmate" }, - fields: [ - ...monitorInfoFields, - ...(Array.isArray(disk) ? disk : []).map((d, idx) => ({ - name: `Disk ${idx}`, - value: `${(d?.usage_percent * 100).toFixed(0)}%`, - inline: true, - })), - goToIncidentField, - ], - }), - }; - const alertTypes = ["cpu", "memory", "disk"]; - for (const type of alertTypes) { - // Iterate over each alert type to see if any need to be decremented - if (alerts[type] === true) { - monitor[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered - - if (monitor[`${type}AlertThreshold`] <= 0) { - // If threshold drops below 0, reset and send notification - monitor[`${type}AlertThreshold`] = monitor.alertThreshold; - - const formatAlert = { - cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - memory: () => - `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - disk: () => - `Your current disk usage: ${disk - .map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`) - .join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - }; - alertsToSend.push(formatAlert[type]()); - discordEmbeds.push(formatDiscordAlert[type]()); - } - } - } - await monitor.save(); - const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null; - return [alertsToSend, discordPayload]; - }; - - buildHardwareEmail = async (networkResponse, alerts) => { - const { monitor } = networkResponse; - const template = "hardwareIncidentTemplate"; - const context = { monitor: monitor.name, url: monitor.url, alerts }; - const subject = `Monitor ${monitor.name} infrastructure alerts`; - const html = await this.emailService.buildEmail(template, context); - return { subject, html }; - }; - - buildHardwareNotificationMessage = (alerts, monitor) => { - const { clientHost } = this.settingsService.getSettings(); - const alertsHeader = [`Monitor: ${monitor.name}`, `URL: ${monitor.url}`]; - const alertFooter = [`Go to incident: ${clientHost}/infrastructure/${monitor._id}`]; - const alertText = alerts.length > 0 ? [...alertsHeader, ...alerts, ...alertFooter] : []; - return alertText.map((alert) => alert).join("\n"); - }; - buildHardwareWebhookBody = (alerts, monitor) => { - const content = alerts.map((alert) => alert).join("\n"); - const body = { text: content, name: monitor.name, url: monitor.url }; - return body; - }; -} - -export default NotificationUtils; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts new file mode 100644 index 000000000..7ca52945e --- /dev/null +++ b/server/src/service/infrastructure/notificationsService.ts @@ -0,0 +1,150 @@ +import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification } from "@/types/index.js"; +import { shouldSendHardwareAlert } from "@/service/infrastructure/notificationProviders/utils.js"; +import { INotificationsRepository } from "@/repositories/index.js"; +import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; +export interface INotificationsService { + handleNotifications: ( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + prevStatus: boolean | undefined, + statusChanged: boolean + ) => Promise; + sendTestNotification: (notification: Notification) => Promise; + testAllNotifications: (notificationIds: string[]) => Promise; +} + +const SERVICE_NAME = "NotificationsService"; + +export class NotificationsService implements INotificationsService { + static SERVICE_NAME = SERVICE_NAME; + + private notificationsRepository: INotificationsRepository; + private webhookProvider: INotificationProvider; + private emailProvider: INotificationProvider; + private slackProvider: INotificationProvider; + private discordProvider: INotificationProvider; + private pagerDutyProvider: INotificationProvider; + private matrixProvider: INotificationProvider; + private logger: any; + + constructor( + notificationsRepository: INotificationsRepository, + webhookProvider: INotificationProvider, + emailProvider: INotificationProvider, + slackProvider: INotificationProvider, + discordProvider: INotificationProvider, + pagerDutyProvider: INotificationProvider, + matrixProvider: INotificationProvider, + logger: any + ) { + this.notificationsRepository = notificationsRepository; + this.webhookProvider = webhookProvider; + this.emailProvider = emailProvider; + this.slackProvider = slackProvider; + this.discordProvider = discordProvider; + this.pagerDutyProvider = pagerDutyProvider; + this.matrixProvider = matrixProvider; + this.logger = logger; + } + + private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { + switch (notification.type) { + case "email": + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse); + case "slack": + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse); + case "discord": + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse); + case "pager_duty": + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse); + case "matrix": + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse); + case "webhook": + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse); + default: + return false; + } + }; + + private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + const notificationIds = monitor.notifications ?? []; + const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); + const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse)); + const outcomes = await Promise.all(tasks); + const succeeded = outcomes.filter(Boolean).length; + const failed = outcomes.length - succeeded; + if (failed > 0) { + this.logger.warn({ + message: `Notification send completed with ${succeeded} success, ${failed} failure(s)`, + service: SERVICE_NAME, + method: "getMonitorJob", + }); + } + // Return true if all notificaitons succeeded + return succeeded === notifications.length; + }; + + handleNotifications = async ( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + prevStatus: boolean | undefined, + statusChanged: boolean + ) => { + const { type } = monitor; + const payload = monitorStatusResponse.payload as HardwareStatusPayload; + // If this is a non-hardeware type monitor and status did not change, we're done + if (type !== "hardware" && statusChanged === false) return false; + // if prevStatus is undefined, monitor is resuming, we're done + if (type !== "hardware" && prevStatus === undefined) return false; + + // Deal with hardware thresholds + if (type === "hardware") { + const thresholds = monitor.thresholds; + + if (thresholds === undefined) return false; // No thresholds set, we're done + const metrics = payload?.data ?? null; + if (metrics === null) return false; // No metrics, we're done + + // We should send a notificaiton + + const shouldSend = shouldSendHardwareAlert(monitor, monitorStatusResponse); + if (shouldSend === false) return false; + + return await this.sendNotifications(monitor, monitorStatusResponse); + } + + // We should send a notification for non-hardware monitor status change + return await this.sendNotifications(monitor, monitorStatusResponse); + }; + + sendTestNotification = async (notification: Notification) => { + switch (notification.type) { + case "email": + return await this.emailProvider.sendTestAlert(notification); + case "slack": + return await this.slackProvider.sendTestAlert(notification); + case "discord": + return await this.discordProvider.sendTestAlert(notification); + case "pager_duty": + return await this.pagerDutyProvider.sendTestAlert(notification); + case "matrix": + return await this.matrixProvider.sendTestAlert(notification); + case "webhook": + return await this.webhookProvider.sendTestAlert(notification); + default: + return false; + } + }; + + testAllNotifications = async (notificationIds: string[]) => { + const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); + const tasks = notifications.map((notification) => this.sendTestNotification(notification)); + const outcomes = await Promise.all(tasks); + const succeeded = outcomes.filter(Boolean).length; + const failed = outcomes.length - succeeded; + if (failed > 0) { + return false; + } + return true; + }; +} diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 7d34017a5..3d9591c02 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -12,31 +12,27 @@ import type { } from "@/types/index.js"; const SERVICE_NAME = "StatusService"; -class StatusService { +export interface IStatusService { + updateRunningStats({ monitor, networkResponse }: { monitor: Monitor; networkResponse: any }): Promise; + getStatusString(status: boolean | undefined): string; + handleIncidentForCheck(check: any, monitor: Monitor, action: any, errorContext?: string): Promise; + updateMonitorStatus( + statusResponse: MonitorStatusResponse, + check: Check + ): Promise; +} + +export class StatusService implements IStatusService { static SERVICE_NAME = SERVICE_NAME; private db: any; private logger: any; private buffer: any; - private incidentService: any; private monitorsRepository: IMonitorsRepository; - constructor({ - db, - logger, - buffer, - incidentService, - monitorsRepository, - }: { - db: any; - logger: any; - buffer: any; - incidentService: any; - monitorsRepository: IMonitorsRepository; - }) { + constructor({ db, logger, buffer, monitorsRepository }: { db: any; logger: any; buffer: any; monitorsRepository: IMonitorsRepository }) { this.db = db; this.logger = logger; this.buffer = buffer; - this.incidentService = incidentService; this.monitorsRepository = monitorsRepository; } @@ -122,11 +118,10 @@ class StatusService { return "unknown"; }; - handleIncidentForCheck = async (check: any, monitor: Monitor, action: any, errorContext = "incident handling") => { + handleIncidentForCheck = async (check: Check, monitor: Monitor, action: any, errorContext = "incident handling") => { try { - let savedCheck = check; - - if (!check._id) { + let savedCheck; + if (!check.id) { try { const checkModel = new CheckModel(check); savedCheck = await checkModel.save(); @@ -144,7 +139,7 @@ class StatusService { } } - if (savedCheck && savedCheck._id) { + if (savedCheck && savedCheck.id) { try { this.buffer.addIncidentToBuffer({ monitor, check: savedCheck, action }); } catch (incidentError: any) { @@ -181,13 +176,10 @@ class StatusService { this.updateRunningStats({ monitor, networkResponse: statusResponse }); // If the status window size has changed, empty - while (monitor.statusWindow.length > monitor.statusWindowSize) { - monitor.statusWindow.shift(); - } - // Update status sliding window + monitor.statusWindow = monitor.statusWindow || []; monitor.statusWindow.push(status); - if (monitor.statusWindow.length > monitor.statusWindowSize) { + while (monitor.statusWindow.length > monitor.statusWindowSize) { monitor.statusWindow.shift(); } @@ -195,9 +187,9 @@ class StatusService { monitor.status = status; } + const prevStatus = monitor.status; let newStatus = monitor.status; let statusChanged = false; - const prevStatus = monitor.status; // Return early if not enough data points if (monitor.statusWindow.length < monitor.statusWindowSize) { @@ -330,4 +322,3 @@ class StatusService { } }; } -export default StatusService; diff --git a/server/src/types/alert.ts b/server/src/types/alert.ts new file mode 100644 index 000000000..4725ebee8 --- /dev/null +++ b/server/src/types/alert.ts @@ -0,0 +1,29 @@ +import { NotificationChannel } from "@/types/index.js"; +export interface AlertWebhookPayload { + body: Record; +} + +export interface AlertPagerDutyPayload { + routingKey: string; + monitorUrl?: string; +} + +export interface AlertMatrixPayload { + friendlyName?: string; + homeserverUrl: string; + accessToken: string; + roomId: string; + monitorName: string; +} + +export interface Alert { + channel: NotificationChannel; + address?: string; + subject: string; + message: string; + html?: string; + discordContent?: Record | null; + webhook?: AlertWebhookPayload | null; + pagerDuty?: AlertPagerDutyPayload | null; + matrix?: AlertMatrixPayload | null; +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts index fcfb6fc37..fda48f0c6 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -7,3 +7,5 @@ export * from "@/types/user.js"; export * from "@/types/invite.js"; export * from "@/types/recoveryToken.js"; export * from "@/types/settings.js"; +export * from "@/types/notification.js"; +export * from "@/types/alert.js"; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 10f89842f..6bed40316 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -13,7 +13,7 @@ import type { MonitorType, } from "@/types/index.js"; -export interface MonitorStatusResponse { +export interface MonitorStatusResponse { monitorId: string; teamId: string; type: MonitorType; diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts new file mode 100644 index 000000000..669c840ca --- /dev/null +++ b/server/src/types/notification.ts @@ -0,0 +1,17 @@ +export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const; +export type NotificationChannel = (typeof NotificationChannels)[number]; + +export interface Notification { + id: string; + userId: string; + teamId: string; + type: NotificationChannel; + notificationName: string; + address?: string; + phone?: string; + homeserverUrl?: string; + roomId?: string; + accessToken?: string; + createdAt: string; + updatedAt: string; +}