Merge pull request #3291 from bluewave-labs/feat/notification-refactor

feat: notification refactor
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-02-17 09:31:47 -08:00
commit 433c57b42e
17 files changed
+1091 -706

No files matched your search

+2 -1
View File
@@ -4,4 +4,5 @@
*.sh
mongo
node_modules/
docs/architecture
docs/architecture
docs/reviews
+7 -1
View File
@@ -23,6 +23,7 @@ import MaintenanceWindowService from "../service/business/maintenanceWindowServi
import { MonitorService } from "@/service/index.js";
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
import IncidentService from "../service/business/incidentService.js";
import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js";
import axios from "axios";
import got from "got";
import ping from "ping";
@@ -88,6 +89,7 @@ export type InitializedServices = {
logger: any;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
// Repositories
monitorsRepository: IMonitorsRepository;
@@ -173,6 +175,8 @@ export const initializeServices = async ({
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
const notificationsService = new NotificationsService(
notificationsRepository,
monitorsRepository,
@@ -183,7 +187,8 @@ export const initializeServices = async ({
pagerDutyProvider,
matrixProvider,
settingsService,
logger
logger,
notificationMessageBuilder
);
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
@@ -262,6 +267,7 @@ export const initializeServices = async ({
logger,
notificationsService,
statusPageService,
notificationMessageBuilder,
// Repositories
monitorsRepository,
@@ -60,11 +60,9 @@ class EmailService {
welcomeEmailTemplate: this.loadTemplate("welcomeEmail"),
employeeActivationTemplate: this.loadTemplate("employeeActivation"),
noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"),
serverIsDownTemplate: this.loadTemplate("serverIsDown"),
serverIsUpTemplate: this.loadTemplate("serverIsUp"),
passwordResetTemplate: this.loadTemplate("passwordReset"),
hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"),
testEmailTemplate: this.loadTemplate("testEmailTemplate"),
unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"),
};
};
@@ -0,0 +1,284 @@
import type { Monitor, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type {
NotificationMessage,
NotificationType,
NotificationSeverity,
ThresholdBreach,
NotificationContent,
} from "@/types/notificationMessage.js";
export interface INotificationMessageBuilder {
buildMessage(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage;
}
const SERVICE_NAME = "NotificationMessageBuilder";
export class NotificationMessageBuilder implements INotificationMessageBuilder {
static SERVICE_NAME = SERVICE_NAME;
buildMessage(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationMessage {
const type = this.determineNotificationType(decision, monitor);
const severity = this.determineSeverity(type, monitor.status);
const content = this.buildContent(type, monitor, monitorStatusResponse, decision, clientHost);
return {
type,
severity,
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
type: monitor.type,
status: monitor.status,
},
content,
clientHost,
metadata: {
teamId: monitor.teamId,
notificationReason: decision.notificationReason || "status_change",
},
};
}
private determineNotificationType(decision: MonitorActionDecision, monitor: Monitor): NotificationType {
// Down status has highest priority (critical)
if (monitor.status === "down") {
return "monitor_down";
}
// Threshold breach (only if not down)
if (decision.notificationReason === "threshold_breach") {
return "threshold_breach";
}
// Recovery from threshold breach (only for hardware monitors)
if (decision.notificationReason === "status_change" && monitor.status === "up" && monitor.type === "hardware") {
return "threshold_resolved";
}
// Standard recovery (up)
if (monitor.status === "up") {
return "monitor_up";
}
// Default to monitor_up for any other case
return "monitor_up";
}
private determineSeverity(type: NotificationType, monitorStatus: string): NotificationSeverity {
switch (type) {
case "monitor_down":
return "critical";
case "threshold_breach":
return "warning";
case "monitor_up":
case "threshold_resolved":
return "success";
case "test":
return "info";
default:
return "info";
}
}
private buildContent(
type: NotificationType,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
switch (type) {
case "monitor_down":
return this.buildMonitorDownContent(monitor, monitorStatusResponse, clientHost);
case "monitor_up":
return this.buildMonitorUpContent(monitor, clientHost);
case "threshold_breach":
return this.buildThresholdBreachContent(monitor, monitorStatusResponse, decision, clientHost);
case "threshold_resolved":
return this.buildThresholdResolvedContent(monitor, clientHost);
default:
return this.buildDefaultContent(monitor, clientHost);
}
}
private buildMonitorDownContent(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, clientHost: string): NotificationContent {
const title = `Monitor Down: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is currently down and unreachable.`;
const details = [`URL: ${monitor.url}`, `Status: Down`, `Type: ${monitor.type}`];
// Add response code if available
if (monitorStatusResponse.code) {
details.push(`Response Code: ${monitorStatusResponse.code}`);
}
// Add error message if available
if (monitorStatusResponse.message) {
details.push(`Error: ${monitorStatusResponse.message}`);
}
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildMonitorUpContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Monitor Recovered: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" is back up and operational.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildThresholdBreachContent(
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): NotificationContent {
const title = `Threshold Breach: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" has breached one or more thresholds.`;
const details = [`URL: ${monitor.url}`, `Status: Breached`, `Type: ${monitor.type}`];
const thresholds = this.extractThresholdBreaches(monitor, monitorStatusResponse);
return {
title,
summary,
details,
thresholds,
timestamp: new Date(),
};
}
private buildThresholdResolvedContent(monitor: Monitor, clientHost: string): NotificationContent {
const title = `Thresholds Resolved: ${monitor.name}`;
const summary = `Monitor "${monitor.name}" thresholds have returned to normal.`;
const details = [`URL: ${monitor.url}`, `Status: Up`, `Type: ${monitor.type}`];
return {
title,
summary,
details,
timestamp: new Date(),
};
}
private buildDefaultContent(monitor: Monitor, clientHost: string): NotificationContent {
return {
title: `Monitor: ${monitor.name}`,
summary: `Status update for monitor "${monitor.name}".`,
details: [`URL: ${monitor.url}`, `Status: ${monitor.status}`, `Type: ${monitor.type}`],
timestamp: new Date(),
};
}
private extractThresholdBreaches(monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): ThresholdBreach[] {
const breaches: ThresholdBreach[] = [];
// Check if this is a hardware monitor with threshold data
if (monitor.type !== "hardware" || !monitorStatusResponse.payload) {
return breaches;
}
// Cast to HardwareStatusPayload type
const payload = monitorStatusResponse.payload as { data?: any };
const hardware = payload.data;
if (!hardware) {
return breaches;
}
// Note: usage_percent values in hardware payload are decimals (0-1)
if (monitor.cpuAlertThreshold !== undefined && monitor.cpuAlertThreshold !== null && hardware.cpu?.usage_percent !== undefined) {
const cpuUsageDecimal = hardware.cpu.usage_percent;
const cpuPercent = cpuUsageDecimal * 100;
const threshold = monitor.cpuAlertThreshold;
if (cpuPercent > threshold) {
breaches.push({
metric: "cpu",
currentValue: cpuPercent,
threshold,
unit: "%",
formattedValue: `${cpuPercent.toFixed(1)}%`,
});
}
}
// Memory threshold breach
if (monitor.memoryAlertThreshold !== undefined && monitor.memoryAlertThreshold !== null && hardware.memory?.usage_percent !== undefined) {
const memoryUsageDecimal = hardware.memory.usage_percent;
const memoryPercent = memoryUsageDecimal * 100;
const threshold = monitor.memoryAlertThreshold;
if (memoryPercent > threshold) {
breaches.push({
metric: "memory",
currentValue: memoryPercent,
threshold,
unit: "%",
formattedValue: `${memoryPercent.toFixed(1)}%`,
});
}
}
// Disk threshold breach
if (monitor.diskAlertThreshold !== undefined && monitor.diskAlertThreshold !== null && Array.isArray(hardware.disk)) {
// Find the highest disk usage
let maxDiskUsageDecimal = 0;
for (const disk of hardware.disk) {
if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsageDecimal) {
maxDiskUsageDecimal = disk.usage_percent;
}
}
const maxDiskPercent = maxDiskUsageDecimal * 100;
const threshold = monitor.diskAlertThreshold;
if (maxDiskPercent > threshold) {
breaches.push({
metric: "disk",
currentValue: maxDiskPercent,
threshold,
unit: "%",
formattedValue: `${maxDiskPercent.toFixed(1)}%`,
});
}
}
// Temperature threshold breach
if (monitor.tempAlertThreshold !== undefined && monitor.tempAlertThreshold !== null && hardware.cpu?.temperature) {
// Temperature is an array in cpu.temperature
const temps = Array.isArray(hardware.cpu.temperature) ? hardware.cpu.temperature : [hardware.cpu.temperature];
const maxTemp = Math.max(...temps.filter((t: number) => !isNaN(t)));
const threshold = monitor.tempAlertThreshold;
if (maxTemp >= threshold) {
breaches.push({
metric: "temp",
currentValue: maxTemp,
threshold,
unit: "°C",
formattedValue: `${maxTemp.toFixed(1)}°C`,
});
}
}
return breaches;
}
}
@@ -1,13 +1,7 @@
import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
export interface INotificationProvider {
sendAlert: (
notification: Notification,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
) => Promise<boolean>;
sendMessage: (notification: Notification, message: NotificationMessage) => Promise<boolean>;
sendTestAlert(notification: Notification): Promise<boolean>;
}
@@ -1,8 +1,8 @@
const SERVICE_NAME = "DiscordProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import type { Notification } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { buildHardwareAlerts, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import got from "got";
export class DiscordProvider implements INotificationProvider {
@@ -11,58 +11,7 @@ export class DiscordProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard format
if (decision.notificationReason === "status_change") {
return buildDiscordBody(monitor, monitorStatusResponse);
}
// For threshold breaches, use hardware alert format
const { discordPayload } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
return discordPayload;
};
sendAlert = async (
notification: Notification,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
) => {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision);
} 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;
@@ -86,4 +35,110 @@ export class DiscordProvider implements INotificationProvider {
return false;
}
};
/**
* New unified message format implementation
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
this.logger.warn({
message: "Discord notification missing webhook URL",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
const embed = this.buildDiscordEmbed(message);
try {
await got.post(notification.address, {
json: { embeds: [embed] },
headers: {
"Content-Type": "application/json",
},
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Discord notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildDiscordEmbed(message: NotificationMessage): any {
// Map severity to Discord embed colors
const colorMap: Record<string, number> = {
critical: 0xdc2626, // red-600
warning: 0xf59e0b, // amber-500
info: 0x3b82f6, // blue-500
success: 0x10b981, // green-500
};
const color = colorMap[message.severity] || colorMap.info;
const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
// Add monitor details
fields.push({
name: "Monitor",
value: message.monitor.name,
inline: true,
});
fields.push({
name: "Type",
value: message.monitor.type.toUpperCase(),
inline: true,
});
fields.push({
name: "Status",
value: message.monitor.status.charAt(0).toUpperCase() + message.monitor.status.slice(1),
inline: true,
});
// Add monitor URL
fields.push({
name: "URL",
value: message.monitor.url,
inline: false,
});
// Add threshold breaches if present
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdLines = message.content.thresholds
.map((t) => `• **${t.metric.toUpperCase()}**: ${t.formattedValue} (threshold: ${t.threshold}${t.unit})`)
.join("\n");
fields.push({
name: "Threshold Breaches",
value: thresholdLines,
inline: false,
});
}
// Add details if present
if (message.content.details && message.content.details.length > 0) {
const detailsText = message.content.details.join("\n");
fields.push({
name: "Details",
value: detailsText,
inline: false,
});
}
return {
title: message.content.title,
description: message.content.summary,
color,
fields,
timestamp: message.content.timestamp.toISOString(),
};
}
}
@@ -2,7 +2,8 @@ const SERVICE_NAME = "EmailProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { buildHardwareAlerts, buildHardwareEmail, buildEmail, buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import { buildTestEmail } from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
export class EmailProvider implements INotificationProvider {
private emailService: any;
@@ -13,68 +14,6 @@ export class EmailProvider implements INotificationProvider {
this.logger = logger;
}
private buildHardwareEmail = async (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard email format
if (decision.notificationReason === "status_change") {
return await buildEmail(this.emailService, monitor);
}
// For threshold breaches, use hardware alert format
const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
const html = buildHardwareEmail(this.emailService, monitor, alertsToSend);
return html;
};
async sendAlert(
notification: Notification,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision,
clientHost: string
): Promise<boolean> {
// For grouped notifications (identified by ":" in name), customize subject to indicate multiple services.
// Example: "2 services: Service A, Service B" becomes "Alert: 2 services are down"
const isGroupedNotification = monitor.name.includes(":");
// Build subject based on notification reason and monitor status
let subject: string;
if (isGroupedNotification) {
subject = `Alert: ${monitor.name} are down`;
} else if (decision.notificationReason === "threshold_breach") {
subject = `Monitor ${monitor.name} threshold breached`;
} else if (monitor.status === "up") {
subject = `Monitor ${monitor.name} is back up`;
} else {
subject = `Monitor ${monitor.name} is down`;
}
let html;
if (monitor.type === "hardware") {
html = await this.buildHardwareEmail(clientHost, monitor, monitorStatusResponse, decision);
} 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);
@@ -90,4 +29,85 @@ export class EmailProvider implements INotificationProvider {
}
return true;
}
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
const subject = this.buildSubject(message);
const html = await this.buildEmailFromMessage(message);
const messageId = await this.emailService.sendEmail(notification.address, subject, html);
if (!messageId) {
this.logger.warn({
message: "Email notification failed",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
return true;
}
private buildSubject(message: NotificationMessage): string {
switch (message.type) {
case "monitor_down":
return `Monitor ${message.monitor.name} is down`;
case "monitor_up":
return `Monitor ${message.monitor.name} is back up`;
case "threshold_breach":
return `Monitor ${message.monitor.name} threshold breached`;
case "threshold_resolved":
return `Monitor ${message.monitor.name} thresholds resolved`;
default:
return `Alert: ${message.monitor.name}`;
}
}
private async buildEmailFromMessage(message: NotificationMessage): Promise<string> {
const context = {
title: message.content.title,
summary: message.content.summary,
monitorName: message.monitor.name,
monitorUrl: message.monitor.url,
monitorType: message.monitor.type,
monitorStatus: message.monitor.status,
headerColor: this.getColorForSeverity(message.severity),
thresholds: message.content.thresholds,
details: message.content.details,
incidentUrl: message.content.incident?.url,
};
this.logger.info({
message: "[DEBUG] Building email from message",
service: SERVICE_NAME,
method: "buildEmailFromMessage",
details: { context },
});
const html = await this.emailService.buildEmail("unifiedNotificationTemplate", context);
this.logger.info({
message: "[DEBUG] Email HTML generated",
service: SERVICE_NAME,
method: "buildEmailFromMessage",
details: {
htmlLength: html?.length || 0,
htmlPreview: html?.substring(0, 200),
},
});
return html;
}
private getColorForSeverity(severity: string): string {
const colorMap: Record<string, string> = {
critical: "red",
warning: "#f59e0b",
info: "#3b82f6",
success: "green",
};
return colorMap[severity] ?? "#3b82f6";
}
}
@@ -1,14 +1,9 @@
const SERVICE_NAME = "MatrixProvider";
import got from "got";
import type { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js";
import {
buildHardwareAlerts,
buildHardwareNotificationMessage,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { Notification } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
export class MatrixProvider implements INotificationProvider {
private logger: any;
@@ -16,73 +11,6 @@ export class MatrixProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard format
if (decision.notificationReason === "status_change") {
return buildWebhookBody(monitor, monitorStatusResponse);
}
// For threshold breaches, use hardware alert format
const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
const body = buildHardwareNotificationMessage(clientHost, 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,
decision: MonitorActionDecision,
clientHost: string
) => {
const { homeserverUrl, accessToken, roomId } = notification;
let content;
if (monitor.type === "hardware") {
content = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision);
} 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;
@@ -113,4 +41,146 @@ export class MatrixProvider implements INotificationProvider {
return false;
}
};
/**
* New unified message format - builds Matrix HTML message from NotificationMessage
*/
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
const { homeserverUrl, accessToken, roomId } = notification;
if (!homeserverUrl || !accessToken || !roomId) {
return false;
}
const { plainText, htmlText } = this.buildMatrixMessage(message);
const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message?access_token=${accessToken}`;
const body = {
msgtype: "m.text",
body: plainText,
format: "org.matrix.custom.html",
formatted_body: htmlText,
};
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 notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
};
/**
* Build Matrix message from NotificationMessage
* Returns both plain text and HTML formatted versions
* Matrix supports HTML subset for rich formatting
*/
private buildMatrixMessage(message: NotificationMessage): { plainText: string; htmlText: string } {
const plainLines: string[] = [];
const htmlLines: string[] = [];
// Determine color based on severity
const colorMap = {
critical: "#FF0000", // Red
warning: "#FFA500", // Orange
success: "#00FF00", // Green
info: "#0000FF", // Blue
};
const color = colorMap[message.severity] || "#808080";
// Title
plainLines.push(`# ${message.content.title}`);
htmlLines.push(`<h2 style="color: ${color};">${this.escapeHtml(message.content.title)}</h2>`);
// Summary
plainLines.push("");
plainLines.push(message.content.summary);
htmlLines.push(`<p>${this.escapeHtml(message.content.summary)}</p>`);
// Monitor details
plainLines.push("");
plainLines.push("## Monitor Details");
plainLines.push(`- Name: ${message.monitor.name}`);
plainLines.push(`- URL: ${message.monitor.url}`);
plainLines.push(`- Type: ${message.monitor.type}`);
plainLines.push(`- Status: ${message.monitor.status}`);
htmlLines.push(`<h3>Monitor Details</h3>`);
htmlLines.push(`<ul>`);
htmlLines.push(`<li><strong>Name:</strong> ${this.escapeHtml(message.monitor.name)}</li>`);
htmlLines.push(`<li><strong>URL:</strong> <a href="${this.escapeHtml(message.monitor.url)}">${this.escapeHtml(message.monitor.url)}</a></li>`);
htmlLines.push(`<li><strong>Type:</strong> ${this.escapeHtml(message.monitor.type)}</li>`);
htmlLines.push(`<li><strong>Status:</strong> ${this.escapeHtml(message.monitor.status)}</li>`);
htmlLines.push(`</ul>`);
// Threshold breaches (if any)
if (message.content.thresholds && message.content.thresholds.length > 0) {
plainLines.push("");
plainLines.push("## Threshold Breaches");
htmlLines.push(`<h3>Threshold Breaches</h3>`);
htmlLines.push(`<ul>`);
message.content.thresholds.forEach((breach) => {
plainLines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
htmlLines.push(
`<li><strong>${this.escapeHtml(breach.metric.toUpperCase())}:</strong> ${this.escapeHtml(breach.formattedValue)} (threshold: ${breach.threshold}${this.escapeHtml(breach.unit)})</li>`
);
});
htmlLines.push(`</ul>`);
}
// Additional details (if any)
if (message.content.details && message.content.details.length > 0) {
plainLines.push("");
plainLines.push("## Additional Information");
htmlLines.push(`<h3>Additional Information</h3>`);
htmlLines.push(`<ul>`);
message.content.details.forEach((detail) => {
plainLines.push(`- ${detail}`);
htmlLines.push(`<li>${this.escapeHtml(detail)}</li>`);
});
htmlLines.push(`</ul>`);
}
// Incident link (if incident exists)
if (message.content.incident) {
const incidentUrl = `${message.clientHost}/infrastructure/${message.monitor.id}`;
plainLines.push("");
plainLines.push(`View Incident: ${incidentUrl}`);
htmlLines.push(`<p><a href="${this.escapeHtml(incidentUrl)}">View Incident</a></p>`);
}
// Footer with timestamp
plainLines.push("");
plainLines.push(`Checkmate | ${new Date(message.content.timestamp).toUTCString()}`);
htmlLines.push(`<hr>`);
htmlLines.push(`<p><small>Checkmate | ${new Date(message.content.timestamp).toUTCString()}</small></p>`);
return {
plainText: plainLines.join("\n"),
htmlText: htmlLines.join(""),
};
}
/**
* Escape HTML special characters for safe rendering
*/
private escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
}
@@ -3,12 +3,8 @@ import got from "got";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import { INotificationProvider } from "@/service/index.js";
import {
buildHardwareAlerts,
buildHardwareNotificationMessage,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
export class PagerDutyProvider implements INotificationProvider {
private logger: any;
@@ -16,67 +12,6 @@ export class PagerDutyProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard format
if (decision.notificationReason === "status_change") {
return buildWebhookBody(monitor, monitorStatusResponse);
}
// For threshold breaches, use hardware alert format
const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
const body = buildHardwareNotificationMessage(clientHost, 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,
decision: MonitorActionDecision,
clientHost: string
): Promise<boolean> {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision);
} 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 {
@@ -105,4 +40,103 @@ export class PagerDutyProvider implements INotificationProvider {
return false;
}
}
/**
* New unified message format implementation
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
this.logger.warn({
message: "PagerDuty notification missing routing key",
service: SERVICE_NAME,
method: "sendMessage",
});
return false;
}
try {
const payload = this.buildPagerDutyPayload(notification, message);
await got.post("https://events.pagerduty.com/v2/enqueue", {
json: payload,
});
this.logger.info({
message: "[NEW] PagerDuty notification sent via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "PagerDuty notification failed",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
private buildPagerDutyPayload(notification: Notification, message: NotificationMessage): any {
// Map our notification type to PagerDuty event_action
const eventAction = message.type === "monitor_up" || message.type === "threshold_resolved" ? "resolve" : "trigger";
// Map severity to PagerDuty severity levels
const severityMap: Record<string, string> = {
critical: "critical",
warning: "warning",
info: "info",
success: "info",
};
const severity = severityMap[message.severity] || "error";
// Build deduplication key based on monitor ID for event grouping
const dedupKey = `checkmate-${message.monitor.id}`;
// Build summary
let summary = `${message.content.title} - ${message.content.summary}`;
// Add threshold details to summary if present
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdInfo = message.content.thresholds
.map((t) => `${t.metric.toUpperCase()}: ${t.formattedValue}/${t.threshold}${t.unit}`)
.join(", ");
summary += ` [${thresholdInfo}]`;
}
// Build custom details
const customDetails: any = {
monitor_name: message.monitor.name,
monitor_url: message.monitor.url,
monitor_type: message.monitor.type,
monitor_status: message.monitor.status,
notification_type: message.type,
};
if (message.content.thresholds && message.content.thresholds.length > 0) {
customDetails.threshold_breaches = message.content.thresholds.map((t) => ({
metric: t.metric,
current: t.formattedValue,
threshold: `${t.threshold}${t.unit}`,
}));
}
if (message.content.details && message.content.details.length > 0) {
customDetails.details = message.content.details;
}
return {
routing_key: notification.address,
dedup_key: dedupKey,
event_action: eventAction,
payload: {
summary,
severity,
source: message.monitor.url,
timestamp: message.content.timestamp.toISOString(),
custom_details: customDetails,
},
};
}
}
@@ -2,12 +2,8 @@ const SERVICE_NAME = "SlackProvider";
import type { Monitor, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import {
buildHardwareAlerts,
buildHardwareNotificationMessage,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got, { HTTPError } from "got";
export class SlackProvider implements INotificationProvider {
@@ -16,64 +12,6 @@ export class SlackProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard format
if (decision.notificationReason === "status_change") {
return buildWebhookBody(monitor, monitorStatusResponse);
}
// For threshold breaches, use hardware alert format
const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
const body = buildHardwareNotificationMessage(clientHost, 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,
decision: MonitorActionDecision,
clientHost: string
): Promise<boolean> {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision);
} 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) {
@@ -99,4 +37,162 @@ export class SlackProvider implements INotificationProvider {
return false;
}
}
/**
* New unified message format - builds Slack Block Kit payload from NotificationMessage
*/
async sendMessage(notification: Notification, message: NotificationMessage): Promise<boolean> {
if (!notification.address) {
return false;
}
const payload = this.buildSlackPayload(message);
try {
await got.post(notification.address, {
json: payload,
headers: {
"Content-Type": "application/json",
},
});
this.logger.info({
message: "[NEW] Slack notification sent via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "[NEW] Slack alert failed via sendMessage",
service: SERVICE_NAME,
method: "sendMessage",
stack: err?.stack,
});
return false;
}
}
/**
* Build Slack Block Kit payload from NotificationMessage
* Uses Slack's rich formatting with sections, fields, and context blocks
*/
private buildSlackPayload(message: NotificationMessage): object {
const blocks: any[] = [];
// Determine color based on severity
const colorMap = {
critical: "#FF0000", // Red
warning: "#FFA500", // Orange
success: "#00FF00", // Green
info: "#0000FF", // Blue
};
const color = colorMap[message.severity] || "#808080";
// Header block with title
blocks.push({
type: "header",
text: {
type: "plain_text",
text: message.content.title,
emoji: true,
},
});
// Summary section
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: message.content.summary,
},
});
// Monitor details as fields
const monitorFields = [
{ type: "mrkdwn", text: `*Name:*\n${message.monitor.name}` },
{ type: "mrkdwn", text: `*Type:*\n${message.monitor.type}` },
{ type: "mrkdwn", text: `*Status:*\n${message.monitor.status}` },
{ type: "mrkdwn", text: `*URL:*\n${message.monitor.url}` },
];
blocks.push({
type: "section",
fields: monitorFields,
});
// Divider
blocks.push({ type: "divider" });
// Threshold breaches (if any)
if (message.content.thresholds && message.content.thresholds.length > 0) {
const thresholdText = message.content.thresholds
.map((breach) => `• *${breach.metric.toUpperCase()}:* ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`)
.join("\n");
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*Threshold Breaches:*\n${thresholdText}`,
},
});
blocks.push({ type: "divider" });
}
// Additional details (if any)
if (message.content.details && message.content.details.length > 0) {
const detailsText = message.content.details.map((detail) => `• ${detail}`).join("\n");
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `*Additional Information:*\n${detailsText}`,
},
});
}
// Incident link button (if incident exists)
if (message.content.incident) {
blocks.push({
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Incident",
emoji: true,
},
url: `${message.clientHost}/infrastructure/${message.monitor.id}`,
style: "primary",
},
],
});
}
// Context footer with timestamp
blocks.push({
type: "context",
elements: [
{
type: "mrkdwn",
text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`,
},
],
});
// Return Slack payload with blocks and attachment color
return {
blocks,
attachments: [
{
color,
blocks: [], // Empty blocks in attachment for color bar
},
],
};
}
}
@@ -1,170 +1,5 @@
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 } => {
// Thresholds are stored as percentages (0-100), convert to decimal (0-1) for comparison
const cpuThreshold = monitor.cpuAlertThreshold !== undefined ? monitor.cpuAlertThreshold / 100 : -1;
const memoryThreshold = monitor.memoryAlertThreshold !== undefined ? monitor.memoryAlertThreshold / 100 : -1;
const diskThreshold = monitor.diskAlertThreshold !== undefined ? monitor.diskAlertThreshold / 100 : -1;
const tempThreshold = monitor.tempAlertThreshold !== undefined ? monitor.tempAlertThreshold : -1;
const payload = networkResponse?.payload as HardwareStatusPayload;
const metrics = payload.data || {};
const { cpu = {}, memory = {}, disk = [] } = metrics;
const cpuUsage = cpu.usage_percent ?? -1;
const memoryUsage = memory.usage_percent ?? -1;
// Get max temperature from CPU temperature sensors array
const temps = cpu.temperature ?? [];
const maxTemp = temps.length > 0 ? Math.max(...temps) : -1;
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,
temp: tempThreshold !== -1 && maxTemp > tempThreshold ? true : 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,
],
}),
temp: () => ({
title: "Temperature alert",
description: `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`,
color: 15548997,
fields: [...monitorInfoFields, goToIncidentField],
footer: { text: "Checkmate" },
}),
};
const alertTypes = ["cpu", "memory", "disk", "temp"] as const;
for (const type of alertTypes) {
if (alerts[type] === true) {
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)}%)`,
temp: () => `Your current temperature (${maxTemp.toFixed(0)}°C) is above your threshold (${tempThreshold.toFixed(0)}°C)`,
};
alertsToSend.push(formatAlert[type]());
discordEmbeds.push(formatDiscordAlert[type]());
}
}
const discordPayload = discordEmbeds.length ? { embeds: discordEmbeds } : null;
return { alertsToSend, discordPayload };
};
export const buildHardwareNotificationMessage = (clientHost: string, alerts: string[], monitor: Monitor): string => {
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.join("\n");
};
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 === "up" ? "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;
};
// Test notification helpers - used by all providers for test alerts
export const buildTestEmail = async (emailService: any) => {
const context = { testName: "Monitoring System" };
const html = await emailService.buildEmail("testEmailTemplate", context);
@@ -2,12 +2,8 @@ const SERVICE_NAME = "WebhookProvider";
import type { Monitor, Alert, Notification, MonitorStatusResponse } from "@/types/index.js";
import { INotificationProvider } from "@/service/index.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import {
buildHardwareAlerts,
buildHardwareNotificationMessage,
buildWebhookBody,
getTestMessage,
} from "@/service/infrastructure/notificationProviders/utils.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js";
import got from "got";
export class WebhookProvider implements INotificationProvider {
@@ -16,65 +12,96 @@ export class WebhookProvider implements INotificationProvider {
constructor(logger: any) {
this.logger = logger;
}
private getHardwareContent = (
clientHost: string,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
) => {
// For status changes (recovery), use standard format
if (decision.notificationReason === "status_change") {
return buildWebhookBody(monitor, monitorStatusResponse);
}
// For threshold breaches, use hardware alert format
const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse);
const body = buildHardwareNotificationMessage(clientHost, 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,
decision: MonitorActionDecision,
clientHost: string
) => {
let body;
if (monitor.type === "hardware") {
body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision);
} else {
body = this.getContent(monitor, monitorStatusResponse);
}
sendMessage = async (notification: Notification, message: NotificationMessage): Promise<boolean> => {
if (!notification.address) {
return false;
}
// Build webhook payload from unified message
const payload = this.buildWebhookPayload(message);
try {
await got.post(notification.address, {
json: { text: body },
json: payload,
headers: {
"Content-Type": "application/json",
},
});
this.logger.info({
message: "Webhook notification sent",
service: SERVICE_NAME,
method: "sendMessage",
});
return true;
} catch (error) {
const err = error as Error;
this.logger.warn({
message: "Webhook alert failed",
service: SERVICE_NAME,
method: "sendAlert",
method: "sendMessage",
stack: err?.stack,
});
return false;
}
};
/**
* Build webhook payload from NotificationMessage
* Format: { text: string, severity: string, monitor: object, details: object }
*/
private buildWebhookPayload(message: NotificationMessage): object {
const lines: string[] = [];
// Title and summary
lines.push(`**${message.content.title}**`);
lines.push(message.content.summary);
lines.push("");
// Monitor information
lines.push("**Monitor Details:**");
lines.push(`- Name: ${message.monitor.name}`);
lines.push(`- URL: ${message.monitor.url}`);
lines.push(`- Type: ${message.monitor.type}`);
lines.push(`- Status: ${message.monitor.status}`);
lines.push("");
// Additional details
if (message.content.details && message.content.details.length > 0) {
lines.push("**Additional Information:**");
message.content.details.forEach((detail) => lines.push(`- ${detail}`));
lines.push("");
}
// Threshold breaches (for hardware monitors)
if (message.content.thresholds && message.content.thresholds.length > 0) {
lines.push("**Threshold Breaches:**");
message.content.thresholds.forEach((breach) => {
lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`);
});
lines.push("");
}
// Incident link
if (message.content.incident) {
lines.push(`[View Incident](${message.clientHost}/infrastructure/${message.monitor.id})`);
}
// Return webhook payload with both text and structured data
return {
text: lines.join("\n"),
severity: message.severity,
type: message.type,
monitor: {
id: message.monitor.id,
name: message.monitor.name,
url: message.monitor.url,
status: message.monitor.status,
},
timestamp: message.content.timestamp,
};
}
sendTestAlert = async (notification: Notification) => {
if (!notification.address) {
return false;
@@ -1,9 +1,11 @@
import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js";
import type { NotificationMessage } from "@/types/notificationMessage.js";
import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js";
import { INotificationProvider } from "./notificationProviders/INotificationProvider.js";
import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js";
export interface INotificationsService {
createNotification: (notificationData: Partial<Notification>) => Promise<Notification>;
@@ -32,6 +34,7 @@ export class NotificationsService implements INotificationsService {
private matrixProvider: INotificationProvider;
private logger: ILogger;
private settingsService: ISettingsService;
private notificationMessageBuilder: INotificationMessageBuilder;
constructor(
notificationsRepository: INotificationsRepository,
@@ -43,7 +46,8 @@ export class NotificationsService implements INotificationsService {
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
settingsService: ISettingsService,
logger: any
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
) {
this.notificationsRepository = notificationsRepository;
this.monitorsRepository = monitorsRepository;
@@ -55,31 +59,45 @@ export class NotificationsService implements INotificationsService {
this.matrixProvider = matrixProvider;
this.settingsService = settingsService;
this.logger = logger;
this.notificationMessageBuilder = notificationMessageBuilder;
}
private send = async (
notification: Notification,
monitor: Monitor,
monitorStatusResponse: MonitorStatusResponse,
decision: MonitorActionDecision
decision: MonitorActionDecision,
notificationMessage: NotificationMessage | undefined
): Promise<boolean> => {
const settings = this.settingsService.getSettings();
const clientHost = settings.clientHost || "Host not defined";
if (!notificationMessage) {
this.logger.warn({
message: "Notification message not provided",
service: SERVICE_NAME,
method: "send",
});
return false;
}
// Route to provider based on notification type
switch (notification.type) {
case "email":
return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
case "slack":
return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
case "discord":
return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
case "pager_duty":
return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
case "matrix":
return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
case "webhook":
return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost);
return await this.webhookProvider.sendMessage!(notification, notificationMessage);
case "slack":
return await this.slackProvider.sendMessage!(notification, notificationMessage);
case "matrix":
return await this.matrixProvider.sendMessage!(notification, notificationMessage);
case "pager_duty":
return await this.pagerDutyProvider.sendMessage!(notification, notificationMessage);
case "discord":
return await this.discordProvider.sendMessage!(notification, notificationMessage);
case "email":
return await this.emailProvider.sendMessage!(notification, notificationMessage);
default:
this.logger.warn({
message: `Unknown notification type: ${notification.type}`,
service: SERVICE_NAME,
method: "send",
});
return false;
}
};
@@ -88,7 +106,12 @@ export class NotificationsService implements INotificationsService {
const notificationIds = monitor.notifications ?? [];
const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds);
const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision));
// Build notification message once for all notifications
const settings = this.settingsService.getSettings();
const clientHost = settings.clientHost || "Host not defined";
const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost);
const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage));
const outcomes = await Promise.all(tasks);
const succeeded = outcomes.filter(Boolean).length;
@@ -105,7 +128,6 @@ export class NotificationsService implements INotificationsService {
};
handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => {
// Early return if no notification should be sent
if (!decision.shouldSendNotification) {
return false;
}
@@ -1,69 +0,0 @@
<mjml>
<mj-head>
<mj-font
name="Roboto"
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text
font-weight="300"
font-size="16px"
color="#616161"
line-height="24px"
></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text
align="left"
font-size="10px"
>
Message from BlueWave Infrastructure Monitoring
</mj-text>
</mj-column>
<mj-column
width="45%"
padding-top="20px"
>
<mj-text
align="center"
font-weight="500"
padding="0px"
font-size="18px"
color="red"
>
Infrastructure Alerts
</mj-text>
<mj-divider
border-width="2px"
border-color="#616161"
></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>{{ monitor }} at {{ url }} has the following infrastructure alerts:</p>
{{#each alerts}}
<p>• {{ this }}</p>
{{/each}}
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-button background-color="#1570EF"> View Infrastructure Details </mj-button>
<mj-text font-size="12px">
<p>This email was sent by BlueWave Infrastructure Monitoring.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
-68
View File
@@ -1,68 +0,0 @@
<mjml>
<mj-head>
<mj-font
name="Roboto"
href="https://fonts.googleapis.com/css?family=Roboto:300,500"
></mj-font>
<mj-attributes>
<mj-all font-family="Roboto, Helvetica, sans-serif"></mj-all>
<mj-text
font-weight="300"
font-size="16px"
color="#616161"
line-height="24px"
></mj-text>
<mj-section padding="0px"></mj-section>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section padding="20px 0">
<mj-column width="100%">
<mj-text
align="left"
font-size="10px"
>
Message from Checkmate Service
</mj-text>
</mj-column>
<mj-column
width="45%"
padding-top="20px"
>
<mj-text
align="center"
font-weight="500"
padding="0px"
font-size="18px"
color="green"
>
{{ monitor }} is up
</mj-text>
<mj-divider
border-width="2px"
border-color="#616161"
></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>Your latest incident is resolved and your monitored service is up again.</p>
<p><b>Monitor name:</b> {{ monitor }}</p>
<p><b>URL:</b> {{ url }}</p>
</mj-text>
</mj-column>
<mj-column width="100%">
<mj-divider
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-button background-color="#1570EF"> View incident details </mj-button>
<mj-text font-size="12px">
<p>This email was sent by Checkmate.</p>
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
@@ -34,9 +34,9 @@
font-weight="500"
padding="0px"
font-size="18px"
color="red"
color="{{headerColor}}"
>
{{ monitor }} is down
{{ title }}
</mj-text>
<mj-divider
border-width="2px"
@@ -47,10 +47,30 @@
<mj-section>
<mj-column width="100%">
<mj-text>
<p>Hello {{ name }}!</p>
<p>We detected an incident on one of your monitors. Your service is currently down. We'll send a message to you once it is up again.</p>
<p><b>Monitor name:</b> {{ monitor }}</p>
<p><b>URL:</b> {{ url }}</p>
<p>Hello!</p>
<p>{{ summary }}</p>
<p><b>Monitor name:</b> {{ monitorName }}</p>
<p><b>URL:</b> {{ monitorUrl }}</p>
<p><b>Type:</b> {{ monitorType }}</p>
<p><b>Status:</b> {{ monitorStatus }}</p>
{{#if thresholds}}
<p><b>Threshold Breaches:</b></p>
<ul>
{{#each thresholds}}
<li>
<b>{{ metric }}:</b> {{ formattedValue }} (threshold: {{ threshold }}{{ unit }})
</li>
{{/each}}
</ul>
{{/if}}
{{#if details}}
<p><b>Additional Details:</b></p>
<ul>
{{#each details}}
<li>{{ this }}</li>
{{/each}}
</ul>
{{/if}}
</mj-text>
</mj-column>
<mj-column width="100%">
@@ -58,7 +78,14 @@
border-width="1px"
border-color="#E0E0E0"
></mj-divider>
<mj-button background-color="#1570EF"> View incident details </mj-button>
{{#if incidentUrl}}
<mj-button
background-color="#1570EF"
href="{{incidentUrl}}"
>
View incident details
</mj-button>
{{/if}}
<mj-text font-size="12px">
<p>This email was sent by Checkmate.</p>
</mj-text>
+53
View File
@@ -0,0 +1,53 @@
/**
* Unified notification message types for cross-provider consistency
* Part of notification system unification effort
*/
export type NotificationType = "monitor_down" | "monitor_up" | "threshold_breach" | "threshold_resolved" | "test";
export type NotificationSeverity = "critical" | "warning" | "info" | "success";
export interface MonitorInfo {
id: string;
name: string;
url: string;
type: string;
status: string;
}
export interface ThresholdBreach {
metric: "cpu" | "memory" | "disk" | "temp";
currentValue: number;
threshold: number;
unit: string;
formattedValue: string; // e.g., "85%" or "72°C"
}
export interface IncidentInfo {
id: string;
url: string;
createdAt: Date;
resolvedAt?: Date;
duration?: string;
}
export interface NotificationContent {
title: string;
summary: string;
details?: string[];
thresholds?: ThresholdBreach[];
incident?: IncidentInfo;
timestamp: Date;
}
export interface NotificationMessage {
type: NotificationType;
severity: NotificationSeverity;
monitor: MonitorInfo;
content: NotificationContent;
clientHost: string;
metadata: {
teamId: string;
notificationReason: string;
};
}