Skip to content

Commit 827b078

Browse files
Merge pull request #642 from HannahVernon/feature/alert-muting-part-2
Feature/alert muting part 2
2 parents 913fe2a + b711025 commit 827b078

16 files changed

Lines changed: 666 additions & 429 deletions

Dashboard/Controls/AlertsHistoryContent.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ private void MuteThisAlert_Click(object sender, RoutedEventArgs e)
484484
ServerName = item.ServerName,
485485
MetricName = item.MetricName
486486
};
487+
context.PopulateFromDetailText(item.DetailText);
487488

488489
var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
489490
if (dialog.ShowDialog() == true)

Dashboard/MainWindow.xaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
151151
// Sync preferences
152152
var startupPrefs = _preferencesService.GetPreferences();
153153
TabHelpers.CsvSeparator = startupPrefs.CsvSeparator;
154+
MuteRuleDialog.DefaultExpiration = startupPrefs.MuteRuleDefaultExpiration;
154155
if (Enum.TryParse<Helpers.TimeDisplayMode>(startupPrefs.TimeDisplayMode, out var tdm))
155156
Helpers.ServerTimeHelper.CurrentDisplayMode = tdm;
156157

@@ -1654,7 +1655,7 @@ await _emailAlertService.TrySendAlertEmailAsync(
16541655
private static string Truncate(string text, int maxLength = 300)
16551656
{
16561657
if (string.IsNullOrEmpty(text)) return "";
1657-
text = text.Trim();
1658+
text = text.Replace('\r', ' ').Replace('\n', ' ').Trim();
16581659
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
16591660
}
16601661

Dashboard/Models/MuteRule.cs

Lines changed: 177 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,177 @@
1-
using System;
2-
3-
namespace PerformanceMonitorDashboard.Models
4-
{
5-
public class MuteRule
6-
{
7-
public string Id { get; set; } = Guid.NewGuid().ToString();
8-
public bool Enabled { get; set; } = true;
9-
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
10-
public DateTime? ExpiresAtUtc { get; set; }
11-
public string? Reason { get; set; }
12-
13-
public string? ServerName { get; set; }
14-
public string? MetricName { get; set; }
15-
public string? DatabasePattern { get; set; }
16-
public string? QueryTextPattern { get; set; }
17-
public string? WaitTypePattern { get; set; }
18-
public string? JobNamePattern { get; set; }
19-
20-
public bool IsExpired => ExpiresAtUtc.HasValue && DateTime.UtcNow >= ExpiresAtUtc.Value;
21-
22-
public MuteRule Clone() => new()
23-
{
24-
Id = Id,
25-
Enabled = Enabled,
26-
CreatedAtUtc = CreatedAtUtc,
27-
ExpiresAtUtc = ExpiresAtUtc,
28-
Reason = Reason,
29-
ServerName = ServerName,
30-
MetricName = MetricName,
31-
DatabasePattern = DatabasePattern,
32-
QueryTextPattern = QueryTextPattern,
33-
WaitTypePattern = WaitTypePattern,
34-
JobNamePattern = JobNamePattern
35-
};
36-
37-
public string ExpiresDisplay => ExpiresAtUtc.HasValue
38-
? (IsExpired ? "Expired" : ExpiresAtUtc.Value.ToLocalTime().ToString("g"))
39-
: "Never";
40-
41-
public string Summary
42-
{
43-
get
44-
{
45-
var parts = new System.Collections.Generic.List<string>();
46-
if (MetricName != null) parts.Add(MetricName);
47-
if (ServerName != null) parts.Add($"on {ServerName}");
48-
if (DatabasePattern != null) parts.Add($"db≈{DatabasePattern}");
49-
if (QueryTextPattern != null) parts.Add($"query≈{QueryTextPattern}");
50-
if (WaitTypePattern != null) parts.Add($"wait≈{WaitTypePattern}");
51-
if (JobNamePattern != null) parts.Add($"job≈{JobNamePattern}");
52-
return parts.Count > 0 ? string.Join(", ", parts) : "(matches all alerts)";
53-
}
54-
}
55-
56-
public bool Matches(AlertMuteContext context)
57-
{
58-
if (!Enabled || IsExpired) return false;
59-
60-
if (ServerName != null &&
61-
!string.Equals(ServerName, context.ServerName, StringComparison.OrdinalIgnoreCase))
62-
return false;
63-
64-
if (MetricName != null &&
65-
!string.Equals(MetricName, context.MetricName, StringComparison.OrdinalIgnoreCase))
66-
return false;
67-
68-
if (DatabasePattern != null &&
69-
(context.DatabaseName == null ||
70-
!context.DatabaseName.Contains(DatabasePattern, StringComparison.OrdinalIgnoreCase)))
71-
return false;
72-
73-
if (QueryTextPattern != null &&
74-
(context.QueryText == null ||
75-
!context.QueryText.Contains(QueryTextPattern, StringComparison.OrdinalIgnoreCase)))
76-
return false;
77-
78-
if (WaitTypePattern != null &&
79-
(context.WaitType == null ||
80-
!context.WaitType.Contains(WaitTypePattern, StringComparison.OrdinalIgnoreCase)))
81-
return false;
82-
83-
if (JobNamePattern != null &&
84-
(context.JobName == null ||
85-
!context.JobName.Contains(JobNamePattern, StringComparison.OrdinalIgnoreCase)))
86-
return false;
87-
88-
return true;
89-
}
90-
}
91-
92-
public class AlertMuteContext
93-
{
94-
public string ServerName { get; set; } = "";
95-
public string MetricName { get; set; } = "";
96-
public string? DatabaseName { get; set; }
97-
public string? QueryText { get; set; }
98-
public string? WaitType { get; set; }
99-
public string? JobName { get; set; }
100-
}
101-
}
1+
using System;
2+
3+
namespace PerformanceMonitorDashboard.Models
4+
{
5+
public class MuteRule
6+
{
7+
public string Id { get; set; } = Guid.NewGuid().ToString();
8+
public bool Enabled { get; set; } = true;
9+
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
10+
public DateTime? ExpiresAtUtc { get; set; }
11+
public string? Reason { get; set; }
12+
13+
public string? ServerName { get; set; }
14+
public string? MetricName { get; set; }
15+
public string? DatabasePattern { get; set; }
16+
public string? QueryTextPattern { get; set; }
17+
public string? WaitTypePattern { get; set; }
18+
public string? JobNamePattern { get; set; }
19+
20+
public bool IsExpired => ExpiresAtUtc.HasValue && DateTime.UtcNow >= ExpiresAtUtc.Value;
21+
22+
public MuteRule Clone() => new()
23+
{
24+
Id = Id,
25+
Enabled = Enabled,
26+
CreatedAtUtc = CreatedAtUtc,
27+
ExpiresAtUtc = ExpiresAtUtc,
28+
Reason = Reason,
29+
ServerName = ServerName,
30+
MetricName = MetricName,
31+
DatabasePattern = DatabasePattern,
32+
QueryTextPattern = QueryTextPattern,
33+
WaitTypePattern = WaitTypePattern,
34+
JobNamePattern = JobNamePattern
35+
};
36+
37+
public string ExpiresDisplay => ExpiresAtUtc.HasValue
38+
? (IsExpired ? "Expired" : ExpiresAtUtc.Value.ToLocalTime().ToString("g"))
39+
: "Never";
40+
41+
public string Summary
42+
{
43+
get
44+
{
45+
var parts = new System.Collections.Generic.List<string>();
46+
if (MetricName != null) parts.Add(MetricName);
47+
if (ServerName != null) parts.Add($"on {ServerName}");
48+
if (DatabasePattern != null) parts.Add($"db≈{DatabasePattern}");
49+
if (QueryTextPattern != null) parts.Add($"query≈{QueryTextPattern}");
50+
if (WaitTypePattern != null) parts.Add($"wait≈{WaitTypePattern}");
51+
if (JobNamePattern != null) parts.Add($"job≈{JobNamePattern}");
52+
return parts.Count > 0 ? string.Join(", ", parts) : "(matches all alerts)";
53+
}
54+
}
55+
56+
public bool Matches(AlertMuteContext context)
57+
{
58+
if (!Enabled || IsExpired) return false;
59+
60+
if (ServerName != null &&
61+
!string.Equals(ServerName, context.ServerName, StringComparison.OrdinalIgnoreCase))
62+
return false;
63+
64+
if (MetricName != null &&
65+
!string.Equals(MetricName, context.MetricName, StringComparison.OrdinalIgnoreCase))
66+
return false;
67+
68+
if (DatabasePattern != null &&
69+
(context.DatabaseName == null ||
70+
!context.DatabaseName.Contains(DatabasePattern, StringComparison.OrdinalIgnoreCase)))
71+
return false;
72+
73+
if (QueryTextPattern != null &&
74+
(context.QueryText == null ||
75+
!context.QueryText.Contains(QueryTextPattern, StringComparison.OrdinalIgnoreCase)))
76+
return false;
77+
78+
if (WaitTypePattern != null &&
79+
(context.WaitType == null ||
80+
!context.WaitType.Contains(WaitTypePattern, StringComparison.OrdinalIgnoreCase)))
81+
return false;
82+
83+
if (JobNamePattern != null &&
84+
(context.JobName == null ||
85+
!context.JobName.Contains(JobNamePattern, StringComparison.OrdinalIgnoreCase)))
86+
return false;
87+
88+
return true;
89+
}
90+
}
91+
92+
public class AlertMuteContext
93+
{
94+
public string ServerName { get; set; } = "";
95+
public string MetricName { get; set; } = "";
96+
public string? DatabaseName { get; set; }
97+
public string? QueryText { get; set; }
98+
public string? WaitType { get; set; }
99+
public string? JobName { get; set; }
100+
101+
/// <summary>
102+
/// Extracts context fields (Database, Query, Wait Type, Job Name) from the
103+
/// structured detail_text stored with each alert. The format is label/value
104+
/// pairs indented with two spaces, e.g. " Database: MyDB".
105+
/// Query values may span multiple lines and use variant labels
106+
/// (Blocked Query, Blocking Query, Victim SQL).
107+
/// </summary>
108+
public void PopulateFromDetailText(string? detailText)
109+
{
110+
if (string.IsNullOrEmpty(detailText)) return;
111+
112+
System.Text.StringBuilder? queryBuilder = null;
113+
var lines = detailText.Split('\n');
114+
115+
foreach (var line in lines)
116+
{
117+
var trimmed = line.TrimStart();
118+
119+
if (DatabaseName == null && trimmed.StartsWith("Database: ", StringComparison.Ordinal))
120+
{
121+
FlushQuery(ref queryBuilder);
122+
DatabaseName = trimmed.Substring("Database: ".Length).Trim();
123+
}
124+
else if (WaitType == null && trimmed.StartsWith("Wait Type: ", StringComparison.Ordinal))
125+
{
126+
FlushQuery(ref queryBuilder);
127+
WaitType = trimmed.Substring("Wait Type: ".Length).Trim();
128+
}
129+
else if (JobName == null && trimmed.StartsWith("Job Name: ", StringComparison.Ordinal))
130+
{
131+
FlushQuery(ref queryBuilder);
132+
JobName = trimmed.Substring("Job Name: ".Length).Trim();
133+
}
134+
else if (QueryText == null && queryBuilder == null && TryExtractQueryValue(trimmed, out var qv))
135+
{
136+
queryBuilder = new System.Text.StringBuilder(qv);
137+
}
138+
else if (queryBuilder != null)
139+
{
140+
// Continuation lines from multi-line query values don't start
141+
// with the two-space indent used by ContextToDetailText fields.
142+
if (string.IsNullOrWhiteSpace(trimmed) || line.StartsWith(" ", StringComparison.Ordinal))
143+
{
144+
FlushQuery(ref queryBuilder);
145+
}
146+
else
147+
{
148+
queryBuilder.Append(' ').Append(trimmed.Trim());
149+
}
150+
}
151+
}
152+
153+
FlushQuery(ref queryBuilder);
154+
}
155+
156+
private void FlushQuery(ref System.Text.StringBuilder? builder)
157+
{
158+
if (builder != null && QueryText == null)
159+
QueryText = builder.ToString();
160+
builder = null;
161+
}
162+
163+
private static bool TryExtractQueryValue(string trimmed, out string value)
164+
{
165+
foreach (var prefix in new[] { "Query: ", "Blocked Query: ", "Blocking Query: ", "Victim SQL: " })
166+
{
167+
if (trimmed.StartsWith(prefix, StringComparison.Ordinal))
168+
{
169+
value = trimmed.Substring(prefix.Length).Trim();
170+
return true;
171+
}
172+
}
173+
value = "";
174+
return false;
175+
}
176+
}
177+
}

Dashboard/Models/UserPreferences.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ private static string GetDefaultCsvSeparator()
141141
// Alert database exclusions
142142
public List<string> AlertExcludedDatabases { get; set; } = new();
143143

144+
// Default mute rule expiration ("1 hour", "24 hours", "7 days", "Never")
145+
public string MuteRuleDefaultExpiration { get; set; } = "24 hours";
146+
144147
// Alert suppression (persisted)
145148
public List<string> SilencedServers { get; set; } = new();
146149
public List<string> SilencedServerTabs { get; set; } = new();

0 commit comments

Comments
 (0)