From 0453d8a6f2ee1954245ed4cefa1bbb937f3a1bce Mon Sep 17 00:00:00 2001 From: Jake Morgan Date: Fri, 27 Mar 2026 10:40:31 +0000 Subject: [PATCH] Add Teams and Slack webhook notifications Push alert notifications to Microsoft Teams and/or Slack channels via incoming webhooks, independently of email alerts. Each channel has its own enable toggle, webhook URL, optional proxy, and test button. Severity colors (red/orange/yellow/green/blue) map 1:1 with existing email alert badges. Muted alerts are respected. Dashboard: new Webhooks tab in Settings, wider resizable window. Lite: new Webhooks section in Settings, persisted to settings.json. Made-with: Cursor --- Dashboard/MainWindow.xaml.cs | 1 + Dashboard/Models/UserPreferences.cs | 10 + Dashboard/Services/EmailAlertService.cs | 105 +++--- Dashboard/Services/WebhookAlertService.cs | 429 ++++++++++++++++++++++ Dashboard/SettingsWindow.xaml | 122 +++++- Dashboard/SettingsWindow.xaml.cs | 131 +++++++ Lite/App.xaml.cs | 20 + Lite/Services/EmailAlertService.cs | 8 + Lite/Services/WebhookAlertService.cs | 409 +++++++++++++++++++++ Lite/Windows/SettingsWindow.xaml | 110 +++++- Lite/Windows/SettingsWindow.xaml.cs | 157 ++++++++ 11 files changed, 1449 insertions(+), 53 deletions(-) create mode 100644 Dashboard/Services/WebhookAlertService.cs create mode 100644 Lite/Services/WebhookAlertService.cs diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 4bddd53f..3aada5bb 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -103,6 +103,7 @@ public MainWindow() _credentialService = new CredentialService(); _emailAlertService = new EmailAlertService(_preferencesService); + _ = new WebhookAlertService(_preferencesService); _alertCheckTimer = new DispatcherTimer(); _alertCheckTimer.Tick += AlertCheckTimer_Tick; diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 9276408c..7a83afe0 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -117,6 +117,16 @@ public int EmailCooldownMinutes public string SmtpFromAddress { get; set; } = ""; public string SmtpRecipients { get; set; } = ""; + // Teams webhook settings + public bool TeamsWebhookEnabled { get; set; } = false; + public string TeamsWebhookUrl { get; set; } = ""; + public string TeamsProxyAddress { get; set; } = ""; + + // Slack webhook settings + public bool SlackWebhookEnabled { get; set; } = false; + public string SlackWebhookUrl { get; set; } = ""; + public string SlackProxyAddress { get; set; } = ""; + // MCP server settings public bool McpEnabled { get; set; } = false; public int McpPort { get; set; } = 5150; diff --git a/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs index 2bb0ab32..ca365287 100644 --- a/Dashboard/Services/EmailAlertService.cs +++ b/Dashboard/Services/EmailAlertService.cs @@ -64,7 +64,8 @@ public EmailAlertService(UserPreferencesService preferencesService) } /// - /// Attempts to send an alert email if SMTP is enabled and cooldown has elapsed. + /// Attempts to send alert notifications (email, Teams, Slack) based on enabled channels. + /// Each channel operates independently — disabling email does not affect webhooks. /// Never throws. /// public async Task TrySendAlertEmailAsync( @@ -78,64 +79,70 @@ public async Task TrySendAlertEmailAsync( try { var prefs = _preferencesService.GetPreferences(); - if (!prefs.SmtpEnabled) return; - - if (string.IsNullOrWhiteSpace(prefs.SmtpServer) || - string.IsNullOrWhiteSpace(prefs.SmtpFromAddress) || - string.IsNullOrWhiteSpace(prefs.SmtpRecipients)) - { - return; - } - - var cooldownKey = $"{serverId}:{metricName}"; - if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) && - 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, prefs.EmailCooldownMinutes, context); - string? sendError = null; bool sent = false; + string notificationType = "tray"; - try + /* Attempt email delivery if SMTP is fully configured */ + if (prefs.SmtpEnabled && + !string.IsNullOrWhiteSpace(prefs.SmtpServer) && + !string.IsNullOrWhiteSpace(prefs.SmtpFromAddress) && + !string.IsNullOrWhiteSpace(prefs.SmtpRecipients)) { - await SendEmailAsync(prefs, subject, htmlBody, plainTextBody, context); - sent = true; - _cooldowns[cooldownKey] = DateTime.UtcNow; + var cooldownKey = $"{serverId}:{metricName}"; + var withinCooldown = _cooldowns.TryGetValue(cooldownKey, out var lastSent) && + DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(prefs.EmailCooldownMinutes); - /* Log recovery if we had previous failures */ - if (_consecutiveFailures > 0) + if (!withinCooldown) { - Logger.Info($"Alert email delivery recovered after {_consecutiveFailures} failure(s)"); - } - _consecutiveFailures = 0; - _lastFailureError = null; - - Logger.Info($"Alert email sent for {metricName} on {serverName}"); - } - catch (Exception ex) - { - sendError = ex.Message; - _consecutiveFailures++; - _lastFailureError = ex.Message; - - /* Loud on first 3 failures, then periodic reminders */ - if (_consecutiveFailures <= 3) - { - Logger.Error($"ALERT EMAIL FAILED ({_consecutiveFailures}x): {ex.GetType().Name}: {ex.Message}"); - } - else if (_consecutiveFailures % 50 == 0) - { - Logger.Error($"ALERT EMAIL STILL FAILING: {_consecutiveFailures} consecutive failures. Last error: {ex.Message}"); + notificationType = "email"; + var subject = $"[SQL Monitor Alert] {metricName} on {serverName}"; + var (htmlBody, plainTextBody) = EmailTemplateBuilder.BuildAlertEmail( + metricName, serverName, currentValue, thresholdValue, prefs.EmailCooldownMinutes, context); + + try + { + await SendEmailAsync(prefs, subject, htmlBody, plainTextBody, context); + sent = true; + _cooldowns[cooldownKey] = DateTime.UtcNow; + + if (_consecutiveFailures > 0) + { + Logger.Info($"Alert email delivery recovered after {_consecutiveFailures} failure(s)"); + } + _consecutiveFailures = 0; + _lastFailureError = null; + + Logger.Info($"Alert email sent for {metricName} on {serverName}"); + } + catch (Exception ex) + { + sendError = ex.Message; + _consecutiveFailures++; + _lastFailureError = ex.Message; + + if (_consecutiveFailures <= 3) + { + Logger.Error($"ALERT EMAIL FAILED ({_consecutiveFailures}x): {ex.GetType().Name}: {ex.Message}"); + } + else if (_consecutiveFailures % 50 == 0) + { + Logger.Error($"ALERT EMAIL STILL FAILING: {_consecutiveFailures} consecutive failures. Last error: {ex.Message}"); + } + } } } /* Log the alert attempt */ - RecordAlert(serverId, serverName, metricName, currentValue, thresholdValue, sent, "email", sendError); + RecordAlert(serverId, serverName, metricName, currentValue, thresholdValue, sent, notificationType, sendError); + + /* Send webhook notifications (Teams / Slack) — independent of email */ + var webhookService = WebhookAlertService.Current; + if (webhookService != null) + { + await webhookService.TrySendWebhookAlertsAsync( + metricName, serverName, currentValue, thresholdValue, serverId, context); + } } catch (Exception ex) { diff --git a/Dashboard/Services/WebhookAlertService.cs b/Dashboard/Services/WebhookAlertService.cs new file mode 100644 index 00000000..d6340cfb --- /dev/null +++ b/Dashboard/Services/WebhookAlertService.cs @@ -0,0 +1,429 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Services +{ + /// + /// Sends alert notifications to Microsoft Teams and/or Slack via incoming webhooks. + /// Color-coded accent bars match the existing email alert severity mapping. + /// + public class WebhookAlertService + { + private const string EditionName = "Performance Monitor Dashboard"; + private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null }; + + private readonly UserPreferencesService _preferencesService; + private readonly ConcurrentDictionary _cooldowns = new(); + + private int _consecutiveTeamsFailures; + private string? _lastTeamsError; + private int _consecutiveSlackFailures; + private string? _lastSlackError; + + public static WebhookAlertService? Current { get; private set; } + + public WebhookAlertService(UserPreferencesService preferencesService) + { + _preferencesService = preferencesService; + Current = this; + } + + /// + /// Sends webhook alerts to all configured channels (Teams and/or Slack). + /// Respects the email cooldown setting for throttling. Never throws. + /// + public async Task TrySendWebhookAlertsAsync( + string metricName, + string serverName, + string currentValue, + string thresholdValue, + string serverId = "", + AlertContext? context = null) + { + try + { + var prefs = _preferencesService.GetPreferences(); + + var cooldownKey = $"webhook:{serverId}:{metricName}"; + if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) && + DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(prefs.EmailCooldownMinutes)) + { + return; + } + + bool sent = false; + + if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl)) + { + sent |= await TrySendTeamsAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); + } + + if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl)) + { + sent |= await TrySendSlackAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); + } + + if (sent) + { + _cooldowns[cooldownKey] = DateTime.UtcNow; + } + } + catch (Exception ex) + { + Logger.Error($"TrySendWebhookAlertsAsync outer error: {ex.Message}"); + } + } + + /// + /// Sends a test notification to Microsoft Teams. Returns null on success, error message on failure. + /// + public static async Task SendTestTeamsAsync(string webhookUrl, string? proxyAddress) + { + try + { + if (string.IsNullOrWhiteSpace(webhookUrl)) + return "Teams webhook URL is not configured."; + + var payload = BuildTeamsPayload("Test Notification", "", "SMTP and webhook configuration verified", "", isTest: true); + return await PostWebhookAsync(webhookUrl, payload, proxyAddress); + } + catch (Exception ex) + { + return ex.Message; + } + } + + /// + /// Sends a test notification to Slack. Returns null on success, error message on failure. + /// + public static async Task SendTestSlackAsync(string webhookUrl, string? proxyAddress) + { + try + { + if (string.IsNullOrWhiteSpace(webhookUrl)) + return "Slack webhook URL is not configured."; + + var payload = BuildSlackPayload("Test Notification", "", "SMTP and webhook configuration verified", "", isTest: true); + return await PostWebhookAsync(webhookUrl, payload, proxyAddress); + } + catch (Exception ex) + { + return ex.Message; + } + } + + public (int ConsecutiveFailures, string? LastError) GetTeamsHealth() => + (_consecutiveTeamsFailures, _lastTeamsError); + + public (int ConsecutiveFailures, string? LastError) GetSlackHealth() => + (_consecutiveSlackFailures, _lastSlackError); + + #region Teams + + private async Task TrySendTeamsAlertAsync( + UserPreferences prefs, + string metricName, + string serverName, + string currentValue, + string thresholdValue, + AlertContext? context) + { + try + { + var payload = BuildTeamsPayload(metricName, serverName, currentValue, thresholdValue, context: context); + var error = await PostWebhookAsync(prefs.TeamsWebhookUrl, payload, prefs.TeamsProxyAddress); + + if (error != null) + { + _consecutiveTeamsFailures++; + _lastTeamsError = error; + + if (_consecutiveTeamsFailures <= 3) + Logger.Error($"TEAMS WEBHOOK FAILED ({_consecutiveTeamsFailures}x): {error}"); + else if (_consecutiveTeamsFailures % 50 == 0) + Logger.Error($"TEAMS WEBHOOK STILL FAILING: {_consecutiveTeamsFailures} consecutive failures. Last error: {error}"); + + return false; + } + + if (_consecutiveTeamsFailures > 0) + Logger.Info($"Teams webhook delivery recovered after {_consecutiveTeamsFailures} failure(s)"); + + _consecutiveTeamsFailures = 0; + _lastTeamsError = null; + Logger.Info($"Teams webhook sent for {metricName} on {serverName}"); + return true; + } + catch (Exception ex) + { + _consecutiveTeamsFailures++; + _lastTeamsError = ex.Message; + Logger.Error($"Teams webhook error: {ex.Message}"); + return false; + } + } + + /// + /// Builds an O365 MessageCard payload for Teams incoming webhooks. + /// The themeColor property renders as a colored accent bar at the top of the card. + /// + internal static string BuildTeamsPayload( + string metricName, + string serverName, + string currentValue, + string thresholdValue, + bool isTest = false, + AlertContext? context = null) + { + var (hexColor, badgeText, emoji) = GetSeverity(metricName); + var themeColor = hexColor.TrimStart('#'); + var utcNow = DateTime.UtcNow; + var localNow = DateTime.Now; + + var facts = new List(); + + if (isTest) + { + facts.Add(new { name = "Status", value = "Webhook configuration is working correctly" }); + facts.Add(new { name = "Sent at", value = localNow.ToString("yyyy-MM-dd HH:mm:ss") }); + } + else + { + facts.Add(new { name = "Server", value = serverName }); + facts.Add(new { name = "Current Value", value = currentValue }); + facts.Add(new { name = "Threshold", value = thresholdValue }); + facts.Add(new { name = "Time (UTC)", value = utcNow.ToString("yyyy-MM-dd HH:mm:ss") }); + facts.Add(new { name = "Time (Local)", value = localNow.ToString("yyyy-MM-dd HH:mm:ss") }); + } + + if (context?.Details != null) + { + foreach (var detail in context.Details) + { + foreach (var (label, value) in detail.Fields) + { + facts.Add(new { name = label, value }); + } + } + } + + var title = isTest + ? $"{emoji} TEST — {metricName}" + : $"{emoji} {badgeText} — {metricName}"; + + var sections = new List + { + new + { + activityTitle = title, + activitySubtitle = isTest ? EditionName : $"{EditionName} — {serverName}", + facts, + markdown = true + } + }; + + var card = new + { + @type = "MessageCard", + @context = "http://schema.org/extensions", + themeColor, + summary = isTest + ? $"[SQL Monitor] Test Notification" + : $"[SQL Monitor] {badgeText}: {metricName} on {serverName}", + sections + }; + + return JsonSerializer.Serialize(card, s_jsonOptions); + } + + #endregion + + #region Slack + + private async Task TrySendSlackAlertAsync( + UserPreferences prefs, + string metricName, + string serverName, + string currentValue, + string thresholdValue, + AlertContext? context) + { + try + { + var payload = BuildSlackPayload(metricName, serverName, currentValue, thresholdValue, context: context); + var error = await PostWebhookAsync(prefs.SlackWebhookUrl, payload, prefs.SlackProxyAddress); + + if (error != null) + { + _consecutiveSlackFailures++; + _lastSlackError = error; + + if (_consecutiveSlackFailures <= 3) + Logger.Error($"SLACK WEBHOOK FAILED ({_consecutiveSlackFailures}x): {error}"); + else if (_consecutiveSlackFailures % 50 == 0) + Logger.Error($"SLACK WEBHOOK STILL FAILING: {_consecutiveSlackFailures} consecutive failures. Last error: {error}"); + + return false; + } + + if (_consecutiveSlackFailures > 0) + Logger.Info($"Slack webhook delivery recovered after {_consecutiveSlackFailures} failure(s)"); + + _consecutiveSlackFailures = 0; + _lastSlackError = null; + Logger.Info($"Slack webhook sent for {metricName} on {serverName}"); + return true; + } + catch (Exception ex) + { + _consecutiveSlackFailures++; + _lastSlackError = ex.Message; + Logger.Error($"Slack webhook error: {ex.Message}"); + return false; + } + } + + /// + /// Builds a Slack incoming webhook payload with a colored attachment sidebar. + /// Uses Slack Block Kit for rich formatting. + /// + internal static string BuildSlackPayload( + string metricName, + string serverName, + string currentValue, + string thresholdValue, + bool isTest = false, + AlertContext? context = null) + { + var (hexColor, badgeText, emoji) = GetSeverity(metricName); + var utcNow = DateTime.UtcNow; + var localNow = DateTime.Now; + + var title = isTest + ? $"{emoji} TEST — {metricName}" + : $"{emoji} {badgeText} — {metricName}"; + + var blocks = new List + { + new + { + type = "header", + text = new { type = "plain_text", text = title, emoji = true } + } + }; + + var fields = new List(); + + if (isTest) + { + fields.Add(new { type = "mrkdwn", text = "*Status:*\nWebhook configuration is working correctly" }); + fields.Add(new { type = "mrkdwn", text = $"*Sent at:*\n{localNow:yyyy-MM-dd HH:mm:ss}" }); + } + else + { + fields.Add(new { type = "mrkdwn", text = $"*Server:*\n{serverName}" }); + fields.Add(new { type = "mrkdwn", text = $"*Current Value:*\n{currentValue}" }); + fields.Add(new { type = "mrkdwn", text = $"*Threshold:*\n{thresholdValue}" }); + fields.Add(new { type = "mrkdwn", text = $"*Time (UTC):*\n{utcNow:yyyy-MM-dd HH:mm:ss}" }); + fields.Add(new { type = "mrkdwn", text = $"*Time (Local):*\n{localNow:yyyy-MM-dd HH:mm:ss}" }); + } + + blocks.Add(new { type = "section", fields }); + + if (context?.Details != null) + { + foreach (var detail in context.Details) + { + blocks.Add(new { type = "divider" }); + + var detailFields = new List(); + detailFields.Add(new { type = "mrkdwn", text = $"*{detail.Heading}*" }); + + foreach (var (label, value) in detail.Fields) + { + detailFields.Add(new { type = "mrkdwn", text = $"*{label}:*\n{value}" }); + } + + blocks.Add(new { type = "section", fields = detailFields }); + } + } + + blocks.Add(new + { + type = "context", + elements = new object[] + { + new { type = "mrkdwn", text = $"Sent by {EditionName}" } + } + }); + + var payload = new + { + attachments = new object[] + { + new { color = hexColor, blocks } + } + }; + + return JsonSerializer.Serialize(payload, s_jsonOptions); + } + + #endregion + + #region Shared + + private static (string HexColor, string BadgeText, string Emoji) GetSeverity(string metricName) => metricName switch + { + "Blocking Detected" => ("#D97706", "ALERT", "\U0001F7E0"), + "Deadlocks Detected" => ("#DC2626", "ALERT", "\U0001F534"), + "High CPU" => ("#F59E0B", "WARNING", "\U0001F7E1"), + "Poison Wait" => ("#DC2626", "CRITICAL", "\U0001F534"), + "Long-Running Query" => ("#D97706", "WARNING", "\U0001F7E0"), + "TempDB Space" => ("#D97706", "WARNING", "\U0001F7E0"), + "Long-Running Job" => ("#D97706", "WARNING", "\U0001F7E0"), + "Server Unreachable" => ("#DC2626", "CRITICAL", "\U0001F534"), + "Server Restored" => ("#16A34A", "RESOLVED", "\U0001F7E2"), + _ => ("#2eaef1", "INFO", "\U0001F535") + }; + + /// + /// Posts a JSON payload to a webhook URL. Returns null on success, error message on failure. + /// + private static async Task PostWebhookAsync(string webhookUrl, string jsonPayload, string? proxyAddress) + { + var handler = new HttpClientHandler(); + if (!string.IsNullOrWhiteSpace(proxyAddress)) + { + handler.Proxy = new WebProxy(proxyAddress); + handler.UseProxy = true; + } + + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + using var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(webhookUrl, content); + + if (response.IsSuccessStatusCode) + return null; + + var body = await response.Content.ReadAsStringAsync(); + return $"HTTP {(int)response.StatusCode}: {body}"; + } + + #endregion + } +} diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index e626d4ec..4944f59c 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -2,9 +2,10 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Settings" - Height="550" Width="550" + Height="600" Width="680" + MinHeight="500" MinWidth="600" WindowStartupLocation="CenterOwner" - ResizeMode="NoResize" + ResizeMode="CanResizeWithGrip" Background="{DynamicResource BackgroundBrush}"> @@ -367,6 +368,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +