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