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/notificationMessageBuilder.ts b/server/src/service/infrastructure/notificationMessageBuilder.ts new file mode 100644 index 000000000..6fd1b29c1 --- /dev/null +++ b/server/src/service/infrastructure/notificationMessageBuilder.ts @@ -0,0 +1,281 @@ +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 + if (decision.notificationReason === "status_change" && monitor.status === "up") { + 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 = `Hardware 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 = `Hardware 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; + } + + // CPU threshold breach + if (monitor.cpuAlertThreshold && hardware.cpu?.usage_percent !== undefined) { + const cpuPercent = hardware.cpu.usage_percent; + 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 && hardware.memory?.usage_percent !== undefined) { + const memoryPercent = hardware.memory.usage_percent; + 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 && Array.isArray(hardware.disk)) { + // Find the highest disk usage + let maxDiskUsage = 0; + for (const disk of hardware.disk) { + if (disk.usage_percent !== undefined && disk.usage_percent > maxDiskUsage) { + maxDiskUsage = disk.usage_percent; + } + } + const threshold = monitor.diskAlertThreshold; + if (maxDiskUsage >= threshold) { + breaches.push({ + metric: "disk", + currentValue: maxDiskUsage, + threshold, + unit: "%", + formattedValue: `${maxDiskUsage.toFixed(1)}%`, + }); + } + } + + // Temperature threshold breach + if (monitor.tempAlertThreshold && 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/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index ae1f0f3af..18a82c4a9 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -4,6 +4,7 @@ import { INotificationProvider } from "./notificationProviders/INotificationProv 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 +33,7 @@ export class NotificationsService implements INotificationsService { private matrixProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; + private notificationMessageBuilder: INotificationMessageBuilder; constructor( notificationsRepository: INotificationsRepository, @@ -43,7 +45,8 @@ export class NotificationsService implements INotificationsService { pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, settingsService: ISettingsService, - logger: ILogger + logger: ILogger, + notificationMessageBuilder: INotificationMessageBuilder ) { this.notificationsRepository = notificationsRepository; this.monitorsRepository = monitorsRepository; @@ -55,6 +58,7 @@ export class NotificationsService implements INotificationsService { this.matrixProvider = matrixProvider; this.settingsService = settingsService; this.logger = logger; + this.notificationMessageBuilder = notificationMessageBuilder; } private send = async ( @@ -105,11 +109,14 @@ 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; } + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + const notificationMessage = this.notificationMessageBuilder.buildMessage(monitor, monitorStatusResponse, decision, clientHost); + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; 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; + }; +}