diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs index 2911fa83..a85d2f6e 100644 --- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs +++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs @@ -484,6 +484,7 @@ private void MuteThisAlert_Click(object sender, RoutedEventArgs e) ServerName = item.ServerName, MetricName = item.MetricName }; + context.PopulateFromDetailText(item.DetailText); var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) }; if (dialog.ShowDialog() == true) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 441de5e2..1eddb357 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -151,6 +151,7 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) // Sync preferences var startupPrefs = _preferencesService.GetPreferences(); TabHelpers.CsvSeparator = startupPrefs.CsvSeparator; + MuteRuleDialog.DefaultExpiration = startupPrefs.MuteRuleDefaultExpiration; if (Enum.TryParse(startupPrefs.TimeDisplayMode, out var tdm)) Helpers.ServerTimeHelper.CurrentDisplayMode = tdm; @@ -1654,7 +1655,7 @@ await _emailAlertService.TrySendAlertEmailAsync( private static string Truncate(string text, int maxLength = 300) { if (string.IsNullOrEmpty(text)) return ""; - text = text.Trim(); + text = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; } diff --git a/Dashboard/Models/MuteRule.cs b/Dashboard/Models/MuteRule.cs index b5b8e40d..f103a14e 100644 --- a/Dashboard/Models/MuteRule.cs +++ b/Dashboard/Models/MuteRule.cs @@ -1,101 +1,177 @@ -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; } - } -} +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; } + + /// + /// Extracts context fields (Database, Query, Wait Type, Job Name) from the + /// structured detail_text stored with each alert. The format is label/value + /// pairs indented with two spaces, e.g. " Database: MyDB". + /// Query values may span multiple lines and use variant labels + /// (Blocked Query, Blocking Query, Victim SQL). + /// + public void PopulateFromDetailText(string? detailText) + { + if (string.IsNullOrEmpty(detailText)) return; + + System.Text.StringBuilder? queryBuilder = null; + var lines = detailText.Split('\n'); + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + if (DatabaseName == null && trimmed.StartsWith("Database: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + DatabaseName = trimmed.Substring("Database: ".Length).Trim(); + } + else if (WaitType == null && trimmed.StartsWith("Wait Type: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + WaitType = trimmed.Substring("Wait Type: ".Length).Trim(); + } + else if (JobName == null && trimmed.StartsWith("Job Name: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + JobName = trimmed.Substring("Job Name: ".Length).Trim(); + } + else if (QueryText == null && queryBuilder == null && TryExtractQueryValue(trimmed, out var qv)) + { + queryBuilder = new System.Text.StringBuilder(qv); + } + else if (queryBuilder != null) + { + // Continuation lines from multi-line query values don't start + // with the two-space indent used by ContextToDetailText fields. + if (string.IsNullOrWhiteSpace(trimmed) || line.StartsWith(" ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + } + else + { + queryBuilder.Append(' ').Append(trimmed.Trim()); + } + } + } + + FlushQuery(ref queryBuilder); + } + + private void FlushQuery(ref System.Text.StringBuilder? builder) + { + if (builder != null && QueryText == null) + QueryText = builder.ToString(); + builder = null; + } + + private static bool TryExtractQueryValue(string trimmed, out string value) + { + foreach (var prefix in new[] { "Query: ", "Blocked Query: ", "Blocking Query: ", "Victim SQL: " }) + { + if (trimmed.StartsWith(prefix, StringComparison.Ordinal)) + { + value = trimmed.Substring(prefix.Length).Trim(); + return true; + } + } + value = ""; + return false; + } + } +} diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index aa2a3cd3..9276408c 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -141,6 +141,9 @@ private static string GetDefaultCsvSeparator() // Alert database exclusions public List AlertExcludedDatabases { get; set; } = new(); + // Default mute rule expiration ("1 hour", "24 hours", "7 days", "Never") + public string MuteRuleDefaultExpiration { get; set; } = "24 hours"; + // Alert suppression (persisted) public List SilencedServers { get; set; } = new(); public List SilencedServerTabs { get; set; } = new(); diff --git a/Dashboard/MuteRuleDialog.xaml b/Dashboard/MuteRuleDialog.xaml index cdae393e..45eb031f 100644 --- a/Dashboard/MuteRuleDialog.xaml +++ b/Dashboard/MuteRuleDialog.xaml @@ -1,112 +1,113 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -