diff --git a/.gitignore b/.gitignore index f6e292bc9..f411b5c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.sh mongo node_modules/ -docs/architecture \ No newline at end of file +docs/architecture +docs/reviews \ No newline at end of file diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 67c16ff6a..8707bc643 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -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, diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index 1f46a080a..26e3e3e6d 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -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"), }; }; diff --git a/server/src/service/infrastructure/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts new file mode 100644 index 000000000..95e0da2d7 --- /dev/null +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -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; + } +} diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 21a221c9c..87056ef7a 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -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; + sendMessage: (notification: Notification, message: NotificationMessage) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index 560c18aae..2f0b07fe8 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -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 { + 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 = { + 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(), + }; + } } diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index fd15034d1..effd94946 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -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 { - // 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 { 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 { + 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 { + 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 = { + critical: "red", + warning: "#f59e0b", + info: "#3b82f6", + success: "green", + }; + return colorMap[severity] ?? "#3b82f6"; + } } diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index f2543886d..8d58e6ead 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -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 => { + 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(`

${this.escapeHtml(message.content.title)}

`); + + // Summary + plainLines.push(""); + plainLines.push(message.content.summary); + htmlLines.push(`

${this.escapeHtml(message.content.summary)}

`); + + // 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(`

Monitor Details

`); + htmlLines.push(`
    `); + htmlLines.push(`
  • Name: ${this.escapeHtml(message.monitor.name)}
  • `); + htmlLines.push(`
  • URL: ${this.escapeHtml(message.monitor.url)}
  • `); + htmlLines.push(`
  • Type: ${this.escapeHtml(message.monitor.type)}
  • `); + htmlLines.push(`
  • Status: ${this.escapeHtml(message.monitor.status)}
  • `); + htmlLines.push(`
`); + + // Threshold breaches (if any) + if (message.content.thresholds && message.content.thresholds.length > 0) { + plainLines.push(""); + plainLines.push("## Threshold Breaches"); + htmlLines.push(`

Threshold Breaches

`); + htmlLines.push(`
    `); + + message.content.thresholds.forEach((breach) => { + plainLines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + htmlLines.push( + `
  • ${this.escapeHtml(breach.metric.toUpperCase())}: ${this.escapeHtml(breach.formattedValue)} (threshold: ${breach.threshold}${this.escapeHtml(breach.unit)})
  • ` + ); + }); + + htmlLines.push(`
`); + } + + // Additional details (if any) + if (message.content.details && message.content.details.length > 0) { + plainLines.push(""); + plainLines.push("## Additional Information"); + htmlLines.push(`

Additional Information

`); + htmlLines.push(`
    `); + + message.content.details.forEach((detail) => { + plainLines.push(`- ${detail}`); + htmlLines.push(`
  • ${this.escapeHtml(detail)}
  • `); + }); + + htmlLines.push(`
`); + } + + // 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(`

View Incident

`); + } + + // Footer with timestamp + plainLines.push(""); + plainLines.push(`Checkmate | ${new Date(message.content.timestamp).toUTCString()}`); + htmlLines.push(`
`); + htmlLines.push(`

Checkmate | ${new Date(message.content.timestamp).toUTCString()}

`); + + 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, "'"); + } } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 291cd7de5..f96ea6cc5 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -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 { - 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 { try { @@ -105,4 +40,103 @@ export class PagerDutyProvider implements INotificationProvider { return false; } } + + /** + * New unified message format implementation + */ + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + 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 = { + 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, + }, + }; + } } diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index f99af48d4..a4cf0ed26 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -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 { - 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 { 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 { + 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 + }, + ], + }; + } } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 72797c1d0..874152413 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -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 = { - 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 => { - 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); diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 17d871950..532714827 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -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 => { 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; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 5f057f0c5..4331c5ff0 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -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) => Promise; @@ -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 => { - 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; } diff --git a/server/src/templates/hardwareIncident.mjml b/server/src/templates/hardwareIncident.mjml deleted file mode 100755 index 3f50a3d58..000000000 --- a/server/src/templates/hardwareIncident.mjml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - Message from BlueWave Infrastructure Monitoring - - - - - Infrastructure Alerts - - - - - - - -

Hello {{ name }}!

-

{{ monitor }} at {{ url }} has the following infrastructure alerts:

- {{#each alerts}} -

• {{ this }}

- {{/each}} -
-
- - - View Infrastructure Details - -

This email was sent by BlueWave Infrastructure Monitoring.

-
-
-
-
-
diff --git a/server/src/templates/serverIsUp.mjml b/server/src/templates/serverIsUp.mjml deleted file mode 100755 index 074c5e3dd..000000000 --- a/server/src/templates/serverIsUp.mjml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - Message from Checkmate Service - - - - - {{ monitor }} is up - - - - - - - -

Hello {{ name }}!

-

Your latest incident is resolved and your monitored service is up again.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

-
-
- - - View incident details - -

This email was sent by Checkmate.

-
-
-
-
-
diff --git a/server/src/templates/serverIsDown.mjml b/server/src/templates/unifiedNotification.mjml old mode 100755 new mode 100644 similarity index 59% rename from server/src/templates/serverIsDown.mjml rename to server/src/templates/unifiedNotification.mjml index bf6424a6b..96b8b614c --- a/server/src/templates/serverIsDown.mjml +++ b/server/src/templates/unifiedNotification.mjml @@ -34,9 +34,9 @@ font-weight="500" padding="0px" font-size="18px" - color="red" + color="{{headerColor}}" > - {{ monitor }} is down + {{ title }} -

Hello {{ name }}!

-

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.

-

Monitor name: {{ monitor }}

-

URL: {{ url }}

+

Hello!

+

{{ summary }}

+

Monitor name: {{ monitorName }}

+

URL: {{ monitorUrl }}

+

Type: {{ monitorType }}

+

Status: {{ monitorStatus }}

+ {{#if thresholds}} +

Threshold Breaches:

+
    + {{#each thresholds}} +
  • + {{ metric }}: {{ formattedValue }} (threshold: {{ threshold }}{{ unit }}) +
  • + {{/each}} +
+ {{/if}} + {{#if details}} +

Additional Details:

+
    + {{#each details}} +
  • {{ this }}
  • + {{/each}} +
+ {{/if}}
@@ -58,7 +78,14 @@ border-width="1px" border-color="#E0E0E0" >
- View incident details + {{#if incidentUrl}} + + View incident details + + {{/if}}

This email was sent by Checkmate.

diff --git a/server/src/types/notificationMessage.ts b/server/src/types/notificationMessage.ts new file mode 100644 index 000000000..f06ff1bd9 --- /dev/null +++ b/server/src/types/notificationMessage.ts @@ -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; + }; +}