Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Dashboard/ManageServersWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
<ContextMenu x:Key="DataGridContextMenu">
<MenuItem Header="Edit..." Click="EditServer_Click"/>
<MenuItem Header="Toggle Favorite" Click="ToggleFavorite_Click"/>
<MenuItem Header="Check Server Version" Click="CheckForUpdates_Click"/>
<MenuItem Header="Purge Now" Click="PurgeNow_Click"/>
<MenuItem Header="Remove" Click="RemoveServer_Click"
Foreground="{DynamicResource ErrorBrush}"/>
<Separator/>
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
<MenuItem.Icon><TextBlock Text="&#x1F4CB;"/></MenuItem.Icon>
</MenuItem>
Expand Down Expand Up @@ -91,6 +98,7 @@
<Button Content="Edit..." Width="80" Height="30" Margin="0,0,8,0" Click="EditServer_Click"/>
<Button x:Name="ToggleFavoriteButton" Content="Toggle Favorite" Width="110" Height="30" Margin="0,0,8,0" Click="ToggleFavorite_Click"/>
<Button x:Name="CheckUpdatesButton" Content="Check Server Version" Width="140" Height="30" Margin="0,0,8,0" Click="CheckForUpdates_Click"/>
<Button Content="Purge Now" Width="100" Height="30" Margin="0,0,8,0" Click="PurgeNow_Click"/>
<Button Content="Remove" Width="80" Height="30" Margin="0,0,8,0" Click="RemoveServer_Click"
Foreground="{DynamicResource ErrorBrush}"/>
</StackPanel>
Expand Down
67 changes: 67 additions & 0 deletions Dashboard/ManageServersWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,73 @@ string Normalize(string v)
}
}

private async void PurgeNow_Click(object sender, RoutedEventArgs e)
{
if (ServersDataGrid.SelectedItem is not ServerConnection server)
{
MessageBox.Show(
"Please select a server to purge.",
"No Server Selected",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}

var dialog = new PurgeNowDialog(server.DisplayNameWithIntent) { Owner = this };
if (dialog.ShowDialog() != true) return;

Cursor = System.Windows.Input.Cursors.Wait;
try
{
var result = await _serverManager.RunDataRetentionAsync(
server,
dialog.RetentionDaysOverride);

bool wasTruncate = dialog.RetentionDaysOverride == 0;
string body;
if (wasTruncate)
{
body = $"All collector tables truncated.\n\n" +
$"Rows wiped: {result.RowsDeleted:N0}\n" +
$"Tables affected: {result.TableCount}\n" +
$"Status: {result.Status}\n" +
$"Duration: {result.DurationMs} ms";
}
else
{
body = $"Purge complete.\n\n" +
$"Rows deleted: {result.RowsDeleted:N0}\n" +
$"Tables touched: {result.TableCount}\n" +
$"Status: {result.Status}\n" +
$"Duration: {result.DurationMs} ms";
}

if (!string.Equals(result.Status, "SUCCESS", StringComparison.Ordinal) &&
!string.IsNullOrEmpty(result.Message))
{
body += $"\n\nMessage: {result.Message}";
}

MessageBox.Show(this, body, "Purge Complete",
MessageBoxButton.OK,
string.Equals(result.Status, "SUCCESS", StringComparison.Ordinal)
? MessageBoxImage.Information
: MessageBoxImage.Warning);
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Failed to run purge on '{server.DisplayNameWithIntent}':\n\n{ex.Message}",
"Purge Failed",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
finally
{
Cursor = null;
}
}

private void CopyCell_Click(object sender, RoutedEventArgs e)
{
if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
Expand Down
53 changes: 53 additions & 0 deletions Dashboard/PurgeNowDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<Window x:Class="PerformanceMonitorDashboard.PurgeNowDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Purge Now"
Height="320" Width="500"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Background="{DynamicResource BackgroundBrush}">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>

<TextBlock Grid.Row="0" x:Name="HeaderText" TextWrapping="Wrap" FontSize="13" FontWeight="Bold"
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,12"/>

<TextBlock Grid.Row="1" Text="Purge mode:" Margin="0,0,0,4"
Foreground="{DynamicResource ForegroundBrush}"/>

<ComboBox Grid.Row="2" x:Name="ModeComboBox" Height="28" Margin="0,0,0,12"
SelectionChanged="ModeComboBox_SelectionChanged">
<ComboBoxItem Content="Use configured retention" IsSelected="True" Tag="configured"/>
<ComboBoxItem Content="1 day" Tag="1"/>
<ComboBoxItem Content="3 days" Tag="3"/>
<ComboBoxItem Content="7 days" Tag="7"/>
<ComboBoxItem Content="Custom..." Tag="custom"/>
<ComboBoxItem x:Name="AllItem" Content="All — TRUNCATE everything" Tag="all"
Foreground="{DynamicResource ErrorBrush}"/>
</ComboBox>

<StackPanel Grid.Row="3" x:Name="CustomDaysPanel" Orientation="Horizontal" Margin="0,0,0,12"
Visibility="Collapsed">
<TextBlock Text="Retention (days):" VerticalAlignment="Center" Margin="0,0,8,0"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBox x:Name="CustomDaysBox" Width="80" Height="26" Padding="6,0,6,0"
VerticalContentAlignment="Center"
Text="14" PreviewTextInput="CustomDaysBox_PreviewTextInput"/>
</StackPanel>

<TextBlock Grid.Row="4" x:Name="WarningText" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource ErrorBrush}" Margin="0,4,0,8"/>

<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="PurgeButton" Content="Purge" Width="80" Height="28" Margin="0,0,8,0" Click="Purge_Click"/>
<Button Content="Cancel" Width="80" Height="28" Click="Cancel_Click" IsCancel="True"/>
</StackPanel>
</Grid>
</Window>
114 changes: 114 additions & 0 deletions Dashboard/PurgeNowDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

namespace PerformanceMonitorDashboard
{
public partial class PurgeNowDialog : Window
{
private readonly string _serverDisplayName;

/// <summary>
/// null = use configured retention.
/// 0 = TRUNCATE every collect.* table.
/// N > 0 = override every table's cutoff to N days.
/// </summary>
public int? RetentionDaysOverride { get; private set; }

public PurgeNowDialog(string serverDisplayName)
{
InitializeComponent();
_serverDisplayName = serverDisplayName;
HeaderText.Text = $"Purge collected data on '{serverDisplayName}'.";
UpdateWarningForMode("configured");
}

private void ModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
/* Fires once during InitializeComponent before other named elements exist; bail until window is loaded. */
if (CustomDaysPanel is null || WarningText is null) return;

if (ModeComboBox.SelectedItem is not ComboBoxItem item) return;

string tag = item.Tag as string ?? "configured";
CustomDaysPanel.Visibility = tag == "custom" ? Visibility.Visible : Visibility.Collapsed;
UpdateWarningForMode(tag);
}

private void UpdateWarningForMode(string tag)
{
WarningText.Text = tag switch
{
"configured" => "Deletes data older than the per-collector retention configured in config.collection_schedule. Cannot be undone.",
"all" => "WARNING: TRUNCATEs every collect.* table. Wipes ALL collected monitoring data on this server. Cannot be undone.",
"custom" => "Deletes data older than the specified number of days. Cannot be undone.",
_ => $"Deletes data older than {tag} day(s). Cannot be undone."
};
}

private void CustomDaysBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
e.Handled = !Regex.IsMatch(e.Text, "^[0-9]+$");
}

private void Purge_Click(object sender, RoutedEventArgs e)
{
if (ModeComboBox.SelectedItem is not ComboBoxItem item) return;

string tag = item.Tag as string ?? "configured";

switch (tag)
{
case "configured":
RetentionDaysOverride = null;
break;

case "1":
case "3":
case "7":
RetentionDaysOverride = int.Parse(tag);
break;

case "custom":
if (!int.TryParse(CustomDaysBox.Text, out int days) || days < 1)
{
MessageBox.Show(this,
"Enter a whole number of days greater than 0.",
"Invalid Retention",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
RetentionDaysOverride = days;
break;

case "all":
var confirm = MessageBox.Show(this,
$"This will TRUNCATE every collect.* table on '{_serverDisplayName}', wiping all monitoring data.\n\nAre you absolutely sure?",
"Confirm Purge All",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
RetentionDaysOverride = 0;
break;
}

DialogResult = true;
}

private void Cancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}
}
88 changes: 88 additions & 0 deletions Dashboard/Services/ServerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,94 @@ IF DB_ID('PerformanceMonitor') IS NOT NULL
Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'");
}

public class PurgeResult
{
public int RowsDeleted { get; set; }
public int TableCount { get; set; }
public int DurationMs { get; set; }
public string Status { get; set; } = "";
public string? Message { get; set; }
}

/// <summary>
/// Runs config.data_retention against the PerformanceMonitor database on the given server.
/// </summary>
/// <param name="retentionDaysOverride">
/// null = use per-collector retention from config.collection_schedule.
/// 0 = TRUNCATE every collect.* table.
/// N > 0 = override every table's cutoff to N days.
/// </param>
public async Task<PurgeResult> RunDataRetentionAsync(
ServerConnection server,
int? retentionDaysOverride)
{
var connectionString = server.GetConnectionString(_credentialService);
var builder = new SqlConnectionStringBuilder(connectionString)
{
InitialCatalog = "PerformanceMonitor",
ConnectTimeout = 10
};

using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();

using (var cmd = new SqlCommand("config.data_retention", connection))
{
cmd.CommandType = System.Data.CommandType.StoredProcedure;
cmd.CommandTimeout = 600;

if (retentionDaysOverride.HasValue)
{
cmd.Parameters.Add(new SqlParameter("@retention_days", System.Data.SqlDbType.Int) { Value = retentionDaysOverride.Value });
}

await cmd.ExecuteNonQueryAsync();
}

using var readCmd = new SqlCommand(@"
SELECT TOP (1)
cl.collection_status,
cl.rows_collected,
cl.duration_ms,
cl.error_message
FROM config.collection_log AS cl
WHERE cl.collector_name = N'data_retention'
ORDER BY cl.collection_time DESC;", connection);
readCmd.CommandTimeout = 30;

using var reader = await readCmd.ExecuteReaderAsync();
var result = new PurgeResult();

if (await reader.ReadAsync())
{
result.Status = reader.IsDBNull(0) ? "" : reader.GetString(0);
result.RowsDeleted = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
result.DurationMs = reader.IsDBNull(2) ? 0 : reader.GetInt32(2);
result.Message = reader.IsDBNull(3) ? null : reader.GetString(3);

if (result.Message is not null && result.Message.StartsWith("Cleaned ", StringComparison.Ordinal))
{
int spaceIdx = result.Message.IndexOf(' ', 8);
if (spaceIdx > 8 && int.TryParse(result.Message.AsSpan(8, spaceIdx - 8), out int tableCount))
{
result.TableCount = tableCount;
}
}
else if (result.Message is not null && result.Message.StartsWith("TRUNCATE all: ", StringComparison.Ordinal))
{
int spaceIdx = result.Message.IndexOf(' ', 14);
if (spaceIdx > 14 && int.TryParse(result.Message.AsSpan(14, spaceIdx - 14), out int tableCount))
{
result.TableCount = tableCount;
}
}
}

Logger.Info($"Ran data_retention on '{server.DisplayName}': status={result.Status}, rowsDeleted={result.RowsDeleted}, tables={result.TableCount}, durationMs={result.DurationMs}");

return result;
}

public void UpdateLastConnected(string id)
{
lock (_serversLock)
Expand Down
Loading
Loading