From 4a73170583dc642d0bef3f4f242f9dd156df6357 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:05:31 +0000 Subject: [PATCH 01/20] initial commit --- .../SuperSimpleQueueHelper.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 6c9e9264a..e9439feef 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -2,9 +2,24 @@ const SERVICE_NAME = "JobQueueHelper"; import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { INetworkService, INotificationsService, IStatusService } from "@/service/index.js"; +import type { StatusChangeResult, MonitorStatusResponse, HardwareStatusPayload, MonitorStatus } from "@/types/index.js"; import IncidentService from "@/service/business/incidentService.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; +export interface MonitorActionDecision { + shouldCreateIncident: boolean; + shouldResolveIncident: boolean; + shouldSendNotification: boolean; + incidentReason: "status_down" | "threshold_breach" | null; + notificationReason: "status_change" | "threshold_breach" | null; + thresholdBreaches?: { + cpu?: boolean; + memory?: boolean; + disk?: boolean; + temp?: boolean; + }; +} + class SuperSimpleQueueHelper { static SERVICE_NAME = SERVICE_NAME; @@ -166,6 +181,126 @@ class SuperSimpleQueueHelper { }, false); return maintenanceWindowIsActive; } + + /** + * Evaluates what actions should be taken based on monitor status and hardware thresholds. + * This is a pure decision function that returns what should happen without executing anything. + * + * @param statusChangeResult - Result from statusService.updateMonitorStatus() + * @param networkResponse - Network response containing payload data + * @returns Decision object indicating what actions to take + */ + private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { + const { monitor, statusChanged, prevStatus } = statusChangeResult; + + // Initialize result + const decision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + }; + + // CASE 1: Non-hardware monitors (http, ping, port, docker, pagespeed, game) + if (monitor.type !== "hardware") { + // Only act on status changes + if (statusChanged) { + if (monitor.status === "down") { + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (monitor.status === "up" && prevStatus === "down") { + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + } + return decision; + } + + // CASE 2: Hardware monitors + const payload = networkResponse.payload as HardwareStatusPayload; + const metrics = payload?.data; + + if (!metrics) { + return decision; // No metrics, can't evaluate + } + + // Check if thresholds exist + const thresholds = monitor.thresholds || {}; + const hasThresholds = + thresholds.usage_cpu !== undefined || + thresholds.usage_memory !== undefined || + thresholds.usage_disk !== undefined || + thresholds.usage_temperature !== undefined; + + // Evaluate threshold breaches + const breaches = { + cpu: false, + memory: false, + disk: false, + temp: false, + }; + + if (hasThresholds) { + // CPU check + if (thresholds.usage_cpu !== undefined) { + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + breaches.cpu = cpuUsage !== -1 && cpuUsage > thresholds.usage_cpu; + } + + // Memory check + if (thresholds.usage_memory !== undefined) { + const memoryUsage = metrics.memory?.usage_percent ?? -1; + breaches.memory = memoryUsage !== -1 && memoryUsage > thresholds.usage_memory; + } + + // Disk check + if (thresholds.usage_disk !== undefined) { + breaches.disk = metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > thresholds.usage_disk!) ?? false; + } + + // Temperature check (alert if ANY sensor exceeds threshold) + if (thresholds.usage_temperature !== undefined) { + const temps = metrics.cpu?.temperature ?? []; + breaches.temp = temps.some((temp: number) => temp > thresholds.usage_temperature!); + } + } + + const anyThresholdBreached = Object.values(breaches).some((b) => b); + + // Check if countdown has reached zero for any threshold + const shouldNotifyThreshold = + (breaches.cpu && (monitor.cpuAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.memory && (monitor.memoryAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.disk && (monitor.diskAlertThreshold ?? monitor.alertThreshold) <= 0) || + (breaches.temp && (monitor.tempAlertThreshold ?? monitor.alertThreshold) <= 0); + + // Decision logic for hardware + if (statusChanged && monitor.status === "down") { + // Hardware service is DOWN (unreachable) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { + // Hardware service recovered + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } else if (anyThresholdBreached && shouldNotifyThreshold) { + // Threshold breach alert (service is UP but metrics are high) + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "threshold_breach"; + decision.notificationReason = "threshold_breach"; + decision.thresholdBreaches = breaches; + } + + return decision; + } } export default SuperSimpleQueueHelper; From a7b0ce4d8ffd5d7d7cded37a1b0c9c4c91b91578 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:21:18 +0000 Subject: [PATCH 02/20] remove thresholds --- client/src/Types/Monitor.ts | 8 ---- server/src/db/models/Monitor.ts | 16 +------ .../monitors/MongoMonitorsRepository.ts | 2 - .../SuperSimpleQueueHelper.ts | 44 +++++++++---------- .../notificationProviders/utils.ts | 12 +++-- .../infrastructure/notificationsService.ts | 9 +++- server/src/types/monitor.ts | 8 ---- 7 files changed, 36 insertions(+), 63 deletions(-) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index ea6f4d019..2f1c1838d 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -21,13 +21,6 @@ export const MonitorStatuses = [ ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export interface MonitorThresholds { - usage_cpu?: number; - usage_memory?: number; - usage_disk?: number; - usage_temperature?: number; -} - export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export interface Monitor { @@ -53,7 +46,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - thresholds?: MonitorThresholds; alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 9111ce47f..bb3108ed3 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -1,5 +1,5 @@ import { Schema, model, Types, type UpdateQuery } from "mongoose"; -import type { Monitor, MonitorMatchMethod, MonitorThresholds, CheckSnapshot } from "@/types/monitor.js"; +import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js"; import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -13,7 +13,6 @@ type MonitorDocumentBase = Omit< notifications: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; - thresholds?: MonitorThresholds; }; interface MonitorDocument extends MonitorDocumentBase { @@ -24,16 +23,6 @@ interface MonitorDocument extends MonitorDocumentBase { updatedAt: Date; } -const thresholdsSchema = new Schema( - { - usage_cpu: { type: Number }, - usage_memory: { type: Number }, - usage_disk: { type: Number }, - usage_temperature: { type: Number }, - }, - { _id: false } -); - const checkSnapshotSchema = new Schema( { id: { type: String, required: true }, @@ -132,9 +121,6 @@ const MonitorSchema = new Schema( secret: { type: String, }, - thresholds: { - type: thresholdsSchema, - }, alertThreshold: { type: Number, default: 5, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 65d93421e..7390d0e0e 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -317,7 +317,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - thresholds: doc.thresholds ?? undefined, alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, @@ -370,7 +369,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - thresholds: doc.thresholds ?? undefined, alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index e9439feef..0b143450c 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -182,14 +182,6 @@ class SuperSimpleQueueHelper { return maintenanceWindowIsActive; } - /** - * Evaluates what actions should be taken based on monitor status and hardware thresholds. - * This is a pure decision function that returns what should happen without executing anything. - * - * @param statusChangeResult - Result from statusService.updateMonitorStatus() - * @param networkResponse - Network response containing payload data - * @returns Decision object indicating what actions to take - */ private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { const { monitor, statusChanged, prevStatus } = statusChangeResult; @@ -202,10 +194,10 @@ class SuperSimpleQueueHelper { notificationReason: null, }; - // CASE 1: Non-hardware monitors (http, ping, port, docker, pagespeed, game) + // CASE 1: Non-hardware if (monitor.type !== "hardware") { - // Only act on status changes if (statusChanged) { + // This is the current state of the monitor AFTER the most recent check if (monitor.status === "down") { decision.shouldCreateIncident = true; decision.shouldSendNotification = true; @@ -220,7 +212,7 @@ class SuperSimpleQueueHelper { return decision; } - // CASE 2: Hardware monitors + // CASE 2: Hardware const payload = networkResponse.payload as HardwareStatusPayload; const metrics = payload?.data; @@ -229,12 +221,11 @@ class SuperSimpleQueueHelper { } // Check if thresholds exist - const thresholds = monitor.thresholds || {}; const hasThresholds = - thresholds.usage_cpu !== undefined || - thresholds.usage_memory !== undefined || - thresholds.usage_disk !== undefined || - thresholds.usage_temperature !== undefined; + monitor.cpuAlertThreshold !== undefined || + monitor.memoryAlertThreshold !== undefined || + monitor.diskAlertThreshold !== undefined || + monitor.tempAlertThreshold !== undefined; // Evaluate threshold breaches const breaches = { @@ -246,26 +237,31 @@ class SuperSimpleQueueHelper { if (hasThresholds) { // CPU check - if (thresholds.usage_cpu !== undefined) { + if (monitor.cpuAlertThreshold !== undefined) { const cpuUsage = metrics.cpu?.usage_percent ?? -1; - breaches.cpu = cpuUsage !== -1 && cpuUsage > thresholds.usage_cpu; + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.cpu = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; } // Memory check - if (thresholds.usage_memory !== undefined) { + if (monitor.memoryAlertThreshold !== undefined) { const memoryUsage = metrics.memory?.usage_percent ?? -1; - breaches.memory = memoryUsage !== -1 && memoryUsage > thresholds.usage_memory; + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.memory = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; } // Disk check - if (thresholds.usage_disk !== undefined) { - breaches.disk = metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > thresholds.usage_disk!) ?? false; + if (monitor.diskAlertThreshold !== undefined) { + // Convert threshold from percentage (0-100) to decimal (0-1) for comparison + breaches.disk = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; } // Temperature check (alert if ANY sensor exceeds threshold) - if (thresholds.usage_temperature !== undefined) { + if (monitor.tempAlertThreshold !== undefined) { const temps = metrics.cpu?.temperature ?? []; - breaches.temp = temps.some((temp: number) => temp > thresholds.usage_temperature!); + // Temperature threshold is in Celsius, compare directly + breaches.temp = temps.some((temp: number) => temp > monitor.tempAlertThreshold); } } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 2fe22e834..ac964d93f 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -23,8 +23,10 @@ export const buildHardwareAlerts = ( monitor: Monitor, networkResponse: MonitorStatusResponse ): { alertsToSend: string[]; discordPayload: any } => { - const thresholds = monitor.thresholds || {}; - const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + // 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 payload = networkResponse?.payload as HardwareStatusPayload; const metrics = payload.data || {}; @@ -124,8 +126,10 @@ export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): st }; export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => { - const thresholds = monitor.thresholds || {}; - const { usage_cpu: cpuThreshold = -1, usage_memory: memoryThreshold = -1, usage_disk: diskThreshold = -1 } = thresholds; + // 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 payload = networkResponse?.payload as HardwareStatusPayload; const metrics = payload.data || {}; diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 531014a7a..b2ae59b7c 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -239,9 +239,14 @@ export class NotificationsService implements INotificationsService { // Deal with hardware thresholds if (type === "hardware") { - const thresholds = monitor.thresholds; + // Check if any thresholds are set + const hasThresholds = + monitor.cpuAlertThreshold !== undefined || + monitor.memoryAlertThreshold !== undefined || + monitor.diskAlertThreshold !== undefined || + monitor.tempAlertThreshold !== undefined; - if (thresholds === undefined) return false; // No thresholds set, we're done + if (!hasThresholds) return false; // No thresholds set, we're done const metrics = payload?.data ?? null; if (metrics === null) return false; // No metrics, we're done diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 384e14fe0..83a207e2d 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -7,13 +7,6 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export interface MonitorThresholds { - usage_cpu?: number; - usage_memory?: number; - usage_disk?: number; - usage_temperature?: number; -} - export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; export interface Monitor { @@ -39,7 +32,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - thresholds?: MonitorThresholds; alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; From 2fb6cb0b6f3f614dc0fb1c860a84ca9364858676 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:29:30 +0000 Subject: [PATCH 03/20] remove alertThreshold --- client/src/Hooks/useMonitorForm.ts | 8 ++-- client/src/Types/Monitor.ts | 1 - server/src/db/models/Monitor.ts | 47 ++----------------- .../monitors/MongoMonitorsRepository.ts | 2 - .../SuperSimpleQueueHelper.ts | 8 ++-- .../notificationProviders/utils.ts | 10 ++-- server/src/types/monitor.ts | 1 - 7 files changed, 17 insertions(+), 60 deletions(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 153cf9f19..633fcb5c9 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -83,10 +83,10 @@ export const useMonitorForm = ({ type: "hardware", url: data?.url || "", secret: data?.secret || "", - cpuAlertThreshold: data?.cpuAlertThreshold ?? 80, - memoryAlertThreshold: data?.memoryAlertThreshold ?? 80, - diskAlertThreshold: data?.diskAlertThreshold ?? 80, - tempAlertThreshold: data?.tempAlertThreshold ?? 80, + cpuAlertThreshold: data?.cpuAlertThreshold ?? 100, + memoryAlertThreshold: data?.memoryAlertThreshold ?? 100, + diskAlertThreshold: data?.diskAlertThreshold ?? 100, + tempAlertThreshold: data?.tempAlertThreshold ?? 100, selectedDisks: data?.selectedDisks || [], }; break; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 2f1c1838d..fdd66b671 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -46,7 +46,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diskAlertThreshold: number; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index bb3108ed3..673872fba 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -121,33 +121,21 @@ const MonitorSchema = new Schema( secret: { type: String, }, - alertThreshold: { - type: Number, - default: 5, - }, cpuAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, memoryAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, diskAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, tempAlertThreshold: { type: Number, - default: function () { - return this.alertThreshold; - }, + default: 100, }, selectedDisks: { type: [String], @@ -175,33 +163,6 @@ const MonitorSchema = new Schema( } ); -MonitorSchema.pre("save", function (next) { - if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) { - this.cpuAlertThreshold = this.alertThreshold; - } - if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) { - this.memoryAlertThreshold = this.alertThreshold; - } - if (!this.diskAlertThreshold || this.isModified("alertThreshold")) { - this.diskAlertThreshold = this.alertThreshold; - } - if (!this.tempAlertThreshold || this.isModified("alertThreshold")) { - this.tempAlertThreshold = this.alertThreshold; - } - next(); -}); - -MonitorSchema.pre("findOneAndUpdate", function (next) { - const update = this.getUpdate() as UpdateQuery | null; - if (update && !Array.isArray(update) && update.alertThreshold !== undefined) { - update.cpuAlertThreshold = update.alertThreshold; - update.memoryAlertThreshold = update.alertThreshold; - update.diskAlertThreshold = update.alertThreshold; - update.tempAlertThreshold = update.alertThreshold; - } - next(); -}); - MonitorSchema.index({ teamId: 1, type: 1 }); const MonitorModel = model("Monitor", MonitorSchema); diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 7390d0e0e..71d4a2a41 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -317,7 +317,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diskAlertThreshold: doc.diskAlertThreshold, @@ -369,7 +368,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, secret: doc.secret ?? undefined, - alertThreshold: doc.alertThreshold, cpuAlertThreshold: doc.cpuAlertThreshold, memoryAlertThreshold: doc.memoryAlertThreshold, diskAlertThreshold: doc.diskAlertThreshold, diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 0b143450c..85e54f793 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -269,10 +269,10 @@ class SuperSimpleQueueHelper { // Check if countdown has reached zero for any threshold const shouldNotifyThreshold = - (breaches.cpu && (monitor.cpuAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.memory && (monitor.memoryAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.disk && (monitor.diskAlertThreshold ?? monitor.alertThreshold) <= 0) || - (breaches.temp && (monitor.tempAlertThreshold ?? monitor.alertThreshold) <= 0); + (breaches.cpu && (monitor.cpuAlertThreshold ?? 100) <= 0) || + (breaches.memory && (monitor.memoryAlertThreshold ?? 100) <= 0) || + (breaches.disk && (monitor.diskAlertThreshold ?? 100) <= 0) || + (breaches.temp && (monitor.tempAlertThreshold ?? 100) <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index ac964d93f..284855846 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -88,12 +88,12 @@ export const buildHardwareAlerts = ( const thresholdKey = alertThresholdKeyMap[type]; // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = ((monitor[thresholdKey] ?? monitor.alertThreshold) as number) - 1; + const nextValue = ((monitor[thresholdKey] ?? 100) as number) - 1; monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered if (monitor[thresholdKey] <= 0) { // If threshold drops below 0, reset and send notification - monitor[thresholdKey] = monitor.alertThreshold; + monitor[thresholdKey] = 100; const formatAlert = { cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, @@ -136,17 +136,17 @@ export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: Monit const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && ((monitor.cpuAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (cpuBreach && ((monitor.cpuAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && ((monitor.memoryAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (memoryBreach && ((monitor.memoryAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && ((monitor.diskAlertThreshold ?? monitor.alertThreshold) as number) - 1 <= 0) { + if (diskBreach && ((monitor.diskAlertThreshold ?? 100) as number) - 1 <= 0) { return true; } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 83a207e2d..d92b351a9 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -32,7 +32,6 @@ export interface Monitor { uptimePercentage?: number; notifications: string[]; secret?: string; - alertThreshold: number; cpuAlertThreshold: number; memoryAlertThreshold: number; diskAlertThreshold: number; From 97ace1e05856da2fdf4473a4d0690d9cd3987801 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:38:00 +0000 Subject: [PATCH 04/20] remove unused checks --- .../SuperSimpleQueueHelper.ts | 69 +++++++------------ .../notificationProviders/utils.ts | 8 +-- 2 files changed, 28 insertions(+), 49 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 85e54f793..ec8a792f2 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -220,59 +220,38 @@ class SuperSimpleQueueHelper { return decision; // No metrics, can't evaluate } - // Check if thresholds exist - const hasThresholds = - monitor.cpuAlertThreshold !== undefined || - monitor.memoryAlertThreshold !== undefined || - monitor.diskAlertThreshold !== undefined || - monitor.tempAlertThreshold !== undefined; - // Evaluate threshold breaches + // CPU check + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; + + // Memory check + const memoryUsage = metrics.memory?.usage_percent ?? -1; + const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; + + // Disk check + const diskBreach = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; + + // Temperature check (alert if ANY sensor exceeds threshold) + const temps = metrics.cpu?.temperature ?? []; + const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); + const breaches = { - cpu: false, - memory: false, - disk: false, - temp: false, + cpu: cpuBreach, + memory: memoryBreach, + disk: diskBreach, + temp: tempBreach, }; - if (hasThresholds) { - // CPU check - if (monitor.cpuAlertThreshold !== undefined) { - const cpuUsage = metrics.cpu?.usage_percent ?? -1; - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.cpu = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; - } - - // Memory check - if (monitor.memoryAlertThreshold !== undefined) { - const memoryUsage = metrics.memory?.usage_percent ?? -1; - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.memory = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - } - - // Disk check - if (monitor.diskAlertThreshold !== undefined) { - // Convert threshold from percentage (0-100) to decimal (0-1) for comparison - breaches.disk = - metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; - } - - // Temperature check (alert if ANY sensor exceeds threshold) - if (monitor.tempAlertThreshold !== undefined) { - const temps = metrics.cpu?.temperature ?? []; - // Temperature threshold is in Celsius, compare directly - breaches.temp = temps.some((temp: number) => temp > monitor.tempAlertThreshold); - } - } - const anyThresholdBreached = Object.values(breaches).some((b) => b); // Check if countdown has reached zero for any threshold const shouldNotifyThreshold = - (breaches.cpu && (monitor.cpuAlertThreshold ?? 100) <= 0) || - (breaches.memory && (monitor.memoryAlertThreshold ?? 100) <= 0) || - (breaches.disk && (monitor.diskAlertThreshold ?? 100) <= 0) || - (breaches.temp && (monitor.tempAlertThreshold ?? 100) <= 0); + (breaches.cpu && monitor.cpuAlertThreshold <= 0) || + (breaches.memory && monitor.memoryAlertThreshold <= 0) || + (breaches.disk && monitor.diskAlertThreshold <= 0) || + (breaches.temp && monitor.tempAlertThreshold <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index 284855846..ed9d0177c 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -88,7 +88,7 @@ export const buildHardwareAlerts = ( const thresholdKey = alertThresholdKeyMap[type]; // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = ((monitor[thresholdKey] ?? 100) as number) - 1; + const nextValue = monitor[thresholdKey] - 1; monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered if (monitor[thresholdKey] <= 0) { @@ -136,17 +136,17 @@ export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: Monit const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && ((monitor.cpuAlertThreshold ?? 100) as number) - 1 <= 0) { + if (cpuBreach && monitor.cpuAlertThreshold - 1 <= 0) { return true; } const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && ((monitor.memoryAlertThreshold ?? 100) as number) - 1 <= 0) { + if (memoryBreach && monitor.memoryAlertThreshold - 1 <= 0) { return true; } const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && ((monitor.diskAlertThreshold ?? 100) as number) - 1 <= 0) { + if (diskBreach && monitor.diskAlertThreshold - 1 <= 0) { return true; } From 9c467c763dfacb24b45397f1adb22d88c9468175 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:49:32 +0000 Subject: [PATCH 05/20] add threshold counters --- client/src/Types/Monitor.ts | 4 ++++ server/src/db/models/Monitor.ts | 16 ++++++++++++++++ .../monitors/MongoMonitorsRepository.ts | 8 ++++++++ server/src/types/monitor.ts | 4 ++++ 4 files changed, 32 insertions(+) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index fdd66b671..0c7cea5bd 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -47,9 +47,13 @@ export interface Monitor { notifications: string[]; secret?: string; cpuAlertThreshold: number; + cpuAlertCounter: number; memoryAlertThreshold: number; + memoryAlertCounter: number; diskAlertThreshold: number; + diskAlertCounter: number; tempAlertThreshold: number; + tempAlertCounter: number; selectedDisks: string[]; gameId?: string; group: string | null; diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 673872fba..a459cdf3e 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -125,18 +125,34 @@ const MonitorSchema = new Schema( type: Number, default: 100, }, + cpuAlertCounter: { + type: Number, + default: 5, + }, memoryAlertThreshold: { type: Number, default: 100, }, + memoryAlertCounter: { + type: Number, + default: 5, + }, diskAlertThreshold: { type: Number, default: 100, }, + diskAlertCounter: { + type: Number, + default: 5, + }, tempAlertThreshold: { type: Number, default: 100, }, + tempAlertCounter: { + type: Number, + default: 5, + }, selectedDisks: { type: [String], default: [], diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 71d4a2a41..c942d749a 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -318,9 +318,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { notifications: notificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, + cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, + memoryAlertCounter: doc.memoryAlertCounter, diskAlertThreshold: doc.diskAlertThreshold, + diskAlertCounter: doc.diskAlertCounter, tempAlertThreshold: doc.tempAlertThreshold, + tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, @@ -369,9 +373,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { notifications: notificationIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, + cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, + memoryAlertCounter: doc.memoryAlertCounter, diskAlertThreshold: doc.diskAlertThreshold, + diskAlertCounter: doc.diskAlertCounter, tempAlertThreshold: doc.tempAlertThreshold, + tempAlertCounter: doc.tempAlertCounter, selectedDisks: doc.selectedDisks ?? [], gameId: doc.gameId ?? undefined, group: doc.group ?? null, diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index d92b351a9..da95f4292 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -33,9 +33,13 @@ export interface Monitor { notifications: string[]; secret?: string; cpuAlertThreshold: number; + cpuAlertCounter: number; memoryAlertThreshold: number; + memoryAlertCounter: number; diskAlertThreshold: number; + diskAlertCounter: number; tempAlertThreshold: number; + tempAlertCounter: number; selectedDisks: string[]; gameId?: string; group: string | null; From 62778886e1184e63f5470174b296d802058267c4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 21:55:47 +0000 Subject: [PATCH 06/20] use counters --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index ec8a792f2..621253f81 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -246,12 +246,12 @@ class SuperSimpleQueueHelper { const anyThresholdBreached = Object.values(breaches).some((b) => b); - // Check if countdown has reached zero for any threshold + // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = - (breaches.cpu && monitor.cpuAlertThreshold <= 0) || - (breaches.memory && monitor.memoryAlertThreshold <= 0) || - (breaches.disk && monitor.diskAlertThreshold <= 0) || - (breaches.temp && monitor.tempAlertThreshold <= 0); + (breaches.cpu && monitor.cpuAlertCounter <= 0) || + (breaches.memory && monitor.memoryAlertCounter <= 0) || + (breaches.disk && monitor.diskAlertCounter <= 0) || + (breaches.temp && monitor.tempAlertCounter <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { From 22645d86f718ce3833edaf50f6d6a05b9ae8b2c6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:14:51 +0000 Subject: [PATCH 07/20] handle infra thresholds --- .../SuperSimpleQueueHelper.ts | 45 +++------------ .../service/infrastructure/statusService.ts | 56 +++++++++++++++++++ server/src/types/network.ts | 6 ++ 3 files changed, 71 insertions(+), 36 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 621253f81..3a5de5970 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -183,7 +183,7 @@ class SuperSimpleQueueHelper { } private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { - const { monitor, statusChanged, prevStatus } = statusChangeResult; + const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; // Initialize result const decision: MonitorActionDecision = { @@ -213,45 +213,18 @@ class SuperSimpleQueueHelper { } // CASE 2: Hardware - const payload = networkResponse.payload as HardwareStatusPayload; - const metrics = payload?.data; - - if (!metrics) { - return decision; // No metrics, can't evaluate + if (!thresholdBreaches) { + return decision; // No breach data available } - // Evaluate threshold breaches - // CPU check - const cpuUsage = metrics.cpu?.usage_percent ?? -1; - const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; - - // Memory check - const memoryUsage = metrics.memory?.usage_percent ?? -1; - const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - - // Disk check - const diskBreach = - metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; - - // Temperature check (alert if ANY sensor exceeds threshold) - const temps = metrics.cpu?.temperature ?? []; - const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); - - const breaches = { - cpu: cpuBreach, - memory: memoryBreach, - disk: diskBreach, - temp: tempBreach, - }; - - const anyThresholdBreached = Object.values(breaches).some((b) => b); + const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = - (breaches.cpu && monitor.cpuAlertCounter <= 0) || - (breaches.memory && monitor.memoryAlertCounter <= 0) || - (breaches.disk && monitor.diskAlertCounter <= 0) || - (breaches.temp && monitor.tempAlertCounter <= 0); + (thresholdBreaches.cpu && monitor.cpuAlertCounter <= 0) || + (thresholdBreaches.memory && monitor.memoryAlertCounter <= 0) || + (thresholdBreaches.disk && monitor.diskAlertCounter <= 0) || + (thresholdBreaches.temp && monitor.tempAlertCounter <= 0); // Decision logic for hardware if (statusChanged && monitor.status === "down") { @@ -271,7 +244,7 @@ class SuperSimpleQueueHelper { decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; - decision.thresholdBreaches = breaches; + decision.thresholdBreaches = thresholdBreaches; } return decision; diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index 0ab47f85b..cf9e1945a 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -253,6 +253,61 @@ export class StatusService implements IStatusService { } monitor.status = newStatus; + + // Evaluate hardware threshold breaches (only for hardware monitors) + let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; + if (monitor.type === "hardware" && statusResponse.payload) { + const payload = statusResponse.payload as HardwareStatusPayload; + const metrics = payload?.data; + + if (metrics) { + // Evaluate threshold breaches + const cpuUsage = metrics.cpu?.usage_percent ?? -1; + const cpuBreach = cpuUsage !== -1 && cpuUsage > monitor.cpuAlertThreshold / 100; + + const memoryUsage = metrics.memory?.usage_percent ?? -1; + const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; + + const diskBreach = + metrics.disk?.some((d: any) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? false; + + const temps = metrics.cpu?.temperature ?? []; + const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); + + thresholdBreaches = { + cpu: cpuBreach, + memory: memoryBreach, + disk: diskBreach, + temp: tempBreach, + }; + + // Update counters: decrement if breached, reset to 5 if not breached + if (cpuBreach) { + monitor.cpuAlertCounter = Math.max(0, monitor.cpuAlertCounter - 1); + } else { + monitor.cpuAlertCounter = 5; + } + + if (memoryBreach) { + monitor.memoryAlertCounter = Math.max(0, monitor.memoryAlertCounter - 1); + } else { + monitor.memoryAlertCounter = 5; + } + + if (diskBreach) { + monitor.diskAlertCounter = Math.max(0, monitor.diskAlertCounter - 1); + } else { + monitor.diskAlertCounter = 5; + } + + if (tempBreach) { + monitor.tempAlertCounter = Math.max(0, monitor.tempAlertCounter - 1); + } else { + monitor.tempAlertCounter = 5; + } + } + } + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { @@ -261,6 +316,7 @@ export class StatusService implements IStatusService { prevStatus, code, timestamp: new Date().getTime(), + thresholdBreaches, }; } catch (error: any) { error.service = SERVICE_NAME; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 7fbfb79fe..d87f1ea4f 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -110,4 +110,10 @@ export type StatusChangeResult = { prevStatus: MonitorStatus; code: number; timestamp: number; + thresholdBreaches?: { + cpu: boolean; + memory: boolean; + disk: boolean; + temp: boolean; + }; }; From e50240885d7e608c5dbb51ea39339d5fc1721baf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:25:15 +0000 Subject: [PATCH 08/20] debug logging --- .../SuperSimpleQueue/SuperSimpleQueueHelper.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 3a5de5970..78e7d54d7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -115,6 +115,10 @@ class SuperSimpleQueueHelper { // Step 4. Update monitor status const statusChangeResult = await this.statusService.updateMonitorStatus(status, check); + // Step 5. Get decisions + const decision = this.evaluateMonitorAction(statusChangeResult); + console.log(JSON.stringify(decision, null, 2)); + // Step 5 handle notifications (best effort, continue even in event of failure, don't wait) this.notificationsService .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) @@ -182,7 +186,7 @@ class SuperSimpleQueueHelper { return maintenanceWindowIsActive; } - private evaluateMonitorAction(statusChangeResult: StatusChangeResult, networkResponse: MonitorStatusResponse): MonitorActionDecision { + private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; // Initialize result From b75c1a4fcbeb481c04b1c81d71845ebf3a7a9632 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:29:06 +0000 Subject: [PATCH 09/20] create incident on hardware breach --- .../SuperSimpleQueueHelper.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 78e7d54d7..7e4c94586 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -218,10 +218,22 @@ class SuperSimpleQueueHelper { // CASE 2: Hardware if (!thresholdBreaches) { - return decision; // No breach data available + // No breach data available - still handle status changes + if (statusChanged && monitor.status === "down") { + decision.shouldCreateIncident = true; + decision.shouldSendNotification = true; + decision.incidentReason = "status_down"; + decision.notificationReason = "status_change"; + } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { + decision.shouldResolveIncident = true; + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; + } + return decision; } const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); + const noThresholdsBreached = Object.values(thresholdBreaches).every((b) => !b); // Check if countdown counter has reached zero for any breached threshold const shouldNotifyThreshold = @@ -233,6 +245,7 @@ class SuperSimpleQueueHelper { // Decision logic for hardware if (statusChanged && monitor.status === "down") { // Hardware service is DOWN (unreachable) + // Status down takes precedence over threshold breaches decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "status_down"; @@ -243,12 +256,17 @@ class SuperSimpleQueueHelper { decision.shouldSendNotification = true; decision.notificationReason = "status_change"; } else if (anyThresholdBreached && shouldNotifyThreshold) { - // Threshold breach alert (service is UP but metrics are high) + // Threshold breach: Counters have reached zero + // Create incident if service is UP but metrics exceed thresholds decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; decision.thresholdBreaches = thresholdBreaches; + } else if (noThresholdsBreached && !anyThresholdBreached) { + // All thresholds returned to normal - resolve any active threshold incidents + decision.shouldResolveIncident = true; + // Don't send notification for resolution (can be added later if needed) } return decision; From 68db4b8701f76588f6ff74d900ba760399d034c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:52:14 +0000 Subject: [PATCH 10/20] breached status --- .../v2/design-elements/StatusLabel.tsx | 2 + client/src/Types/Monitor.ts | 1 + client/src/Utils/MonitorUtils.ts | 3 + client/src/locales/en.json | 1 + .../SuperSimpleQueueHelper.ts | 69 +++---------------- .../service/infrastructure/statusService.ts | 31 ++++++++- server/src/types/monitor.ts | 2 +- 7 files changed, 48 insertions(+), 61 deletions(-) diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/v2/design-elements/StatusLabel.tsx index cf404421e..19bb8d157 100644 --- a/client/src/Components/v2/design-elements/StatusLabel.tsx +++ b/client/src/Components/v2/design-elements/StatusLabel.tsx @@ -21,6 +21,8 @@ export const StatusLabel = ({ status, sx }: { status: MonitorStatus; sx?: SxProp return t("pages.common.monitors.status.up"); } else if (status === "down") { return t("pages.common.monitors.status.down"); + } else if (status === "breached") { + return t("pages.common.monitors.status.breached"); } else if (status === "maintenance") { return t("pages.common.monitors.status.maintenance"); } else if (status === "paused") { diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 0c7cea5bd..cf637c184 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -18,6 +18,7 @@ export const MonitorStatuses = [ "paused", "initializing", "maintenance", + "breached", ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 02c291efc..b73fc5bc4 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -23,6 +23,9 @@ export const getStatusPalette = (status: MonitorStatus): PaletteKey => { if (status === "down") { return "error"; } + if (status === "breached") { + return "error"; + } return "warning"; }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 8e18d8e2a..d8a46fb7c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -387,6 +387,7 @@ }, "status": { "down": "down", + "breached": "breached", "initializing": "initializing", "maintenance": "maintenance", "paused": "paused", diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 7e4c94586..4082368a7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -187,7 +187,7 @@ class SuperSimpleQueueHelper { } private evaluateMonitorAction(statusChangeResult: StatusChangeResult): MonitorActionDecision { - const { monitor, statusChanged, prevStatus, thresholdBreaches } = statusChangeResult; + const { monitor, statusChanged, prevStatus } = statusChangeResult; // Initialize result const decision: MonitorActionDecision = { @@ -198,75 +198,28 @@ class SuperSimpleQueueHelper { notificationReason: null, }; - // CASE 1: Non-hardware - if (monitor.type !== "hardware") { - if (statusChanged) { - // This is the current state of the monitor AFTER the most recent check - if (monitor.status === "down") { - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (monitor.status === "up" && prevStatus === "down") { - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - } + // Simplified logic: Just check status changes + if (!statusChanged) { return decision; } - // CASE 2: Hardware - if (!thresholdBreaches) { - // No breach data available - still handle status changes - if (statusChanged && monitor.status === "down") { - decision.shouldCreateIncident = true; - decision.shouldSendNotification = true; - decision.incidentReason = "status_down"; - decision.notificationReason = "status_change"; - } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } - return decision; - } - - const anyThresholdBreached = Object.values(thresholdBreaches).some((b) => b); - const noThresholdsBreached = Object.values(thresholdBreaches).every((b) => !b); - - // Check if countdown counter has reached zero for any breached threshold - const shouldNotifyThreshold = - (thresholdBreaches.cpu && monitor.cpuAlertCounter <= 0) || - (thresholdBreaches.memory && monitor.memoryAlertCounter <= 0) || - (thresholdBreaches.disk && monitor.diskAlertCounter <= 0) || - (thresholdBreaches.temp && monitor.tempAlertCounter <= 0); - - // Decision logic for hardware - if (statusChanged && monitor.status === "down") { - // Hardware service is DOWN (unreachable) - // Status down takes precedence over threshold breaches + if (monitor.status === "down") { + // Monitor went down (unreachable) decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "status_down"; decision.notificationReason = "status_change"; - } else if (statusChanged && monitor.status === "up" && prevStatus === "down") { - // Hardware service recovered - decision.shouldResolveIncident = true; - decision.shouldSendNotification = true; - decision.notificationReason = "status_change"; - } else if (anyThresholdBreached && shouldNotifyThreshold) { - // Threshold breach: Counters have reached zero - // Create incident if service is UP but metrics exceed thresholds + } else if (monitor.status === "breached") { + // Hardware monitor exceeded thresholds decision.shouldCreateIncident = true; decision.shouldSendNotification = true; decision.incidentReason = "threshold_breach"; decision.notificationReason = "threshold_breach"; - decision.thresholdBreaches = thresholdBreaches; - } else if (noThresholdsBreached && !anyThresholdBreached) { - // All thresholds returned to normal - resolve any active threshold incidents + } else if (monitor.status === "up" && (prevStatus === "down" || prevStatus === "breached")) { + // Monitor recovered from down or breached state decision.shouldResolveIncident = true; - // Don't send notification for resolution (can be added later if needed) + decision.shouldSendNotification = true; + decision.notificationReason = "status_change"; } return decision; diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index cf9e1945a..b01131bbe 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -252,8 +252,6 @@ export class StatusService implements IStatusService { statusChanged = true; } - monitor.status = newStatus; - // Evaluate hardware threshold breaches (only for hardware monitors) let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; if (monitor.type === "hardware" && statusResponse.payload) { @@ -305,9 +303,38 @@ export class StatusService implements IStatusService { } else { monitor.tempAlertCounter = 5; } + + // Check if any counter has reached zero (initial breach) + const anyCounterZero = + monitor.cpuAlertCounter === 0 || monitor.memoryAlertCounter === 0 || monitor.diskAlertCounter === 0 || monitor.tempAlertCounter === 0; + + const anyThresholdBreached = cpuBreach || memoryBreach || diskBreach || tempBreach; + const allThresholdsNormal = !cpuBreach && !memoryBreach && !diskBreach && !tempBreach; + + // Update monitor status based on threshold breach state + if (newStatus !== "down") { + // Don't override "down" status - service unreachable takes precedence + // Check current monitor status, not newStatus for comparison + if (anyCounterZero && anyThresholdBreached && monitor.status !== "breached") { + // Initial breach: counter hit zero, change status to breached + newStatus = "breached"; + statusChanged = true; + } else if (anyCounterZero && anyThresholdBreached && monitor.status === "breached") { + // Already breached, keep status but don't mark as changed + newStatus = "breached"; + // statusChanged remains false + } else if (allThresholdsNormal && monitor.status === "breached") { + // All thresholds returned to normal, recover from breached state + newStatus = "up"; + statusChanged = true; + } + } } } + // Apply the final status + monitor.status = newStatus; + const updated = await this.monitorsRepository.updateById(monitor.id, monitor.teamId, monitor); return { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index da95f4292..021172c3f 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -4,7 +4,7 @@ export type { CheckSnapshot } from "@/types/check.js"; export const MonitorTypes = ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "unknown"] as const; export type MonitorType = (typeof MonitorTypes)[number]; -export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance"] as const; +export const MonitorStatuses = ["up", "down", "paused", "initializing", "maintenance", "breached"] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; From 32a7bd6543b4b72f8b5b7038a601fbb1fa4f70d3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 22:57:27 +0000 Subject: [PATCH 11/20] add breached status to summary header --- .../Components/v2/design-elements/StatusBox.tsx | 11 +++++++++++ .../v2/monitors/HeaderMonitorsSummary.tsx | 2 ++ client/src/Types/Monitor.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 17 ++++++++++++++++- server/src/types/monitor.ts | 1 + 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx index 1be76988b..73a881736 100644 --- a/client/src/Components/v2/design-elements/StatusBox.tsx +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -139,6 +139,17 @@ export const InitializingStatusBox = ({ n }: { n: number }) => { /> ); }; +export const BreachedStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; export const TotalChecksBox = ({ n }: { n: number }) => { const theme = useTheme(); diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx index b2a93a807..49a4fd471 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx +++ b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx @@ -3,6 +3,7 @@ import { DownStatusBox, PausedStatusBox, InitializingStatusBox, + BreachedStatusBox, } from "@/Components/v2/design-elements"; import Stack from "@mui/material/Stack"; @@ -22,6 +23,7 @@ export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => { > + diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index cf637c184..043076704 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -72,6 +72,7 @@ export interface MonitorsSummary { pausedMonitors: number; initializingMonitors: number; maintenanceMonitors: number; + breachedMonitors: number; } export interface MonitorsWithChecksResponse { diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index c942d749a..c6f3266a6 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -252,13 +252,28 @@ class MongoMonitorsRepository implements IMonitorsRepository { $cond: [{ $eq: ["$status", "maintenance"] }, 1, 0], }, }, + breachedMonitors: { + $sum: { + $cond: [{ $eq: ["$status", "breached"] }, 1, 0], + }, + }, }, }, { $project: { _id: 0 } }, ]; const [summary] = await MonitorModel.aggregate(pipeline); - return summary ?? { totalMonitors: 0, upMonitors: 0, downMonitors: 0, pausedMonitors: 0, initializingMonitors: 0, maintenanceMonitors: 0 }; + return ( + summary ?? { + totalMonitors: 0, + upMonitors: 0, + downMonitors: 0, + pausedMonitors: 0, + initializingMonitors: 0, + maintenanceMonitors: 0, + breachedMonitors: 0, + } + ); }; findGroupsByTeamId = async (teamId: string): Promise => { diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index 021172c3f..a196aaae6 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -55,6 +55,7 @@ export interface MonitorsSummary { pausedMonitors: number; initializingMonitors: number; maintenanceMonitors: number; + breachedMonitors: number; } export interface MonitorsWithChecksByTeamIdResult { From 70ae15725eeaa1b75040953a046892190bee6c82 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sat, 14 Feb 2026 23:09:27 +0000 Subject: [PATCH 12/20] conditinally render breached stauts box --- .../src/Components/v2/monitors/HeaderMonitorsSummary.tsx | 8 ++++++-- client/src/Pages/Infrastructure/Monitors/index.tsx | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx index 49a4fd471..38a6adfde 100644 --- a/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx +++ b/client/src/Components/v2/monitors/HeaderMonitorsSummary.tsx @@ -12,9 +12,13 @@ import { useTheme } from "@mui/material"; interface MonitorsSummaryProps { summary: MonitorsSummary | null; + showBreached?: boolean; } -export const HeaderMonitorsSummary = ({ summary }: MonitorsSummaryProps) => { +export const HeaderMonitorsSummary = ({ + summary, + showBreached = false, +}: MonitorsSummaryProps) => { const theme = useTheme(); return ( { > - + {showBreached && } diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 01de1c09c..8d1ef1a3a 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -133,7 +133,10 @@ const InfrastructureMonitors = () => { isLoading={isLoading} isAdmin={isAdmin} /> - + Date: Sun, 15 Feb 2026 18:09:21 +0000 Subject: [PATCH 13/20] use decision object in incidentService --- .../src/service/business/incidentService.ts | 28 +++++++---- .../SuperSimpleQueueHelper.ts | 47 +++++++++---------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 881f98f56..bcb17d3a5 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -4,6 +4,7 @@ import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; const dateRangeLookup: Record = { recent: new Date(new Date().setHours(new Date().getHours() - 2)), @@ -43,10 +44,14 @@ class IncidentService { return IncidentService.SERVICE_NAME; } - handleIncident = async (monitor: Monitor, code: number): Promise => { + handleIncident = async (monitor: Monitor, code: number, decision: MonitorActionDecision): Promise => { + if (!decision.shouldCreateIncident && !decision.shouldResolveIncident) { + return null; + } + const activeIncident = await this.incidentsRepository.findActiveByMonitorId(monitor.id, monitor.teamId); - // Monitor is down, create an incident - if (monitor.status === "down") { + + if (decision.shouldCreateIncident) { if (activeIncident) { return activeIncident; } else { @@ -61,14 +66,17 @@ class IncidentService { } } - // Monitor is up, resolve active incidents - if (!activeIncident) { - return null; + if (decision.shouldResolveIncident) { + if (!activeIncident) { + return null; + } + activeIncident.status = false; + activeIncident.endTime = Date.now().toString(); + activeIncident.resolutionType = "automatic"; + return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); } - activeIncident.status = false; - activeIncident.endTime = Date.now().toString(); - activeIncident.resolutionType = "automatic"; - return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); + + return null; }; resolveIncident = async (incidentId: string, userId: string, teamId: string, comment?: string, userEmail?: string) => { diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 4082368a7..84a26d1e2 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -117,33 +117,32 @@ class SuperSimpleQueueHelper { // Step 5. Get decisions const decision = this.evaluateMonitorAction(statusChangeResult); - console.log(JSON.stringify(decision, null, 2)); - // Step 5 handle notifications (best effort, continue even in event of failure, don't wait) - this.notificationsService - .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) - .catch((error: any) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, - stack: error.stack, + // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) + if (decision.shouldSendNotification) { + this.notificationsService + .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) + .catch((error: any) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, + stack: error.stack, + }); }); - }); - - // Step 6. Handle incidents (best effort, don't wait) - if (statusChangeResult.statusChanged) { - this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code).catch((error: any) => { - this.logger.warn({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error handling incident for job ${monitor.id}: ${error.message}`, - stack: error.stack, - }); - }); } + + // Step 7. Handle incidents (best effort, don't wait) + this.incidentService.handleIncident(statusChangeResult.monitor, statusChangeResult.code, decision).catch((error: any) => { + this.logger.warn({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error handling incident for job ${monitor.id}: ${error.message}`, + stack: error.stack, + }); + }); } catch (error: any) { this.logger.warn({ message: error.message, From 8020bfde21d5611050d6e0ebfb38affa0149b666 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 15 Feb 2026 18:20:42 +0000 Subject: [PATCH 14/20] refactor handleIncidents --- .../SuperSimpleQueueHelper.ts | 18 ++-- .../notificationProviders/utils.ts | 87 +++++++------------ .../infrastructure/notificationsService.ts | 42 ++------- 3 files changed, 46 insertions(+), 101 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 84a26d1e2..274318ad7 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -120,17 +120,15 @@ class SuperSimpleQueueHelper { // Step 6. Handle notifications (best effort, continue even in event of failure, don't wait) if (decision.shouldSendNotification) { - this.notificationsService - .handleNotifications(statusChangeResult.monitor, status, statusChangeResult.prevStatus, statusChangeResult.statusChanged) - .catch((error: any) => { - this.logger.error({ - message: error.message, - service: SERVICE_NAME, - method: "getMonitorJob", - details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, - stack: error.stack, - }); + this.notificationsService.handleNotifications(statusChangeResult.monitor, status, decision).catch((error: any) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMonitorJob", + details: `Error sending notifications for job ${statusChangeResult.monitor.id}: ${error.message}`, + stack: error.stack, }); + }); } // Step 7. Handle incidents (best effort, don't wait) diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index ed9d0177c..b258ccd52 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -27,15 +27,22 @@ export const buildHardwareAlerts = ( 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: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; + 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 = []; @@ -45,6 +52,7 @@ export const buildHardwareAlerts = ( { name: "URL", value: monitor.url, inline: false }, ]; const goToIncidentField = { name: `Go to incident`, value: `${clientHost}/infrastructure/${monitor.id}` }; + const formatDiscordAlert = { cpu: () => ({ title: "CPU alert", @@ -77,38 +85,33 @@ export const buildHardwareAlerts = ( 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"] as const; - const alertThresholdKeyMap: Record<(typeof alertTypes)[number], "cpuAlertThreshold" | "memoryAlertThreshold" | "diskAlertThreshold"> = { - cpu: "cpuAlertThreshold", - memory: "memoryAlertThreshold", - disk: "diskAlertThreshold", - }; + + const alertTypes = ["cpu", "memory", "disk", "temp"] as const; + for (const type of alertTypes) { - const thresholdKey = alertThresholdKeyMap[type]; - // Iterate over each alert type to see if any need to be decremented if (alerts[type] === true) { - const nextValue = monitor[thresholdKey] - 1; - monitor[thresholdKey] = nextValue; // Decrement threshold if an alert is triggered - - if (monitor[thresholdKey] <= 0) { - // If threshold drops below 0, reset and send notification - monitor[thresholdKey] = 100; - - const formatAlert = { - cpu: () => `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, - memory: () => - `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, - disk: () => - `Your current disk usage: ${disk - .map((d, idx) => `(Disk${idx}: ${(d?.usage_percent ?? 0 * 100).toFixed(0)}%)`) - .join(", ")} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, - }; - alertsToSend.push(formatAlert[type]()); - discordEmbeds.push(formatDiscordAlert[type]()); - } + const 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 }; }; @@ -125,34 +128,6 @@ export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): st return content; }; -export const shouldSendHardwareAlert = (monitor: Monitor, networkResponse: MonitorStatusResponse): boolean => { - // 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 payload = networkResponse?.payload as HardwareStatusPayload; - const metrics = payload.data || {}; - const { cpu: { usage_percent: cpuUsage = -1 } = {}, memory: { usage_percent: memoryUsage = -1 } = {}, disk = [] } = metrics; - - const cpuBreach = cpuThreshold !== -1 && cpuUsage > cpuThreshold; - if (cpuBreach && monitor.cpuAlertThreshold - 1 <= 0) { - return true; - } - - const memoryBreach = memoryThreshold !== -1 && memoryUsage > memoryThreshold; - if (memoryBreach && monitor.memoryAlertThreshold - 1 <= 0) { - return true; - } - - const diskBreach = disk?.some((d) => diskThreshold !== -1 && typeof d?.usage_percent === "number" && d?.usage_percent > diskThreshold); - if (diskBreach && monitor.diskAlertThreshold - 1 <= 0) { - return true; - } - - return false; -}; - export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { const { status, code } = monitorStatusResponse; const { localTimeZone, localTime, utcTime } = getTime(); diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index b2ae59b7c..6174dceb3 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -1,7 +1,7 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notification, MonitorStatus } from "@/types/index.js"; -import { shouldSendHardwareAlert } from "@/service/infrastructure/notificationProviders/utils.js"; import { IMonitorsRepository, INotificationsRepository } from "@/repositories/index.js"; import { INotificationProvider } from "./notificationProviders/INotificationProvider.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -9,12 +9,7 @@ export interface INotificationsService { findNotificationsByTeamId: (teamId: string) => Promise; updateById(id: string, teamId: string, updateData: Partial): Promise; deleteById: (id: string, teamId: string) => Promise; - handleNotifications: ( - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - prevStatus: MonitorStatus, - statusChanged: boolean - ) => Promise; + handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise; sendTestNotification: (notification: Notification) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; @@ -229,36 +224,13 @@ export class NotificationsService implements INotificationsService { return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus); }; - handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, prevStatus: MonitorStatus, statusChanged: boolean) => { - const { type } = monitor; - const payload = monitorStatusResponse.payload as HardwareStatusPayload; - // If this is a non-hardeware type monitor and status did not change, we're done - if (type !== "hardware" && statusChanged === false) return false; - // if prevStatus is undefined, monitor is resuming, we're done - if (type !== "hardware" && prevStatus === undefined) return false; - - // Deal with hardware thresholds - if (type === "hardware") { - // Check if any thresholds are set - const hasThresholds = - monitor.cpuAlertThreshold !== undefined || - monitor.memoryAlertThreshold !== undefined || - monitor.diskAlertThreshold !== undefined || - monitor.tempAlertThreshold !== undefined; - - if (!hasThresholds) return false; // No thresholds set, we're done - const metrics = payload?.data ?? null; - if (metrics === null) return false; // No metrics, we're done - - // We should send a notificaiton - - const shouldSend = shouldSendHardwareAlert(monitor, monitorStatusResponse); - if (shouldSend === false) return false; - - return await this.sendNotifications(monitor, monitorStatusResponse); + handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + // Early return if no notification should be sent + if (!decision.shouldSendNotification) { + return false; } - // We should send a notification for non-hardware monitor status change + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse); }; From be8580ee996cba398b5c5a428d1681dadb88b2b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 15 Feb 2026 18:35:54 +0000 Subject: [PATCH 15/20] update providers --- .../INotificationProvider.ts | 8 ++++- .../notificationProviders/discord.ts | 12 +++++-- .../notificationProviders/email.ts | 30 +++++++++++++--- .../notificationProviders/matrix.ts | 19 +++++++--- .../notificationProviders/pagerduty.ts | 24 ++++++++++--- .../notificationProviders/slack.ts | 24 ++++++++++--- .../notificationProviders/utils.ts | 4 +-- .../notificationProviders/webhook.ts | 19 +++++++--- .../infrastructure/notificationsService.ts | 35 ++++++++++++++----- 9 files changed, 140 insertions(+), 35 deletions(-) diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 2d363d6b3..0a1dbf876 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -1,6 +1,12 @@ import type { Monitor, Notification, Alert, MonitorStatusResponse } from "@/types/index.js"; +import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; export interface INotificationProvider { - sendAlert: (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => Promise; + sendAlert: ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index ad1f2f28a..200b8af2c 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "DiscordProvider"; 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, buildDiscordBody, getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; import got from "got"; @@ -10,15 +11,20 @@ export class DiscordProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private getHardwareContent = (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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); return discordPayload; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent(monitor, monitorStatusResponse, decision); } else { body = buildDiscordBody(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index 32ccf1859..802c44b80 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -1,6 +1,7 @@ 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"; export class EmailProvider implements INotificationProvider { @@ -12,21 +13,42 @@ export class EmailProvider implements INotificationProvider { this.logger = logger; } - private buildHardwareEmail = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private buildHardwareEmail = async (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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); return html; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): 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(":"); - const subject = isGroupedNotification ? `Alert: ${monitor.name} are down` : `Monitor ${monitor.name} is down`; + + // 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 = this.buildHardwareEmail(monitor, monitorStatusResponse); + html = await this.buildHardwareEmail(monitor, monitorStatusResponse, decision); } else { html = await buildEmail(this.emailService, monitor); } diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 8914dbf80..5a65a7909 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -1,6 +1,7 @@ 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, @@ -15,9 +16,19 @@ export class MatrixProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,12 +37,12 @@ export class MatrixProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { const { homeserverUrl, accessToken, roomId } = notification; let content; if (monitor.type === "hardware") { - content = this.getHardwareContent(monitor, monitorStatusResponse); + content = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { content = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index f65e50c68..8ed4ccc68 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -1,6 +1,7 @@ const SERVICE_NAME = "PagerDutyProvider"; 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, @@ -15,9 +16,19 @@ export class PagerDutyProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,15 @@ export class PagerDutyProvider implements INotificationProvider { return body; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index 2aeb442eb..0e7710b3f 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -1,6 +1,7 @@ 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, buildHardwareWebhookBody, @@ -15,9 +16,19 @@ export class SlackProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,15 @@ export class SlackProvider implements INotificationProvider { return body; }; - async sendAlert(notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise { + async sendAlert( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index b258ccd52..bb1c928ca 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -123,8 +123,8 @@ export const buildHardwareNotificationMessage = (clientHost: string, alerts: any return alertText.map((alert) => alert).join("\n"); }; -export const buildHardwareWebhookBody = (alerts: string[], monitor: Monitor): string => { - const content = alerts.map((alert) => alert).join("\n"); +export const buildHardwareWebhookBody = (clientHost: string, alerts: string[], monitor: Monitor): string => { + const content = buildHardwareNotificationMessage(clientHost, alerts, monitor); return content; }; diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 9e743ae53..dfc5f4763 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -1,6 +1,7 @@ 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, buildHardwareWebhookBody, @@ -15,9 +16,19 @@ export class WebhookProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(alertsToSend, monitor); + const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); return body; }; @@ -26,10 +37,10 @@ export class WebhookProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse); + body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 6174dceb3..f43402f30 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -41,6 +41,7 @@ export class NotificationsService implements INotificationsService { createdAt: number; } >; + private currentDecision?: MonitorActionDecision; constructor( notificationsRepository: INotificationsRepository, @@ -83,17 +84,17 @@ export class NotificationsService implements INotificationsService { private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); default: return false; } @@ -157,7 +158,15 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.delete(key); try { - await this.flushEmailGroup(notification, group.monitors, group.statusResponses); + // For grouped notifications (always DOWN), create a decision + const groupedDecision: MonitorActionDecision = { + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }; + await this.flushEmailGroup(notification, group.monitors, group.statusResponses, groupedDecision); } catch (error: any) { this.logger.error({ message: error?.message, @@ -196,7 +205,12 @@ export class NotificationsService implements INotificationsService { * @param statusResponses Array of status responses (parallel to monitors) * @returns true if email was sent successfully, false otherwise */ - private flushEmailGroup = async (notification: Notification, monitors: Monitor[], statusResponses: MonitorStatusResponse[]): Promise => { + private flushEmailGroup = async ( + notification: Notification, + monitors: Monitor[], + statusResponses: MonitorStatusResponse[], + decision: MonitorActionDecision + ): Promise => { if (!monitors.length || !statusResponses.length) { return false; } @@ -221,7 +235,7 @@ export class NotificationsService implements INotificationsService { }; // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus); + return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision); }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { @@ -230,6 +244,9 @@ export class NotificationsService implements INotificationsService { return false; } + // Store decision for use in send method + this.currentDecision = decision; + // Send notifications based on decision return await this.sendNotifications(monitor, monitorStatusResponse); }; From 412a3b7cc9d0801687cdc98c411a6e4f1bdad9e7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 00:17:51 +0000 Subject: [PATCH 16/20] remove redundant method --- .../infrastructure/notificationProviders/matrix.ts | 4 ++-- .../infrastructure/notificationProviders/pagerduty.ts | 4 ++-- .../infrastructure/notificationProviders/slack.ts | 4 ++-- .../infrastructure/notificationProviders/utils.ts | 9 ++------- .../infrastructure/notificationProviders/webhook.ts | 4 ++-- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 5a65a7909..afca6da95 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -5,7 +5,7 @@ import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimple import type { Notification, Monitor, MonitorStatusResponse } from "@/types/index.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class MatrixProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 8ed4ccc68..0634f46b4 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -5,7 +5,7 @@ import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimple import { INotificationProvider } from "@/service/index.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class PagerDutyProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index 0e7710b3f..da7338a75 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -4,7 +4,7 @@ import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class SlackProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; diff --git a/server/src/service/infrastructure/notificationProviders/utils.ts b/server/src/service/infrastructure/notificationProviders/utils.ts index bb1c928ca..72797c1d0 100644 --- a/server/src/service/infrastructure/notificationProviders/utils.ts +++ b/server/src/service/infrastructure/notificationProviders/utils.ts @@ -116,16 +116,11 @@ export const buildHardwareAlerts = ( return { alertsToSend, discordPayload }; }; -export const buildHardwareNotificationMessage = (clientHost: string, alerts: any, monitor: Monitor) => { +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.map((alert) => alert).join("\n"); -}; - -export const buildHardwareWebhookBody = (clientHost: string, alerts: string[], monitor: Monitor): string => { - const content = buildHardwareNotificationMessage(clientHost, alerts, monitor); - return content; + return alertText.join("\n"); }; export const buildWebhookBody = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index dfc5f4763..13a0ae8ef 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -4,7 +4,7 @@ import { INotificationProvider } from "@/service/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import { buildHardwareAlerts, - buildHardwareWebhookBody, + buildHardwareNotificationMessage, buildWebhookBody, getTestMessage, } from "@/service/infrastructure/notificationProviders/utils.js"; @@ -28,7 +28,7 @@ export class WebhookProvider implements INotificationProvider { } // For threshold breaches, use hardware alert format const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); - const body = buildHardwareWebhookBody(clientHost, alertsToSend, monitor); + const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; From a8ecd44abf44cf80ea1fd6e4787046da6109400b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 00:39:34 +0000 Subject: [PATCH 17/20] inject settings service, extract client host --- server/src/config/services.ts | 1 + .../INotificationProvider.ts | 3 ++- .../notificationProviders/discord.ts | 19 +++++++++++--- .../notificationProviders/email.ts | 14 +++++++--- .../notificationProviders/matrix.ts | 12 ++++++--- .../notificationProviders/pagerduty.ts | 7 ++--- .../notificationProviders/slack.ts | 7 ++--- .../notificationProviders/webhook.ts | 12 ++++++--- .../infrastructure/notificationsService.ts | 26 ++++++++++++++----- 9 files changed, 73 insertions(+), 28 deletions(-) diff --git a/server/src/config/services.ts b/server/src/config/services.ts index f88bc2584..67c16ff6a 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -182,6 +182,7 @@ export const initializeServices = async ({ discordProvider, pagerDutyProvider, matrixProvider, + settingsService, logger ); diff --git a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts index 0a1dbf876..21a221c9c 100644 --- a/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts +++ b/server/src/service/infrastructure/notificationProviders/INotificationProvider.ts @@ -6,7 +6,8 @@ export interface INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ) => Promise; sendTestAlert(notification: Notification): Promise; } diff --git a/server/src/service/infrastructure/notificationProviders/discord.ts b/server/src/service/infrastructure/notificationProviders/discord.ts index 200b8af2c..560c18aae 100644 --- a/server/src/service/infrastructure/notificationProviders/discord.ts +++ b/server/src/service/infrastructure/notificationProviders/discord.ts @@ -11,20 +11,31 @@ export class DiscordProvider implements INotificationProvider { constructor(logger: any) { this.logger = logger; } - private getHardwareContent = (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { discordPayload } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); return discordPayload; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + sendAlert = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent(monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = buildDiscordBody(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/email.ts b/server/src/service/infrastructure/notificationProviders/email.ts index 802c44b80..fd15034d1 100644 --- a/server/src/service/infrastructure/notificationProviders/email.ts +++ b/server/src/service/infrastructure/notificationProviders/email.ts @@ -13,13 +13,18 @@ export class EmailProvider implements INotificationProvider { this.logger = logger; } - private buildHardwareEmail = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const html = buildHardwareEmail(this.emailService, monitor, alertsToSend); return html; }; @@ -28,7 +33,8 @@ export class EmailProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + 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" @@ -48,7 +54,7 @@ export class EmailProvider implements INotificationProvider { let html; if (monitor.type === "hardware") { - html = await this.buildHardwareEmail(monitor, monitorStatusResponse, decision); + html = await this.buildHardwareEmail(clientHost, monitor, monitorStatusResponse, decision); } else { html = await buildEmail(this.emailService, monitor); } diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index afca6da95..f2543886d 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -27,7 +27,7 @@ export class MatrixProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -37,12 +37,18 @@ export class MatrixProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + 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("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + content = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { content = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/pagerduty.ts b/server/src/service/infrastructure/notificationProviders/pagerduty.ts index 0634f46b4..291cd7de5 100644 --- a/server/src/service/infrastructure/notificationProviders/pagerduty.ts +++ b/server/src/service/infrastructure/notificationProviders/pagerduty.ts @@ -27,7 +27,7 @@ export class PagerDutyProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -41,11 +41,12 @@ export class PagerDutyProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/slack.ts b/server/src/service/infrastructure/notificationProviders/slack.ts index da7338a75..f99af48d4 100644 --- a/server/src/service/infrastructure/notificationProviders/slack.ts +++ b/server/src/service/infrastructure/notificationProviders/slack.ts @@ -27,7 +27,7 @@ export class SlackProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -41,11 +41,12 @@ export class SlackProvider implements INotificationProvider { notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision + decision: MonitorActionDecision, + clientHost: string ): Promise { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationProviders/webhook.ts b/server/src/service/infrastructure/notificationProviders/webhook.ts index 13a0ae8ef..17d871950 100644 --- a/server/src/service/infrastructure/notificationProviders/webhook.ts +++ b/server/src/service/infrastructure/notificationProviders/webhook.ts @@ -27,7 +27,7 @@ export class WebhookProvider implements INotificationProvider { return buildWebhookBody(monitor, monitorStatusResponse); } // For threshold breaches, use hardware alert format - const { alertsToSend } = buildHardwareAlerts("HOST_PLACEHOLDER", monitor, monitorStatusResponse); + const { alertsToSend } = buildHardwareAlerts(clientHost, monitor, monitorStatusResponse); const body = buildHardwareNotificationMessage(clientHost, alertsToSend, monitor); return body; }; @@ -37,10 +37,16 @@ export class WebhookProvider implements INotificationProvider { return body; }; - sendAlert = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { + sendAlert = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision, + clientHost: string + ) => { let body; if (monitor.type === "hardware") { - body = this.getHardwareContent("HOST_PLACEHOLDER", monitor, monitorStatusResponse, decision); + body = this.getHardwareContent(clientHost, monitor, monitorStatusResponse, decision); } else { body = this.getContent(monitor, monitorStatusResponse); } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index f43402f30..e3dc36747 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -2,6 +2,7 @@ import type { HardwareStatusPayload, Monitor, MonitorStatusResponse, Notificatio 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"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -29,6 +30,7 @@ export class NotificationsService implements INotificationsService { private pagerDutyProvider: INotificationProvider; private matrixProvider: INotificationProvider; private logger: any; + private settingsService: ISettingsService; // Email grouping (batching) configuration private emailGroupingWindowMs: number; @@ -52,6 +54,7 @@ export class NotificationsService implements INotificationsService { discordProvider: INotificationProvider, pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, + settingsService: ISettingsService, logger: any ) { this.notificationsRepository = notificationsRepository; @@ -62,6 +65,7 @@ export class NotificationsService implements INotificationsService { this.discordProvider = discordProvider; this.pagerDutyProvider = pagerDutyProvider; this.matrixProvider = matrixProvider; + this.settingsService = settingsService; this.logger = logger; // Configure email grouping window (in milliseconds). @@ -82,19 +86,21 @@ export class NotificationsService implements INotificationsService { } private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); default: return false; } @@ -215,6 +221,12 @@ export class NotificationsService implements INotificationsService { return false; } + const { clientHost } = this.settingsService.getSettings(); + if (!clientHost) { + this.logger.warn({ message: "CLIENT_HOST not configured", service: SERVICE_NAME, method: "flushEmailGroup" }); + return false; + } + // Build a combined monitor name listing all affected services. // Example: "Service A, Service B" (2 services) or "Service A" (1 service) const uniqueNames = Array.from(new Set(monitors.map((m) => m.name))); @@ -235,7 +247,7 @@ export class NotificationsService implements INotificationsService { }; // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision); + return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision, clientHost); }; handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { From 55885ea0079139cba17d867ecdb14cc741bba641 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:25:35 +0000 Subject: [PATCH 18/20] remove global decision --- .../infrastructure/notificationsService.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index e3dc36747..93a436df9 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -39,11 +39,11 @@ export class NotificationsService implements INotificationsService { { monitors: Monitor[]; statusResponses: MonitorStatusResponse[]; + decisions: MonitorActionDecision[]; timer: ReturnType; createdAt: number; } >; - private currentDecision?: MonitorActionDecision; constructor( notificationsRepository: INotificationsRepository, @@ -85,39 +85,45 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups = new Map(); } - private send = async (notification: Notification, monitor: Monitor, monitorStatusResponse: MonitorStatusResponse): Promise => { + private send = async ( + notification: Notification, + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise => { const settings = this.settingsService.getSettings(); const clientHost = settings.clientHost || "Host not defined"; + switch (notification.type) { case "email": - return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.emailProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "slack": - return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.slackProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "discord": - return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.discordProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "pager_duty": - return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.pagerDutyProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "matrix": - return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.matrixProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); case "webhook": - return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, this.currentDecision!, clientHost); + return await this.webhookProvider.sendAlert(notification, monitor, monitorStatusResponse, decision, clientHost); default: return false; } }; - private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse) => { + private sendNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { const notificationIds = monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); const tasks = notifications.map((notification) => { // Only group emails, only for DOWN transitions, and only if a window is configured. if (notification.type === "email" && this.emailGroupingWindowMs > 0 && monitorStatusResponse.status === false) { - return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse); + return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse, decision); } // For all other cases (UP notifications or non-email channels), send immediately. - return this.send(notification, monitor, monitorStatusResponse); + return this.send(notification, monitor, monitorStatusResponse, decision); }); const outcomes = await Promise.all(tasks); @@ -144,11 +150,12 @@ export class NotificationsService implements INotificationsService { private queueGroupedEmailNotification = async ( notification: Notification, monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision ): Promise => { // If grouping is disabled, fallback to immediate send. if (this.emailGroupingWindowMs <= 0) { - return await this.send(notification, monitor, monitorStatusResponse); + return await this.send(notification, monitor, monitorStatusResponse, decision); } const key = notification.id; @@ -164,15 +171,9 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.delete(key); try { - // For grouped notifications (always DOWN), create a decision - const groupedDecision: MonitorActionDecision = { - shouldCreateIncident: false, - shouldResolveIncident: false, - shouldSendNotification: true, - incidentReason: "status_down", - notificationReason: "status_change", - }; - await this.flushEmailGroup(notification, group.monitors, group.statusResponses, groupedDecision); + // Use the first decision from the group (they should all be similar for grouped notifications) + const firstDecision = group.decisions[0]!; + await this.flushEmailGroup(notification, group.monitors, group.statusResponses, firstDecision); } catch (error: any) { this.logger.error({ message: error?.message, @@ -186,6 +187,7 @@ export class NotificationsService implements INotificationsService { this.pendingEmailGroups.set(key, { monitors: [monitor], statusResponses: [monitorStatusResponse], + decisions: [decision], timer, createdAt: now, }); @@ -193,6 +195,7 @@ export class NotificationsService implements INotificationsService { // Append to existing group. existingGroup.monitors.push(monitor); existingGroup.statusResponses.push(monitorStatusResponse); + existingGroup.decisions.push(decision); } // Consider queueing as "succeeded" from the caller's perspective. @@ -256,11 +259,8 @@ export class NotificationsService implements INotificationsService { return false; } - // Store decision for use in send method - this.currentDecision = decision; - // Send notifications based on decision - return await this.sendNotifications(monitor, monitorStatusResponse); + return await this.sendNotifications(monitor, monitorStatusResponse, decision); }; sendTestNotification = async (notification: Notification) => { From e7a7c0e60383eebdbf88f9954cb4a67271adec65 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:39:41 +0000 Subject: [PATCH 19/20] remove grouping --- .../infrastructure/notificationsService.ts | 156 +----------------- 1 file changed, 3 insertions(+), 153 deletions(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 93a436df9..7fe825da0 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -32,19 +32,6 @@ export class NotificationsService implements INotificationsService { private logger: any; private settingsService: ISettingsService; - // Email grouping (batching) configuration - private emailGroupingWindowMs: number; - private pendingEmailGroups: Map< - string, - { - monitors: Monitor[]; - statusResponses: MonitorStatusResponse[]; - decisions: MonitorActionDecision[]; - timer: ReturnType; - createdAt: number; - } - >; - constructor( notificationsRepository: INotificationsRepository, monitorsRepository: IMonitorsRepository, @@ -67,22 +54,6 @@ export class NotificationsService implements INotificationsService { this.matrixProvider = matrixProvider; this.settingsService = settingsService; this.logger = logger; - - // Configure email grouping window (in milliseconds). - // When > 0, multiple DOWN events for monitors that share the same - // email notification within this window will be batched into a single email. - const rawGroupingWindow = process.env.NOTIFICATION_GROUP_WINDOW_MS ?? process.env.NOTIFICATION_GROUP_WINDOW_SECONDS; - let groupingWindowMs = 0; - if (rawGroupingWindow) { - const parsed = Number(rawGroupingWindow); - if (!Number.isNaN(parsed) && parsed > 0) { - // If value looks like seconds (small number), convert to ms. - // This allows either milliseconds (e.g. 60000) or seconds (e.g. 60). - groupingWindowMs = parsed <= 300 ? parsed * 1000 : parsed; - } - } - this.emailGroupingWindowMs = groupingWindowMs; - this.pendingEmailGroups = new Map(); } private send = async ( @@ -116,15 +87,7 @@ export class NotificationsService implements INotificationsService { const notificationIds = monitor.notifications ?? []; const notifications = await this.notificationsRepository.findNotificationsByIds(notificationIds); - const tasks = notifications.map((notification) => { - // Only group emails, only for DOWN transitions, and only if a window is configured. - if (notification.type === "email" && this.emailGroupingWindowMs > 0 && monitorStatusResponse.status === false) { - return this.queueGroupedEmailNotification(notification, monitor, monitorStatusResponse, decision); - } - - // For all other cases (UP notifications or non-email channels), send immediately. - return this.send(notification, monitor, monitorStatusResponse, decision); - }); + const tasks = notifications.map((notification) => this.send(notification, monitor, monitorStatusResponse, decision)); const outcomes = await Promise.all(tasks); const succeeded = outcomes.filter(Boolean).length; @@ -133,126 +96,13 @@ export class NotificationsService implements INotificationsService { this.logger.warn({ message: `Notification send completed with ${succeeded} success, ${failed} failure(s)`, service: SERVICE_NAME, - method: "getMonitorJob", + method: "sendNotifications", }); } - // Return true if all notificaitons succeeded + // Return true if all notifications succeeded return succeeded === notifications.length; }; - /** - * Queue a DOWN email notification to be potentially grouped with other - * DOWN events for the same email notification within the configured window. - * - * This method returns immediately; the actual email is sent asynchronously - * when the grouping window expires. - */ - private queueGroupedEmailNotification = async ( - notification: Notification, - monitor: Monitor, - monitorStatusResponse: MonitorStatusResponse, - decision: MonitorActionDecision - ): Promise => { - // If grouping is disabled, fallback to immediate send. - if (this.emailGroupingWindowMs <= 0) { - return await this.send(notification, monitor, monitorStatusResponse, decision); - } - - const key = notification.id; - const now = Date.now(); - const existingGroup = this.pendingEmailGroups.get(key); - - if (!existingGroup) { - // Create a new group and schedule a flush after the window expires. - const timer = setTimeout(async () => { - const group = this.pendingEmailGroups.get(key); - if (!group) return; - - this.pendingEmailGroups.delete(key); - - try { - // Use the first decision from the group (they should all be similar for grouped notifications) - const firstDecision = group.decisions[0]!; - await this.flushEmailGroup(notification, group.monitors, group.statusResponses, firstDecision); - } catch (error: any) { - this.logger.error({ - message: error?.message, - service: SERVICE_NAME, - method: "flushEmailGroup", - stack: error?.stack, - }); - } - }, this.emailGroupingWindowMs); - - this.pendingEmailGroups.set(key, { - monitors: [monitor], - statusResponses: [monitorStatusResponse], - decisions: [decision], - timer, - createdAt: now, - }); - } else { - // Append to existing group. - existingGroup.monitors.push(monitor); - existingGroup.statusResponses.push(monitorStatusResponse); - existingGroup.decisions.push(decision); - } - - // Consider queueing as "succeeded" from the caller's perspective. - return true; - }; - - /** - * Flush a grouped set of DOWN events into a single email. - * - * To avoid changing email templates, we construct a synthetic Monitor - * whose name concisely lists all affected services. The existing - * `serverIsDownTemplate` is then reused. - * - * @param notification The email notification to send to - * @param monitors Array of monitors that went down - * @param statusResponses Array of status responses (parallel to monitors) - * @returns true if email was sent successfully, false otherwise - */ - private flushEmailGroup = async ( - notification: Notification, - monitors: Monitor[], - statusResponses: MonitorStatusResponse[], - decision: MonitorActionDecision - ): Promise => { - if (!monitors.length || !statusResponses.length) { - return false; - } - - const { clientHost } = this.settingsService.getSettings(); - if (!clientHost) { - this.logger.warn({ message: "CLIENT_HOST not configured", service: SERVICE_NAME, method: "flushEmailGroup" }); - return false; - } - - // Build a combined monitor name listing all affected services. - // Example: "Service A, Service B" (2 services) or "Service A" (1 service) - const uniqueNames = Array.from(new Set(monitors.map((m) => m.name))); - const servicesCount = uniqueNames.length; - const servicesList = uniqueNames.join(", "); - - const combinedName = servicesCount === 1 ? servicesList : `${servicesCount} services: ${servicesList}`; - - // Use the first monitor as a base for URL and other fields. - const baseMonitor = monitors[0]!; - const baseStatus = statusResponses[0]!; - - // Create a shallow clone so we don't mutate the original entity. - // This preserves monitor properties while overriding the name for grouped display. - const syntheticMonitor: Monitor = { - ...baseMonitor, - name: combinedName, - }; - - // Reuse existing email provider to send grouped notification. - return await this.emailProvider.sendAlert(notification, syntheticMonitor, baseStatus, decision, clientHost); - }; - handleNotifications = async (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => { // Early return if no notification should be sent if (!decision.shouldSendNotification) { From 27f3def9bdd6cf9457c6851c72226a3a6b8defb3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 16 Feb 2026 18:43:03 +0000 Subject: [PATCH 20/20] correct logger type --- server/src/service/infrastructure/notificationsService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 7fe825da0..5f057f0c5 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -3,6 +3,7 @@ import { IMonitorsRepository, INotificationsRepository } from "@/repositories/in 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"; export interface INotificationsService { createNotification: (notificationData: Partial) => Promise; @@ -29,7 +30,7 @@ export class NotificationsService implements INotificationsService { private discordProvider: INotificationProvider; private pagerDutyProvider: INotificationProvider; private matrixProvider: INotificationProvider; - private logger: any; + private logger: ILogger; private settingsService: ISettingsService; constructor(