Skip to content

Commit 8d3ca32

Browse files
Merge pull request #901 from erikdarlingdata/feature/900-purge-now-and-manage-servers-context-menu
Add Purge Now action to Manage Servers (closes #900)
2 parents b23d64b + 69fe460 commit 8d3ca32

6 files changed

Lines changed: 515 additions & 41 deletions

File tree

Dashboard/ManageServersWindow.xaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
<Window.Resources>
1010
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
1111
<ContextMenu x:Key="DataGridContextMenu">
12+
<MenuItem Header="Edit..." Click="EditServer_Click"/>
13+
<MenuItem Header="Toggle Favorite" Click="ToggleFavorite_Click"/>
14+
<MenuItem Header="Check Server Version" Click="CheckForUpdates_Click"/>
15+
<MenuItem Header="Purge Now" Click="PurgeNow_Click"/>
16+
<MenuItem Header="Remove" Click="RemoveServer_Click"
17+
Foreground="{DynamicResource ErrorBrush}"/>
18+
<Separator/>
1219
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
1320
<MenuItem.Icon><TextBlock Text="&#x1F4CB;"/></MenuItem.Icon>
1421
</MenuItem>
@@ -91,6 +98,7 @@
9198
<Button Content="Edit..." Width="80" Height="30" Margin="0,0,8,0" Click="EditServer_Click"/>
9299
<Button x:Name="ToggleFavoriteButton" Content="Toggle Favorite" Width="110" Height="30" Margin="0,0,8,0" Click="ToggleFavorite_Click"/>
93100
<Button x:Name="CheckUpdatesButton" Content="Check Server Version" Width="140" Height="30" Margin="0,0,8,0" Click="CheckForUpdates_Click"/>
101+
<Button Content="Purge Now" Width="100" Height="30" Margin="0,0,8,0" Click="PurgeNow_Click"/>
94102
<Button Content="Remove" Width="80" Height="30" Margin="0,0,8,0" Click="RemoveServer_Click"
95103
Foreground="{DynamicResource ErrorBrush}"/>
96104
</StackPanel>

Dashboard/ManageServersWindow.xaml.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,73 @@ string Normalize(string v)
297297
}
298298
}
299299

300+
private async void PurgeNow_Click(object sender, RoutedEventArgs e)
301+
{
302+
if (ServersDataGrid.SelectedItem is not ServerConnection server)
303+
{
304+
MessageBox.Show(
305+
"Please select a server to purge.",
306+
"No Server Selected",
307+
MessageBoxButton.OK,
308+
MessageBoxImage.Information);
309+
return;
310+
}
311+
312+
var dialog = new PurgeNowDialog(server.DisplayNameWithIntent) { Owner = this };
313+
if (dialog.ShowDialog() != true) return;
314+
315+
Cursor = System.Windows.Input.Cursors.Wait;
316+
try
317+
{
318+
var result = await _serverManager.RunDataRetentionAsync(
319+
server,
320+
dialog.RetentionDaysOverride);
321+
322+
bool wasTruncate = dialog.RetentionDaysOverride == 0;
323+
string body;
324+
if (wasTruncate)
325+
{
326+
body = $"All collector tables truncated.\n\n" +
327+
$"Rows wiped: {result.RowsDeleted:N0}\n" +
328+
$"Tables affected: {result.TableCount}\n" +
329+
$"Status: {result.Status}\n" +
330+
$"Duration: {result.DurationMs} ms";
331+
}
332+
else
333+
{
334+
body = $"Purge complete.\n\n" +
335+
$"Rows deleted: {result.RowsDeleted:N0}\n" +
336+
$"Tables touched: {result.TableCount}\n" +
337+
$"Status: {result.Status}\n" +
338+
$"Duration: {result.DurationMs} ms";
339+
}
340+
341+
if (!string.Equals(result.Status, "SUCCESS", StringComparison.Ordinal) &&
342+
!string.IsNullOrEmpty(result.Message))
343+
{
344+
body += $"\n\nMessage: {result.Message}";
345+
}
346+
347+
MessageBox.Show(this, body, "Purge Complete",
348+
MessageBoxButton.OK,
349+
string.Equals(result.Status, "SUCCESS", StringComparison.Ordinal)
350+
? MessageBoxImage.Information
351+
: MessageBoxImage.Warning);
352+
}
353+
catch (Exception ex)
354+
{
355+
MessageBox.Show(this,
356+
$"Failed to run purge on '{server.DisplayNameWithIntent}':\n\n{ex.Message}",
357+
"Purge Failed",
358+
MessageBoxButton.OK,
359+
MessageBoxImage.Error);
360+
}
361+
finally
362+
{
363+
Cursor = null;
364+
}
365+
}
366+
300367
private void CopyCell_Click(object sender, RoutedEventArgs e)
301368
{
302369
if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)

Dashboard/PurgeNowDialog.xaml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<Window x:Class="PerformanceMonitorDashboard.PurgeNowDialog"
2+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
Title="Purge Now"
5+
Height="320" Width="500"
6+
WindowStartupLocation="CenterOwner"
7+
ResizeMode="NoResize"
8+
Background="{DynamicResource BackgroundBrush}">
9+
<Grid Margin="20">
10+
<Grid.RowDefinitions>
11+
<RowDefinition Height="Auto"/>
12+
<RowDefinition Height="Auto"/>
13+
<RowDefinition Height="Auto"/>
14+
<RowDefinition Height="Auto"/>
15+
<RowDefinition Height="*"/>
16+
<RowDefinition Height="Auto"/>
17+
</Grid.RowDefinitions>
18+
19+
<TextBlock Grid.Row="0" x:Name="HeaderText" TextWrapping="Wrap" FontSize="13" FontWeight="Bold"
20+
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,12"/>
21+
22+
<TextBlock Grid.Row="1" Text="Purge mode:" Margin="0,0,0,4"
23+
Foreground="{DynamicResource ForegroundBrush}"/>
24+
25+
<ComboBox Grid.Row="2" x:Name="ModeComboBox" Height="28" Margin="0,0,0,12"
26+
SelectionChanged="ModeComboBox_SelectionChanged">
27+
<ComboBoxItem Content="Use configured retention" IsSelected="True" Tag="configured"/>
28+
<ComboBoxItem Content="1 day" Tag="1"/>
29+
<ComboBoxItem Content="3 days" Tag="3"/>
30+
<ComboBoxItem Content="7 days" Tag="7"/>
31+
<ComboBoxItem Content="Custom..." Tag="custom"/>
32+
<ComboBoxItem x:Name="AllItem" Content="All — TRUNCATE everything" Tag="all"
33+
Foreground="{DynamicResource ErrorBrush}"/>
34+
</ComboBox>
35+
36+
<StackPanel Grid.Row="3" x:Name="CustomDaysPanel" Orientation="Horizontal" Margin="0,0,0,12"
37+
Visibility="Collapsed">
38+
<TextBlock Text="Retention (days):" VerticalAlignment="Center" Margin="0,0,8,0"
39+
Foreground="{DynamicResource ForegroundBrush}"/>
40+
<TextBox x:Name="CustomDaysBox" Width="80" Height="26" Padding="6,0,6,0"
41+
VerticalContentAlignment="Center"
42+
Text="14" PreviewTextInput="CustomDaysBox_PreviewTextInput"/>
43+
</StackPanel>
44+
45+
<TextBlock Grid.Row="4" x:Name="WarningText" TextWrapping="Wrap" FontSize="11"
46+
Foreground="{DynamicResource ErrorBrush}" Margin="0,4,0,8"/>
47+
48+
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
49+
<Button x:Name="PurgeButton" Content="Purge" Width="80" Height="28" Margin="0,0,8,0" Click="Purge_Click"/>
50+
<Button Content="Cancel" Width="80" Height="28" Click="Cancel_Click" IsCancel="True"/>
51+
</StackPanel>
52+
</Grid>
53+
</Window>

Dashboard/PurgeNowDialog.xaml.cs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (c) 2026 Erik Darling, Darling Data LLC
3+
*
4+
* This file is part of the SQL Server Performance Monitor.
5+
*
6+
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
7+
*/
8+
9+
using System.Text.RegularExpressions;
10+
using System.Windows;
11+
using System.Windows.Controls;
12+
using System.Windows.Input;
13+
14+
namespace PerformanceMonitorDashboard
15+
{
16+
public partial class PurgeNowDialog : Window
17+
{
18+
private readonly string _serverDisplayName;
19+
20+
/// <summary>
21+
/// null = use configured retention.
22+
/// 0 = TRUNCATE every collect.* table.
23+
/// N > 0 = override every table's cutoff to N days.
24+
/// </summary>
25+
public int? RetentionDaysOverride { get; private set; }
26+
27+
public PurgeNowDialog(string serverDisplayName)
28+
{
29+
InitializeComponent();
30+
_serverDisplayName = serverDisplayName;
31+
HeaderText.Text = $"Purge collected data on '{serverDisplayName}'.";
32+
UpdateWarningForMode("configured");
33+
}
34+
35+
private void ModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
36+
{
37+
/* Fires once during InitializeComponent before other named elements exist; bail until window is loaded. */
38+
if (CustomDaysPanel is null || WarningText is null) return;
39+
40+
if (ModeComboBox.SelectedItem is not ComboBoxItem item) return;
41+
42+
string tag = item.Tag as string ?? "configured";
43+
CustomDaysPanel.Visibility = tag == "custom" ? Visibility.Visible : Visibility.Collapsed;
44+
UpdateWarningForMode(tag);
45+
}
46+
47+
private void UpdateWarningForMode(string tag)
48+
{
49+
WarningText.Text = tag switch
50+
{
51+
"configured" => "Deletes data older than the per-collector retention configured in config.collection_schedule. Cannot be undone.",
52+
"all" => "WARNING: TRUNCATEs every collect.* table. Wipes ALL collected monitoring data on this server. Cannot be undone.",
53+
"custom" => "Deletes data older than the specified number of days. Cannot be undone.",
54+
_ => $"Deletes data older than {tag} day(s). Cannot be undone."
55+
};
56+
}
57+
58+
private void CustomDaysBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
59+
{
60+
e.Handled = !Regex.IsMatch(e.Text, "^[0-9]+$");
61+
}
62+
63+
private void Purge_Click(object sender, RoutedEventArgs e)
64+
{
65+
if (ModeComboBox.SelectedItem is not ComboBoxItem item) return;
66+
67+
string tag = item.Tag as string ?? "configured";
68+
69+
switch (tag)
70+
{
71+
case "configured":
72+
RetentionDaysOverride = null;
73+
break;
74+
75+
case "1":
76+
case "3":
77+
case "7":
78+
RetentionDaysOverride = int.Parse(tag);
79+
break;
80+
81+
case "custom":
82+
if (!int.TryParse(CustomDaysBox.Text, out int days) || days < 1)
83+
{
84+
MessageBox.Show(this,
85+
"Enter a whole number of days greater than 0.",
86+
"Invalid Retention",
87+
MessageBoxButton.OK,
88+
MessageBoxImage.Warning);
89+
return;
90+
}
91+
RetentionDaysOverride = days;
92+
break;
93+
94+
case "all":
95+
var confirm = MessageBox.Show(this,
96+
$"This will TRUNCATE every collect.* table on '{_serverDisplayName}', wiping all monitoring data.\n\nAre you absolutely sure?",
97+
"Confirm Purge All",
98+
MessageBoxButton.YesNo,
99+
MessageBoxImage.Warning,
100+
MessageBoxResult.No);
101+
if (confirm != MessageBoxResult.Yes) return;
102+
RetentionDaysOverride = 0;
103+
break;
104+
}
105+
106+
DialogResult = true;
107+
}
108+
109+
private void Cancel_Click(object sender, RoutedEventArgs e)
110+
{
111+
DialogResult = false;
112+
}
113+
}
114+
}

Dashboard/Services/ServerManager.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,94 @@ IF DB_ID('PerformanceMonitor') IS NOT NULL
196196
Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'");
197197
}
198198

199+
public class PurgeResult
200+
{
201+
public int RowsDeleted { get; set; }
202+
public int TableCount { get; set; }
203+
public int DurationMs { get; set; }
204+
public string Status { get; set; } = "";
205+
public string? Message { get; set; }
206+
}
207+
208+
/// <summary>
209+
/// Runs config.data_retention against the PerformanceMonitor database on the given server.
210+
/// </summary>
211+
/// <param name="retentionDaysOverride">
212+
/// null = use per-collector retention from config.collection_schedule.
213+
/// 0 = TRUNCATE every collect.* table.
214+
/// N > 0 = override every table's cutoff to N days.
215+
/// </param>
216+
public async Task<PurgeResult> RunDataRetentionAsync(
217+
ServerConnection server,
218+
int? retentionDaysOverride)
219+
{
220+
var connectionString = server.GetConnectionString(_credentialService);
221+
var builder = new SqlConnectionStringBuilder(connectionString)
222+
{
223+
InitialCatalog = "PerformanceMonitor",
224+
ConnectTimeout = 10
225+
};
226+
227+
using var connection = new SqlConnection(builder.ConnectionString);
228+
await connection.OpenAsync();
229+
230+
using (var cmd = new SqlCommand("config.data_retention", connection))
231+
{
232+
cmd.CommandType = System.Data.CommandType.StoredProcedure;
233+
cmd.CommandTimeout = 600;
234+
235+
if (retentionDaysOverride.HasValue)
236+
{
237+
cmd.Parameters.Add(new SqlParameter("@retention_days", System.Data.SqlDbType.Int) { Value = retentionDaysOverride.Value });
238+
}
239+
240+
await cmd.ExecuteNonQueryAsync();
241+
}
242+
243+
using var readCmd = new SqlCommand(@"
244+
SELECT TOP (1)
245+
cl.collection_status,
246+
cl.rows_collected,
247+
cl.duration_ms,
248+
cl.error_message
249+
FROM config.collection_log AS cl
250+
WHERE cl.collector_name = N'data_retention'
251+
ORDER BY cl.collection_time DESC;", connection);
252+
readCmd.CommandTimeout = 30;
253+
254+
using var reader = await readCmd.ExecuteReaderAsync();
255+
var result = new PurgeResult();
256+
257+
if (await reader.ReadAsync())
258+
{
259+
result.Status = reader.IsDBNull(0) ? "" : reader.GetString(0);
260+
result.RowsDeleted = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
261+
result.DurationMs = reader.IsDBNull(2) ? 0 : reader.GetInt32(2);
262+
result.Message = reader.IsDBNull(3) ? null : reader.GetString(3);
263+
264+
if (result.Message is not null && result.Message.StartsWith("Cleaned ", StringComparison.Ordinal))
265+
{
266+
int spaceIdx = result.Message.IndexOf(' ', 8);
267+
if (spaceIdx > 8 && int.TryParse(result.Message.AsSpan(8, spaceIdx - 8), out int tableCount))
268+
{
269+
result.TableCount = tableCount;
270+
}
271+
}
272+
else if (result.Message is not null && result.Message.StartsWith("TRUNCATE all: ", StringComparison.Ordinal))
273+
{
274+
int spaceIdx = result.Message.IndexOf(' ', 14);
275+
if (spaceIdx > 14 && int.TryParse(result.Message.AsSpan(14, spaceIdx - 14), out int tableCount))
276+
{
277+
result.TableCount = tableCount;
278+
}
279+
}
280+
}
281+
282+
Logger.Info($"Ran data_retention on '{server.DisplayName}': status={result.Status}, rowsDeleted={result.RowsDeleted}, tables={result.TableCount}, durationMs={result.DurationMs}");
283+
284+
return result;
285+
}
286+
199287
public void UpdateLastConnected(string id)
200288
{
201289
lock (_serversLock)

0 commit comments

Comments
 (0)