diff --git a/Dashboard/AlertDetailWindow.xaml b/Dashboard/AlertDetailWindow.xaml
new file mode 100644
index 00000000..0d3bdf31
--- /dev/null
+++ b/Dashboard/AlertDetailWindow.xaml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/AlertDetailWindow.xaml.cs b/Dashboard/AlertDetailWindow.xaml.cs
new file mode 100644
index 00000000..69e7e521
--- /dev/null
+++ b/Dashboard/AlertDetailWindow.xaml.cs
@@ -0,0 +1,36 @@
+/*
+ * Performance Monitor Dashboard
+ * Copyright (c) 2026 Darling Data, LLC
+ * Licensed under the MIT License - see LICENSE file for details
+ */
+
+using System.Windows;
+using PerformanceMonitorDashboard.Controls;
+
+namespace PerformanceMonitorDashboard
+{
+ public partial class AlertDetailWindow : Window
+ {
+ public AlertDetailWindow(AlertHistoryDisplayItem item)
+ {
+ InitializeComponent();
+
+ TimeText.Text = item.TimeLocal;
+ ServerText.Text = item.ServerName;
+ MetricText.Text = item.MetricName;
+ CurrentValueText.Text = item.CurrentValue;
+ ThresholdText.Text = item.ThresholdValue;
+ NotificationText.Text = item.NotificationType;
+ StatusText.Text = item.StatusDisplay;
+
+ if (item.Muted)
+ MutedBanner.Visibility = Visibility.Visible;
+
+ if (!string.IsNullOrWhiteSpace(item.DetailText))
+ {
+ DetailTextBox.Text = item.DetailText;
+ DetailPanel.Visibility = Visibility.Visible;
+ }
+ }
+ }
+}
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml b/Dashboard/Controls/AlertsHistoryContent.xaml
index b97fbc3d..f7dfc2b6 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml
@@ -9,6 +9,10 @@
+
+
@@ -22,6 +26,13 @@
+
+
+
@@ -89,6 +104,7 @@
CanUserResizeColumns="True"
SelectionMode="Extended"
SelectionChanged="AlertsDataGrid_SelectionChanged"
+ MouseDoubleClick="AlertsDataGrid_MouseDoubleClick"
RowStyle="{StaticResource AlertRowStyle}">
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
index 22b9ed79..2911fa83 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
@@ -25,6 +25,8 @@ public partial class AlertsHistoryContent : UserControl
{
public event EventHandler? AlertsDismissed;
+ public MuteRuleService? MuteRuleService { get; set; }
+
private List _allAlerts = new();
/* Column filter state */
@@ -71,7 +73,9 @@ private void LoadAlerts()
IsResolved = e.MetricName.Contains("Cleared") || e.MetricName.Contains("Resolved"),
IsCritical = e.MetricName.Contains("Deadlock") || e.MetricName.Contains("Poison"),
IsWarning = !e.MetricName.Contains("Cleared") && !e.MetricName.Contains("Resolved")
- && !e.MetricName.Contains("Deadlock") && !e.MetricName.Contains("Poison")
+ && !e.MetricName.Contains("Deadlock") && !e.MetricName.Contains("Poison"),
+ Muted = e.Muted,
+ DetailText = e.DetailText
}).ToList();
ApplyFilters();
@@ -432,6 +436,86 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
}
#endregion
+
+ #region Mute Handlers
+
+ private void AlertsDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (sender is not DataGrid) return;
+
+ // Walk up the visual tree from the click target to find the DataGridRow
+ var source = e.OriginalSource as DependencyObject;
+ while (source != null && source is not DataGridRow && source is not DataGridColumnHeader)
+ source = System.Windows.Media.VisualTreeHelper.GetParent(source);
+
+ // Ignore clicks on column headers or outside rows
+ if (source is not DataGridRow row) return;
+ if (row.DataContext is not AlertHistoryDisplayItem item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private void ViewAlertDetails_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var detailWindow = new AlertDetailWindow(item) { Owner = Window.GetWindow(this) };
+ detailWindow.ShowDialog();
+ }
+
+ private void MuteThisAlert_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var context = new AlertMuteContext
+ {
+ ServerName = item.ServerName,
+ MetricName = item.MetricName
+ };
+
+ var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ MuteRuleService.AddRule(dialog.Rule);
+ LoadAlerts();
+ }
+ }
+
+ private void MuteSimilarAlerts_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var context = new AlertMuteContext
+ {
+ MetricName = item.MetricName
+ };
+
+ var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ MuteRuleService.AddRule(dialog.Rule);
+ LoadAlerts();
+ }
+ }
+
+ #endregion
}
public class AlertHistoryDisplayItem
@@ -447,5 +531,7 @@ public class AlertHistoryDisplayItem
public bool IsResolved { get; set; }
public bool IsCritical { get; set; }
public bool IsWarning { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
}
}
diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs
index 54d9f5d3..00ffaf90 100644
--- a/Dashboard/MainWindow.xaml.cs
+++ b/Dashboard/MainWindow.xaml.cs
@@ -41,6 +41,7 @@ public partial class MainWindow : Window
private readonly DispatcherTimer _connectionStatusTimer;
private NotificationService? _notificationService;
private readonly AlertStateService _alertStateService;
+ private readonly MuteRuleService _muteRuleService;
private readonly Dictionary _previousConnectionStates;
private readonly Dictionary _tabBadges;
private readonly Dictionary _latestHealthStatus;
@@ -92,6 +93,7 @@ public MainWindow()
_openTabs = new Dictionary();
_preferencesService = new UserPreferencesService();
_alertStateService = new AlertStateService();
+ _muteRuleService = new MuteRuleService();
_serverListItems = new ObservableCollection();
_previousConnectionStates = new Dictionary();
_tabBadges = new Dictionary();
@@ -209,7 +211,7 @@ private async void StartMcpServerIfEnabled()
return;
}
- _mcpHostService = new McpHostService(_serverManager, _credentialService, prefs.McpPort);
+ _mcpHostService = new McpHostService(_serverManager, _credentialService, _muteRuleService, _preferencesService, prefs.McpPort);
_mcpCts = new CancellationTokenSource();
_ = _mcpHostService.StartAsync(_mcpCts.Token);
}
@@ -673,6 +675,7 @@ private void OpenAlertsTab()
}
_alertsHistoryContent = new AlertsHistoryContent();
+ _alertsHistoryContent.MuteRuleService = _muteRuleService;
_alertsHistoryContent.AlertsDismissed += (_, _) => UpdateAlertBadge();
var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
@@ -813,6 +816,9 @@ private void CloseTab_Click(object sender, RoutedEventArgs e)
private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
+ // Only respond to tab selection changes, not child control selection events that bubble up
+ if (e.OriginalSource != ServerTabControl) return;
+
/* Restore the selected tab's UTC offset so charts use the correct server timezone */
if (ServerTabControl.SelectedItem is TabItem { Content: ServerTab serverTab })
{
@@ -993,7 +999,7 @@ private void Settings_Click(object sender, RoutedEventArgs e)
bool wasEnabled = oldPrefs.McpEnabled;
int oldPort = oldPrefs.McpPort;
- var dialog = new SettingsWindow(_preferencesService);
+ var dialog = new SettingsWindow(_preferencesService, _muteRuleService);
dialog.Owner = this;
if (dialog.ShowDialog() == true)
{
@@ -1221,25 +1227,36 @@ private async Task EvaluateAlertConditionsAsync(
_activeBlockingAlert[serverId] = true;
if (!_lastBlockingAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
- _notificationService?.ShowBlockingNotification(
- serverName,
- (int)health.TotalBlocked,
- (int)health.LongestBlockedSeconds);
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Blocking Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastBlockingAlert[serverId] = now;
- _emailAlertService.RecordAlert(serverId, serverName, "Blocking Detected",
- $"{(int)health.TotalBlocked} session(s), longest {(int)health.LongestBlockedSeconds}s",
- $"{prefs.BlockingThresholdSeconds}s", true, "tray");
-
var blockingContext = await BuildBlockingContextAsync(databaseService, prefs.AlertExcludedDatabases);
+ var detailText = ContextToDetailText(blockingContext)
+ ?? $"Blocked Sessions: {(int)health.TotalBlocked}\nLongest Wait: {(int)health.LongestBlockedSeconds}s";
+
+ if (!isMuted)
+ {
+ _notificationService?.ShowBlockingNotification(
+ serverName,
+ (int)health.TotalBlocked,
+ (int)health.LongestBlockedSeconds);
+ }
- await _emailAlertService.TrySendAlertEmailAsync(
- "Blocking Detected",
- serverName,
+ _emailAlertService.RecordAlert(serverId, serverName, "Blocking Detected",
$"{(int)health.TotalBlocked} session(s), longest {(int)health.LongestBlockedSeconds}s",
- $"{prefs.BlockingThresholdSeconds}s",
- serverId,
- blockingContext);
+ $"{prefs.BlockingThresholdSeconds}s", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
+
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "Blocking Detected",
+ serverName,
+ $"{(int)health.TotalBlocked} session(s), longest {(int)health.LongestBlockedSeconds}s",
+ $"{prefs.BlockingThresholdSeconds}s",
+ serverId,
+ blockingContext);
+ }
}
}
else if (_activeBlockingAlert.TryRemove(serverId, out var wasBlocking) && wasBlocking)
@@ -1272,24 +1289,35 @@ Falls back to the raw delta when no databases are excluded. */
_activeDeadlockAlert[serverId] = true;
if (!_lastDeadlockAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
- _notificationService?.ShowDeadlockNotification(
- serverName,
- (int)effectiveDeadlockDelta);
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Deadlocks Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastDeadlockAlert[serverId] = now;
- _emailAlertService.RecordAlert(serverId, serverName, "Deadlocks Detected",
- effectiveDeadlockDelta.ToString(),
- prefs.DeadlockThreshold.ToString(), true, "tray");
-
var deadlockContext = await BuildDeadlockContextAsync(databaseService, prefs.AlertExcludedDatabases);
+ var detailText = ContextToDetailText(deadlockContext)
+ ?? $"New Deadlocks: {effectiveDeadlockDelta}";
- await _emailAlertService.TrySendAlertEmailAsync(
- "Deadlocks Detected",
- serverName,
+ if (!isMuted)
+ {
+ _notificationService?.ShowDeadlockNotification(
+ serverName,
+ (int)effectiveDeadlockDelta);
+ }
+
+ _emailAlertService.RecordAlert(serverId, serverName, "Deadlocks Detected",
effectiveDeadlockDelta.ToString(),
- prefs.DeadlockThreshold.ToString(),
- serverId,
- deadlockContext);
+ prefs.DeadlockThreshold.ToString(), !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
+
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "Deadlocks Detected",
+ serverName,
+ effectiveDeadlockDelta.ToString(),
+ prefs.DeadlockThreshold.ToString(),
+ serverId,
+ deadlockContext);
+ }
}
}
else if (_activeDeadlockAlert.TryRemove(serverId, out var wasDeadlock) && wasDeadlock)
@@ -1311,21 +1339,31 @@ await _emailAlertService.TrySendAlertEmailAsync(
_activeHighCpuAlert[serverId] = true;
if (!_lastHighCpuAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
- _notificationService?.ShowHighCpuNotification(
- serverName,
- totalCpu);
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "High CPU" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastHighCpuAlert[serverId] = now;
+ if (!isMuted)
+ {
+ _notificationService?.ShowHighCpuNotification(
+ serverName,
+ totalCpu);
+ }
+
_emailAlertService.RecordAlert(serverId, serverName, "High CPU",
$"{totalCpu:F0}%",
- $"{prefs.CpuThresholdPercent}%", true, "tray");
+ $"{prefs.CpuThresholdPercent}%", !isMuted, isMuted ? "muted" : "tray", muted: isMuted,
+ detailText: $" CPU: {totalCpu:F0}%\n Threshold: {prefs.CpuThresholdPercent}%");
- await _emailAlertService.TrySendAlertEmailAsync(
- "High CPU",
- serverName,
- $"{totalCpu:F0}%",
- $"{prefs.CpuThresholdPercent}%",
- serverId);
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "High CPU",
+ serverName,
+ $"{totalCpu:F0}%",
+ $"{prefs.CpuThresholdPercent}%",
+ serverId);
+ }
}
}
else if (_activeHighCpuAlert.TryRemove(serverId, out var wasCpu) && wasCpu)
@@ -1348,23 +1386,37 @@ await _emailAlertService.TrySendAlertEmailAsync(
if (!_lastPoisonWaitAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
var worst = triggeredWaits[0];
- _notificationService?.ShowPoisonWaitNotification(serverName, worst.WaitType, worst.AvgMsPerWait);
- _lastPoisonWaitAlert[serverId] = now;
-
var allWaitNames = string.Join(", ", triggeredWaits.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)"));
- _emailAlertService.RecordAlert(serverId, serverName, "Poison Wait",
- allWaitNames,
- $"{prefs.PoisonWaitThresholdMs}ms avg", true, "tray");
+ /* Poison wait mute check uses the worst (highest avg ms/wait) triggered wait type.
+ Limitation: if a user mutes a specific wait type that isn't the worst, the alert
+ still fires. Conversely, muting the worst type suppresses the entire alert even
+ if other unmuted poison waits are present. */
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Poison Wait", WaitType = worst.WaitType };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
+ _lastPoisonWaitAlert[serverId] = now;
var poisonContext = BuildPoisonWaitContext(triggeredWaits);
+ var detailText = ContextToDetailText(poisonContext);
- await _emailAlertService.TrySendAlertEmailAsync(
- "Poison Wait",
- serverName,
+ if (!isMuted)
+ {
+ _notificationService?.ShowPoisonWaitNotification(serverName, worst.WaitType, worst.AvgMsPerWait);
+ }
+
+ _emailAlertService.RecordAlert(serverId, serverName, "Poison Wait",
allWaitNames,
- $"{prefs.PoisonWaitThresholdMs}ms avg",
- serverId,
- poisonContext);
+ $"{prefs.PoisonWaitThresholdMs}ms avg", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
+
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "Poison Wait",
+ serverName,
+ allWaitNames,
+ $"{prefs.PoisonWaitThresholdMs}ms avg",
+ serverId,
+ poisonContext);
+ }
}
}
else if (_activePoisonWaitAlert.TryRemove(serverId, out var wasPoisonWait) && wasPoisonWait)
@@ -1395,23 +1447,39 @@ await _emailAlertService.TrySendAlertEmailAsync(
var worst = lrqList[0];
var elapsedMinutes = worst.ElapsedSeconds / 60;
var preview = Truncate(worst.QueryText, 80);
- _notificationService?.ShowLongRunningQueryNotification(
- serverName, worst.SessionId, elapsedMinutes, preview);
+
+ var muteCtx = new AlertMuteContext
+ {
+ ServerName = serverName,
+ MetricName = "Long-Running Query",
+ DatabaseName = worst.DatabaseName,
+ QueryText = worst.QueryText
+ };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningQueryAlert[serverId] = now;
+ var lrqContext = BuildLongRunningQueryContext(lrqList);
+ var detailText = ContextToDetailText(lrqContext);
+
+ if (!isMuted)
+ {
+ _notificationService?.ShowLongRunningQueryNotification(
+ serverName, worst.SessionId, elapsedMinutes, preview);
+ }
_emailAlertService.RecordAlert(serverId, serverName, "Long-Running Query",
$"Session #{worst.SessionId} running {elapsedMinutes}m",
- $"{prefs.LongRunningQueryThresholdMinutes}m", true, "tray");
+ $"{prefs.LongRunningQueryThresholdMinutes}m", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
- var lrqContext = BuildLongRunningQueryContext(lrqList);
-
- await _emailAlertService.TrySendAlertEmailAsync(
- "Long-Running Query",
- serverName,
- $"{lrqList.Count} query(s), longest {elapsedMinutes}m",
- $"{prefs.LongRunningQueryThresholdMinutes}m",
- serverId,
- lrqContext);
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "Long-Running Query",
+ serverName,
+ $"{lrqList.Count} query(s), longest {elapsedMinutes}m",
+ $"{prefs.LongRunningQueryThresholdMinutes}m",
+ serverId,
+ lrqContext);
+ }
}
}
else if (_activeLongRunningQueryAlert.TryRemove(serverId, out var wasLongRunning) && wasLongRunning)
@@ -1433,22 +1501,31 @@ await _emailAlertService.TrySendAlertEmailAsync(
_activeTempDbSpaceAlert[serverId] = true;
if (!_lastTempDbSpaceAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
- _notificationService?.ShowTempDbSpaceNotification(serverName, tempDb.UsedPercent);
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "TempDB Space" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastTempDbSpaceAlert[serverId] = now;
+ var tempDbContext = BuildTempDbSpaceContext(tempDb);
+ var detailText = ContextToDetailText(tempDbContext);
+
+ if (!isMuted)
+ {
+ _notificationService?.ShowTempDbSpaceNotification(serverName, tempDb.UsedPercent);
+ }
_emailAlertService.RecordAlert(serverId, serverName, "TempDB Space",
$"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)",
- $"{prefs.TempDbSpaceThresholdPercent}%", true, "tray");
-
- var tempDbContext = BuildTempDbSpaceContext(tempDb);
+ $"{prefs.TempDbSpaceThresholdPercent}%", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
- await _emailAlertService.TrySendAlertEmailAsync(
- "TempDB Space",
- serverName,
- $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)",
- $"{prefs.TempDbSpaceThresholdPercent}%",
- serverId,
- tempDbContext);
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "TempDB Space",
+ serverName,
+ $"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)",
+ $"{prefs.TempDbSpaceThresholdPercent}%",
+ serverId,
+ tempDbContext);
+ }
}
}
else if (_activeTempDbSpaceAlert.TryRemove(serverId, out var wasTempDb) && wasTempDb)
@@ -1473,23 +1550,33 @@ await _emailAlertService.TrySendAlertEmailAsync(
if (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastAlert) || (now - lastAlert) >= alertCooldown)
{
var currentMinutes = worst.CurrentDurationSeconds / 60;
- _notificationService?.ShowLongRunningJobNotification(
- serverName, worst.JobName, currentMinutes, worst.PercentOfAverage ?? 0);
+
+ var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Long-Running Job", JobName = worst.JobName };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningJobAlert[jobKey] = now;
+ var jobContext = BuildAnomalousJobContext(health.AnomalousJobs);
+ var detailText = ContextToDetailText(jobContext);
+
+ if (!isMuted)
+ {
+ _notificationService?.ShowLongRunningJobNotification(
+ serverName, worst.JobName, currentMinutes, worst.PercentOfAverage ?? 0);
+ }
_emailAlertService.RecordAlert(serverId, serverName, "Long-Running Job",
$"{worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
- $"{prefs.LongRunningJobMultiplier}x avg", true, "tray");
+ $"{prefs.LongRunningJobMultiplier}x avg", !isMuted, isMuted ? "muted" : "tray", muted: isMuted, detailText: detailText);
- var jobContext = BuildAnomalousJobContext(health.AnomalousJobs);
-
- await _emailAlertService.TrySendAlertEmailAsync(
- "Long-Running Job",
- serverName,
- $"{health.AnomalousJobs.Count} job(s) exceeding {prefs.LongRunningJobMultiplier}x average",
- $"{prefs.LongRunningJobMultiplier}x historical avg",
- serverId,
- jobContext);
+ if (!isMuted)
+ {
+ await _emailAlertService.TrySendAlertEmailAsync(
+ "Long-Running Job",
+ serverName,
+ $"{health.AnomalousJobs.Count} job(s) exceeding {prefs.LongRunningJobMultiplier}x average",
+ $"{prefs.LongRunningJobMultiplier}x historical avg",
+ serverId,
+ jobContext);
+ }
}
}
else if (_activeLongRunningJobAlert.TryRemove(serverId, out var wasJob) && wasJob)
@@ -1508,6 +1595,20 @@ private static string Truncate(string text, int maxLength = 300)
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
}
+ private static string? ContextToDetailText(AlertContext? context)
+ {
+ if (context == null || context.Details.Count == 0) return null;
+ var sb = new System.Text.StringBuilder();
+ foreach (var detail in context.Details)
+ {
+ if (sb.Length > 0) sb.AppendLine();
+ sb.AppendLine(detail.Heading);
+ foreach (var (label, value) in detail.Fields)
+ sb.AppendLine($" {label}: {value}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
private static async Task BuildBlockingContextAsync(DatabaseService databaseService, List? excludedDatabases = null)
{
try
diff --git a/Dashboard/ManageMuteRulesWindow.xaml b/Dashboard/ManageMuteRulesWindow.xaml
new file mode 100644
index 00000000..9f8b7ceb
--- /dev/null
+++ b/Dashboard/ManageMuteRulesWindow.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/ManageMuteRulesWindow.xaml.cs b/Dashboard/ManageMuteRulesWindow.xaml.cs
new file mode 100644
index 00000000..0c7eed64
--- /dev/null
+++ b/Dashboard/ManageMuteRulesWindow.xaml.cs
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services;
+
+namespace PerformanceMonitorDashboard
+{
+ public partial class ManageMuteRulesWindow : Window
+ {
+ private readonly MuteRuleService _muteRuleService;
+ private readonly ObservableCollection _rules;
+
+ public ManageMuteRulesWindow(MuteRuleService muteRuleService)
+ {
+ InitializeComponent();
+ _muteRuleService = muteRuleService;
+ _rules = new ObservableCollection(_muteRuleService.GetRules());
+ RulesGrid.ItemsSource = _rules;
+ }
+
+ private void AddRule_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new MuteRuleDialog { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ _muteRuleService.AddRule(dialog.Rule);
+ _rules.Add(dialog.Rule);
+ }
+ }
+
+ private void EditRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var dialog = new MuteRuleDialog(selected) { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ _muteRuleService.UpdateRule(dialog.Rule);
+ RefreshList();
+ }
+ }
+
+ private void ToggleRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ _muteRuleService.SetRuleEnabled(selected.Id, !selected.Enabled);
+ RefreshList();
+ if (index < _rules.Count) RulesGrid.SelectedIndex = index;
+ RulesGrid.Focus();
+ }
+
+ private void DeleteRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ var result = MessageBox.Show(
+ $"Delete this mute rule?\n\n{selected.Summary}",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ _muteRuleService.RemoveRule(selected.Id);
+ _rules.Remove(selected);
+ if (_rules.Count > 0)
+ RulesGrid.SelectedIndex = Math.Min(index, _rules.Count - 1);
+ RulesGrid.Focus();
+ }
+ }
+
+ private void PurgeExpired_Click(object sender, RoutedEventArgs e)
+ {
+ int removed = _muteRuleService.PurgeExpiredRules();
+ if (removed > 0)
+ {
+ RefreshList();
+ MessageBox.Show($"Removed {removed} expired rule(s).", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show("No expired rules to remove.", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private void EnabledCheckBox_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is CheckBox cb && cb.DataContext is MuteRule rule)
+ {
+ _muteRuleService.SetRuleEnabled(rule.Id, cb.IsChecked == true);
+ }
+ }
+
+ private void RulesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ EditRule_Click(sender, e);
+ }
+
+ private void RefreshList()
+ {
+ _rules.Clear();
+ foreach (var rule in _muteRuleService.GetRules())
+ _rules.Add(rule);
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+ }
+}
diff --git a/Dashboard/Mcp/McpAlertTools.cs b/Dashboard/Mcp/McpAlertTools.cs
index 6cae73bf..bf90dac4 100644
--- a/Dashboard/Mcp/McpAlertTools.cs
+++ b/Dashboard/Mcp/McpAlertTools.cs
@@ -111,7 +111,9 @@ public static Task GetAlertHistory(
threshold_value = a.ThresholdValue,
alert_sent = a.AlertSent,
notification_type = a.NotificationType,
- send_error = a.SendError
+ send_error = a.SendError,
+ muted = a.Muted,
+ detail_text = a.DetailText
})
};
@@ -122,4 +124,43 @@ public static Task GetAlertHistory(
return Task.FromResult(McpHelpers.FormatError("get_alert_history", ex));
}
}
+
+ [McpServerTool(Name = "get_mute_rules"), Description("Gets the configured alert mute rules. Mute rules suppress specific recurring alerts while still logging them.")]
+ public static Task GetMuteRules(
+ MuteRuleService muteRuleService,
+ [Description("Include only enabled rules. Default true.")] bool enabled_only = true)
+ {
+ try
+ {
+ var rules = muteRuleService.GetRules();
+ if (enabled_only)
+ rules = rules.Where(r => r.Enabled && (r.ExpiresAtUtc == null || r.ExpiresAtUtc > DateTime.UtcNow)).ToList();
+
+ var result = new
+ {
+ mute_rules = rules.Select(r => new
+ {
+ id = r.Id,
+ enabled = r.Enabled,
+ created_at_utc = r.CreatedAtUtc.ToString("o"),
+ expires_at_utc = r.ExpiresAtUtc?.ToString("o"),
+ reason = r.Reason,
+ server_name = r.ServerName,
+ metric_name = r.MetricName,
+ database_pattern = r.DatabasePattern,
+ query_text_pattern = r.QueryTextPattern,
+ wait_type_pattern = r.WaitTypePattern,
+ job_name_pattern = r.JobNamePattern,
+ summary = r.Summary
+ }).ToArray(),
+ total_count = rules.Count
+ };
+
+ return Task.FromResult(JsonSerializer.Serialize(result, McpHelpers.JsonOptions));
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(McpHelpers.FormatError("get_mute_rules", ex));
+ }
+ }
}
diff --git a/Dashboard/Mcp/McpHostService.cs b/Dashboard/Mcp/McpHostService.cs
index 25d32b73..1fab4cee 100644
--- a/Dashboard/Mcp/McpHostService.cs
+++ b/Dashboard/Mcp/McpHostService.cs
@@ -21,14 +21,18 @@ public sealed class McpHostService : BackgroundService
{
private readonly ServerManager _serverManager;
private readonly ICredentialService _credentialService;
+ private readonly MuteRuleService _muteRuleService;
+ private readonly UserPreferencesService _preferencesService;
private readonly int _port;
private WebApplication? _app;
private DatabaseServiceRegistry? _registry;
- public McpHostService(ServerManager serverManager, ICredentialService credentialService, int port)
+ public McpHostService(ServerManager serverManager, ICredentialService credentialService, MuteRuleService muteRuleService, UserPreferencesService preferencesService, int port)
{
_serverManager = serverManager;
_credentialService = credentialService;
+ _muteRuleService = muteRuleService;
+ _preferencesService = preferencesService;
_port = port;
}
@@ -51,6 +55,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
_registry = new DatabaseServiceRegistry(_serverManager, _credentialService);
builder.Services.AddSingleton(_serverManager);
builder.Services.AddSingleton(_registry);
+ builder.Services.AddSingleton(_muteRuleService);
+ builder.Services.AddSingleton(_preferencesService);
/* Register MCP server with all tool classes */
builder.Services
diff --git a/Dashboard/Models/MuteRule.cs b/Dashboard/Models/MuteRule.cs
new file mode 100644
index 00000000..b5b8e40d
--- /dev/null
+++ b/Dashboard/Models/MuteRule.cs
@@ -0,0 +1,101 @@
+using System;
+
+namespace PerformanceMonitorDashboard.Models
+{
+ public class MuteRule
+ {
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public bool Enabled { get; set; } = true;
+ public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
+ public DateTime? ExpiresAtUtc { get; set; }
+ public string? Reason { get; set; }
+
+ public string? ServerName { get; set; }
+ public string? MetricName { get; set; }
+ public string? DatabasePattern { get; set; }
+ public string? QueryTextPattern { get; set; }
+ public string? WaitTypePattern { get; set; }
+ public string? JobNamePattern { get; set; }
+
+ public bool IsExpired => ExpiresAtUtc.HasValue && DateTime.UtcNow >= ExpiresAtUtc.Value;
+
+ public MuteRule Clone() => new()
+ {
+ Id = Id,
+ Enabled = Enabled,
+ CreatedAtUtc = CreatedAtUtc,
+ ExpiresAtUtc = ExpiresAtUtc,
+ Reason = Reason,
+ ServerName = ServerName,
+ MetricName = MetricName,
+ DatabasePattern = DatabasePattern,
+ QueryTextPattern = QueryTextPattern,
+ WaitTypePattern = WaitTypePattern,
+ JobNamePattern = JobNamePattern
+ };
+
+ public string ExpiresDisplay => ExpiresAtUtc.HasValue
+ ? (IsExpired ? "Expired" : ExpiresAtUtc.Value.ToLocalTime().ToString("g"))
+ : "Never";
+
+ public string Summary
+ {
+ get
+ {
+ var parts = new System.Collections.Generic.List();
+ if (MetricName != null) parts.Add(MetricName);
+ if (ServerName != null) parts.Add($"on {ServerName}");
+ if (DatabasePattern != null) parts.Add($"dbβ{DatabasePattern}");
+ if (QueryTextPattern != null) parts.Add($"queryβ{QueryTextPattern}");
+ if (WaitTypePattern != null) parts.Add($"waitβ{WaitTypePattern}");
+ if (JobNamePattern != null) parts.Add($"jobβ{JobNamePattern}");
+ return parts.Count > 0 ? string.Join(", ", parts) : "(matches all alerts)";
+ }
+ }
+
+ public bool Matches(AlertMuteContext context)
+ {
+ if (!Enabled || IsExpired) return false;
+
+ if (ServerName != null &&
+ !string.Equals(ServerName, context.ServerName, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ if (MetricName != null &&
+ !string.Equals(MetricName, context.MetricName, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ if (DatabasePattern != null &&
+ (context.DatabaseName == null ||
+ !context.DatabaseName.Contains(DatabasePattern, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ if (QueryTextPattern != null &&
+ (context.QueryText == null ||
+ !context.QueryText.Contains(QueryTextPattern, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ if (WaitTypePattern != null &&
+ (context.WaitType == null ||
+ !context.WaitType.Contains(WaitTypePattern, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ if (JobNamePattern != null &&
+ (context.JobName == null ||
+ !context.JobName.Contains(JobNamePattern, StringComparison.OrdinalIgnoreCase)))
+ return false;
+
+ return true;
+ }
+ }
+
+ public class AlertMuteContext
+ {
+ public string ServerName { get; set; } = "";
+ public string MetricName { get; set; } = "";
+ public string? DatabaseName { get; set; }
+ public string? QueryText { get; set; }
+ public string? WaitType { get; set; }
+ public string? JobName { get; set; }
+ }
+}
diff --git a/Dashboard/MuteRuleDialog.xaml b/Dashboard/MuteRuleDialog.xaml
new file mode 100644
index 00000000..cdae393e
--- /dev/null
+++ b/Dashboard/MuteRuleDialog.xaml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/MuteRuleDialog.xaml.cs b/Dashboard/MuteRuleDialog.xaml.cs
new file mode 100644
index 00000000..3d0662c3
--- /dev/null
+++ b/Dashboard/MuteRuleDialog.xaml.cs
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard
+{
+ public partial class MuteRuleDialog : Window
+ {
+ public MuteRule Rule { get; private set; }
+
+ public MuteRuleDialog(MuteRule? existingRule = null)
+ {
+ InitializeComponent();
+
+ if (existingRule != null)
+ {
+ Title = "Edit Mute Rule";
+ HeaderText.Text = "Edit Mute Rule";
+ Rule = existingRule.Clone();
+ PopulateFromRule(Rule);
+ }
+ else
+ {
+ Rule = new MuteRule();
+ }
+ }
+
+ ///
+ /// Creates a dialog pre-populated for muting from an alert context.
+ ///
+ public MuteRuleDialog(AlertMuteContext context) : this()
+ {
+ if (!string.IsNullOrEmpty(context.ServerName))
+ ServerNameBox.Text = context.ServerName;
+ if (!string.IsNullOrEmpty(context.MetricName))
+ SelectMetric(context.MetricName);
+ if (!string.IsNullOrEmpty(context.DatabaseName))
+ DatabasePatternBox.Text = context.DatabaseName;
+ if (!string.IsNullOrEmpty(context.QueryText))
+ QueryTextPatternBox.Text = context.QueryText.Length > 200
+ ? context.QueryText.Substring(0, 200)
+ : context.QueryText;
+ if (!string.IsNullOrEmpty(context.WaitType))
+ WaitTypePatternBox.Text = context.WaitType;
+ if (!string.IsNullOrEmpty(context.JobName))
+ JobNamePatternBox.Text = context.JobName;
+ }
+
+ private void PopulateFromRule(MuteRule rule)
+ {
+ ReasonBox.Text = rule.Reason ?? "";
+ ServerNameBox.Text = rule.ServerName ?? "";
+ DatabasePatternBox.Text = rule.DatabasePattern ?? "";
+ QueryTextPatternBox.Text = rule.QueryTextPattern ?? "";
+ WaitTypePatternBox.Text = rule.WaitTypePattern ?? "";
+ JobNamePatternBox.Text = rule.JobNamePattern ?? "";
+
+ if (!string.IsNullOrEmpty(rule.MetricName))
+ SelectMetric(rule.MetricName);
+
+ if (rule.ExpiresAtUtc == null)
+ ExpirationCombo.SelectedIndex = 3;
+ else
+ {
+ var remaining = rule.ExpiresAtUtc.Value - DateTime.UtcNow;
+ if (remaining.TotalHours <= 1.5) ExpirationCombo.SelectedIndex = 0;
+ else if (remaining.TotalHours <= 25) ExpirationCombo.SelectedIndex = 1;
+ else ExpirationCombo.SelectedIndex = 2;
+ }
+ }
+
+ private void SelectMetric(string metricName)
+ {
+ for (int i = 0; i < MetricCombo.Items.Count; i++)
+ {
+ if (MetricCombo.Items[i] is ComboBoxItem item &&
+ string.Equals(item.Content?.ToString(), metricName, StringComparison.OrdinalIgnoreCase))
+ {
+ MetricCombo.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ Rule.Reason = string.IsNullOrWhiteSpace(ReasonBox.Text) ? null : ReasonBox.Text.Trim();
+ Rule.ServerName = string.IsNullOrWhiteSpace(ServerNameBox.Text) ? null : ServerNameBox.Text.Trim();
+
+ // Only save pattern fields that are visible/relevant for the selected metric
+ Rule.DatabasePattern = DatabasePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(DatabasePatternBox.Text)
+ ? DatabasePatternBox.Text.Trim() : null;
+ Rule.QueryTextPattern = QueryTextPatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(QueryTextPatternBox.Text)
+ ? QueryTextPatternBox.Text.Trim() : null;
+ Rule.WaitTypePattern = WaitTypePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(WaitTypePatternBox.Text)
+ ? WaitTypePatternBox.Text.Trim() : null;
+ Rule.JobNamePattern = JobNamePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(JobNamePatternBox.Text)
+ ? JobNamePatternBox.Text.Trim() : null;
+
+ if (MetricCombo.SelectedIndex > 0 && MetricCombo.SelectedItem is ComboBoxItem selected)
+ Rule.MetricName = selected.Content?.ToString();
+ else
+ Rule.MetricName = null;
+
+ Rule.ExpiresAtUtc = ExpirationCombo.SelectedIndex switch
+ {
+ 0 => DateTime.UtcNow.AddHours(1),
+ 1 => DateTime.UtcNow.AddHours(24),
+ 2 => DateTime.UtcNow.AddDays(7),
+ _ => null
+ };
+
+ if (Rule.ServerName == null && Rule.MetricName == null && Rule.DatabasePattern == null
+ && Rule.QueryTextPattern == null && Rule.WaitTypePattern == null && Rule.JobNamePattern == null)
+ {
+ var result = MessageBox.Show(
+ "This rule has no filters and will mute ALL alerts. Are you sure?",
+ "Mute All Alerts", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ if (result != MessageBoxResult.Yes) return;
+ }
+
+ DialogResult = true;
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ private void MetricCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (DatabasePatternBox == null) return; // not yet initialized
+
+ var metric = (MetricCombo.SelectedItem as ComboBoxItem)?.Content?.ToString();
+
+ // Determine which pattern fields are relevant per metric
+ bool showDatabase = metric is null or "(any)" or "Blocking Detected" or "Deadlocks Detected" or "Long-Running Query";
+ bool showWaitType = metric is null or "(any)" or "Poison Wait" or "Long-Running Query";
+ bool showQueryText = metric is null or "(any)" or "Blocking Detected" or "Long-Running Query";
+ bool showJobName = metric is null or "(any)" or "Long-Running Job";
+
+ DatabaseLabel.Visibility = DatabasePatternBox.Visibility = showDatabase ? Visibility.Visible : Visibility.Collapsed;
+ WaitTypeLabel.Visibility = WaitTypePatternBox.Visibility = showWaitType ? Visibility.Visible : Visibility.Collapsed;
+ QueryTextLabel.Visibility = QueryTextPatternBox.Visibility = showQueryText ? Visibility.Visible : Visibility.Collapsed;
+ JobNameLabel.Visibility = JobNamePatternBox.Visibility = showJobName ? Visibility.Visible : Visibility.Collapsed;
+
+ // Hide the entire pattern grid if no fields are relevant
+ PatternFieldsGrid.Visibility = (showDatabase || showWaitType || showQueryText || showJobName)
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
+}
diff --git a/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs
index d17db946..2bb0ab32 100644
--- a/Dashboard/Services/EmailAlertService.cs
+++ b/Dashboard/Services/EmailAlertService.cs
@@ -148,7 +148,7 @@ public async Task TrySendAlertEmailAsync(
///
public void RecordAlert(string serverId, string serverName, string metricName,
string currentValue, string thresholdValue, bool alertSent,
- string notificationType, string? sendError = null)
+ string notificationType, string? sendError = null, bool muted = false, string? detailText = null)
{
var entry = new AlertLogEntry
{
@@ -160,7 +160,9 @@ public void RecordAlert(string serverId, string serverName, string metricName,
ThresholdValue = thresholdValue,
AlertSent = alertSent,
NotificationType = notificationType,
- SendError = sendError
+ SendError = sendError,
+ Muted = muted,
+ DetailText = detailText
};
lock (_alertLogLock)
@@ -410,5 +412,7 @@ public class AlertLogEntry
public string NotificationType { get; set; } = "";
public string? SendError { get; set; }
public bool Hidden { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
}
}
diff --git a/Dashboard/Services/MuteRuleService.cs b/Dashboard/Services/MuteRuleService.cs
new file mode 100644
index 00000000..b573f721
--- /dev/null
+++ b/Dashboard/Services/MuteRuleService.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard.Services
+{
+ ///
+ /// Manages alert mute rules with JSON persistence.
+ /// Thread-safe: all operations are protected by _lock.
+ ///
+ public class MuteRuleService
+ {
+ private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true };
+
+ private readonly string _filePath;
+ private readonly object _lock = new object();
+ private List _rules = new();
+
+ public MuteRuleService()
+ {
+ var appDataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "PerformanceMonitorDashboard");
+ Directory.CreateDirectory(appDataDir);
+ _filePath = Path.Combine(appDataDir, "alert_mute_rules.json");
+ Load();
+ PurgeExpiredRules();
+ }
+
+ public bool IsAlertMuted(AlertMuteContext context)
+ {
+ lock (_lock)
+ {
+ return _rules.Any(r => r.Matches(context));
+ }
+ }
+
+ public List GetRules()
+ {
+ lock (_lock)
+ {
+ return _rules.ToList();
+ }
+ }
+
+ public List GetActiveRules()
+ {
+ lock (_lock)
+ {
+ return _rules.Where(r => r.Enabled && !r.IsExpired).ToList();
+ }
+ }
+
+ public void AddRule(MuteRule rule)
+ {
+ lock (_lock)
+ {
+ _rules.Add(rule);
+ Save();
+ }
+ }
+
+ public void RemoveRule(string ruleId)
+ {
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => r.Id == ruleId);
+ Save();
+ }
+ }
+
+ public void UpdateRule(MuteRule updated)
+ {
+ lock (_lock)
+ {
+ var index = _rules.FindIndex(r => r.Id == updated.Id);
+ if (index >= 0)
+ {
+ _rules[index] = updated;
+ Save();
+ }
+ }
+ }
+
+ public void SetRuleEnabled(string ruleId, bool enabled)
+ {
+ lock (_lock)
+ {
+ var rule = _rules.FirstOrDefault(r => r.Id == ruleId);
+ if (rule != null)
+ {
+ rule.Enabled = enabled;
+ Save();
+ }
+ }
+ }
+
+ ///
+ /// Removes all expired rules from the list.
+ ///
+ public int PurgeExpiredRules()
+ {
+ lock (_lock)
+ {
+ int removed = _rules.RemoveAll(r => r.IsExpired);
+ if (removed > 0) Save();
+ return removed;
+ }
+ }
+
+ private void Load()
+ {
+ lock (_lock)
+ {
+ try
+ {
+ if (File.Exists(_filePath))
+ {
+ var json = File.ReadAllText(_filePath);
+ _rules = JsonSerializer.Deserialize>(json) ?? new();
+ }
+ }
+ catch
+ {
+ _rules = new();
+ }
+ }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(_rules, s_jsonOptions);
+ File.WriteAllText(_filePath, json);
+ }
+ catch (Exception ex)
+ {
+ Helpers.Logger.Error("MuteRuleService: Failed to save mute rules", ex);
+ }
+ }
+ }
+}
diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml
index 6c2b14ec..2b73cba6 100644
--- a/Dashboard/SettingsWindow.xaml
+++ b/Dashboard/SettingsWindow.xaml
@@ -283,6 +283,14 @@
+
+
+
+
+
diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs
index de6d5940..d22775e9 100644
--- a/Dashboard/SettingsWindow.xaml.cs
+++ b/Dashboard/SettingsWindow.xaml.cs
@@ -22,15 +22,17 @@ namespace PerformanceMonitorDashboard
public partial class SettingsWindow : Window
{
private readonly IUserPreferencesService _preferencesService;
+ private readonly MuteRuleService? _muteRuleService;
private bool _isLoading = true;
private readonly string _originalTheme = ThemeManager.CurrentTheme;
private bool _saved;
- public SettingsWindow(IUserPreferencesService preferencesService)
+ public SettingsWindow(IUserPreferencesService preferencesService, MuteRuleService? muteRuleService = null)
{
InitializeComponent();
_preferencesService = preferencesService;
+ _muteRuleService = muteRuleService;
LoadSettings();
_isLoading = false;
}
@@ -323,6 +325,13 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
UpdateAlertPreviewText();
}
+ private void ManageMuteRulesButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_muteRuleService == null) return;
+ var window = new ManageMuteRulesWindow(_muteRuleService) { Owner = this };
+ window.ShowDialog();
+ }
+
private void UpdateAlertPreviewText()
{
var parts = new System.Collections.Generic.List();
diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml
index f23e2da9..640310c3 100644
--- a/Dashboard/WaitDrillDownWindow.xaml
+++ b/Dashboard/WaitDrillDownWindow.xaml
@@ -22,6 +22,10 @@
+
+
diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs
index 35b94288..a1b68f58 100644
--- a/Lite.Tests/DuckDbSchemaTests.cs
+++ b/Lite.Tests/DuckDbSchemaTests.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitorLite.Database;
@@ -68,7 +69,8 @@ public async Task InitializeAsync_CreatesAllTables()
"database_scoped_config",
"trace_flags",
"running_jobs",
- "config_alert_log"
+ "config_alert_log",
+ "config_mute_rules"
};
using var connection = new DuckDBConnection($"Data Source={_dbPath}");
@@ -136,8 +138,8 @@ public void SchemaStatements_MatchTableCount()
foreach (var _ in Schema.GetAllTableStatements())
tableCount++;
- /* 27 tables from Schema (schema_version is created separately by DuckDbInitializer) */
- Assert.Equal(27, tableCount);
+ /* 28 tables from Schema (schema_version is created separately by DuckDbInitializer) */
+ Assert.Equal(28, tableCount);
}
[Fact]
@@ -157,4 +159,57 @@ public async Task InitializeAsync_CreatesIndexes()
/* We create 18 indexes */
Assert.True(indexCount >= 18, $"Expected >= 18 indexes, found {indexCount}");
}
+
+ ///
+ /// DuckDB does not support NOT NULL on ALTER TABLE ADD COLUMN.
+ /// This test scans the migration source code to prevent regressions,
+ /// including multi-line statements where ADD COLUMN and NOT NULL
+ /// appear on different lines within the same SQL statement.
+ ///
+ [Fact]
+ public void Migrations_DoNotUseNotNullOnAlterTableAddColumn()
+ {
+ var sourceFile = FindSourceFile("DuckDbInitializer.cs");
+ Assert.True(sourceFile != null, "Could not find DuckDbInitializer.cs in the Lite project tree");
+
+ var content = File.ReadAllText(sourceFile!);
+
+ // Strip line comments (// ...) and block comments (/* ... */)
+ var stripped = Regex.Replace(content, @"//[^\r\n]*", " ");
+ stripped = Regex.Replace(stripped, @"/\*.*?\*/", " ", RegexOptions.Singleline);
+
+ // Match ADD COLUMN ... NOT NULL within the same SQL statement (up to the next semicolon).
+ // RegexOptions.IgnoreCase + Singleline so . matches newlines.
+ var pattern = @"ADD\s+COLUMN\b[^;]*?\bNOT\s+NULL\b";
+ var matches = Regex.Matches(stripped, pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline);
+
+ var violations = new System.Collections.Generic.List();
+ foreach (Match m in matches)
+ {
+ // Find the line number in the original content for a useful error message
+ int lineNum = content[..m.Index].Split('\n').Length;
+ var snippet = m.Value.Replace("\r", "").Replace("\n", " ");
+ if (snippet.Length > 120) snippet = snippet[..120] + "...";
+ violations.Add($"Line ~{lineNum}: {snippet}");
+ }
+
+ Assert.True(violations.Count == 0,
+ "DuckDB does not support NOT NULL on ALTER TABLE ADD COLUMN. " +
+ "Use a nullable column with DEFAULT instead.\n\nViolations:\n" +
+ string.Join("\n", violations));
+ }
+
+ private static string? FindSourceFile(string fileName)
+ {
+ var dir = AppContext.BaseDirectory;
+ for (int i = 0; i < 8; i++)
+ {
+ var candidate = Path.Combine(dir, "Lite", "Database", fileName);
+ if (File.Exists(candidate)) return candidate;
+ var parent = Directory.GetParent(dir);
+ if (parent == null) break;
+ dir = parent.FullName;
+ }
+ return null;
+ }
}
diff --git a/Lite/Controls/AlertsHistoryTab.xaml b/Lite/Controls/AlertsHistoryTab.xaml
index 2602362a..1bdfda7d 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml
+++ b/Lite/Controls/AlertsHistoryTab.xaml
@@ -8,6 +8,10 @@
+
+
@@ -21,6 +25,13 @@
+
+
+
@@ -88,6 +103,7 @@
HeadersVisibility="Column"
SelectionMode="Extended"
SelectionChanged="AlertsDataGrid_SelectionChanged"
+ MouseDoubleClick="AlertsDataGrid_MouseDoubleClick"
RowStyle="{StaticResource AlertRowStyle}">
diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs
index 62a59ff7..f4e86126 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml.cs
+++ b/Lite/Controls/AlertsHistoryTab.xaml.cs
@@ -29,6 +29,8 @@ public partial class AlertsHistoryTab : UserControl
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
+ public MuteRuleService? MuteRuleService { get; set; }
+
public AlertsHistoryTab()
{
InitializeComponent();
@@ -396,4 +398,80 @@ private static string CsvEscape(string value)
}
#endregion
+
+ #region Mute Handlers
+
+ private void AlertsDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (sender is not DataGrid) return;
+
+ // Walk up the visual tree from the click target to find the DataGridRow
+ var source = e.OriginalSource as DependencyObject;
+ while (source != null && source is not DataGridRow && source is not DataGridColumnHeader)
+ source = System.Windows.Media.VisualTreeHelper.GetParent(source);
+
+ // Ignore clicks on column headers or outside rows
+ if (source is not DataGridRow row) return;
+ if (row.DataContext is not AlertHistoryRow item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new Windows.AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private void ViewAlertDetails_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new Windows.AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private async void MuteThisAlert_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var context = new AlertMuteContext
+ {
+ ServerName = item.ServerName,
+ MetricName = item.MetricName
+ };
+
+ var dialog = new Windows.MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ await MuteRuleService.AddRuleAsync(dialog.Rule);
+ await LoadAlertsAsync();
+ }
+ }
+
+ private async void MuteSimilarAlerts_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var context = new AlertMuteContext
+ {
+ MetricName = item.MetricName
+ };
+
+ var dialog = new Windows.MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ await MuteRuleService.AddRuleAsync(dialog.Rule);
+ await LoadAlertsAsync();
+ }
+ }
+
+ #endregion
}
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index 5e9a6148..f6c5bcd4 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -141,7 +141,19 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
{
Interval = TimeSpan.FromSeconds(60)
};
- _refreshTimer.Tick += async (s, e) => await RefreshAllDataAsync(fullRefresh: false);
+ _refreshTimer.Tick += async (s, e) =>
+ {
+ if (_isRefreshing) return;
+ _isRefreshing = true;
+ try
+ {
+ await RefreshAllDataAsync(fullRefresh: false);
+ }
+ finally
+ {
+ _isRefreshing = false;
+ }
+ };
_refreshTimer.Start();
/* Initialize time picker ComboBoxes */
diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs
index 6680ce7e..ca7f7914 100644
--- a/Lite/Database/DuckDbInitializer.cs
+++ b/Lite/Database/DuckDbInitializer.cs
@@ -86,7 +86,7 @@ public void Dispose()
///
/// Current schema version. Increment this when schema changes require table rebuilds.
///
- internal const int CurrentSchemaVersion = 19;
+ internal const int CurrentSchemaVersion = 21;
private readonly string _archivePath;
@@ -554,6 +554,33 @@ New tables only β no existing table changes needed. Tables created by
_logger?.LogWarning("Migration to v19 encountered an error (non-fatal): {Error}", ex.Message);
}
}
+
+ if (fromVersion < 20)
+ {
+ _logger?.LogInformation("Running migration to v20: adding mute rules table and muted column to alert log");
+ try
+ {
+ /* DuckDB does not support ADD COLUMN with NOT NULL β use nullable with DEFAULT */
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE config_alert_log ADD COLUMN IF NOT EXISTS muted BOOLEAN DEFAULT false");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning("Migration to v20 encountered an error (non-fatal): {Error}", ex.Message);
+ }
+ }
+
+ if (fromVersion < 21)
+ {
+ _logger?.LogInformation("Running migration to v21: adding detail_text column to alert log");
+ try
+ {
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE config_alert_log ADD COLUMN IF NOT EXISTS detail_text VARCHAR");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning("Migration to v21 encountered an error (non-fatal): {Error}", ex.Message);
+ }
+ }
}
///
diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs
index 250db85f..90ac3492 100644
--- a/Lite/Database/Schema.cs
+++ b/Lite/Database/Schema.cs
@@ -676,7 +676,24 @@ CREATE TABLE IF NOT EXISTS config_alert_log (
alert_sent BOOLEAN NOT NULL DEFAULT false,
notification_type VARCHAR NOT NULL DEFAULT 'tray',
send_error VARCHAR,
- dismissed BOOLEAN NOT NULL DEFAULT false
+ dismissed BOOLEAN NOT NULL DEFAULT false,
+ muted BOOLEAN NOT NULL DEFAULT false,
+ detail_text VARCHAR
+)";
+
+ public const string CreateMuteRulesTable = @"
+CREATE TABLE IF NOT EXISTS config_mute_rules (
+ id VARCHAR NOT NULL PRIMARY KEY,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ created_at_utc TIMESTAMP NOT NULL,
+ expires_at_utc TIMESTAMP,
+ reason VARCHAR,
+ server_name VARCHAR,
+ metric_name VARCHAR,
+ database_pattern VARCHAR,
+ query_text_pattern VARCHAR,
+ wait_type_pattern VARCHAR,
+ job_name_pattern VARCHAR
)";
///
@@ -711,6 +728,7 @@ public static IEnumerable GetAllTableStatements()
yield return CreateServerPropertiesTable;
yield return CreateSessionStatsTable;
yield return CreateAlertLogTable;
+ yield return CreateMuteRulesTable;
}
///
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index 328c020c..20e26555 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -49,6 +49,7 @@ public partial class MainWindow : Window
private LocalDataService? _dataService;
private McpHostService? _mcpService;
private readonly AlertStateService _alertStateService = new();
+ private readonly MuteRuleService _muteRuleService;
private EmailAlertService _emailAlertService;
/* Track active alert states for resolved notifications */
@@ -67,6 +68,7 @@ public MainWindow()
// Initialize services (with loggers wired to AppLogger)
_databaseInitializer = new DuckDbInitializer(App.DatabasePath, new AppLoggerAdapter());
_emailAlertService = new EmailAlertService(_databaseInitializer);
+ _muteRuleService = new MuteRuleService(_databaseInitializer);
_serverManager = new ServerManager(App.ConfigDirectory, logger: new AppLoggerAdapter());
_scheduleManager = new ScheduleManager(App.ConfigDirectory);
@@ -123,8 +125,12 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
// Initialize data service for overview
_dataService = new LocalDataService(_databaseInitializer);
+ // Load mute rules from database
+ await _muteRuleService.LoadAsync();
+
// Initialize alerts history tab
AlertsHistoryContent.Initialize(_dataService);
+ AlertsHistoryContent.MuteRuleService = _muteRuleService;
// Initialize FinOps tab
FinOpsContent.Initialize(_dataService, _serverManager);
@@ -223,6 +229,9 @@ private async void MainWindow_Closing(object? sender, System.ComponentModel.Canc
private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
+ // Only respond to tab selection changes, not child control selection events that bubble up
+ if (e.OriginalSource != ServerTabControl) return;
+
/* Restore the selected tab's UTC offset so charts use the correct server timezone */
if (ServerTabControl.SelectedItem is TabItem { Content: ServerTab serverTab })
{
@@ -252,7 +261,7 @@ private async Task StartMcpServerAsync()
return;
}
- _mcpService = new McpHostService(_dataService!, _serverManager, mcpSettings.Port);
+ _mcpService = new McpHostService(_dataService!, _serverManager, _muteRuleService, mcpSettings.Port);
_ = _mcpService.StartAsync(_backgroundCts!.Token);
}
catch (Exception ex)
@@ -812,7 +821,7 @@ private void ManageServersButton_Click(object sender, RoutedEventArgs e)
private async void SettingsButton_Click(object sender, RoutedEventArgs e)
{
- var window = new SettingsWindow(_scheduleManager, _backgroundService, _mcpService) { Owner = this };
+ var window = new SettingsWindow(_scheduleManager, _backgroundService, _mcpService, _muteRuleService) { Owner = this };
window.ShowDialog();
UpdateStatusBar();
@@ -1023,18 +1032,28 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary)
_activeCpuAlert[key] = true;
if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= alertCooldown))
{
- _trayService.ShowNotification(
- "High CPU",
- $"{summary.DisplayName}: CPU at {summary.CpuPercent:F0}% (threshold: {App.AlertCpuThreshold}%)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "High CPU" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastCpuAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "High CPU",
+ $"{summary.DisplayName}: CPU at {summary.CpuPercent:F0}% (threshold: {App.AlertCpuThreshold}%)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
+ var cpuDetailText = $" CPU: {summary.CpuPercent:F0}%\n Threshold: {App.AlertCpuThreshold}%";
+
await _emailAlertService.TrySendAlertEmailAsync(
"High CPU",
summary.DisplayName,
$"{summary.CpuPercent:F0}%",
$"{App.AlertCpuThreshold}%",
- summary.ServerId);
+ summary.ServerId,
+ muted: isMuted,
+ detailText: cpuDetailText);
}
}
else if (_activeCpuAlert.TryGetValue(key, out var wasCpu) && wasCpu)
@@ -1073,13 +1092,20 @@ await _emailAlertService.TrySendAlertEmailAsync(
_activeBlockingAlert[key] = true;
if (!suppressPopups && (!_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= alertCooldown))
{
- _trayService.ShowNotification(
- "Blocking Detected",
- $"{summary.DisplayName}: {effectiveBlockingCount} blocking session(s)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Blocking Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastBlockingAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Blocking Detected",
+ $"{summary.DisplayName}: {effectiveBlockingCount} blocking session(s)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var blockingContext = await BuildBlockingContextAsync(summary.ServerId);
+ var detailText = ContextToDetailText(blockingContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Blocking Detected",
@@ -1087,7 +1113,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
effectiveBlockingCount.ToString(),
App.AlertBlockingThreshold.ToString(),
summary.ServerId,
- blockingContext);
+ blockingContext,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeBlockingAlert.TryGetValue(key, out var wasBlocking) && wasBlocking)
@@ -1124,13 +1152,20 @@ await _emailAlertService.TrySendAlertEmailAsync(
_activeDeadlockAlert[key] = true;
if (!suppressPopups && (!_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= alertCooldown))
{
- _trayService.ShowNotification(
- "Deadlocks Detected",
- $"{summary.DisplayName}: {effectiveDeadlockCount} deadlock(s) in the last hour",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Deadlocks Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastDeadlockAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Deadlocks Detected",
+ $"{summary.DisplayName}: {effectiveDeadlockCount} deadlock(s) in the last hour",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ }
+
var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId);
+ var detailText = ContextToDetailText(deadlockContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Deadlocks Detected",
@@ -1138,7 +1173,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
effectiveDeadlockCount.ToString(),
App.AlertDeadlockThreshold.ToString(),
summary.ServerId,
- deadlockContext);
+ deadlockContext,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeDeadlockAlert.TryGetValue(key, out var wasDeadlock) && wasDeadlock)
@@ -1165,13 +1202,25 @@ await _emailAlertService.TrySendAlertEmailAsync(
{
var worst = triggered[0];
var allWaitNames = string.Join(", ", triggered.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)"));
- _trayService.ShowNotification(
- "Poison Wait",
- $"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+
+ /* Poison wait mute check uses the worst (highest avg ms/wait) triggered wait type.
+ Limitation: if a user mutes a specific wait type that isn't the worst, the alert
+ still fires. Conversely, muting the worst type suppresses the entire alert even
+ if other unmuted poison waits are present. */
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Poison Wait", WaitType = worst.WaitType };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastPoisonWaitAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Poison Wait",
+ $"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ }
+
var poisonContext = BuildPoisonWaitContext(triggered);
+ var detailText = ContextToDetailText(poisonContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Poison Wait",
@@ -1181,7 +1230,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
summary.ServerId,
poisonContext,
numericCurrentValue: worst.AvgMsPerWait,
- numericThresholdValue: App.AlertPoisonWaitThresholdMs);
+ numericThresholdValue: App.AlertPoisonWaitThresholdMs,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait)
@@ -1224,13 +1275,27 @@ await _emailAlertService.TrySendAlertEmailAsync(
var elapsedMinutes = worst.ElapsedSeconds / 60;
var preview = TruncateText(worst.QueryText, 80);
var previewSuffix = string.IsNullOrEmpty(preview) ? "" : $" β {preview}";
- _trayService.ShowNotification(
- "Long-Running Query",
- $"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+
+ var muteCtx = new AlertMuteContext
+ {
+ ServerName = summary.DisplayName,
+ MetricName = "Long-Running Query",
+ DatabaseName = worst.DatabaseName,
+ QueryText = worst.QueryText
+ };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningQueryAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Long-Running Query",
+ $"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var lrqContext = BuildLongRunningQueryContext(longRunning);
+ var detailText = ContextToDetailText(lrqContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Long-Running Query",
@@ -1240,7 +1305,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
summary.ServerId,
lrqContext,
numericCurrentValue: elapsedMinutes,
- numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes);
+ numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning)
@@ -1270,13 +1337,20 @@ await _emailAlertService.TrySendAlertEmailAsync(
_activeTempDbSpaceAlert[key] = true;
if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= alertCooldown))
{
- _trayService.ShowNotification(
- "TempDB Space",
- $"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "TempDB Space" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastTempDbSpaceAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "TempDB Space",
+ $"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var tempDbContext = BuildTempDbSpaceContext(tempDb);
+ var detailText = ContextToDetailText(tempDbContext);
await _emailAlertService.TrySendAlertEmailAsync(
"TempDB Space",
@@ -1286,7 +1360,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
summary.ServerId,
tempDbContext,
numericCurrentValue: tempDb.UsedPercent,
- numericThresholdValue: App.AlertTempDbSpaceThresholdPercent);
+ numericThresholdValue: App.AlertTempDbSpaceThresholdPercent,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb)
@@ -1321,13 +1397,21 @@ await _emailAlertService.TrySendAlertEmailAsync(
if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= alertCooldown))
{
var currentMinutes = worst.CurrentDurationSeconds / 60;
- _trayService.ShowNotification(
- "Long-Running Job",
- $"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Long-Running Job", JobName = worst.JobName };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningJobAlert[jobKey] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Long-Running Job",
+ $"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var jobContext = BuildAnomalousJobContext(anomalousJobs);
+ var detailText = ContextToDetailText(jobContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Long-Running Job",
@@ -1337,7 +1421,9 @@ await _emailAlertService.TrySendAlertEmailAsync(
summary.ServerId,
jobContext,
numericCurrentValue: (double)(worst.PercentOfAverage ?? 0),
- numericThresholdValue: App.AlertLongRunningJobMultiplier * 100);
+ numericThresholdValue: App.AlertLongRunningJobMultiplier * 100,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob)
@@ -1363,6 +1449,20 @@ private static string TruncateText(string text, int maxLength = 300)
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
}
+ private static string? ContextToDetailText(AlertContext? context)
+ {
+ if (context == null || context.Details.Count == 0) return null;
+ var sb = new System.Text.StringBuilder();
+ foreach (var detail in context.Details)
+ {
+ if (sb.Length > 0) sb.AppendLine();
+ sb.AppendLine(detail.Heading);
+ foreach (var (label, value) in detail.Fields)
+ sb.AppendLine($" {label}: {value}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
private async Task BuildBlockingContextAsync(int serverId)
{
try
diff --git a/Lite/Mcp/McpAlertTools.cs b/Lite/Mcp/McpAlertTools.cs
index d590ddd5..fcc03f94 100644
--- a/Lite/Mcp/McpAlertTools.cs
+++ b/Lite/Mcp/McpAlertTools.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using System.Text.Json;
-using DuckDB.NET.Data;
using ModelContextProtocol.Server;
using PerformanceMonitorLite.Services;
@@ -23,50 +22,27 @@ public static async Task GetAlertHistory(
var limitError = McpHelpers.ValidateTop(limit);
if (limitError != null) return limitError;
- using var connection = await dataService.OpenConnectionAsync();
+ var rows = await dataService.GetAlertHistoryAsync(hours_back, limit);
- using var command = connection.CreateCommand();
- command.CommandText = @"
-SELECT
- alert_time,
- server_id,
- server_name,
- metric_name,
- current_value,
- threshold_value,
- alert_sent,
- notification_type,
- send_error
-FROM config_alert_log
-WHERE alert_time >= $1
-ORDER BY alert_time DESC
-LIMIT $2";
-
- command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hours_back) });
- command.Parameters.Add(new DuckDBParameter { Value = limit });
-
- var alerts = new List
private async Task LogAlertAsync(int serverId, string serverName, string metricName,
- double currentValue, double thresholdValue, bool alertSent, string notificationType, string? sendError)
+ double currentValue, double thresholdValue, bool alertSent, string notificationType, string? sendError, bool muted = false, string? detailText = null)
{
try
{
@@ -213,13 +215,14 @@ private async Task LogAlertAsync(int serverId, string serverName, string metricN
duckDb = new DuckDbInitializer(dbPath);
}
+ using var writeLock = duckDb.AcquireWriteLock();
using var connection = duckDb.CreateConnection();
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = @"
-INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, alert_sent, notification_type, send_error)
-VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
+INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, alert_sent, notification_type, send_error, muted, detail_text)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = DateTime.UtcNow });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId });
@@ -230,6 +233,8 @@ INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, c
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = alertSent });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = notificationType });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = sendError ?? (object)DBNull.Value });
+ command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = muted });
+ command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = detailText ?? (object)DBNull.Value });
await command.ExecuteNonQueryAsync();
diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs
index baf16726..0d42cdc5 100644
--- a/Lite/Services/LocalDataService.AlertHistory.cs
+++ b/Lite/Services/LocalDataService.AlertHistory.cs
@@ -37,7 +37,9 @@ public async Task> GetAlertHistoryAsync(int hoursBack = 24
threshold_value,
alert_sent,
notification_type,
- send_error
+ send_error,
+ muted,
+ detail_text
FROM v_config_alert_log
WHERE alert_time >= $1
AND server_id = $2
@@ -60,7 +62,9 @@ ORDER BY alert_time DESC
threshold_value,
alert_sent,
notification_type,
- send_error
+ send_error,
+ muted,
+ detail_text
FROM v_config_alert_log
WHERE alert_time >= $1
AND dismissed = FALSE
@@ -84,7 +88,9 @@ ORDER BY alert_time DESC
ThresholdValue = Convert.ToDouble(reader.GetValue(5)),
AlertSent = reader.GetBoolean(6),
NotificationType = reader.GetString(7),
- SendError = reader.IsDBNull(8) ? null : reader.GetString(8)
+ SendError = reader.IsDBNull(8) ? null : reader.GetString(8),
+ Muted = !reader.IsDBNull(9) && reader.GetBoolean(9),
+ DetailText = reader.IsDBNull(10) ? null : reader.GetString(10)
});
}
@@ -163,6 +169,8 @@ public class AlertHistoryRow
public bool AlertSent { get; set; }
public string NotificationType { get; set; } = "";
public string? SendError { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
public string TimeLocal => AlertTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
public string CurrentValueDisplay => FormatValue(MetricName, CurrentValue);
diff --git a/Lite/Services/MuteRuleService.cs b/Lite/Services/MuteRuleService.cs
new file mode 100644
index 00000000..4bf3d5c6
--- /dev/null
+++ b/Lite/Services/MuteRuleService.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services
+{
+ ///
+ /// Manages alert mute rules with DuckDB persistence.
+ /// Rules are cached in memory for fast matching and synced to DuckDB on changes.
+ /// Thread-safe: all operations are protected by _lock.
+ /// Database operations use the LockedConnection pattern to coordinate with CHECKPOINT.
+ ///
+ public class MuteRuleService
+ {
+ private readonly DuckDbInitializer _dbInitializer;
+ private readonly object _lock = new object();
+ private List _rules = new();
+
+ public MuteRuleService(DuckDbInitializer dbInitializer)
+ {
+ _dbInitializer = dbInitializer;
+ }
+
+ public bool IsAlertMuted(AlertMuteContext context)
+ {
+ lock (_lock)
+ {
+ return _rules.Any(r => r.Matches(context));
+ }
+ }
+
+ public List GetRules()
+ {
+ lock (_lock)
+ {
+ return _rules.ToList();
+ }
+ }
+
+ public List GetActiveRules()
+ {
+ lock (_lock)
+ {
+ return _rules.Where(r => r.Enabled && !r.IsExpired).ToList();
+ }
+ }
+
+ public async Task LoadAsync()
+ {
+ var rules = new List();
+ try
+ {
+ using var readLock = _dbInitializer.AcquireReadLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ SELECT id, enabled, created_at_utc, expires_at_utc, reason,
+ server_name, metric_name, database_pattern,
+ query_text_pattern, wait_type_pattern, job_name_pattern
+ FROM config_mute_rules
+ ORDER BY created_at_utc DESC";
+
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ rules.Add(new MuteRule
+ {
+ Id = reader.GetString(0),
+ Enabled = reader.GetBoolean(1),
+ CreatedAtUtc = reader.GetDateTime(2),
+ ExpiresAtUtc = reader.IsDBNull(3) ? null : reader.GetDateTime(3),
+ Reason = reader.IsDBNull(4) ? null : reader.GetString(4),
+ ServerName = reader.IsDBNull(5) ? null : reader.GetString(5),
+ MetricName = reader.IsDBNull(6) ? null : reader.GetString(6),
+ DatabasePattern = reader.IsDBNull(7) ? null : reader.GetString(7),
+ QueryTextPattern = reader.IsDBNull(8) ? null : reader.GetString(8),
+ WaitTypePattern = reader.IsDBNull(9) ? null : reader.GetString(9),
+ JobNamePattern = reader.IsDBNull(10) ? null : reader.GetString(10)
+ });
+ }
+ }
+ catch
+ {
+ /* Non-fatal β start with empty rules if DB not ready */
+ }
+
+ lock (_lock)
+ {
+ _rules = rules;
+ }
+
+ /* Purge expired rules on startup */
+ await PurgeExpiredRulesAsync();
+ }
+
+ public async Task AddRuleAsync(MuteRule rule)
+ {
+ try
+ {
+ await PersistRuleAsync(rule);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to persist new mute rule to DuckDB β rule will not be saved: {ex.Message}");
+ return;
+ }
+
+ lock (_lock)
+ {
+ _rules.Add(rule);
+ }
+ }
+
+ public async Task RemoveRuleAsync(string ruleId)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM config_mute_rules WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = ruleId });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to delete mute rule from DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => r.Id == ruleId);
+ }
+ }
+
+ public async Task UpdateRuleAsync(MuteRule updated)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ UPDATE config_mute_rules SET
+ enabled = $2, expires_at_utc = $3, reason = $4,
+ server_name = $5, metric_name = $6, database_pattern = $7,
+ query_text_pattern = $8, wait_type_pattern = $9, job_name_pattern = $10
+ WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = updated.Id });
+ cmd.Parameters.Add(new DuckDBParameter { Value = updated.Enabled });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.ExpiresAtUtc ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.Reason ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.ServerName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.MetricName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.DatabasePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.QueryTextPattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.WaitTypePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.JobNamePattern ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to update mute rule in DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ var index = _rules.FindIndex(r => r.Id == updated.Id);
+ if (index >= 0)
+ _rules[index] = updated;
+ }
+ }
+
+ public async Task SetRuleEnabledAsync(string ruleId, bool enabled)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "UPDATE config_mute_rules SET enabled = $2 WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = ruleId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = enabled });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to update mute rule enabled state in DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ var rule = _rules.FirstOrDefault(r => r.Id == ruleId);
+ if (rule != null) rule.Enabled = enabled;
+ }
+ }
+
+ public async Task PurgeExpiredRulesAsync()
+ {
+ List expiredIds;
+ lock (_lock)
+ {
+ expiredIds = _rules.Where(r => r.IsExpired).Select(r => r.Id).ToList();
+ if (expiredIds.Count == 0) return 0;
+ }
+
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ foreach (var id in expiredIds)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM config_mute_rules WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = id });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to purge expired mute rules from DuckDB: {ex.Message}");
+ return 0;
+ }
+
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => expiredIds.Contains(r.Id));
+ }
+
+ return expiredIds.Count;
+ }
+
+ private async Task PersistRuleAsync(MuteRule rule)
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ INSERT INTO config_mute_rules
+ (id, enabled, created_at_utc, expires_at_utc, reason,
+ server_name, metric_name, database_pattern,
+ query_text_pattern, wait_type_pattern, job_name_pattern)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.Id });
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.Enabled });
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.CreatedAtUtc });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.ExpiresAtUtc ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.Reason ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.ServerName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.MetricName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.DatabasePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.QueryTextPattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.WaitTypePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.JobNamePattern ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+}
diff --git a/Lite/Windows/AlertDetailWindow.xaml b/Lite/Windows/AlertDetailWindow.xaml
new file mode 100644
index 00000000..7e7f4226
--- /dev/null
+++ b/Lite/Windows/AlertDetailWindow.xaml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/AlertDetailWindow.xaml.cs b/Lite/Windows/AlertDetailWindow.xaml.cs
new file mode 100644
index 00000000..8eeba67e
--- /dev/null
+++ b/Lite/Windows/AlertDetailWindow.xaml.cs
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Windows;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class AlertDetailWindow : Window
+{
+ public AlertDetailWindow(AlertHistoryRow item)
+ {
+ InitializeComponent();
+
+ TimeText.Text = item.TimeLocal;
+ ServerText.Text = item.ServerName;
+ MetricText.Text = item.MetricName;
+ CurrentValueText.Text = item.CurrentValueDisplay;
+ ThresholdText.Text = item.ThresholdValueDisplay;
+ NotificationText.Text = item.NotificationType;
+ StatusText.Text = item.StatusDisplay;
+
+ if (item.Muted)
+ MutedBanner.Visibility = Visibility.Visible;
+
+ if (!string.IsNullOrWhiteSpace(item.DetailText))
+ {
+ DetailTextBox.Text = item.DetailText;
+ DetailPanel.Visibility = Visibility.Visible;
+ }
+ }
+}
diff --git a/Lite/Windows/ManageMuteRulesWindow.xaml b/Lite/Windows/ManageMuteRulesWindow.xaml
new file mode 100644
index 00000000..b424f180
--- /dev/null
+++ b/Lite/Windows/ManageMuteRulesWindow.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/ManageMuteRulesWindow.xaml.cs b/Lite/Windows/ManageMuteRulesWindow.xaml.cs
new file mode 100644
index 00000000..8cda3323
--- /dev/null
+++ b/Lite/Windows/ManageMuteRulesWindow.xaml.cs
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class ManageMuteRulesWindow : Window
+{
+ private readonly MuteRuleService _muteRuleService;
+ private readonly ObservableCollection _rules;
+
+ public ManageMuteRulesWindow(MuteRuleService muteRuleService)
+ {
+ InitializeComponent();
+ _muteRuleService = muteRuleService;
+ _rules = new ObservableCollection(_muteRuleService.GetRules());
+ RulesGrid.ItemsSource = _rules;
+ }
+
+ private async void AddRule_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new MuteRuleDialog { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ await _muteRuleService.AddRuleAsync(dialog.Rule);
+ _rules.Add(dialog.Rule);
+ }
+ }
+
+ private async void EditRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var dialog = new MuteRuleDialog(selected) { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ await _muteRuleService.UpdateRuleAsync(dialog.Rule);
+ RefreshList();
+ }
+ }
+
+ private async void ToggleRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ await _muteRuleService.SetRuleEnabledAsync(selected.Id, !selected.Enabled);
+ RefreshList();
+ if (index < _rules.Count) RulesGrid.SelectedIndex = index;
+ RulesGrid.Focus();
+ }
+
+ private async void DeleteRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ var result = MessageBox.Show(
+ $"Delete this mute rule?\n\n{selected.Summary}",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ await _muteRuleService.RemoveRuleAsync(selected.Id);
+ _rules.Remove(selected);
+ if (_rules.Count > 0)
+ RulesGrid.SelectedIndex = Math.Min(index, _rules.Count - 1);
+ RulesGrid.Focus();
+ }
+ }
+
+ private async void PurgeExpired_Click(object sender, RoutedEventArgs e)
+ {
+ int removed = await _muteRuleService.PurgeExpiredRulesAsync();
+ if (removed > 0)
+ {
+ RefreshList();
+ MessageBox.Show($"Removed {removed} expired rule(s).", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show("No expired rules to remove.", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private async void EnabledCheckBox_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is CheckBox cb && cb.DataContext is MuteRule rule)
+ {
+ await _muteRuleService.SetRuleEnabledAsync(rule.Id, cb.IsChecked == true);
+ }
+ }
+
+ private void RulesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ EditRule_Click(sender, e);
+ }
+
+ private void RefreshList()
+ {
+ _rules.Clear();
+ foreach (var rule in _muteRuleService.GetRules())
+ _rules.Add(rule);
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/Lite/Windows/MuteRuleDialog.xaml b/Lite/Windows/MuteRuleDialog.xaml
new file mode 100644
index 00000000..8d67fdf4
--- /dev/null
+++ b/Lite/Windows/MuteRuleDialog.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/MuteRuleDialog.xaml.cs b/Lite/Windows/MuteRuleDialog.xaml.cs
new file mode 100644
index 00000000..2fea9e53
--- /dev/null
+++ b/Lite/Windows/MuteRuleDialog.xaml.cs
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class MuteRuleDialog : Window
+{
+ public MuteRule Rule { get; private set; }
+
+ public MuteRuleDialog(MuteRule? existingRule = null)
+ {
+ InitializeComponent();
+
+ if (existingRule != null)
+ {
+ Title = "Edit Mute Rule";
+ HeaderText.Text = "Edit Mute Rule";
+ Rule = existingRule.Clone();
+ PopulateFromRule(Rule);
+ }
+ else
+ {
+ Rule = new MuteRule();
+ }
+ }
+
+ ///
+ /// Creates a dialog pre-populated for muting from an alert context.
+ ///
+ public MuteRuleDialog(AlertMuteContext context) : this()
+ {
+ if (!string.IsNullOrEmpty(context.ServerName))
+ ServerNameBox.Text = context.ServerName;
+ if (!string.IsNullOrEmpty(context.MetricName))
+ SelectMetric(context.MetricName);
+ if (!string.IsNullOrEmpty(context.DatabaseName))
+ DatabasePatternBox.Text = context.DatabaseName;
+ if (!string.IsNullOrEmpty(context.QueryText))
+ QueryTextPatternBox.Text = context.QueryText.Length > 200
+ ? context.QueryText.Substring(0, 200)
+ : context.QueryText;
+ if (!string.IsNullOrEmpty(context.WaitType))
+ WaitTypePatternBox.Text = context.WaitType;
+ if (!string.IsNullOrEmpty(context.JobName))
+ JobNamePatternBox.Text = context.JobName;
+ }
+
+ private void PopulateFromRule(MuteRule rule)
+ {
+ ReasonBox.Text = rule.Reason ?? "";
+ ServerNameBox.Text = rule.ServerName ?? "";
+ DatabasePatternBox.Text = rule.DatabasePattern ?? "";
+ QueryTextPatternBox.Text = rule.QueryTextPattern ?? "";
+ WaitTypePatternBox.Text = rule.WaitTypePattern ?? "";
+ JobNamePatternBox.Text = rule.JobNamePattern ?? "";
+
+ if (!string.IsNullOrEmpty(rule.MetricName))
+ SelectMetric(rule.MetricName);
+
+ if (rule.ExpiresAtUtc == null)
+ ExpirationCombo.SelectedIndex = 3;
+ else
+ {
+ var remaining = rule.ExpiresAtUtc.Value - DateTime.UtcNow;
+ if (remaining.TotalHours <= 1.5) ExpirationCombo.SelectedIndex = 0;
+ else if (remaining.TotalHours <= 25) ExpirationCombo.SelectedIndex = 1;
+ else ExpirationCombo.SelectedIndex = 2;
+ }
+ }
+
+ private void SelectMetric(string metricName)
+ {
+ for (int i = 0; i < MetricCombo.Items.Count; i++)
+ {
+ if (MetricCombo.Items[i] is ComboBoxItem item &&
+ string.Equals(item.Content?.ToString(), metricName, StringComparison.OrdinalIgnoreCase))
+ {
+ MetricCombo.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ Rule.Reason = string.IsNullOrWhiteSpace(ReasonBox.Text) ? null : ReasonBox.Text.Trim();
+ Rule.ServerName = string.IsNullOrWhiteSpace(ServerNameBox.Text) ? null : ServerNameBox.Text.Trim();
+
+ Rule.DatabasePattern = DatabasePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(DatabasePatternBox.Text)
+ ? DatabasePatternBox.Text.Trim() : null;
+ Rule.QueryTextPattern = QueryTextPatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(QueryTextPatternBox.Text)
+ ? QueryTextPatternBox.Text.Trim() : null;
+ Rule.WaitTypePattern = WaitTypePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(WaitTypePatternBox.Text)
+ ? WaitTypePatternBox.Text.Trim() : null;
+ Rule.JobNamePattern = JobNamePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(JobNamePatternBox.Text)
+ ? JobNamePatternBox.Text.Trim() : null;
+
+ if (MetricCombo.SelectedIndex > 0 && MetricCombo.SelectedItem is ComboBoxItem selected)
+ Rule.MetricName = selected.Content?.ToString();
+ else
+ Rule.MetricName = null;
+
+ Rule.ExpiresAtUtc = ExpirationCombo.SelectedIndex switch
+ {
+ 0 => DateTime.UtcNow.AddHours(1),
+ 1 => DateTime.UtcNow.AddHours(24),
+ 2 => DateTime.UtcNow.AddDays(7),
+ _ => null
+ };
+
+ if (Rule.ServerName == null && Rule.MetricName == null && Rule.DatabasePattern == null
+ && Rule.QueryTextPattern == null && Rule.WaitTypePattern == null && Rule.JobNamePattern == null)
+ {
+ var result = MessageBox.Show(
+ "This rule has no filters and will mute ALL alerts. Are you sure?",
+ "Mute All Alerts", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ if (result != MessageBoxResult.Yes) return;
+ }
+
+ DialogResult = true;
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ private void MetricCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (DatabasePatternBox == null) return;
+
+ var metric = (MetricCombo.SelectedItem as ComboBoxItem)?.Content?.ToString();
+
+ bool showDatabase = metric is null or "(any)" or "Blocking Detected" or "Deadlocks Detected" or "Long-Running Query";
+ bool showWaitType = metric is null or "(any)" or "Poison Wait" or "Long-Running Query";
+ bool showQueryText = metric is null or "(any)" or "Blocking Detected" or "Long-Running Query";
+ bool showJobName = metric is null or "(any)" or "Long-Running Job";
+
+ DatabaseLabel.Visibility = DatabasePatternBox.Visibility = showDatabase ? Visibility.Visible : Visibility.Collapsed;
+ WaitTypeLabel.Visibility = WaitTypePatternBox.Visibility = showWaitType ? Visibility.Visible : Visibility.Collapsed;
+ QueryTextLabel.Visibility = QueryTextPatternBox.Visibility = showQueryText ? Visibility.Visible : Visibility.Collapsed;
+ JobNameLabel.Visibility = JobNamePatternBox.Visibility = showJobName ? Visibility.Visible : Visibility.Collapsed;
+
+ PatternFieldsGrid.Visibility = (showDatabase || showWaitType || showQueryText || showJobName)
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+}
diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml
index 108e845b..40ef0ef1 100644
--- a/Lite/Windows/SettingsWindow.xaml
+++ b/Lite/Windows/SettingsWindow.xaml
@@ -247,6 +247,16 @@
+
+
+
+
+
diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs
index dbe4202d..8366eb3c 100644
--- a/Lite/Windows/SettingsWindow.xaml.cs
+++ b/Lite/Windows/SettingsWindow.xaml.cs
@@ -25,16 +25,19 @@ public partial class SettingsWindow : Window
private readonly ScheduleManager _scheduleManager;
private readonly CollectionBackgroundService? _backgroundService;
private readonly McpHostService? _mcpService;
+ private readonly MuteRuleService? _muteRuleService;
public SettingsWindow(
ScheduleManager scheduleManager,
CollectionBackgroundService? backgroundService = null,
- McpHostService? mcpService = null)
+ McpHostService? mcpService = null,
+ MuteRuleService? muteRuleService = null)
{
InitializeComponent();
_scheduleManager = scheduleManager;
_backgroundService = backgroundService;
_mcpService = mcpService;
+ _muteRuleService = muteRuleService;
LoadSchedules();
UpdateCollectionStatus();
@@ -688,6 +691,13 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
UpdateAlertPreviewText();
}
+ private void ManageMuteRulesButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_muteRuleService == null) return;
+ var window = new ManageMuteRulesWindow(_muteRuleService) { Owner = this };
+ window.ShowDialog();
+ }
+
private void UpdateAlertPreviewText()
{
var parts = new System.Collections.Generic.List();
diff --git a/README.md b/README.md
index e5390065..cd348133 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ Both editions include real-time alerts (system tray + email), charts and graphs,
π **Graphical plan viewer** with native ShowPlan rendering, 30-rule PlanAnalyzer, operator-level cost breakdown, and a standalone mode for opening `.sqlplan` files without a server connection
-π€ **Built-in MCP server** with 27-31 read-only tools for AI analysis β ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data
+π€ **Built-in MCP server** with 28-32 read-only tools for AI analysis β ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data
π§° **Community tools installed automatically** β sp_WhoIsActive, sp_BlitzLock, sp_HealthParser, sp_HumanEventsBlockViewer
@@ -295,7 +295,7 @@ The Full Edition supports Azure SQL Managed Instance and AWS RDS for SQL Server
| Dashboard | Separate app | Built-in |
| Themes | Dark and light | Dark and light |
| Portability | Server-bound | Single executable |
-| MCP server (LLM integration) | Built into Dashboard (27 tools) | Built-in (31 tools) |
+| MCP server (LLM integration) | Built into Dashboard (28 tools) | Built-in (32 tools) |
---
@@ -344,11 +344,17 @@ Both editions include a real-time alert engine that monitors for performance iss
|---|---|---|
| **Blocking** | 30 seconds (Full), 5 seconds (Lite) | Fires when the longest blocked session exceeds the threshold |
| **Deadlocks** | 1 | Fires when new deadlocks are detected since the last check |
+| **Poison waits** | 100 ms avg | Fires when any poison wait type exceeds the average-ms-per-wait threshold |
+| **Long-running queries** | 5 minutes | Fires when any query exceeds the elapsed-time threshold |
+| **TempDB space** | 80% | Fires when TempDB usage exceeds the percentage threshold |
+| **Long-running agent jobs** | 3Γ average | Fires when a job's current duration exceeds a multiple of its historical average |
| **High CPU** | 90% (Full), 80% (Lite) | Fires when total CPU (SQL + other) exceeds the threshold |
| **Connection changes** | N/A | Fires when a monitored server goes offline or comes back online |
All thresholds are configurable in Settings.
+**Poison wait types** monitored: [`THREADPOOL`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#threadpool) (worker thread exhaustion), [`RESOURCE_SEMAPHORE`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#resource_semaphore) (memory grant pressure), and [`RESOURCE_SEMAPHORE_QUERY_COMPILE`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#resource_semaphore_query_compile) (compilation memory pressure). These waits indicate severe resource starvation and should never occur under normal operation.
+
### Notification Channels
- **System tray** β balloon notifications with a configurable per-metric cooldown (default: 5 minutes)
@@ -370,6 +376,8 @@ Alert emails include:
- **Server silencing** β right-click a server tab to acknowledge alerts, silence all alerts, or unsilence
- **Always-on** β the Dashboard alert engine runs independently of which tab is active, including when minimized to the system tray. The Lite edition's alert engine also runs regardless of tab visibility.
- **Alert history** β Dashboard keeps an in-memory alert log (accessible via MCP). Lite logs alerts to DuckDB (`config_alert_log`).
+- **Alert muting** β create rules to suppress specific recurring alerts while still logging them. Rules match on server name, metric type, database, query text, wait type, or job name (AND logic across fields). Access via Settings β Manage Mute Rules, or right-click an alert in the Alert History tab. The context menu offers two muting options: **Mute This Alert** (pre-fills server + metric for a targeted rule) and **Mute Similar Alerts** (pre-fills metric only, matching across all servers). Muted alerts appear grayed out in alert history and are still recorded for auditability. Rules support optional expiration (1h, 24h, 7 days, or permanent).
+- **Alert details** β right-click any alert in the Alert History tab and choose **View Details** to open a detail window. The window shows core alert fields (time, server, metric, value, threshold, notification type, status) plus context-sensitive details that vary by metric: query text and session info for long-running queries, job name and duration stats for anomalous agent jobs, per-wait-type breakdowns for poison waits, space usage by category for TempDB, and blocking/deadlock session counts.
---
@@ -415,13 +423,13 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/
### Available Tools
-Full Edition exposes 27 tools, Lite Edition exposes 31. Core tools are shared across both editions.
+Full Edition exposes 28 tools, Lite Edition exposes 32. Core tools are shared across both editions.
| Category | Tools |
|---|---|
| Discovery | `list_servers` |
| Health | `get_server_summary`\*, `get_daily_summary`\*\*, `get_collection_health` |
-| Alerts | `get_alert_history`, `get_alert_settings` |
+| Alerts | `get_alert_history`, `get_alert_settings`, `get_mute_rules` |
| Waits | `get_wait_stats`, `get_wait_types`\*, `get_wait_trend`, `get_waiting_tasks`\* |
| Queries | `get_top_queries_by_cpu`, `get_top_procedures_by_cpu`, `get_query_store_top`, `get_expensive_queries`\*\*, `get_query_duration_trend`\*, `get_query_trend` |
| CPU | `get_cpu_utilization` |