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..a28a56c 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -94,6 +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 + 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/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs index e35bde0..d17db94 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,14 +89,14 @@ 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; } 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/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..07364a8 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,13 +616,15 @@ private void OkButton_Click(object sender, RoutedEventArgs e) else if (prefs.NotifyOnLongRunningJobs) validationErrors.Add("Job multiplier must be a positive number"); - 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); - } + 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"); // Save SMTP email settings prefs.SmtpEnabled = SmtpEnabledCheckBox.IsChecked == true; @@ -627,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() ?? ""; @@ -643,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/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..bbb35ed 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) { @@ -72,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 478f72d..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(" · 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 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..5408d6b 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -111,24 +111,26 @@ 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(); SaveColorTheme(); SaveTimeDisplayMode(); - SaveAlertSettings(); + bool alertsValid = SaveAlertSettings(); SaveSmtpSettings(); _saved = true; + if (!alertsValid || !mcpValid) return; + var message = mcpChanged ? "Settings saved. MCP changes take effect after restarting the application." : "Settings saved."; 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"); @@ -152,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); } } @@ -445,10 +459,12 @@ 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(); } - private void SaveAlertSettings() + private bool SaveAlertSettings() { App.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked == true; App.AlertsEnabled = AlertsEnabledCheckBox.IsChecked == true; @@ -480,6 +496,15 @@ 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 + 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."); var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json"); try @@ -517,6 +542,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)); @@ -525,6 +552,17 @@ private void 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; } private void AlertsEnabledCheckBox_Changed(object sender, RoutedEventArgs e) @@ -541,6 +579,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