diff --git a/.coderabbit.yaml b/.coderabbit.yaml
deleted file mode 100644
index 67c7ecc2..00000000
--- a/.coderabbit.yaml
+++ /dev/null
@@ -1,67 +0,0 @@
-# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
-
-language: "en-US"
-early_access: false
-enable_free_tier: true
-
-reviews:
- profile: "chill"
- high_level_summary: true
- review_status: true
- commit_status: true
- collapse_walkthrough: true
- sequence_diagrams: false
- poem: false
-
- path_filters:
- - "!**/*.Designer.cs"
- - "!**/bin/**"
- - "!**/obj/**"
- - "!**/publish/**"
- - "!**/*.user"
- - "!**/*.suo"
-
- path_instructions:
- - path: "Dashboard/**/*.cs"
- instructions: >
- This is a WPF .NET 8 desktop app (Dashboard) that reads from SQL Server.
- Uses data binding, async/await patterns, and INotifyPropertyChanged.
- Watch for: null reference risks, disposal of SQL connections,
- thread safety with UI dispatch, and proper async patterns.
- - path: "Lite/**/*.cs"
- instructions: >
- This is a WPF .NET 8 desktop app (Lite) that collects SQL Server DMV data
- into a local DuckDB database. Uses ReaderWriterLockSlim for DB coordination.
- Watch for: connection disposal, thread safety, DuckDB access patterns,
- and proper async/await usage.
- - path: "**/*.sql"
- instructions: >
- T-SQL stored procedures and scripts for SQL Server.
- Watch for: SQL injection risks, missing error handling (TRY/CATCH),
- proper use of SET NOCOUNT ON, and parameter sniffing concerns.
- - path: "Installers/**"
- instructions: >
- WiX-based MSI installer projects. Be cautious about upgrade paths
- and file versioning. Schema upgrades go in upgrades/ folder, not install scripts.
-
- auto_review:
- enabled: true
- drafts: false
- base_branches:
- - "dev"
- - "main"
-
- tools:
- gitleaks:
- enabled: true
- github-checks:
- enabled: true
-
-chat:
- auto_reply: true
-
-knowledge_base:
- learnings:
- scope: "local"
- pull_requests:
- scope: "local"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 408089fe..92d06a69 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,9 +42,17 @@ jobs:
- name: Publish Dashboard
run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard
+ - name: Publish Dashboard (self-contained for Velopack)
+ if: github.event_name == 'release'
+ run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack
+
- name: Publish Lite
run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite
+ - name: Publish Lite (self-contained for Velopack)
+ if: github.event_name == 'release'
+ run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack
+
- name: Publish CLI Installer
run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release
@@ -156,6 +164,25 @@ jobs:
Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue
Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force
+ - name: Create Velopack release (Dashboard)
+ if: github.event_name == 'release'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ dotnet tool install -g vpk
+ New-Item -ItemType Directory -Force -Path releases/velopack-dashboard
+ New-Item -ItemType Directory -Force -Path releases/velopack-lite
+
+ # Dashboard: download previous + pack
+ vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN
+ vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard
+
+ # Lite: download previous + pack
+ vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN
+ vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite
+
- name: Generate checksums
if: github.event_name == 'release'
shell: pwsh
@@ -174,3 +201,13 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber
+
+ - name: Upload Velopack artifacts
+ if: github.event_name == 'release'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
+ vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42d7bf2c..1f8b28fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,64 @@ 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.4.0] - 2026-03-23
+
+### Important
+
+- **Lite data directory moved**: Lite now stores all data (config, DuckDB, archives, logs) in `%LOCALAPPDATA%\PerformanceMonitorLite\` instead of alongside the executable. This enables auto-update support. Existing users upgrading from the zip should use **Import Settings** and **Import Data** to bring over their configuration and historical data from the old install folder.
+- **Auto-update (Windows)**: Both Dashboard and Lite now include Velopack auto-update. Users who install via the new Setup.exe will receive update notifications and can download + apply updates from within the app. Existing zip distribution continues to work as before.
+
+### Added
+
+- **Velopack auto-update** for Dashboard and Lite — check on startup, download + apply from About window with confirmation dialog before restart ([#635])
+- **Per-tab time range slicers** on Dashboard and Lite query tabs — filter data directly on each tab without changing global time range ([#655], [#662])
+- **Time display picker** (Local/UTC/Server) in Dashboard and Lite toolbars ([#646])
+- **Import Settings** — renamed from "Import Connections", now also copies `settings.json`, `collection_schedule.json`, `ignored_wait_types.json`, and `alert_state.json` from a previous install
+- **Alert muting improvements** — pre-fill context fields (database, query, wait type, job name) from alert detail text, configurable default expiration for new mute rules, tooltip on query text field ([#642])
+- **Missing date columns** on Query Stats and Procedure Stats tabs (`creation_time`, `last_execution_time`) ([#649], [#651], [#654])
+- **Trace pattern drill-down** now includes `CollectionTime` and `NtUserName` columns ([#663])
+- **DataGrid sort preservation** across auto-refresh — sort order no longer resets when data refreshes ([#659])
+- **CLI installer**: colored output (green/red/yellow) and version check on startup ([#639])
+- **GUI installer**: version check on startup
+- **Growth rate and VLF count** columns in Database Sizes (from v2.3.0 nightly, now in upgrade path) ([#567])
+- `llms.txt` and `CITATION.cff` for project discoverability ([#630])
+
+### Changed
+
+- **Lite data directory** moved to `%LOCALAPPDATA%\PerformanceMonitorLite\` for Velopack compatibility
+- **Delta gap detection** added to all cumulative-counter collectors (file I/O, wait stats, query stats, procedure stats, memory grants) — prevents inflated spikes after app restart ([#633])
+- **File I/O NULL fallbacks** improved when `sys.master_files` is inaccessible — falls back to `DB_NAME()` and `File_{id}` instead of generic "Unknown" ([#633])
+- **Running jobs collector** skipped gracefully when login lacks msdb access ([#656])
+- NuGet packages updated to latest minor versions ([#653])
+
+### Fixed
+
+- **Installer writing SUCCESS when files fail** — CLI tolerated 1 failure in automated mode, GUI had a similar workaround. Now any failure = not success.
+- **Query stats collector causing SQL dumps** on passive mirror servers — removed `dm_exec_plan_attributes` CROSS APPLY, uses temp table of ONLINE database IDs instead ([#632])
+- **Trigger name extraction** fails when comment before `CREATE TRIGGER` contains " ON " ([#666])
+- **FinOps expensive queries** DuckDB error — query referenced `statement_start_offset` column that doesn't exist in schema
+- **Imported parquet files** not recognized by archive compaction — added regex patterns for `imported_` prefix
+- **Auto-refresh after Import Data** — views now refresh immediately after import completes
+
+[#630]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/630
+[#632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/632
+[#633]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/633
+[#635]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/635
+[#639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/639
+[#642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/642
+[#646]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/646
+[#649]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/649
+[#651]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/651
+[#653]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/653
+[#654]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/654
+[#655]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/655
+[#656]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/656
+[#659]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/659
+[#662]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/662
+[#663]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/663
+[#666]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/666
+[#567]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/567
+
## [2.3.0] - 2026-03-18
### Important
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 00000000..53f532fd
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,22 @@
+cff-version: 1.2.0
+title: "SQL Server Performance Monitor"
+message: "If you use this software, please cite it as below."
+type: software
+authors:
+ - given-names: Erik
+ family-names: Darling
+ affiliation: "Darling Data, LLC"
+ website: "https://erikdarling.com"
+repository-code: "https://github.com/erikdarlingdata/PerformanceMonitor"
+url: "https://erikdarling.com/free-sql-server-performance-monitoring/"
+license: MIT
+version: "2.3.0"
+date-released: "2026-03-18"
+keywords:
+ - sql-server
+ - performance-monitoring
+ - database-monitoring
+ - dba-tools
+ - mcp-server
+ - wait-stats
+ - execution-plans
diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs
index 0e61086e..19cd7a8b 100644
--- a/Dashboard/AboutWindow.xaml.cs
+++ b/Dashboard/AboutWindow.xaml.cs
@@ -8,6 +8,8 @@
using System.Reflection;
using System.Windows;
using PerformanceMonitorDashboard.Services;
+using Velopack;
+using System;
namespace PerformanceMonitorDashboard
{
@@ -19,6 +21,8 @@ public partial class AboutWindow : Window
private const string DarlingDataUrl = "https://www.erikdarling.com";
private string? _updateReleaseUrl;
+ private UpdateManager? _velopackMgr;
+ private Velopack.UpdateInfo? _pendingUpdate;
public AboutWindow()
{
@@ -47,6 +51,32 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
UpdateStatusText.Text = "Checking for updates...";
UpdateStatusText.Visibility = Visibility.Visible;
+ // Try Velopack first (supports download + apply)
+ try
+ {
+ _velopackMgr = new UpdateManager(
+ new Velopack.Sources.GithubSource("https://github.com/erikdarlingdata/PerformanceMonitor", null, false));
+
+ var updateInfo = await _velopackMgr.CheckForUpdatesAsync();
+ if (updateInfo != null)
+ {
+ _pendingUpdate = updateInfo;
+ UpdateStatusText.Text = $"Update available: v{updateInfo.TargetFullRelease.Version} — click to install";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp -= UpdateStatusText_Click;
+ UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
+ ?? System.Windows.Media.Brushes.DodgerBlue;
+ return;
+ }
+ }
+ catch
+ {
+ // Velopack packages may not exist yet — fall through to legacy check
+ }
+
+ // Fallback: GitHub Releases API check (opens browser)
var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true);
if (result == null)
@@ -56,8 +86,9 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
else if (result.IsUpdateAvailable)
{
_updateReleaseUrl = result.ReleaseUrl;
- UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})";
+ UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion}) — click to open releases";
UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click;
UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click;
UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
@@ -69,6 +100,50 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
}
}
+ private bool _updateDownloaded;
+
+ private async void VelopackDownload_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (_velopackMgr == null || _pendingUpdate == null) return;
+
+ // Step 3: restart with confirmation
+ if (_updateDownloaded)
+ {
+ var result = MessageBox.Show(this,
+ "The application will close and restart with the new version. Continue?",
+ "Update Ready",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.OK)
+ {
+ _velopackMgr.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease);
+ }
+ return;
+ }
+
+ // Step 2: download
+ try
+ {
+ UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click;
+ UpdateStatusText.TextDecorations = null;
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Arrow;
+ UpdateStatusText.Text = "Downloading update...";
+
+ await _velopackMgr.DownloadUpdatesAsync(_pendingUpdate);
+
+ _updateDownloaded = true;
+ UpdateStatusText.Text = "Update downloaded.";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click;
+ }
+ catch (Exception ex)
+ {
+ UpdateStatusText.Text = $"Download failed: {ex.Message}";
+ }
+ }
+
private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (!string.IsNullOrEmpty(_updateReleaseUrl))
diff --git a/Dashboard/Analysis/FactScorer.cs b/Dashboard/Analysis/FactScorer.cs
index 82382989..78347953 100644
--- a/Dashboard/Analysis/FactScorer.cs
+++ b/Dashboard/Analysis/FactScorer.cs
@@ -308,8 +308,10 @@ private static double ScoreBadActorFact(Fact fact)
///
private static double ScoreAnomalyFact(Fact fact)
{
- if (fact.Key.StartsWith("ANOMALY_CPU_SPIKE") || fact.Key.StartsWith("ANOMALY_READ_LATENCY")
- || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY"))
+ if ( fact.Key.StartsWith("ANOMALY_CPU_SPIKE" , StringComparison.OrdinalIgnoreCase)
+ || fact.Key.StartsWith("ANOMALY_READ_LATENCY" , StringComparison.OrdinalIgnoreCase)
+ || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY", StringComparison.OrdinalIgnoreCase)
+ )
{
// Deviation-based scoring: 2σ = 0.5, 4σ = 1.0
var deviation = fact.Metadata.GetValueOrDefault("deviation_sigma");
@@ -319,7 +321,7 @@ private static double ScoreAnomalyFact(Fact fact)
return base_score * confidence;
}
- if (fact.Key.StartsWith("ANOMALY_WAIT_"))
+ if (fact.Key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase))
{
// Ratio-based scoring: 5x = 0.5, 20x = 1.0
var ratio = fact.Metadata.GetValueOrDefault("ratio");
@@ -327,7 +329,9 @@ private static double ScoreAnomalyFact(Fact fact)
return 0.5 + 0.5 * Math.Min((ratio - 5.0) / 15.0, 1.0);
}
- if (fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE") || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE"))
+ if ( fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE", StringComparison.OrdinalIgnoreCase)
+ || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE", StringComparison.OrdinalIgnoreCase)
+ )
{
// Ratio-based: 3x = 0.5, 10x = 1.0
var ratio = fact.Metadata.GetValueOrDefault("ratio");
@@ -859,7 +863,7 @@ private static (double concerning, double? critical)? GetWaitThresholds(string w
///
/// An amplifier definition: a named predicate that boosts severity when matched.
///
-internal class AmplifierDefinition
+internal sealed class AmplifierDefinition
{
public string Description { get; set; } = string.Empty;
public double Boost { get; set; }
diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.cs b/Dashboard/Analysis/SqlServerDrillDownCollector.cs
index 050ffe30..cf435978 100644
--- a/Dashboard/Analysis/SqlServerDrillDownCollector.cs
+++ b/Dashboard/Analysis/SqlServerDrillDownCollector.cs
@@ -62,10 +62,15 @@ public async Task EnrichFindingsAsync(List findings, AnalysisCo
if (pathKeys.Contains("QUERY_SPILLS"))
await CollectTopSpillingQueries(finding, context);
- if (pathKeys.Contains("IO_READ_LATENCY_MS") || pathKeys.Contains("IO_WRITE_LATENCY_MS"))
+ if ( pathKeys.Contains("IO_READ_LATENCY_MS")
+ || pathKeys.Contains("IO_WRITE_LATENCY_MS")
+ )
await CollectFileLatencyBreakdown(finding, context);
- if (pathKeys.Contains("LCK") || pathKeys.Contains("LCK_M_S") || pathKeys.Contains("LCK_M_IS"))
+ if ( pathKeys.Contains("LCK")
+ || pathKeys.Contains("LCK_M_S")
+ || pathKeys.Contains("LCK_M_IS")
+ )
await CollectLockModeBreakdown(finding, context);
if (pathKeys.Contains("DB_CONFIG"))
@@ -77,7 +82,7 @@ public async Task EnrichFindingsAsync(List findings, AnalysisCo
if (pathKeys.Contains("MEMORY_GRANT_PENDING"))
await CollectPendingGrants(finding, context);
- if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_")))
+ if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)))
await CollectBadActorDetail(finding, context);
// Plan analysis: for findings with top queries, analyze their cached plans
@@ -618,7 +623,7 @@ private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext
// Only analyze plans for bad actor findings (1 plan each).
// Skip top_cpu_queries (5 plans would be too heavy).
- if (!finding.RootFactKey.StartsWith("BAD_ACTOR_")) return;
+ if (!finding.RootFactKey.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) return;
var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", "");
if (string.IsNullOrEmpty(queryHash)) return;
diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs
index a99d9aa1..446e4277 100644
--- a/Dashboard/Analysis/SqlServerFactCollector.cs
+++ b/Dashboard/Analysis/SqlServerFactCollector.cs
@@ -1671,15 +1671,15 @@ private static void GroupParallelismWaits(List facts, AnalysisContext cont
///
private static bool IsGeneralLockWait(string waitType)
{
- if (!waitType.StartsWith("LCK_M_")) return false;
+ if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false;
// Keep individual: reader/writer locks
if (waitType is "LCK_M_S" or "LCK_M_IS") return false;
// Keep individual: range locks (serializable/repeatable read)
- if (waitType.StartsWith("LCK_M_RS_") ||
- waitType.StartsWith("LCK_M_RIn_") ||
- waitType.StartsWith("LCK_M_RX_")) return false;
+ if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) ||
+ waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) ||
+ waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false;
// Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) -> group
return true;
diff --git a/Dashboard/App.xaml b/Dashboard/App.xaml
index c8b1ea46..f3576c29 100644
--- a/Dashboard/App.xaml
+++ b/Dashboard/App.xaml
@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="clr-namespace:PerformanceMonitorDashboard.Helpers"
- StartupUri="MainWindow.xaml">
+ >
diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs
index dfc546d8..738cd06c 100644
--- a/Dashboard/App.xaml.cs
+++ b/Dashboard/App.xaml.cs
@@ -14,6 +14,7 @@
using System.Windows.Markup;
using System.Windows.Threading;
using PerformanceMonitorDashboard.Helpers;
+using Velopack;
namespace PerformanceMonitorDashboard
{
@@ -59,6 +60,10 @@ protected override void OnStartup(StartupEventArgs e)
Logger.Info($"OS: {Environment.OSVersion}");
Logger.Info($".NET Runtime: {Environment.Version}");
Logger.Info($"Log Directory: {Logger.GetLogDirectory()}");
+
+ // Create and show main window (StartupUri removed for Velopack custom Main)
+ var mainWindow = new MainWindow();
+ mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
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/Controls/FinOpsContent.xaml.cs b/Dashboard/Controls/FinOpsContent.xaml.cs
index 3533b833..e2a3a6c8 100644
--- a/Dashboard/Controls/FinOpsContent.xaml.cs
+++ b/Dashboard/Controls/FinOpsContent.xaml.cs
@@ -16,7 +16,6 @@
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
-using System.Windows.Controls.Primitives;
using System.Windows.Media;
using Microsoft.Win32;
using PerformanceMonitorDashboard.Helpers;
diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs
index 43590b72..3ec40d49 100644
--- a/Dashboard/Controls/PlanViewerControl.xaml.cs
+++ b/Dashboard/Controls/PlanViewerControl.xaml.cs
@@ -454,7 +454,7 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child)
};
}
- private object BuildEdgeTooltipContent(PlanNode child)
+ private Border BuildEdgeTooltipContent(PlanNode child)
{
var grid = new Grid { MinWidth = 240 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml
index 5236029c..2126752a 100644
--- a/Dashboard/Controls/QueryPerformanceContent.xaml
+++ b/Dashboard/Controls/QueryPerformanceContent.xaml
@@ -80,7 +80,12 @@
-
+
+
+
+
+
@@ -565,10 +570,16 @@
-
+
+
+
+
+
@@ -859,10 +870,16 @@
-
+
+
+
+
+
@@ -1122,10 +1139,16 @@
-
+
+
+
+
+
diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs
index 48fe2a57..cd6e293b 100644
--- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs
+++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs
@@ -225,6 +225,274 @@ public void Initialize(DatabaseService databaseService, Action? statusCa
{
_databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService));
_statusCallback = statusCallback;
+ ActiveQueriesSlicer.RangeChanged += OnActiveQueriesSlicerChanged;
+ QueryStatsSlicer.RangeChanged += OnQueryStatsSlicerChanged;
+ ProcStatsSlicer.RangeChanged += OnProcStatsSlicerChanged;
+ QueryStoreSlicer.RangeChanged += OnQueryStoreSlicerChanged;
+ }
+
+ // ── Active Queries Slicer ──
+
+ private async Task LoadActiveQueriesSlicerAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetActiveQuerySlicerDataAsync(
+ _activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate);
+ if (data.Count > 0)
+ ActiveQueriesSlicer.LoadData(data, "Sessions");
+ }
+ catch { }
+ }
+
+ private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ // Dashboard data is in server time; slicer sends server time directly
+ var data = await _databaseService.GetQuerySnapshotsAsync(0, e.Start, e.End);
+ _activeQueriesUnfilteredData = data;
+ ActiveQueriesDataGrid.ItemsSource = data;
+ ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch { }
+ }
+
+ // ── Query Stats Slicer ──
+
+ private List? _queryStatsSlicerData;
+ private string _queryStatsSlicerMetric = "TotalCpu";
+
+ private async Task LoadQueryStatsSlicerAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetQueryStatsSlicerDataAsync(
+ _queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate);
+ _queryStatsSlicerData = data;
+ _queryStatsSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ QueryStatsSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch { }
+ }
+
+ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetQueryStatsAsync(0, e.Start, e.End, fromSlicer: true);
+ PopulateQueryStatsGrid(data);
+ }
+ catch { }
+ }
+
+ private void QueryStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_queryStatsSlicerData == null || _queryStatsSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b)
+ col = b.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"),
+ "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"),
+ "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "TotalLogicalReads" or "AvgLogicalReads" => ("TotalReads", "Total Reads"),
+ "TotalLogicalWrites" => ("TotalWrites", "Total Writes"),
+ "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"),
+ "IntervalExecutions" => ("Sessions", "Executions"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _queryStatsSlicerMetric) return;
+ _queryStatsSlicerMetric = metric;
+
+ foreach (var bucket in _queryStatsSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ "Sessions" => bucket.SessionCount,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ QueryStatsSlicer.UpdateMetric(label);
+ }
+
+ // ── Procedure Stats Slicer ──
+
+ private List? _procStatsSlicerData;
+ private string _procStatsSlicerMetric = "TotalCpu";
+
+ private async Task LoadProcStatsSlicerAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetProcStatsSlicerDataAsync(
+ _procStatsHoursBack, _procStatsFromDate, _procStatsToDate);
+ _procStatsSlicerData = data;
+ _procStatsSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ ProcStatsSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch { }
+ }
+
+ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetProcedureStatsAsync(0, e.Start, e.End, fromSlicer: true);
+ PopulateProcStatsGrid(data);
+ }
+ catch { }
+ }
+
+ private void ProcStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_procStatsSlicerData == null || _procStatsSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc2 && bc2.Binding is System.Windows.Data.Binding b2)
+ col = b2.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"),
+ "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"),
+ "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "TotalLogicalReads" => ("TotalReads", "Total Reads"),
+ "TotalLogicalWrites" => ("TotalWrites", "Total Writes"),
+ "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"),
+ "IntervalExecutions" => ("Sessions", "Executions"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _procStatsSlicerMetric) return;
+ _procStatsSlicerMetric = metric;
+
+ foreach (var bucket in _procStatsSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ "Sessions" => bucket.SessionCount,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ ProcStatsSlicer.UpdateMetric(label);
+ }
+
+ // ── Query Store Slicer ──
+
+ private List? _queryStoreSlicerData;
+ private string _queryStoreSlicerMetric = "TotalCpu";
+
+ private async Task LoadQueryStoreSlicerAsync()
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetQueryStoreSlicerDataAsync(
+ _queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate);
+ _queryStoreSlicerData = data;
+ _queryStoreSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ QueryStoreSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch { }
+ }
+
+ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ if (_databaseService == null) return;
+ try
+ {
+ var data = await _databaseService.GetQueryStoreDataAsync(0, e.Start, e.End, fromSlicer: true);
+ PopulateQueryStoreGrid(data);
+ }
+ catch { }
+ }
+
+ private void QueryStoreDataGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_queryStoreSlicerData == null || _queryStoreSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc3 && bc3.Binding is System.Windows.Data.Binding b3)
+ col = b3.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "AvgCpuTimeMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "AvgDurationMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "AvgLogicalReads" => ("TotalReads", "Avg Reads"),
+ "AvgLogicalWrites" => ("TotalWrites", "Avg Writes"),
+ "AvgPhysicalReads" => ("TotalReads", "Avg Physical Reads"),
+ "ExecutionCount" => ("Sessions", "Executions"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _queryStoreSlicerMetric) return;
+ _queryStoreSlicerMetric = metric;
+
+ foreach (var bucket in _queryStoreSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ "Sessions" => bucket.SessionCount,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ QueryStoreSlicer.UpdateMetric(label);
+ }
+
+ public void RefreshGridBindings()
+ {
+ QueryStatsDataGrid.Items.Refresh();
+ ProcStatsDataGrid.Items.Refresh();
+ QueryStoreDataGrid.Items.Refresh();
+ QueryStoreRegressionsDataGrid.Items.Refresh();
+ ActiveQueriesDataGrid.Items.Refresh();
+ CurrentActiveQueriesDataGrid.Items.Refresh();
+ LongRunningQueryPatternsDataGrid.Items.Refresh();
+ ActiveQueriesSlicer.Redraw();
+ QueryStatsSlicer.Redraw();
+ ProcStatsSlicer.Redraw();
+ QueryStoreSlicer.Redraw();
}
///
@@ -322,9 +590,37 @@ await Task.WhenAll(
);
// Populate grids from summary data
- PopulateQueryStatsGrid(await queryStatsTask);
- PopulateProcStatsGrid(await procStatsTask);
- PopulateQueryStoreGrid(await queryStoreTask);
+ // If slicer is narrowed, re-query with slicer dates instead of global range
+ if (QueryStatsSlicer.HasNarrowedSelection)
+ {
+ var slicerData = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true);
+ PopulateQueryStatsGrid(slicerData);
+ }
+ else
+ {
+ PopulateQueryStatsGrid(await queryStatsTask);
+ }
+ LoadQueryStatsSlicerAsync().ConfigureAwait(false);
+ if (ProcStatsSlicer.HasNarrowedSelection)
+ {
+ var slicerProcData = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true);
+ PopulateProcStatsGrid(slicerProcData);
+ }
+ else
+ {
+ PopulateProcStatsGrid(await procStatsTask);
+ }
+ LoadProcStatsSlicerAsync().ConfigureAwait(false);
+ if (QueryStoreSlicer.HasNarrowedSelection)
+ {
+ var slicerQsData = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true);
+ PopulateQueryStoreGrid(slicerQsData);
+ }
+ else
+ {
+ PopulateQueryStoreGrid(await queryStoreTask);
+ }
+ LoadQueryStoreSlicerAsync().ConfigureAwait(false);
// Populate charts from time-series data
LoadDurationChart(QueryPerfTrendsQueryChart, await queryDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[0], _queryDurationHover);
@@ -362,43 +658,55 @@ private async Task RefreshPerformanceTrendsAsync()
private async Task RefreshQueryStatsGridAsync()
{
if (_databaseService == null) return;
- var data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate);
+ List data;
+ if (QueryStatsSlicer.HasNarrowedSelection)
+ data = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true);
+ else
+ data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate);
PopulateQueryStatsGrid(data);
+ LoadQueryStatsSlicerAsync().ConfigureAwait(false);
}
private async Task RefreshProcStatsGridAsync()
{
if (_databaseService == null) return;
- var data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate);
+ List data;
+ if (ProcStatsSlicer.HasNarrowedSelection)
+ data = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true);
+ else
+ data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate);
PopulateProcStatsGrid(data);
+ LoadProcStatsSlicerAsync().ConfigureAwait(false);
}
private async Task RefreshQueryStoreGridAsync()
{
if (_databaseService == null) return;
- var data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate);
+ List data;
+ if (QueryStoreSlicer.HasNarrowedSelection)
+ data = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true);
+ else
+ data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate);
PopulateQueryStoreGrid(data);
+ LoadQueryStoreSlicerAsync().ConfigureAwait(false);
}
private void PopulateQueryStatsGrid(List data)
{
- QueryStatsDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(QueryStatsDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending);
QueryStatsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- SetInitialSort(QueryStatsDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending);
}
private void PopulateProcStatsGrid(List data)
{
- ProcStatsDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(ProcStatsDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending);
ProcStatsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- SetInitialSort(ProcStatsDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending);
}
private void PopulateQueryStoreGrid(List data)
{
- QueryStoreDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(QueryStoreDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending);
QueryStoreNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- SetInitialSort(QueryStoreDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending);
}
private void SetStatus(string message)
@@ -406,16 +714,42 @@ private void SetStatus(string message)
_statusCallback?.Invoke(message);
}
- private static void SetInitialSort(DataGrid grid, string bindingPath, ListSortDirection direction)
+ private static void SetItemsSourcePreservingSort(
+ DataGrid grid, System.Collections.IEnumerable? newSource,
+ string? defaultSortProperty = null,
+ ListSortDirection defaultDirection = ListSortDirection.Descending)
{
- foreach (var column in grid.Columns)
+ var savedSorts = grid.Items.SortDescriptions.ToList();
+
+ grid.ItemsSource = newSource;
+
+ if (savedSorts.Count > 0)
{
- if (column is DataGridBoundColumn bc &&
- bc.Binding is Binding b &&
- b.Path.Path == bindingPath)
+ foreach (var sort in savedSorts)
+ grid.Items.SortDescriptions.Add(sort);
+
+ foreach (var column in grid.Columns)
{
- column.SortDirection = direction;
- return;
+ if (column is DataGridBoundColumn bc &&
+ bc.Binding is Binding b)
+ {
+ var match = savedSorts.FirstOrDefault(s => s.PropertyName == b.Path.Path);
+ column.SortDirection = match.PropertyName != null ? match.Direction : null;
+ }
+ }
+ }
+ else if (defaultSortProperty != null)
+ {
+ grid.Items.SortDescriptions.Add(new SortDescription(defaultSortProperty, defaultDirection));
+ foreach (var column in grid.Columns)
+ {
+ if (column is DataGridBoundColumn bc &&
+ bc.Binding is Binding b &&
+ b.Path.Path == defaultSortProperty)
+ {
+ column.SortDirection = defaultDirection;
+ return;
+ }
}
}
}
@@ -529,10 +863,21 @@ private async Task RefreshActiveQueriesAsync()
}
SetStatus("Loading active queries...");
- var data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate);
- ActiveQueriesDataGrid.ItemsSource = data;
+ // If user has narrowed the slicer, use slicer dates for the grid
+ List data;
+ if (ActiveQueriesSlicer.HasNarrowedSelection)
+ {
+ data = await _databaseService.GetQuerySnapshotsAsync(0, ActiveQueriesSlicer.SelectionStart, ActiveQueriesSlicer.SelectionEnd);
+ }
+ else
+ {
+ data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate);
+ }
+
+ SetItemsSourcePreservingSort(ActiveQueriesDataGrid, data);
ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
SetStatus($"Loaded {data.Count} query snapshots");
+ LoadActiveQueriesSlicerAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -693,7 +1038,7 @@ private async Task RefreshCurrentActiveQueriesAsync()
var data = await _databaseService.GetCurrentActiveQueriesAsync();
_currentActiveUnfilteredData = data;
- CurrentActiveQueriesDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(CurrentActiveQueriesDataGrid, data);
CurrentActiveNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
CurrentActiveTimestamp.Text = $"Last refreshed: {DateTime.Now:HH:mm:ss} — {data.Count} queries";
@@ -1435,9 +1780,8 @@ private async Task RefreshQueryStoreRegressionsAsync()
{
SetStatus("Loading query store regressions...");
var data = await _databaseService.GetQueryStoreRegressionsAsync(_qsRegressionsHoursBack, _qsRegressionsFromDate, _qsRegressionsToDate);
- QueryStoreRegressionsDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(QueryStoreRegressionsDataGrid, data, "DurationRegressionPercent", ListSortDirection.Descending);
QueryStoreRegressionsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- SetInitialSort(QueryStoreRegressionsDataGrid, "DurationRegressionPercent", ListSortDirection.Descending);
SetStatus($"Loaded {data.Count} query store regression records");
}
catch (Exception ex)
@@ -1536,9 +1880,8 @@ private async Task RefreshLongRunningPatternsAsync()
{
SetStatus("Loading long running query patterns...");
var data = await _databaseService.GetLongRunningQueryPatternsAsync(_lrqPatternsHoursBack, _lrqPatternsFromDate, _lrqPatternsToDate);
- LongRunningQueryPatternsDataGrid.ItemsSource = data;
+ SetItemsSourcePreservingSort(LongRunningQueryPatternsDataGrid, data, "AvgDurationSec", ListSortDirection.Descending);
LongRunningQueryPatternsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
- SetInitialSort(LongRunningQueryPatternsDataGrid, "AvgDurationSec", ListSortDirection.Descending);
SetStatus($"Loaded {data.Count} long running query pattern records");
}
catch (Exception ex)
diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml b/Dashboard/Controls/TimeRangeSlicerControl.xaml
new file mode 100644
index 00000000..d67c1ef3
--- /dev/null
+++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs
new file mode 100644
index 00000000..b90e09f9
--- /dev/null
+++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs
@@ -0,0 +1,390 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard.Controls;
+
+///
+/// Time range slicer for Dashboard. Timestamps are in SERVER LOCAL TIME
+/// (matching Dashboard's collect.* tables which use SYSDATETIME()).
+///
+public partial class TimeRangeSlicerControl : UserControl
+{
+ private List _data = new();
+ private string _metricLabel = "Sessions";
+ private bool _isExpanded = true;
+
+ private double _rangeStart;
+ private double _rangeEnd = 1.0;
+
+ private const double HandleWidthPx = 8;
+ private const double HandleGripWidthPx = 20;
+ private const double MinRangeNorm = 0.02;
+ private const double ChartPaddingTop = 16;
+ private const double ChartPaddingBottom = 20;
+
+ private enum DragMode { None, MoveRange, DragStart, DragEnd }
+ private DragMode _dragMode = DragMode.None;
+ private double _dragOriginX;
+ private double _dragOriginRangeStart;
+ private double _dragOriginRangeEnd;
+
+ ///
+ /// Fired when the user finishes adjusting the slicer handles.
+ /// Start/End are in server local time (matching Dashboard data).
+ ///
+ public event EventHandler? RangeChanged;
+
+ public TimeRangeSlicerControl()
+ {
+ InitializeComponent();
+ SlicerBorder.SizeChanged += (_, _) => Redraw();
+ IsVisibleChanged += (_, _) => { if (IsVisible) Redraw(); };
+ }
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ _isExpanded = value;
+ SlicerBorder.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed;
+ ToggleIcon.Text = _isExpanded ? "▾" : "▸";
+ }
+ }
+
+ public void LoadData(List data, string metricLabel)
+ {
+ // Preserve selection if we already have data (auto-refresh)
+ DateTime? prevStart = null, prevEnd = null;
+ if (_data.Count > 0 && (_rangeStart > 0 || _rangeEnd < 1.0))
+ {
+ prevStart = TimeAtNorm(_rangeStart);
+ prevEnd = TimeAtNorm(_rangeEnd);
+ }
+
+ _data = data;
+ _metricLabel = metricLabel;
+
+ if (prevStart.HasValue && prevEnd.HasValue && _data.Count >= 2)
+ {
+ _rangeStart = NormAtTime(prevStart.Value);
+ _rangeEnd = NormAtTime(prevEnd.Value);
+ }
+ else
+ {
+ _rangeStart = 0;
+ _rangeEnd = 1.0;
+ }
+
+ UpdateRangeLabel();
+ Redraw();
+ }
+
+ public void UpdateMetric(string metricLabel)
+ {
+ _metricLabel = metricLabel;
+ Redraw();
+ }
+
+ public DateTime? SelectionStart => _data.Count > 0 ? TimeAtNorm(_rangeStart) : null;
+ public DateTime? SelectionEnd => _data.Count > 0 ? TimeAtNorm(_rangeEnd) : null;
+ public bool HasNarrowedSelection => _data.Count > 0 && (_rangeStart > 0.01 || _rangeEnd < 0.99);
+
+ private DateTime DataStart => _data[0].BucketTime;
+ private DateTime DataEnd => _data[^1].BucketTime.AddHours(1);
+
+ private DateTime TimeAtNorm(double norm)
+ {
+ var ticks = DataStart.Ticks + (long)((DataEnd.Ticks - DataStart.Ticks) * norm);
+ return new DateTime(Math.Clamp(ticks, DataStart.Ticks, DataEnd.Ticks));
+ }
+
+ private double NormAtTime(DateTime dt)
+ {
+ var span = DataEnd.Ticks - DataStart.Ticks;
+ if (span <= 0) return 0;
+ return Math.Clamp((double)(dt.Ticks - DataStart.Ticks) / span, 0, 1);
+ }
+
+ // ── Drawing ──
+
+ public void Redraw()
+ {
+ SlicerCanvas.Children.Clear();
+ if (_data.Count < 2) return;
+
+ var w = SlicerBorder.ActualWidth;
+ var h = SlicerBorder.ActualHeight;
+ if (w <= 0 || h <= 0) return;
+
+ var values = _data.Select(d => d.Value).ToArray();
+ var max = values.Max();
+ if (max <= 0) max = 1;
+
+ var chartTop = ChartPaddingTop;
+ var chartBottom = h - ChartPaddingBottom;
+ var chartHeight = chartBottom - chartTop;
+ if (chartHeight <= 0) return;
+
+ var n = values.Length;
+
+ var linePoints = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var x = NormAtTime(_data[i].BucketTime) * w;
+ var y = chartBottom - (values[i] / max) * chartHeight;
+ linePoints.Add(new Point(x, y));
+ }
+
+ var fillBrush = FindBrush("SlicerChartFillBrush", "#332EAEF1");
+ var areaGeo = new StreamGeometry();
+ using (var ctx = areaGeo.Open())
+ {
+ ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true, true);
+ foreach (var pt in linePoints) ctx.LineTo(pt, true, false);
+ ctx.LineTo(new Point(linePoints[^1].X, chartBottom), true, false);
+ }
+ SlicerCanvas.Children.Add(new Path { Data = areaGeo, Fill = fillBrush });
+
+ var lineBrush = FindBrush("SlicerChartLineBrush", "#2EAEF1");
+ var lineGeo = new StreamGeometry();
+ using (var ctx = lineGeo.Open())
+ {
+ ctx.BeginFigure(linePoints[0], false, false);
+ for (int i = 1; i < linePoints.Count; i++) ctx.LineTo(linePoints[i], true, false);
+ }
+ SlicerCanvas.Children.Add(new Path { Data = lineGeo, Stroke = lineBrush, StrokeThickness = 1.5 });
+
+ // X-axis labels — evenly spaced by TIME across the full range, skip if too close
+ var labelBrush = FindBrush("SlicerLabelBrush", "#99E4E6EB");
+ const double minLabelSpacingPx = 90;
+ double lastLabelX = -minLabelSpacingPx;
+ int targetLabels = Math.Max(2, (int)(w / minLabelSpacingPx));
+ var timeStep = (DataEnd - DataStart).TotalHours / targetLabels;
+ for (int tick = 0; tick <= targetLabels; tick++)
+ {
+ var tickTime = DataStart.AddHours(tick * timeStep);
+ var x = NormAtTime(tickTime) * w;
+ if (x - lastLabelX < minLabelSpacingPx) continue;
+ if (x < 10 || x > w - 40) continue; // avoid edge clipping
+ var dt = ServerTimeHelper.ConvertForDisplay(tickTime, ServerTimeHelper.CurrentDisplayMode);
+ var tb = new TextBlock { Text = dt.ToString("MM/dd HH:mm"), FontSize = 9, Foreground = labelBrush };
+ Canvas.SetLeft(tb, x - 25);
+ Canvas.SetTop(tb, chartBottom + 2);
+ SlicerCanvas.Children.Add(tb);
+ lastLabelX = x;
+ }
+
+ var metricBrush = FindBrush("SlicerToggleBrush", "#E4E6EB");
+ var metricTb = new TextBlock { Text = _metricLabel, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = metricBrush };
+ Canvas.SetLeft(metricTb, w - 120);
+ Canvas.SetTop(metricTb, 2);
+ SlicerCanvas.Children.Add(metricTb);
+
+ var overlayBrush = FindBrush("SlicerOverlayBrush", "#99000000");
+ var selectedBrush = FindBrush("SlicerSelectedBrush", "#22FFFFFF");
+ var handleBrush = FindBrush("SlicerHandleBrush", "#E4E6EB");
+
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ if (selLeft > 0) AddRect(0, 0, selLeft, h, overlayBrush);
+ if (selRight < w) AddRect(selRight, 0, w - selRight, h, overlayBrush);
+ AddRect(selLeft, 0, Math.Max(0, selRight - selLeft), h, selectedBrush);
+
+ DrawHandle(selLeft, h, handleBrush);
+ DrawHandle(selRight - HandleWidthPx, h, handleBrush);
+ AddLine(selLeft, 0, selRight, 0, handleBrush, 0.5);
+ AddLine(selLeft, h, selRight, h, handleBrush, 0.5);
+ }
+
+ private void AddRect(double x, double y, double width, double height, Brush fill)
+ {
+ var rect = new Rectangle { Width = width, Height = height, Fill = fill };
+ Canvas.SetLeft(rect, x); Canvas.SetTop(rect, y);
+ SlicerCanvas.Children.Add(rect);
+ }
+
+ private void AddLine(double x1, double y1, double x2, double y2, Brush stroke, double opacity)
+ {
+ SlicerCanvas.Children.Add(new Line
+ {
+ X1 = x1, Y1 = y1, X2 = x2, Y2 = y2,
+ Stroke = stroke, StrokeThickness = 1, Opacity = opacity
+ });
+ }
+
+ private void DrawHandle(double x, double canvasHeight, Brush brush)
+ {
+ AddRect(x, 0, HandleWidthPx, canvasHeight, brush);
+ ((Rectangle)SlicerCanvas.Children[^1]).Opacity = 0.7;
+ var midY = canvasHeight / 2;
+ for (int i = -1; i <= 1; i++)
+ {
+ SlicerCanvas.Children.Add(new Line
+ {
+ X1 = x + 2, Y1 = midY + i * 5, X2 = x + HandleWidthPx - 2, Y2 = midY + i * 5,
+ Stroke = Brushes.Black, StrokeThickness = 1, Opacity = 0.6
+ });
+ }
+ }
+
+ private Brush FindBrush(string key, string fallbackHex)
+ {
+ if (TryFindResource(key) is Brush b) return b;
+ return new SolidColorBrush((Color)ColorConverter.ConvertFromString(fallbackHex));
+ }
+
+ // ── Range label ──
+
+ private void UpdateRangeLabel()
+ {
+ if (_data.Count == 0) { RangeLabel.Text = ""; return; }
+ var start = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeStart), ServerTimeHelper.CurrentDisplayMode);
+ var end = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeEnd), ServerTimeHelper.CurrentDisplayMode);
+ var span = end - start;
+ RangeLabel.Text = $"{start:yyyy-MM-dd HH:mm} \u2192 {end:yyyy-MM-dd HH:mm} ({span.TotalHours:F0}h)";
+ }
+
+ // ── Mouse interaction ──
+
+ private void Toggle_Click(object sender, RoutedEventArgs e) => IsExpanded = !IsExpanded;
+
+ private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+ var pos = e.GetPosition(SlicerCanvas);
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ _dragOriginX = pos.X;
+ _dragOriginRangeStart = _rangeStart;
+ _dragOriginRangeEnd = _rangeEnd;
+
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx)
+ { _dragMode = DragMode.DragStart; SlicerCanvas.CaptureMouse(); e.Handled = true; return; }
+ if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ { _dragMode = DragMode.DragEnd; SlicerCanvas.CaptureMouse(); e.Handled = true; return; }
+ if (pos.X >= selLeft && pos.X <= selRight)
+ { _dragMode = DragMode.MoveRange; SlicerCanvas.CaptureMouse(); e.Handled = true; }
+ }
+
+ private void Canvas_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+ var pos = e.GetPosition(SlicerCanvas);
+
+ if (_dragMode == DragMode.None)
+ {
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ SlicerCanvas.Cursor = Cursors.SizeWE;
+ else if (pos.X >= selLeft && pos.X <= selRight)
+ SlicerCanvas.Cursor = Cursors.SizeAll;
+ else
+ SlicerCanvas.Cursor = Cursors.Arrow;
+ return;
+ }
+
+ var deltaNorm = (pos.X - _dragOriginX) / w;
+ switch (_dragMode)
+ {
+ case DragMode.DragStart:
+ _rangeStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - MinRangeNorm);
+ break;
+ case DragMode.DragEnd:
+ _rangeEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + MinRangeNorm, 1);
+ break;
+ case DragMode.MoveRange:
+ var span = _dragOriginRangeEnd - _dragOriginRangeStart;
+ var newStart = _dragOriginRangeStart + deltaNorm;
+ if (newStart < 0) newStart = 0;
+ if (newStart + span > 1) newStart = 1 - span;
+ _rangeStart = newStart;
+ _rangeEnd = newStart + span;
+ break;
+ }
+ UpdateRangeLabel();
+ Redraw();
+ e.Handled = true;
+ }
+
+ private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (_dragMode != DragMode.None)
+ {
+ _dragMode = DragMode.None;
+ SlicerCanvas.ReleaseMouseCapture();
+ FireRangeChanged();
+ e.Handled = true;
+ }
+ }
+
+ private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return;
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+
+ var pos = e.GetPosition(SlicerCanvas);
+ var pivot = Math.Clamp(pos.X / w, 0, 1);
+ var span = _rangeEnd - _rangeStart;
+
+ var zoomFactor = e.Delta > 0 ? 0.85 : 1.0 / 0.85;
+ var newSpan = Math.Clamp(span * zoomFactor, MinRangeNorm, 1.0);
+
+ var pivotInRange = (pivot - _rangeStart) / span;
+ var newStart = pivot - pivotInRange * newSpan;
+ var newEnd = newStart + newSpan;
+
+ if (newStart < 0) { newStart = 0; newEnd = newSpan; }
+ if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; }
+
+ _rangeStart = Math.Max(0, newStart);
+ _rangeEnd = Math.Min(1, newEnd);
+
+ UpdateRangeLabel();
+ Redraw();
+ FireRangeChanged();
+ e.Handled = true;
+ }
+
+ private void FireRangeChanged()
+ {
+ if (_data.Count == 0) return;
+ // Snap to hour boundaries so slider positions align with hourly buckets
+ var start = FloorToHour(TimeAtNorm(_rangeStart));
+ var end = CeilToHour(TimeAtNorm(_rangeEnd));
+ RangeChanged?.Invoke(this, new SlicerRangeEventArgs(start, end));
+ }
+
+ private static DateTime FloorToHour(DateTime dt) =>
+ new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind);
+
+ private static DateTime CeilToHour(DateTime dt)
+ {
+ var floored = FloorToHour(dt);
+ return floored == dt ? dt : floored.AddHours(1);
+ }
+}
+
+public class SlicerRangeEventArgs : EventArgs
+{
+ public DateTime Start { get; }
+ public DateTime End { get; }
+ public SlicerRangeEventArgs(DateTime start, DateTime end) { Start = start; End = end; }
+}
diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj
index aa54e6f7..73b591ef 100644
--- a/Dashboard/Dashboard.csproj
+++ b/Dashboard/Dashboard.csproj
@@ -4,12 +4,13 @@
net8.0-windows
enable
true
+ PerformanceMonitorDashboard.Program
PerformanceMonitorDashboard
SQL Server Performance Monitor Dashboard
- 2.3.0
- 2.3.0.0
- 2.3.0.0
- 2.3.0
+ 2.4.0
+ 2.4.0.0
+ 2.4.0.0
+ 2.4.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
@@ -35,12 +36,13 @@
-
-
+
+
-
+
+
diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs
index 8d67436b..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;
@@ -176,9 +177,34 @@ private async Task CheckForUpdatesOnStartupAsync()
{
try
{
+ await Task.Delay(5000); // Don't slow down startup
+
var prefs = _preferencesService.GetPreferences();
if (!prefs.CheckForUpdatesOnStartup) return;
+ // Try Velopack first (supports download + apply)
+ try
+ {
+ var mgr = new Velopack.UpdateManager(
+ new Velopack.Sources.GithubSource(
+ "https://github.com/erikdarlingdata/PerformanceMonitor", null, false));
+
+ var newVersion = await mgr.CheckForUpdatesAsync();
+ if (newVersion != null)
+ {
+ _notificationService?.ShowNotification(
+ "Update Available",
+ $"Performance Monitor {newVersion.TargetFullRelease.Version} is available. Use Help > About to download and install.",
+ NotificationType.Info);
+ return;
+ }
+ }
+ catch
+ {
+ // Velopack packages may not exist yet — fall through to legacy check
+ }
+
+ // Fallback: GitHub Releases API check (notification only)
var result = await UpdateCheckService.CheckForUpdateAsync();
if (result?.IsUpdateAvailable == true)
{
@@ -1629,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/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs
index 72ffa00b..c20db796 100644
--- a/Dashboard/Mcp/McpAnalysisTools.cs
+++ b/Dashboard/Mcp/McpAnalysisTools.cs
@@ -433,15 +433,17 @@ public static System.Collections.Generic.List
diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml
index 47165f04..44519117 100644
--- a/Dashboard/Themes/DarkTheme.xaml
+++ b/Dashboard/Themes/DarkTheme.xaml
@@ -1257,4 +1257,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Themes/LightTheme.xaml b/Dashboard/Themes/LightTheme.xaml
index 497b1428..882619fd 100644
--- a/Dashboard/Themes/LightTheme.xaml
+++ b/Dashboard/Themes/LightTheme.xaml
@@ -1258,4 +1258,15 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/TracePatternHistoryWindow.xaml b/Dashboard/TracePatternHistoryWindow.xaml
index 8981a6e6..b3a7d828 100644
--- a/Dashboard/TracePatternHistoryWindow.xaml
+++ b/Dashboard/TracePatternHistoryWindow.xaml
@@ -87,6 +87,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -151,6 +167,14 @@
+
+
+
+
+
+
+
+
@@ -175,14 +199,6 @@
-
-
-
-
-
-
-
-
diff --git a/Installer.Tests/AdversarialTests.cs b/Installer.Tests/AdversarialTests.cs
index 6f62c44b..2908b6bc 100644
--- a/Installer.Tests/AdversarialTests.cs
+++ b/Installer.Tests/AdversarialTests.cs
@@ -36,11 +36,11 @@ public async Task UpgradeFailure_DoesNotDropDatabase()
// Insert a canary row we can check survived
using (var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString()))
{
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
CREATE TABLE config.canary_data (id int NOT NULL, value nvarchar(50) NOT NULL);
INSERT INTO config.canary_data VALUES (1, 'must_survive');", conn);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
// Create a poisoned upgrade that will fail
@@ -58,17 +58,19 @@ public async Task UpgradeFailure_DoesNotDropDatabase()
dir.RootPath,
TestDatabaseHelper.GetTestDbConnectionString(),
"2.0.0",
- "2.1.0");
+ "2.1.0",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
Assert.True(failureCount > 0, "Upgrade should have failed");
// The critical assertion: database and data must still exist
using (var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString()))
{
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(
"SELECT value FROM config.canary_data WHERE id = 1;", conn);
- var result = await cmd.ExecuteScalarAsync();
+ var result = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.Equal("must_survive", result?.ToString());
}
}
@@ -91,10 +93,10 @@ public async Task PartialInstall_InstallScriptsRecover()
// Verify: only installation_history exists, no collect/report schemas
using (var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString()))
{
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(
"SELECT COUNT(*) FROM sys.tables WHERE schema_id != SCHEMA_ID('config');", conn);
- var nonConfigTables = (int)(await cmd.ExecuteScalarAsync())!;
+ var nonConfigTables = (int)(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!;
Assert.Equal(0, nonConfigTables);
}
@@ -112,18 +114,18 @@ public async Task PartialInstall_InstallScriptsRecover()
var fileName = Path.GetFileName(file);
try
{
- var sql = await File.ReadAllTextAsync(file);
+ var sql = await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken);
sql = RewriteForTestDatabase(sql);
var batches = SplitGoBatches(sql);
using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
foreach (var batch in batches)
{
if (string.IsNullOrWhiteSpace(batch)) continue;
using var cmd = new SqlCommand(batch, conn) { CommandTimeout = 120 };
- try { await cmd.ExecuteNonQueryAsync(); }
+ try { await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); }
catch (SqlException ex)
{
if (IsExpectedTestFailure(ex, fileName)) continue;
@@ -144,12 +146,12 @@ public async Task PartialInstall_InstallScriptsRecover()
// Verify core tables were created from the partial state
using (var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString()))
{
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
SELECT COUNT(*) FROM sys.tables
WHERE schema_id = SCHEMA_ID('collect')
AND name IN ('wait_stats', 'query_stats', 'cpu_utilization_stats');", conn);
- var collectTables = (int)(await cmd.ExecuteScalarAsync())!;
+ var collectTables = (int)(await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken))!;
Assert.True(collectTables >= 3, $"Expected at least 3 collect tables, got {collectTables}");
}
}
@@ -190,17 +192,19 @@ public async Task CriticalFileFailure_AbortsInstallation()
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
files,
- cleanInstall: false);
+ cleanInstall: false,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
Assert.False(result.Success);
Assert.True(result.FilesFailed >= 1);
// Verify abort: scripts after 02_ must NOT have run
using var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString());
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(
"SELECT OBJECT_ID('dbo.should_not_exist', 'U');", conn);
- var obj = await cmd.ExecuteScalarAsync();
+ var obj = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.True(obj == null || obj == DBNull.Value,
"03_config.sql should not have executed after 02_ critical failure");
}
@@ -243,13 +247,13 @@ await InstallationService.ExecuteAllUpgradesAsync(
// Version must still be 2.0.0 — no SUCCESS row written for 2.1.0
using var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString());
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
SELECT TOP 1 installer_version
FROM config.installation_history
WHERE installation_status = 'SUCCESS'
ORDER BY installation_date DESC;", conn);
- var version = await cmd.ExecuteScalarAsync();
+ var version = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.Equal("2.0.0", version?.ToString());
}
@@ -278,16 +282,18 @@ public async Task NonCriticalFileFailure_ContinuesInstallation()
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
files,
- cleanInstall: false);
+ cleanInstall: false,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
// 04_ failed but 05_ should have run
Assert.True(result.FilesFailed >= 1);
using var conn = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString());
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(
"SELECT OBJECT_ID('dbo.proof_it_continued', 'U');", conn);
- var obj = await cmd.ExecuteScalarAsync();
+ var obj = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.True(obj != null && obj != DBNull.Value,
"05_ should have executed despite 04_ failure");
}
@@ -313,7 +319,9 @@ public async Task CorruptSqlContent_FailsGracefully()
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
files,
- cleanInstall: false);
+ cleanInstall: false,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
// Should complete (not throw), with 04_ counted as failed
Assert.True(result.FilesFailed >= 1);
@@ -337,7 +345,9 @@ public async Task EmptySqlFile_DoesNotCrash()
var result = await InstallationService.ExecuteInstallationAsync(
TestDatabaseHelper.GetTestDbConnectionString(),
files,
- cleanInstall: false);
+ cleanInstall: false,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
Assert.True(result.Success);
}
@@ -351,7 +361,9 @@ public async Task EmptySqlFile_DoesNotCrash()
public async Task VersionCheck_Sql2022_IsSupported()
{
var info = await InstallationService.TestConnectionAsync(
- TestDatabaseHelper.GetConnectionString());
+ TestDatabaseHelper.GetConnectionString(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
Assert.True(info.IsConnected);
Assert.True(info.ProductMajorVersion >= 13,
@@ -409,7 +421,7 @@ public async Task VersionDetection_ConnectionFailure_ReturnsNull()
// Intentionally bad connection string
var badConnStr = "Server=DOESNOTEXIST;Database=master;User Id=sa;Password=x;TrustServerCertificate=true;Connect Timeout=2;";
- var version = await InstallationService.GetInstalledVersionAsync(badConnStr);
+ var version = await InstallationService.GetInstalledVersionAsync(badConnStr, cancellationToken: TestContext.Current.CancellationToken);
// GUI swallows exceptions and returns null.
// This means a transient network failure could cause the GUI to treat
@@ -431,14 +443,14 @@ public async Task RestrictedPermissions_FreshInstall_FailsClearly()
// Verify the login can connect but has no dbcreator
using (var conn = new SqlConnection(restrictedConnStr))
{
- await conn.OpenAsync();
+ await conn.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand("SELECT IS_SRVROLEMEMBER('dbcreator');", conn);
- var isDbCreator = await cmd.ExecuteScalarAsync();
+ var isDbCreator = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
Assert.Equal(0, Convert.ToInt32(isDbCreator));
}
// Version detection should return null (no PerformanceMonitor database)
- var version = await InstallationService.GetInstalledVersionAsync(restrictedConnStr);
+ var version = await InstallationService.GetInstalledVersionAsync(restrictedConnStr, cancellationToken: TestContext.Current.CancellationToken);
Assert.Null(version);
// Try to install — 01_install_database.sql should fail on CREATE DATABASE
@@ -455,7 +467,9 @@ IF DB_ID(N'PerformanceMonitor_RestrictedTest') IS NULL
var result = await InstallationService.ExecuteInstallationAsync(
restrictedConnStr,
files,
- cleanInstall: false);
+ cleanInstall: false,
+ cancellationToken: TestContext.Current.CancellationToken
+ );
// Must fail — and because 01_ is critical, it should abort
Assert.False(result.Success);
diff --git a/Installer.Tests/Helpers/TestDatabaseHelper.cs b/Installer.Tests/Helpers/TestDatabaseHelper.cs
index 7a9111f0..8f5b9f6c 100644
--- a/Installer.Tests/Helpers/TestDatabaseHelper.cs
+++ b/Installer.Tests/Helpers/TestDatabaseHelper.cs
@@ -19,18 +19,18 @@ public static string GetTestDbConnectionString()
public static async Task CreateTestDatabaseAsync()
{
using var connection = new SqlConnection(GetConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand($@"
IF DB_ID(N'{TestDatabaseName}') IS NULL
CREATE DATABASE [{TestDatabaseName}];", connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
public static async Task DropTestDatabaseAsync()
{
using var connection = new SqlConnection(GetConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand($@"
IF DB_ID(N'{TestDatabaseName}') IS NOT NULL
@@ -38,7 +38,7 @@ IF DB_ID(N'{TestDatabaseName}') IS NOT NULL
ALTER DATABASE [{TestDatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [{TestDatabaseName}];
END;", connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
///
@@ -50,7 +50,7 @@ public static async Task CreatePartialInstallationAsync(string version)
await CreateTestDatabaseAsync();
using var connection = new SqlConnection(GetTestDbConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
// Must match the real schema from 01_install_database.sql exactly,
// otherwise CREATE OR ALTER VIEW on config.current_version will fail
@@ -83,7 +83,7 @@ INSERT INTO config.installation_history
VALUES
(N'{version}', N'SUCCESS', N'UPGRADE', @@VERSION, N'Test');",
connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
///
@@ -95,7 +95,7 @@ public static async Task CreateInstallationWithNoSuccessRowsAsync()
await CreateTestDatabaseAsync();
using var connection = new SqlConnection(GetTestDbConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
IF SCHEMA_ID('config') IS NULL
@@ -120,7 +120,7 @@ installation_notes nvarchar(max) NULL,
CONSTRAINT PK_installation_history PRIMARY KEY CLUSTERED (installation_id)
);",
connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
///
@@ -131,7 +131,7 @@ public static async Task CreateInstallationWithOnlyFailedRowsAsync()
await CreateInstallationWithNoSuccessRowsAsync();
using var connection = new SqlConnection(GetTestDbConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
INSERT INTO config.installation_history
@@ -139,6 +139,6 @@ INSERT INTO config.installation_history
VALUES
(N'2.0.0', N'FAILED', N'UPGRADE', @@VERSION, N'Test');",
connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
}
diff --git a/Installer.Tests/IdempotencyTests.cs b/Installer.Tests/IdempotencyTests.cs
index 7d37fa16..9ab0ba6e 100644
--- a/Installer.Tests/IdempotencyTests.cs
+++ b/Installer.Tests/IdempotencyTests.cs
@@ -100,7 +100,7 @@ private static List GetFilteredInstallFiles(string installDir)
var batches = SplitBatches(sql);
using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
foreach (var batch in batches)
{
@@ -113,7 +113,7 @@ private static List GetFilteredInstallFiles(string installDir)
try
{
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
}
catch (SqlException ex)
{
diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj
index 9a3f2575..c9307454 100644
--- a/Installer.Tests/Installer.Tests.csproj
+++ b/Installer.Tests/Installer.Tests.csproj
@@ -7,6 +7,7 @@
enable
true
CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CS4014;NU1701;CA1001;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806
+ false
diff --git a/Installer.Tests/VersionDetectionTests.cs b/Installer.Tests/VersionDetectionTests.cs
index 20025182..8eb54069 100644
--- a/Installer.Tests/VersionDetectionTests.cs
+++ b/Installer.Tests/VersionDetectionTests.cs
@@ -81,7 +81,7 @@ public async Task MultipleSuccessRows_ReturnsLatest()
// Add a newer success row
using var connection = new SqlConnection(TestDatabaseHelper.GetTestDbConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = new SqlCommand(@"
-- Use explicit future date to ensure this row sorts first
INSERT INTO config.installation_history
@@ -89,7 +89,7 @@ INSERT INTO config.installation_history
VALUES
(N'2.2.0', N'SUCCESS', N'UPGRADE', @@VERSION, N'Test', DATEADD(HOUR, 1, SYSDATETIME()));",
connection);
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
var version = await GetInstalledVersionFromTestDbAsync();
Assert.Equal("2.2.0", version);
@@ -106,19 +106,19 @@ INSERT INTO config.installation_history
try
{
using var connection = new SqlConnection(TestDatabaseHelper.GetConnectionString());
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
// Check if database exists
using var dbCheckCmd = new SqlCommand($@"
SELECT database_id FROM sys.databases WHERE name = N'{testDbName}';", connection);
- var dbExists = await dbCheckCmd.ExecuteScalarAsync();
+ var dbExists = await dbCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
if (dbExists == null || dbExists == DBNull.Value)
return null;
// Check if installation_history table exists
using var tableCheckCmd = new SqlCommand($@"
SELECT OBJECT_ID(N'{testDbName}.config.installation_history', N'U');", connection);
- var tableExists = await tableCheckCmd.ExecuteScalarAsync();
+ var tableExists = await tableCheckCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
if (tableExists == null || tableExists == DBNull.Value)
return null;
@@ -128,7 +128,7 @@ SELECT TOP 1 installer_version
FROM {testDbName}.config.installation_history
WHERE installation_status = 'SUCCESS'
ORDER BY installation_date DESC;", connection);
- var version = await versionCmd.ExecuteScalarAsync();
+ var version = await versionCmd.ExecuteScalarAsync(TestContext.Current.CancellationToken);
if (version != null && version != DBNull.Value)
return version.ToString();
diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj
index 3ba7e90b..f9243d8d 100644
--- a/Installer/PerformanceMonitorInstaller.csproj
+++ b/Installer/PerformanceMonitorInstaller.csproj
@@ -20,10 +20,10 @@
PerformanceMonitorInstaller
SQL Server Performance Monitor Installer
- 2.3.0
- 2.3.0.0
- 2.3.0.0
- 2.3.0
+ 2.4.0
+ 2.4.0.0
+ 2.4.0.0
+ 2.4.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025
diff --git a/Installer/Program.cs b/Installer/Program.cs
index 0de82acb..4307c1eb 100644
--- a/Installer/Program.cs
+++ b/Installer/Program.cs
@@ -133,7 +133,9 @@ static async Task Main(string[] args)
Console.WriteLine("Licensed under the MIT License");
Console.WriteLine("https://github.com/erikdarlingdata/PerformanceMonitor");
Console.WriteLine("================================================================================");
- Console.WriteLine();
+
+ await CheckForInstallerUpdateAsync(version);
+
/*
Determine if running in automated mode (command-line arguments provided)
@@ -422,7 +424,7 @@ Test connection and get SQL Server version
using (var connection = new SqlConnection(builder.ConnectionString))
{
await connection.OpenAsync().ConfigureAwait(false);
- Console.WriteLine("Connection successful!");
+ WriteSuccess("Connection successful!");
/*Capture SQL Server version for summary report*/
using (var versionCmd = new SqlCommand(@"
@@ -468,7 +470,7 @@ Azure MI (EngineEdition 8) is always current, skip the check.*/
}
catch (Exception ex)
{
- Console.WriteLine($"Connection failed: {ex.Message}");
+ WriteError($"Connection failed: {ex.Message}");
Console.WriteLine($"Exception type: {ex.GetType().Name}");
if (ex.InnerException != null)
{
@@ -655,7 +657,7 @@ Traces are server-level and persist after database drops
}
}
- Console.WriteLine("✓ Clean install completed (jobs and database removed)");
+ WriteSuccess("Clean install completed (jobs and database removed)");
}
catch (Exception ex)
{
@@ -736,7 +738,7 @@ Traces are server-level and persist after database drops
{
Console.WriteLine();
Console.WriteLine("================================================================================");
- Console.WriteLine("Installation aborted: upgrade scripts must succeed before installation can proceed.");
+ WriteError("Installation aborted: upgrade scripts must succeed before installation can proceed.");
Console.WriteLine("Fix the errors above and re-run the installer.");
Console.WriteLine("================================================================================");
if (!automatedMode)
@@ -854,12 +856,12 @@ Match GO only when it's a whole word on its own line
}
}
- Console.WriteLine("✓ Success");
+ WriteSuccess("Success");
installSuccessCount++;
}
catch (Exception ex)
{
- Console.WriteLine($"✗ FAILED");
+ WriteError("FAILED");
Console.WriteLine($" Error: {ex.Message}");
installFailureCount++;
installationErrors.Add((fileName, ex.Message));
@@ -938,7 +940,7 @@ Use SYSDATETIME() (local) because collection_time is stored in server local time
command.CommandTimeout = LongTimeoutSeconds;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
- Console.WriteLine("✓ Success");
+ WriteSuccess("Success");
/*
Verify data was collected — only from this validation run, not historical errors
@@ -1089,7 +1091,7 @@ WHERE t.name LIKE 'query_snapshots_%'
}
}
- Console.WriteLine("✓ Success");
+ WriteSuccess("Success");
installFailureCount = 0; /* Reset failure count */
}
}
@@ -1130,7 +1132,7 @@ Calculate totals and determine success
*/
totalSuccessCount = upgradeSuccessCount + installSuccessCount;
totalFailureCount = upgradeFailureCount + installFailureCount;
- installationSuccessful = (totalFailureCount == 0) || (totalFailureCount == 1 && automatedMode);
+ installationSuccessful = totalFailureCount == 0;
/*
Log installation history to database
@@ -1159,7 +1161,7 @@ await LogInstallationHistory(
if (installationSuccessful)
{
- Console.WriteLine("Installation completed successfully!");
+ WriteSuccess("Installation completed successfully!");
Console.WriteLine();
Console.WriteLine("WHAT WAS INSTALLED:");
Console.WriteLine("✓ PerformanceMonitor database and all collection tables");
@@ -1179,7 +1181,7 @@ await LogInstallationHistory(
}
else
{
- Console.WriteLine($"Installation completed with {totalFailureCount} error(s).");
+ WriteWarning($"Installation completed with {totalFailureCount} error(s).");
Console.WriteLine("Review errors above and check PerformanceMonitor.config.collection_log for details.");
}
@@ -1307,7 +1309,7 @@ private static async Task PerformUninstallAsync(string connectionString, bo
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
Console.WriteLine();
- Console.WriteLine("✓ Uninstall completed successfully");
+ WriteSuccess("Uninstall completed successfully");
Console.WriteLine();
Console.WriteLine("Note: blocked process threshold (s) was NOT reset.");
}
@@ -1550,12 +1552,12 @@ Execute an upgrade folder
}
}
- Console.WriteLine("✓ Success");
+ WriteSuccess("Success");
successCount++;
}
catch (Exception ex)
{
- Console.WriteLine($"✗ FAILED");
+ WriteError("FAILED");
Console.WriteLine($" Error: {ex.Message}");
failureCount++;
}
@@ -1899,7 +1901,7 @@ private static async Task InstallDependenciesAsync(string connectionString)
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
}
- Console.WriteLine("✓ Success");
+ WriteSuccess("Success");
Console.WriteLine($" {description}");
successCount++;
}
@@ -2050,6 +2052,70 @@ Write file
return reportPath;
}
+ private static void WriteSuccess(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.Write("√ ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static void WriteError(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Write("✗ ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static void WriteWarning(string message)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.Write("! ");
+ Console.ResetColor();
+ Console.WriteLine(message);
+ }
+
+ private static async Task CheckForInstallerUpdateAsync(string currentVersion)
+ {
+ try
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor");
+ client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
+
+ var response = await client.GetAsync(
+ "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest")
+ .ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode) return;
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
+ var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? "";
+ var versionString = tagName.TrimStart('v', 'V');
+
+ if (!Version.TryParse(versionString, out var latest)) return;
+ if (!Version.TryParse(currentVersion, out var current)) return;
+
+ if (latest > current)
+ {
+ Console.WriteLine();
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
+ Console.WriteLine($"║ A newer version ({tagName}) is available! ");
+ Console.WriteLine("║ https://github.com/erikdarlingdata/PerformanceMonitor/releases ");
+ Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
+ Console.ResetColor();
+ Console.WriteLine();
+ }
+ }
+ catch
+ {
+ /* Best effort — don't block installation if GitHub is unreachable */
+ }
+ }
+
[GeneratedRegex(@"^\s*GO\s*(?:--[^\r\n]*)?\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex GoBatchRegExp();
}
diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj
index c10b089b..df994fc9 100644
--- a/InstallerGui/InstallerGui.csproj
+++ b/InstallerGui/InstallerGui.csproj
@@ -8,10 +8,10 @@
PerformanceMonitorInstallerGui
PerformanceMonitorInstallerGui
SQL Server Performance Monitor Installer
- 2.3.0
- 2.3.0.0
- 2.3.0.0
- 2.3.0
+ 2.4.0
+ 2.4.0.0
+ 2.4.0.0
+ 2.4.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
diff --git a/InstallerGui/MainWindow.xaml.cs b/InstallerGui/MainWindow.xaml.cs
index 6725cfca..c7d80b70 100644
--- a/InstallerGui/MainWindow.xaml.cs
+++ b/InstallerGui/MainWindow.xaml.cs
@@ -83,11 +83,12 @@ public MainWindow()
}
}
- private void MainWindow_Loaded(object sender, RoutedEventArgs e)
+ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
try
{
FindInstallationFiles();
+ await CheckForInstallerUpdateAsync();
}
catch (Exception ex)
{
@@ -779,6 +780,38 @@ protected override void OnClosed(EventArgs e)
///
/// Report progress from installation service
///
+ private async Task CheckForInstallerUpdateAsync()
+ {
+ try
+ {
+ using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor");
+ client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
+
+ var response = await client.GetAsync(
+ "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest");
+
+ if (!response.IsSuccessStatusCode) return;
+
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
+ var tagName = doc.RootElement.GetProperty("tag_name").GetString() ?? "";
+ var versionString = tagName.TrimStart('v', 'V');
+
+ if (!Version.TryParse(versionString, out var latest)) return;
+ if (!Version.TryParse(AppAssemblyVersion, out var current)) return;
+
+ if (latest > current)
+ {
+ LogMessage($"A newer version ({tagName}) is available at https://github.com/erikdarlingdata/PerformanceMonitor/releases", "Warning");
+ }
+ }
+ catch
+ {
+ /* Best effort — don't block installation if GitHub is unreachable */
+ }
+ }
+
private void ReportProgress(InstallationProgress progress)
{
LogMessage(progress.Message, progress.Status);
diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs
index 7162058d..0f0c91a4 100644
--- a/InstallerGui/Services/InstallationService.cs
+++ b/InstallerGui/Services/InstallationService.cs
@@ -620,10 +620,7 @@ Execute SQL files
result.EndTime = DateTime.Now;
- /*Allow query_snapshots to fail - those views get created eventually*/
- bool onlyQuerySnapshotsFailed = result.FilesFailed == 1 &&
- result.Errors.Any(e => e.FileName.Contains("query_snapshots", StringComparison.OrdinalIgnoreCase));
- result.Success = result.FilesFailed == 0 || onlyQuerySnapshotsFailed;
+ result.Success = result.FilesFailed == 0;
return result;
}
diff --git a/Lite.Tests/FactCollectorMiseryTests.cs b/Lite.Tests/FactCollectorMiseryTests.cs
index b22b2130..288700d0 100644
--- a/Lite.Tests/FactCollectorMiseryTests.cs
+++ b/Lite.Tests/FactCollectorMiseryTests.cs
@@ -279,7 +279,7 @@ public async Task SingleCollectionPoint_CpuStillCollected()
// Insert just one CPU data point manually
using var readLock = _duckDb.AcquireReadLock();
using var connection = _duckDb.CreateConnection();
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -292,7 +292,7 @@ INSERT INTO cpu_utilization_stats
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerName });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestPeriodEnd.AddMinutes(-30) });
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
var collector = new DuckDbFactCollector(_duckDb);
var context = TestDataSeeder.CreateTestContext();
@@ -733,7 +733,7 @@ public async Task TraceFlags_OnlySessionFlags_ProducesNoFact()
// Seed a session-level trace flag (not global)
using var readLock = _duckDb.AcquireReadLock();
using var connection = _duckDb.CreateConnection();
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -745,7 +745,7 @@ INSERT INTO trace_flags
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestPeriodEnd });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerName });
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
var collector = new DuckDbFactCollector(_duckDb);
var context = TestDataSeeder.CreateTestContext();
@@ -773,7 +773,7 @@ public async Task QueryStats_ZeroExecutions_ProducesNoFacts()
// Seed query_stats with delta_execution_count = 0 (stale row)
using var readLock = _duckDb.AcquireReadLock();
using var connection = _duckDb.CreateConnection();
- await connection.OpenAsync();
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
using var cmd = connection.CreateCommand();
cmd.CommandText = @"
@@ -786,7 +786,7 @@ INSERT INTO query_stats
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestPeriodEnd.AddMinutes(-30) });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerId });
cmd.Parameters.Add(new DuckDBParameter { Value = TestDataSeeder.TestServerName });
- await cmd.ExecuteNonQueryAsync();
+ await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
var collector = new DuckDbFactCollector(_duckDb);
var context = TestDataSeeder.CreateTestContext();
diff --git a/Lite/Analysis/DrillDownCollector.cs b/Lite/Analysis/DrillDownCollector.cs
index 928d20fa..b3d515ed 100644
--- a/Lite/Analysis/DrillDownCollector.cs
+++ b/Lite/Analysis/DrillDownCollector.cs
@@ -74,7 +74,7 @@ public async Task EnrichFindingsAsync(List findings, AnalysisCo
if (pathKeys.Contains("MEMORY_GRANT_PENDING"))
await CollectPendingGrants(finding, context);
- if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_")))
+ if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)))
await CollectBadActorDetail(finding, context);
// Plan analysis: for findings with top queries, analyze their cached plans
@@ -563,7 +563,7 @@ private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext
// Only analyze plans for bad actor findings (1 plan each).
// Skip top_cpu_queries (5 plans would be too heavy).
- if (!finding.RootFactKey.StartsWith("BAD_ACTOR_")) return;
+ if (!finding.RootFactKey.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) return;
var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", "");
if (string.IsNullOrEmpty(queryHash)) return;
diff --git a/Lite/Analysis/DuckDbFactCollector.cs b/Lite/Analysis/DuckDbFactCollector.cs
index 24644d5d..b6b12b97 100644
--- a/Lite/Analysis/DuckDbFactCollector.cs
+++ b/Lite/Analysis/DuckDbFactCollector.cs
@@ -1563,15 +1563,15 @@ private static void GroupParallelismWaits(List facts, AnalysisContext cont
///
private static bool IsGeneralLockWait(string waitType)
{
- if (!waitType.StartsWith("LCK_M_")) return false;
+ if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false;
// Keep individual: reader/writer locks
if (waitType is "LCK_M_S" or "LCK_M_IS") return false;
// Keep individual: range locks (serializable/repeatable read)
- if (waitType.StartsWith("LCK_M_RS_") ||
- waitType.StartsWith("LCK_M_RIn_") ||
- waitType.StartsWith("LCK_M_RX_")) return false;
+ if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) ||
+ waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) ||
+ waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false;
// Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) → group
return true;
diff --git a/Lite/Analysis/FactScorer.cs b/Lite/Analysis/FactScorer.cs
index a9fc97e5..1ec47f7a 100644
--- a/Lite/Analysis/FactScorer.cs
+++ b/Lite/Analysis/FactScorer.cs
@@ -308,8 +308,8 @@ private static double ScoreBadActorFact(Fact fact)
///
private static double ScoreAnomalyFact(Fact fact)
{
- if (fact.Key.StartsWith("ANOMALY_CPU_SPIKE") || fact.Key.StartsWith("ANOMALY_READ_LATENCY")
- || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY"))
+ if (fact.Key.StartsWith("ANOMALY_CPU_SPIKE", StringComparison.OrdinalIgnoreCase) || fact.Key.StartsWith("ANOMALY_READ_LATENCY", StringComparison.OrdinalIgnoreCase)
+ || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY", StringComparison.OrdinalIgnoreCase))
{
// Deviation-based scoring: 2σ = 0.5, 4σ = 1.0
var deviation = fact.Metadata.GetValueOrDefault("deviation_sigma");
@@ -319,7 +319,7 @@ private static double ScoreAnomalyFact(Fact fact)
return base_score * confidence;
}
- if (fact.Key.StartsWith("ANOMALY_WAIT_"))
+ if (fact.Key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase))
{
// Ratio-based scoring: 5x = 0.5, 20x = 1.0
var ratio = fact.Metadata.GetValueOrDefault("ratio");
@@ -327,7 +327,7 @@ private static double ScoreAnomalyFact(Fact fact)
return 0.5 + 0.5 * Math.Min((ratio - 5.0) / 15.0, 1.0);
}
- if (fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE") || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE"))
+ if (fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE", StringComparison.OrdinalIgnoreCase) || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE", StringComparison.OrdinalIgnoreCase))
{
// Ratio-based: 3x = 0.5, 10x = 1.0
var ratio = fact.Metadata.GetValueOrDefault("ratio");
diff --git a/Lite/App.xaml b/Lite/App.xaml
index 63ae9596..b3e581bc 100644
--- a/Lite/App.xaml
+++ b/Lite/App.xaml
@@ -1,7 +1,7 @@
+ >
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index 1631db19..a9da026d 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -76,6 +76,7 @@ public partial class App : Application
public static int AlertLongRunningJobMultiplier { get; set; } = 3;
public static int AlertCooldownMinutes { get; set; } = 5; // Tray notification cooldown between repeated alerts
public static int EmailCooldownMinutes { get; set; } = 15; // Email cooldown between repeated alerts
+ public static string MuteRuleDefaultExpiration { get; set; } = "24 hours"; // Default expiration for new mute rules
/* Connection settings */
public static int ConnectionTimeoutSeconds { get; set; } = 5;
@@ -166,18 +167,19 @@ protected override void OnStartup(StartupEventArgs e)
base.OnStartup(e);
- // Initialize paths - use executable directory for portability
- var exeDirectory = AppDomain.CurrentDomain.BaseDirectory;
- DataDirectory = exeDirectory;
- ConfigDirectory = Path.Combine(exeDirectory, "config");
- DatabasePath = Path.Combine(exeDirectory, "monitor.duckdb");
- ArchiveDirectory = Path.Combine(exeDirectory, "archive");
+ // Initialize paths — store data in %LOCALAPPDATA% so Velopack updates
+ // can replace the app directory without losing data
+ var appDataRoot = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "PerformanceMonitorLite");
+ DataDirectory = appDataRoot;
+ ConfigDirectory = Path.Combine(appDataRoot, "config");
+ DatabasePath = Path.Combine(appDataRoot, "monitor.duckdb");
+ ArchiveDirectory = Path.Combine(appDataRoot, "archive");
// Ensure directories exist
- if (!Directory.Exists(ConfigDirectory))
- {
- Directory.CreateDirectory(ConfigDirectory);
- }
+ Directory.CreateDirectory(ConfigDirectory);
+ Directory.CreateDirectory(Path.Combine(appDataRoot, "archive"));
// Load settings
LoadDefaultTimeRange();
@@ -187,7 +189,7 @@ protected override void OnStartup(StartupEventArgs e)
Helpers.ThemeManager.Apply(ColorTheme);
// Initialize logging
- var logDirectory = Path.Combine(exeDirectory, "logs");
+ var logDirectory = Path.Combine(appDataRoot, "logs");
AppLogger.Initialize(logDirectory);
Helpers.MethodProfiler.Initialize(logDirectory);
Helpers.QueryLogger.Initialize(logDirectory);
@@ -198,6 +200,10 @@ protected override void OnStartup(StartupEventArgs e)
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
DispatcherUnhandledException += OnDispatcherUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+
+ // Create and show main window (StartupUri removed for Velopack custom Main)
+ var mainWindow = new MainWindow();
+ mainWindow.Show();
}
protected override void OnExit(ExitEventArgs e)
@@ -272,6 +278,12 @@ public static void LoadAlertSettings()
if (root.TryGetProperty("alert_long_running_job_multiplier", out v)) AlertLongRunningJobMultiplier = v.GetInt32();
if (root.TryGetProperty("alert_cooldown_minutes", out v)) AlertCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120);
if (root.TryGetProperty("email_cooldown_minutes", out v)) EmailCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120);
+ if (root.TryGetProperty("mute_rule_default_expiration", out v))
+ {
+ var exp = v.GetString();
+ if (exp is "1 hour" or "24 hours" or "7 days" or "Never")
+ MuteRuleDefaultExpiration = exp;
+ }
/* Connection settings */
if (root.TryGetProperty("connection_timeout_seconds", out v))
diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs
index f4e86126..afd24e7b 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml.cs
+++ b/Lite/Controls/AlertsHistoryTab.xaml.cs
@@ -444,6 +444,7 @@ private async void MuteThisAlert_Click(object sender, RoutedEventArgs e)
ServerName = item.ServerName,
MetricName = item.MetricName
};
+ context.PopulateFromDetailText(item.DetailText);
var dialog = new Windows.MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
if (dialog.ShowDialog() == true)
diff --git a/Lite/Controls/PlanViewerControl.xaml.cs b/Lite/Controls/PlanViewerControl.xaml.cs
index b740cde6..fc40d6a4 100644
--- a/Lite/Controls/PlanViewerControl.xaml.cs
+++ b/Lite/Controls/PlanViewerControl.xaml.cs
@@ -471,7 +471,7 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child)
};
}
- private object BuildEdgeTooltipContent(PlanNode child)
+ private Border BuildEdgeTooltipContent(PlanNode child)
{
var grid = new Grid { MinWidth = 240 };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 35b41532..dcd630c1 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -113,6 +113,14 @@
+
+
+
+
+
+
+
@@ -207,10 +215,12 @@
+
-
+
+
-
+ CanUserSortColumns="True"
+ Sorting="QuerySnapshotsGrid_Sorting">
@@ -320,16 +331,29 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -437,13 +461,21 @@
+
-
+
+
+
+
+
+
@@ -455,6 +487,12 @@
+
+
+
+
+
+
@@ -521,9 +559,6 @@
-
-
-
@@ -538,13 +573,21 @@
+
-
+
+
+
+
+
+
@@ -556,6 +599,12 @@
+
+
+
+
+
+
@@ -583,9 +632,6 @@
-
-
-
@@ -610,9 +656,6 @@
-
-
-
@@ -645,6 +688,7 @@
+
@@ -1159,11 +1203,16 @@
+
-
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index b1c10dcc..f33b4ea4 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -25,6 +25,7 @@
using Microsoft.Win32;
using PerformanceMonitorLite.Database;
using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Services;
using ScottPlot;
@@ -104,6 +105,7 @@ public partial class ServerTab : UserControl
};
public int UtcOffsetMinutes { get; }
+ private readonly bool _hasMsdbAccess;
///
/// Raised after each data refresh with alert counts for tab badge display.
@@ -112,7 +114,7 @@ public partial class ServerTab : UserControl
public event Action? ApplyTimeRangeRequested; /* selectedIndex */
public event Func? ManualRefreshRequested;
- public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialService credentialService, int utcOffsetMinutes = 0)
+ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialService credentialService, int utcOffsetMinutes = 0, bool hasMsdbAccess = true)
{
InitializeComponent();
@@ -121,6 +123,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
_serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
_credentialService = credentialService;
UtcOffsetMinutes = utcOffsetMinutes;
+ _hasMsdbAccess = hasMsdbAccess;
ServerTimeHelper.UtcOffsetMinutes = utcOffsetMinutes;
ServerNameText.Text = server.ReadOnlyIntent ? $"{server.DisplayName} (Read-Only)" : server.DisplayName;
@@ -157,9 +160,26 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
};
_refreshTimer.Start();
+ /* Show warning on Running Jobs tab if login lacks msdb access */
+ if (!_hasMsdbAccess)
+ {
+ RunningJobsMsdbWarning.Visibility = System.Windows.Visibility.Visible;
+ }
+
/* Initialize time picker ComboBoxes */
InitializeTimeComboBoxes();
+ /* Sync time display mode picker */
+ var modeTag = ServerTimeHelper.CurrentDisplayMode.ToString();
+ for (int i = 0; i < TimeDisplayModeBox.Items.Count; i++)
+ {
+ if (TimeDisplayModeBox.Items[i] is ComboBoxItem item && item.Tag?.ToString() == modeTag)
+ {
+ TimeDisplayModeBox.SelectedIndex = i;
+ break;
+ }
+ }
+
/* Initialize column filter managers */
InitializeFilterManagers();
@@ -251,6 +271,11 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
Helpers.ThemeManager.ThemeChanged += OnThemeChanged;
Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged;
+ ActiveQueriesSlicer.RangeChanged += OnActiveQueriesSlicerChanged;
+ QueryStatsSlicer.RangeChanged += OnQueryStatsSlicerChanged;
+ ProcStatsSlicer.RangeChanged += OnProcStatsSlicerChanged;
+ QueryStoreSlicer.RangeChanged += OnQueryStoreSlicerChanged;
+
/* Initial load is triggered by MainWindow.ConnectToServer calling RefreshData()
after collectors finish - no Loaded handler needed */
@@ -402,6 +427,39 @@ private async void RefreshDataButton_Click(object sender, RoutedEventArgs e)
}
}
+ private void TimeDisplayMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded) return;
+ if (TimeDisplayModeBox.SelectedItem is not ComboBoxItem item) return;
+ var tag = item.Tag?.ToString();
+ var mode = tag switch
+ {
+ "LocalTime" => TimeDisplayMode.LocalTime,
+ "UTC" => TimeDisplayMode.UTC,
+ _ => TimeDisplayMode.ServerTime
+ };
+ if (mode == ServerTimeHelper.CurrentDisplayMode) return;
+
+ ServerTimeHelper.CurrentDisplayMode = mode;
+
+ // Refresh all DataGrid bindings so ServerTimeConverter re-evaluates
+ QuerySnapshotsGrid.Items.Refresh();
+ QueryStatsGrid.Items.Refresh();
+ ProcedureStatsGrid.Items.Refresh();
+ QueryStoreGrid.Items.Refresh();
+ BlockedProcessReportGrid.Items.Refresh();
+ DeadlockGrid.Items.Refresh();
+ RunningJobsGrid.Items.Refresh();
+ CollectionHealthGrid.Items.Refresh();
+ CollectionLogGrid.Items.Refresh();
+
+ // Refresh slicer labels
+ ActiveQueriesSlicer.Redraw();
+ QueryStatsSlicer.Redraw();
+ ProcStatsSlicer.Redraw();
+ QueryStoreSlicer.Redraw();
+ }
+
private async void TimeRangeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!IsLoaded) return;
@@ -719,13 +777,13 @@ await System.Threading.Tasks.Task.WhenAll(
_querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
LiveSnapshotIndicator.Text = "";
_queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
- SetInitialSort(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_procStatsFilterMgr!.UpdateData(procStatsTask.Result);
- SetInitialSort(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
_deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
_queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
- SetInitialSort(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
_serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
_databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
_dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
@@ -829,21 +887,25 @@ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, Dat
var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate);
_querySnapshotsFilterMgr!.UpdateData(snapshots);
LiveSnapshotIndicator.Text = "";
+ _ = LoadActiveQueriesSlicerAsync();
break;
case 2: // Top Queries by Duration
var queryStats = await _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
_queryStatsFilterMgr!.UpdateData(queryStats);
- SetInitialSort(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _ = LoadQueryStatsSlicerAsync();
break;
case 3: // Top Procedures by Duration
var procStats = await _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
_procStatsFilterMgr!.UpdateData(procStats);
- SetInitialSort(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _ = LoadProcStatsSlicerAsync();
break;
case 4: // Query Store by Duration
var qsData = await _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate);
_queryStoreFilterMgr!.UpdateData(qsData);
- SetInitialSort(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ _ = LoadQueryStoreSlicerAsync();
break;
}
return;
@@ -865,12 +927,18 @@ await System.Threading.Tasks.Task.WhenAll(
_querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
LiveSnapshotIndicator.Text = "";
+
+ _ = LoadActiveQueriesSlicerAsync();
+
_queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
- SetInitialSort(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _ = LoadQueryStatsSlicerAsync();
_procStatsFilterMgr!.UpdateData(procStatsTask.Result);
- SetInitialSort(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _ = LoadProcStatsSlicerAsync();
_queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
- SetInitialSort(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ SetDefaultSortIfNone(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ _ = LoadQueryStoreSlicerAsync();
UpdateQueryDurationTrendChart(queryDurationTrendTask.Result);
UpdateProcDurationTrendChart(procDurationTrendTask.Result);
@@ -3622,6 +3690,373 @@ private async void GetActualPlan_Click(object sender, RoutedEventArgs e)
catch { return null; }
}
+ // ── Active Queries Slicer ──
+
+ private async System.Threading.Tasks.Task LoadActiveQueriesSlicerAsync()
+ {
+ try
+ {
+ var hoursBack = GetHoursBack();
+ DateTime? fromDate = null, toDate = null;
+ if (IsCustomRange)
+ {
+ var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo);
+ var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo);
+ if (fromLocal.HasValue && toLocal.HasValue)
+ {
+ fromDate = ServerTimeHelper.LocalToServerTime(fromLocal.Value);
+ toDate = ServerTimeHelper.LocalToServerTime(toLocal.Value);
+ }
+ }
+
+ var data = await _dataService.GetActiveQuerySlicerDataAsync(_serverId, hoursBack, fromDate, toDate);
+ _activeQueriesSlicerData = data;
+ _activeQueriesSlicerMetric = "Sessions";
+ if (data.Count > 0)
+ ActiveQueriesSlicer.LoadData(data, "Sessions");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadActiveQueriesSlicerAsync failed: {ex.Message}");
+ }
+ }
+
+ private string _activeQueriesSlicerMetric = "Sessions";
+ private List? _activeQueriesSlicerData;
+
+ private void QuerySnapshotsGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_activeQueriesSlicerData == null || _activeQueriesSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col))
+ {
+ // Fall back to binding path
+ if (e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b)
+ col = b.Path.Path;
+ }
+ var (metric, label) = col switch
+ {
+ "CpuTimeMs" => ("TotalCpu", "Total CPU (ms)"),
+ "TotalElapsedTimeMs" => ("TotalElapsed", "Total Elapsed (ms)"),
+ "Reads" => ("TotalReads", "Total Reads"),
+ "LogicalReads" => ("TotalLogicalReads", "Total Logical Reads"),
+ "Writes" => ("TotalWrites", "Total Writes"),
+ _ => ("Sessions", "Sessions"),
+ };
+
+ if (metric == _activeQueriesSlicerMetric) return;
+ _activeQueriesSlicerMetric = metric;
+
+ foreach (var bucket in _activeQueriesSlicerData)
+ {
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "TotalReads" => bucket.TotalReads,
+ "TotalLogicalReads" => bucket.TotalLogicalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ _ => bucket.SessionCount,
+ };
+ }
+
+ ActiveQueriesSlicer.UpdateMetric(label);
+ }
+
+ private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ try
+ {
+ // Slicer sends UTC dates; GetTimeRange expects server time for fromDate/toDate
+ var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc);
+ var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
+
+ var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromServer, toServer);
+ _querySnapshotsFilterMgr!.UpdateData(snapshots);
+ LiveSnapshotIndicator.Text = "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnActiveQueriesSlicerChanged failed: {ex.Message}");
+ }
+ }
+
+ // ── Query Stats Slicer ──
+
+ private string _queryStatsSlicerMetric = "TotalCpu";
+ private List? _queryStatsSlicerData;
+
+ private async System.Threading.Tasks.Task LoadQueryStatsSlicerAsync()
+ {
+ try
+ {
+ var hoursBack = GetHoursBack();
+ DateTime? fromDate = null, toDate = null;
+ if (IsCustomRange)
+ {
+ var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo);
+ var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo);
+ if (fromLocal.HasValue && toLocal.HasValue)
+ {
+ fromDate = ServerTimeHelper.LocalToServerTime(fromLocal.Value);
+ toDate = ServerTimeHelper.LocalToServerTime(toLocal.Value);
+ }
+ }
+
+ var data = await _dataService.GetQueryStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate);
+ _queryStatsSlicerData = data;
+ _queryStatsSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ QueryStatsSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadQueryStatsSlicerAsync failed: {ex.Message}");
+ }
+ }
+
+ private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ try
+ {
+ var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc);
+ var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
+ var queryStats = await _dataService.GetTopQueriesByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes);
+ _queryStatsFilterMgr!.UpdateData(queryStats);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnQueryStatsSlicerChanged failed: {ex.Message}");
+ }
+ }
+
+ private void QueryStatsGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_queryStatsSlicerData == null || _queryStatsSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b)
+ col = b.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"),
+ "AvgCpuMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "TotalElapsedMs" => ("TotalElapsed", "Total Duration (ms)"),
+ "AvgElapsedMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "TotalLogicalReads" => ("TotalReads", "Total Reads"),
+ "AvgReads" => ("AvgReads", "Avg Reads"),
+ "TotalLogicalWrites" => ("TotalWrites", "Total Writes"),
+ "TotalPhysicalReads" => ("TotalPhysReads", "Total Physical Reads"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _queryStatsSlicerMetric) return;
+ _queryStatsSlicerMetric = metric;
+
+ foreach (var bucket in _queryStatsSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "AvgReads" => bucket.TotalReads / n,
+ "TotalWrites" => bucket.TotalWrites,
+ "TotalPhysReads" => bucket.TotalLogicalReads,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ QueryStatsSlicer.UpdateMetric(label);
+ }
+
+ // ── Query Store Slicer ──
+
+ private string _queryStoreSlicerMetric = "TotalCpu";
+ private List? _queryStoreSlicerData;
+
+ private async System.Threading.Tasks.Task LoadQueryStoreSlicerAsync()
+ {
+ try
+ {
+ var hoursBack = GetHoursBack();
+ DateTime? fromDate = null, toDate = null;
+ if (IsCustomRange)
+ {
+ var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo);
+ var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo);
+ if (fromLocal.HasValue && toLocal.HasValue)
+ {
+ fromDate = ServerTimeHelper.LocalToServerTime(fromLocal.Value);
+ toDate = ServerTimeHelper.LocalToServerTime(toLocal.Value);
+ }
+ }
+
+ var data = await _dataService.GetQueryStoreSlicerDataAsync(_serverId, hoursBack, fromDate, toDate);
+ _queryStoreSlicerData = data;
+ _queryStoreSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ QueryStoreSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadQueryStoreSlicerAsync failed: {ex.Message}");
+ }
+ }
+
+ private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ try
+ {
+ var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc);
+ var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
+ var qsData = await _dataService.GetQueryStoreTopQueriesAsync(_serverId, 0, 50, fromServer, toServer);
+ _queryStoreFilterMgr!.UpdateData(qsData);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnQueryStoreSlicerChanged failed: {ex.Message}");
+ }
+ }
+
+ private void QueryStoreGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_queryStoreSlicerData == null || _queryStoreSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b)
+ col = b.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"),
+ "AvgCpuTimeMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "TotalDurationMs" => ("TotalElapsed", "Total Duration (ms)"),
+ "AvgDurationMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "AvgLogicalReads" => ("TotalReads", "Avg Reads"),
+ "AvgLogicalWrites" => ("TotalWrites", "Avg Writes"),
+ "AvgPhysicalReads" => ("TotalReads", "Avg Physical Reads"),
+ "TotalExecutions" => ("Sessions", "Executions"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _queryStoreSlicerMetric) return;
+ _queryStoreSlicerMetric = metric;
+
+ foreach (var bucket in _queryStoreSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ "Sessions" => bucket.SessionCount,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ QueryStoreSlicer.UpdateMetric(label);
+ }
+
+ // ── Procedure Stats Slicer ──
+
+ private string _procStatsSlicerMetric = "TotalCpu";
+ private List? _procStatsSlicerData;
+
+ private async System.Threading.Tasks.Task LoadProcStatsSlicerAsync()
+ {
+ try
+ {
+ var hoursBack = GetHoursBack();
+ DateTime? fromDate = null, toDate = null;
+ if (IsCustomRange)
+ {
+ var fromLocal = GetDateTimeFromPickers(FromDatePicker!, FromHourCombo, FromMinuteCombo);
+ var toLocal = GetDateTimeFromPickers(ToDatePicker!, ToHourCombo, ToMinuteCombo);
+ if (fromLocal.HasValue && toLocal.HasValue)
+ {
+ fromDate = ServerTimeHelper.LocalToServerTime(fromLocal.Value);
+ toDate = ServerTimeHelper.LocalToServerTime(toLocal.Value);
+ }
+ }
+
+ var data = await _dataService.GetProcStatsSlicerDataAsync(_serverId, hoursBack, fromDate, toDate);
+ _procStatsSlicerData = data;
+ _procStatsSlicerMetric = "TotalCpu";
+ if (data.Count > 0)
+ ProcStatsSlicer.LoadData(data, "Total CPU (ms)");
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] LoadProcStatsSlicerAsync failed: {ex.Message}");
+ }
+ }
+
+ private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e)
+ {
+ try
+ {
+ var fromServer = ServerTimeHelper.ToServerTime(e.StartUtc);
+ var toServer = ServerTimeHelper.ToServerTime(e.EndUtc);
+ var procStats = await _dataService.GetTopProceduresByCpuAsync(_serverId, 0, 50, fromServer, toServer, UtcOffsetMinutes);
+ _procStatsFilterMgr!.UpdateData(procStats);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] OnProcStatsSlicerChanged failed: {ex.Message}");
+ }
+ }
+
+ private void ProcedureStatsGrid_Sorting(object sender, DataGridSortingEventArgs e)
+ {
+ if (_procStatsSlicerData == null || _procStatsSlicerData.Count == 0) return;
+
+ var col = e.Column.SortMemberPath ?? "";
+ if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b)
+ col = b.Path.Path;
+
+ var (metric, label) = col switch
+ {
+ "TotalCpuMs" => ("TotalCpu", "Total CPU (ms)"),
+ "AvgCpuMs" => ("AvgCpu", "Avg CPU (ms)"),
+ "TotalElapsedMs" => ("TotalElapsed", "Total Duration (ms)"),
+ "AvgElapsedMs" => ("AvgElapsed", "Avg Duration (ms)"),
+ "TotalLogicalReads" or "AvgReads" => ("TotalReads", "Total Reads"),
+ "TotalLogicalWrites" => ("TotalWrites", "Total Writes"),
+ "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"),
+ _ => ("TotalCpu", "Total CPU (ms)"),
+ };
+
+ if (metric == _procStatsSlicerMetric) return;
+ _procStatsSlicerMetric = metric;
+
+ foreach (var bucket in _procStatsSlicerData)
+ {
+ var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1;
+ bucket.Value = metric switch
+ {
+ "TotalCpu" => bucket.TotalCpu,
+ "AvgCpu" => bucket.TotalCpu / n,
+ "TotalElapsed" => bucket.TotalElapsed,
+ "AvgElapsed" => bucket.TotalElapsed / n,
+ "TotalReads" => bucket.TotalReads,
+ "TotalWrites" => bucket.TotalWrites,
+ _ => bucket.TotalCpu,
+ };
+ }
+
+ ProcStatsSlicer.UpdateMetric(label);
+ }
+
private async void LiveSnapshot_Click(object sender, RoutedEventArgs e)
{
LiveSnapshotButton.IsEnabled = false;
@@ -3815,7 +4250,7 @@ private void UpdateCollectorDurationChart(List data)
private void OpenLogFile_Click(object sender, RoutedEventArgs e)
{
- var logDir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
+ var logDir = System.IO.Path.Combine(App.DataDirectory, "logs");
var logFile = System.IO.Path.Combine(logDir, $"lite_{DateTime.Now:yyyyMMdd}.log");
if (File.Exists(logFile))
@@ -3941,8 +4376,11 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
return null;
}
- private static void SetInitialSort(DataGrid grid, string bindingPath, ListSortDirection direction)
+ private static void SetDefaultSortIfNone(DataGrid grid, string bindingPath, ListSortDirection direction)
{
+ if (grid.Items.SortDescriptions.Count > 0) return;
+
+ grid.Items.SortDescriptions.Add(new SortDescription(bindingPath, direction));
foreach (var column in grid.Columns)
{
if (column is DataGridBoundColumn bc &&
diff --git a/Lite/Controls/TimeRangeSlicerControl.xaml b/Lite/Controls/TimeRangeSlicerControl.xaml
new file mode 100644
index 00000000..8fe344be
--- /dev/null
+++ b/Lite/Controls/TimeRangeSlicerControl.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/TimeRangeSlicerControl.xaml.cs b/Lite/Controls/TimeRangeSlicerControl.xaml.cs
new file mode 100644
index 00000000..761581b9
--- /dev/null
+++ b/Lite/Controls/TimeRangeSlicerControl.xaml.cs
@@ -0,0 +1,401 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Controls;
+
+public partial class TimeRangeSlicerControl : UserControl
+{
+ private List _data = new();
+ private string _metricLabel = "Sessions";
+ private bool _isExpanded = true;
+
+ // Range as normalised [0..1] positions within the data span
+ private double _rangeStart;
+ private double _rangeEnd = 1.0;
+
+ private const double HandleWidthPx = 8;
+ private const double HandleGripWidthPx = 20;
+ private const double MinRangeNorm = 0.02; // minimum ~2% of total span
+ private const double ChartPaddingTop = 16;
+ private const double ChartPaddingBottom = 20;
+
+ private enum DragMode { None, MoveRange, DragStart, DragEnd }
+ private DragMode _dragMode = DragMode.None;
+ private double _dragOriginX;
+ private double _dragOriginRangeStart;
+ private double _dragOriginRangeEnd;
+
+ ///
+ /// Fired when the user finishes adjusting the slicer handles.
+ /// StartUtc/EndUtc are in UTC (matching DuckDB collection_time).
+ ///
+ public event EventHandler? RangeChanged;
+
+ public TimeRangeSlicerControl()
+ {
+ InitializeComponent();
+ SlicerBorder.SizeChanged += (_, _) => Redraw();
+ IsVisibleChanged += (_, _) => { if (IsVisible) Redraw(); };
+ }
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set
+ {
+ _isExpanded = value;
+ SlicerBorder.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed;
+ ToggleIcon.Text = _isExpanded ? "▾" : "▸";
+ }
+ }
+
+ ///
+ /// Loads slicer data. All timestamps must be UTC.
+ /// Selection defaults to the full range (no filtering until user interacts).
+ ///
+ public void LoadData(List data, string metricLabel)
+ {
+ // Preserve selection if we already have data (auto-refresh)
+ DateTime? prevStart = null, prevEnd = null;
+ if (_data.Count > 0 && (_rangeStart > 0 || _rangeEnd < 1.0))
+ {
+ prevStart = UtcAtNorm(_rangeStart);
+ prevEnd = UtcAtNorm(_rangeEnd);
+ }
+
+ _data = data;
+ _metricLabel = metricLabel;
+
+ if (prevStart.HasValue && prevEnd.HasValue && _data.Count >= 2)
+ {
+ _rangeStart = NormAtUtc(prevStart.Value);
+ _rangeEnd = NormAtUtc(prevEnd.Value);
+ }
+ else
+ {
+ _rangeStart = 0;
+ _rangeEnd = 1.0;
+ }
+
+ UpdateRangeLabel();
+ Redraw();
+ }
+
+ /// Updates the metric label and redraws (when sort column changes).
+ public void UpdateMetric(string metricLabel)
+ {
+ _metricLabel = metricLabel;
+ Redraw();
+ }
+
+ public DateTime? SelectionStartUtc => _data.Count > 0 ? UtcAtNorm(_rangeStart) : null;
+ public DateTime? SelectionEndUtc => _data.Count > 0 ? UtcAtNorm(_rangeEnd) : null;
+
+ // ── Time mapping ──
+
+ private DateTime DataStartUtc => _data[0].BucketTimeUtc;
+ private DateTime DataEndUtc => _data[^1].BucketTimeUtc.AddHours(1);
+
+ private DateTime UtcAtNorm(double norm)
+ {
+ var ticks = DataStartUtc.Ticks + (long)((DataEndUtc.Ticks - DataStartUtc.Ticks) * norm);
+ return new DateTime(Math.Clamp(ticks, DataStartUtc.Ticks, DataEndUtc.Ticks), DateTimeKind.Utc);
+ }
+
+ private double NormAtUtc(DateTime utc)
+ {
+ var span = DataEndUtc.Ticks - DataStartUtc.Ticks;
+ if (span <= 0) return 0;
+ return Math.Clamp((double)(utc.Ticks - DataStartUtc.Ticks) / span, 0, 1);
+ }
+
+ // ── Drawing ──
+
+ public void Redraw()
+ {
+ SlicerCanvas.Children.Clear();
+ if (_data.Count < 2) return;
+
+ var w = SlicerBorder.ActualWidth;
+ var h = SlicerBorder.ActualHeight;
+ if (w <= 0 || h <= 0) return;
+
+ var values = _data.Select(d => d.Value).ToArray();
+ var max = values.Max();
+ if (max <= 0) max = 1;
+
+ var chartTop = ChartPaddingTop;
+ var chartBottom = h - ChartPaddingBottom;
+ var chartHeight = chartBottom - chartTop;
+ if (chartHeight <= 0) return;
+
+ var n = values.Length;
+
+ // Area chart — position points proportionally by time
+ var linePoints = new List(n);
+ for (int i = 0; i < n; i++)
+ {
+ var x = NormAtUtc(_data[i].BucketTimeUtc) * w;
+ var y = chartBottom - (values[i] / max) * chartHeight;
+ linePoints.Add(new Point(x, y));
+ }
+
+ var fillBrush = FindBrush("SlicerChartFillBrush", "#332EAEF1");
+ var areaGeo = new StreamGeometry();
+ using (var ctx = areaGeo.Open())
+ {
+ ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true, true);
+ foreach (var pt in linePoints) ctx.LineTo(pt, true, false);
+ ctx.LineTo(new Point(linePoints[^1].X, chartBottom), true, false);
+ }
+ SlicerCanvas.Children.Add(new Path { Data = areaGeo, Fill = fillBrush });
+
+ var lineBrush = FindBrush("SlicerChartLineBrush", "#2EAEF1");
+ var lineGeo = new StreamGeometry();
+ using (var ctx = lineGeo.Open())
+ {
+ ctx.BeginFigure(linePoints[0], false, false);
+ for (int i = 1; i < linePoints.Count; i++) ctx.LineTo(linePoints[i], true, false);
+ }
+ SlicerCanvas.Children.Add(new Path { Data = lineGeo, Stroke = lineBrush, StrokeThickness = 1.5 });
+
+ // X-axis labels — evenly spaced by TIME, skip if too close
+ var labelBrush = FindBrush("SlicerLabelBrush", "#99E4E6EB");
+ const double minLabelSpacingPx = 90;
+ double lastLabelX = -minLabelSpacingPx;
+ int targetLabels = Math.Max(2, (int)(w / minLabelSpacingPx));
+ var timeStep = (DataEndUtc - DataStartUtc).TotalHours / targetLabels;
+ for (int tick = 0; tick <= targetLabels; tick++)
+ {
+ var tickTime = DataStartUtc.AddHours(tick * timeStep);
+ var x = NormAtUtc(tickTime) * w;
+ if (x - lastLabelX < minLabelSpacingPx) continue;
+ if (x < 10 || x > w - 40) continue;
+ var dt = ServerTimeHelper.FormatServerTime(tickTime, "MM/dd HH:mm");
+ var tb = new TextBlock { Text = dt, FontSize = 9, Foreground = labelBrush };
+ Canvas.SetLeft(tb, x - 25);
+ Canvas.SetTop(tb, chartBottom + 2);
+ SlicerCanvas.Children.Add(tb);
+ lastLabelX = x;
+ }
+
+ // Metric label top-right
+ var metricBrush = FindBrush("SlicerToggleBrush", "#E4E6EB");
+ var metricTb = new TextBlock { Text = _metricLabel, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = metricBrush };
+ Canvas.SetLeft(metricTb, w - 120);
+ Canvas.SetTop(metricTb, 2);
+ SlicerCanvas.Children.Add(metricTb);
+
+ // Selection overlays
+ var overlayBrush = FindBrush("SlicerOverlayBrush", "#99000000");
+ var selectedBrush = FindBrush("SlicerSelectedBrush", "#22FFFFFF");
+ var handleBrush = FindBrush("SlicerHandleBrush", "#E4E6EB");
+
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ if (selLeft > 0) AddRect(0, 0, selLeft, h, overlayBrush);
+ if (selRight < w) AddRect(selRight, 0, w - selRight, h, overlayBrush);
+ AddRect(selLeft, 0, Math.Max(0, selRight - selLeft), h, selectedBrush);
+
+ DrawHandle(selLeft, h, handleBrush);
+ DrawHandle(selRight - HandleWidthPx, h, handleBrush);
+
+ AddLine(selLeft, 0, selRight, 0, handleBrush, 0.5);
+ AddLine(selLeft, h, selRight, h, handleBrush, 0.5);
+ }
+
+ private void AddRect(double x, double y, double width, double height, Brush fill)
+ {
+ var rect = new Rectangle { Width = width, Height = height, Fill = fill };
+ Canvas.SetLeft(rect, x); Canvas.SetTop(rect, y);
+ SlicerCanvas.Children.Add(rect);
+ }
+
+ private void AddLine(double x1, double y1, double x2, double y2, Brush stroke, double opacity)
+ {
+ SlicerCanvas.Children.Add(new Line
+ {
+ X1 = x1, Y1 = y1, X2 = x2, Y2 = y2,
+ Stroke = stroke, StrokeThickness = 1, Opacity = opacity
+ });
+ }
+
+ private void DrawHandle(double x, double canvasHeight, Brush brush)
+ {
+ AddRect(x, 0, HandleWidthPx, canvasHeight, brush);
+ ((Rectangle)SlicerCanvas.Children[^1]).Opacity = 0.7;
+ var midY = canvasHeight / 2;
+ for (int i = -1; i <= 1; i++)
+ {
+ SlicerCanvas.Children.Add(new Line
+ {
+ X1 = x + 2, Y1 = midY + i * 5, X2 = x + HandleWidthPx - 2, Y2 = midY + i * 5,
+ Stroke = Brushes.Black, StrokeThickness = 1, Opacity = 0.6
+ });
+ }
+ }
+
+ private Brush FindBrush(string key, string fallbackHex)
+ {
+ if (TryFindResource(key) is Brush b) return b;
+ return new SolidColorBrush((Color)ColorConverter.ConvertFromString(fallbackHex));
+ }
+
+ // ── Range label ──
+
+ private void UpdateRangeLabel()
+ {
+ if (_data.Count == 0) { RangeLabel.Text = ""; return; }
+ var startDisplay = ServerTimeHelper.FormatServerTime(UtcAtNorm(_rangeStart), "yyyy-MM-dd HH:mm");
+ var endDisplay = ServerTimeHelper.FormatServerTime(UtcAtNorm(_rangeEnd), "yyyy-MM-dd HH:mm");
+ var spanHours = (UtcAtNorm(_rangeEnd) - UtcAtNorm(_rangeStart)).TotalHours;
+ RangeLabel.Text = $"{startDisplay} \u2192 {endDisplay} ({spanHours:F0}h)";
+ }
+
+ // ── Mouse interaction ──
+
+ private void Toggle_Click(object sender, RoutedEventArgs e) => IsExpanded = !IsExpanded;
+
+ private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+ var pos = e.GetPosition(SlicerCanvas);
+
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+
+ _dragOriginX = pos.X;
+ _dragOriginRangeStart = _rangeStart;
+ _dragOriginRangeEnd = _rangeEnd;
+
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx)
+ { _dragMode = DragMode.DragStart; SlicerCanvas.CaptureMouse(); e.Handled = true; return; }
+ if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ { _dragMode = DragMode.DragEnd; SlicerCanvas.CaptureMouse(); e.Handled = true; return; }
+ if (pos.X >= selLeft && pos.X <= selRight)
+ { _dragMode = DragMode.MoveRange; SlicerCanvas.CaptureMouse(); e.Handled = true; }
+ }
+
+ private void Canvas_MouseMove(object sender, MouseEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+ var pos = e.GetPosition(SlicerCanvas);
+
+ if (_dragMode == DragMode.None)
+ {
+ var selLeft = _rangeStart * w;
+ var selRight = _rangeEnd * w;
+ if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || Math.Abs(pos.X - selRight) <= HandleGripWidthPx)
+ SlicerCanvas.Cursor = Cursors.SizeWE;
+ else if (pos.X >= selLeft && pos.X <= selRight)
+ SlicerCanvas.Cursor = Cursors.SizeAll;
+ else
+ SlicerCanvas.Cursor = Cursors.Arrow;
+ return;
+ }
+
+ var deltaNorm = (pos.X - _dragOriginX) / w;
+
+ switch (_dragMode)
+ {
+ case DragMode.DragStart:
+ _rangeStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - MinRangeNorm);
+ break;
+ case DragMode.DragEnd:
+ _rangeEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + MinRangeNorm, 1);
+ break;
+ case DragMode.MoveRange:
+ var span = _dragOriginRangeEnd - _dragOriginRangeStart;
+ var newStart = _dragOriginRangeStart + deltaNorm;
+ if (newStart < 0) newStart = 0;
+ if (newStart + span > 1) newStart = 1 - span;
+ _rangeStart = newStart;
+ _rangeEnd = newStart + span;
+ break;
+ }
+
+ UpdateRangeLabel();
+ Redraw();
+ e.Handled = true;
+ }
+
+ private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ if (_dragMode != DragMode.None)
+ {
+ _dragMode = DragMode.None;
+ SlicerCanvas.ReleaseMouseCapture();
+ FireRangeChanged();
+ e.Handled = true;
+ }
+ }
+
+ private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e)
+ {
+ if (_data.Count < 2) return;
+ if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return;
+
+ var w = SlicerBorder.ActualWidth;
+ if (w <= 0) return;
+
+ var pos = e.GetPosition(SlicerCanvas);
+ var pivot = Math.Clamp(pos.X / w, 0, 1);
+ var span = _rangeEnd - _rangeStart;
+
+ var zoomFactor = e.Delta > 0 ? 0.85 : 1.0 / 0.85;
+ var newSpan = Math.Clamp(span * zoomFactor, MinRangeNorm, 1.0);
+
+ var pivotInRange = (pivot - _rangeStart) / span;
+ var newStart = pivot - pivotInRange * newSpan;
+ var newEnd = newStart + newSpan;
+
+ if (newStart < 0) { newStart = 0; newEnd = newSpan; }
+ if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; }
+
+ _rangeStart = Math.Max(0, newStart);
+ _rangeEnd = Math.Min(1, newEnd);
+
+ UpdateRangeLabel();
+ Redraw();
+ FireRangeChanged();
+ e.Handled = true;
+ }
+
+ private void FireRangeChanged()
+ {
+ if (_data.Count == 0) return;
+ // Snap to hour boundaries so slider positions align with the hourly buckets shown in the graph
+ var startUtc = FloorToHour(UtcAtNorm(_rangeStart));
+ var endUtc = CeilToHour(UtcAtNorm(_rangeEnd));
+ RangeChanged?.Invoke(this, new SlicerRangeEventArgs(startUtc, endUtc));
+ }
+
+ private static DateTime FloorToHour(DateTime dt) =>
+ new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind);
+
+ private static DateTime CeilToHour(DateTime dt)
+ {
+ var floored = FloorToHour(dt);
+ return floored == dt ? dt : floored.AddHours(1);
+ }
+}
+
+public class SlicerRangeEventArgs : EventArgs
+{
+ public DateTime StartUtc { get; }
+ public DateTime EndUtc { get; }
+ public SlicerRangeEventArgs(DateTime startUtc, DateTime endUtc) { StartUtc = startUtc; EndUtc = endUtc; }
+}
diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml
index 14e32e03..1bd09ca9 100644
--- a/Lite/MainWindow.xaml
+++ b/Lite/MainWindow.xaml
@@ -175,6 +175,13 @@
+
public bool IsAwsRds { get; set; }
+ ///
+ /// Whether the connected login has access to msdb.
+ /// Used for gating collectors that query msdb system tables (e.g., running jobs).
+ ///
+ public bool HasMsdbAccess { get; set; } = true;
+
///
/// The server's UTC offset in minutes, queried via DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()).
/// Used to convert UTC collection_time values to server-local time for display.
diff --git a/Lite/Models/TimeSliceBucket.cs b/Lite/Models/TimeSliceBucket.cs
new file mode 100644
index 00000000..ba4d1d88
--- /dev/null
+++ b/Lite/Models/TimeSliceBucket.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// One hourly bucket of aggregated metrics for a time-range slicer.
+/// Timestamps are in UTC (matching DuckDB collection_time storage).
+///
+public class TimeSliceBucket
+{
+ public DateTime BucketTimeUtc { get; set; }
+ public long SessionCount { get; set; }
+ public double TotalCpu { get; set; }
+ public double TotalElapsed { get; set; }
+ public double TotalReads { get; set; }
+ public double TotalLogicalReads { get; set; }
+ public double TotalWrites { get; set; }
+
+ /// The display value used by the slicer chart. Set by the caller based on sort column.
+ public double Value { get; set; }
+}
diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj
index be54641d..76c69a5b 100644
--- a/Lite/PerformanceMonitorLite.csproj
+++ b/Lite/PerformanceMonitorLite.csproj
@@ -4,13 +4,14 @@
net8.0-windows
enable
true
+ PerformanceMonitorLite.Program
PerformanceMonitorLite
PerformanceMonitorLite
SQL Server Performance Monitor Lite
- 2.3.0
- 2.3.0.0
- 2.3.0.0
- 2.3.0
+ 2.4.0
+ 2.4.0.0
+ 2.4.0.0
+ 2.4.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
Lightweight SQL Server performance monitoring - no installation required on target servers
@@ -49,20 +50,21 @@
-
+
-
+
-
+
+
diff --git a/Lite/Program.cs b/Lite/Program.cs
new file mode 100644
index 00000000..adcab3cc
--- /dev/null
+++ b/Lite/Program.cs
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using Velopack;
+
+namespace PerformanceMonitorLite
+{
+ public static class Program
+ {
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ VelopackApp.Build().Run();
+
+ var app = new App();
+ app.InitializeComponent();
+ app.Run();
+ }
+ }
+}
diff --git a/Lite/Services/AlertStateService.cs b/Lite/Services/AlertStateService.cs
index f25830b0..f30fe86b 100644
--- a/Lite/Services/AlertStateService.cs
+++ b/Lite/Services/AlertStateService.cs
@@ -34,7 +34,7 @@ public class AlertStateService
public AlertStateService()
{
- _stateFilePath = Path.Combine(AppContext.BaseDirectory, "alert_state.json");
+ _stateFilePath = Path.Combine(App.DataDirectory, "alert_state.json");
_silencedServers = new HashSet(StringComparer.OrdinalIgnoreCase);
_acknowledgedAlerts = new Dictionary(StringComparer.OrdinalIgnoreCase);
Load();
diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs
index 505da8ad..76925130 100644
--- a/Lite/Services/ArchiveService.cs
+++ b/Lite/Services/ArchiveService.cs
@@ -247,6 +247,28 @@ private void CompactParquetFiles()
}
}
+ /* imported_YYYYMM_tablename (imported from previous install) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^imported_(\d{6})_(.+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value;
+ table = m.Groups[2].Value;
+ }
+ }
+
+ /* imported_YYYYMMDD_HHMM_tablename (imported per-cycle files) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^imported_(\d{8})_\d{4}_(.+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value[..6];
+ table = m.Groups[2].Value;
+ }
+ }
+
/* YYYYMM_tablename (already monthly — our target format) */
if (month == null)
{
@@ -261,11 +283,13 @@ private void CompactParquetFiles()
if (month != null && table != null)
{
var key = (month, table);
- if (!groups.ContainsKey(key))
+ if (!groups.TryGetValue(key, out List? value))
{
- groups[key] = [];
+ value = [];
+ groups[key] = value;
}
- groups[key].Add(file);
+
+ value.Add(file);
}
else
{
diff --git a/Lite/Services/DataGridFilterManager.cs b/Lite/Services/DataGridFilterManager.cs
index cf088387..1fe8c66a 100644
--- a/Lite/Services/DataGridFilterManager.cs
+++ b/Lite/Services/DataGridFilterManager.cs
@@ -8,9 +8,11 @@
using System;
using System.Collections.Generic;
+using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Data;
using System.Windows.Media;
using PerformanceMonitorLite.Models;
@@ -45,7 +47,7 @@ public DataGridFilterManager(DataGrid dataGrid)
///
/// Called when new data arrives (refresh cycle). Captures unfiltered data,
- /// then re-applies any active filters.
+ /// then re-applies any active filters. Preserves user sort order.
///
public void UpdateData(List newData)
{
@@ -53,7 +55,7 @@ public void UpdateData(List newData)
if (!HasActiveFilters())
{
- _dataGrid.ItemsSource = newData;
+ SetItemsSourcePreservingSort(newData);
return;
}
@@ -85,7 +87,7 @@ private void ApplyFilters()
if (!HasActiveFilters())
{
- _dataGrid.ItemsSource = _unfilteredData;
+ SetItemsSourcePreservingSort(_unfilteredData);
return;
}
@@ -99,7 +101,30 @@ private void ApplyFilters()
return true;
}).ToList();
- _dataGrid.ItemsSource = filteredData;
+ SetItemsSourcePreservingSort(filteredData);
+ }
+
+ private void SetItemsSourcePreservingSort(System.Collections.IEnumerable? newSource)
+ {
+ var savedSorts = _dataGrid.Items.SortDescriptions.ToList();
+
+ _dataGrid.ItemsSource = newSource;
+
+ if (savedSorts.Count > 0)
+ {
+ foreach (var sort in savedSorts)
+ _dataGrid.Items.SortDescriptions.Add(sort);
+
+ foreach (var column in _dataGrid.Columns)
+ {
+ if (column is DataGridBoundColumn bc &&
+ bc.Binding is Binding b)
+ {
+ var match = savedSorts.FirstOrDefault(s => s.PropertyName == b.Path.Path);
+ column.SortDirection = match.PropertyName != null ? match.Direction : null;
+ }
+ }
+ }
}
///
diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs
index 4f9cb7a6..22eabc9a 100644
--- a/Lite/Services/LocalDataService.Blocking.cs
+++ b/Lite/Services/LocalDataService.Blocking.cs
@@ -131,6 +131,58 @@ ORDER BY deadlock_time DESC
return items;
}
+ ///
+ /// Gets hourly-bucketed metrics from query snapshots for the time-range slicer.
+ /// The metric column is determined by the caller's sort preference.
+ ///
+ public async Task> GetActiveQuerySlicerDataAsync(
+ int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
+
+ command.CommandText = @"
+SELECT
+ date_trunc('hour', collection_time) AS bucket,
+ COUNT(*) AS session_count,
+ COALESCE(SUM(cpu_time_ms), 0) AS total_cpu,
+ COALESCE(SUM(total_elapsed_time_ms), 0) AS total_elapsed,
+ COALESCE(SUM(reads), 0) AS total_reads,
+ COALESCE(SUM(logical_reads), 0) AS total_logical_reads,
+ COALESCE(SUM(writes), 0) AS total_writes
+FROM v_query_snapshots
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+GROUP BY date_trunc('hour', collection_time)
+ORDER BY bucket";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new Models.TimeSliceBucket
+ {
+ BucketTimeUtc = reader.GetDateTime(0),
+ SessionCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ TotalCpu = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ TotalElapsed = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)),
+ TotalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ TotalLogicalReads = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)),
+ TotalWrites = reader.IsDBNull(6) ? 0 : ToDouble(reader.GetValue(6)),
+ Value = reader.IsDBNull(1) ? 0 : Convert.ToDouble(reader.GetValue(1)), // default: session count
+ });
+ }
+
+ return items;
+ }
+
///
/// Gets query snapshots (currently running queries) for a server.
///
diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs
index 985edaac..2569f580 100644
--- a/Lite/Services/LocalDataService.FinOps.cs
+++ b/Lite/Services/LocalDataService.FinOps.cs
@@ -1219,8 +1219,6 @@ AND delta_worker_time > 0
GROUP BY
database_name,
sql_handle,
- statement_start_offset,
- statement_end_offset,
query_text
ORDER BY SUM(delta_worker_time) DESC
LIMIT $3";
diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs
index 3783e9f0..532fb79d 100644
--- a/Lite/Services/LocalDataService.QueryStats.cs
+++ b/Lite/Services/LocalDataService.QueryStats.cs
@@ -36,6 +36,52 @@ FROM sys.databases AS d
///
/// Gets top queries by CPU for a server over a time period.
///
+ public async Task> GetQueryStatsSlicerDataAsync(
+ int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
+
+ command.CommandText = @"
+SELECT
+ date_trunc('hour', collection_time) AS bucket,
+ COUNT(DISTINCT query_hash) AS query_count,
+ COALESCE(SUM(delta_worker_time), 0) / 1000.0 AS total_cpu_ms,
+ COALESCE(SUM(delta_elapsed_time), 0) / 1000.0 AS total_elapsed_ms,
+ COALESCE(SUM(delta_logical_reads), 0) AS total_reads,
+ COALESCE(SUM(delta_logical_writes), 0) AS total_writes,
+ COALESCE(SUM(delta_physical_reads), 0) AS total_physical_reads
+FROM v_query_stats
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+GROUP BY date_trunc('hour', collection_time)
+ORDER BY bucket";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new Models.TimeSliceBucket
+ {
+ BucketTimeUtc = reader.GetDateTime(0),
+ SessionCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ TotalCpu = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ TotalElapsed = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)),
+ TotalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ TotalWrites = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)),
+ TotalLogicalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ Value = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)), // default: total CPU
+ });
+ }
+ return items;
+ }
+
public async Task> GetTopQueriesByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0)
{
using var _q = TimeQuery("GetTopQueriesByCpuAsync", "v_query_stats top N by CPU");
@@ -48,6 +94,8 @@ public async Task> GetTopQueriesByCpuAsync(int serverId, int
SELECT
database_name,
query_hash,
+ MAX(last_execution_time) AS last_execution_time,
+ MAX(creation_time) AS creation_time,
SUM(delta_execution_count) AS total_executions,
SUM(delta_worker_time) AS total_cpu_us,
SUM(delta_elapsed_time) AS total_elapsed_us,
@@ -100,33 +148,35 @@ ORDER BY SUM(delta_elapsed_time) DESC
{
DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
QueryHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
- TotalExecutions = reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
- TotalCpuUs = reader.IsDBNull(3) ? 0 : reader.GetInt64(3),
- TotalElapsedUs = reader.IsDBNull(4) ? 0 : reader.GetInt64(4),
- TotalLogicalReads = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
- TotalRows = reader.IsDBNull(6) ? 0 : reader.GetInt64(6),
- TotalLogicalWrites = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
- TotalPhysicalReads = reader.IsDBNull(8) ? 0 : reader.GetInt64(8),
- TotalSpills = reader.IsDBNull(9) ? 0 : reader.GetInt64(9),
- MinDop = reader.IsDBNull(10) ? 0 : reader.GetInt32(10),
- MaxDop = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
- MinCpuUs = reader.IsDBNull(12) ? 0 : reader.GetInt64(12),
- MaxCpuUs = reader.IsDBNull(13) ? 0 : reader.GetInt64(13),
- MinElapsedUs = reader.IsDBNull(14) ? 0 : reader.GetInt64(14),
- MaxElapsedUs = reader.IsDBNull(15) ? 0 : reader.GetInt64(15),
- MinPhysicalReads = reader.IsDBNull(16) ? 0 : reader.GetInt64(16),
- MaxPhysicalReads = reader.IsDBNull(17) ? 0 : reader.GetInt64(17),
- MinRows = reader.IsDBNull(18) ? 0 : reader.GetInt64(18),
- MaxRows = reader.IsDBNull(19) ? 0 : reader.GetInt64(19),
- MinGrantKb = reader.IsDBNull(20) ? 0 : reader.GetInt64(20),
- MaxGrantKb = reader.IsDBNull(21) ? 0 : reader.GetInt64(21),
- MinSpills = reader.IsDBNull(22) ? 0 : reader.GetInt64(22),
- MaxSpills = reader.IsDBNull(23) ? 0 : reader.GetInt64(23),
- QueryPlanHash = reader.IsDBNull(24) ? "" : reader.GetString(24),
- SqlHandle = reader.IsDBNull(25) ? "" : reader.GetString(25),
- PlanHandle = reader.IsDBNull(26) ? "" : reader.GetString(26),
- QueryText = reader.IsDBNull(27) ? "" : reader.GetString(27),
- QueryPlan = reader.IsDBNull(28) ? null : reader.GetString(28)
+ LastExecutionTime = reader.IsDBNull(2) ? null : reader.GetDateTime(2),
+ CreationTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3),
+ TotalExecutions = reader.IsDBNull(4) ? 0 : reader.GetInt64(4),
+ TotalCpuUs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
+ TotalElapsedUs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6),
+ TotalLogicalReads = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
+ TotalRows = reader.IsDBNull(8) ? 0 : reader.GetInt64(8),
+ TotalLogicalWrites = reader.IsDBNull(9) ? 0 : reader.GetInt64(9),
+ TotalPhysicalReads = reader.IsDBNull(10) ? 0 : reader.GetInt64(10),
+ TotalSpills = reader.IsDBNull(11) ? 0 : reader.GetInt64(11),
+ MinDop = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
+ MaxDop = reader.IsDBNull(13) ? 0 : reader.GetInt32(13),
+ MinCpuUs = reader.IsDBNull(14) ? 0 : reader.GetInt64(14),
+ MaxCpuUs = reader.IsDBNull(15) ? 0 : reader.GetInt64(15),
+ MinElapsedUs = reader.IsDBNull(16) ? 0 : reader.GetInt64(16),
+ MaxElapsedUs = reader.IsDBNull(17) ? 0 : reader.GetInt64(17),
+ MinPhysicalReads = reader.IsDBNull(18) ? 0 : reader.GetInt64(18),
+ MaxPhysicalReads = reader.IsDBNull(19) ? 0 : reader.GetInt64(19),
+ MinRows = reader.IsDBNull(20) ? 0 : reader.GetInt64(20),
+ MaxRows = reader.IsDBNull(21) ? 0 : reader.GetInt64(21),
+ MinGrantKb = reader.IsDBNull(22) ? 0 : reader.GetInt64(22),
+ MaxGrantKb = reader.IsDBNull(23) ? 0 : reader.GetInt64(23),
+ MinSpills = reader.IsDBNull(24) ? 0 : reader.GetInt64(24),
+ MaxSpills = reader.IsDBNull(25) ? 0 : reader.GetInt64(25),
+ QueryPlanHash = reader.IsDBNull(26) ? "" : reader.GetString(26),
+ SqlHandle = reader.IsDBNull(27) ? "" : reader.GetString(27),
+ PlanHandle = reader.IsDBNull(28) ? "" : reader.GetString(28),
+ QueryText = reader.IsDBNull(29) ? "" : reader.GetString(29),
+ QueryPlan = reader.IsDBNull(30) ? null : reader.GetString(30)
});
}
@@ -380,6 +430,52 @@ ps.total_elapsed_time DESC
///
/// Gets top procedures by CPU for a server.
///
+ public async Task> GetProcStatsSlicerDataAsync(
+ int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
+
+ command.CommandText = @"
+SELECT
+ date_trunc('hour', collection_time) AS bucket,
+ COUNT(DISTINCT object_name) AS proc_count,
+ COALESCE(SUM(delta_worker_time), 0) / 1000.0 AS total_cpu_ms,
+ COALESCE(SUM(delta_elapsed_time), 0) / 1000.0 AS total_elapsed_ms,
+ COALESCE(SUM(delta_logical_reads), 0) AS total_reads,
+ COALESCE(SUM(delta_logical_writes), 0) AS total_writes,
+ COALESCE(SUM(delta_physical_reads), 0) AS total_physical_reads
+FROM v_procedure_stats
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+GROUP BY date_trunc('hour', collection_time)
+ORDER BY bucket";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new Models.TimeSliceBucket
+ {
+ BucketTimeUtc = reader.GetDateTime(0),
+ SessionCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ TotalCpu = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ TotalElapsed = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)),
+ TotalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ TotalWrites = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)),
+ TotalLogicalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ Value = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ });
+ }
+ return items;
+ }
+
public async Task> GetTopProceduresByCpuAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null, int utcOffsetMinutes = 0)
{
using var _q = TimeQuery("GetTopProceduresByCpuAsync", "v_procedure_stats top N by CPU");
@@ -414,6 +510,7 @@ public async Task> GetTopProceduresByCpuAsync(int server
MIN(min_spills) AS min_spills,
MAX(max_spills) AS max_spills,
MAX(cached_time) AS cached_time,
+ MAX(last_execution_time) AS last_execution_time,
MAX(sql_handle) AS sql_handle,
MAX(plan_handle) AS plan_handle
FROM v_procedure_stats
@@ -462,8 +559,9 @@ ORDER BY SUM(delta_elapsed_time) DESC
MinSpills = reader.IsDBNull(21) ? 0 : reader.GetInt64(21),
MaxSpills = reader.IsDBNull(22) ? 0 : reader.GetInt64(22),
CachedTime = reader.IsDBNull(23) ? (DateTime?)null : reader.GetDateTime(23),
- SqlHandle = reader.IsDBNull(24) ? "" : reader.GetString(24),
- PlanHandle = reader.IsDBNull(25) ? "" : reader.GetString(25)
+ LastExecutionTime = reader.IsDBNull(24) ? (DateTime?)null : reader.GetDateTime(24),
+ SqlHandle = reader.IsDBNull(25) ? "" : reader.GetString(25),
+ PlanHandle = reader.IsDBNull(26) ? "" : reader.GetString(26)
});
}
@@ -625,6 +723,10 @@ public class QueryStatsRow
{
public string DatabaseName { get; set; } = "";
public string QueryHash { get; set; } = "";
+ public DateTime? LastExecutionTime { get; set; }
+ public DateTime? CreationTime { get; set; }
+ public string LastExecutionTimeLocal => Services.ServerTimeHelper.FormatServerTime(LastExecutionTime);
+ public string CreationTimeLocal => Services.ServerTimeHelper.FormatServerTime(CreationTime);
public long TotalExecutions { get; set; }
public long TotalCpuUs { get; set; }
public long TotalElapsedUs { get; set; }
@@ -690,6 +792,7 @@ public class ProcedureStatsRow
public long MinSpills { get; set; }
public long MaxSpills { get; set; }
public DateTime? CachedTime { get; set; }
+ public DateTime? LastExecutionTime { get; set; }
public string SqlHandle { get; set; } = "";
public string PlanHandle { get; set; } = "";
public string FullName => string.IsNullOrEmpty(SchemaName) ? ObjectName : $"{SchemaName}.{ObjectName}";
@@ -702,7 +805,8 @@ public class ProcedureStatsRow
public double MaxCpuMs => MaxWorkerTimeUs / 1000.0;
public double MinElapsedMs => MinElapsedTimeUs / 1000.0;
public double MaxElapsedMs => MaxElapsedTimeUs / 1000.0;
- public string CachedTimeFormatted => CachedTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "";
+ public string CachedTimeFormatted => Services.ServerTimeHelper.FormatServerTime(CachedTime);
+ public string LastExecutionTimeLocal => Services.ServerTimeHelper.FormatServerTime(LastExecutionTime);
}
public class QueryStatsHistoryRow
diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs
index a6dde182..20db24db 100644
--- a/Lite/Services/LocalDataService.QueryStore.cs
+++ b/Lite/Services/LocalDataService.QueryStore.cs
@@ -21,6 +21,52 @@ public partial class LocalDataService
/// Gets the latest Query Store snapshot for a server, aggregated across all databases.
/// Shows top queries by total duration (execution_count * avg_duration).
///
+ public async Task> GetQueryStoreSlicerDataAsync(
+ int serverId, int hoursBack, DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
+
+ command.CommandText = @"
+SELECT
+ date_trunc('hour', collection_time) AS bucket,
+ COUNT(DISTINCT query_id) AS query_count,
+ COALESCE(SUM(CAST(avg_cpu_time_us AS DOUBLE) * execution_count), 0) / 1000.0 AS total_cpu_ms,
+ COALESCE(SUM(CAST(avg_duration_us AS DOUBLE) * execution_count), 0) / 1000.0 AS total_duration_ms,
+ COALESCE(SUM(CAST(avg_logical_io_reads AS DOUBLE) * execution_count), 0) AS total_reads,
+ COALESCE(SUM(CAST(avg_logical_io_writes AS DOUBLE) * execution_count), 0) AS total_writes,
+ COALESCE(SUM(CAST(avg_physical_io_reads AS DOUBLE) * execution_count), 0) AS total_physical_reads
+FROM v_query_store_stats
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+GROUP BY date_trunc('hour', collection_time)
+ORDER BY bucket";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new Models.TimeSliceBucket
+ {
+ BucketTimeUtc = reader.GetDateTime(0),
+ SessionCount = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ TotalCpu = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ TotalElapsed = reader.IsDBNull(3) ? 0 : ToDouble(reader.GetValue(3)),
+ TotalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ TotalWrites = reader.IsDBNull(5) ? 0 : ToDouble(reader.GetValue(5)),
+ TotalLogicalReads = reader.IsDBNull(4) ? 0 : ToDouble(reader.GetValue(4)),
+ Value = reader.IsDBNull(2) ? 0 : ToDouble(reader.GetValue(2)),
+ });
+ }
+ return items;
+ }
+
public async Task> GetQueryStoreTopQueriesAsync(int serverId, int hoursBack = 24, int top = 50, DateTime? fromDate = null, DateTime? toDate = null)
{
using var _q = TimeQuery("GetQueryStoreTopQueriesAsync", "v_query_store_stats top N");
diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs
index 0905dd78..5f7e3264 100644
--- a/Lite/Services/PlanAnalyzer.cs
+++ b/Lite/Services/PlanAnalyzer.cs
@@ -325,7 +325,7 @@ private static void AnalyzeStatement(PlanStatement stmt)
private static void CheckForTableVariables(PlanNode node, bool isModification,
ref bool hasTableVar, ref bool modifiesTableVar)
{
- if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
+ if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@", StringComparison.OrdinalIgnoreCase))
{
hasTableVar = true;
if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
@@ -573,7 +573,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
"Leading wildcard LIKE prevents an index seek — SQL Server must scan every row. If substring search performance is critical, consider a full-text index or a trigram-based approach.",
"CASE expression in predicate" =>
"CASE expression in a predicate prevents an index seek. Rewrite using separate WHERE clauses combined with OR, or split into multiple queries.",
- _ when nonSargableReason.StartsWith("Function call") =>
+ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgnoreCase) =>
$"{nonSargableReason} prevents an index seek. Remove the function from the column side — apply it to the parameter instead, or create a computed column with the expression and index that.",
_ =>
$"{nonSargableReason} prevents an index seek, forcing a scan."
diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs
index 595bb9d0..203573e4 100644
--- a/Lite/Services/RemoteCollectorService.FileIo.cs
+++ b/Lite/Services/RemoteCollectorService.FileIo.cs
@@ -59,10 +59,10 @@ LEFT JOIN sys.database_files AS df
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- database_name = ISNULL(d.name, N'Unknown'),
- file_name = mf.name,
- file_type = mf.type_desc,
- physical_name = mf.physical_name,
+ database_name = ISNULL(d.name, DB_NAME(vfs.database_id)),
+ file_name = ISNULL(mf.name, N'File_' + CONVERT(nvarchar(10), vfs.file_id)),
+ file_type = ISNULL(mf.type_desc, N'UNKNOWN'),
+ physical_name = ISNULL(mf.physical_name, N''),
size_mb = CONVERT(decimal(18,2), vfs.size_on_disk_bytes / 1048576.0),
num_of_reads = vfs.num_of_reads,
num_of_writes = vfs.num_of_writes,
@@ -143,14 +143,14 @@ AND vfs.database_id < 32761
foreach (var stat in fileStats)
{
var deltaKey = $"{stat.DatabaseName}|{stat.FileName}";
- var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads, baselineOnly: true);
- var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites, baselineOnly: true);
- var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes, baselineOnly: true);
- var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes, baselineOnly: true);
- var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs, baselineOnly: true);
- var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs, baselineOnly: true);
- var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs, baselineOnly: true);
- var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs, baselineOnly: true);
+ var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
diff --git a/Lite/Services/RemoteCollectorService.MemoryGrants.cs b/Lite/Services/RemoteCollectorService.MemoryGrants.cs
index 244e08b2..e2bf31d4 100644
--- a/Lite/Services/RemoteCollectorService.MemoryGrants.cs
+++ b/Lite/Services/RemoteCollectorService.MemoryGrants.cs
@@ -92,8 +92,8 @@ WHERE deqrs.max_target_memory_kb IS NOT NULL
foreach (var r in rows)
{
var deltaKey = $"{r.PoolId}_{r.ResourceSemaphoreId}";
- var deltaTimeouts = _deltaCalculator.CalculateDelta(serverId, "memory_grants_timeouts", deltaKey, r.TimeoutErrorCount, baselineOnly: true);
- var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount, baselineOnly: true);
+ var deltaTimeouts = _deltaCalculator.CalculateDelta(serverId, "memory_grants_timeouts", deltaKey, r.TimeoutErrorCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs
index e46b71ab..58a50570 100644
--- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs
+++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs
@@ -95,7 +95,7 @@ THEN LTRIM(RTRIM(REPLACE(REPLACE(
SUBSTRING(
st.text,
CHARINDEX(N''CREATE TRIGGER'', st.text) + 15,
- CHARINDEX(N'' ON '', st.text + N'' ON '') - CHARINDEX(N''CREATE TRIGGER'', st.text) - 15
+ CHARINDEX(N'' ON '', st.text + N'' ON '', CHARINDEX(N''CREATE TRIGGER'', st.text) + 15) - CHARINDEX(N''CREATE TRIGGER'', st.text) - 15
), N''['', N''''), N'']'', N'''')))
ELSE N''trigger_'' + CONVERT(nvarchar(20), s.object_id)
END
@@ -278,12 +278,12 @@ ORDER BY s.total_elapsed_time DESC
/* Delta key: plan_handle to prevent cross-contamination
when multiple plans exist for the same object */
var deltaKey = planHandle ?? $"{dbName}.{schemaName}.{objectName}";
- var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount, baselineOnly: true);
- var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime, baselineOnly: true);
- var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime, baselineOnly: true);
- var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads, baselineOnly: true);
- var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites, baselineOnly: true);
- var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads, baselineOnly: true);
+ var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
/* Appender column order must match DuckDB table definition exactly */
var row = appender.CreateRow();
diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs
index f1aee244..b47705a6 100644
--- a/Lite/Services/RemoteCollectorService.QueryStats.cs
+++ b/Lite/Services/RemoteCollectorService.QueryStats.cs
@@ -266,14 +266,14 @@ qs.total_elapsed_time DESC
/* Delta calculations keyed by plan_handle to prevent cross-contamination
when multiple plans exist for the same query_hash */
var deltaKey = planHandle ?? queryHash;
- var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", deltaKey, executionCount, baselineOnly: true);
- var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime, baselineOnly: true);
- var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime, baselineOnly: true);
- var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads, baselineOnly: true);
- var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites, baselineOnly: true);
- var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads, baselineOnly: true);
- var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows, baselineOnly: true);
- var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills, baselineOnly: true);
+ var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", deltaKey, executionCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
/* Appender column order must match DuckDB table definition exactly */
var row = appender.CreateRow();
diff --git a/Lite/Services/RemoteCollectorService.WaitStats.cs b/Lite/Services/RemoteCollectorService.WaitStats.cs
index 35ade9ab..632e2845 100644
--- a/Lite/Services/RemoteCollectorService.WaitStats.cs
+++ b/Lite/Services/RemoteCollectorService.WaitStats.cs
@@ -127,9 +127,9 @@ WHERE ws.wait_time_ms > 0
foreach (var stat in waitStats)
{
var deltaKey = stat.WaitType;
- var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks, baselineOnly: true);
- var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs, baselineOnly: true);
- var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs, baselineOnly: true);
+ var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
+ var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300);
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */
diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs
index 9e307177..bd2f3c95 100644
--- a/Lite/Services/RemoteCollectorService.cs
+++ b/Lite/Services/RemoteCollectorService.cs
@@ -317,8 +317,9 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam
var majorVersion = serverStatus.SqlMajorVersion;
var engineEdition = serverStatus.SqlEngineEdition;
var isAwsRds = serverStatus.IsAwsRds;
+ var hasMsdbAccess = serverStatus.HasMsdbAccess;
- if (!IsCollectorSupported(collectorName, majorVersion, engineEdition, isAwsRds))
+ if (!IsCollectorSupported(collectorName, majorVersion, engineEdition, isAwsRds, hasMsdbAccess))
{
AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} SKIPPED (version {majorVersion}, edition {engineEdition})");
return;
@@ -740,7 +741,7 @@ internal static int GetDeterministicHashCode(string value)
/// Version 13 = SQL Server 2016, 14 = 2017, 15 = 2019, 16 = 2022, 17 = 2025.
/// Engine edition 5 = Azure SQL DB, 8 = Azure MI.
///
- private static bool IsCollectorSupported(string collectorName, int majorVersion, int engineEdition, bool isAwsRds = false)
+ private static bool IsCollectorSupported(string collectorName, int majorVersion, int engineEdition, bool isAwsRds = false, bool hasMsdbAccess = true)
{
bool isAzureSqlDb = engineEdition == 5;
bool isAzureMi = engineEdition == 8;
@@ -781,6 +782,16 @@ private static bool IsCollectorSupported(string collectorName, int majorVersion,
}
}
+ /* msdb access gate — login may not have access to msdb on any edition */
+ if (!hasMsdbAccess)
+ {
+ switch (collectorName)
+ {
+ case "running_jobs": /* requires msdb.dbo.sysjobs, sysjobactivity, etc. */
+ return false;
+ }
+ }
+
return true;
}
}
diff --git a/Lite/Services/ServerManager.cs b/Lite/Services/ServerManager.cs
index bc738a29..70daa653 100644
--- a/Lite/Services/ServerManager.cs
+++ b/Lite/Services/ServerManager.cs
@@ -347,7 +347,8 @@ public async Task CheckConnectionAsync(string serverId,
CONVERT(integer, SERVERPROPERTY('ProductMajorVersion')) AS major_version,
DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) AS utc_offset_minutes,
CONVERT(integer, SERVERPROPERTY('EngineEdition')) AS engine_edition,
- CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds
+ CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds,
+ HAS_DBACCESS(N'msdb') AS has_msdb_access
FROM sys.dm_os_sys_info", connection);
command.CommandTimeout = ConnectionCheckTimeoutSeconds;
@@ -366,6 +367,8 @@ CASE WHEN DB_ID('rdsadmin') IS NOT NULL THEN 1 ELSE 0 END AS is_aws_rds
status.SqlEngineEdition = Convert.ToInt32(reader.GetValue(4));
if (!reader.IsDBNull(5))
status.IsAwsRds = Convert.ToInt32(reader.GetValue(5)) == 1;
+ if (!reader.IsDBNull(6))
+ status.HasMsdbAccess = Convert.ToInt32(reader.GetValue(6)) == 1;
}
}
catch (SqlException metaEx)
@@ -510,6 +513,60 @@ private void LoadServers()
}
}
+ ///
+ /// Imports server connections from an external servers.json file.
+ /// Upserts by ServerName — existing servers are skipped, new ones are added
+ /// with their original GUIDs so Credential Manager entries still resolve.
+ /// Returns (imported count, skipped count).
+ ///
+ public (int Imported, int Skipped) ImportServersFromFile(string serversJsonPath)
+ {
+ if (!File.Exists(serversJsonPath))
+ throw new FileNotFoundException("servers.json not found", serversJsonPath);
+
+ var json = File.ReadAllText(serversJsonPath);
+ var config = JsonSerializer.Deserialize(json);
+ var importedServers = config?.Servers ?? [];
+
+ int imported = 0;
+ int skipped = 0;
+
+ lock (_serversLock)
+ {
+ foreach (var server in importedServers)
+ {
+ // Skip if we already have a server with the same name
+ var existing = _servers.FirstOrDefault(s =>
+ string.Equals(s.ServerName, server.ServerName, StringComparison.OrdinalIgnoreCase) &&
+ s.ReadOnlyIntent == server.ReadOnlyIntent);
+
+ if (existing != null)
+ {
+ skipped++;
+ continue;
+ }
+
+ // Also skip if the same GUID already exists (shouldn't happen, but defensive)
+ if (_servers.Any(s => s.Id == server.Id))
+ {
+ skipped++;
+ continue;
+ }
+
+ // Add with original GUID so Credential Manager entries still work
+ _servers.Add(server);
+ _connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id };
+ imported++;
+ }
+
+ if (imported > 0)
+ SaveServers();
+ }
+
+ _logger?.LogInformation("Imported {Imported} servers, skipped {Skipped} duplicates", imported, skipped);
+ return (imported, skipped);
+ }
+
///
/// Saves servers to the JSON config file.
///
diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml
index 2160b01a..014635c3 100644
--- a/Lite/Themes/DarkTheme.xaml
+++ b/Lite/Themes/DarkTheme.xaml
@@ -1205,5 +1205,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs
index 05a388a4..5395feeb 100644
--- a/Lite/Windows/AboutWindow.xaml.cs
+++ b/Lite/Windows/AboutWindow.xaml.cs
@@ -6,10 +6,12 @@
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/
+using System;
using System.Diagnostics;
using System.Reflection;
using System.Windows;
using PerformanceMonitorLite.Services;
+using Velopack;
namespace PerformanceMonitorLite.Windows;
@@ -21,6 +23,9 @@ public partial class AboutWindow : Window
private const string DarlingDataUrl = "https://www.erikdarling.com";
private string? _updateReleaseUrl;
+ private UpdateManager? _velopackMgr;
+ private Velopack.UpdateInfo? _pendingUpdate;
+ private bool _updateDownloaded;
public AboutWindow()
{
@@ -45,6 +50,32 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
UpdateStatusText.Text = "Checking for updates...";
UpdateStatusText.Visibility = Visibility.Visible;
+ // Try Velopack first (supports download + apply)
+ try
+ {
+ _velopackMgr = new UpdateManager(
+ new Velopack.Sources.GithubSource("https://github.com/erikdarlingdata/PerformanceMonitor", null, false));
+
+ var updateInfo = await _velopackMgr.CheckForUpdatesAsync();
+ if (updateInfo != null)
+ {
+ _pendingUpdate = updateInfo;
+ UpdateStatusText.Text = $"Update available: v{updateInfo.TargetFullRelease.Version} — click to install";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp -= UpdateStatusText_Click;
+ UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
+ ?? System.Windows.Media.Brushes.DodgerBlue;
+ return;
+ }
+ }
+ catch
+ {
+ // Velopack packages may not exist yet — fall through
+ }
+
+ // Fallback: GitHub Releases API check (opens browser)
var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true);
if (result == null)
@@ -54,8 +85,9 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
else if (result.IsUpdateAvailable)
{
_updateReleaseUrl = result.ReleaseUrl;
- UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})";
+ UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion}) — click to open releases";
UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click;
UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click;
UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
@@ -67,6 +99,48 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
}
}
+ private async void VelopackDownload_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (_velopackMgr == null || _pendingUpdate == null) return;
+
+ // Step 3: restart with confirmation
+ if (_updateDownloaded)
+ {
+ var result = MessageBox.Show(this,
+ "The application will close and restart with the new version. Continue?",
+ "Update Ready",
+ MessageBoxButton.OKCancel,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.OK)
+ {
+ _velopackMgr.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease);
+ }
+ return;
+ }
+
+ // Step 2: download
+ try
+ {
+ UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click;
+ UpdateStatusText.TextDecorations = null;
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Arrow;
+ UpdateStatusText.Text = "Downloading update...";
+
+ await _velopackMgr.DownloadUpdatesAsync(_pendingUpdate);
+
+ _updateDownloaded = true;
+ UpdateStatusText.Text = "Update downloaded.";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click;
+ }
+ catch (Exception ex)
+ {
+ UpdateStatusText.Text = $"Download failed: {ex.Message}";
+ }
+ }
+
private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (!string.IsNullOrEmpty(_updateReleaseUrl))
diff --git a/Lite/Windows/MuteRuleDialog.xaml b/Lite/Windows/MuteRuleDialog.xaml
index 8d67fdf4..d9eb08b5 100644
--- a/Lite/Windows/MuteRuleDialog.xaml
+++ b/Lite/Windows/MuteRuleDialog.xaml
@@ -1,113 +1,114 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/MuteRuleDialog.xaml.cs b/Lite/Windows/MuteRuleDialog.xaml.cs
index 2fea9e53..abcf7b03 100644
--- a/Lite/Windows/MuteRuleDialog.xaml.cs
+++ b/Lite/Windows/MuteRuleDialog.xaml.cs
@@ -31,6 +31,7 @@ public MuteRuleDialog(MuteRule? existingRule = null)
else
{
Rule = new MuteRule();
+ ApplyDefaultExpiration();
}
}
@@ -154,4 +155,15 @@ private void MetricCombo_SelectionChanged(object sender, SelectionChangedEventAr
PatternFieldsGrid.Visibility = (showDatabase || showWaitType || showQueryText || showJobName)
? Visibility.Visible : Visibility.Collapsed;
}
+
+ private void ApplyDefaultExpiration()
+ {
+ ExpirationCombo.SelectedIndex = App.MuteRuleDefaultExpiration switch
+ {
+ "1 hour" => 0,
+ "24 hours" => 1,
+ "7 days" => 2,
+ _ => 3
+ };
+ }
}
diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml
index 06abd11d..b9beaee6 100644
--- a/Lite/Windows/SettingsWindow.xaml
+++ b/Lite/Windows/SettingsWindow.xaml
@@ -251,9 +251,19 @@
+
+
+
+
+
+
+
+
+
+ Padding="12,4" Margin="20,4,0,4"/>
diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs
index f96fcc81..64d5ef07 100644
--- a/Lite/Windows/SettingsWindow.xaml.cs
+++ b/Lite/Windows/SettingsWindow.xaml.cs
@@ -559,6 +559,13 @@ private void LoadAlertSettings()
AlertLongRunningJobMultiplierBox.Text = App.AlertLongRunningJobMultiplier.ToString();
AlertCooldownBox.Text = App.AlertCooldownMinutes.ToString();
EmailCooldownBox.Text = App.EmailCooldownMinutes.ToString();
+ MuteRuleDefaultExpirationCombo.SelectedIndex = App.MuteRuleDefaultExpiration switch
+ {
+ "1 hour" => 0,
+ "24 hours" => 1,
+ "7 days" => 2,
+ _ => 3
+ };
UpdateAlertControlStates();
}
@@ -608,6 +615,7 @@ private bool SaveAlertSettings()
App.EmailCooldownMinutes = emailCooldown;
else
validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes.");
+ App.MuteRuleDefaultExpiration = (MuteRuleDefaultExpirationCombo.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "24 hours";
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
try
@@ -650,6 +658,7 @@ private bool SaveAlertSettings()
root["alert_long_running_job_multiplier"] = App.AlertLongRunningJobMultiplier;
root["alert_cooldown_minutes"] = App.AlertCooldownMinutes;
root["email_cooldown_minutes"] = App.EmailCooldownMinutes;
+ root["mute_rule_default_expiration"] = App.MuteRuleDefaultExpiration;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(options));
@@ -688,6 +697,7 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
AlertCooldownBox.Text = "5";
EmailCooldownBox.Text = "15";
AlertExcludedDatabasesBox.Text = "";
+ MuteRuleDefaultExpirationCombo.SelectedIndex = 1; // 24 hours
UpdateAlertPreviewText();
}
diff --git a/README.md b/README.md
index 823bb579..32674aa5 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,7 @@ All release binaries are digitally signed via [SignPath](https://signpath.io)
Data starts flowing within 1–5 minutes. That's it. No installation on your server, no Agent jobs, no sysadmin required.
-**Upgrading?** Click **Import Data** in the sidebar and point it at your old Lite folder — all historical data (DuckDB + Parquet archives) is imported into the new install.
+**Upgrading from zip?** Click **Import Settings** then **Import Data** in the sidebar and point both at your old Lite folder. Settings imports server connections, alert thresholds, SMTP config, and schedules. Data imports historical DuckDB + Parquet archives. **Auto-update users** (installed via Setup.exe) get updates automatically — no manual import needed.
**Always On AG?** Enable **ReadOnlyIntent** in the connection settings to route Lite's monitoring queries to a readable secondary, keeping the primary clear.
@@ -117,6 +117,8 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser
### Lite Data Storage
+All data is stored in `%LOCALAPPDATA%\PerformanceMonitorLite\` — separate from the executable, so auto-updates don't affect your data.
+
- **Hot data** in DuckDB 1.5.0 — non-blocking checkpoints, free block reuse, stable file size without periodic resets
- **Archive** to Parquet with ZSTD compression (~10x reduction) — automatic monthly compaction keeps file count low (~75 files vs thousands)
- **Retention**: 3-month calendar-month rolling window
diff --git a/install/20_collect_file_io_stats.sql b/install/20_collect_file_io_stats.sql
index d2e3f7f5..44b71021 100644
--- a/install/20_collect_file_io_stats.sql
+++ b/install/20_collect_file_io_stats.sql
@@ -134,14 +134,14 @@ BEGIN
ISNULL
(
d.name,
- N'UNKNOWN'
+ DB_NAME(vfs.database_id)
),
file_id = vfs.file_id,
file_name =
ISNULL
(
mf.name,
- N'UNKNOWN'
+ N'File_' + CONVERT(nvarchar(10), vfs.file_id)
),
file_type_desc =
ISNULL
@@ -149,7 +149,7 @@ BEGIN
mf.type_desc,
N'UNKNOWN'
),
- physical_name = mf.physical_name,
+ physical_name = ISNULL(mf.physical_name, N''),
size_on_disk_bytes = vfs.size_on_disk_bytes,
num_of_reads = vfs.num_of_reads,
num_of_bytes_read = vfs.num_of_bytes_read,
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 00000000..ee838441
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,20 @@
+# SQL Server Performance Monitor
+
+> Free, open-source SQL Server performance monitoring tool by Erik Darling (Darling Data, LLC). 32 T-SQL collectors, real-time alerts, graphical execution plan viewer, and a built-in MCP server with 51-63 tools for AI-powered analysis. Two editions: Full (server-installed with SQL Agent) and Lite (standalone desktop app with DuckDB storage). Replaces expensive commercial tools like SentryOne and Solarwinds DPA. MIT licensed.
+
+- Supports SQL Server 2016-2025, Azure SQL Managed Instance, AWS RDS for SQL Server, and Azure SQL Database (Lite only)
+- Monitors wait stats, query performance, blocking chains, deadlocks, memory grants, file I/O, tempdb, CPU, perfmon counters, and more
+- Real-time alerts via system tray notifications and styled HTML emails
+- Built-in MCP server exposes monitoring data to Claude Code, Cursor, and other LLM clients
+- 4,300+ downloads across releases
+
+## Documentation
+
+- [README](https://github.com/erikdarlingdata/PerformanceMonitor/blob/main/README.md): Complete documentation including quick start, permissions, collector reference, alert configuration, MCP server setup, and edition comparison
+- [Releases](https://github.com/erikdarlingdata/PerformanceMonitor/releases): Download pre-built binaries
+- [Product page](https://erikdarling.com/free-sql-server-performance-monitoring/): Overview and commercial support options
+
+## Optional
+
+- [Third-party notices](https://github.com/erikdarlingdata/PerformanceMonitor/blob/main/THIRD_PARTY_NOTICES.md): License information for bundled components
+- [License](https://github.com/erikdarlingdata/PerformanceMonitor/blob/main/LICENSE): MIT License
diff --git a/upgrades/2.3.0-to-2.4.0/01_add_growth_vlf_columns.sql b/upgrades/2.3.0-to-2.4.0/01_add_growth_vlf_columns.sql
new file mode 100644
index 00000000..96b00a8b
--- /dev/null
+++ b/upgrades/2.3.0-to-2.4.0/01_add_growth_vlf_columns.sql
@@ -0,0 +1,87 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.3.0 to 2.4.0
+Re-applies growth/VLF columns for servers that upgraded to 2.3.0 before PR #625 shipped
+Adds growth settings and VLF count columns to collect.database_size_stats:
+
+database_size_stats:
+ - is_percent_growth: new column (bit NULL) — true when auto-growth is percent-based
+ - growth_pct: new column (integer NULL) — raw growth percent value (set when is_percent_growth = 1)
+ - vlf_count: new column (integer NULL) — VLF count for log files (NULL for data files)
+*/
+
+SET ANSI_NULLS ON;
+SET ANSI_PADDING ON;
+SET ANSI_WARNINGS ON;
+SET ARITHABORT ON;
+SET CONCAT_NULL_YIELDS_NULL ON;
+SET QUOTED_IDENTIFIER ON;
+SET NUMERIC_ROUNDABORT OFF;
+SET IMPLICIT_TRANSACTIONS OFF;
+SET STATISTICS TIME, IO OFF;
+GO
+
+USE PerformanceMonitor;
+GO
+
+/*
+database_size_stats: add growth settings and VLF count columns
+*/
+IF OBJECT_ID(N'collect.database_size_stats', N'U') IS NOT NULL
+BEGIN
+ PRINT 'Checking collect.database_size_stats columns...';
+
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = N'collect'
+ AND TABLE_NAME = N'database_size_stats'
+ AND COLUMN_NAME = N'is_percent_growth'
+ )
+ BEGIN
+ ALTER TABLE collect.database_size_stats ADD is_percent_growth bit NULL;
+ PRINT ' is_percent_growth: added (bit NULL)';
+ END;
+
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = N'collect'
+ AND TABLE_NAME = N'database_size_stats'
+ AND COLUMN_NAME = N'growth_pct'
+ )
+ BEGIN
+ ALTER TABLE collect.database_size_stats ADD growth_pct integer NULL;
+ PRINT ' growth_pct: added (integer NULL)';
+ END;
+
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = N'collect'
+ AND TABLE_NAME = N'database_size_stats'
+ AND COLUMN_NAME = N'vlf_count'
+ )
+ BEGIN
+ ALTER TABLE collect.database_size_stats ADD vlf_count integer NULL;
+ PRINT ' vlf_count: added (integer NULL)';
+ END;
+
+ PRINT 'database_size_stats complete.';
+END;
+ELSE
+BEGIN
+ PRINT 'collect.database_size_stats not found — skipping.';
+END;
+GO
+
+PRINT 'Upgrade 03_add_growth_vlf_columns complete.';
+GO
diff --git a/upgrades/2.3.0-to-2.4.0/upgrade.txt b/upgrades/2.3.0-to-2.4.0/upgrade.txt
new file mode 100644
index 00000000..fdb03016
--- /dev/null
+++ b/upgrades/2.3.0-to-2.4.0/upgrade.txt
@@ -0,0 +1 @@
+01_add_growth_vlf_columns.sql