diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 440d6855..b5936c08 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -5,9 +5,11 @@ A clear description of the change and why it's being made.
## Which component(s) does this affect?
- [ ] Full Dashboard
-- [ ] Lite
+- [ ] Lite Dashboard
+- [ ] Lite Tests
- [ ] SQL collection scripts
-- [ ] Installer
+- [ ] CLI Installer
+- [ ] GUI Installer
- [ ] Documentation
## How was this tested?
diff --git a/.github/sql/ci_validate_installation.sql b/.github/sql/ci_validate_installation.sql
index 77187200..0761f80a 100644
--- a/.github/sql/ci_validate_installation.sql
+++ b/.github/sql/ci_validate_installation.sql
@@ -30,7 +30,7 @@ IF SCHEMA_ID(N'report') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: sche
PRINT '';
/*
-Procedures in collect schema (37)
+Procedures in collect schema (36)
*/
PRINT 'Checking collect procedures...';
@@ -65,7 +65,6 @@ IF OBJECT_ID(N'collect.tempdb_stats_collector', N'P') IS NULL BEGIN SE
IF OBJECT_ID(N'collect.plan_cache_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.plan_cache_stats_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.session_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_stats_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.waiting_tasks_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.waiting_tasks_collector'; END; SET @checked += 1;
-IF OBJECT_ID(N'collect.session_wait_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_wait_stats_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.server_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_configuration_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.database_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_configuration_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.configuration_issues_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.configuration_issues_analyzer'; END; SET @checked += 1;
@@ -142,7 +141,6 @@ IF OBJECT_ID(N'report.blocking_chain_analysis', N'V') IS NULL BEGIN
IF OBJECT_ID(N'report.tempdb_contention_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_contention_analysis'; END; SET @checked += 1;
IF OBJECT_ID(N'report.parameter_sensitivity_detection', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.parameter_sensitivity_detection'; END; SET @checked += 1;
IF OBJECT_ID(N'report.scheduler_cpu_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.scheduler_cpu_analysis'; END; SET @checked += 1;
-IF OBJECT_ID(N'report.session_wait_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.session_wait_analysis'; END; SET @checked += 1;
IF OBJECT_ID(N'report.critical_issues', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.critical_issues'; END; SET @checked += 1;
IF OBJECT_ID(N'report.memory_usage_trends', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_usage_trends'; END; SET @checked += 1;
IF OBJECT_ID(N'report.running_jobs', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.running_jobs'; END; SET @checked += 1;
@@ -182,7 +180,7 @@ WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'config';
PRINT ' collect schema tables: ' + CONVERT(varchar(10), @collect_tables);
PRINT ' config schema tables: ' + CONVERT(varchar(10), @config_tables);
-IF @collect_tables < 20 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 20 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1;
+IF @collect_tables < 19 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 20 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1;
IF @config_tables < 5 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 5 config tables, found ' + CONVERT(varchar(10), @config_tables); END; SET @checked += 1;
PRINT '';
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d33ee270..e7ba32d7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -47,7 +47,7 @@ jobs:
run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release
- name: Publish GUI Installer
- run: dotnet publish InstallerGui/InstallerGui.csproj -c Release -r win-x64 --self-contained
+ run: dotnet publish InstallerGui/InstallerGui.csproj -c Release
- name: Package release artifacts
if: github.event_name == 'release'
@@ -69,7 +69,7 @@ jobs:
New-Item -ItemType Directory -Force -Path "$instDir/upgrades"
Copy-Item 'Installer/bin/Release/net8.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir
- Copy-Item 'InstallerGui/bin/Release/net8.0-windows/win-x64/publish/InstallerGui.exe' $instDir -ErrorAction SilentlyContinue
+ Copy-Item 'InstallerGui/bin/Release/net8.0-windows/win-x64/publish/PerformanceMonitorInstallerGui.exe' $instDir -ErrorAction SilentlyContinue
Copy-Item 'install/*.sql' "$instDir/install/"
if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue }
if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f999e77..f10f250d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.0.0] - 2026-02-25
+
+### Important
+
+- **Schema upgrade**: The `collect.memory_grant_stats` table gains new delta columns and drops unused warning columns. The `collect.session_wait_stats` table, its collector procedure, reporting view, and schedule entry are removed (zero UI coverage). Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks.
+
+### Added
+
+- **Graphical query plan viewer** — native ShowPlan rendering in both Dashboard and Lite with SSMS-parity operator icons, properties panel, tooltips, warning/parallelism badges, and tabbed plan display ([#220])
+- **Actual execution plan support** — execute queries with SET STATISTICS XML ON to capture actual plans, with loading indicator and confirmation dialog ([#233])
+- **PlanAnalyzer** — automated plan analysis with rules for missing indexes, eager spools, key lookups, implicit conversions, memory grants, and more
+- **Current Active Queries live snapshot** — real-time view of running queries with estimated/live plan download ([#149])
+- **Memory clerks tab** in Lite with picker-driven chart ([#145])
+- **Current Waits charts** in Blocking tab for both Dashboard and Lite ([#280])
+- **File I/O throughput charts** — read/write throughput trends, file-level latency breakdown, queued I/O overlay ([#281])
+- **Memory grant stats charts** — standardized collection with delta framework integration and trend visualization ([#281])
+- **CPU scheduler pressure status** — real-time scheduler, worker, runnable task counts with color-coded pressure level below CPU chart
+- **Collection log drill-down** and daily summary in Lite ([#138])
+- **Collector duration trends chart** in Dashboard Collection Health ([#138])
+- **Themed perfmon counter packs** — 14 new counters with organized themed groups ([#255])
+- **User-configurable connection timeout** setting ([#236])
+- **Per-collector retention** — uses per-collector retention from `config.collection_schedule` in data retention ([#237])
+- **Query identifiers** in drill-down windows — query hash, plan hash, SQL handle visible for identification ([#268])
+- **Trace pattern drill-down** with missing columns and query text tooltips ([#273])
+- **Query Store Regressions drill-down** with TVF rewrite for performance ([#274])
+- **CLI `--help` flag** for installer ([#111])
+- Sort arrows, right-aligned numerics, and initial sort indicators across all grids ([#110])
+- Copyable plan viewer properties ([#269])
+- Standardized chart save/export filenames between Dashboard and Lite ([#284])
+- Full Dashboard column parity for query_stats, procedure_stats, and query_store_stats
+- Min/max extremes surfaced in both apps — physical reads, rows, grant KB, spills, CLR time, log bytes ([#281])
+
+### Changed
+
+- Query Store detection uses `sys.database_query_store_options` instead of `sys.databases.is_query_store_on` for Azure SQL DB compatibility ([#287])
+- Config tab consolidation, DB drop on server remove, DuckDB-first plan lookups, procedure stats parity
+- Collector health status now detects consecutive recent failures — 5+ consecutive errors = FAILING, 3+ = WARNING
+- Plan buttons now show a MessageBox when no plan is available instead of silently doing nothing
+- CSV export uses locale-appropriate separators for non-US locales ([#240])
+- Query Store Regressions and Query Trace Patterns migrated to popup grid filtering ([#260])
+- NuGet packages updated; xUnit v3 migration
+
+### Fixed
+
+- **DuckDB file corruption** during maintenance — ReaderWriterLockSlim coordination, archive-all-and-reset at 512MB replaces compaction ([#218])
+- Archive view column mismatch, wait_stats thread-safety, and percent_complete type cast ([#234])
+- Collector health status bar text color ([#234])
+- View Plan for Query Store and Query Store Regressions tabs ([#261])
+- Query Store drill-down time filter alignment with main view ([#263])
+- Execution count mismatches between main views and drill-downs
+- Drill-down chart UX — sparse data markers, hover tooltips, window sizing ([#271])
+- Truncated status text in Add Server dialog ([#257])
+- Scrollbar visibility, self-filtering artifacts, missing columns, and context menus ([#245], [#246], [#247], [#248])
+- query_stats and procedure_stats collectors ignoring recent queries
+- Blank tooltips on warning and parallel badge icons
+- Missing chart context menu on File I/O Throughput charts in Lite
+
+### Removed
+
+- `collect.session_wait_stats` table, `collect.session_wait_stats_collector` procedure, `report.session_wait_analysis` view, and schedule entry — zero UI coverage, never surfaced in Dashboard or Lite ([#281])
+
## [1.3.0] - 2026-02-20
### Important
@@ -119,6 +180,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Delta normalization for per-second rate calculations
- Dark theme UI
+[2.0.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.3.0...v2.0.0
[1.3.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.0.0...v1.1.0
@@ -187,3 +249,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#206]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/206
[#210]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/210
[#214]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/214
+[#218]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/218
+[#220]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/220
+[#233]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/233
+[#234]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/234
+[#236]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/236
+[#237]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/237
+[#240]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/240
+[#245]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/245
+[#246]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/246
+[#247]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/247
+[#248]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/248
+[#255]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/255
+[#257]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/257
+[#260]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/260
+[#261]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/261
+[#263]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/263
+[#268]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/268
+[#269]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/269
+[#271]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/271
+[#273]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/273
+[#274]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/274
+[#280]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/280
+[#281]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/281
+[#284]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/284
+[#287]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/287
diff --git a/Dashboard/CollectionLogWindow.xaml b/Dashboard/CollectionLogWindow.xaml
index 7fb2e45a..32a75576 100644
--- a/Dashboard/CollectionLogWindow.xaml
+++ b/Dashboard/CollectionLogWindow.xaml
@@ -6,6 +6,15 @@
Title="Collection History" Height="600" Width="1000"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource BackgroundBrush}">
+
+
+
+
+
+
+
+
+
@@ -28,7 +37,8 @@
CanUserSortColumns="True"
AlternatingRowBackground="{DynamicResource BackgroundLightBrush}"
GridLinesVisibility="All"
- Margin="10">
+ Margin="10"
+ ContextMenu="{StaticResource DataGridContextMenu}">
@@ -46,7 +56,7 @@
-
+
@@ -54,7 +64,7 @@
-
+
diff --git a/Dashboard/CollectionLogWindow.xaml.cs b/Dashboard/CollectionLogWindow.xaml.cs
index 4ebd6e6f..85076ead 100644
--- a/Dashboard/CollectionLogWindow.xaml.cs
+++ b/Dashboard/CollectionLogWindow.xaml.cs
@@ -189,6 +189,80 @@ private void UpdateLogFilterButtonStyles()
}
}
+ private void CopyCell_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.CurrentCell.Item != null)
+ {
+ var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell);
+ if (!string.IsNullOrEmpty(cellContent))
+ Clipboard.SetDataObject(cellContent, false);
+ }
+ }
+ }
+
+ private void CopyRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem != null)
+ Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false);
+ }
+ }
+
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var sb = new System.Text.StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ headers.Add(Helpers.DataGridClipboardBehavior.GetHeaderText(column));
+ sb.AppendLine(string.Join("\t", headers));
+ foreach (var item in dataGrid.Items)
+ sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item));
+ Clipboard.SetDataObject(sb.ToString(), false);
+ }
+ }
+ }
+
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = $"collection_log_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
+ DefaultExt = ".csv",
+ Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*"
+ };
+ if (dialog.ShowDialog() == true)
+ {
+ var sb = new System.Text.StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ headers.Add(Helpers.TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(column)));
+ sb.AppendLine(string.Join(",", headers));
+ foreach (var item in dataGrid.Items)
+ {
+ var values = Helpers.TabHelpers.GetRowValues(dataGrid, item);
+ sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v))));
+ }
+ System.IO.File.WriteAllText(dialog.FileName, sb.ToString());
+ }
+ }
+ }
+ }
+
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
diff --git a/Dashboard/CollectorScheduleWindow.xaml b/Dashboard/CollectorScheduleWindow.xaml
index ee370e21..b2a8103e 100644
--- a/Dashboard/CollectorScheduleWindow.xaml
+++ b/Dashboard/CollectorScheduleWindow.xaml
@@ -6,6 +6,15 @@
WindowStartupLocation="CenterOwner"
ResizeMode="CanResizeWithGrip"
Background="{DynamicResource BackgroundBrush}">
+
+
+
+
+
+
+
+
+
@@ -34,7 +43,8 @@
Background="{DynamicResource BackgroundBrush}"
RowBackground="{DynamicResource BackgroundBrush}"
AlternatingRowBackground="{DynamicResource BackgroundLightBrush}"
- BorderThickness="0">
+ BorderThickness="0"
+ ContextMenu="{StaticResource DataGridContextMenu}">
diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/Dashboard/CollectorScheduleWindow.xaml.cs
index 1eb6aa92..4d1b126e 100644
--- a/Dashboard/CollectorScheduleWindow.xaml.cs
+++ b/Dashboard/CollectorScheduleWindow.xaml.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
+using System.Linq;
using System.Windows;
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Services;
@@ -115,6 +116,80 @@ private async void Refresh_Click(object sender, RoutedEventArgs e)
await LoadSchedulesAsync();
}
+ private void CopyCell_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.CurrentCell.Item != null)
+ {
+ var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell);
+ if (!string.IsNullOrEmpty(cellContent))
+ Clipboard.SetDataObject(cellContent, false);
+ }
+ }
+ }
+
+ private void CopyRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem != null)
+ Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false);
+ }
+ }
+
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var sb = new System.Text.StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ headers.Add(Helpers.DataGridClipboardBehavior.GetHeaderText(column));
+ sb.AppendLine(string.Join("\t", headers));
+ foreach (var item in dataGrid.Items)
+ sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item));
+ Clipboard.SetDataObject(sb.ToString(), false);
+ }
+ }
+ }
+
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu)
+ {
+ var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = $"collector_schedules_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
+ DefaultExt = ".csv",
+ Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*"
+ };
+ if (dialog.ShowDialog() == true)
+ {
+ var sb = new System.Text.StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ headers.Add(Helpers.TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(column)));
+ sb.AppendLine(string.Join(",", headers));
+ foreach (var item in dataGrid.Items)
+ {
+ var values = Helpers.TabHelpers.GetRowValues(dataGrid, item);
+ sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v))));
+ }
+ System.IO.File.WriteAllText(dialog.FileName, sb.ToString());
+ }
+ }
+ }
+ }
+
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
index 132cb1d8..22b9ed79 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
@@ -373,7 +373,7 @@ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
var sb = new StringBuilder();
var headers = dataGrid.Columns
.OfType()
- .Select(c => TabHelpers.GetColumnHeader(c))
+ .Select(c => Helpers.DataGridClipboardBehavior.GetHeaderText(c))
.ToList();
sb.AppendLine(string.Join("\t", headers));
@@ -404,16 +404,17 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
try
{
var sb = new StringBuilder();
+ var sep = TabHelpers.CsvSeparator;
var headers = dataGrid.Columns
.OfType()
- .Select(c => TabHelpers.EscapeCsvField(TabHelpers.GetColumnHeader(c)))
+ .Select(c => TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(c), sep))
.ToList();
- sb.AppendLine(string.Join(",", headers));
+ sb.AppendLine(string.Join(sep, headers));
foreach (var item in dataGrid.Items)
{
var values = TabHelpers.GetRowValues(dataGrid, item);
- sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v))));
+ sb.AppendLine(string.Join(sep, values.Select(v => TabHelpers.EscapeCsvField(v, sep))));
}
File.WriteAllText(saveFileDialog.FileName, sb.ToString());
diff --git a/Dashboard/Controls/ConfigChangesContent.xaml b/Dashboard/Controls/ConfigChangesContent.xaml
new file mode 100644
index 00000000..a0aea1a6
--- /dev/null
+++ b/Dashboard/Controls/ConfigChangesContent.xaml
@@ -0,0 +1,294 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Controls/ConfigChangesContent.xaml.cs b/Dashboard/Controls/ConfigChangesContent.xaml.cs
new file mode 100644
index 00000000..732af2b0
--- /dev/null
+++ b/Dashboard/Controls/ConfigChangesContent.xaml.cs
@@ -0,0 +1,462 @@
+/*
+ * 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;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services;
+
+namespace PerformanceMonitorDashboard.Controls
+{
+ public partial class ConfigChangesContent : UserControl
+ {
+ private DatabaseService? _databaseService;
+
+ private int _hoursBack = 24;
+ private DateTime? _fromDate;
+ private DateTime? _toDate;
+
+ // Popup filter state
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+ private string? _activeFilterGrid;
+
+ private readonly Dictionary _serverConfigChangesFilters = new();
+ private List? _serverConfigChangesUnfilteredData;
+
+ private readonly Dictionary _dbConfigChangesFilters = new();
+ private List? _dbConfigChangesUnfilteredData;
+
+ private readonly Dictionary _traceFlagChangesFilters = new();
+ private List? _traceFlagChangesUnfilteredData;
+
+ public ConfigChangesContent()
+ {
+ InitializeComponent();
+ }
+
+ public void Initialize(DatabaseService databaseService)
+ {
+ _databaseService = databaseService;
+ }
+
+ public void SetTimeRange(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ _hoursBack = hoursBack;
+ _fromDate = fromDate;
+ _toDate = toDate;
+ }
+
+ public async Task RefreshAllDataAsync()
+ {
+ if (_databaseService == null) return;
+
+ await Task.WhenAll(
+ RefreshServerConfigChangesAsync(),
+ RefreshDbConfigChangesAsync(),
+ RefreshTraceFlagChangesAsync()
+ );
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ TabHelpers.AutoSizeColumnMinWidths(ServerConfigChangesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(DatabaseConfigChangesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(TraceFlagChangesDataGrid);
+ TabHelpers.FreezeColumns(ServerConfigChangesDataGrid, 1);
+ TabHelpers.FreezeColumns(DatabaseConfigChangesDataGrid, 2);
+ TabHelpers.FreezeColumns(TraceFlagChangesDataGrid, 1);
+ }
+
+ #region Server Configuration Changes
+
+ private async Task RefreshServerConfigChangesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetServerConfigChangesAsync(_hoursBack, _fromDate, _toDate);
+ _serverConfigChangesUnfilteredData = data;
+ _serverConfigChangesFilters.Clear();
+ ServerConfigChangesDataGrid.ItemsSource = data;
+ ServerConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ UpdateFilterButtonStyles(ServerConfigChangesDataGrid, _serverConfigChangesFilters);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading server configuration changes: {ex.Message}");
+ }
+ }
+
+ private void ServerConfigChangesBoolFilter_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ ApplyServerConfigChangesFilters();
+ }
+
+ private void ApplyServerConfigChangesFilters()
+ {
+ if (_serverConfigChangesUnfilteredData == null) return;
+
+ var restartFilter = (ServerConfigChangesRestartFilterCombo?.SelectedItem as ComboBoxItem)?.Content?.ToString();
+ var dynamicFilter = (ServerConfigChangesDynamicFilterCombo?.SelectedItem as ComboBoxItem)?.Content?.ToString();
+ var advancedFilter = (ServerConfigChangesAdvancedFilterCombo?.SelectedItem as ComboBoxItem)?.Content?.ToString();
+
+ if (_serverConfigChangesFilters.Count == 0 && restartFilter == "All" && dynamicFilter == "All" && advancedFilter == "All")
+ {
+ ServerConfigChangesDataGrid.ItemsSource = _serverConfigChangesUnfilteredData;
+ return;
+ }
+
+ var filteredData = _serverConfigChangesUnfilteredData.Where(item =>
+ {
+ foreach (var filter in _serverConfigChangesFilters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
+ return false;
+ }
+
+ if (restartFilter != "All" && restartFilter != null)
+ {
+ bool expected = restartFilter == "True";
+ if (item.RequiresRestart != expected) return false;
+ }
+ if (dynamicFilter != "All" && dynamicFilter != null)
+ {
+ bool expected = dynamicFilter == "True";
+ if (item.IsDynamic != expected) return false;
+ }
+ if (advancedFilter != "All" && advancedFilter != null)
+ {
+ bool expected = advancedFilter == "True";
+ if (item.IsAdvanced != expected) return false;
+ }
+
+ return true;
+ }).ToList();
+
+ ServerConfigChangesDataGrid.ItemsSource = filteredData;
+ }
+
+ #endregion
+
+ #region Database Configuration Changes
+
+ private async Task RefreshDbConfigChangesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetDatabaseConfigChangesAsync(_hoursBack, _fromDate, _toDate);
+ _dbConfigChangesUnfilteredData = data;
+ _dbConfigChangesFilters.Clear();
+ DatabaseConfigChangesDataGrid.ItemsSource = data;
+ DatabaseConfigChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ UpdateFilterButtonStyles(DatabaseConfigChangesDataGrid, _dbConfigChangesFilters);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading database configuration changes: {ex.Message}");
+ }
+ }
+
+ private void ApplyDbConfigChangesFilters()
+ {
+ if (_dbConfigChangesUnfilteredData == null) return;
+
+ if (_dbConfigChangesFilters.Count == 0)
+ {
+ DatabaseConfigChangesDataGrid.ItemsSource = _dbConfigChangesUnfilteredData;
+ return;
+ }
+
+ var filteredData = _dbConfigChangesUnfilteredData.Where(item =>
+ {
+ foreach (var filter in _dbConfigChangesFilters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
+ return false;
+ }
+ return true;
+ }).ToList();
+
+ DatabaseConfigChangesDataGrid.ItemsSource = filteredData;
+ }
+
+ #endregion
+
+ #region Trace Flag Changes
+
+ private async Task RefreshTraceFlagChangesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetTraceFlagChangesAsync(_hoursBack, _fromDate, _toDate);
+ _traceFlagChangesUnfilteredData = data;
+ _traceFlagChangesFilters.Clear();
+ TraceFlagChangesDataGrid.ItemsSource = data;
+ TraceFlagChangesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ UpdateFilterButtonStyles(TraceFlagChangesDataGrid, _traceFlagChangesFilters);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading trace flag changes: {ex.Message}");
+ }
+ }
+
+ private void TraceFlagChangesBoolFilter_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ ApplyTraceFlagChangesFilters();
+ }
+
+ private void ApplyTraceFlagChangesFilters()
+ {
+ if (_traceFlagChangesUnfilteredData == null) return;
+
+ var globalFilter = (TraceFlagChangesGlobalFilterCombo?.SelectedItem as ComboBoxItem)?.Content?.ToString();
+ var sessionFilter = (TraceFlagChangesSessionFilterCombo?.SelectedItem as ComboBoxItem)?.Content?.ToString();
+
+ if (_traceFlagChangesFilters.Count == 0 && globalFilter == "All" && sessionFilter == "All")
+ {
+ TraceFlagChangesDataGrid.ItemsSource = _traceFlagChangesUnfilteredData;
+ return;
+ }
+
+ var filteredData = _traceFlagChangesUnfilteredData.Where(item =>
+ {
+ foreach (var filter in _traceFlagChangesFilters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
+ return false;
+ }
+
+ if (globalFilter != "All" && globalFilter != null)
+ {
+ bool expected = globalFilter == "True";
+ if (item.IsGlobal != expected) return false;
+ }
+ if (sessionFilter != "All" && sessionFilter != null)
+ {
+ bool expected = sessionFilter == "True";
+ if (item.IsSession != expected) return false;
+ }
+
+ return true;
+ }).ToList();
+
+ TraceFlagChangesDataGrid.ItemsSource = filteredData;
+ }
+
+ #endregion
+
+ #region Popup Filter Infrastructure
+
+ private void ServerConfigChangesFilter_Click(object sender, RoutedEventArgs e)
+ {
+ _activeFilterGrid = "ServerConfigChanges";
+ if (sender is Button button && button.Tag is string columnName)
+ ShowFilterPopup(button, columnName, _serverConfigChangesFilters);
+ }
+
+ private void DbConfigChangesFilter_Click(object sender, RoutedEventArgs e)
+ {
+ _activeFilterGrid = "DbConfigChanges";
+ if (sender is Button button && button.Tag is string columnName)
+ ShowFilterPopup(button, columnName, _dbConfigChangesFilters);
+ }
+
+ private void TraceFlagChangesFilter_Click(object sender, RoutedEventArgs e)
+ {
+ _activeFilterGrid = "TraceFlagChanges";
+ if (sender is Button button && button.Tag is string columnName)
+ ShowFilterPopup(button, columnName, _traceFlagChangesFilters);
+ }
+
+ private void ShowFilterPopup(Button button, string columnName, Dictionary filters)
+ {
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+
+ filters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent!.Initialize(columnName, existingFilter);
+ _filterPopup.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+
+ switch (_activeFilterGrid)
+ {
+ case "ServerConfigChanges":
+ if (e.FilterState.IsActive)
+ _serverConfigChangesFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _serverConfigChangesFilters.Remove(e.FilterState.ColumnName);
+ ApplyServerConfigChangesFilters();
+ UpdateFilterButtonStyles(ServerConfigChangesDataGrid, _serverConfigChangesFilters);
+ break;
+
+ case "DbConfigChanges":
+ if (e.FilterState.IsActive)
+ _dbConfigChangesFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _dbConfigChangesFilters.Remove(e.FilterState.ColumnName);
+ ApplyDbConfigChangesFilters();
+ UpdateFilterButtonStyles(DatabaseConfigChangesDataGrid, _dbConfigChangesFilters);
+ break;
+
+ case "TraceFlagChanges":
+ if (e.FilterState.IsActive)
+ _traceFlagChangesFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _traceFlagChangesFilters.Remove(e.FilterState.ColumnName);
+ ApplyTraceFlagChangesFilters();
+ UpdateFilterButtonStyles(TraceFlagChangesDataGrid, _traceFlagChangesFilters);
+ break;
+ }
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+ }
+
+ private void UpdateFilterButtonStyles(DataGrid grid, Dictionary filters)
+ {
+ foreach (var column in grid.Columns)
+ {
+ if (column.Header is StackPanel stackPanel)
+ {
+ var filterButton = stackPanel.Children.OfType