From bf6246393c417dc2359bd54e3bb0a4d60bef5a69 Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Thu, 5 Mar 2026 13:30:41 -0600 Subject: [PATCH 1/5] Add configurable alert cooldown periods for tray and email Both tray notification and email alert cooldowns were previously hard-coded constants (5 min and 15 min respectively). This change makes both user-configurable via the Settings window. Dashboard: - UserPreferences: add AlertCooldownMinutes (default 5) and EmailCooldownMinutes (default 15) - MainWindow: remove static readonly AlertCooldown constant; derive alertCooldown as a local var from prefs in EvaluateAlertConditionsAsync - EmailAlertService: remove static readonly CooldownPeriod; read prefs.EmailCooldownMinutes at send time - SettingsWindow: add Tray notification cooldown and Email alert cooldown TextBoxes (1-120 min validation); include in restore defaults Lite: - App.xaml.cs: add AlertCooldownMinutes (default 5) and EmailCooldownMinutes (default 15) static props; load/save alert_cooldown_minutes and email_cooldown_minutes in settings.json - MainWindow: remove static readonly AlertCooldown constant; derive alertCooldown as a local var from App props in CheckPerformanceAlerts - EmailAlertService: remove static readonly CooldownPeriod; read App.EmailCooldownMinutes at send time - EmailTemplateBuilder: replace hardcoded '15-minute cooldown' footer text with the live App.EmailCooldownMinutes value - SettingsWindow: add Tray notification cooldown and Email alert cooldown TextBoxes (1-120 min validation); include in restore defaults Documentation: - README.md: update notification channel descriptions to note that both cooldowns are configurable in Settings (defaults: 5 min tray, 15 min email) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dashboard/MainWindow.xaml.cs | 16 ++++++++-------- Dashboard/Models/UserPreferences.cs | 2 ++ Dashboard/Services/EmailAlertService.cs | 3 +-- Dashboard/SettingsWindow.xaml | 10 ++++++++++ Dashboard/SettingsWindow.xaml.cs | 14 ++++++++++++++ Lite/App.xaml.cs | 4 ++++ Lite/MainWindow.xaml.cs | 16 ++++++++-------- Lite/Services/EmailAlertService.cs | 3 +-- Lite/Services/EmailTemplateBuilder.cs | 2 +- Lite/Windows/SettingsWindow.xaml | 14 ++++++++++++++ Lite/Windows/SettingsWindow.xaml.cs | 10 ++++++++++ README.md | 6 ++++-- 12 files changed, 77 insertions(+), 23 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 275917f..00027fb 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -60,7 +60,6 @@ public partial class MainWindow : Window private readonly ConcurrentDictionary _lastBlockingAlert = new(); private readonly ConcurrentDictionary _lastDeadlockAlert = new(); private readonly ConcurrentDictionary _lastHighCpuAlert = new(); - private static readonly TimeSpan AlertCooldown = TimeSpan.FromMinutes(5); private readonly ConcurrentDictionary _activeBlockingAlert = new(); private readonly ConcurrentDictionary _activeDeadlockAlert = new(); private readonly ConcurrentDictionary _activeHighCpuAlert = new(); @@ -1097,6 +1096,7 @@ private async Task EvaluateAlertConditionsAsync( string serverId, string serverName, AlertHealthResult health, DatabaseService databaseService) { var prefs = _preferencesService.GetPreferences(); + var alertCooldown = TimeSpan.FromMinutes(prefs.AlertCooldownMinutes); if (_alertStateService.IsAnySilencingActive(serverId)) { @@ -1112,7 +1112,7 @@ private async Task EvaluateAlertConditionsAsync( if (blockingExceeded) { _activeBlockingAlert[serverId] = true; - if (!_lastBlockingAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastBlockingAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { _notificationService?.ShowBlockingNotification( serverName, @@ -1158,7 +1158,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (deadlocksExceeded) { _activeDeadlockAlert[serverId] = true; - if (!_lastDeadlockAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastDeadlockAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { _notificationService?.ShowDeadlockNotification( serverName, @@ -1197,7 +1197,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { var totalCpu = health.TotalCpuPercent!.Value; _activeHighCpuAlert[serverId] = true; - if (!_lastHighCpuAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastHighCpuAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { _notificationService?.ShowHighCpuNotification( serverName, @@ -1233,7 +1233,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (triggeredWaits.Count > 0) { _activePoisonWaitAlert[serverId] = true; - if (!_lastPoisonWaitAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastPoisonWaitAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { var worst = triggeredWaits[0]; _notificationService?.ShowPoisonWaitNotification(serverName, worst.WaitType, worst.AvgMsPerWait); @@ -1270,7 +1270,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (longRunningTriggered) { _activeLongRunningQueryAlert[serverId] = true; - if (!_lastLongRunningQueryAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastLongRunningQueryAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { var worst = health.LongRunningQueries[0]; var elapsedMinutes = worst.ElapsedSeconds / 60; @@ -1311,7 +1311,7 @@ await _emailAlertService.TrySendAlertEmailAsync( { var tempDb = health.TempDbSpace!; _activeTempDbSpaceAlert[serverId] = true; - if (!_lastTempDbSpaceAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastTempDbSpaceAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown) { _notificationService?.ShowTempDbSpaceNotification(serverName, tempDb.UsedPercent); _lastTempDbSpaceAlert[serverId] = now; @@ -1350,7 +1350,7 @@ await _emailAlertService.TrySendAlertEmailAsync( var worst = health.AnomalousJobs[0]; var jobKey = $"{serverId}:{worst.JobId}:{worst.StartTime:O}"; - if (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastAlert) || (now - lastAlert) >= AlertCooldown) + if (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastAlert) || (now - lastAlert) >= alertCooldown) { var currentMinutes = worst.CurrentDurationSeconds / 60; _notificationService?.ShowLongRunningJobNotification( diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 17e3ecb..a701d70 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -94,6 +94,8 @@ public class UserPreferences public int TempDbSpaceThresholdPercent { get; set; } = 80; // Alert when TempDB used > X% public bool NotifyOnLongRunningJobs { get; set; } = true; public int LongRunningJobMultiplier { get; set; } = 3; // Alert when job runs > Nx historical average + public int AlertCooldownMinutes { get; set; } = 5; // Tray notification cooldown between repeated alerts + public int EmailCooldownMinutes { get; set; } = 15; // Email cooldown between repeated alerts // SMTP email alert settings public bool SmtpEnabled { get; set; } = false; diff --git a/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs index e35bde0..831b794 100644 --- a/Dashboard/Services/EmailAlertService.cs +++ b/Dashboard/Services/EmailAlertService.cs @@ -33,7 +33,6 @@ public class EmailAlertService private readonly UserPreferencesService _preferencesService; private readonly ConcurrentDictionary _cooldowns = new(); - private static readonly TimeSpan CooldownPeriod = TimeSpan.FromMinutes(15); /* Alert log — loaded from JSON on startup, saved on exit, new alerts added in-memory */ private readonly List _alertLog = new(); @@ -90,7 +89,7 @@ public async Task TrySendAlertEmailAsync( var cooldownKey = $"{serverId}:{metricName}"; if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) && - DateTime.UtcNow - lastSent < CooldownPeriod) + DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(prefs.EmailCooldownMinutes)) { return; } diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index 5930e73..1b5266a 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -267,6 +267,16 @@ + + + + + + + + + + diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index 5a39551..7e98783 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -171,6 +171,8 @@ private void LoadSettings() TempDbSpaceThresholdTextBox.Text = prefs.TempDbSpaceThresholdPercent.ToString(CultureInfo.InvariantCulture); NotifyOnLongRunningJobsCheckBox.IsChecked = prefs.NotifyOnLongRunningJobs; LongRunningJobMultiplierTextBox.Text = prefs.LongRunningJobMultiplier.ToString(CultureInfo.InvariantCulture); + AlertCooldownTextBox.Text = prefs.AlertCooldownMinutes.ToString(CultureInfo.InvariantCulture); + EmailCooldownTextBox.Text = prefs.EmailCooldownMinutes.ToString(CultureInfo.InvariantCulture); UpdateNotificationCheckboxStates(); @@ -311,6 +313,8 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e) LongRunningQueryThresholdTextBox.Text = "30"; TempDbSpaceThresholdTextBox.Text = "80"; LongRunningJobMultiplierTextBox.Text = "3"; + AlertCooldownTextBox.Text = "5"; + EmailCooldownTextBox.Text = "15"; UpdateAlertPreviewText(); } @@ -612,6 +616,16 @@ private void OkButton_Click(object sender, RoutedEventArgs e) else if (prefs.NotifyOnLongRunningJobs) validationErrors.Add("Job multiplier must be a positive number"); + if (int.TryParse(AlertCooldownTextBox.Text, out int alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120) + prefs.AlertCooldownMinutes = alertCooldown; + else + validationErrors.Add("Tray notification cooldown must be between 1 and 120 minutes"); + + if (int.TryParse(EmailCooldownTextBox.Text, out int emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120) + prefs.EmailCooldownMinutes = emailCooldown; + else + validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes"); + if (validationErrors.Count > 0) { MessageBox.Show( diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index bf9c405..df0f02b 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -73,6 +73,8 @@ public partial class App : Application public static int AlertTempDbSpaceThresholdPercent { get; set; } = 80; public static bool AlertLongRunningJobEnabled { get; set; } = true; public static int AlertLongRunningJobMultiplier { get; set; } = 3; + public static int AlertCooldownMinutes { get; set; } = 5; // Tray notification cooldown between repeated alerts + public static int EmailCooldownMinutes { get; set; } = 15; // Email cooldown between repeated alerts /* Connection settings */ public static int ConnectionTimeoutSeconds { get; set; } = 5; @@ -256,6 +258,8 @@ public static void LoadAlertSettings() if (root.TryGetProperty("alert_tempdb_space_threshold_percent", out v)) AlertTempDbSpaceThresholdPercent = v.GetInt32(); if (root.TryGetProperty("alert_long_running_job_enabled", out v)) AlertLongRunningJobEnabled = v.GetBoolean(); if (root.TryGetProperty("alert_long_running_job_multiplier", out v)) AlertLongRunningJobMultiplier = v.GetInt32(); + if (root.TryGetProperty("alert_cooldown_minutes", out v)) AlertCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120); + if (root.TryGetProperty("email_cooldown_minutes", out v)) EmailCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120); /* Connection settings */ if (root.TryGetProperty("connection_timeout_seconds", out v)) diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 7e16095..0de4cbc 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -43,7 +43,6 @@ public partial class MainWindow : Window private readonly Dictionary _lastLongRunningQueryAlert = new(); private readonly Dictionary _lastTempDbSpaceAlert = new(); private readonly Dictionary _lastLongRunningJobAlert = new(); - private static readonly TimeSpan AlertCooldown = TimeSpan.FromMinutes(5); private readonly DispatcherTimer _statusTimer; private LocalDataService? _dataService; private McpHostService? _mcpService; @@ -963,6 +962,7 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary) var key = summary.ServerId.ToString(); var now = DateTime.UtcNow; + var alertCooldown = TimeSpan.FromMinutes(App.AlertCooldownMinutes); /* Skip popup/email alerts if user has acknowledged or silenced this server */ bool suppressPopups = !_alertStateService.ShouldShowAlerts(key); @@ -975,7 +975,7 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary) if (cpuExceeded) { _activeCpuAlert[key] = true; - if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= AlertCooldown)) + if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= alertCooldown)) { _trayService.ShowNotification( "High CPU", @@ -1007,7 +1007,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (blockingExceeded) { _activeBlockingAlert[key] = true; - if (!suppressPopups && (!_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= AlertCooldown)) + if (!suppressPopups && (!_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= alertCooldown)) { _trayService.ShowNotification( "Blocking Detected", @@ -1042,7 +1042,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (deadlocksExceeded) { _activeDeadlockAlert[key] = true; - if (!suppressPopups && (!_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= AlertCooldown)) + if (!suppressPopups && (!_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= alertCooldown)) { _trayService.ShowNotification( "Deadlocks Detected", @@ -1081,7 +1081,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (triggered.Count > 0) { _activePoisonWaitAlert[key] = true; - if (!suppressPopups && (!_lastPoisonWaitAlert.TryGetValue(key, out var lastPoisonWait) || now - lastPoisonWait >= AlertCooldown)) + if (!suppressPopups && (!_lastPoisonWaitAlert.TryGetValue(key, out var lastPoisonWait) || now - lastPoisonWait >= alertCooldown)) { var worst = triggered[0]; var allWaitNames = string.Join(", ", triggered.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)")); @@ -1127,7 +1127,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (longRunning.Count > 0) { _activeLongRunningQueryAlert[key] = true; - if (!suppressPopups && (!_lastLongRunningQueryAlert.TryGetValue(key, out var lastLrq) || now - lastLrq >= AlertCooldown)) + if (!suppressPopups && (!_lastLongRunningQueryAlert.TryGetValue(key, out var lastLrq) || now - lastLrq >= alertCooldown)) { var worst = longRunning[0]; var elapsedMinutes = worst.ElapsedSeconds / 60; @@ -1175,7 +1175,7 @@ await _emailAlertService.TrySendAlertEmailAsync( if (tempDb != null && tempDb.UsedPercent >= App.AlertTempDbSpaceThresholdPercent) { _activeTempDbSpaceAlert[key] = true; - if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= AlertCooldown)) + if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= alertCooldown)) { _trayService.ShowNotification( "TempDB Space", @@ -1223,7 +1223,7 @@ await _emailAlertService.TrySendAlertEmailAsync( var worst = anomalousJobs[0]; var jobKey = $"{key}:{worst.JobId}:{worst.StartTime:O}"; - if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= AlertCooldown)) + if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= alertCooldown)) { var currentMinutes = worst.CurrentDurationSeconds / 60; _trayService.ShowNotification( diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index f837221..b8613db 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -25,7 +25,6 @@ namespace PerformanceMonitorLite.Services; public class EmailAlertService { private readonly ConcurrentDictionary _cooldowns = new(); - private static readonly TimeSpan CooldownPeriod = TimeSpan.FromMinutes(15); private readonly DuckDbInitializer? _duckDb; /* Failure tracking for louder logging */ @@ -64,7 +63,7 @@ public async Task TrySendAlertEmailAsync( { var cooldownKey = $"{serverId}:{metricName}"; var withinCooldown = _cooldowns.TryGetValue(cooldownKey, out var lastSent) && - DateTime.UtcNow - lastSent < CooldownPeriod; + DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(App.EmailCooldownMinutes); if (!withinCooldown) { diff --git a/Lite/Services/EmailTemplateBuilder.cs b/Lite/Services/EmailTemplateBuilder.cs index 478f72d..fdded5c 100644 --- a/Lite/Services/EmailTemplateBuilder.cs +++ b/Lite/Services/EmailTemplateBuilder.cs @@ -169,7 +169,7 @@ private static string BuildHtmlBody( sb.Append($"Sent by {WebUtility.HtmlEncode(EditionName)}"); if (!isTest) { - sb.Append(" · 15-minute cooldown between repeat alerts"); + sb.Append($" · {App.EmailCooldownMinutes}-minute cooldown between repeat alerts"); } sb.Append(""); sb.Append(""); diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 098555b..284b5c2 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -218,6 +218,20 @@ + + + + + + + + + + diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 350458a..74df0bf 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -445,6 +445,8 @@ private void LoadAlertSettings() AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString(); AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled; AlertLongRunningJobMultiplierBox.Text = App.AlertLongRunningJobMultiplier.ToString(); + AlertCooldownBox.Text = App.AlertCooldownMinutes.ToString(); + EmailCooldownBox.Text = App.EmailCooldownMinutes.ToString(); UpdateAlertControlStates(); } @@ -480,6 +482,10 @@ private void SaveAlertSettings() App.AlertLongRunningJobEnabled = AlertLongRunningJobCheckBox.IsChecked == true; if (int.TryParse(AlertLongRunningJobMultiplierBox.Text, out var jobMult) && jobMult >= 2 && jobMult <= 20) App.AlertLongRunningJobMultiplier = jobMult; + if (int.TryParse(AlertCooldownBox.Text, out var alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120) + App.AlertCooldownMinutes = alertCooldown; + if (int.TryParse(EmailCooldownBox.Text, out var emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120) + App.EmailCooldownMinutes = emailCooldown; var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); try @@ -517,6 +523,8 @@ private void SaveAlertSettings() root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent; root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled; root["alert_long_running_job_multiplier"] = App.AlertLongRunningJobMultiplier; + root["alert_cooldown_minutes"] = App.AlertCooldownMinutes; + root["email_cooldown_minutes"] = App.EmailCooldownMinutes; var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(settingsPath, root.ToJsonString(options)); @@ -541,6 +549,8 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e) AlertLongRunningQueryThresholdBox.Text = "30"; AlertTempDbSpaceThresholdBox.Text = "80"; AlertLongRunningJobMultiplierBox.Text = "3"; + AlertCooldownBox.Text = "5"; + EmailCooldownBox.Text = "15"; UpdateAlertPreviewText(); } diff --git a/README.md b/README.md index fa8c4e3..94c163b 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,10 @@ All thresholds are configurable in Settings. ### Notification Channels -- **System tray** — balloon notifications with 5-minute per-metric cooldown -- **Email (SMTP)** — styled HTML emails with 15-minute per-metric cooldown, configurable SMTP settings (server, port, SSL, authentication, recipients) +- **System tray** — balloon notifications with a configurable per-metric cooldown (default: 5 minutes) +- **Email (SMTP)** — styled HTML emails with a configurable per-metric cooldown (default: 15 minutes), plus configurable SMTP settings (server, port, SSL, authentication, recipients) + +Both cooldown periods are independently configurable in Settings under the Performance Alerts section. ### Email Alerts From c6d65aef48ad19dd36c66dc160c44746c020b95d Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Thu, 5 Mar 2026 16:58:08 -0600 Subject: [PATCH 2/5] Fix cooldown parity: dynamic email footer in Dashboard, validation in Lite - Dashboard EmailTemplateBuilder: pass emailCooldownMinutes as a required parameter instead of hardcoding 15 minutes in the footer - Dashboard EmailAlertService: pass prefs.EmailCooldownMinutes at call site - Lite SettingsWindow: show validation error and abort save when cooldown values are outside 1-120 minutes, matching Dashboard behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dashboard/Services/EmailAlertService.cs | 2 +- Dashboard/Services/EmailTemplateBuilder.cs | 8 +++++--- Lite/Windows/SettingsWindow.xaml.cs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs index 831b794..d17db94 100644 --- a/Dashboard/Services/EmailAlertService.cs +++ b/Dashboard/Services/EmailAlertService.cs @@ -96,7 +96,7 @@ public async Task TrySendAlertEmailAsync( var subject = $"[SQL Monitor Alert] {metricName} on {serverName}"; var (htmlBody, plainTextBody) = EmailTemplateBuilder.BuildAlertEmail( - metricName, serverName, currentValue, thresholdValue, context); + metricName, serverName, currentValue, thresholdValue, prefs.EmailCooldownMinutes, context); string? sendError = null; bool sent = false; diff --git a/Dashboard/Services/EmailTemplateBuilder.cs b/Dashboard/Services/EmailTemplateBuilder.cs index b72fa13..8738743 100644 --- a/Dashboard/Services/EmailTemplateBuilder.cs +++ b/Dashboard/Services/EmailTemplateBuilder.cs @@ -27,6 +27,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail( string serverName, string currentValue, string thresholdValue, + int emailCooldownMinutes, AlertContext? context = null) { var utcNow = DateTime.UtcNow; @@ -34,7 +35,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail( var (accentColor, badgeText) = GetSeverity(metricName); var html = BuildHtmlBody(metricName, serverName, currentValue, - thresholdValue, utcNow, localNow, accentColor, badgeText, context: context); + thresholdValue, utcNow, localNow, accentColor, badgeText, context: context, emailCooldownMinutes: emailCooldownMinutes); var plain = BuildPlainTextBody(metricName, serverName, currentValue, thresholdValue, utcNow, localNow, context); @@ -87,7 +88,8 @@ private static string BuildHtmlBody( string accentColor, string badgeText, bool isTest = false, - AlertContext? context = null) + AlertContext? context = null, + int emailCooldownMinutes = 15) { var sb = new StringBuilder(2048); @@ -167,7 +169,7 @@ private static string BuildHtmlBody( sb.Append($"Sent by {WebUtility.HtmlEncode(EditionName)}"); if (!isTest) { - sb.Append(" · 15-minute cooldown between repeat alerts"); + sb.Append($" · {emailCooldownMinutes}-minute cooldown between repeat alerts"); } sb.Append(""); sb.Append(""); diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 74df0bf..847be69 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -484,8 +484,20 @@ private void SaveAlertSettings() App.AlertLongRunningJobMultiplier = jobMult; if (int.TryParse(AlertCooldownBox.Text, out var alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120) App.AlertCooldownMinutes = alertCooldown; + else + { + MessageBox.Show("Tray notification cooldown must be between 1 and 120 minutes.", + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } if (int.TryParse(EmailCooldownBox.Text, out var emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120) App.EmailCooldownMinutes = emailCooldown; + else + { + MessageBox.Show("Email alert cooldown must be between 1 and 120 minutes.", + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); try From 8f116d0192cbdc408d16315b92bd6540ee1e1808 Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Thu, 5 Mar 2026 17:03:06 -0600 Subject: [PATCH 3/5] Fix deserialization clamping and validation abort issues - Dashboard UserPreferences: use backing fields with Math.Clamp(1,120) in setters so hand-edited JSON values are clamped on deserialization, matching Lite's App.xaml.cs load-time clamping - Lite SettingsWindow: replace early-return on invalid cooldown with collected validation errors so other valid settings still persist to settings.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dashboard/Models/UserPreferences.cs | 15 +++++++++++++-- Lite/Windows/SettingsWindow.xaml.cs | 13 ++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index a701d70..a28a56c 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -94,8 +94,19 @@ public class UserPreferences public int TempDbSpaceThresholdPercent { get; set; } = 80; // Alert when TempDB used > X% public bool NotifyOnLongRunningJobs { get; set; } = true; public int LongRunningJobMultiplier { get; set; } = 3; // Alert when job runs > Nx historical average - public int AlertCooldownMinutes { get; set; } = 5; // Tray notification cooldown between repeated alerts - public int EmailCooldownMinutes { get; set; } = 15; // Email cooldown between repeated alerts + private int _alertCooldownMinutes = 5; + public int AlertCooldownMinutes + { + get => _alertCooldownMinutes; + set => _alertCooldownMinutes = Math.Clamp(value, 1, 120); + } + + private int _emailCooldownMinutes = 15; + public int EmailCooldownMinutes + { + get => _emailCooldownMinutes; + set => _emailCooldownMinutes = Math.Clamp(value, 1, 120); + } // SMTP email alert settings public bool SmtpEnabled { get; set; } = false; diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 847be69..604920f 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -482,21 +482,20 @@ private void SaveAlertSettings() App.AlertLongRunningJobEnabled = AlertLongRunningJobCheckBox.IsChecked == true; if (int.TryParse(AlertLongRunningJobMultiplierBox.Text, out var jobMult) && jobMult >= 2 && jobMult <= 20) App.AlertLongRunningJobMultiplier = jobMult; + var validationErrors = new List(); if (int.TryParse(AlertCooldownBox.Text, out var alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120) App.AlertCooldownMinutes = alertCooldown; else - { - MessageBox.Show("Tray notification cooldown must be between 1 and 120 minutes.", - "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } + validationErrors.Add("Tray notification cooldown must be between 1 and 120 minutes."); if (int.TryParse(EmailCooldownBox.Text, out var emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120) App.EmailCooldownMinutes = emailCooldown; else + validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes."); + if (validationErrors.Count > 0) { - MessageBox.Show("Email alert cooldown must be between 1 and 120 minutes.", + MessageBox.Show( + string.Join("\n", validationErrors), "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); - return; } var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); From 38769c6c88a1de78aab9aad468a627bb7e56b011 Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Thu, 5 Mar 2026 17:09:45 -0600 Subject: [PATCH 4/5] Keep Settings window open on validation errors - Dashboard: return early after showing validation MessageBox so the window stays open instead of saving and closing - Lite: change SaveAlertSettings to return bool; skip 'Settings saved' message on validation failure so the user can correct the values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dashboard/SettingsWindow.xaml.cs | 1 + Lite/Windows/SettingsWindow.xaml.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index 7e98783..f36afa7 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -632,6 +632,7 @@ private void OkButton_Click(object sender, RoutedEventArgs e) "Some alert thresholds have invalid values and were not changed:\n\n" + string.Join("\n", validationErrors), "Validation", MessageBoxButton.OK, MessageBoxImage.Warning); + return; } // Save SMTP email settings diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 604920f..00ac9ef 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -117,11 +117,13 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) SaveCsvSeparator(); SaveColorTheme(); SaveTimeDisplayMode(); - SaveAlertSettings(); + bool alertsValid = SaveAlertSettings(); SaveSmtpSettings(); _saved = true; + if (!alertsValid) return; + var message = mcpChanged ? "Settings saved. MCP changes take effect after restarting the application." : "Settings saved."; @@ -450,7 +452,7 @@ private void LoadAlertSettings() UpdateAlertControlStates(); } - private void SaveAlertSettings() + private bool SaveAlertSettings() { App.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked == true; App.AlertsEnabled = AlertsEnabledCheckBox.IsChecked == true; @@ -494,8 +496,10 @@ private void SaveAlertSettings() if (validationErrors.Count > 0) { MessageBox.Show( + "Some alert settings have invalid values and were not changed:\n\n" + string.Join("\n", validationErrors), "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; } var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); @@ -544,6 +548,7 @@ private void SaveAlertSettings() { AppLogger.Error("Settings", $"Failed to save alert settings: {ex.Message}"); } + return true; } private void AlertsEnabledCheckBox_Changed(object sender, RoutedEventArgs e) From 0746f3f0fee77b4635656049069e0d5b20d98f25 Mon Sep 17 00:00:00 2001 From: Hannah Vernon Date: Fri, 6 Mar 2026 10:27:58 -0600 Subject: [PATCH 5/5] Fix validation save behavior and MCP port checks - Dashboard: move DialogResult=true into no-errors branch so window stays open on validation failures - Dashboard: add MCP port to collected validation errors for consistency with SMTP port - Lite: always persist valid alert settings to disk before reporting validation errors (fixes memory/disk desync) - Lite: add MCP port validation error with tuple return type (Changed, Valid) - Lite: thread emailCooldownMinutes as parameter through EmailTemplateBuilder (matches Dashboard pattern) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Dashboard/SettingsWindow.xaml.cs | 28 +++++++++++-------- Lite/Services/EmailAlertService.cs | 2 +- Lite/Services/EmailTemplateBuilder.cs | 8 ++++-- Lite/Windows/SettingsWindow.xaml.cs | 40 ++++++++++++++++++--------- 4 files changed, 50 insertions(+), 28 deletions(-) diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index f36afa7..07364a8 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -626,15 +626,6 @@ private void OkButton_Click(object sender, RoutedEventArgs e) else validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes"); - if (validationErrors.Count > 0) - { - MessageBox.Show( - "Some alert thresholds have invalid values and were not changed:\n\n" + - string.Join("\n", validationErrors), - "Validation", MessageBoxButton.OK, MessageBoxImage.Warning); - return; - } - // Save SMTP email settings prefs.SmtpEnabled = SmtpEnabledCheckBox.IsChecked == true; prefs.SmtpServer = SmtpServerTextBox.Text?.Trim() ?? ""; @@ -642,6 +633,8 @@ private void OkButton_Click(object sender, RoutedEventArgs e) { prefs.SmtpPort = smtpPort; } + else + validationErrors.Add("Smtp Port failed validation - must be a valid TCP port number."); prefs.SmtpUseSsl = SmtpSslCheckBox.IsChecked == true; prefs.SmtpUsername = SmtpUsernameTextBox.Text?.Trim() ?? ""; prefs.SmtpFromAddress = SmtpFromTextBox.Text?.Trim() ?? ""; @@ -658,12 +651,25 @@ private void OkButton_Click(object sender, RoutedEventArgs e) { prefs.McpPort = mcpPort; } + else + validationErrors.Add("MCP port failed validation - must be a valid TCP port number."); _preferencesService.SavePreferences(prefs); _saved = true; - DialogResult = true; - Close(); + + if (validationErrors.Count > 0) + { + MessageBox.Show( + "Some settings have invalid values and were not changed:\n\n" + + string.Join("\n", validationErrors), + "Validation", MessageBoxButton.OK, MessageBoxImage.Warning); + } + else + { + DialogResult = true; + Close(); + } } private void CancelButton_Click(object sender, RoutedEventArgs e) diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs index b8613db..bbb35ed 100644 --- a/Lite/Services/EmailAlertService.cs +++ b/Lite/Services/EmailAlertService.cs @@ -71,7 +71,7 @@ public async Task TrySendAlertEmailAsync( var subject = $"[SQL Monitor Alert] {metricName} on {serverName}"; var (htmlBody, plainTextBody) = EmailTemplateBuilder.BuildAlertEmail( - metricName, serverName, currentValue, thresholdValue, context); + metricName, serverName, currentValue, thresholdValue, App.EmailCooldownMinutes, context); try { diff --git a/Lite/Services/EmailTemplateBuilder.cs b/Lite/Services/EmailTemplateBuilder.cs index fdded5c..7dd5596 100644 --- a/Lite/Services/EmailTemplateBuilder.cs +++ b/Lite/Services/EmailTemplateBuilder.cs @@ -29,6 +29,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail( string serverName, string currentValue, string thresholdValue, + int emailCooldownMinutes, AlertContext? context = null) { var utcNow = DateTime.UtcNow; @@ -36,7 +37,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail( var (accentColor, badgeText) = GetSeverity(metricName); var html = BuildHtmlBody(metricName, serverName, currentValue, - thresholdValue, utcNow, localNow, accentColor, badgeText, context: context); + thresholdValue, utcNow, localNow, accentColor, badgeText, context: context, emailCooldownMinutes: emailCooldownMinutes); var plain = BuildPlainTextBody(metricName, serverName, currentValue, thresholdValue, utcNow, localNow, context); @@ -89,7 +90,8 @@ private static string BuildHtmlBody( string accentColor, string badgeText, bool isTest = false, - AlertContext? context = null) + AlertContext? context = null, + int emailCooldownMinutes = 15) { var sb = new StringBuilder(2048); @@ -169,7 +171,7 @@ private static string BuildHtmlBody( sb.Append($"Sent by {WebUtility.HtmlEncode(EditionName)}"); if (!isTest) { - sb.Append($" · {App.EmailCooldownMinutes}-minute cooldown between repeat alerts"); + sb.Append($" · {emailCooldownMinutes}-minute cooldown between repeat alerts"); } sb.Append(""); sb.Append(""); diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 00ac9ef..5408d6b 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -111,7 +111,7 @@ private void PauseResumeButton_Click(object sender, RoutedEventArgs e) private void SaveButton_Click(object sender, RoutedEventArgs e) { _scheduleManager.SaveSchedules(); - bool mcpChanged = SaveMcpSettings(); + var (mcpChanged, mcpValid) = SaveMcpSettings(); SaveDefaultTimeRange(); SaveConnectionTimeout(); SaveCsvSeparator(); @@ -122,7 +122,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) _saved = true; - if (!alertsValid) return; + if (!alertsValid || !mcpValid) return; var message = mcpChanged ? "Settings saved. MCP changes take effect after restarting the application." @@ -130,7 +130,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e) MessageBox.Show(message, "Settings", MessageBoxButton.OK, MessageBoxImage.Information); } - private bool SaveMcpSettings() + private (bool Changed, bool Valid) SaveMcpSettings() { var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); @@ -154,20 +154,32 @@ private bool SaveMcpSettings() root["mcp_enabled"] = newEnabled; + bool portValid = true; if (newPort > 0 && newPort < 65536) { root["mcp_port"] = newPort; } + else + { + portValid = false; + } var options = new JsonSerializerOptions { WriteIndented = true }; File.WriteAllText(settingsPath, root.ToJsonString(options)); - return oldEnabled != newEnabled || oldPort != newPort; + if (!portValid) + { + MessageBox.Show( + "MCP port failed validation - must be a valid TCP port number.\nOther MCP settings were saved.", + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + } + + return (oldEnabled != newEnabled || oldPort != newPort, portValid); } catch (Exception ex) { AppLogger.Error("Settings", $"Failed to save MCP settings: {ex.Message}"); - return false; + return (false, true); } } @@ -493,14 +505,6 @@ private bool SaveAlertSettings() App.EmailCooldownMinutes = emailCooldown; else validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes."); - if (validationErrors.Count > 0) - { - MessageBox.Show( - "Some alert settings have invalid values and were not changed:\n\n" + - string.Join("\n", validationErrors), - "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); - return false; - } var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); try @@ -548,6 +552,16 @@ private bool SaveAlertSettings() { AppLogger.Error("Settings", $"Failed to save alert settings: {ex.Message}"); } + + if (validationErrors.Count > 0) + { + MessageBox.Show( + "Some alert settings have invalid values and were not changed:\n\n" + + string.Join("\n", validationErrors), + "Settings", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; + } + return true; }