Merge pull request #3291 from bluewave-labs/feat/notification-refactor
feat: notification refactor
This commit is contained in:
17 files changed
+1091
-706
No files matched your search
+2
-1
@@ -4,4 +4,5 @@
|
||||
*.sh
|
||||
mongo
|
||||
node_modules/
|
||||
docs/architecture
|
||||
docs/architecture
|
||||
docs/reviews
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Executable → Regular
+34
-7
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user