Merge pull request #3152 from bluewave-labs/feat/notifications-refactor

feat/notifications refactor
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-01-19 14:20:36 -08:00
commit 33dfedb4d3
32 files changed
+1106 -541

No files matched your search

+1 -1
View File
@@ -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);
+39 -24
View File
@@ -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) => {
@@ -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 });
-74
View File
@@ -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);
+50
View File
@@ -0,0 +1,50 @@
import { Schema, model, type Types } from "mongoose";
import type { Notification, NotificationChannel } from "@/types/notification.js";
interface NotificationDocument extends Omit<Notification, "id" | "userId" | "teamId" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
userId: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const NotificationSchema = new Schema<NotificationDocument>(
{
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<NotificationDocument>("Notification", NotificationSchema);
export type { NotificationDocument };
export { NotificationModel };
export default NotificationModel;
+2 -2
View File
@@ -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<User, "id" | "teamId" | "createdAt" | "updatedAt"> & {
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) {
+3
View File
@@ -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";
@@ -1,5 +1,3 @@
// import Notification from "../../models/Notification.js";
// import Monitor from "../../models/Monitor.js";
const SERVICE_NAME = "notificationModule";
class NotificationModule {
+3
View File
@@ -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";
@@ -0,0 +1,8 @@
import type { Notification } from "@/types/index.js";
export interface INotificationsRepository {
// create
// fetch
findNotificationsByIds(ids: string[]): Promise<Notification[]>;
// update
// delete
}
@@ -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;
@@ -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,
});
}
}
+11
View File
@@ -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";
@@ -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,
@@ -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;
@@ -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<boolean>;
sendTestAlert(notification: Notification): Promise<boolean>;
}
@@ -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;
}
};
}
@@ -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<boolean> {
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<boolean> {
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;
}
}
@@ -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;
@@ -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;
}
};
}
@@ -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<boolean> {
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<boolean> {
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;
}
}
}
@@ -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<boolean> {
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<boolean> {
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;
}
}
}
@@ -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<string, boolean> = {
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<string> => {
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";
};
@@ -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;
}
};
}
@@ -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;
@@ -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;
@@ -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<boolean>;
sendTestNotification: (notification: Notification) => Promise<boolean>;
testAllNotifications: (notificationIds: string[]) => Promise<boolean>;
}
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<boolean> => {
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;
};
}
@@ -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<boolean>;
getStatusString(status: boolean | undefined): string;
handleIncidentForCheck(check: any, monitor: Monitor, action: any, errorContext?: string): Promise<void>;
updateMonitorStatus(
statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>,
check: Check
): Promise<StatusChangeResult>;
}
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;
+29
View File
@@ -0,0 +1,29 @@
import { NotificationChannel } from "@/types/index.js";
export interface AlertWebhookPayload {
body: Record<string, unknown>;
}
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<string, unknown> | null;
webhook?: AlertWebhookPayload | null;
pagerDuty?: AlertPagerDutyPayload | null;
matrix?: AlertMatrixPayload | null;
}
+2
View File
@@ -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";
+1 -1
View File
@@ -13,7 +13,7 @@ import type {
MonitorType,
} from "@/types/index.js";
export interface MonitorStatusResponse<T = unknown> {
export interface MonitorStatusResponse<T = any> {
monitorId: string;
teamId: string;
type: MonitorType;
+17
View File
@@ -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;
}