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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +