diff --git a/.github/sql/ci_validate_installation.sql b/.github/sql/ci_validate_installation.sql
index 0761f80a..d3b6382f 100644
--- a/.github/sql/ci_validate_installation.sql
+++ b/.github/sql/ci_validate_installation.sql
@@ -30,7 +30,7 @@ IF SCHEMA_ID(N'report') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: sche
PRINT '';
/*
-Procedures in collect schema (36)
+Procedures in collect schema (38)
*/
PRINT 'Checking collect procedures...';
@@ -70,11 +70,13 @@ IF OBJECT_ID(N'collect.database_configuration_collector', N'P') IS NULL BEGIN SE
IF OBJECT_ID(N'collect.configuration_issues_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.configuration_issues_analyzer'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.scheduled_master_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.scheduled_master_collector'; END; SET @checked += 1;
IF OBJECT_ID(N'collect.running_jobs_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.running_jobs_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.database_size_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_size_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.server_properties_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_properties_collector'; END; SET @checked += 1;
PRINT '';
/*
-Procedures in config schema (10)
+Procedures in config schema (8)
*/
PRINT 'Checking config procedures...';
@@ -82,9 +84,7 @@ IF OBJECT_ID(N'config.ensure_config_tables', N'P') IS NULL BEGIN SET @miss
IF OBJECT_ID(N'config.ensure_collection_table', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_collection_table'; END; SET @checked += 1;
IF OBJECT_ID(N'config.update_collector_frequency', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.update_collector_frequency'; END; SET @checked += 1;
IF OBJECT_ID(N'config.set_collector_enabled', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.set_collector_enabled'; END; SET @checked += 1;
-IF OBJECT_ID(N'config.enable_realtime_monitoring', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_realtime_monitoring'; END; SET @checked += 1;
-IF OBJECT_ID(N'config.enable_consulting_analysis', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_consulting_analysis'; END; SET @checked += 1;
-IF OBJECT_ID(N'config.enable_baseline_monitoring', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_baseline_monitoring'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.apply_collection_preset'; END; SET @checked += 1;
IF OBJECT_ID(N'config.show_collection_schedule', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.show_collection_schedule'; END; SET @checked += 1;
IF OBJECT_ID(N'config.data_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.data_retention'; END; SET @checked += 1;
IF OBJECT_ID(N'config.check_hung_collector_job', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.check_hung_collector_job'; END; SET @checked += 1;
@@ -102,7 +102,7 @@ IF OBJECT_ID(N'config.server_info', N'V') IS NULL BEGIN SET @missing += 1; P
PRINT '';
/*
-Views in report schema (37)
+Views in report schema (41)
Note: report.query_snapshots and report.query_snapshots_blocking are created
dynamically by collect.query_snapshots_create_views, so they are not checked here.
*/
@@ -144,6 +144,10 @@ IF OBJECT_ID(N'report.scheduler_cpu_analysis', N'V') IS NULL BEGIN
IF OBJECT_ID(N'report.critical_issues', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.critical_issues'; END; SET @checked += 1;
IF OBJECT_ID(N'report.memory_usage_trends', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_usage_trends'; END; SET @checked += 1;
IF OBJECT_ID(N'report.running_jobs', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.running_jobs'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.finops_database_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_database_resource_usage'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.finops_utilization_efficiency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_utilization_efficiency'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.finops_peak_utilization', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_peak_utilization'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.finops_application_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_application_resource_usage'; END; SET @checked += 1;
PRINT '';
@@ -180,7 +184,7 @@ WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'config';
PRINT ' collect schema tables: ' + CONVERT(varchar(10), @collect_tables);
PRINT ' config schema tables: ' + CONVERT(varchar(10), @config_tables);
-IF @collect_tables < 19 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 20 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1;
+IF @collect_tables < 21 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 21 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1;
IF @config_tables < 5 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 5 config tables, found ' + CONVERT(varchar(10), @config_tables); END; SET @checked += 1;
PRINT '';
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ef240d0c..408089fe 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,6 +10,8 @@ on:
permissions:
contents: write
+ id-token: write
+ actions: read
jobs:
build:
@@ -78,6 +80,82 @@ jobs:
Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force
+ - name: Upload Dashboard for signing
+ if: github.event_name == 'release'
+ id: upload-dashboard
+ uses: actions/upload-artifact@v4
+ with:
+ name: Dashboard-unsigned
+ path: publish/Dashboard/
+
+ - name: Upload Lite for signing
+ if: github.event_name == 'release'
+ id: upload-lite
+ uses: actions/upload-artifact@v4
+ with:
+ name: Lite-unsigned
+ path: publish/Lite/
+
+ - name: Upload Installer for signing
+ if: github.event_name == 'release'
+ id: upload-installer
+ uses: actions/upload-artifact@v4
+ with:
+ name: Installer-unsigned
+ path: publish/Installer/
+
+ - name: Sign Dashboard
+ if: github.event_name == 'release'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceMonitor'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'Dashboard'
+ github-artifact-id: '${{ steps.upload-dashboard.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/Dashboard'
+
+ - name: Sign Lite
+ if: github.event_name == 'release'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceMonitor'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'Lite'
+ github-artifact-id: '${{ steps.upload-lite.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/Lite'
+
+ - name: Sign Installer
+ if: github.event_name == 'release'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceMonitor'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'Installers'
+ github-artifact-id: '${{ steps.upload-installer.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/Installer'
+
+ - name: Replace with signed artifacts
+ if: github.event_name == 'release'
+ shell: pwsh
+ run: |
+ $version = "${{ steps.version.outputs.VERSION }}"
+ # Re-zip signed files into release archives
+ Remove-Item "releases/PerformanceMonitorDashboard-$version.zip" -ErrorAction SilentlyContinue
+ Compress-Archive -Path 'signed/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force
+ Remove-Item "releases/PerformanceMonitorLite-$version.zip" -ErrorAction SilentlyContinue
+ Compress-Archive -Path 'signed/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force
+ Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue
+ Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force
+
- name: Generate checksums
if: github.event_name == 'release'
shell: pwsh
diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml
new file mode 100644
index 00000000..19bca8d5
--- /dev/null
+++ b/.github/workflows/check-version-bump.yml
@@ -0,0 +1,48 @@
+name: Check version bump
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ check-version:
+ if: github.head_ref == 'dev'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v4
+
+ - name: Get PR version
+ id: pr
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+ Write-Host "PR version: $version"
+
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ path: main-branch
+
+ - name: Get main version
+ id: main
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+ Write-Host "Main version: $version"
+
+ - name: Compare versions
+ env:
+ PR_VERSION: ${{ steps.pr.outputs.VERSION }}
+ MAIN_VERSION: ${{ steps.main.outputs.VERSION }}
+ run: |
+ echo "Main version: $MAIN_VERSION"
+ echo "PR version: $PR_VERSION"
+ if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then
+ echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main."
+ exit 1
+ fi
+ echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION"
diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml
index fe06470f..e4f266d3 100644
--- a/.github/workflows/sql-validation.yml
+++ b/.github/workflows/sql-validation.yml
@@ -3,10 +3,10 @@ name: SQL Validation
on:
push:
branches: [dev]
- paths: ['install/**']
+ paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml']
pull_request:
branches: [dev]
- paths: ['install/**']
+ paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml']
jobs:
validate-sql:
diff --git a/.gitignore b/.gitignore
index 0de5c6d0..8d19cb1a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,4 +54,5 @@ nul
# Lite runtime configuration (user-specific)
Lite/config/servers.json
+Lite/servers.json
Lite/collection_schedule.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a6d5cc8f..05c86966 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,113 @@ 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.2.0] - 2026-03-11
+
+**Contributors:** [@HannahVernon](https://github.com/HannahVernon), [@ClaudioESSilva](https://github.com/ClaudioESSilva), [@dphugo](https://github.com/dphugo), [@Orestes](https://github.com/Orestes) — thank you!
+
+### Important
+
+- **Schema upgrade**: Three large collection tables (`query_stats`, `procedure_stats`, `query_store_data`) are migrated to use `COMPRESS()` for query text and plan columns. The upgrade performs a table swap (create new → migrate data → rename) which may take several minutes on large tables. A `row_hash` column is added for deduplication. Three new tracking tables are also created. Volume stats columns are added to `database_size_stats`. Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks.
+
+ Compression results measured on a production instance:
+
+ | Table | Compressed | Uncompressed | Ratio |
+ |---|---|---|---|
+ | query_stats | 18.0 MB | 339.0 MB | 18.8x |
+ | query_store_data | 13.5 MB | 258.0 MB | 19.1x |
+ | **Total** | **31.5 MB** | **597 MB** | **~19x** |
+
+### Added
+
+- **FinOps monitoring tab** — database size tracking, server properties, storage growth analysis (7d/30d), index analysis with unused/duplicate/compressible detection, utilization efficiency, idle database identification, and estate-level resource views ([#474])
+- **Named collection presets** — Aggressive, Balanced, and Low-Impact schedule profiles via `config.apply_collection_preset` ([#454])
+- **Entra ID interactive MFA authentication** in both CLI and GUI installers for Azure SQL MI connections ([#481])
+- **MCP port validation** — TCP port conflict detection, range validation (1024+), Auto port button, and auto-restart on settings change ([#453])
+- **Alert database exclusion filters** — filter blocking and deadlock alerts by database in both Dashboard and Lite ([#410], [#412])
+- **Configurable alert cooldown periods** for tray notifications and email alerts
+- **Wait stats query drill-down** — click a wait type to see the queries causing it ([#372])
+- **Configurable long-running query settings** — max results, WAITFOR/backup/diagnostics exclusions ([#415])
+- **Uninstall option** in both CLI and GUI installers ([#431])
+- **Session stats collector** for active session tracking ([#474])
+- **LOB compression and deduplication** for query stats tables to reduce storage ([#419])
+- **Volume-level drive space** enrichment in database size stats via `dm_os_volume_stats`
+- **GUI installer installation history** logging to `config.installation_history` ([#414])
+- **ReadOnlyIntent connection option** — Lite connections can set `ApplicationIntent=ReadOnly` for automatic read routing to Always On AG readable secondaries ([#515])
+- **Alert muting** — mute individual alerts or create pattern-based mute rules by server, metric, database, or application. Manage Mute Rules window with enable/disable toggle. Alert history detail view with double-click drill-down and context-sensitive detail text. Poison wait type documentation links. ([#512])
+- **SignPath code signing** — all release binaries (Dashboard, Lite, Installers) are digitally signed, eliminating Windows SmartScreen warnings ([#511])
+- CI version bump check on PRs to main
+- Permissions section in README with least-privilege setup ([#421])
+
+### Changed
+
+- **Utilization tab redesigned** — ported to Dashboard with aligned metrics between apps ([#478])
+- PlanAnalyzer rules synced from PerformanceStudio — Rule 5 message format, seek predicate parsing, spool labels, unmatched index detail ([#416], [#475], [#480])
+- Data retention now purges processed XE staging rows
+- GeneratedRegex conversion for compile-time regex patterns ([#346], [#420])
+- Server health card width increased from 260 to 300 for less text truncation ([#489])
+- User's locale used for date/time formatting in WPF bindings ([#459])
+- XML processing instructions stripped from sql_command/sql_text display
+- Parameterized queries in blocking/deadlock alert filtering
+- **DuckDB 1.5.0 upgrade** — non-blocking checkpointing eliminates read stalls during WAL flushes, free block reuse stabilizes database file size without archive-and-reset cycles ([#516])
+- **Automatic parquet compaction** — archive files are merged into monthly files after each archive cycle, reducing file count from 2,600+ to ~75 and eliminating per-file metadata overhead on glob scans ([#516])
+
+ Combined with the UI responsiveness overhaul (#510), Lite's refresh cycle improved 13-26x:
+
+ | Metric | Before | After |
+ |---|---|---|
+ | Lite `RefreshAllDataAsync` | 6-13s | < 500ms |
+ | Parquet files scanned per query | 233 | 19 |
+ | Archive-and-reset frequency | 21/day | ~0 |
+ | `v_wait_stats` query time | 1,700ms | 27ms |
+
+- **Monthly archive retention** — switched from 90-day file-age deletion to 3-month calendar-month rolling window, aligned with compacted monthly filenames ([#516])
+- **Lite status bar** shows used data size vs file size (e.g., "Database: 175.5 / 423.8 MB") via DuckDB `pragma_database_size()` ([#517])
+- **Query Store collector diagnostics** — reader/append/flush timing breakdown logged when collection exceeds 2 seconds, for identifying SQL Server DMV contention under heavy workloads ([#518])
+- SSMS-parity edge tooltips on plan viewer operator connections and ManyToMany indicator always shown for merge join operators ([#504])
+- **Lite UI responsiveness overhaul** — visible-tab-only refresh, sub-tab awareness, Query Store collector optimization (NULL plan XML + LOOP JOIN hint), and DuckDB write reduction ([#510])
+
+ Timer tick improvements measured under TPC-C load on SQL2022:
+
+ | Scenario | Before | After | Improvement |
+ |---|---|---|---|
+ | Lite idle | 6-13s | 546-750ms | ~90% |
+ | Lite under TPC-C | 6-13s | ~3s | ~70% |
+ | Dashboard idle | 5.6s | 0.6-0.8s | 86% |
+ | Dashboard under TPC-C | 5.6s | 1.8-2.0s | 64% |
+
+ Query Store collector specifically:
+
+ | Metric | Before | After |
+ |---|---|---|
+ | query_store collector total | 6-18s | ~600ms |
+ | query_store SQL time | 374-1,104ms | ~300ms (LOOP JOIN hint) |
+ | query_store DuckDB write | 6-16s | ~75-230ms (NULL plan XML) |
+
+### Fixed
+
+- **UI hang** when opening Dashboard tab for offline server — replaced synchronous `.GetAwaiter().GetResult()` with proper `await` ([#477])
+- **First-collection spike** skewing PerfMon, wait stats, file I/O, memory grant, query stats, and procedure stats charts — first cumulative value now treated as baseline ([#482])
+- **Wait type filter TextBox** too small to read ([#488])
+- **Poison wait false positives** and alert log parsing ([#445], [#448])
+- **RID Lookup** analyzer rule matching new PhysicalOp label ([#429])
+- **procedure_stats** plan query using DECOMPRESS after compression migration
+- **database_size_stats** InvalidCastException on compatibility_level
+- **Deadlock filter** using wrong column reference in `GetFilteredDeadlockCountAsync`
+- **RESTORING database** filter added to waiting_tasks collector ([#430])
+- Custom TrayToolTip crash — replaced with plain ToolTipText ([#422])
+- **Lite tab switch freeze** — added `_isRefreshing` guard to prevent tab switch handler from competing with timer ticks for DuckDB connection, eliminating "not responding" hangs ([#510])
+- DuckDB read lock acquisition resilience
+- Formatted duration columns sorting alphabetically instead of numerically
+- Settings window staying open on validation errors
+- Deserialization clamping and validation abort issues
+- **sp_IndexCleanup** summary grid column mapping off-by-one, expanded both grids to show all columns from both result sets ([#503])
+- **Rule 22 table variable** false positive on modification operators — INSERT/UPDATE/DELETE on table variables is expected ([#513])
+- **ComboBox focus steal** in plan viewer stealing keyboard focus from other controls ([#508])
+- **DOP 2 skew** false positive — parallel skew rule no longer fires at DOP 2 ([#508])
+- **ReadOnlyIntent connections** sharing server_id in DuckDB when the same server was added with and without ReadOnlyIntent ([#521])
+
+[2.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v2.1.0...v2.2.0
+
## [2.1.0] - 2026-03-04
### Important
@@ -371,3 +478,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#393]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/393
[#289]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/289
[#395]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/395
+[#400]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/400
+[#401]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/401
+[#410]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/410
+[#412]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/412
+[#414]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/414
+[#415]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/415
+[#416]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/416
+[#419]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/419
+[#420]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/420
+[#421]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/421
+[#422]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/422
+[#429]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/429
+[#430]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/430
+[#431]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/431
+[#445]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/445
+[#448]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/448
+[#453]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/453
+[#454]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/454
+[#459]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/459
+[#474]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/474
+[#475]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/475
+[#477]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/477
+[#478]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/478
+[#480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/480
+[#481]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/481
+[#482]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/482
+[#488]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/488
+[#489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/489
+[#503]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/503
+[#504]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/504
+[#508]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/508
+[#510]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/510
+[#512]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/512
+[#511]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/511
+[#513]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/513
+[#515]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/515
+[#516]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/516
+[#517]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/517
+[#518]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/518
+[#521]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/521
diff --git a/Dashboard/AlertDetailWindow.xaml b/Dashboard/AlertDetailWindow.xaml
new file mode 100644
index 00000000..0d3bdf31
--- /dev/null
+++ b/Dashboard/AlertDetailWindow.xaml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/AlertDetailWindow.xaml.cs b/Dashboard/AlertDetailWindow.xaml.cs
new file mode 100644
index 00000000..69e7e521
--- /dev/null
+++ b/Dashboard/AlertDetailWindow.xaml.cs
@@ -0,0 +1,36 @@
+/*
+ * Performance Monitor Dashboard
+ * Copyright (c) 2026 Darling Data, LLC
+ * Licensed under the MIT License - see LICENSE file for details
+ */
+
+using System.Windows;
+using PerformanceMonitorDashboard.Controls;
+
+namespace PerformanceMonitorDashboard
+{
+ public partial class AlertDetailWindow : Window
+ {
+ public AlertDetailWindow(AlertHistoryDisplayItem item)
+ {
+ InitializeComponent();
+
+ TimeText.Text = item.TimeLocal;
+ ServerText.Text = item.ServerName;
+ MetricText.Text = item.MetricName;
+ CurrentValueText.Text = item.CurrentValue;
+ ThresholdText.Text = item.ThresholdValue;
+ NotificationText.Text = item.NotificationType;
+ StatusText.Text = item.StatusDisplay;
+
+ if (item.Muted)
+ MutedBanner.Visibility = Visibility.Visible;
+
+ if (!string.IsNullOrWhiteSpace(item.DetailText))
+ {
+ DetailTextBox.Text = item.DetailText;
+ DetailPanel.Visibility = Visibility.Visible;
+ }
+ }
+ }
+}
diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs
index f920d823..dfc546d8 100644
--- a/Dashboard/App.xaml.cs
+++ b/Dashboard/App.xaml.cs
@@ -11,6 +11,7 @@
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Markup;
using System.Windows.Threading;
using PerformanceMonitorDashboard.Helpers;
@@ -39,6 +40,11 @@ protected override void OnStartup(StartupEventArgs e)
base.OnStartup(e);
+ // Use the user's locale for date/time formatting in WPF bindings (issue #459)
+ FrameworkElement.LanguageProperty.OverrideMetadata(
+ typeof(FrameworkElement),
+ new FrameworkPropertyMetadata(XmlLanguage.GetLanguage(Thread.CurrentThread.CurrentCulture.IetfLanguageTag)));
+
// Apply saved color theme before the main window is shown
var prefs = new Services.UserPreferencesService().GetPreferences();
ThemeManager.Apply(prefs.ColorTheme ?? "Dark");
@@ -95,6 +101,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
+ /* Silently swallow Hardcodet TrayToolTip race condition (issue #422).
+ The crash occurs in Popup.CreateWindow when showing the custom visual tooltip
+ and is harmless — the tooltip simply doesn't show that one time. */
+ if (IsTrayToolTipCrash(e.Exception))
+ {
+ Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422)");
+ e.Handled = true;
+ return;
+ }
+
Logger.Error("Unhandled Dispatcher Exception", e.Exception);
MessageBox.Show(
@@ -114,6 +130,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv
e.SetObserved(); // Prevent process termination
}
+ ///
+ /// Detects the Hardcodet TrayToolTip race condition crash (issue #422).
+ ///
+ private static bool IsTrayToolTipCrash(Exception ex)
+ {
+ return ex is ArgumentException
+ && ex.Message.Contains("VisualTarget")
+ && ex.StackTrace?.Contains("TaskbarIcon") == true;
+ }
+
private void CreateCrashDump(Exception? exception)
{
try
diff --git a/Dashboard/CollectorScheduleWindow.xaml b/Dashboard/CollectorScheduleWindow.xaml
index 6487ff92..0538ad73 100644
--- a/Dashboard/CollectorScheduleWindow.xaml
+++ b/Dashboard/CollectorScheduleWindow.xaml
@@ -34,8 +34,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/Dashboard/CollectorScheduleWindow.xaml.cs
index 4d1b126e..a74f4025 100644
--- a/Dashboard/CollectorScheduleWindow.xaml.cs
+++ b/Dashboard/CollectorScheduleWindow.xaml.cs
@@ -11,6 +11,7 @@
using System.ComponentModel;
using System.Linq;
using System.Windows;
+using System.Windows.Controls;
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Services;
@@ -20,6 +21,107 @@ public partial class CollectorScheduleWindow : Window
{
private readonly DatabaseService _databaseService;
private List? _schedules;
+ private bool _suppressPresetChange;
+
+ private static readonly Dictionary> Presets = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Aggressive"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats_collector"] = 1,
+ ["query_stats_collector"] = 1,
+ ["memory_stats_collector"] = 1,
+ ["memory_pressure_events_collector"] = 1,
+ ["system_health_collector"] = 2,
+ ["blocked_process_xml_collector"] = 1,
+ ["deadlock_xml_collector"] = 1,
+ ["process_blocked_process_xml"] = 2,
+ ["blocking_deadlock_analyzer"] = 2,
+ ["process_deadlock_xml"] = 2,
+ ["query_store_collector"] = 2,
+ ["procedure_stats_collector"] = 1,
+ ["query_snapshots_collector"] = 1,
+ ["file_io_stats_collector"] = 1,
+ ["memory_grant_stats_collector"] = 1,
+ ["cpu_scheduler_stats_collector"] = 1,
+ ["memory_clerks_stats_collector"] = 2,
+ ["perfmon_stats_collector"] = 1,
+ ["cpu_utilization_stats_collector"] = 1,
+ ["trace_analysis_collector"] = 1,
+ ["default_trace_collector"] = 2,
+ ["configuration_issues_analyzer"] = 1,
+ ["latch_stats_collector"] = 1,
+ ["spinlock_stats_collector"] = 1,
+ ["tempdb_stats_collector"] = 1,
+ ["plan_cache_stats_collector"] = 2,
+ ["session_stats_collector"] = 1,
+ ["waiting_tasks_collector"] = 1,
+ ["running_jobs_collector"] = 2
+ },
+ ["Balanced"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats_collector"] = 1,
+ ["query_stats_collector"] = 2,
+ ["memory_stats_collector"] = 1,
+ ["memory_pressure_events_collector"] = 1,
+ ["system_health_collector"] = 5,
+ ["blocked_process_xml_collector"] = 1,
+ ["deadlock_xml_collector"] = 1,
+ ["process_blocked_process_xml"] = 5,
+ ["blocking_deadlock_analyzer"] = 5,
+ ["process_deadlock_xml"] = 5,
+ ["query_store_collector"] = 2,
+ ["procedure_stats_collector"] = 2,
+ ["query_snapshots_collector"] = 1,
+ ["file_io_stats_collector"] = 1,
+ ["memory_grant_stats_collector"] = 1,
+ ["cpu_scheduler_stats_collector"] = 1,
+ ["memory_clerks_stats_collector"] = 5,
+ ["perfmon_stats_collector"] = 5,
+ ["cpu_utilization_stats_collector"] = 1,
+ ["trace_analysis_collector"] = 2,
+ ["default_trace_collector"] = 5,
+ ["configuration_issues_analyzer"] = 1,
+ ["latch_stats_collector"] = 1,
+ ["spinlock_stats_collector"] = 1,
+ ["tempdb_stats_collector"] = 1,
+ ["plan_cache_stats_collector"] = 5,
+ ["session_stats_collector"] = 1,
+ ["waiting_tasks_collector"] = 1,
+ ["running_jobs_collector"] = 1
+ },
+ ["Low-Impact"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats_collector"] = 5,
+ ["query_stats_collector"] = 10,
+ ["memory_stats_collector"] = 10,
+ ["memory_pressure_events_collector"] = 5,
+ ["system_health_collector"] = 15,
+ ["blocked_process_xml_collector"] = 5,
+ ["deadlock_xml_collector"] = 5,
+ ["process_blocked_process_xml"] = 10,
+ ["blocking_deadlock_analyzer"] = 10,
+ ["process_deadlock_xml"] = 10,
+ ["query_store_collector"] = 30,
+ ["procedure_stats_collector"] = 10,
+ ["query_snapshots_collector"] = 5,
+ ["file_io_stats_collector"] = 10,
+ ["memory_grant_stats_collector"] = 5,
+ ["cpu_scheduler_stats_collector"] = 5,
+ ["memory_clerks_stats_collector"] = 30,
+ ["perfmon_stats_collector"] = 5,
+ ["cpu_utilization_stats_collector"] = 5,
+ ["trace_analysis_collector"] = 10,
+ ["default_trace_collector"] = 15,
+ ["configuration_issues_analyzer"] = 5,
+ ["latch_stats_collector"] = 5,
+ ["spinlock_stats_collector"] = 5,
+ ["tempdb_stats_collector"] = 5,
+ ["plan_cache_stats_collector"] = 15,
+ ["session_stats_collector"] = 5,
+ ["waiting_tasks_collector"] = 5,
+ ["running_jobs_collector"] = 30
+ }
+ };
public CollectorScheduleWindow(DatabaseService databaseService)
{
@@ -59,6 +161,7 @@ private async System.Threading.Tasks.Task LoadSchedulesAsync()
}
ScheduleDataGrid.ItemsSource = _schedules;
+ DetectActivePreset();
}
catch (Exception ex)
{
@@ -71,6 +174,101 @@ private async System.Threading.Tasks.Task LoadSchedulesAsync()
}
}
+ private void DetectActivePreset()
+ {
+ if (_schedules == null) return;
+
+ _suppressPresetChange = true;
+ try
+ {
+ var currentIntervals = _schedules
+ .Where(s => s.FrequencyMinutes < 1440)
+ .ToDictionary(s => s.CollectorName, s => s.FrequencyMinutes, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (presetName, presetIntervals) in Presets)
+ {
+ bool matches = true;
+ foreach (var (collector, freq) in presetIntervals)
+ {
+ if (currentIntervals.TryGetValue(collector, out int current) && current != freq)
+ {
+ matches = false;
+ break;
+ }
+ }
+
+ if (matches)
+ {
+ for (int i = 0; i < PresetComboBox.Items.Count; i++)
+ {
+ if (PresetComboBox.Items[i] is ComboBoxItem item &&
+ string.Equals(item.Content?.ToString(), presetName, StringComparison.OrdinalIgnoreCase))
+ {
+ PresetComboBox.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+ }
+
+ /* No preset matched */
+ PresetComboBox.SelectedIndex = 0;
+ }
+ finally
+ {
+ _suppressPresetChange = false;
+ }
+ }
+
+ private async void PresetComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressPresetChange) return;
+ if (PresetComboBox.SelectedItem is not ComboBoxItem selected) return;
+
+ string presetName = selected.Content?.ToString() ?? "";
+ if (presetName == "Custom") return;
+
+ var result = MessageBox.Show(
+ $"Apply the \"{presetName}\" preset?\n\nThis will change all collector frequencies. Enabled/disabled state and retention settings are not affected.",
+ "Apply Collection Preset",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question
+ );
+
+ if (result != MessageBoxResult.Yes)
+ {
+ DetectActivePreset();
+ return;
+ }
+
+ try
+ {
+ await _databaseService.ApplyCollectionPresetAsync(presetName);
+
+ /* Unsubscribe, reload, resubscribe */
+ if (_schedules != null)
+ {
+ foreach (var schedule in _schedules)
+ {
+ schedule.PropertyChanged -= Schedule_PropertyChanged;
+ }
+ }
+
+ await LoadSchedulesAsync();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(
+ $"Failed to apply preset:\n\n{ex.Message}",
+ "Error Applying Preset",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+
+ DetectActivePreset();
+ }
+ }
+
private async void Schedule_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is CollectorScheduleItem schedule)
@@ -88,6 +286,11 @@ await _databaseService.UpdateCollectorScheduleAsync(
schedule.FrequencyMinutes,
schedule.RetentionDays
);
+
+ if (e.PropertyName == nameof(CollectorScheduleItem.FrequencyMinutes))
+ {
+ DetectActivePreset();
+ }
}
catch (Exception ex)
{
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml b/Dashboard/Controls/AlertsHistoryContent.xaml
index b97fbc3d..f7dfc2b6 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml
@@ -9,6 +9,10 @@
+
+
@@ -22,6 +26,13 @@
+
+
+
@@ -89,6 +104,7 @@
CanUserResizeColumns="True"
SelectionMode="Extended"
SelectionChanged="AlertsDataGrid_SelectionChanged"
+ MouseDoubleClick="AlertsDataGrid_MouseDoubleClick"
RowStyle="{StaticResource AlertRowStyle}">
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
index 22b9ed79..2911fa83 100644
--- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
@@ -25,6 +25,8 @@ public partial class AlertsHistoryContent : UserControl
{
public event EventHandler? AlertsDismissed;
+ public MuteRuleService? MuteRuleService { get; set; }
+
private List _allAlerts = new();
/* Column filter state */
@@ -71,7 +73,9 @@ private void LoadAlerts()
IsResolved = e.MetricName.Contains("Cleared") || e.MetricName.Contains("Resolved"),
IsCritical = e.MetricName.Contains("Deadlock") || e.MetricName.Contains("Poison"),
IsWarning = !e.MetricName.Contains("Cleared") && !e.MetricName.Contains("Resolved")
- && !e.MetricName.Contains("Deadlock") && !e.MetricName.Contains("Poison")
+ && !e.MetricName.Contains("Deadlock") && !e.MetricName.Contains("Poison"),
+ Muted = e.Muted,
+ DetailText = e.DetailText
}).ToList();
ApplyFilters();
@@ -432,6 +436,86 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
}
#endregion
+
+ #region Mute Handlers
+
+ private void AlertsDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (sender is not DataGrid) return;
+
+ // Walk up the visual tree from the click target to find the DataGridRow
+ var source = e.OriginalSource as DependencyObject;
+ while (source != null && source is not DataGridRow && source is not DataGridColumnHeader)
+ source = System.Windows.Media.VisualTreeHelper.GetParent(source);
+
+ // Ignore clicks on column headers or outside rows
+ if (source is not DataGridRow row) return;
+ if (row.DataContext is not AlertHistoryDisplayItem item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private void ViewAlertDetails_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var detailWindow = new AlertDetailWindow(item) { Owner = Window.GetWindow(this) };
+ detailWindow.ShowDialog();
+ }
+
+ private void MuteThisAlert_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var context = new AlertMuteContext
+ {
+ ServerName = item.ServerName,
+ MetricName = item.MetricName
+ };
+
+ var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ MuteRuleService.AddRule(dialog.Rule);
+ LoadAlerts();
+ }
+ }
+
+ private void MuteSimilarAlerts_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var contextMenu = menuItem.Parent as ContextMenu;
+ if (contextMenu == null) return;
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem is not AlertHistoryDisplayItem item) return;
+
+ var context = new AlertMuteContext
+ {
+ MetricName = item.MetricName
+ };
+
+ var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ MuteRuleService.AddRule(dialog.Rule);
+ LoadAlerts();
+ }
+ }
+
+ #endregion
}
public class AlertHistoryDisplayItem
@@ -447,5 +531,7 @@ public class AlertHistoryDisplayItem
public bool IsResolved { get; set; }
public bool IsCritical { get; set; }
public bool IsWarning { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
}
}
diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml
new file mode 100644
index 00000000..14ae05c2
--- /dev/null
+++ b/Dashboard/Controls/FinOpsContent.xaml
@@ -0,0 +1,1419 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Controls/FinOpsContent.xaml.cs b/Dashboard/Controls/FinOpsContent.xaml.cs
new file mode 100644
index 00000000..4d34b207
--- /dev/null
+++ b/Dashboard/Controls/FinOpsContent.xaml.cs
@@ -0,0 +1,788 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using Microsoft.Win32;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services;
+
+namespace PerformanceMonitorDashboard.Controls
+{
+ public partial class FinOpsContent : UserControl
+ {
+ private DatabaseService? _databaseService;
+ private ServerManager? _serverManager;
+ private CredentialService? _credentialService;
+ private List? _serverInventoryCache;
+ private DateTime _serverInventoryCacheTime;
+
+ public FinOpsContent()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ TabHelpers.AutoSizeColumnMinWidths(DatabaseResourcesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(DatabaseSizesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(ApplicationConnectionsDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(ServerInventoryDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(TopTotalGrid);
+ TabHelpers.AutoSizeColumnMinWidths(TopAvgGrid);
+ TabHelpers.AutoSizeColumnMinWidths(StorageGrowthDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(IdleDatabasesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(TempdbPressureDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(WaitCategorySummaryDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(ExpensiveQueriesDataGrid);
+ TabHelpers.AutoSizeColumnMinWidths(IndexAnalysisSummaryGrid);
+ TabHelpers.AutoSizeColumnMinWidths(IndexAnalysisDetailGrid);
+
+ TabHelpers.FreezeColumns(DatabaseResourcesDataGrid, 1);
+ TabHelpers.FreezeColumns(DatabaseSizesDataGrid, 1);
+ TabHelpers.FreezeColumns(ApplicationConnectionsDataGrid, 1);
+ TabHelpers.FreezeColumns(ServerInventoryDataGrid, 1);
+ TabHelpers.FreezeColumns(TopTotalGrid, 1);
+ TabHelpers.FreezeColumns(TopAvgGrid, 1);
+ TabHelpers.FreezeColumns(StorageGrowthDataGrid, 1);
+ TabHelpers.FreezeColumns(IdleDatabasesDataGrid, 1);
+ TabHelpers.FreezeColumns(WaitCategorySummaryDataGrid, 1);
+ TabHelpers.FreezeColumns(ExpensiveQueriesDataGrid, 1);
+ TabHelpers.FreezeColumns(IndexAnalysisDetailGrid, 1);
+ }
+
+ ///
+ /// Initializes the control with required dependencies.
+ ///
+ public void Initialize(ServerManager serverManager, CredentialService credentialService)
+ {
+ _serverManager = serverManager ?? throw new ArgumentNullException(nameof(serverManager));
+ _credentialService = credentialService ?? throw new ArgumentNullException(nameof(credentialService));
+
+ var servers = _serverManager.GetAllServers();
+ ServerSelector.ItemsSource = servers;
+ if (servers.Count > 0)
+ ServerSelector.SelectedIndex = 0;
+ }
+
+ private async void ServerSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (ServerSelector.SelectedItem is ServerConnection server && _credentialService != null)
+ {
+ var connectionString = server.GetConnectionString(_credentialService);
+ _databaseService = new DatabaseService(connectionString);
+ await RefreshDataAsync();
+ }
+ }
+
+ ///
+ /// Refreshes all FinOps data. Can be called from parent control.
+ ///
+ public async Task RefreshDataAsync()
+ {
+ try
+ {
+ await Task.WhenAll(
+ LoadUtilizationAsync(),
+ LoadDatabaseResourcesAsync(),
+ LoadDatabaseSizesAsync(),
+ LoadApplicationConnectionsAsync(),
+ LoadServerInventoryAsync()
+ );
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing FinOps data: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Utilization Tab
+ // ============================================
+
+ private async Task LoadUtilizationAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var efficiency = await _databaseService.GetFinOpsUtilizationEfficiencyAsync();
+ UpdateUtilizationSummary(efficiency);
+ NoUtilizationMessage.Visibility = efficiency == null ? Visibility.Visible : Visibility.Collapsed;
+ SummaryContent.Visibility = efficiency == null ? Visibility.Collapsed : Visibility.Visible;
+
+ if (efficiency != null)
+ {
+ TopTotalGrid.ItemsSource = await _databaseService.GetFinOpsTopResourceConsumersByTotalAsync();
+ TopAvgGrid.ItemsSource = await _databaseService.GetFinOpsTopResourceConsumersByAvgAsync();
+ DbSizeChart.ItemsSource = await _databaseService.GetFinOpsDatabaseSizeSummaryAsync();
+ ProvisioningTrendGrid.ItemsSource = await _databaseService.GetFinOpsProvisioningTrendAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading utilization data: {ex.Message}", ex);
+ }
+ }
+
+ private void UpdateUtilizationSummary(FinOpsUtilizationEfficiency? efficiency)
+ {
+ if (efficiency == null)
+ {
+ ProvisioningStatusText.Text = "No Data";
+ ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray);
+ AvgCpuText.Text = P95CpuText.Text = MaxCpuText.Text = CpuSamplesText.Text = "-";
+ AvgCpuBar.Width = P95CpuBar.Width = MaxCpuBar.Width = 0;
+ MemoryUtilBar.Width = MemoryRatioBar.Width = 0;
+ MemoryUtilText.Text = MemoryRatioText.Text = "-";
+ PhysicalMemoryText.Text = TargetMemoryText.Text = TotalMemoryText.Text = BufferPoolText.Text = "-";
+ WorkerThreadsText.Text = "-";
+ CpuCountText.Text = "-";
+ CpuSamplesText.Text = "-";
+ ClassificationExplanation.Text = "";
+ UtilizationContent.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ UtilizationContent.Visibility = Visibility.Visible;
+
+ // Provisioning status with color coding
+ ProvisioningStatusText.Text = efficiency.ProvisioningStatus.Replace("_", " ");
+
+ switch (efficiency.ProvisioningStatus)
+ {
+ case "RIGHT_SIZED":
+ ProvisioningStatusBorder.Background = (Brush)FindResource("SuccessBrush");
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ case "OVER_PROVISIONED":
+ ProvisioningStatusBorder.Background = (Brush)FindResource("WarningBrush");
+ ProvisioningStatusText.Foreground = Brushes.Black;
+ break;
+ case "UNDER_PROVISIONED":
+ ProvisioningStatusBorder.Background = (Brush)FindResource("ErrorBrush");
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ default:
+ ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray);
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ }
+
+ /* CPU text + bars */
+ AvgCpuText.Text = $"{efficiency.AvgCpuPct:N2}%";
+ P95CpuText.Text = $"{efficiency.P95CpuPct:N2}%";
+ MaxCpuText.Text = $"{efficiency.MaxCpuPct}%";
+ CpuSamplesText.Text = efficiency.CpuSamples.ToString("N0");
+ CpuCountText.Text = efficiency.CpuCount.ToString("N0");
+
+ SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)efficiency.AvgCpuPct);
+ SetBar(P95CpuBar, P95CpuFilled, P95CpuEmpty, (double)efficiency.P95CpuPct);
+ SetBar(MaxCpuBar, MaxCpuFilled, MaxCpuEmpty, efficiency.MaxCpuPct);
+
+ /* Stolen Memory % = (Total Server Memory - Buffer Pool) / Total Server Memory
+ Uses perfmon counter value (TotalServerMemoryMb) for parity with Lite */
+ var tsm = efficiency.TotalServerMemoryMb > 0 ? efficiency.TotalServerMemoryMb : efficiency.TotalMemoryMb;
+ var stolenPct = tsm > 0
+ ? (double)(tsm - efficiency.BufferPoolMb) / tsm * 100.0
+ : 0;
+ MemoryUtilText.Text = $"{stolenPct:N0}%";
+ SetBar(MemoryUtilBar, MemUtilFilled, MemUtilEmpty, stolenPct);
+
+ /* Buffer Pool % = Buffer Pool / Physical Memory */
+ var bpPct = efficiency.PhysicalMemoryMb > 0
+ ? (double)efficiency.BufferPoolMb / efficiency.PhysicalMemoryMb * 100.0
+ : 0;
+ MemoryRatioText.Text = $"{bpPct:N0}%";
+ SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, bpPct);
+
+ PhysicalMemoryText.Text = $"{efficiency.PhysicalMemoryMb:N0} MB";
+ TargetMemoryText.Text = $"{efficiency.TargetMemoryMb:N0} MB";
+ TotalMemoryText.Text = $"{tsm:N0} MB";
+ BufferPoolText.Text = $"{efficiency.BufferPoolMb:N0} MB";
+ WorkerThreadsText.Text = $"{efficiency.WorkerThreadsCurrent:N0} / {efficiency.WorkerThreadsMax:N0}";
+
+ /* Contextual explanation — one sentence describing WHY this classification */
+ ClassificationExplanation.Text = efficiency.ProvisioningStatus switch
+ {
+ "RIGHT_SIZED" => $"CPU is moderately loaded (avg {efficiency.AvgCpuPct:N1}%, p95 {efficiency.P95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bpPct:N0}% of physical RAM). No action needed.",
+ "OVER_PROVISIONED" => $"CPU is lightly loaded (avg {efficiency.AvgCpuPct:N1}%, max {efficiency.MaxCpuPct}%) and buffer pool uses only {bpPct:N0}% of physical RAM. This server may have more resources than it needs.",
+ "UNDER_PROVISIONED" => efficiency.P95CpuPct > 85
+ ? $"CPU p95 is {efficiency.P95CpuPct:N1}% (threshold: 85%). This server may need more CPU capacity."
+ : $"Buffer pool uses {bpPct:N0}% of physical RAM and memory ratio is {efficiency.MemoryRatio:N2} (threshold: 0.95). Memory pressure is high.",
+ _ => ""
+ };
+ }
+
+ private static void SetBar(Border bar, ColumnDefinition filled, ColumnDefinition empty, double pct)
+ {
+ var clamped = Math.Max(0, Math.Min(100, pct));
+
+ /* Color thresholds: green < 60, orange 60-85, red > 85 */
+ var color = clamped switch
+ {
+ > 85 => "#E74C3C",
+ > 60 => "#F39C12",
+ _ => "#27AE60"
+ };
+ bar.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
+
+ /* Use star-width proportions — the layout engine handles sizing natively */
+ filled.Width = new GridLength(Math.Max(clamped, 0.1), GridUnitType.Star);
+ empty.Width = new GridLength(Math.Max(100 - clamped, 0.1), GridUnitType.Star);
+ }
+
+ // ============================================
+ // Database Resources Tab
+ // ============================================
+
+ private int GetResourceUsageHoursBack()
+ {
+ return ResourceUsageTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private async void ResourceUsageTimeRange_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _databaseService == null) return;
+ await LoadDatabaseResourcesAsync();
+ }
+
+ private async Task LoadDatabaseResourcesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var hoursBack = GetResourceUsageHoursBack();
+ var data = await _databaseService.GetFinOpsDatabaseResourceUsageAsync(hoursBack);
+ DatabaseResourcesDataGrid.ItemsSource = data;
+ DatabaseResourcesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ DbResourcesCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading database resources: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Storage Growth Tab
+ // ============================================
+
+ private async Task LoadStorageGrowthAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsStorageGrowthAsync();
+ StorageGrowthDataGrid.ItemsSource = data;
+ StorageGrowthNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ StorageGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading storage growth: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Optimization Tab — Idle Databases
+ // ============================================
+
+ private async Task LoadIdleDatabasesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsIdleDatabasesAsync();
+ IdleDatabasesDataGrid.ItemsSource = data;
+ IdleDatabasesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ IdleDatabasesCountIndicator.Text = data.Count > 0 ? $"{data.Count} idle database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading idle databases: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Optimization Tab — Tempdb Pressure
+ // ============================================
+
+ private async Task LoadTempdbSummaryAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsTempdbSummaryAsync();
+ TempdbPressureDataGrid.ItemsSource = data;
+ TempdbPressureNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading tempdb summary: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Optimization Tab — Wait Stats Summary
+ // ============================================
+
+ private int GetWaitStatsHoursBack()
+ {
+ return WaitStatsTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private int GetExpensiveQueriesHoursBack()
+ {
+ return ExpensiveQueriesTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private async Task LoadWaitCategorySummaryAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var hoursBack = GetWaitStatsHoursBack();
+ var data = await _databaseService.GetFinOpsWaitCategorySummaryAsync(hoursBack);
+ WaitCategorySummaryDataGrid.ItemsSource = data;
+ WaitCategorySummaryNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading wait category summary: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Optimization Tab — Expensive Queries
+ // ============================================
+
+ private async Task LoadExpensiveQueriesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var hoursBack = GetExpensiveQueriesHoursBack();
+ var data = await _databaseService.GetFinOpsExpensiveQueriesAsync(hoursBack);
+ ExpensiveQueriesDataGrid.ItemsSource = data;
+ ExpensiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ExpensiveQueriesCountIndicator.Text = data.Count > 0 ? $"{data.Count} query(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading expensive queries: {ex.Message}", ex);
+ }
+ }
+
+ private async Task LoadMemoryGrantEfficiencyAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsMemoryGrantEfficiencyAsync();
+ MemoryGrantEfficiencyDataGrid.ItemsSource = data;
+ MemoryGrantEfficiencyNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading memory grant efficiency: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Index Analysis Tab
+ // ============================================
+
+ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e)
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ // Check if sp_IndexCleanup exists
+ var exists = await _databaseService.CheckSpIndexCleanupExistsAsync();
+ if (!exists)
+ {
+ IndexAnalysisNotInstalledMessage.Visibility = Visibility.Visible;
+ IndexAnalysisNoDataMessage.Visibility = Visibility.Collapsed;
+ IndexAnalysisSummaryGrid.ItemsSource = null;
+ IndexAnalysisDetailGrid.ItemsSource = null;
+ return;
+ }
+
+ IndexAnalysisNotInstalledMessage.Visibility = Visibility.Collapsed;
+
+ // Show busy state
+ RunIndexAnalysisButton.IsEnabled = false;
+ IndexAnalysisStatusText.Text = "Running analysis...";
+
+ var databaseName = IndexAnalysisDatabaseInput.Text?.Trim();
+ var getAllDatabases = IndexAnalysisAllDatabases.IsChecked == true;
+
+ var (details, summaries) = await _databaseService.RunIndexAnalysisAsync(
+ string.IsNullOrWhiteSpace(databaseName) ? null : databaseName,
+ getAllDatabases);
+
+ IndexAnalysisSummaryGrid.ItemsSource = summaries;
+ IndexAnalysisDetailGrid.ItemsSource = details;
+ IndexAnalysisNoDataMessage.Visibility = details.Count == 0 && summaries.Count == 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ IndexAnalysisStatusText.Text = details.Count > 0
+ ? $"{details.Count} index(es) found"
+ : "Analysis complete — no index issues found";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error running index analysis: {ex.Message}", ex);
+ IndexAnalysisStatusText.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ RunIndexAnalysisButton.IsEnabled = true;
+ }
+ }
+
+ // ============================================
+ // Database Sizes Tab
+ // ============================================
+
+ private async Task LoadDatabaseSizesAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsDatabaseSizeStatsAsync();
+ DatabaseSizesDataGrid.ItemsSource = data;
+ DatabaseSizesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ DbSizeCountIndicator.Text = data.Count > 0 ? $"{data.Count} file(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading database sizes: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Application Connections Tab
+ // ============================================
+
+ private async Task LoadApplicationConnectionsAsync()
+ {
+ if (_databaseService == null) return;
+
+ try
+ {
+ var data = await _databaseService.GetFinOpsApplicationResourceUsageAsync();
+ ApplicationConnectionsDataGrid.ItemsSource = data;
+ ApplicationConnectionsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ AppConnectionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} application(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading application connections: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Server Inventory Tab
+ // ============================================
+
+ private async Task LoadServerInventoryAsync(bool forceRefresh = false)
+ {
+ if (_serverManager == null || _credentialService == null) return;
+
+ // Use cache if available and less than 5 minutes old
+ if (!forceRefresh && _serverInventoryCache != null
+ && (DateTime.Now - _serverInventoryCacheTime).TotalMinutes < 5)
+ {
+ ServerInventoryDataGrid.ItemsSource = _serverInventoryCache;
+ ServerInventoryNoDataMessage.Visibility = _serverInventoryCache.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ServerInventoryCountIndicator.Text = _serverInventoryCache.Count > 0 ? $"{_serverInventoryCache.Count} server(s)" : "";
+ return;
+ }
+
+ try
+ {
+ var servers = _serverManager.GetAllServers();
+
+ var tasks = servers.Select(async server =>
+ {
+ try
+ {
+ var connStr = server.GetConnectionString(_credentialService);
+
+ // Step 1: Query live server properties (works from any DB context)
+ var item = await DatabaseService.GetServerPropertiesLiveAsync(connStr);
+ item.ServerName = server.DisplayName;
+
+ // Step 2: Try to augment with collected metrics from PerformanceMonitor DB
+ try
+ {
+ var svc = new DatabaseService(connStr);
+ var (avgCpu, storageGb, idleDbs, status) = await svc.GetServerMetricsAsync();
+ if (avgCpu.HasValue) item.AvgCpuPct = avgCpu;
+ if (storageGb.HasValue) item.StorageTotalGb = storageGb;
+ if (idleDbs.HasValue) item.IdleDbCount = idleDbs;
+ if (status != null) item.ProvisioningStatus = status;
+ }
+ catch
+ {
+ // PerformanceMonitor DB may not exist or have no data — that's OK
+ }
+
+ return item;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading server inventory for {server.DisplayName}: {ex.Message}", ex);
+ return (FinOpsServerInventory?)null;
+ }
+ });
+
+ var results = await Task.WhenAll(tasks);
+ var allItems = results.Where(r => r != null).Cast().ToList();
+
+ _serverInventoryCache = allItems;
+ _serverInventoryCacheTime = DateTime.Now;
+
+ ServerInventoryDataGrid.ItemsSource = allItems;
+ ServerInventoryNoDataMessage.Visibility = allItems.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ServerInventoryCountIndicator.Text = allItems.Count > 0 ? $"{allItems.Count} server(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error loading server inventory: {ex.Message}", ex);
+ }
+ }
+
+ // ============================================
+ // Refresh Button Handlers
+ // ============================================
+
+ private async void UtilizationRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadUtilizationAsync();
+ }
+
+ private async void DatabaseResourcesRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadDatabaseResourcesAsync();
+ }
+
+ private async void StorageGrowthRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadStorageGrowthAsync();
+ }
+
+ private async void WaitStatsTimeRange_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _databaseService == null) return;
+ await LoadWaitCategorySummaryAsync();
+ }
+
+ private async void ExpensiveQueriesTimeRange_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _databaseService == null) return;
+ await LoadExpensiveQueriesAsync();
+ }
+
+ private async void OptimizationRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await Task.WhenAll(
+ LoadIdleDatabasesAsync(),
+ LoadTempdbSummaryAsync(),
+ LoadWaitCategorySummaryAsync(),
+ LoadExpensiveQueriesAsync(),
+ LoadMemoryGrantEfficiencyAsync()
+ );
+ }
+
+ private async void DatabaseSizesRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadDatabaseSizesAsync();
+ }
+
+ private async void ApplicationConnectionsRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadApplicationConnectionsAsync();
+ }
+
+ private async void ServerInventoryRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadServerInventoryAsync(forceRefresh: true);
+ }
+
+ // ============================================
+ // Copy / Export Context Menu Handlers
+ // ============================================
+
+ private void CopyCell_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null)
+ {
+ var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell);
+ if (!string.IsNullOrEmpty(cellContent))
+ {
+ /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */
+ Clipboard.SetDataObject(cellContent, false);
+ }
+ }
+ }
+ }
+
+ private void CopyRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null)
+ {
+ var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem);
+ if (!string.IsNullOrEmpty(rowText))
+ {
+ /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */
+ Clipboard.SetDataObject(rowText, false);
+ }
+ }
+ }
+ }
+
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ if (contextMenu.PlacementTarget is DataGrid grid)
+ {
+ var sb = new StringBuilder();
+
+ // Header row
+ var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c));
+ sb.AppendLine(string.Join("\t", headers));
+
+ // Data rows
+ foreach (var item in grid.Items)
+ {
+ var values = new List();
+ foreach (var column in grid.Columns)
+ {
+ var binding = (column as DataGridBoundColumn)?.Binding as Binding;
+ if (binding != null)
+ {
+ var prop = item.GetType().GetProperty(binding.Path.Path);
+ var value = prop?.GetValue(item)?.ToString() ?? string.Empty;
+ values.Add(value);
+ }
+ }
+ sb.AppendLine(string.Join("\t", values));
+ }
+
+ if (sb.Length > 0)
+ {
+ /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */
+ Clipboard.SetDataObject(sb.ToString(), false);
+ }
+ }
+ }
+ }
+
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ if (contextMenu.PlacementTarget is DataGrid grid)
+ {
+ var dialog = new SaveFileDialog
+ {
+ Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
+ DefaultExt = ".csv",
+ FileName = $"FinOps_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ try
+ {
+ var sb = new StringBuilder();
+
+ // Header row
+ var sep = TabHelpers.CsvSeparator;
+ var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep));
+ sb.AppendLine(string.Join(sep, headers));
+
+ // Data rows
+ foreach (var item in grid.Items)
+ {
+ var values = new List();
+ foreach (var column in grid.Columns)
+ {
+ var binding = (column as DataGridBoundColumn)?.Binding as Binding;
+ if (binding != null)
+ {
+ var prop = item.GetType().GetProperty(binding.Path.Path);
+ values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep));
+ }
+ }
+ sb.AppendLine(string.Join(sep, values));
+ }
+
+ File.WriteAllText(dialog.FileName, sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error exporting to CSV: {ex.Message}", ex);
+ MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Dashboard/Controls/LandingPage.xaml b/Dashboard/Controls/LandingPage.xaml
index 1352450b..f55d8c5e 100644
--- a/Dashboard/Controls/LandingPage.xaml
+++ b/Dashboard/Controls/LandingPage.xaml
@@ -72,7 +72,7 @@
-
diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs
index 6ea34e78..43590b72 100644
--- a/Dashboard/Controls/PlanViewerControl.xaml.cs
+++ b/Dashboard/Controls/PlanViewerControl.xaml.cs
@@ -183,12 +183,24 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
BorderThickness = new Thickness(isExpensive ? 2 : 1),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 4, 6, 4),
- ToolTip = BuildNodeTooltip(node),
Cursor = Cursors.Hand,
SnapsToDevicePixels = true,
Tag = node
};
+ // Tooltip — root node includes statement-level PlanWarnings
+ if (totalWarningCount > 0 && _currentStatement != null)
+ {
+ var allWarnings = new List();
+ allWarnings.AddRange(_currentStatement.PlanWarnings);
+ CollectWarnings(node, allWarnings);
+ border.ToolTip = BuildNodeTooltip(node, allWarnings);
+ }
+ else
+ {
+ border.ToolTip = BuildNodeTooltip(node);
+ }
+
// Click to select + show properties
border.MouseLeftButtonUp += Node_Click;
@@ -431,21 +443,87 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child)
figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true));
geometry.Figures.Add(figure);
- var rowText = child.HasActualStats
- ? $"Actual Rows: {child.ActualRows:N0}"
- : $"Estimated Rows: {child.EstimateRows:N0}";
-
return new WpfPath
{
Data = geometry,
Stroke = EdgeBrush,
StrokeThickness = thickness,
StrokeLineJoin = PenLineJoin.Round,
- ToolTip = rowText,
+ ToolTip = BuildEdgeTooltipContent(child),
SnapsToDevicePixels = true
};
}
+ private object BuildEdgeTooltipContent(PlanNode child)
+ {
+ var grid = new Grid { MinWidth = 240 };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ int row = 0;
+
+ void AddRow(string label, string value)
+ {
+ grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ var lbl = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 12,
+ Margin = new Thickness(0, 1, 12, 1)
+ };
+ var val = new TextBlock
+ {
+ Text = value,
+ Foreground = new SolidColorBrush(Colors.White),
+ FontSize = 12,
+ FontWeight = FontWeights.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetRow(lbl, row);
+ Grid.SetColumn(lbl, 0);
+ Grid.SetRow(val, row);
+ Grid.SetColumn(val, 1);
+ grid.Children.Add(lbl);
+ grid.Children.Add(val);
+ row++;
+ }
+
+ if (child.HasActualStats)
+ AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}");
+
+ AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}");
+
+ var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds;
+ var estimatedRowsAllExec = child.EstimateRows * executions;
+ AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}");
+
+ if (child.EstimatedRowSize > 0)
+ {
+ AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize));
+ var dataSize = estimatedRowsAllExec * child.EstimatedRowSize;
+ AddRow("Estimated Data Size", FormatBytes(dataSize));
+ }
+
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 6, 10, 6),
+ CornerRadius = new CornerRadius(4),
+ Child = grid
+ };
+ }
+
+ private static string FormatBytes(double bytes)
+ {
+ if (bytes < 1024) return $"{bytes:N0} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB";
+ if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB";
+ return $"{bytes / (1024L * 1024 * 1024):N1} GB";
+ }
+
#endregion
#region Node Selection & Properties Panel
@@ -534,7 +612,8 @@ private void ShowPropertiesPanel(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
headerText += $" ({node.LogicalOp})";
PropertiesHeader.Text = headerText;
PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
@@ -610,7 +689,7 @@ private void ShowPropertiesPanel(PlanNode node)
|| !string.IsNullOrEmpty(node.InnerSideJoinColumns)
|| !string.IsNullOrEmpty(node.OuterSideJoinColumns)
|| !string.IsNullOrEmpty(node.ActionColumn)
- || node.ManyToMany || node.BitmapCreator
+ || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator
|| node.SortDistinct || node.StartupExpression
|| node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
|| node.WithTies || node.Remoting || node.LocalParallelism
@@ -675,8 +754,10 @@ private void ShowPropertiesPanel(PlanNode node)
AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true);
if (!string.IsNullOrEmpty(node.OuterSideJoinColumns))
AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true);
- if (node.ManyToMany)
- AddPropertyRow("Many to Many", "True");
+ if (node.PhysicalOp == "Merge Join")
+ AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No");
+ else if (node.ManyToMany)
+ AddPropertyRow("Many to Many", "Yes");
if (!string.IsNullOrEmpty(node.ConstantScanValues))
AddPropertyRow("Values", node.ConstantScanValues, isCode: true);
if (!string.IsNullOrEmpty(node.UdxUsedColumns))
@@ -1466,7 +1547,7 @@ private void ClosePropertiesPanel()
#region Tooltips
- private ToolTip BuildNodeTooltip(PlanNode node)
+ private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null)
{
var tip = new ToolTip
{
@@ -1481,7 +1562,8 @@ private ToolTip BuildNodeTooltip(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
headerText += $" ({node.LogicalOp})";
stack.Children.Add(new TextBlock
{
@@ -1605,22 +1687,51 @@ private ToolTip BuildNodeTooltip(PlanNode node)
AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true);
}
- // Warnings
- if (node.HasWarnings)
+ // Warnings — use allWarnings (includes statement-level) for root, node.Warnings for others
+ var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null);
+ if (warnings != null && warnings.Count > 0)
{
stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) });
- foreach (var w in node.Warnings)
+
+ if (allWarnings != null)
{
- var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
- : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- stack.Children.Add(new TextBlock
+ // Root node: show distinct warning type names only
+ var distinct = warnings
+ .GroupBy(w => w.WarningType)
+ .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count()))
+ .OrderByDescending(g => g.MaxSeverity)
+ .ThenBy(g => g.Type);
+
+ foreach (var (type, severity, count) in distinct)
{
- Text = $"\u26A0 {w.WarningType}: {w.Message}",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
- FontSize = 11,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 2, 0, 0)
- });
+ var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
+ : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}";
+ stack.Children.Add(new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
+ FontSize = 11,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
+ }
+ else
+ {
+ // Individual node: show full warning messages
+ foreach (var w in warnings)
+ {
+ var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
+ : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"\u26A0 {w.WarningType}: {w.Message}",
+ Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
+ FontSize = 11,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
}
}
@@ -1923,7 +2034,7 @@ void AddRow(string label, string value)
if (statement.MemoryGrant != null)
{
var mg = statement.MemoryGrant;
- AddRow("Memory grant", $"{mg.GrantedMemoryKB:N0} KB granted, {mg.MaxUsedMemoryKB:N0} KB used");
+ AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used");
if (mg.GrantWaitTimeMs > 0)
AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms");
}
@@ -1967,6 +2078,19 @@ void AddRow(string label, string value)
RuntimeSummaryContent.Children.Add(grid);
}
+ ///
+ /// Formats a memory value given in KB to a human-readable string.
+ /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals).
+ ///
+ private static string FormatMemoryGrantKB(long kb)
+ {
+ if (kb < 1024)
+ return $"{kb:N0} KB";
+ if (kb < 1024 * 1024)
+ return $"{kb / 1024.0:N1} MB";
+ return $"{kb / (1024.0 * 1024.0):N2} GB";
+ }
+
private void UpdateInsightsHeader()
{
InsightsPanel.Visibility = Visibility.Visible;
@@ -2011,9 +2135,30 @@ private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventAr
private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
+ // Don't steal focus from interactive controls (ComboBox, DataGrid, TextBox, etc.)
+ // ComboBox dropdown items live in a separate visual tree (Popup), so also check
+ // for ComboBoxItem to avoid stealing focus when selecting dropdown items.
+ if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase
+ || e.OriginalSource is ComboBox
+ || e.OriginalSource is ComboBoxItem
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null)
+ return;
+
Focus();
}
+ private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject
+ {
+ while (child != null)
+ {
+ if (child is T parent) return parent;
+ child = VisualTreeHelper.GetParent(child);
+ }
+ return null;
+ }
+
private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control
diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml
index ad5f8f6d..2829cb19 100644
--- a/Dashboard/Controls/ResourceMetricsContent.xaml
+++ b/Dashboard/Controls/ResourceMetricsContent.xaml
@@ -119,7 +119,7 @@
-
@@ -1813,6 +1814,48 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e)
await UpdateWaitStatsDetailChartAsync();
}
+ private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu)
+ {
+ contextMenu.Items.Insert(0, new Separator());
+ var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" };
+ drillDownItem.Click += ShowQueriesForWaitType_Click;
+ contextMenu.Items.Insert(0, drillDownItem);
+
+ contextMenu.Opened += (s, _) =>
+ {
+ var pos = System.Windows.Input.Mouse.GetPosition(chart);
+ var nearest = _waitStatsHover?.GetNearestSeries(pos);
+ if (nearest.HasValue)
+ {
+ drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time);
+ drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}";
+ drillDownItem.IsEnabled = true;
+ }
+ else
+ {
+ drillDownItem.Tag = null;
+ drillDownItem.Header = "Show Queries With This Wait";
+ drillDownItem.IsEnabled = false;
+ }
+ };
+ }
+
+ private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ if (menuItem.Tag is not ValueTuple tag) return;
+ if (_databaseService == null) return;
+
+ // ±15 minute window around the clicked point
+ var fromDate = tag.Item2.AddMinutes(-15);
+ var toDate = tag.Item2.AddMinutes(15);
+
+ var window = new WaitDrillDownWindow(
+ _databaseService, tag.Item1, 1, fromDate, toDate);
+ window.Owner = Window.GetWindow(this);
+ window.ShowDialog();
+ }
+
private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_allWaitStatsDetailData != null)
diff --git a/Dashboard/Converters/QueryTextCleanupConverter.cs b/Dashboard/Converters/QueryTextCleanupConverter.cs
index e1ae8481..764131d0 100644
--- a/Dashboard/Converters/QueryTextCleanupConverter.cs
+++ b/Dashboard/Converters/QueryTextCleanupConverter.cs
@@ -12,7 +12,7 @@
namespace PerformanceMonitorDashboard.Converters
{
- public class QueryTextCleanupConverter : IValueConverter
+ public partial class QueryTextCleanupConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
@@ -28,7 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn
text = text.Replace("\t", " ", StringComparison.Ordinal);
// Replace multiple spaces with single space
- text = Regex.Replace(text, @"\s+", " ");
+ text = MultipleSpacesRegExp().Replace(text, " ");
// Trim leading/trailing whitespace
text = text.Trim();
@@ -40,5 +40,8 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
{
throw new NotImplementedException();
}
+
+ [GeneratedRegex(@"\s+")]
+ private static partial Regex MultipleSpacesRegExp();
}
}
diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj
index 0f215418..e15a88be 100644
--- a/Dashboard/Dashboard.csproj
+++ b/Dashboard/Dashboard.csproj
@@ -6,10 +6,10 @@
true
PerformanceMonitorDashboard
SQL Server Performance Monitor Dashboard
- 2.1.0
- 2.1.0.0
- 2.1.0.0
- 2.1.0
+ 2.2.0
+ 2.2.0.0
+ 2.2.0.0
+ 2.2.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs
index 17f27027..1fb73cc2 100644
--- a/Dashboard/Helpers/ChartHoverHelper.cs
+++ b/Dashboard/Helpers/ChartHoverHelper.cs
@@ -61,6 +61,50 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
_scatters.Add((scatter, label));
+ ///
+ /// Returns the nearest series label and data-point time for the given mouse position,
+ /// or null if no series is close enough.
+ ///
+ public (string Label, DateTime Time)? GetNearestSeries(Point mousePos)
+ {
+ if (_scatters.Count == 0) return null;
+ try
+ {
+ var dpi = VisualTreeHelper.GetDpi(_chart);
+ var pixel = new ScottPlot.Pixel(
+ (float)(mousePos.X * dpi.DpiScaleX),
+ (float)(mousePos.Y * dpi.DpiScaleY));
+ var mouseCoords = _chart.Plot.GetCoordinates(pixel);
+
+ double bestYDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestLabel = "";
+ bool found = false;
+
+ foreach (var (scatter, label) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+ var nearestPixel = _chart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = Math.Abs(nearestPixel.X - pixel.X);
+ double dy = Math.Abs(nearestPixel.Y - pixel.Y);
+ if (dx < 80 && dy < bestYDistance)
+ {
+ bestYDistance = dy;
+ bestPoint = nearest;
+ bestLabel = label;
+ found = true;
+ }
+ }
+
+ if (found)
+ return (bestLabel, DateTime.FromOADate(bestPoint.X));
+ }
+ catch { }
+ return null;
+ }
+
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_scatters.Count == 0) return;
@@ -71,9 +115,10 @@ private void OnMouseMove(object sender, MouseEventArgs e)
try
{
var pos = e.GetPosition(_chart);
+ var dpi = VisualTreeHelper.GetDpi(_chart);
var pixel = new ScottPlot.Pixel(
- (float)(pos.X * _chart.DisplayScale),
- (float)(pos.Y * _chart.DisplayScale));
+ (float)(pos.X * dpi.DpiScaleX),
+ (float)(pos.Y * dpi.DpiScaleY));
var mouseCoords = _chart.Plot.GetCoordinates(pixel);
/* Use X-axis (time) proximity as the primary filter, Y-axis distance
diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/Dashboard/Helpers/DateFilterHelper.cs
index b9b5e4f4..def0f459 100644
--- a/Dashboard/Helpers/DateFilterHelper.cs
+++ b/Dashboard/Helpers/DateFilterHelper.cs
@@ -11,7 +11,7 @@
namespace PerformanceMonitorDashboard.Helpers
{
- public static class DateFilterHelper
+ public static partial class DateFilterHelper
{
public static bool MatchesFilter(object? value, string? filterText)
{
@@ -148,7 +148,7 @@ private static bool TryConvertToDateTime(object value, out DateTime result)
}
// "last N hours/days/weeks" expressions
- var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)");
+ var lastMatch = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower);
if (lastMatch.Success)
{
int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture);
@@ -231,5 +231,8 @@ private static bool IsRelativeExpression(string expression)
expression == "tomorrow" ||
Regex.IsMatch(expression, @"last\s+\d+\s+(hour|hours|day|days|week|weeks|month|months)");
}
+
+ [GeneratedRegex(@"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)")]
+ private static partial Regex LastNHoursDaysWeeksMonthsRegExp();
}
}
diff --git a/Dashboard/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs
index ed1260b5..6a5a7361 100644
--- a/Dashboard/Helpers/TabHelpers.cs
+++ b/Dashboard/Helpers/TabHelpers.cs
@@ -603,7 +603,7 @@ public static string FormatForExport(object? value)
/// The WpfPlot chart control
/// A descriptive name for the chart (used in filenames)
/// Optional SQL view/table name that populates this chart
- public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
+ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
{
var contextMenu = new ContextMenu();
@@ -786,6 +786,8 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string
chart.Plot.Axes.AutoScale();
chart.Refresh();
};
+
+ return contextMenu;
}
///
diff --git a/Dashboard/Helpers/WaitDrillDownHelper.cs b/Dashboard/Helpers/WaitDrillDownHelper.cs
new file mode 100644
index 00000000..ef62d977
--- /dev/null
+++ b/Dashboard/Helpers/WaitDrillDownHelper.cs
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PerformanceMonitorDashboard.Helpers;
+
+///
+/// Classifies wait types for drill-down behavior and walks blocking chains
+/// to find head blockers. Used by WaitDrillDownWindow.
+///
+public static class WaitDrillDownHelper
+{
+ public enum WaitCategory
+ {
+ /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric.
+ Correlated,
+ /// Walk blocking chain to find head blockers (LCK_M_*).
+ Chain,
+ /// Sessions may lack worker threads, unlikely to appear in snapshots.
+ Uncapturable,
+ /// Attempt direct wait_type filter; may return empty for brief waits.
+ Filtered
+ }
+
+ public sealed record WaitClassification(
+ WaitCategory Category,
+ string SortProperty,
+ string Description
+ );
+
+ ///
+ /// Lightweight result from the chain walker — just the head blocker identity and blocked count.
+ /// Callers look up the original full row by (CollectionTime, SessionId).
+ ///
+ public sealed record HeadBlockerInfo(
+ DateTime CollectionTime,
+ int SessionId,
+ int BlockedSessionCount,
+ string BlockingPath
+ );
+
+ public sealed record SnapshotInfo
+ {
+ public int SessionId { get; init; }
+ public int BlockingSessionId { get; init; }
+ public DateTime CollectionTime { get; init; }
+ public string DatabaseName { get; init; } = "";
+ public string Status { get; init; } = "";
+ public string QueryText { get; init; } = "";
+ public string? WaitType { get; init; }
+ public long WaitTimeMs { get; init; }
+ public long CpuTimeMs { get; init; }
+ public long Reads { get; init; }
+ public long Writes { get; init; }
+ public long LogicalReads { get; init; }
+ }
+
+ private const int MaxChainDepth = 20;
+
+ public static WaitClassification Classify(string waitType)
+ {
+ if (string.IsNullOrEmpty(waitType))
+ return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown");
+
+ return waitType switch
+ {
+ "SOS_SCHEDULER_YIELD" =>
+ new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"),
+ "WRITELOG" =>
+ new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"),
+ "CXPACKET" or "CXCONSUMER" =>
+ new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"),
+ "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" =>
+ new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"),
+ "THREADPOOL" =>
+ new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"),
+ "LATCH_EX" or "LATCH_UP" =>
+ new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"),
+ _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) =>
+ new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"),
+ _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) =>
+ new(WaitCategory.Chain, "", "Lock contention — showing head blockers"),
+ _ =>
+ new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type")
+ };
+ }
+
+ ///
+ /// Walks blocking chains to find head blockers.
+ /// Returns lightweight HeadBlockerInfo records — callers look up original full rows
+ /// by (CollectionTime, SessionId) to preserve all columns.
+ ///
+ public static List WalkBlockingChains(
+ IEnumerable waiters,
+ IEnumerable allSnapshots)
+ {
+ var byTime = allSnapshots
+ .GroupBy(s => s.CollectionTime)
+ .ToDictionary(
+ g => g.Key,
+ g => g.ToDictionary(s => s.SessionId));
+
+ var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>();
+
+ foreach (var waiter in waiters)
+ {
+ if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime))
+ continue;
+
+ var head = FindHeadBlocker(waiter, sessionsAtTime);
+ if (head == null)
+ continue;
+
+ var key = (waiter.CollectionTime, head.SessionId);
+ if (!headBlockers.TryGetValue(key, out var existing))
+ {
+ existing = (head, new HashSet());
+ headBlockers[key] = existing;
+ }
+
+ existing.BlockedSessions.Add(waiter.SessionId);
+ }
+
+ return headBlockers.Values
+ .Select(hb => new HeadBlockerInfo(
+ hb.Info.CollectionTime,
+ hb.Info.SessionId,
+ hb.BlockedSessions.Count,
+ $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)"))
+ .OrderByDescending(r => r.BlockedSessionCount)
+ .ThenByDescending(r => r.CollectionTime)
+ .ToList();
+ }
+
+ private static SnapshotInfo? FindHeadBlocker(
+ SnapshotInfo waiter,
+ Dictionary sessionsAtTime)
+ {
+ var visited = new HashSet();
+ var current = waiter;
+
+ for (int depth = 0; depth < MaxChainDepth; depth++)
+ {
+ if (!visited.Add(current.SessionId))
+ return current; // cycle detected — treat current as head
+
+ var blockerId = current.BlockingSessionId;
+
+ // Head blocker: not blocked by anyone, or blocked by self, or blocker not found
+ if (blockerId <= 0 || blockerId == current.SessionId)
+ return current;
+
+ if (!sessionsAtTime.TryGetValue(blockerId, out var blocker))
+ return current; // blocker not in snapshot — treat current as head
+
+ current = blocker;
+ }
+
+ return current; // max depth — treat current as head
+ }
+}
diff --git a/Dashboard/MainWindow.xaml b/Dashboard/MainWindow.xaml
index 1edd0c28..5351bd71 100644
--- a/Dashboard/MainWindow.xaml
+++ b/Dashboard/MainWindow.xaml
@@ -193,6 +193,12 @@
+
-
+
diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs
index d1dd898d..27eb14c8 100644
--- a/Dashboard/ServerTab.xaml.cs
+++ b/Dashboard/ServerTab.xaml.cs
@@ -325,7 +325,7 @@ private void SetupAutoRefresh()
try
{
- await LoadDataAsync();
+ await LoadDataAsync(fullRefresh: false);
}
catch (Exception ex)
{
@@ -396,7 +396,7 @@ public void RefreshAutoRefreshSettings()
try
{
- await LoadDataAsync();
+ await LoadDataAsync(fullRefresh: false);
}
catch (Exception ex)
{
@@ -445,7 +445,7 @@ private void AutoRefreshToggle_Click(object sender, RoutedEventArgs e)
try
{
- await LoadDataAsync();
+ await LoadDataAsync(fullRefresh: false);
}
catch (Exception ex)
{
@@ -1081,7 +1081,12 @@ private async Task ApplyAndRefreshCurrentTabAsync()
}
}
- private async Task LoadDataAsync()
+ ///
+ /// Loads data for the Dashboard. When fullRefresh is true (first load, manual refresh,
+ /// Apply to All), all tabs are refreshed in parallel. When false (auto-refresh timer tick),
+ /// only the currently visible tab is refreshed to reduce SQL Server load.
+ ///
+ private async Task LoadDataAsync(bool fullRefresh = true)
{
using var _ = Helpers.MethodProfiler.StartTiming("ServerTab");
try
@@ -1104,35 +1109,110 @@ private async Task LoadDataAsync()
StatusText.Text = GetLoadingMessage();
- // Fetch all data in parallel — overview queries + all tab refreshes
+ if (fullRefresh)
+ {
+ // Full refresh: query all tabs in parallel (first load, manual refresh, Apply to All)
+ await RefreshAllTabsAsync();
+ }
+ else
+ {
+ // Timer tick: only refresh the currently visible tab
+ await RefreshVisibleTabAsync();
+ }
+
+ StatusText.Text = "Ready";
+ FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}";
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = "Error loading data";
+ MessageBox.Show(
+ $"Error loading data:\n\n{ex.Message}",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ finally
+ {
+ RefreshButton.IsEnabled = true;
+ }
+ }
+
+ // ====================================================================
+ // Per-Tab Refresh Methods
+ // ====================================================================
+
+ ///
+ /// Refreshes all tabs in parallel — used on first load, manual refresh, and Apply to All.
+ ///
+ private async Task RefreshAllTabsAsync()
+ {
+ var overviewTask = RefreshOverviewTabAsync();
+ var queriesTask = RefreshQueriesTabAsync();
+ var resourceMetricsTask = RefreshResourceMetricsTabAsync();
+ var memoryTask = RefreshMemoryTabAsync();
+ var lockingTask = RefreshLockingTabAsync();
+ var systemEventsTask = RefreshSystemEventsTabAsync();
+
+ await Task.WhenAll(overviewTask, queriesTask, resourceMetricsTask, memoryTask, lockingTask, systemEventsTask);
+ }
+
+ ///
+ /// Refreshes only the currently visible tab — used on auto-refresh timer tick.
+ ///
+ private async Task RefreshVisibleTabAsync()
+ {
+ var selectedTab = DataTabControl.SelectedItem as TabItem;
+ if (selectedTab == null) return;
+
+ var tabHeader = GetTabHeaderText(selectedTab);
+
+ switch (tabHeader)
+ {
+ case "Overview":
+ await RefreshOverviewTabAsync();
+ break;
+ case "Queries":
+ await RefreshQueriesTabAsync();
+ break;
+ case "Resource Metrics":
+ await RefreshResourceMetricsTabAsync();
+ break;
+ case "Memory":
+ await RefreshMemoryTabAsync();
+ break;
+ case "Locking":
+ await RefreshLockingTabAsync();
+ break;
+ case "System Events":
+ await RefreshSystemEventsTabAsync();
+ break;
+ // Plan Viewer has no data to refresh
+ }
+ }
+
+ ///
+ /// Refreshes the Overview tab: Collection Health, Duration Trends, Daily Summary,
+ /// Critical Issues, Default Trace, Current Config, Config Changes, Resource Overview, Running Jobs.
+ ///
+ private async Task RefreshOverviewTabAsync()
+ {
+ try
+ {
var healthTask = _databaseService.GetCollectionHealthAsync();
var durationLogsTask = _databaseService.GetCollectionDurationLogsAsync();
- var blockingEventsTask = _databaseService.GetBlockingEventsAsync();
- var deadlocksTask = _databaseService.GetDeadlocksAsync();
- var blockingStatsTask = _databaseService.GetBlockingDeadlockStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
- var lockWaitStatsTask = _databaseService.GetLockWaitStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
- var currentWaitsDurationTask = _databaseService.GetWaitingTaskTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
- var currentWaitsBlockedTask = _databaseService.GetBlockedSessionTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
-
- var performanceTask = PerformanceTab.RefreshAllDataAsync();
- var memoryTask = MemoryTab.RefreshAllDataAsync();
var resourceOverviewTask = RefreshResourceOverviewAsync();
var runningJobsTask = RefreshRunningJobsAsync();
- var resourceMetricsTask = ResourceMetricsContent.RefreshAllDataAsync();
var dailySummaryTask = DailySummaryTab.RefreshDataAsync();
var criticalIssuesTask = CriticalIssuesTab.RefreshDataAsync();
var defaultTraceTask = DefaultTraceTab.RefreshAllDataAsync();
var currentConfigTask = CurrentConfigTab.RefreshAllDataAsync();
var configChangesTask = ConfigChangesTab.RefreshAllDataAsync();
- var systemEventsTask = SystemEventsContent.RefreshAllDataAsync();
- // Wait for everything to complete before _isRefreshing resets
- await Task.WhenAll(
- healthTask, durationLogsTask, blockingEventsTask, deadlocksTask, blockingStatsTask, lockWaitStatsTask, currentWaitsDurationTask, currentWaitsBlockedTask,
- performanceTask, memoryTask, resourceOverviewTask, runningJobsTask,
- resourceMetricsTask, dailySummaryTask, criticalIssuesTask, defaultTraceTask, currentConfigTask, configChangesTask, systemEventsTask);
+ await Task.WhenAll(healthTask, durationLogsTask, resourceOverviewTask, runningJobsTask,
+ dailySummaryTask, criticalIssuesTask, defaultTraceTask, currentConfigTask, configChangesTask);
- // Populate grids with fetched data
var healthData = await healthTask;
HealthDataGrid.ItemsSource = healthData;
UpdateDataGridFilterButtonStyles(HealthDataGrid, _collectionHealthFilters);
@@ -1140,6 +1220,74 @@ await Task.WhenAll(
var durationLogs = await durationLogsTask;
UpdateCollectorDurationChart(durationLogs);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing Overview tab: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Refreshes the Queries tab (delegated to QueryPerformanceContent UserControl).
+ ///
+ private async Task RefreshQueriesTabAsync()
+ {
+ try
+ {
+ await PerformanceTab.RefreshAllDataAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing Queries tab: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Refreshes the Resource Metrics tab (delegated to ResourceMetricsContent UserControl).
+ ///
+ private async Task RefreshResourceMetricsTabAsync()
+ {
+ try
+ {
+ await ResourceMetricsContent.RefreshAllDataAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing Resource Metrics tab: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Refreshes the Memory tab (delegated to MemoryContent UserControl).
+ ///
+ private async Task RefreshMemoryTabAsync()
+ {
+ try
+ {
+ await MemoryTab.RefreshAllDataAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing Memory tab: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Refreshes the Locking tab: Blocking events, deadlocks, blocking/deadlock stats,
+ /// lock wait stats, current waits duration, and current waits blocked sessions.
+ ///
+ private async Task RefreshLockingTabAsync()
+ {
+ try
+ {
+ var blockingEventsTask = _databaseService.GetBlockingEventsAsync();
+ var deadlocksTask = _databaseService.GetDeadlocksAsync();
+ var blockingStatsTask = _databaseService.GetBlockingDeadlockStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
+ var lockWaitStatsTask = _databaseService.GetLockWaitStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
+ var currentWaitsDurationTask = _databaseService.GetWaitingTaskTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
+ var currentWaitsBlockedTask = _databaseService.GetBlockedSessionTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate);
+
+ await Task.WhenAll(blockingEventsTask, deadlocksTask, blockingStatsTask, lockWaitStatsTask, currentWaitsDurationTask, currentWaitsBlockedTask);
try
{
@@ -1180,27 +1328,55 @@ await Task.WhenAll(
{
Logger.Warning($"Could not load blocking/deadlock stats: {blockingStatsEx.Message}");
}
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing Locking tab: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Refreshes the System Events tab (delegated to SystemEventsContent UserControl).
+ ///
+ private async Task RefreshSystemEventsTabAsync()
+ {
+ try
+ {
+ await SystemEventsContent.RefreshAllDataAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Error refreshing System Events tab: {ex.Message}", ex);
+ }
+ }
- int failing = healthData.Count(h => h.HealthStatus == "FAILING");
- int stale = healthData.Count(h => h.HealthStatus == "STALE");
- int healthy = healthData.Count(h => h.HealthStatus == "HEALTHY");
+ ///
+ /// Handles the main TabControl's SelectionChanged event to refresh the newly
+ /// visible tab with current data. Guards against bubbling from nested TabControls.
+ ///
+ private async void DataTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ // Only handle events from the main DataTabControl, not from nested sub-tab controls
+ if (e.Source != DataTabControl) return;
+
+ // Don't refresh during initial load or if already refreshing
+ if (_isRefreshing || !IsLoaded) return;
+ _isRefreshing = true;
+ try
+ {
+ await RefreshVisibleTabAsync();
StatusText.Text = "Ready";
FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}";
}
catch (Exception ex)
{
- StatusText.Text = "Error loading data";
- MessageBox.Show(
- $"Error loading data:\n\n{ex.Message}",
- "Error",
- MessageBoxButton.OK,
- MessageBoxImage.Error
- );
+ Logger.Error($"Error refreshing on tab switch: {ex.Message}", ex);
+ StatusText.Text = "Error refreshing data";
}
finally
{
- RefreshButton.IsEnabled = true;
+ _isRefreshing = false;
}
}
diff --git a/Dashboard/Services/DatabaseService.FinOps.cs b/Dashboard/Services/DatabaseService.FinOps.cs
new file mode 100644
index 00000000..641730ce
--- /dev/null
+++ b/Dashboard/Services/DatabaseService.FinOps.cs
@@ -0,0 +1,1867 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using PerformanceMonitorDashboard.Helpers;
+
+namespace PerformanceMonitorDashboard.Services
+{
+ public partial class DatabaseService
+ {
+ // ============================================
+ // FinOps Tab Data Access
+ // ============================================
+
+ ///
+ /// Fetches per-database resource usage from report.finops_database_resource_usage.
+ ///
+ public async Task> GetFinOpsDatabaseResourceUsageAsync(int hoursBack = 24)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ workload_stats AS
+ (
+ SELECT
+ database_name = qs.database_name,
+ cpu_time_ms =
+ SUM(qs.total_worker_time_delta) / 1000,
+ logical_reads =
+ SUM(qs.total_logical_reads_delta),
+ physical_reads =
+ SUM(qs.total_physical_reads_delta),
+ logical_writes =
+ SUM(qs.total_logical_writes_delta),
+ execution_count =
+ SUM(qs.execution_count_delta)
+ FROM collect.query_stats AS qs
+ WHERE qs.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND qs.total_worker_time_delta IS NOT NULL
+ GROUP BY
+ qs.database_name
+ ),
+ io_stats AS
+ (
+ SELECT
+ database_name = fio.database_name,
+ io_read_bytes =
+ SUM(fio.num_of_bytes_read_delta),
+ io_write_bytes =
+ SUM(fio.num_of_bytes_written_delta),
+ io_stall_ms =
+ SUM(fio.io_stall_ms_delta)
+ FROM collect.file_io_stats AS fio
+ WHERE fio.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND fio.num_of_bytes_read_delta IS NOT NULL
+ GROUP BY
+ fio.database_name
+ ),
+ totals AS
+ (
+ SELECT
+ total_cpu_ms =
+ NULLIF(SUM(ws.cpu_time_ms), 0),
+ total_io_bytes =
+ NULLIF
+ (
+ SUM(ios.io_read_bytes) +
+ SUM(ios.io_write_bytes),
+ 0
+ )
+ FROM workload_stats AS ws
+ FULL JOIN io_stats AS ios
+ ON ios.database_name = ws.database_name
+ )
+SELECT
+ database_name =
+ COALESCE(ws.database_name, ios.database_name),
+ cpu_time_ms =
+ ISNULL(ws.cpu_time_ms, 0),
+ logical_reads =
+ ISNULL(ws.logical_reads, 0),
+ physical_reads =
+ ISNULL(ws.physical_reads, 0),
+ logical_writes =
+ ISNULL(ws.logical_writes, 0),
+ execution_count =
+ ISNULL(ws.execution_count, 0),
+ io_read_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ ISNULL(ios.io_read_bytes, 0) / 1048576.0
+ ),
+ io_write_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ ISNULL(ios.io_write_bytes, 0) / 1048576.0
+ ),
+ io_stall_ms =
+ ISNULL(ios.io_stall_ms, 0),
+ pct_cpu_share =
+ CONVERT
+ (
+ decimal(5,2),
+ ISNULL(ws.cpu_time_ms, 0) * 100.0 /
+ t.total_cpu_ms
+ ),
+ pct_io_share =
+ CONVERT
+ (
+ decimal(5,2),
+ (ISNULL(ios.io_read_bytes, 0) + ISNULL(ios.io_write_bytes, 0)) * 100.0 /
+ t.total_io_bytes
+ )
+FROM workload_stats AS ws
+FULL JOIN io_stats AS ios
+ ON ios.database_name = ws.database_name
+CROSS JOIN totals AS t
+ORDER BY
+ ISNULL(ws.cpu_time_ms, 0) DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_DatabaseResourceUsage", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsDatabaseResourceUsage
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ LogicalReads = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)),
+ PhysicalReads = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)),
+ LogicalWrites = reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)),
+ ExecutionCount = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
+ IoReadMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ IoWriteMb = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)),
+ IoStallMs = reader.IsDBNull(8) ? 0 : Convert.ToInt64(reader.GetValue(8)),
+ PctCpuShare = reader.IsDBNull(9) ? 0m : Convert.ToDecimal(reader.GetValue(9)),
+ PctIoShare = reader.IsDBNull(10) ? 0m : Convert.ToDecimal(reader.GetValue(10))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Fetches utilization efficiency metrics from report.finops_utilization_efficiency.
+ ///
+ public async Task GetFinOpsUtilizationEfficiencyAsync()
+ {
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT
+ v.avg_cpu_pct,
+ v.max_cpu_pct,
+ v.p95_cpu_pct,
+ v.cpu_samples,
+ v.total_memory_mb,
+ v.target_memory_mb,
+ v.physical_memory_mb,
+ v.memory_ratio,
+ v.memory_utilization_pct,
+ v.worker_threads_current,
+ v.worker_threads_max,
+ v.worker_thread_ratio,
+ v.cpu_count,
+ v.provisioning_status,
+ m.buffer_pool_mb,
+ tsm.total_server_memory_mb
+ FROM report.finops_utilization_efficiency AS v
+ OUTER APPLY
+ (
+ SELECT TOP (1)
+ ms.buffer_pool_mb
+ FROM collect.memory_stats AS ms
+ ORDER BY
+ ms.collection_time DESC
+ ) AS m
+ OUTER APPLY
+ (
+ SELECT
+ total_server_memory_mb =
+ pc.cntr_value / 1024
+ FROM sys.dm_os_performance_counters AS pc
+ WHERE pc.counter_name = N'Total Server Memory (KB)'
+ ) AS tsm
+ OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_UtilizationEfficiency", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ return new FinOpsUtilizationEfficiency
+ {
+ AvgCpuPct = reader.IsDBNull(0) ? 0m : Convert.ToDecimal(reader.GetValue(0)),
+ MaxCpuPct = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)),
+ P95CpuPct = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ CpuSamples = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)),
+ TotalMemoryMb = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)),
+ TargetMemoryMb = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ PhysicalMemoryMb = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)),
+ MemoryRatio = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)),
+ MemoryUtilizationPct = reader.IsDBNull(8) ? 0 : Convert.ToInt32(reader.GetValue(8)),
+ WorkerThreadsCurrent = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)),
+ WorkerThreadsMax = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)),
+ WorkerThreadRatio = reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)),
+ CpuCount = reader.IsDBNull(12) ? 0 : Convert.ToInt32(reader.GetValue(12)),
+ ProvisioningStatus = reader.IsDBNull(13) ? "" : reader.GetString(13),
+ BufferPoolMb = reader.IsDBNull(14) ? 0 : Convert.ToInt32(reader.GetValue(14)),
+ TotalServerMemoryMb = reader.IsDBNull(15) ? 0 : Convert.ToInt32(reader.GetValue(15))
+ };
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Fetches per-application resource usage from report.finops_application_resource_usage.
+ ///
+ public async Task> GetFinOpsApplicationResourceUsageAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT
+ application_name,
+ avg_connections,
+ max_connections,
+ sample_count,
+ first_seen,
+ last_seen
+ FROM report.finops_application_resource_usage
+ ORDER BY
+ max_connections DESC
+ OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ApplicationResourceUsage", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsApplicationResourceUsage
+ {
+ ApplicationName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ AvgConnections = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)),
+ MaxConnections = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ SampleCount = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)),
+ FirstSeen = reader.IsDBNull(4) ? DateTime.MinValue : reader.GetDateTime(4),
+ LastSeen = reader.IsDBNull(5) ? DateTime.MinValue : reader.GetDateTime(5)
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Fetches latest database size stats from collect.database_size_stats.
+ ///
+ public async Task> GetFinOpsDatabaseSizeStatsAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT
+ collection_time,
+ database_name,
+ database_id,
+ file_id,
+ file_type_desc,
+ file_name,
+ physical_name,
+ total_size_mb,
+ used_size_mb,
+ free_space_mb,
+ used_pct,
+ auto_growth_mb,
+ max_size_mb,
+ recovery_model_desc,
+ compatibility_level,
+ state_desc,
+ volume_mount_point,
+ volume_total_mb,
+ volume_free_mb
+ FROM collect.database_size_stats
+ WHERE collection_time =
+ (
+ SELECT
+ MAX(collection_time)
+ FROM collect.database_size_stats
+ )
+ ORDER BY
+ database_name,
+ file_type_desc,
+ file_name
+ OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_DatabaseSizeStats", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsDatabaseSizeStats
+ {
+ CollectionTime = reader.IsDBNull(0) ? DateTime.MinValue : reader.GetDateTime(0),
+ DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ DatabaseId = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ FileId = reader.IsDBNull(3) ? 0 : Convert.ToInt32(reader.GetValue(3)),
+ FileTypeDesc = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ FileName = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ PhysicalName = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ TotalSizeMb = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)),
+ UsedSizeMb = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8)),
+ FreeSpaceMb = reader.IsDBNull(9) ? 0m : Convert.ToDecimal(reader.GetValue(9)),
+ UsedPct = reader.IsDBNull(10) ? 0m : Convert.ToDecimal(reader.GetValue(10)),
+ AutoGrowthMb = reader.IsDBNull(11) ? 0m : Convert.ToDecimal(reader.GetValue(11)),
+ MaxSizeMb = reader.IsDBNull(12) ? 0m : Convert.ToDecimal(reader.GetValue(12)),
+ RecoveryModelDesc = reader.IsDBNull(13) ? "" : reader.GetString(13),
+ CompatibilityLevel = reader.IsDBNull(14) ? 0 : Convert.ToInt32(reader.GetValue(14)),
+ StateDesc = reader.IsDBNull(15) ? "" : reader.GetString(15),
+ VolumeMountPoint = reader.IsDBNull(16) ? "" : reader.GetString(16),
+ VolumeTotalMb = reader.IsDBNull(17) ? 0m : Convert.ToDecimal(reader.GetValue(17)),
+ VolumeFreeMb = reader.IsDBNull(18) ? 0m : Convert.ToDecimal(reader.GetValue(18))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Fetches server inventory from config.server_info.
+ ///
+ ///
+ /// Queries a SQL Server directly for its properties via SERVERPROPERTY + sys.dm_os_sys_info.
+ /// Works from any database context — no PerformanceMonitor DB required.
+ ///
+ public static async Task GetServerPropertiesLiveAsync(string connectionString)
+ {
+ await using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+
+ const string query = @"
+SELECT
+ edition =
+ CONVERT(nvarchar(256), SERVERPROPERTY('Edition')),
+ product_version =
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')),
+ product_level =
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductLevel')),
+ product_update_level =
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductUpdateLevel')),
+ cpu_count =
+ si.cpu_count,
+ physical_memory_mb =
+ si.physical_memory_kb / 1024,
+ sqlserver_start_time =
+ si.sqlserver_start_time,
+ total_storage_gb =
+ (SELECT SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.master_files),
+ socket_count =
+ si.socket_count,
+ cores_per_socket =
+ si.cores_per_socket,
+ engine_edition =
+ CONVERT(int, SERVERPROPERTY('EngineEdition')),
+ is_hadr_enabled =
+ CONVERT(int, SERVERPROPERTY('IsHadrEnabled')),
+ is_clustered =
+ CONVERT(int, SERVERPROPERTY('IsClustered'))
+FROM sys.dm_os_sys_info AS si;";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 30;
+
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ var version = reader.IsDBNull(1) ? "" : reader.GetString(1);
+ var level = reader.IsDBNull(2) ? "" : reader.GetString(2);
+ var updateLevel = reader.IsDBNull(3) ? null : reader.GetString(3);
+ var versionDisplay = !string.IsNullOrEmpty(updateLevel)
+ ? $"{version} - {updateLevel}"
+ : $"{version} - {level}";
+
+ return new FinOpsServerInventory
+ {
+ Edition = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ SqlVersion = versionDisplay,
+ CpuCount = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)),
+ PhysicalMemoryMb = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)),
+ SqlServerStartTime = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
+ StorageTotalGb = reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)),
+ SocketCount = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
+ CoresPerSocket = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)),
+ EngineEdition = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10)),
+ IsHadrEnabled = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)) == 1,
+ IsClustered = reader.IsDBNull(12) ? null : Convert.ToInt32(reader.GetValue(12)) == 1,
+ LastUpdated = DateTime.Now
+ };
+ }
+
+ return new FinOpsServerInventory();
+ }
+
+ ///
+ /// Gets collected metrics (CPU, storage, idle DBs) from the PerformanceMonitor database.
+ /// Returns null values if no data is collected yet.
+ ///
+ public async Task<(decimal? AvgCpuPct, decimal? StorageTotalGb, int? IdleDbCount, string? ProvisioningStatus)> GetServerMetricsAsync()
+ {
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ cpu_24h AS
+ (
+ SELECT DISTINCT
+ avg_cpu_pct =
+ AVG(CONVERT(decimal(5,2), cu.sqlserver_cpu_utilization)) OVER (),
+ max_cpu_pct =
+ MAX(cu.sqlserver_cpu_utilization) OVER (),
+ p95_cpu_pct =
+ CONVERT
+ (
+ decimal(5,2),
+ PERCENTILE_CONT(0.95)
+ WITHIN GROUP (ORDER BY cu.sqlserver_cpu_utilization)
+ OVER ()
+ )
+ FROM collect.cpu_utilization_stats AS cu
+ WHERE cu.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ ),
+ mem_latest AS
+ (
+ SELECT TOP (1)
+ memory_ratio =
+ CONVERT(decimal(10,4), ms.total_memory_mb) /
+ NULLIF(ms.committed_target_memory_mb, 0)
+ FROM collect.memory_stats AS ms
+ ORDER BY
+ ms.collection_time DESC
+ ),
+ storage_total AS
+ (
+ SELECT
+ total_storage_gb =
+ SUM(ds.total_size_mb) / 1024.0
+ FROM collect.database_size_stats AS ds
+ WHERE ds.collection_time =
+ (
+ SELECT MAX(ds2.collection_time)
+ FROM collect.database_size_stats AS ds2
+ )
+ ),
+ idle_dbs AS
+ (
+ SELECT
+ idle_db_count = COUNT(DISTINCT d.database_name)
+ FROM
+ (
+ SELECT DISTINCT ds.database_name
+ FROM collect.database_size_stats AS ds
+ WHERE ds.collection_time =
+ (
+ SELECT MAX(ds2.collection_time)
+ FROM collect.database_size_stats AS ds2
+ )
+ AND ds.database_name NOT IN (N'master', N'model', N'msdb', N'tempdb')
+ EXCEPT
+ SELECT DISTINCT qs.database_name
+ FROM collect.query_stats AS qs
+ WHERE qs.collection_time >= DATEADD(DAY, -7, SYSDATETIME())
+ AND qs.execution_count_delta > 0
+ ) AS d
+ )
+SELECT
+ c.avg_cpu_pct,
+ st.total_storage_gb,
+ id.idle_db_count,
+ provisioning_status =
+ CASE
+ WHEN c.avg_cpu_pct < 15
+ AND c.max_cpu_pct < 40
+ AND ISNULL(m.memory_ratio, 0) < 0.5
+ THEN N'OVER_PROVISIONED'
+ WHEN c.p95_cpu_pct > 85
+ OR ISNULL(m.memory_ratio, 0) > 0.95
+ THEN N'UNDER_PROVISIONED'
+ ELSE N'RIGHT_SIZED'
+ END
+FROM (SELECT 1 AS x) AS anchor
+LEFT JOIN cpu_24h AS c
+ ON 1 = 1
+LEFT JOIN mem_latest AS m
+ ON 1 = 1
+LEFT JOIN storage_total AS st
+ ON 1 = 1
+LEFT JOIN idle_dbs AS id
+ ON 1 = 1
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ServerMetrics", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ return (
+ reader.IsDBNull(0) ? null : Convert.ToDecimal(reader.GetValue(0)),
+ reader.IsDBNull(1) ? null : Convert.ToDecimal(reader.GetValue(1)),
+ reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2)),
+ reader.IsDBNull(3) ? null : reader.GetString(3)
+ );
+ }
+ }
+
+ return (null, null, null, null);
+ }
+
+ ///
+ /// Gets top N databases by total CPU for the utilization summary.
+ ///
+ public async Task> GetFinOpsTopResourceConsumersByTotalAsync(int hoursBack = 24, int topN = 5)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ workload AS
+ (
+ SELECT
+ database_name,
+ cpu_time_ms =
+ SUM(qs.total_worker_time_delta) / 1000,
+ execution_count =
+ SUM(qs.execution_count_delta)
+ FROM collect.query_stats AS qs
+ WHERE qs.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND qs.total_worker_time_delta IS NOT NULL
+ GROUP BY
+ qs.database_name
+ ),
+ io AS
+ (
+ SELECT
+ database_name,
+ io_total_bytes =
+ SUM(fio.num_of_bytes_read_delta + fio.num_of_bytes_written_delta)
+ FROM collect.file_io_stats AS fio
+ WHERE fio.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND fio.num_of_bytes_read_delta IS NOT NULL
+ GROUP BY
+ fio.database_name
+ ),
+ combined AS
+ (
+ SELECT
+ database_name =
+ COALESCE(w.database_name, i.database_name),
+ cpu_time_ms =
+ ISNULL(w.cpu_time_ms, 0),
+ execution_count =
+ ISNULL(w.execution_count, 0),
+ io_total_mb =
+ CONVERT(decimal(19,2), ISNULL(i.io_total_bytes, 0) / 1048576.0)
+ FROM workload AS w
+ FULL JOIN io AS i
+ ON i.database_name = w.database_name
+ ),
+ totals AS
+ (
+ SELECT
+ total_cpu =
+ NULLIF(SUM(cpu_time_ms), 0),
+ total_io =
+ NULLIF(SUM(io_total_mb), 0)
+ FROM combined
+ )
+SELECT TOP(@topN)
+ c.database_name,
+ c.cpu_time_ms,
+ c.execution_count,
+ c.io_total_mb,
+ pct_cpu =
+ CONVERT(decimal(5,2), c.cpu_time_ms * 100.0 / t.total_cpu),
+ pct_io =
+ CONVERT(decimal(5,2), c.io_total_mb * 100.0 / t.total_io)
+FROM combined AS c
+CROSS JOIN totals AS t
+ORDER BY
+ c.cpu_time_ms DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.Parameters.AddWithValue("@topN", topN);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_TopResourceByTotal", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsTopResourceConsumer
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ ExecutionCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)),
+ IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ PctCpu = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ PctIo = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets top N databases by average CPU per execution for the utilization summary.
+ ///
+ public async Task> GetFinOpsTopResourceConsumersByAvgAsync(int hoursBack = 24, int topN = 5)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ workload AS
+ (
+ SELECT
+ database_name,
+ cpu_time_ms =
+ SUM(qs.total_worker_time_delta) / 1000,
+ execution_count =
+ SUM(qs.execution_count_delta)
+ FROM collect.query_stats AS qs
+ WHERE qs.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND qs.total_worker_time_delta IS NOT NULL
+ GROUP BY
+ qs.database_name
+ HAVING
+ SUM(qs.execution_count_delta) > 0
+ ),
+ io AS
+ (
+ SELECT
+ database_name,
+ io_total_mb =
+ SUM(fio.num_of_bytes_read_delta + fio.num_of_bytes_written_delta) / 1048576.0
+ FROM collect.file_io_stats AS fio
+ WHERE fio.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND fio.num_of_bytes_read_delta IS NOT NULL
+ GROUP BY
+ fio.database_name
+ )
+SELECT TOP(@topN)
+ w.database_name,
+ avg_cpu_ms =
+ CONVERT(decimal(19,2), w.cpu_time_ms * 1.0 / w.execution_count),
+ w.execution_count,
+ io_total_mb =
+ CONVERT(decimal(19,2), ISNULL(i.io_total_mb, 0)),
+ w.cpu_time_ms,
+ avg_io_mb =
+ CONVERT(decimal(19,4), ISNULL(i.io_total_mb, 0) * 1.0 / w.execution_count)
+FROM workload AS w
+LEFT JOIN io AS i
+ ON i.database_name = w.database_name
+ORDER BY
+ avg_cpu_ms DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.Parameters.AddWithValue("@topN", topN);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_TopResourceByAvg", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsTopResourceConsumer
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ ExecutionCount = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)),
+ IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TotalCpuTimeMs = reader.IsDBNull(4) ? 0 : Convert.ToInt64(reader.GetValue(4)),
+ AvgIoMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets per-database total allocated and used space for the utilization size chart.
+ ///
+ public async Task> GetFinOpsDatabaseSizeSummaryAsync(int topN = 10)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT TOP(@topN)
+ database_name,
+ total_mb =
+ SUM(total_size_mb),
+ used_mb =
+ SUM(used_size_mb)
+FROM collect.database_size_stats
+WHERE collection_time =
+(
+ SELECT MAX(collection_time)
+ FROM collect.database_size_stats
+)
+GROUP BY
+ database_name
+ORDER BY
+ SUM(total_size_mb) DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@topN", topN);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_DatabaseSizeSummary", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsDatabaseSizeSummary
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ UsedMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets per-database storage growth trends comparing current size to 7d and 30d ago.
+ ///
+ public async Task> GetFinOpsStorageGrowthAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ latest AS
+ (
+ SELECT
+ database_name,
+ current_size_mb =
+ SUM(total_size_mb)
+ FROM collect.database_size_stats
+ WHERE collection_time =
+ (
+ SELECT MAX(collection_time)
+ FROM collect.database_size_stats
+ )
+ GROUP BY
+ database_name
+ ),
+ past_7d AS
+ (
+ SELECT
+ database_name,
+ size_mb =
+ SUM(total_size_mb)
+ FROM collect.database_size_stats
+ WHERE collection_time =
+ (
+ SELECT MAX(collection_time)
+ FROM collect.database_size_stats
+ WHERE collection_time <= DATEADD(DAY, -7, SYSDATETIME())
+ )
+ GROUP BY
+ database_name
+ ),
+ past_30d AS
+ (
+ SELECT
+ database_name,
+ size_mb =
+ SUM(total_size_mb)
+ FROM collect.database_size_stats
+ WHERE collection_time =
+ (
+ SELECT MAX(collection_time)
+ FROM collect.database_size_stats
+ WHERE collection_time <= DATEADD(DAY, -30, SYSDATETIME())
+ )
+ GROUP BY
+ database_name
+ )
+SELECT
+ l.database_name,
+ l.current_size_mb,
+ p7.size_mb,
+ p30.size_mb,
+ growth_7d_mb =
+ l.current_size_mb - ISNULL(p7.size_mb, l.current_size_mb),
+ growth_30d_mb =
+ l.current_size_mb - ISNULL(p30.size_mb, l.current_size_mb),
+ daily_growth_rate_mb =
+ CASE
+ WHEN p30.size_mb IS NOT NULL
+ THEN (l.current_size_mb - p30.size_mb) / 30.0
+ WHEN p7.size_mb IS NOT NULL
+ THEN (l.current_size_mb - p7.size_mb) / 7.0
+ ELSE 0
+ END,
+ growth_pct_30d =
+ CASE
+ WHEN p30.size_mb IS NOT NULL
+ AND p30.size_mb > 0
+ THEN (l.current_size_mb - p30.size_mb) * 100.0 / p30.size_mb
+ ELSE 0
+ END
+FROM latest AS l
+LEFT JOIN past_7d AS p7
+ ON p7.database_name = l.database_name
+LEFT JOIN past_30d AS p30
+ ON p30.database_name = l.database_name
+ORDER BY
+ l.current_size_mb - ISNULL(p30.size_mb, l.current_size_mb) DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_StorageGrowth", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsStorageGrowthRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CurrentSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ Size7dAgoMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)),
+ Size30dAgoMb = reader.IsDBNull(3) ? null : Convert.ToDecimal(reader.GetValue(3)),
+ Growth7dMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ Growth30dMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)),
+ DailyGrowthRateMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ GrowthPct30d = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Detects databases with zero query executions over the last N days.
+ ///
+ public async Task> GetFinOpsIdleDatabasesAsync(int daysBack = 7)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ db_sizes AS
+ (
+ SELECT
+ database_name,
+ total_size_mb =
+ SUM(total_size_mb),
+ file_count =
+ COUNT(*)
+ FROM collect.database_size_stats
+ WHERE collection_time =
+ (
+ SELECT MAX(collection_time)
+ FROM collect.database_size_stats
+ )
+ GROUP BY
+ database_name
+ ),
+ db_activity AS
+ (
+ SELECT
+ database_name,
+ total_executions =
+ SUM(execution_count_delta),
+ last_execution =
+ MAX(last_execution_time)
+ FROM collect.query_stats
+ WHERE collection_time >= DATEADD(DAY, -@daysBack, SYSDATETIME())
+ AND execution_count_delta IS NOT NULL
+ GROUP BY
+ database_name
+ )
+SELECT
+ ds.database_name,
+ ds.total_size_mb,
+ ds.file_count,
+ a.last_execution
+FROM db_sizes AS ds
+LEFT JOIN db_activity AS a
+ ON a.database_name = ds.database_name
+WHERE ISNULL(a.total_executions, 0) = 0
+AND ds.database_name NOT IN (N'master', N'model', N'msdb', N'tempdb')
+ORDER BY
+ ds.total_size_mb DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@daysBack", daysBack);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_IdleDatabases", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsIdleDatabase
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ FileCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ LastExecutionTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3)
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets tempdb pressure summary: latest and 24h peak values.
+ ///
+ public async Task> GetFinOpsTempdbSummaryAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ latest AS
+ (
+ SELECT TOP (1)
+ user_object_reserved_mb,
+ internal_object_reserved_mb,
+ version_store_reserved_mb,
+ total_reserved_mb
+ FROM collect.tempdb_stats
+ ORDER BY
+ collection_time DESC
+ ),
+ peak AS
+ (
+ SELECT
+ max_user_mb =
+ MAX(user_object_reserved_mb),
+ max_internal_mb =
+ MAX(internal_object_reserved_mb),
+ max_version_store_mb =
+ MAX(version_store_reserved_mb),
+ max_total_mb =
+ MAX(total_reserved_mb)
+ FROM collect.tempdb_stats
+ WHERE collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ )
+SELECT
+ metric = N'User Objects',
+ current_mb = l.user_object_reserved_mb,
+ peak_24h_mb = p.max_user_mb,
+ warning =
+ CASE
+ WHEN p.max_user_mb > 1024
+ THEN N'High user object usage'
+ ELSE N''
+ END
+FROM latest AS l
+CROSS JOIN peak AS p
+UNION ALL
+SELECT
+ N'Internal Objects',
+ l.internal_object_reserved_mb,
+ p.max_internal_mb,
+ CASE
+ WHEN p.max_internal_mb > 1024
+ THEN N'High internal object usage (sorts/hashes)'
+ ELSE N''
+ END
+FROM latest AS l
+CROSS JOIN peak AS p
+UNION ALL
+SELECT
+ N'Version Store',
+ l.version_store_reserved_mb,
+ p.max_version_store_mb,
+ CASE
+ WHEN p.max_version_store_mb > 2048
+ THEN N'Version store pressure — check long-running transactions'
+ ELSE N''
+ END
+FROM latest AS l
+CROSS JOIN peak AS p
+UNION ALL
+SELECT
+ N'Total Reserved',
+ l.total_reserved_mb,
+ p.max_total_mb,
+ N''
+FROM latest AS l
+CROSS JOIN peak AS p
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_TempdbSummary", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsTempdbSummary
+ {
+ Metric = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CurrentMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ Peak24hMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ Warning = reader.IsDBNull(3) ? "" : reader.GetString(3)
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets wait stats grouped by cost category over the last 24 hours.
+ ///
+ public async Task> GetFinOpsWaitCategorySummaryAsync(int hoursBack = 24)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ categorized AS
+ (
+ SELECT
+ category =
+ CASE
+ WHEN wait_type IN (N'SOS_SCHEDULER_YIELD', N'CXPACKET', N'CXCONSUMER', N'CXSYNC_PORT', N'CXSYNC_CONSUMER')
+ THEN N'CPU'
+ WHEN wait_type LIKE N'PAGEIOLATCH%'
+ OR wait_type IN (N'WRITELOG', N'IO_COMPLETION', N'ASYNC_IO_COMPLETION')
+ THEN N'Storage'
+ WHEN wait_type IN (N'RESOURCE_SEMAPHORE', N'RESOURCE_SEMAPHORE_QUERY_COMPILE', N'CMEMTHREAD')
+ THEN N'Memory'
+ WHEN wait_type = N'ASYNC_NETWORK_IO'
+ THEN N'Network'
+ WHEN wait_type LIKE N'LCK_M_%'
+ THEN N'Locks'
+ ELSE N'Other'
+ END,
+ wait_type,
+ wait_time_ms =
+ SUM(wait_time_ms_delta),
+ waiting_tasks =
+ SUM(waiting_tasks_count_delta)
+ FROM collect.wait_stats
+ WHERE collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+ AND wait_time_ms_delta IS NOT NULL
+ AND wait_time_ms_delta > 0
+ GROUP BY
+ CASE
+ WHEN wait_type IN (N'SOS_SCHEDULER_YIELD', N'CXPACKET', N'CXCONSUMER', N'CXSYNC_PORT', N'CXSYNC_CONSUMER')
+ THEN N'CPU'
+ WHEN wait_type LIKE N'PAGEIOLATCH%'
+ OR wait_type IN (N'WRITELOG', N'IO_COMPLETION', N'ASYNC_IO_COMPLETION')
+ THEN N'Storage'
+ WHEN wait_type IN (N'RESOURCE_SEMAPHORE', N'RESOURCE_SEMAPHORE_QUERY_COMPILE', N'CMEMTHREAD')
+ THEN N'Memory'
+ WHEN wait_type = N'ASYNC_NETWORK_IO'
+ THEN N'Network'
+ WHEN wait_type LIKE N'LCK_M_%'
+ THEN N'Locks'
+ ELSE N'Other'
+ END,
+ wait_type
+ ),
+ by_category AS
+ (
+ SELECT
+ category,
+ total_wait_time_ms =
+ SUM(wait_time_ms),
+ total_waiting_tasks =
+ SUM(waiting_tasks),
+ top_wait_type =
+ MAX(CASE WHEN rn = 1 THEN wait_type END),
+ top_wait_time_ms =
+ MAX(CASE WHEN rn = 1 THEN wait_time_ms END)
+ FROM
+ (
+ SELECT
+ *,
+ rn = ROW_NUMBER() OVER
+ (
+ PARTITION BY category
+ ORDER BY wait_time_ms DESC
+ )
+ FROM categorized
+ ) AS ranked
+ GROUP BY
+ category
+ ),
+ grand_total AS
+ (
+ SELECT
+ total = NULLIF(SUM(total_wait_time_ms), 0)
+ FROM by_category
+ )
+SELECT
+ bc.category,
+ bc.total_wait_time_ms,
+ bc.total_waiting_tasks,
+ pct_of_total =
+ CONVERT
+ (
+ decimal(5,1),
+ bc.total_wait_time_ms * 100.0 / gt.total
+ ),
+ bc.top_wait_type,
+ bc.top_wait_time_ms
+FROM by_category AS bc
+CROSS JOIN grand_total AS gt
+ORDER BY
+ bc.total_wait_time_ms DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_WaitCategorySummary", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsWaitCategorySummary
+ {
+ Category = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalWaitTimeMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ WaitingTasks = reader.IsDBNull(2) ? 0 : Convert.ToInt64(reader.GetValue(2)),
+ PctOfTotal = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TopWaitType = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ TopWaitTimeMs = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5))
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets top 20 most expensive queries by total CPU over the last 24 hours.
+ ///
+ public async Task> GetFinOpsExpensiveQueriesAsync(int hoursBack = 24, int topN = 20)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT TOP(@topN)
+ qs.database_name,
+ total_cpu_ms =
+ SUM(qs.total_worker_time_delta) / 1000,
+ avg_cpu_ms_per_exec =
+ CONVERT
+ (
+ decimal(19,2),
+ SUM(qs.total_worker_time_delta) / 1000.0 /
+ NULLIF(SUM(qs.execution_count_delta), 0)
+ ),
+ total_reads =
+ SUM(qs.total_logical_reads_delta),
+ avg_reads_per_exec =
+ CONVERT
+ (
+ decimal(19,0),
+ SUM(qs.total_logical_reads_delta) * 1.0 /
+ NULLIF(SUM(qs.execution_count_delta), 0)
+ ),
+ executions =
+ SUM(qs.execution_count_delta),
+ query_preview =
+ LEFT
+ (
+ CONVERT
+ (
+ nvarchar(max),
+ DECOMPRESS(qs.query_text)
+ ),
+ 200
+ )
+FROM collect.query_stats AS qs
+WHERE qs.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+AND qs.total_worker_time_delta IS NOT NULL
+AND qs.total_worker_time_delta > 0
+GROUP BY
+ qs.database_name,
+ qs.sql_handle,
+ qs.statement_start_offset,
+ qs.statement_end_offset,
+ qs.query_text
+ORDER BY
+ SUM(qs.total_worker_time_delta) DESC
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.Parameters.AddWithValue("@topN", topN);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ExpensiveQueries", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsExpensiveQuery
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalCpuMs = reader.IsDBNull(1) ? 0 : Convert.ToInt64(reader.GetValue(1)),
+ AvgCpuMsPerExec = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ TotalReads = reader.IsDBNull(3) ? 0 : Convert.ToInt64(reader.GetValue(3)),
+ AvgReadsPerExec = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ Executions = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
+ QueryPreview = reader.IsDBNull(6) ? "" : reader.GetString(6)
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Checks if sp_IndexCleanup is installed on the target server.
+ ///
+ public async Task CheckSpIndexCleanupExistsAsync()
+ {
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ using var command = new SqlCommand("SELECT OBJECT_ID('dbo.sp_IndexCleanup', 'P')", connection);
+ command.CommandTimeout = 30;
+ var result = await command.ExecuteScalarAsync();
+ return result != null && result != DBNull.Value;
+ }
+
+ ///
+ /// Runs sp_IndexCleanup and returns both detail and summary result sets.
+ ///
+ public async Task<(List Details, List Summaries)> RunIndexAnalysisAsync(string? databaseName, bool getAllDatabases)
+ {
+ var details = new List();
+ var summaries = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ using var command = new SqlCommand("dbo.sp_IndexCleanup", connection);
+ command.CommandType = System.Data.CommandType.StoredProcedure;
+ command.CommandTimeout = 300;
+
+ if (getAllDatabases)
+ {
+ command.Parameters.AddWithValue("@get_all_databases", 1);
+ }
+ else if (!string.IsNullOrWhiteSpace(databaseName))
+ {
+ command.Parameters.AddWithValue("@database_name", databaseName);
+ }
+
+ using var reader = await command.ExecuteReaderAsync();
+
+ // Result set 1: Detail rows
+ while (await reader.ReadAsync())
+ {
+ details.Add(new IndexCleanupResult
+ {
+ ScriptType = reader.IsDBNull(0) ? "" : reader.GetValue(0).ToString() ?? "",
+ AdditionalInfo = reader.IsDBNull(1) ? "" : reader.GetValue(1).ToString() ?? "",
+ DatabaseName = reader.IsDBNull(2) ? "" : reader.GetValue(2).ToString() ?? "",
+ SchemaName = reader.IsDBNull(3) ? "" : reader.GetValue(3).ToString() ?? "",
+ TableName = reader.IsDBNull(4) ? "" : reader.GetValue(4).ToString() ?? "",
+ IndexName = reader.IsDBNull(5) ? "" : reader.GetValue(5).ToString() ?? "",
+ ConsolidationRule = reader.IsDBNull(6) ? "" : reader.GetValue(6).ToString() ?? "",
+ TargetIndexName = reader.IsDBNull(7) ? "" : reader.GetValue(7).ToString() ?? "",
+ SupersededInfo = reader.IsDBNull(8) ? "" : reader.GetValue(8).ToString() ?? "",
+ IndexSizeGb = reader.IsDBNull(9) ? "" : reader.GetValue(9).ToString() ?? "",
+ IndexRows = reader.IsDBNull(10) ? "" : reader.GetValue(10).ToString() ?? "",
+ IndexReads = reader.IsDBNull(11) ? "" : reader.GetValue(11).ToString() ?? "",
+ IndexWrites = reader.IsDBNull(12) ? "" : reader.GetValue(12).ToString() ?? "",
+ OriginalIndexDefinition = reader.IsDBNull(13) ? "" : reader.GetValue(13).ToString() ?? "",
+ Script = reader.IsDBNull(14) ? "" : reader.GetValue(14).ToString() ?? ""
+ });
+ }
+
+ // Result set 2: Summary rows (if present)
+ if (await reader.NextResultAsync())
+ {
+ while (await reader.ReadAsync())
+ {
+ var fc = reader.FieldCount;
+ string Col(int i) => fc > i && !reader.IsDBNull(i) ? reader.GetValue(i).ToString() ?? "" : "";
+ summaries.Add(new IndexCleanupSummary
+ {
+ Level = Col(0),
+ DatabaseInfo = Col(1),
+ SchemaName = Col(2),
+ TableName = Col(3),
+ TablesAnalyzed = Col(4),
+ TotalIndexes = Col(5),
+ RemovableIndexes = Col(6),
+ MergeableIndexes = Col(7),
+ CompressableIndexes = Col(8),
+ PercentRemovable = Col(9),
+ CurrentSizeGb = Col(10),
+ SizeAfterCleanupGb = Col(11),
+ SpaceSavedGb = Col(12),
+ SpaceReductionPercent = Col(13),
+ CompressionSavingsPotential = Col(14),
+ CompressionSavingsPotentialTotal = Col(15),
+ ComputedColumnsWithUdfs = Col(16),
+ CheckConstraintsWithUdfs = Col(17),
+ FilteredIndexesNeedingIncludes = Col(18),
+ TotalRows = Col(19),
+ ReadsBreakdown = Col(20),
+ Writes = Col(21),
+ DailyWriteOpsSaved = Col(22),
+ LockWaitCount = Col(23),
+ DailyLockWaitsSaved = Col(24),
+ AvgLockWaitMs = Col(25),
+ LatchWaitCount = Col(26),
+ DailyLatchWaitsSaved = Col(27),
+ AvgLatchWaitMs = Col(28)
+ });
+ }
+ }
+
+ return (details, summaries);
+ }
+
+ ///
+ /// Gets 7-day daily provisioning classification trend.
+ ///
+ public async Task> GetFinOpsProvisioningTrendAsync()
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+WITH
+ daily_cpu AS
+ (
+ SELECT DISTINCT
+ day = CONVERT(date, cu.collection_time),
+ avg_cpu_pct =
+ AVG(CONVERT(decimal(5,2), cu.sqlserver_cpu_utilization))
+ OVER (PARTITION BY CONVERT(date, cu.collection_time)),
+ max_cpu_pct =
+ MAX(cu.sqlserver_cpu_utilization)
+ OVER (PARTITION BY CONVERT(date, cu.collection_time)),
+ p95_cpu_pct =
+ CONVERT
+ (
+ decimal(5,2),
+ PERCENTILE_CONT(0.95)
+ WITHIN GROUP (ORDER BY cu.sqlserver_cpu_utilization)
+ OVER (PARTITION BY CONVERT(date, cu.collection_time))
+ )
+ FROM collect.cpu_utilization_stats AS cu
+ WHERE cu.collection_time >= DATEADD(DAY, -7, SYSDATETIME())
+ ),
+ daily_mem AS
+ (
+ SELECT
+ day = CONVERT(date, ms.collection_time),
+ avg_memory_ratio =
+ AVG
+ (
+ CONVERT(decimal(10,4), ms.total_memory_mb) /
+ NULLIF(ms.committed_target_memory_mb, 0)
+ )
+ FROM collect.memory_stats AS ms
+ WHERE ms.collection_time >= DATEADD(DAY, -7, SYSDATETIME())
+ GROUP BY
+ CONVERT(date, ms.collection_time)
+ )
+SELECT
+ c.day,
+ c.avg_cpu_pct,
+ c.max_cpu_pct,
+ c.p95_cpu_pct,
+ ISNULL(m.avg_memory_ratio, 0),
+ provisioning_status =
+ CASE
+ WHEN c.avg_cpu_pct < 15
+ AND c.max_cpu_pct < 40
+ AND ISNULL(m.avg_memory_ratio, 0) < 0.5
+ THEN N'OVER_PROVISIONED'
+ WHEN c.p95_cpu_pct > 85
+ OR ISNULL(m.avg_memory_ratio, 0) > 0.95
+ THEN N'UNDER_PROVISIONED'
+ ELSE N'RIGHT_SIZED'
+ END
+FROM daily_cpu AS c
+LEFT JOIN daily_mem AS m
+ ON m.day = c.day
+ORDER BY
+ c.day
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_ProvisioningTrend", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsProvisioningTrend
+ {
+ Day = reader.GetDateTime(0),
+ AvgCpuPct = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ MaxCpuPct = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ P95CpuPct = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ MemoryRatio = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ Status = reader.IsDBNull(5) ? "" : reader.GetString(5)
+ });
+ }
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets memory grant efficiency from resource semaphore data.
+ ///
+ public async Task> GetFinOpsMemoryGrantEfficiencyAsync(int hoursBack = 24)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ day = CONVERT(date, mg.collection_time),
+ avg_granted_mb =
+ AVG(mg.granted_memory_mb),
+ avg_used_mb =
+ AVG(mg.used_memory_mb),
+ efficiency_pct =
+ CONVERT
+ (
+ decimal(5,1),
+ AVG(mg.used_memory_mb) * 100.0 /
+ NULLIF(AVG(mg.granted_memory_mb), 0)
+ ),
+ peak_granted_mb =
+ MAX(mg.granted_memory_mb),
+ total_grantees =
+ SUM(mg.grantee_count),
+ total_waiters =
+ SUM(mg.waiter_count),
+ timeout_errors =
+ SUM(mg.timeout_error_count_delta),
+ forced_grants =
+ SUM(mg.forced_grant_count_delta)
+FROM collect.memory_grant_stats AS mg
+WHERE mg.collection_time >= DATEADD(HOUR, -@hoursBack, SYSDATETIME())
+GROUP BY
+ CONVERT(date, mg.collection_time)
+ORDER BY
+ CONVERT(date, mg.collection_time)
+OPTION(MAXDOP 1, RECOMPILE);";
+
+ using var command = new SqlCommand(query, connection);
+ command.Parameters.AddWithValue("@hoursBack", hoursBack);
+ command.CommandTimeout = 120;
+
+ using (StartQueryTiming("FinOps_MemoryGrantEfficiency", query, connection))
+ {
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new FinOpsMemoryGrantEfficiency
+ {
+ Day = reader.GetDateTime(0),
+ AvgGrantedMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ AvgUsedMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ EfficiencyPct = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ PeakGrantedMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ TotalGrantees = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
+ TotalWaiters = reader.IsDBNull(6) ? 0 : Convert.ToInt64(reader.GetValue(6)),
+ TimeoutErrors = reader.IsDBNull(7) ? 0 : Convert.ToInt64(reader.GetValue(7)),
+ ForcedGrants = reader.IsDBNull(8) ? 0 : Convert.ToInt64(reader.GetValue(8))
+ });
+ }
+ }
+
+ return items;
+ }
+ }
+
+ // ============================================
+ // FinOps Model Classes
+ // ============================================
+
+ public class FinOpsDatabaseResourceUsage
+ {
+ public string DatabaseName { get; set; } = "";
+ public long CpuTimeMs { get; set; }
+ public long LogicalReads { get; set; }
+ public long PhysicalReads { get; set; }
+ public long LogicalWrites { get; set; }
+ public long ExecutionCount { get; set; }
+ public decimal IoReadMb { get; set; }
+ public decimal IoWriteMb { get; set; }
+ public long IoStallMs { get; set; }
+ public decimal PctCpuShare { get; set; }
+ public decimal PctIoShare { get; set; }
+ }
+
+ public class FinOpsUtilizationEfficiency
+ {
+ public decimal AvgCpuPct { get; set; }
+ public int MaxCpuPct { get; set; }
+ public decimal P95CpuPct { get; set; }
+ public long CpuSamples { get; set; }
+ public int TotalMemoryMb { get; set; }
+ public int TargetMemoryMb { get; set; }
+ public int PhysicalMemoryMb { get; set; }
+ public decimal MemoryRatio { get; set; }
+ public int MemoryUtilizationPct { get; set; }
+ public int WorkerThreadsCurrent { get; set; }
+ public int WorkerThreadsMax { get; set; }
+ public decimal WorkerThreadRatio { get; set; }
+ public int CpuCount { get; set; }
+ public int BufferPoolMb { get; set; }
+ public int TotalServerMemoryMb { get; set; }
+ public string ProvisioningStatus { get; set; } = "";
+ }
+
+ public class FinOpsApplicationResourceUsage
+ {
+ public string ApplicationName { get; set; } = "";
+ public int AvgConnections { get; set; }
+ public int MaxConnections { get; set; }
+ public long SampleCount { get; set; }
+ public DateTime FirstSeen { get; set; }
+ public DateTime LastSeen { get; set; }
+ }
+
+ public class FinOpsServerInventory
+ {
+ public string ServerName { get; set; } = "";
+ public string Edition { get; set; } = "";
+ public string SqlVersion { get; set; } = "";
+ public int CpuCount { get; set; }
+ public long PhysicalMemoryMb { get; set; }
+ public int? SocketCount { get; set; }
+ public int? CoresPerSocket { get; set; }
+ public int? EngineEdition { get; set; }
+ public DateTime? SqlServerStartTime { get; set; }
+ public DateTime? LastUpdated { get; set; }
+ public bool? IsHadrEnabled { get; set; }
+ public bool? IsClustered { get; set; }
+ public decimal? AvgCpuPct { get; set; }
+ public decimal? StorageTotalGb { get; set; }
+ public int? IdleDbCount { get; set; }
+ public string? ProvisioningStatus { get; set; }
+ public string UptimeDisplay
+ {
+ get
+ {
+ if (SqlServerStartTime == null) return "";
+ var uptime = DateTime.Now - SqlServerStartTime.Value;
+ return $"{(int)uptime.TotalDays}d {uptime.Hours}h";
+ }
+ }
+ public string ProvisioningDisplay => ProvisioningStatus?.Replace("_", " ") ?? "";
+ public string HadrDisplay => IsHadrEnabled.HasValue ? (IsHadrEnabled.Value ? "Yes" : "No") : "";
+ public string ClusteredDisplay => IsClustered.HasValue ? (IsClustered.Value ? "Yes" : "No") : "";
+ }
+
+ public class FinOpsDatabaseSizeStats
+ {
+ public DateTime CollectionTime { get; set; }
+ public string DatabaseName { get; set; } = "";
+ public int DatabaseId { get; set; }
+ public int FileId { get; set; }
+ public string FileTypeDesc { get; set; } = "";
+ public string FileName { get; set; } = "";
+ public string PhysicalName { get; set; } = "";
+ public decimal TotalSizeMb { get; set; }
+ public decimal UsedSizeMb { get; set; }
+ public decimal FreeSpaceMb { get; set; }
+ public decimal UsedPct { get; set; }
+ public decimal AutoGrowthMb { get; set; }
+ public decimal MaxSizeMb { get; set; }
+ public string RecoveryModelDesc { get; set; } = "";
+ public int CompatibilityLevel { get; set; }
+ public string StateDesc { get; set; } = "";
+ public string VolumeMountPoint { get; set; } = "";
+ public decimal VolumeTotalMb { get; set; }
+ public decimal VolumeFreeMb { get; set; }
+ }
+
+ public class FinOpsTopResourceConsumer
+ {
+ public string DatabaseName { get; set; } = "";
+ public long CpuTimeMs { get; set; }
+ public long ExecutionCount { get; set; }
+ public decimal IoTotalMb { get; set; }
+ public decimal PctCpu { get; set; }
+ public decimal PctIo { get; set; }
+ public long TotalCpuTimeMs { get; set; }
+ public decimal AvgIoMb { get; set; }
+ }
+
+ public class FinOpsDatabaseSizeSummary
+ {
+ public string DatabaseName { get; set; } = "";
+ public decimal TotalMb { get; set; }
+ public decimal? UsedMb { get; set; }
+ public decimal FreeMb => UsedMb.HasValue ? TotalMb - UsedMb.Value : TotalMb;
+ public decimal UsedPct => TotalMb > 0 && UsedMb.HasValue ? Math.Round(UsedMb.Value * 100m / TotalMb, 1) : 0;
+
+ /* Star-width GridLength for XAML binding — drives the stacked bar proportions */
+ public System.Windows.GridLength UsedStarWidth =>
+ new(Math.Max((double)(UsedMb ?? 0m), 0.1), System.Windows.GridUnitType.Star);
+ public System.Windows.GridLength FreeStarWidth =>
+ new(Math.Max((double)FreeMb, 0.1), System.Windows.GridUnitType.Star);
+ }
+
+ public class FinOpsStorageGrowthRow
+ {
+ public string DatabaseName { get; set; } = "";
+ public decimal CurrentSizeMb { get; set; }
+ public decimal? Size7dAgoMb { get; set; }
+ public decimal? Size30dAgoMb { get; set; }
+ public decimal Growth7dMb { get; set; }
+ public decimal Growth30dMb { get; set; }
+ public decimal DailyGrowthRateMb { get; set; }
+ public decimal GrowthPct30d { get; set; }
+ }
+
+ public class FinOpsIdleDatabase
+ {
+ public string DatabaseName { get; set; } = "";
+ public decimal TotalSizeMb { get; set; }
+ public int FileCount { get; set; }
+ public DateTime? LastExecutionTime { get; set; }
+ }
+
+ public class FinOpsTempdbSummary
+ {
+ public string Metric { get; set; } = "";
+ public decimal CurrentMb { get; set; }
+ public decimal Peak24hMb { get; set; }
+ public string Warning { get; set; } = "";
+ }
+
+ public class FinOpsWaitCategorySummary
+ {
+ public string Category { get; set; } = "";
+ public long TotalWaitTimeMs { get; set; }
+ public long WaitingTasks { get; set; }
+ public decimal PctOfTotal { get; set; }
+ public string TopWaitType { get; set; } = "";
+ public long TopWaitTimeMs { get; set; }
+ }
+
+ public class FinOpsExpensiveQuery
+ {
+ public string DatabaseName { get; set; } = "";
+ public long TotalCpuMs { get; set; }
+ public decimal AvgCpuMsPerExec { get; set; }
+ public long TotalReads { get; set; }
+ public decimal AvgReadsPerExec { get; set; }
+ public long Executions { get; set; }
+ public string QueryPreview { get; set; } = "";
+ }
+
+ public class IndexCleanupResult
+ {
+ public string ScriptType { get; set; } = "";
+ public string AdditionalInfo { get; set; } = "";
+ public string DatabaseName { get; set; } = "";
+ public string SchemaName { get; set; } = "";
+ public string TableName { get; set; } = "";
+ public string IndexName { get; set; } = "";
+ public string ConsolidationRule { get; set; } = "";
+ public string TargetIndexName { get; set; } = "";
+ public string SupersededInfo { get; set; } = "";
+ public string IndexSizeGb { get; set; } = "";
+ public string IndexRows { get; set; } = "";
+ public string IndexReads { get; set; } = "";
+ public string IndexWrites { get; set; } = "";
+ public string OriginalIndexDefinition { get; set; } = "";
+ public string Script { get; set; } = "";
+ }
+
+ public class FinOpsProvisioningTrend
+ {
+ public DateTime Day { get; set; }
+ public decimal AvgCpuPct { get; set; }
+ public int MaxCpuPct { get; set; }
+ public decimal P95CpuPct { get; set; }
+ public decimal MemoryRatio { get; set; }
+ public string Status { get; set; } = "";
+ public string DayDisplay => Day.ToString("ddd MM/dd");
+ public string StatusDisplay => Status.Replace("_", " ");
+ }
+
+ public class FinOpsMemoryGrantEfficiency
+ {
+ public DateTime Day { get; set; }
+ public decimal AvgGrantedMb { get; set; }
+ public decimal AvgUsedMb { get; set; }
+ public decimal EfficiencyPct { get; set; }
+ public decimal PeakGrantedMb { get; set; }
+ public long TotalGrantees { get; set; }
+ public long TotalWaiters { get; set; }
+ public long TimeoutErrors { get; set; }
+ public long ForcedGrants { get; set; }
+ public string DayDisplay => Day.ToString("ddd MM/dd");
+ public decimal WastedMb => AvgGrantedMb - AvgUsedMb;
+ }
+
+ public class IndexCleanupSummary
+ {
+ public string Level { get; set; } = "";
+ public string DatabaseInfo { get; set; } = "";
+ public string SchemaName { get; set; } = "";
+ public string TableName { get; set; } = "";
+ public string TablesAnalyzed { get; set; } = "";
+ public string TotalIndexes { get; set; } = "";
+ public string RemovableIndexes { get; set; } = "";
+ public string MergeableIndexes { get; set; } = "";
+ public string CompressableIndexes { get; set; } = "";
+ public string PercentRemovable { get; set; } = "";
+ public string CurrentSizeGb { get; set; } = "";
+ public string SizeAfterCleanupGb { get; set; } = "";
+ public string SpaceSavedGb { get; set; } = "";
+ public string SpaceReductionPercent { get; set; } = "";
+ public string CompressionSavingsPotential { get; set; } = "";
+ public string CompressionSavingsPotentialTotal { get; set; } = "";
+ public string ComputedColumnsWithUdfs { get; set; } = "";
+ public string CheckConstraintsWithUdfs { get; set; } = "";
+ public string FilteredIndexesNeedingIncludes { get; set; } = "";
+ public string TotalRows { get; set; } = "";
+ public string ReadsBreakdown { get; set; } = "";
+ public string Writes { get; set; } = "";
+ public string DailyWriteOpsSaved { get; set; } = "";
+ public string LockWaitCount { get; set; } = "";
+ public string DailyLockWaitsSaved { get; set; } = "";
+ public string AvgLockWaitMs { get; set; } = "";
+ public string LatchWaitCount { get; set; } = "";
+ public string DailyLatchWaitsSaved { get; set; } = "";
+ public string AvgLatchWaitMs { get; set; } = "";
+ }
+}
diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs
index 4e711ef4..e67133a3 100644
--- a/Dashboard/Services/DatabaseService.NocHealth.cs
+++ b/Dashboard/Services/DatabaseService.NocHealth.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Data;
+using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using PerformanceMonitorDashboard.Helpers;
@@ -121,7 +122,16 @@ public async Task RefreshNocHealthStatusAsync(ServerHealthStatus status, int eng
/// Lightweight alert-only health check. Runs 3 queries instead of 9.
/// Used by MainWindow's independent alert timer.
///
- public async Task GetAlertHealthAsync(int engineEdition = 0, int longRunningQueryThresholdMinutes = 30, int longRunningJobMultiplier = 3)
+ public async Task GetAlertHealthAsync(
+ int engineEdition = 0,
+ int longRunningQueryThresholdMinutes = 30,
+ int longRunningJobMultiplier = 3,
+ int longRunningQueryMaxResults = 5,
+ bool excludeSpServerDiagnostics = true,
+ bool excludeWaitFor = true,
+ bool excludeBackups = true,
+ bool excludeMiscWaits = true,
+ IReadOnlyList? excludedDatabases = null)
{
var result = new AlertHealthResult();
@@ -133,14 +143,20 @@ public async Task GetAlertHealthAsync(int engineEdition = 0,
result.IsOnline = true;
var cpuTask = GetCpuPercentAsync(connection, engineEdition);
- var blockingTask = GetBlockingValuesAsync(connection);
+ var blockingTask = GetBlockingValuesAsync(connection, excludedDatabases ?? Array.Empty());
var deadlockTask = GetDeadlockCountAsync(connection);
+ var filteredDeadlockTask = excludedDatabases?.Count > 0
+ ? GetFilteredDeadlockCountAsync(connection, excludedDatabases)
+ : null;
var poisonWaitTask = GetPoisonWaitDeltasAsync(connection);
- var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes);
+ var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits);
var tempDbTask = GetTempDbSpaceAsync(connection);
var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier);
- await Task.WhenAll(cpuTask, blockingTask, deadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask);
+ var allTasks = filteredDeadlockTask != null
+ ? new Task[] { cpuTask, blockingTask, deadlockTask, filteredDeadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask }
+ : new Task[] { cpuTask, blockingTask, deadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask };
+ await Task.WhenAll(allTasks);
var cpuResult = await cpuTask;
result.CpuPercent = cpuResult.SqlCpu;
@@ -151,6 +167,8 @@ public async Task GetAlertHealthAsync(int engineEdition = 0,
result.LongestBlockedSeconds = blockingResult.LongestBlockedSeconds;
result.DeadlockCount = await deadlockTask;
+ if (filteredDeadlockTask != null)
+ result.FilteredDeadlockCount = await filteredDeadlockTask;
result.PoisonWaits = await poisonWaitTask;
result.LongRunningQueries = await longRunningTask;
result.TempDbSpace = await tempDbTask;
@@ -169,9 +187,16 @@ public async Task GetAlertHealthAsync(int engineEdition = 0,
/// Returns blocking values directly (without writing to a ServerHealthStatus).
/// Used by GetAlertHealthAsync for lightweight alert checks.
///
- private async Task<(long TotalBlocked, decimal LongestBlockedSeconds)> GetBlockingValuesAsync(SqlConnection connection)
+ private async Task<(long TotalBlocked, decimal LongestBlockedSeconds)> GetBlockingValuesAsync(SqlConnection connection, IReadOnlyList excludedDatabases)
{
- const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ var dbFilter = "";
+ var dbParams = new List();
+ for (int i = 0; i < excludedDatabases.Count; i++)
+ dbParams.Add($"@exdb{i}");
+ if (dbParams.Count > 0)
+ dbFilter = $"AND DB_NAME(s.dbid) NOT IN ({string.Join(", ", dbParams)})";
+
+ var query = $@"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
total_blocked = COUNT_BIG(*),
@@ -179,12 +204,15 @@ public async Task GetAlertHealthAsync(int engineEdition = 0,
FROM sys.sysprocesses AS s
WHERE s.blocked <> 0
AND s.lastwaittype LIKE N'LCK%'
+ {dbFilter}
OPTION(MAXDOP 1, RECOMPILE);";
try
{
using var cmd = new SqlCommand(query, connection);
cmd.CommandTimeout = 10;
+ for (int i = 0; i < excludedDatabases.Count; i++)
+ cmd.Parameters.AddWithValue($"@exdb{i}", excludedDatabases[i]);
using var reader = await cmd.ExecuteReaderAsync();
if (await reader.ReadAsync())
@@ -423,6 +451,49 @@ WHERE pc.counter_name LIKE N'Number of Deadlocks/sec%'
}
}
+ ///
+ /// Counts recent deadlocks from collect.blocking_deadlock_stats, excluding the specified databases.
+ /// Uses a 5-minute window matching the alert cooldown so each cooldown period
+ /// reflects only deadlocks from non-excluded databases.
+ /// This is the filtered equivalent of GetDeadlockCountAsync, which reads from
+ /// sys.dm_os_performance_counters and cannot be filtered by database.
+ ///
+ private async Task GetFilteredDeadlockCountAsync(SqlConnection connection, IReadOnlyList excludedDatabases)
+ {
+ var dbFilter = "";
+ var dbParams = new List();
+ for (int i = 0; i < excludedDatabases.Count; i++)
+ dbParams.Add($"@exdb{i}");
+ if (dbParams.Count > 0)
+ dbFilter = $"AND bds.database_name NOT IN ({string.Join(", ", dbParams)})";
+
+ var query = $@"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT
+ filtered_deadlock_count =
+ COALESCE(SUM(bds.deadlock_count_delta), 0)
+ FROM collect.blocking_deadlock_stats AS bds
+ WHERE bds.collection_time >= DATEADD(MINUTE, -5, SYSUTCDATETIME())
+ AND bds.deadlock_count_delta IS NOT NULL
+ {dbFilter}
+ OPTION(MAXDOP 1, RECOMPILE);";
+
+ try
+ {
+ using var cmd = new SqlCommand(query, connection);
+ cmd.CommandTimeout = 10;
+ for (int i = 0; i < excludedDatabases.Count; i++)
+ cmd.Parameters.AddWithValue($"@exdb{i}", excludedDatabases[i]);
+ var result = await cmd.ExecuteScalarAsync();
+ return result is long l ? l : (result is int i2 ? (long)i2 : 0);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning($"Failed to get filtered deadlock count: {ex.Message}");
+ return null; // Fall back to raw delta
+ }
+ }
+
private async Task GetCollectorStatusAsync(SqlConnection connection, ServerHealthStatus status)
{
const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
@@ -603,24 +674,29 @@ ORDER BY collection_time DESC
/// Gets currently running queries that exceed the duration threshold.
/// Uses live DMV data (sys.dm_exec_requests) for immediate detection.
///
- private async Task> GetLongRunningQueriesAsync(SqlConnection connection, int thresholdMinutes)
+ private async Task> GetLongRunningQueriesAsync(
+ SqlConnection connection,
+ int thresholdMinutes,
+ int maxResults = 5,
+ bool excludeSpServerDiagnostics = true,
+ bool excludeWaitFor = true,
+ bool excludeBackups = true,
+ bool excludeMiscWaits = true)
{
+ maxResults = Math.Clamp(maxResults, 1, 1000);
- // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable.
- string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'";
-
- // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem.
- string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')";
-
- // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context.
- string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')";
-
- // Exclude miscellaneous wait type that aren't typically actionable
- string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')";
+ string spServerDiagnosticsFilter = excludeSpServerDiagnostics
+ ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : "";
+ string waitForFilter = excludeWaitFor
+ ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : "";
+ string backupsFilter = excludeBackups
+ ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : "";
+ string miscWaitsFilter = excludeMiscWaits
+ ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : "";
string query = @$"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
- SELECT TOP(5)
+ SELECT TOP(@maxResults)
r.session_id,
DB_NAME(r.database_id) AS database_name,
SUBSTRING(t.text, 1, 300) AS query_text,
@@ -651,6 +727,7 @@ ORDER BY r.total_elapsed_time DESC
using var cmd = new SqlCommand(query, connection);
cmd.CommandTimeout = 10;
cmd.Parameters.Add(new SqlParameter("@thresholdMs", SqlDbType.BigInt) { Value = (long)thresholdMinutes * 60 * 1000 });
+ cmd.Parameters.Add(new SqlParameter("@maxResults", SqlDbType.Int) { Value = maxResults});
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs
index 6c3c2c81..a3ca9555 100644
--- a/Dashboard/Services/DatabaseService.QueryPerformance.cs
+++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs
@@ -547,8 +547,8 @@ SELECT TOP (500)
qs.login_name,
qs.host_name,
qs.program_name,
- sql_text = CONVERT(nvarchar(max), qs.sql_text),
- sql_command = CONVERT(nvarchar(max), qs.sql_command),
+ sql_text = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_text), N'', N''),
+ sql_command = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_command), N'', N''),
qs.CPU,
qs.reads,
qs.writes,
@@ -590,8 +590,8 @@ SELECT TOP (500)
qs.login_name,
qs.host_name,
qs.program_name,
- sql_text = CONVERT(nvarchar(max), qs.sql_text),
- sql_command = CONVERT(nvarchar(max), qs.sql_command),
+ sql_text = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_text), N'', N''),
+ sql_command = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_command), N'', N''),
qs.CPU,
qs.reads,
qs.writes,
@@ -690,6 +690,163 @@ FROM report.query_snapshots AS qs
return result == DBNull.Value ? null : result as string;
}
+ ///
+ /// Gets query snapshots filtered by wait type for the wait drill-down feature.
+ /// Uses LIKE on wait_info to match sp_WhoIsActive's formatted wait string.
+ ///
+ public async Task> GetQuerySnapshotsByWaitTypeAsync(
+ string waitType, int hoursBack = 1,
+ DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ var items = new List();
+
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ // Check if the view exists
+ string checkViewQuery = @"
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT 1 FROM sys.views
+ WHERE name = 'query_snapshots'
+ AND schema_id = SCHEMA_ID('report')";
+
+ using var checkCommand = new SqlCommand(checkViewQuery, connection);
+ var viewExists = await checkCommand.ExecuteScalarAsync();
+
+ if (viewExists == null)
+ return items;
+
+ bool useCustomDates = fromDate.HasValue && toDate.HasValue;
+
+ // sp_WhoIsActive formats wait_info as "(1x: 349ms)LCK_M_X, (1x: 12ms)..."
+ // The ')' always precedes the wait type name, so we use '%)WAIT_TYPE%'
+ // to avoid false positives (e.g., LCK_M_X matching LCK_M_IX)
+ string query = useCustomDates
+ ? @"
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT TOP (500)
+ qs.collection_time,
+ qs.[dd hh:mm:ss.mss],
+ qs.session_id,
+ qs.status,
+ qs.wait_info,
+ qs.blocking_session_id,
+ qs.blocked_session_count,
+ qs.database_name,
+ qs.login_name,
+ qs.host_name,
+ qs.program_name,
+ sql_text = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_text), N'', N''),
+ sql_command = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_command), N'', N''),
+ qs.CPU,
+ qs.reads,
+ qs.writes,
+ qs.physical_reads,
+ qs.context_switches,
+ qs.used_memory,
+ qs.tempdb_current,
+ qs.tempdb_allocations,
+ qs.tran_log_writes,
+ qs.open_tran_count,
+ qs.percent_complete,
+ qs.start_time,
+ qs.tran_start_time,
+ qs.request_id,
+ additional_info = CONVERT(nvarchar(max), qs.additional_info)
+ FROM report.query_snapshots AS qs
+ WHERE qs.collection_time >= @from_date
+ AND qs.collection_time <= @to_date
+ AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%'
+ ORDER BY
+ qs.collection_time DESC,
+ qs.session_id;"
+ : @"
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ SELECT TOP (500)
+ qs.collection_time,
+ qs.[dd hh:mm:ss.mss],
+ qs.session_id,
+ qs.status,
+ qs.wait_info,
+ qs.blocking_session_id,
+ qs.blocked_session_count,
+ qs.database_name,
+ qs.login_name,
+ qs.host_name,
+ qs.program_name,
+ sql_text = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_text), N'', N''),
+ sql_command = REPLACE(REPLACE(CONVERT(nvarchar(max), qs.sql_command), N'', N''),
+ qs.CPU,
+ qs.reads,
+ qs.writes,
+ qs.physical_reads,
+ qs.context_switches,
+ qs.used_memory,
+ qs.tempdb_current,
+ qs.tempdb_allocations,
+ qs.tran_log_writes,
+ qs.open_tran_count,
+ qs.percent_complete,
+ qs.start_time,
+ qs.tran_start_time,
+ qs.request_id,
+ additional_info = CONVERT(nvarchar(max), qs.additional_info)
+ FROM report.query_snapshots AS qs
+ WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME())
+ AND CONVERT(nvarchar(max), qs.wait_info) LIKE N'%)' + @wait_type + N'%'
+ ORDER BY
+ qs.collection_time DESC,
+ qs.session_id;";
+
+ using var command = new SqlCommand(query, connection);
+ command.CommandTimeout = 120;
+ command.Parameters.Add(new SqlParameter("@wait_type", SqlDbType.NVarChar, 200) { Value = waitType });
+ command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack });
+ if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value });
+ if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value });
+
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new QuerySnapshotItem
+ {
+ CollectionTime = reader.GetDateTime(0),
+ Duration = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1)?.ToString() ?? string.Empty,
+ SessionId = SafeToInt16(reader.GetValue(2), "session_id") ?? 0,
+ Status = reader.IsDBNull(3) ? null : reader.GetValue(3)?.ToString(),
+ WaitInfo = reader.IsDBNull(4) ? null : reader.GetValue(4)?.ToString(),
+ BlockingSessionId = SafeToInt16(reader.GetValue(5), "blocking_session_id"),
+ BlockedSessionCount = SafeToInt16(reader.GetValue(6), "blocked_session_count"),
+ DatabaseName = reader.IsDBNull(7) ? null : reader.GetValue(7)?.ToString(),
+ LoginName = reader.IsDBNull(8) ? null : reader.GetValue(8)?.ToString(),
+ HostName = reader.IsDBNull(9) ? null : reader.GetValue(9)?.ToString(),
+ ProgramName = reader.IsDBNull(10) ? null : reader.GetValue(10)?.ToString(),
+ SqlText = reader.IsDBNull(11) ? null : reader.GetValue(11)?.ToString(),
+ SqlCommand = reader.IsDBNull(12) ? null : reader.GetValue(12)?.ToString(),
+ Cpu = SafeToInt64(reader.GetValue(13), "CPU"),
+ Reads = SafeToInt64(reader.GetValue(14), "reads"),
+ Writes = SafeToInt64(reader.GetValue(15), "writes"),
+ PhysicalReads = SafeToInt64(reader.GetValue(16), "physical_reads"),
+ ContextSwitches = SafeToInt64(reader.GetValue(17), "context_switches"),
+ UsedMemoryMb = SafeToDecimal(reader.GetValue(18), "used_memory"),
+ TempdbCurrentMb = SafeToDecimal(reader.GetValue(19), "tempdb_current"),
+ TempdbAllocations = SafeToDecimal(reader.GetValue(20), "tempdb_allocations"),
+ TranLogWrites = reader.IsDBNull(21) ? null : reader.GetValue(21)?.ToString(),
+ OpenTranCount = SafeToInt16(reader.GetValue(22), "open_tran_count"),
+ PercentComplete = SafeToDecimal(reader.GetValue(23), "percent_complete"),
+ StartTime = reader.IsDBNull(24) ? null : reader.GetDateTime(24),
+ TranStartTime = reader.IsDBNull(25) ? null : reader.GetDateTime(25),
+ RequestId = SafeToInt16(reader.GetValue(26), "request_id"),
+ AdditionalInfo = reader.IsDBNull(27) ? null : reader.GetValue(27)?.ToString()
+ });
+ }
+
+ return items;
+ }
+
public async Task> GetQueryStatsAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null)
{
var items = new List();
@@ -739,8 +896,8 @@ WITH per_lifetime AS
total_spills = MAX(qs.total_spills),
min_spills = MIN(qs.min_spills),
max_spills = MAX(qs.max_spills),
- query_text = MAX(qs.query_text),
- query_plan_text = MAX(qs.query_plan_text),
+ query_text = CAST(DECOMPRESS(MAX(qs.query_text)) AS nvarchar(max)),
+ query_plan_text = CAST(DECOMPRESS(MAX(qs.query_plan_text)) AS nvarchar(max)),
query_plan_hash = MAX(qs.query_plan_hash),
sql_handle = MAX(qs.sql_handle),
plan_handle = MAX(qs.plan_handle)
@@ -753,7 +910,7 @@ FROM collect.query_stats AS qs
OR (qs.last_execution_time >= @fromDate AND qs.last_execution_time <= @toDate)
OR (qs.creation_time <= @fromDate AND qs.last_execution_time >= @toDate)))
)
- AND qs.query_text NOT LIKE N'WAITFOR%'
+ AND CAST(DECOMPRESS(qs.query_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%'
GROUP BY
qs.database_name,
qs.query_hash,
@@ -922,7 +1079,7 @@ WITH per_lifetime AS
total_spills = MAX(ps.total_spills),
min_spills = MIN(ps.min_spills),
max_spills = MAX(ps.max_spills),
- query_plan_text = MAX(ps.query_plan_text),
+ query_plan_text = CAST(DECOMPRESS(MAX(ps.query_plan_text)) AS nvarchar(max)),
sql_handle = MAX(ps.sql_handle),
plan_handle = MAX(ps.plan_handle)
FROM collect.procedure_stats AS ps
@@ -1101,7 +1258,7 @@ public async Task> GetQueryStoreDataAsync(int hoursBack = 2
plan_type = MAX(qsd.plan_type),
is_forced_plan = MAX(CONVERT(tinyint, qsd.is_forced_plan)),
compatibility_level = MAX(qsd.compatibility_level),
- query_sql_text = CONVERT(nvarchar(max), MAX(qsd.query_sql_text)),
+ query_sql_text = CAST(DECOMPRESS(MAX(qsd.query_sql_text)) AS nvarchar(max)),
query_plan_hash = CONVERT(nvarchar(20), MAX(qsd.query_plan_hash), 1),
force_failure_count = SUM(qsd.force_failure_count),
last_force_failure_reason_desc = MAX(qsd.last_force_failure_reason_desc),
@@ -1121,7 +1278,7 @@ FROM collect.query_store_data AS qsd
OR (qsd.server_last_execution_time >= @fromDate AND qsd.server_last_execution_time <= @toDate)
OR (qsd.server_first_execution_time <= @fromDate AND qsd.server_last_execution_time >= @toDate)))
)
- AND qsd.query_sql_text NOT LIKE N'WAITFOR%'
+ AND CAST(DECOMPRESS(qsd.query_sql_text) AS nvarchar(max)) NOT LIKE N'WAITFOR%'
GROUP BY
qsd.database_name,
qsd.query_id
@@ -2228,7 +2385,7 @@ ORDER BY
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- qsd.query_plan_text
+ CAST(DECOMPRESS(qsd.query_plan_text) AS nvarchar(max)) AS query_plan_text
FROM collect.query_store_data AS qsd
WHERE qsd.collection_id = @collection_id;";
@@ -2252,7 +2409,7 @@ FROM collect.query_store_data AS qsd
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- ps.query_plan
+ CAST(DECOMPRESS(ps.query_plan_text) AS nvarchar(max)) AS query_plan_text
FROM collect.procedure_stats AS ps
WHERE ps.collection_id = @collection_id;";
@@ -2276,7 +2433,7 @@ FROM collect.procedure_stats AS ps
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- qs.query_plan_text
+ CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max)) AS query_plan_text
FROM collect.query_stats AS qs
WHERE qs.collection_id = @collection_id;";
diff --git a/Dashboard/Services/DatabaseService.cs b/Dashboard/Services/DatabaseService.cs
index c5dfb36e..61610615 100644
--- a/Dashboard/Services/DatabaseService.cs
+++ b/Dashboard/Services/DatabaseService.cs
@@ -357,5 +357,18 @@ public async Task UpdateCollectorScheduleAsync(int scheduleId, bool enabled, int
await command.ExecuteNonQueryAsync();
}
+
+ public async Task ApplyCollectionPresetAsync(string presetName)
+ {
+ await using var tc = await OpenThrottledConnectionAsync();
+ var connection = tc.Connection;
+
+ using var command = new SqlCommand("config.apply_collection_preset", connection);
+ command.CommandType = System.Data.CommandType.StoredProcedure;
+ command.CommandTimeout = 120;
+ command.Parameters.Add(new SqlParameter("@preset_name", SqlDbType.NVarChar, 128) { Value = presetName });
+
+ await command.ExecuteNonQueryAsync();
+ }
}
}
diff --git a/Dashboard/Services/EmailAlertService.cs b/Dashboard/Services/EmailAlertService.cs
index e35bde0c..2bb0ab32 100644
--- a/Dashboard/Services/EmailAlertService.cs
+++ b/Dashboard/Services/EmailAlertService.cs
@@ -33,7 +33,6 @@ public class EmailAlertService
private readonly UserPreferencesService _preferencesService;
private readonly ConcurrentDictionary _cooldowns = new();
- private static readonly TimeSpan CooldownPeriod = TimeSpan.FromMinutes(15);
/* Alert log — loaded from JSON on startup, saved on exit, new alerts added in-memory */
private readonly List _alertLog = new();
@@ -90,14 +89,14 @@ public async Task TrySendAlertEmailAsync(
var cooldownKey = $"{serverId}:{metricName}";
if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) &&
- DateTime.UtcNow - lastSent < CooldownPeriod)
+ DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(prefs.EmailCooldownMinutes))
{
return;
}
var subject = $"[SQL Monitor Alert] {metricName} on {serverName}";
var (htmlBody, plainTextBody) = EmailTemplateBuilder.BuildAlertEmail(
- metricName, serverName, currentValue, thresholdValue, context);
+ metricName, serverName, currentValue, thresholdValue, prefs.EmailCooldownMinutes, context);
string? sendError = null;
bool sent = false;
@@ -149,7 +148,7 @@ public async Task TrySendAlertEmailAsync(
///
public void RecordAlert(string serverId, string serverName, string metricName,
string currentValue, string thresholdValue, bool alertSent,
- string notificationType, string? sendError = null)
+ string notificationType, string? sendError = null, bool muted = false, string? detailText = null)
{
var entry = new AlertLogEntry
{
@@ -161,7 +160,9 @@ public void RecordAlert(string serverId, string serverName, string metricName,
ThresholdValue = thresholdValue,
AlertSent = alertSent,
NotificationType = notificationType,
- SendError = sendError
+ SendError = sendError,
+ Muted = muted,
+ DetailText = detailText
};
lock (_alertLogLock)
@@ -411,5 +412,7 @@ public class AlertLogEntry
public string NotificationType { get; set; } = "";
public string? SendError { get; set; }
public bool Hidden { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
}
}
diff --git a/Dashboard/Services/EmailTemplateBuilder.cs b/Dashboard/Services/EmailTemplateBuilder.cs
index b72fa130..87387439 100644
--- a/Dashboard/Services/EmailTemplateBuilder.cs
+++ b/Dashboard/Services/EmailTemplateBuilder.cs
@@ -27,6 +27,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail(
string serverName,
string currentValue,
string thresholdValue,
+ int emailCooldownMinutes,
AlertContext? context = null)
{
var utcNow = DateTime.UtcNow;
@@ -34,7 +35,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail(
var (accentColor, badgeText) = GetSeverity(metricName);
var html = BuildHtmlBody(metricName, serverName, currentValue,
- thresholdValue, utcNow, localNow, accentColor, badgeText, context: context);
+ thresholdValue, utcNow, localNow, accentColor, badgeText, context: context, emailCooldownMinutes: emailCooldownMinutes);
var plain = BuildPlainTextBody(metricName, serverName, currentValue,
thresholdValue, utcNow, localNow, context);
@@ -87,7 +88,8 @@ private static string BuildHtmlBody(
string accentColor,
string badgeText,
bool isTest = false,
- AlertContext? context = null)
+ AlertContext? context = null,
+ int emailCooldownMinutes = 15)
{
var sb = new StringBuilder(2048);
@@ -167,7 +169,7 @@ private static string BuildHtmlBody(
sb.Append($"Sent by {WebUtility.HtmlEncode(EditionName)}");
if (!isTest)
{
- sb.Append(" · 15-minute cooldown between repeat alerts");
+ sb.Append($" · {emailCooldownMinutes}-minute cooldown between repeat alerts");
}
sb.Append("");
sb.Append("");
diff --git a/Dashboard/Services/MuteRuleService.cs b/Dashboard/Services/MuteRuleService.cs
new file mode 100644
index 00000000..b573f721
--- /dev/null
+++ b/Dashboard/Services/MuteRuleService.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using PerformanceMonitorDashboard.Models;
+
+namespace PerformanceMonitorDashboard.Services
+{
+ ///
+ /// Manages alert mute rules with JSON persistence.
+ /// Thread-safe: all operations are protected by _lock.
+ ///
+ public class MuteRuleService
+ {
+ private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true };
+
+ private readonly string _filePath;
+ private readonly object _lock = new object();
+ private List _rules = new();
+
+ public MuteRuleService()
+ {
+ var appDataDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "PerformanceMonitorDashboard");
+ Directory.CreateDirectory(appDataDir);
+ _filePath = Path.Combine(appDataDir, "alert_mute_rules.json");
+ Load();
+ PurgeExpiredRules();
+ }
+
+ public bool IsAlertMuted(AlertMuteContext context)
+ {
+ lock (_lock)
+ {
+ return _rules.Any(r => r.Matches(context));
+ }
+ }
+
+ public List GetRules()
+ {
+ lock (_lock)
+ {
+ return _rules.ToList();
+ }
+ }
+
+ public List GetActiveRules()
+ {
+ lock (_lock)
+ {
+ return _rules.Where(r => r.Enabled && !r.IsExpired).ToList();
+ }
+ }
+
+ public void AddRule(MuteRule rule)
+ {
+ lock (_lock)
+ {
+ _rules.Add(rule);
+ Save();
+ }
+ }
+
+ public void RemoveRule(string ruleId)
+ {
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => r.Id == ruleId);
+ Save();
+ }
+ }
+
+ public void UpdateRule(MuteRule updated)
+ {
+ lock (_lock)
+ {
+ var index = _rules.FindIndex(r => r.Id == updated.Id);
+ if (index >= 0)
+ {
+ _rules[index] = updated;
+ Save();
+ }
+ }
+ }
+
+ public void SetRuleEnabled(string ruleId, bool enabled)
+ {
+ lock (_lock)
+ {
+ var rule = _rules.FirstOrDefault(r => r.Id == ruleId);
+ if (rule != null)
+ {
+ rule.Enabled = enabled;
+ Save();
+ }
+ }
+ }
+
+ ///
+ /// Removes all expired rules from the list.
+ ///
+ public int PurgeExpiredRules()
+ {
+ lock (_lock)
+ {
+ int removed = _rules.RemoveAll(r => r.IsExpired);
+ if (removed > 0) Save();
+ return removed;
+ }
+ }
+
+ private void Load()
+ {
+ lock (_lock)
+ {
+ try
+ {
+ if (File.Exists(_filePath))
+ {
+ var json = File.ReadAllText(_filePath);
+ _rules = JsonSerializer.Deserialize>(json) ?? new();
+ }
+ }
+ catch
+ {
+ _rules = new();
+ }
+ }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(_rules, s_jsonOptions);
+ File.WriteAllText(_filePath, json);
+ }
+ catch (Exception ex)
+ {
+ Helpers.Logger.Error("MuteRuleService: Failed to save mute rules", ex);
+ }
+ }
+ }
+}
diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs
index 5e30b333..e174f311 100644
--- a/Dashboard/Services/NotificationService.cs
+++ b/Dashboard/Services/NotificationService.cs
@@ -44,7 +44,10 @@ public void Initialize()
bool HasLightBackground = Helpers.ThemeManager.HasLightBackground;
- /* Custom tooltip styled to match current theme */
+ /* Custom tooltip styled to match current theme.
+ Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow
+ that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422).
+ The DispatcherUnhandledException handler silently swallows this specific crash. */
_trayIcon.TrayToolTip = new Border
{
Background = new SolidColorBrush(HasLightBackground
diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs
index ec03090e..bc4fe057 100644
--- a/Dashboard/Services/PlanAnalyzer.cs
+++ b/Dashboard/Services/PlanAnalyzer.cs
@@ -10,24 +10,16 @@ namespace PerformanceMonitorDashboard.Services;
/// Post-parse analysis pass that walks a parsed plan tree and adds warnings
/// for common performance anti-patterns. Called after ShowPlanParser.Parse().
///
-public static class PlanAnalyzer
+public static partial class PlanAnalyzer
{
- private static readonly Regex FunctionInPredicateRegex = new(
- @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp();
- private static readonly Regex LeadingWildcardLikeRegex = new(
- @"\blike\b[^'""]*?N?'%",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp();
- private static readonly Regex CaseInPredicateRegex = new(
- @"\bCASE\s+(WHEN\b|$)",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp();
// Matches CTE definitions: WITH name AS ( or , name AS (
- private static readonly Regex CteDefinitionRegex = new(
- @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp();
public static void Analyze(ParsedPlan plan)
{
@@ -186,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt)
// Rule 27: OPTIMIZE FOR UNKNOWN in statement text
if (!string.IsNullOrEmpty(stmt.StatementText) &&
- Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase))
+ OptimizeForUnknownRegExp().IsMatch(stmt.StatementText))
{
stmt.PlanWarnings.Add(new PlanWarning
{
@@ -196,24 +188,44 @@ private static void AnalyzeStatement(PlanStatement stmt)
});
}
- // Rule 25: Ineffective parallelism — parallel plan where CPU ≈ elapsed
+ // Rule 25: Ineffective parallelism — DOP-aware efficiency scoring
+ // Efficiency = (speedup - 1) / (DOP - 1) * 100
+ // where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%).
+ // Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working.
if (stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null)
{
var cpu = stmt.QueryTimeStats.CpuTimeMs;
var elapsed = stmt.QueryTimeStats.ElapsedTimeMs;
+ var dop = stmt.DegreeOfParallelism;
if (elapsed >= 1000 && cpu > 0)
{
- var ratio = (double)cpu / elapsed;
- if (ratio <= 1.3)
+ var speedup = (double)cpu / elapsed;
+ var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0));
+
+ if (speedup < 0.5)
+ {
+ // CPU well below Elapsed: threads are waiting, not doing CPU work
+ var waitPct = (1.0 - speedup) * 100;
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Parallel Wait Bottleneck",
+ Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " +
+ $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " +
+ $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+ else if (efficiency < 40)
{
+ // CPU >= Elapsed but well below DOP potential — parallelism is ineffective
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Ineffective Parallelism",
- Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) but CPU time ({cpu:N0}ms) is nearly equal to elapsed time ({elapsed:N0}ms). " +
- $"The work ran essentially serially despite the overhead of parallelism. " +
+ Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " +
+ $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " +
$"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.",
- Severity = PlanWarningSeverity.Warning
+ Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
@@ -279,6 +291,53 @@ private static void AnalyzeStatement(PlanStatement stmt)
}
}
}
+
+ // Rule 22 (statement-level): Table variable warnings
+ if (stmt.RootNode != null)
+ {
+ var hasTableVar = false;
+ var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE";
+ var modifiesTableVar = false;
+ CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar);
+
+ if (hasTableVar && !modifiesTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+
+ if (modifiesTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.",
+ Severity = PlanWarningSeverity.Critical
+ });
+ }
+ }
+ }
+
+ private static void CheckForTableVariables(PlanNode node, bool isModification,
+ ref bool hasTableVar, ref bool modifiesTableVar)
+ {
+ if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
+ {
+ hasTableVar = true;
+ if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
+ {
+ modifiesTableVar = true;
+ }
+ }
+ foreach (var child in node.Children)
+ CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
}
private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt)
@@ -296,10 +355,16 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
{
var impact = QuantifyFilterImpact(node);
+ var predicate = Truncate(node.Predicate, 200);
+ var message = "Filter operator discarding rows late in the plan.";
+ if (!string.IsNullOrEmpty(impact))
+ message += $"\n{impact}";
+ message += $"\nPredicate: {predicate}";
+
node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
- Message = $"Filter operator discarding rows late in the plan.{impact} Predicate: {Truncate(node.Predicate, 200)}",
+ Message = message,
Severity = PlanWarningSeverity.Warning
});
}
@@ -366,12 +431,12 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
var direction = ratio >= 10.0 ? "underestimated" : "overestimated";
var factor = ratio >= 10.0 ? ratio : 1.0 / ratio;
var actualDisplay = executions > 1
- ? $"actual {actualPerExec:N0}/exec ({node.ActualRows:N0} total across {executions:N0} executions)"
- : $"actual {node.ActualRows:N0}";
+ ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)"
+ : $"Actual {node.ActualRows:N0}";
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Estimate Mismatch",
- Message = $"Estimated {node.EstimateRows:N0} rows, {actualDisplay} ({factor:F0}x {direction}). {harm}",
+ Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}",
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
@@ -444,15 +509,19 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
// Rule 8: Parallel thread skew (actual plans with per-thread stats)
// Only warn when there are enough rows to meaningfully distribute across threads
+ // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators
if (node.PerThreadStats.Count > 1)
{
- var totalRows = node.PerThreadStats.Sum(t => t.ActualRows);
- var minRowsForSkew = node.PerThreadStats.Count * 1000;
+ var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList();
+ if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback
+ var totalRows = workerThreads.Sum(t => t.ActualRows);
+ var minRowsForSkew = workerThreads.Count * 1000;
if (totalRows >= minRowsForSkew)
{
- var maxThread = node.PerThreadStats.OrderByDescending(t => t.ActualRows).First();
+ var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First();
var skewRatio = (double)maxThread.ActualRows / totalRows;
- var skewThreshold = node.PerThreadStats.Count == 2 ? 0.75 : 0.50;
+ // At DOP 2, a 60/40 split is normal — use higher threshold
+ var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50;
if (skewRatio >= skewThreshold)
{
node.Warnings.Add(new PlanWarning
@@ -467,7 +536,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
// Rule 10: Key Lookup / RID Lookup with residual predicate
// Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true
- if (node.PhysicalOp == "RID Lookup")
+ if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase))
{
var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table.";
if (!string.IsNullOrEmpty(node.Predicate))
@@ -485,8 +554,8 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
node.Warnings.Add(new PlanWarning
{
WarningType = "Key Lookup",
- Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column. Predicate: {Truncate(node.Predicate, 200)}",
- Severity = PlanWarningSeverity.Warning
+ Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column.\nPredicate: {Truncate(node.Predicate, 200)}",
+ Severity = PlanWarningSeverity.Critical
});
}
@@ -513,7 +582,7 @@ _ when nonSargableReason.StartsWith("Function call") =>
node.Warnings.Add(new PlanWarning
{
WarningType = "Non-SARGable Predicate",
- Message = $"{nonSargableAdvice} Predicate: {Truncate(node.Predicate!, 200)}",
+ Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
@@ -523,10 +592,11 @@ _ when nonSargableReason.StartsWith("Function call") =>
if (nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) &&
!IsProbeOnly(node.Predicate))
{
+ var displayPredicate = StripProbeExpressions(node.Predicate);
node.Warnings.Add(new PlanWarning
{
WarningType = "Scan With Predicate",
- Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Create an index on the predicate columns. Predicate: {Truncate(node.Predicate, 200)}",
+ Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: {Truncate(displayPredicate, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
@@ -554,7 +624,8 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio
// Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result)
- if (node.LogicalOp == "Lazy Spool")
+ if (node.LogicalOp == "Lazy Spool"
+ && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase))
{
var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds;
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
@@ -686,13 +757,19 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 22: Table variables (Object name starts with @)
if (!string.IsNullOrEmpty(node.ObjectName) &&
- node.ObjectName.StartsWith("@"))
+ node.ObjectName.StartsWith('@'))
{
+ var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase);
+
node.Warnings.Add(new PlanWarning
{
WarningType = "Table Variable",
- Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
- Severity = PlanWarningSeverity.Warning
+ Message = isModificationOp
+ ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution."
+ : "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
+ Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
@@ -708,42 +785,48 @@ _ when nonSargableReason.StartsWith("Function call") =>
});
}
- // Rule 24: Top above a scan on the inner side of Nested Loops
- // This pattern means the scan executes once per outer row, and the Top
- // limits each iteration — but with no supporting index the scan is a
- // linear search repeated potentially millions of times.
- if (node.PhysicalOp == "Nested Loops" && node.Children.Count >= 2)
+ // Rule 24: Top above a scan
+ // Detects Top or Top N Sort operators feeding from a scan. This often means the
+ // query is scanning the entire table/index and sorting just to return a few rows,
+ // when an appropriate index could satisfy the request directly.
{
- var inner = node.Children[1];
-
- // Walk through pass-through operators to find Top
- while (inner.PhysicalOp == "Compute Scalar" && inner.Children.Count > 0)
- inner = inner.Children[0];
+ var isTop = node.PhysicalOp == "Top";
+ var isTopNSort = node.LogicalOp == "Top N Sort";
- if (inner.PhysicalOp == "Top" && inner.Children.Count > 0)
+ if ((isTop || isTopNSort) && node.Children.Count > 0)
{
// Walk through pass-through operators below the Top to find the scan
- var scanCandidate = inner.Children[0];
- while (scanCandidate.PhysicalOp == "Compute Scalar" && scanCandidate.Children.Count > 0)
+ var scanCandidate = node.Children[0];
+ while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism")
+ && scanCandidate.Children.Count > 0)
scanCandidate = scanCandidate.Children[0];
if (IsScanOperator(scanCandidate))
{
+ var topLabel = isTopNSort ? "Top N Sort" : "Top";
+ var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2
+ && node.Parent.Children[1] == node;
+ var innerNote = onInner
+ ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row."
+ : "";
var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate)
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
: "";
- inner.Warnings.Add(new PlanWarning
+ node.Warnings.Add(new PlanWarning
{
WarningType = "Top Above Scan",
- Message = $"Top operator reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}) on the inner side of Nested Loops (Node {node.NodeId}).{predInfo} Create an index on the predicate columns to convert the scan into a seek.",
- Severity = PlanWarningSeverity.Warning
+ Message = $"{topLabel} reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}).{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.",
+ Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
}
// Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN
- if (node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 &&
+ // Only surface on data access operators (seeks/scans) where the row goal actually matters
+ var isDataAccess = node.PhysicalOp != null &&
+ (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek"));
+ if (isDataAccess && node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 &&
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
@@ -758,7 +841,7 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
- if (node.PhysicalOp == "Row Count Spool")
+ if (node.PhysicalOp.Contains("Row Count Spool"))
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
@@ -793,7 +876,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt)
{
// Check statement text for NOT IN
if (string.IsNullOrEmpty(stmt.StatementText) ||
- !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase))
+ !NotInRegExp().IsMatch(stmt.StatementText))
return false;
// Walk up the tree checking ancestors and their children
@@ -854,6 +937,21 @@ private static bool IsProbeOnly(string predicate)
return stripped.Length == 0;
}
+ ///
+ /// Strips PROBE(...) bitmap filter expressions from a predicate for display,
+ /// leaving only the real residual predicate columns.
+ ///
+ private static string StripProbeExpressions(string predicate)
+ {
+ var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
+ RegexOptions.IgnoreCase);
+ stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "",
+ RegexOptions.IgnoreCase);
+ stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
+ RegexOptions.IgnoreCase);
+ return stripped.Trim();
+ }
+
///
/// Returns true for any scan operator including columnstore.
/// Excludes spools and constant scans.
@@ -890,7 +988,7 @@ private static bool IsScanOperator(PlanNode node)
return "Implicit conversion (CONVERT_IMPLICIT)";
// ISNULL / COALESCE wrapping column
- if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase))
+ if (IsNullCoalesceRegExp().IsMatch(predicate))
return "ISNULL/COALESCE wrapping column";
// Common function calls on columns
@@ -930,7 +1028,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt)
var refPattern = new Regex(
$@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b",
RegexOptions.IgnoreCase);
- var refCount = refPattern.Matches(text).Count;
+ var refCount = refPattern.Count(text);
if (refCount > 1)
{
@@ -955,8 +1053,8 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode)
while (parent != null && parent.PhysicalOp == "Compute Scalar")
parent = parent.Parent;
- // Expect TopN Sort
- if (parent == null || parent.LogicalOp != "TopN Sort")
+ // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort")
+ if (parent == null || parent.LogicalOp != "Top N Sort")
return false;
// Walk up to Merge Interval
@@ -1133,7 +1231,7 @@ private static string QuantifyFilterImpact(PlanNode filterNode)
if (parts.Count == 0)
return "";
- return $" Subtree cost to produce filtered rows: {string.Join(", ", parts)}.";
+ return string.Join("\n", parts.Select(p => "• " + p));
}
private static long SumSubtreeReads(PlanNode node)
@@ -1176,8 +1274,8 @@ private static long SumSubtreeReads(PlanNode node)
if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive)
{
return ratio >= 10.0
- ? "The underestimate may have caused the optimizer to choose a suboptimal join strategy."
- : "The overestimate may have caused the optimizer to choose a suboptimal join strategy.";
+ ? "The underestimate may have caused the optimizer to make poor choices."
+ : "The overestimate may have caused the optimizer to make poor choices.";
}
// Walk up to check if a parent was harmed by this bad estimate
@@ -1204,8 +1302,8 @@ private static long SumSubtreeReads(PlanNode node)
return null; // Adaptive join self-corrects — no harm
return ratio >= 10.0
- ? $"The underestimate may have caused the optimizer to choose {ancestor.PhysicalOp} when a different join type would be more efficient."
- : $"The overestimate may have caused the optimizer to choose {ancestor.PhysicalOp} when a different join type would be more efficient.";
+ ? "The underestimate may have caused the optimizer to make poor choices."
+ : "The overestimate may have caused the optimizer to make poor choices.";
}
// Parent Sort/Hash that spilled — downstream bad estimate caused the spill
@@ -1243,4 +1341,19 @@ private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength ? value : value[..maxLength] + "...";
}
+
+ [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex FunctionInPredicateRegExp();
+ [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)]
+ private static partial Regex LeadingWildcardLikeRegExp();
+ [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)]
+ private static partial Regex CaseInPredicateRegExp();
+ [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex CteDefinitionRegExp();
+ [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex IsNullCoalesceRegExp();
+ [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)]
+ private static partial Regex OptimizeForUnknownRegExp();
+ [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)]
+ private static partial Regex NotInRegExp();
}
diff --git a/Dashboard/Services/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs
index 659a9014..6ed411e3 100644
--- a/Dashboard/Services/PlanIconMapper.cs
+++ b/Dashboard/Services/PlanIconMapper.cs
@@ -30,6 +30,8 @@ public static class PlanIconMapper
["Index Scan"] = "index_scan",
["Index Seek"] = "index_seek",
["Index Spool"] = "index_spool",
+ ["Eager Index Spool"] = "index_spool",
+ ["Lazy Index Spool"] = "index_spool",
["Index Update"] = "index_update",
// Columnstore
@@ -74,7 +76,11 @@ public static class PlanIconMapper
// Spool
["Table Spool"] = "table_spool",
+ ["Eager Table Spool"] = "table_spool",
+ ["Lazy Table Spool"] = "table_spool",
["Row Count Spool"] = "row_count_spool",
+ ["Eager Row Count Spool"] = "row_count_spool",
+ ["Lazy Row Count Spool"] = "row_count_spool",
["Window Spool"] = "table_spool",
["Eager Spool"] = "table_spool",
["Lazy Spool"] = "table_spool",
diff --git a/Dashboard/Services/PortUtilityService.cs b/Dashboard/Services/PortUtilityService.cs
new file mode 100644
index 00000000..1d061945
--- /dev/null
+++ b/Dashboard/Services/PortUtilityService.cs
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PerformanceMonitorDashboard.Services
+{
+ public static partial class PortUtilityService
+ {
+ // Checks whether something is currently LISTENING on this TCP port.
+ // Note: the underlying API is synchronous; we yield once to keep an async signature.
+ public static async Task IsTcpPortListeningAsync(
+ int port,
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
+ throw new ArgumentOutOfRangeException(nameof(port));
+
+ address ??= IPAddress.Any;
+
+ var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners();
+
+ return listeners.Any(ep =>
+ ep.Port == port &&
+ (ep.Address.Equals(address) ||
+ ep.Address.Equals(IPAddress.Any) ||
+ ep.Address.Equals(IPAddress.IPv6Any)));
+ }
+
+ // Definitive check: attempt to bind.
+ // Note: the underlying API is synchronous; we yield once to keep an async signature.
+ public static async Task CanBindTcpPortAsync(
+ int port,
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
+ throw new ArgumentOutOfRangeException(nameof(port));
+
+ address ??= IPAddress.Any;
+
+ try
+ {
+ var listener = new TcpListener(address, port);
+ listener.Start();
+ listener.Stop();
+ return true;
+ }
+ catch (SocketException)
+ {
+ return false;
+ }
+ }
+
+ // Let the OS choose a free port (0), then read it back.
+ // Note: this is still synchronous under the hood; we yield once to keep an async signature.
+ public static async Task GetFreeTcpPortAsync(
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ address ??= IPAddress.Loopback;
+
+ var listener = new TcpListener(address, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+ }
+}
diff --git a/Dashboard/Services/ReproScriptBuilder.cs b/Dashboard/Services/ReproScriptBuilder.cs
index 3605db3e..f008c549 100644
--- a/Dashboard/Services/ReproScriptBuilder.cs
+++ b/Dashboard/Services/ReproScriptBuilder.cs
@@ -20,7 +20,7 @@ namespace PerformanceMonitorDashboard.Services;
/// Builds paste-ready T-SQL reproduction scripts from query text and plan XML.
/// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder).
///
-public static class ReproScriptBuilder
+public static partial class ReproScriptBuilder
{
///
/// Builds a complete reproduction script from available query data.
@@ -399,7 +399,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase);
/* Find all @variable references in the query text */
- var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase);
+ var matches = AtVariableRegExp().Matches(queryText);
var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
@@ -429,6 +429,9 @@ private static List FindUnresolvedVariables(string queryText, List
diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs
index 21778012..bdc8a980 100644
--- a/Dashboard/Services/ShowPlanParser.cs
+++ b/Dashboard/Services/ShowPlanParser.cs
@@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl)
StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value)
};
+ // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp
+ // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool"
+ if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase)
+ && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase))
+ {
+ node.PhysicalOp = "Eager " + node.PhysicalOp;
+ }
+ else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase)
+ && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase))
+ {
+ node.PhysicalOp = "Lazy " + node.PhysicalOp;
+ }
+
// Map to icon
node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp);
@@ -638,6 +651,10 @@ private static PlanNode ParseRelOp(XElement relOpEl)
var physicalOpEl = GetOperatorElement(relOpEl);
if (physicalOpEl != null)
{
+ // Top N Sort — XML element is but PhysicalOp is "Sort"
+ if (physicalOpEl.Name.LocalName == "TopSort")
+ node.LogicalOp = "Top N Sort";
+
// Object reference (table/index name) — scoped to stop at child RelOps
var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault();
if (objEl != null)
@@ -699,16 +716,35 @@ private static PlanNode ParseRelOp(XElement relOpEl)
var seekParts = new List();
foreach (var sp in seekPreds)
{
- var scalarOps = sp.Descendants(Ns + "ScalarOperator");
- foreach (var so in scalarOps)
+ foreach (var seekKeys in sp.Elements(Ns + "SeekKeys"))
{
- var val = so.Attribute("ScalarString")?.Value;
- if (!string.IsNullOrEmpty(val))
- seekParts.Add(val);
+ foreach (var range in seekKeys.Elements())
+ {
+ var scanType = range.Attribute("ScanType")?.Value;
+ var cols = range.Element(Ns + "RangeColumns")?
+ .Elements(Ns + "ColumnReference")
+ .Select(FormatColumnRef)
+ .ToList();
+ var exprs = range.Element(Ns + "RangeExpressions")?
+ .Elements(Ns + "ScalarOperator")
+ .Select(so => so.Attribute("ScalarString")?.Value ?? "?")
+ .ToList();
+
+ if (cols != null && exprs != null)
+ {
+ var op = scanType switch
+ {
+ "EQ" => "=", "GT" => ">", "GE" => ">=",
+ "LT" => "<", "LE" => "<=", _ => scanType ?? "="
+ };
+ for (int ci = 0; ci < cols.Count && ci < exprs.Count; ci++)
+ seekParts.Add($"{cols[ci]} {op} {exprs[ci]}");
+ }
+ }
}
}
if (seekParts.Count > 0)
- node.SeekPredicates = string.Join(" AND ", seekParts);
+ node.SeekPredicates = string.Join(", ", seekParts);
// GuessedSelectivity — check if optimizer guessed selectivity on predicates
if (ScopedDescendants(physicalOpEl, Ns + "GuessedSelectivity").Any())
@@ -832,6 +868,19 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1";
node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1";
+ // Override PhysicalOp, LogicalOp, and icon when Lookup=true.
+ // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with
+ // rather than "Key Lookup (Clustered)" — correct the label here so all display
+ // paths (node card, tooltip, properties panel) show the right operator name.
+ if (node.Lookup)
+ {
+ var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true
+ || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase);
+ node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)";
+ node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup";
+ node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup";
+ }
+
// Table cardinality and rows to be read (on per XSD)
node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value);
node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value);
@@ -1399,8 +1448,8 @@ private static List ParseWarningsFromElement(XElement warningsEl)
result.Add(new PlanWarning
{
WarningType = "No Join Predicate",
- Message = "This join has no join predicate (possible cross join)",
- Severity = PlanWarningSeverity.Critical
+ Message = "This join triggered a no join predicate warning, which is worth checking on, but is often misleading. The optimizer may have removed a redundant predicate after simplification.",
+ Severity = PlanWarningSeverity.Warning
});
}
@@ -1416,10 +1465,32 @@ private static List ParseWarningsFromElement(XElement warningsEl)
if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1")
{
+ var unmatchedMsg = "Indexes could not be matched due to parameterization";
+ var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes");
+ if (unmatchedEl != null)
+ {
+ var unmatchedDetails = new List();
+ foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization"))
+ {
+ var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", "");
+ var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", "");
+ var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "");
+ var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", "");
+ var parts = new List();
+ if (!string.IsNullOrEmpty(db)) parts.Add(db);
+ if (!string.IsNullOrEmpty(schema)) parts.Add(schema);
+ if (!string.IsNullOrEmpty(table)) parts.Add(table);
+ if (!string.IsNullOrEmpty(index)) parts.Add(index);
+ if (parts.Count > 0)
+ unmatchedDetails.Add(string.Join(".", parts));
+ }
+ if (unmatchedDetails.Count > 0)
+ unmatchedMsg += ": " + string.Join(", ", unmatchedDetails);
+ }
result.Add(new PlanWarning
{
WarningType = "Unmatched Indexes",
- Message = "Indexes could not be matched due to parameterization",
+ Message = unmatchedMsg,
Severity = PlanWarningSeverity.Warning
});
}
diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml
index f955eff0..2b73cba6 100644
--- a/Dashboard/SettingsWindow.xaml
+++ b/Dashboard/SettingsWindow.xaml
@@ -217,8 +217,28 @@
Margin="8,0,8,0"
VerticalAlignment="Center"
TextAlignment="Center"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -296,6 +340,10 @@
Width="70"
VerticalAlignment="Center"
TextAlignment="Center"/>
+
diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs
index 86ac58d3..d22775e9 100644
--- a/Dashboard/SettingsWindow.xaml.cs
+++ b/Dashboard/SettingsWindow.xaml.cs
@@ -8,6 +8,9 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using PerformanceMonitorDashboard.Helpers;
@@ -19,15 +22,17 @@ namespace PerformanceMonitorDashboard
public partial class SettingsWindow : Window
{
private readonly IUserPreferencesService _preferencesService;
+ private readonly MuteRuleService? _muteRuleService;
private bool _isLoading = true;
private readonly string _originalTheme = ThemeManager.CurrentTheme;
private bool _saved;
- public SettingsWindow(IUserPreferencesService preferencesService)
+ public SettingsWindow(IUserPreferencesService preferencesService, MuteRuleService? muteRuleService = null)
{
InitializeComponent();
_preferencesService = preferencesService;
+ _muteRuleService = muteRuleService;
LoadSettings();
_isLoading = false;
}
@@ -162,10 +167,18 @@ private void LoadSettings()
PoisonWaitThresholdTextBox.Text = prefs.PoisonWaitThresholdMs.ToString(CultureInfo.InvariantCulture);
NotifyOnLongRunningQueriesCheckBox.IsChecked = prefs.NotifyOnLongRunningQueries;
LongRunningQueryThresholdTextBox.Text = prefs.LongRunningQueryThresholdMinutes.ToString(CultureInfo.InvariantCulture);
+ LongRunningQueryMaxResultsTextBox.Text = prefs.LongRunningQueryMaxResults.ToString(CultureInfo.InvariantCulture);
+ LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = prefs.LongRunningQueryExcludeSpServerDiagnostics;
+ LrqExcludeWaitForCheckBox.IsChecked = prefs.LongRunningQueryExcludeWaitFor;
+ LrqExcludeBackupsCheckBox.IsChecked = prefs.LongRunningQueryExcludeBackups;
+ LrqExcludeMiscWaitsCheckBox.IsChecked = prefs.LongRunningQueryExcludeMiscWaits;
+ AlertExcludedDatabasesTextBox.Text = string.Join(", ", prefs.AlertExcludedDatabases);
NotifyOnTempDbSpaceCheckBox.IsChecked = prefs.NotifyOnTempDbSpace;
TempDbSpaceThresholdTextBox.Text = prefs.TempDbSpaceThresholdPercent.ToString(CultureInfo.InvariantCulture);
NotifyOnLongRunningJobsCheckBox.IsChecked = prefs.NotifyOnLongRunningJobs;
LongRunningJobMultiplierTextBox.Text = prefs.LongRunningJobMultiplier.ToString(CultureInfo.InvariantCulture);
+ AlertCooldownTextBox.Text = prefs.AlertCooldownMinutes.ToString(CultureInfo.InvariantCulture);
+ EmailCooldownTextBox.Text = prefs.EmailCooldownMinutes.ToString(CultureInfo.InvariantCulture);
UpdateNotificationCheckboxStates();
@@ -306,9 +319,19 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
LongRunningQueryThresholdTextBox.Text = "30";
TempDbSpaceThresholdTextBox.Text = "80";
LongRunningJobMultiplierTextBox.Text = "3";
+ AlertCooldownTextBox.Text = "5";
+ EmailCooldownTextBox.Text = "15";
+ AlertExcludedDatabasesTextBox.Text = "";
UpdateAlertPreviewText();
}
+ private void ManageMuteRulesButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_muteRuleService == null) return;
+ var window = new ManageMuteRulesWindow(_muteRuleService) { Owner = this };
+ window.ShowDialog();
+ }
+
private void UpdateAlertPreviewText()
{
var parts = new System.Collections.Generic.List();
@@ -463,6 +486,20 @@ private void McpEnabledCheckBox_Changed(object sender, RoutedEventArgs e)
McpPortTextBox.IsEnabled = McpEnabledCheckBox.IsChecked == true;
}
+ private async void AutoPortButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ int port = await PortUtilityService.GetFreeTcpPortAsync();
+ McpPortTextBox.Text = port.ToString();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Could not find an available port: {ex.Message}",
+ "Error", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
private void UpdateMcpStatus(Models.UserPreferences prefs)
{
if (prefs.McpEnabled)
@@ -486,7 +523,7 @@ private void CopyMcpCommandButton_Click(object sender, RoutedEventArgs e)
McpStatusText.Text = "Copied to clipboard!";
}
- private void OkButton_Click(object sender, RoutedEventArgs e)
+ private async void OkButton_Click(object sender, RoutedEventArgs e)
{
var prefs = _preferencesService.GetPreferences();
@@ -581,6 +618,25 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
else if (prefs.NotifyOnLongRunningQueries)
validationErrors.Add("Long-running query threshold must be a positive number");
+ if (int.TryParse(LongRunningQueryMaxResultsTextBox.Text, out int lrqMaxResults) && lrqMaxResults >= 1 && lrqMaxResults <= int.MaxValue)
+ {
+ prefs.LongRunningQueryMaxResults = lrqMaxResults;
+ }
+ else
+ {
+ validationErrors.Add($"Long-running query max results must be between 1 and {int.MaxValue}");
+ }
+
+ prefs.LongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true;
+ prefs.LongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true;
+ prefs.LongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true;
+ prefs.LongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true;
+ prefs.AlertExcludedDatabases = AlertExcludedDatabasesTextBox.Text
+ .Split(',')
+ .Select(s => s.Trim())
+ .Where(s => s.Length > 0)
+ .ToList();
+
prefs.NotifyOnTempDbSpace = NotifyOnTempDbSpaceCheckBox.IsChecked == true;
if (int.TryParse(TempDbSpaceThresholdTextBox.Text, out int tempDbThreshold) && tempDbThreshold > 0 && tempDbThreshold <= 100)
prefs.TempDbSpaceThresholdPercent = tempDbThreshold;
@@ -593,13 +649,15 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
else if (prefs.NotifyOnLongRunningJobs)
validationErrors.Add("Job multiplier must be a positive number");
- if (validationErrors.Count > 0)
- {
- MessageBox.Show(
- "Some alert thresholds have invalid values and were not changed:\n\n" +
- string.Join("\n", validationErrors),
- "Validation", MessageBoxButton.OK, MessageBoxImage.Warning);
- }
+ if (int.TryParse(AlertCooldownTextBox.Text, out int alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120)
+ prefs.AlertCooldownMinutes = alertCooldown;
+ else
+ validationErrors.Add("Tray notification cooldown must be between 1 and 120 minutes");
+
+ if (int.TryParse(EmailCooldownTextBox.Text, out int emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120)
+ prefs.EmailCooldownMinutes = emailCooldown;
+ else
+ validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes");
// Save SMTP email settings
prefs.SmtpEnabled = SmtpEnabledCheckBox.IsChecked == true;
@@ -608,6 +666,8 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
{
prefs.SmtpPort = smtpPort;
}
+ else
+ validationErrors.Add("Smtp Port failed validation - must be a valid TCP port number.");
prefs.SmtpUseSsl = SmtpSslCheckBox.IsChecked == true;
prefs.SmtpUsername = SmtpUsernameTextBox.Text?.Trim() ?? "";
prefs.SmtpFromAddress = SmtpFromTextBox.Text?.Trim() ?? "";
@@ -619,17 +679,39 @@ private void OkButton_Click(object sender, RoutedEventArgs e)
}
// Save MCP server settings
+ bool mcpWasEnabled = prefs.McpEnabled;
prefs.McpEnabled = McpEnabledCheckBox.IsChecked == true;
- if (int.TryParse(McpPortTextBox.Text, out int mcpPort) && mcpPort > 0 && mcpPort <= 65535)
+ if (int.TryParse(McpPortTextBox.Text, out int mcpPort) && mcpPort >= 1024 && mcpPort <= IPEndPoint.MaxPort)
{
+ if (prefs.McpEnabled && (mcpPort != prefs.McpPort || !mcpWasEnabled))
+ {
+ // CanBindTcpPortAsync attempts an actual bind, which is more reliable
+ // than checking listeners (TOCTOU is still possible but less likely)
+ bool canBind = await PortUtilityService.CanBindTcpPortAsync(mcpPort, IPAddress.Loopback);
+ if (!canBind)
+ {
+ validationErrors.Add($"Port {mcpPort} is already in use. Choose a different port for the MCP server.");
+ }
+ }
prefs.McpPort = mcpPort;
}
+ else
+ validationErrors.Add($"MCP port must be between 1024 and {IPEndPoint.MaxPort}.\nPorts 0–1023 are well-known privileged ports reserved by the operating system.");
- _preferencesService.SavePreferences(prefs);
-
- _saved = true;
- DialogResult = true;
- Close();
+ if (validationErrors.Count > 0)
+ {
+ MessageBox.Show(
+ "Some settings have invalid values and were not changed:\n\n" +
+ string.Join("\n", validationErrors),
+ "Validation", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ else
+ {
+ _preferencesService.SavePreferences(prefs);
+ _saved = true;
+ DialogResult = true;
+ Close();
+ }
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs
index 71ce17c3..fd1de4e8 100644
--- a/Dashboard/TracePatternHistoryWindow.xaml.cs
+++ b/Dashboard/TracePatternHistoryWindow.xaml.cs
@@ -59,7 +59,7 @@ public TracePatternHistoryWindow(
_toDate = toDate;
// Collapse newlines/tabs to spaces and truncate for a clean single-line header
- var displayPattern = System.Text.RegularExpressions.Regex.Replace(queryPattern, @"\s+", " ").Trim();
+ var displayPattern = MultipleSpacesRegExp().Replace(queryPattern, " ").Trim();
if (displayPattern.Length > 120)
displayPattern = displayPattern.Substring(0, 120) + "...";
QueryIdentifierText.Text = $"Trace Pattern History: [{databaseName}] — {displayPattern}";
@@ -406,6 +406,9 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
}
}
+ [System.Text.RegularExpressions.GeneratedRegex(@"\s+")]
+ private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp();
+
#endregion
}
}
diff --git a/Dashboard/WaitDrillDownWindow.xaml b/Dashboard/WaitDrillDownWindow.xaml
new file mode 100644
index 00000000..640310c3
--- /dev/null
+++ b/Dashboard/WaitDrillDownWindow.xaml
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/WaitDrillDownWindow.xaml.cs b/Dashboard/WaitDrillDownWindow.xaml.cs
new file mode 100644
index 00000000..b92393c9
--- /dev/null
+++ b/Dashboard/WaitDrillDownWindow.xaml.cs
@@ -0,0 +1,521 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using Microsoft.Win32;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services;
+using static PerformanceMonitorDashboard.Helpers.WaitDrillDownHelper;
+
+namespace PerformanceMonitorDashboard;
+
+public partial class WaitDrillDownWindow : Window
+{
+ private readonly DatabaseService _databaseService;
+ private readonly string _waitType;
+ private readonly int _hoursBack;
+ private readonly DateTime? _fromDate;
+ private readonly DateTime? _toDate;
+
+ // Filter state
+ private Dictionary _filters = new();
+ private List? _unfilteredData;
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+
+ public WaitDrillDownWindow(
+ DatabaseService databaseService,
+ string waitType,
+ int hoursBack,
+ DateTime? fromDate = null,
+ DateTime? toDate = null)
+ {
+ InitializeComponent();
+ _databaseService = databaseService;
+ _waitType = waitType;
+ _hoursBack = hoursBack;
+ _fromDate = fromDate;
+ _toDate = toDate;
+
+ Title = $"Wait Drill-Down: {waitType}";
+
+ var classification = Classify(waitType);
+ HeaderText.Text = classification.Category == WaitCategory.Correlated
+ ? $"Queries active during {waitType} spike"
+ : $"Queries experiencing {waitType}";
+
+ Loaded += async (_, _) => await LoadDataAsync();
+ ThemeManager.ThemeChanged += OnThemeChanged;
+ Closed += (_, _) => ThemeManager.ThemeChanged -= OnThemeChanged;
+ }
+
+ private async System.Threading.Tasks.Task LoadDataAsync()
+ {
+ SummaryText.Text = "Loading...";
+
+ try
+ {
+ var classification = Classify(_waitType);
+ SetWarningBanner(classification);
+
+ List data;
+ if (classification.Category == WaitCategory.Correlated || classification.Category == WaitCategory.Uncapturable)
+ {
+ // Fetch ALL queries in time range (no wait type filter)
+ data = await _databaseService.GetQuerySnapshotsAsync(_hoursBack, _fromDate, _toDate);
+ }
+ else
+ {
+ data = await _databaseService.GetQuerySnapshotsByWaitTypeAsync(
+ _waitType, _hoursBack, _fromDate, _toDate);
+ }
+
+ if (data.Count == 0)
+ {
+ SummaryText.Text = classification.Category == WaitCategory.Correlated
+ ? "No query snapshots found in the selected time range."
+ : $"No query-level data found for {_waitType} in the selected time range.";
+ return;
+ }
+
+ if (classification.Category == WaitCategory.Chain)
+ {
+ LoadChainData(data, classification);
+ }
+ else
+ {
+ LoadDirectData(data, classification);
+ }
+ }
+ catch (Exception ex)
+ {
+ SummaryText.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private void LoadDirectData(List data, WaitClassification classification)
+ {
+ data = SortByProperty(data, classification.SortProperty);
+ _unfilteredData = data;
+ _filters.Clear();
+ ResultsDataGrid.ItemsSource = data;
+ UpdateFilterButtonStyles();
+
+ var timeRange = GetTimeRangeDescription(data);
+ var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
+ SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
+
+ ApplyInitialSort(classification.SortProperty);
+ }
+
+ private void LoadChainData(List data, WaitClassification classification)
+ {
+ // Map QuerySnapshotItem to SnapshotInfo for chain walker
+ var allInfos = data.Select(ToSnapshotInfo).ToList();
+
+ // For Dashboard, all returned rows already have the target wait type (filtered server-side)
+ // so they're all "waiters" — use all of them for chain walking
+ var headBlockerInfos = WalkBlockingChains(allInfos, allInfos);
+
+ if (headBlockerInfos.Count == 0)
+ {
+ // No chain found — fall back to showing direct data
+ _unfilteredData = data;
+ _filters.Clear();
+ ResultsDataGrid.ItemsSource = data;
+ UpdateFilterButtonStyles();
+ var timeRange = GetTimeRangeDescription(data);
+ SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | No blocking chains found, showing waiters";
+ return;
+ }
+
+ // Look up original full rows for each head blocker and set chain metadata
+ var snapshotLookup = data
+ .GroupBy(s => (s.CollectionTime, (int)s.SessionId))
+ .ToDictionary(g => g.Key, g => g.First());
+
+ var headBlockerRows = new List();
+ foreach (var hb in headBlockerInfos)
+ {
+ if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row))
+ {
+ row.ChainBlockingPath = hb.BlockingPath;
+ // Overwrite BlockedSessionCount with chain walker's count
+ row.BlockedSessionCount = (short)Math.Min(hb.BlockedSessionCount, short.MaxValue);
+ headBlockerRows.Add(row);
+ }
+ }
+
+ if (headBlockerRows.Count == 0)
+ {
+ // Head blockers not in data — show original data
+ _unfilteredData = data;
+ _filters.Clear();
+ ResultsDataGrid.ItemsSource = data;
+ UpdateFilterButtonStyles();
+ var timeRange = GetTimeRangeDescription(data);
+ SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange} | Head blockers not in snapshots, showing waiters";
+ return;
+ }
+
+ // Insert chain-specific columns into the existing XAML columns
+ InsertChainColumns();
+
+ _unfilteredData = headBlockerRows;
+ _filters.Clear();
+ ResultsDataGrid.ItemsSource = headBlockerRows;
+ UpdateFilterButtonStyles();
+
+ var timeRangeDesc = GetTimeRangeDescription(headBlockerRows);
+ SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {data.Count} waiting session(s) | " +
+ $"{classification.Description} | {timeRangeDesc}";
+ }
+
+ private void InsertChainColumns()
+ {
+ // Insert "Blocking Path" column at the beginning — BlockedSessionCount already exists in the XAML columns
+ var blockingPathCol = CreateFilterColumn("Blocking Path", "ChainBlockingPath", 250);
+ ResultsDataGrid.Columns.Insert(0, blockingPathCol);
+ }
+
+ private DataGridTextColumn CreateFilterColumn(string headerText, string bindingPath, int width,
+ bool isNumeric = false, string? stringFormat = null)
+ {
+ var filterButton = new Button { Tag = bindingPath, Margin = new Thickness(0, 0, 4, 0) };
+ filterButton.SetResourceReference(StyleProperty, "ColumnFilterButtonStyle");
+ filterButton.Click += Filter_Click;
+
+ var header = new StackPanel { Orientation = Orientation.Horizontal };
+ header.Children.Add(filterButton);
+ header.Children.Add(new System.Windows.Controls.TextBlock
+ {
+ Text = headerText,
+ FontWeight = FontWeights.Bold,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+
+ var binding = new System.Windows.Data.Binding(bindingPath);
+ if (stringFormat != null) binding.StringFormat = stringFormat;
+
+ var column = new DataGridTextColumn
+ {
+ Header = header,
+ Binding = binding,
+ Width = new DataGridLength(width)
+ };
+
+ if (isNumeric)
+ {
+ var numericStyle = (Style?)FindResource("NumericCell");
+ if (numericStyle != null) column.ElementStyle = numericStyle;
+ }
+
+ return column;
+ }
+
+ private void SetWarningBanner(WaitClassification classification)
+ {
+ if (classification.Category == WaitCategory.Uncapturable)
+ {
+ WarningText.Text = $"Sessions experiencing {_waitType} waits may not be captured in query snapshots " +
+ "because they may lack assigned worker threads. Showing all queries in this time range.";
+ WarningBanner.Visibility = Visibility.Visible;
+ }
+ else if (classification.Category == WaitCategory.Correlated)
+ {
+ WarningText.Text = $"{_waitType} waits are too brief to appear in query snapshots. " +
+ "Showing all queries active during this period, sorted by the most correlated metric.";
+ WarningBanner.Visibility = Visibility.Visible;
+ WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
+ WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
+ WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
+ }
+ else if (classification.Category == WaitCategory.Chain)
+ {
+ WarningText.Text = $"Showing head blockers (the cause of {_waitType} waits), not the waiting sessions themselves.";
+ WarningBanner.Visibility = Visibility.Visible;
+ WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
+ WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
+ WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
+ }
+ }
+
+ private static SnapshotInfo ToSnapshotInfo(QuerySnapshotItem item) => new()
+ {
+ SessionId = item.SessionId,
+ BlockingSessionId = item.BlockingSessionId ?? 0,
+ CollectionTime = item.CollectionTime,
+ DatabaseName = item.DatabaseName ?? "",
+ Status = item.Status ?? "",
+ QueryText = item.SqlText ?? "",
+ WaitType = item.WaitInfo,
+ WaitTimeMs = 0, // Dashboard wait_info is formatted text, no separate ms column
+ CpuTimeMs = item.Cpu ?? 0,
+ Reads = item.Reads ?? 0,
+ Writes = item.Writes ?? 0,
+ LogicalReads = 0 // Not available in Dashboard snapshot model
+ };
+
+ private static List SortByProperty(List data, string property) =>
+ property switch
+ {
+ "CpuTimeMs" => data.OrderByDescending(r => r.Cpu ?? 0).ToList(),
+ "Reads" => data.OrderByDescending(r => r.Reads ?? 0).ToList(),
+ "Writes" => data.OrderByDescending(r => r.Writes ?? 0).ToList(),
+ "Dop" => data, // Dashboard snapshots don't have a DOP column
+ "GrantedQueryMemoryGb" => data.OrderByDescending(r => r.UsedMemoryMb ?? 0).ToList(),
+ "WaitTimeMs" => data, // wait_info is text, can't sort numerically
+ _ => data
+ };
+
+ private void ApplyInitialSort(string property)
+ {
+ var columnHeader = property switch
+ {
+ "CpuTimeMs" => "CPU (ms)",
+ "Reads" => "Reads (pages)",
+ "Writes" => "Writes (pages)",
+ "GrantedQueryMemoryGb" => "Used Mem (MB)",
+ _ => null
+ };
+
+ if (columnHeader == null) return;
+
+ foreach (var column in ResultsDataGrid.Columns)
+ {
+ if (column.Header is StackPanel sp)
+ {
+ var textBlock = sp.Children.OfType().FirstOrDefault();
+ if (textBlock?.Text == columnHeader)
+ {
+ column.SortDirection = ListSortDirection.Descending;
+ break;
+ }
+ }
+ }
+ }
+
+ private static string GetTimeRangeDescription(List data)
+ {
+ if (data.Count == 0) return "";
+ var first = data.Min(r => r.CollectionTime);
+ var last = data.Max(r => r.CollectionTime);
+ return $"{ServerTimeHelper.ConvertForDisplay(first, ServerTimeHelper.CurrentDisplayMode):MM/dd HH:mm} to " +
+ $"{ServerTimeHelper.ConvertForDisplay(last, ServerTimeHelper.CurrentDisplayMode):MM/dd HH:mm}";
+ }
+
+ private void OnThemeChanged(string _)
+ {
+ UpdateFilterButtonStyles();
+ }
+
+ #region Column Filter Popup
+
+ private void Filter_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnName) return;
+
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+
+ _filters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent!.Initialize(columnName, existingFilter);
+
+ _filterPopup.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null) _filterPopup.IsOpen = false;
+
+ if (e.FilterState.IsActive)
+ _filters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _filters.Remove(e.FilterState.ColumnName);
+
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null) _filterPopup.IsOpen = false;
+ }
+
+ private void ApplyFilters()
+ {
+ if (_unfilteredData == null) return;
+
+ if (_filters.Count == 0)
+ {
+ ResultsDataGrid.ItemsSource = _unfilteredData;
+ return;
+ }
+
+ var filtered = _unfilteredData.Where(item =>
+ {
+ foreach (var filter in _filters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
+ return false;
+ }
+ return true;
+ }).ToList();
+
+ ResultsDataGrid.ItemsSource = filtered;
+ }
+
+ private void UpdateFilterButtonStyles()
+ {
+ foreach (var column in ResultsDataGrid.Columns)
+ {
+ if (column.Header is StackPanel stackPanel)
+ {
+ var filterButton = stackPanel.Children.OfType().FirstOrDefault();
+ if (filterButton?.Tag is string columnName)
+ {
+ bool hasActive = _filters.TryGetValue(columnName, out var filter) && filter.IsActive;
+ filterButton.Content = new System.Windows.Controls.TextBlock
+ {
+ Text = "\uE71C",
+ FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
+ Foreground = hasActive
+ ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xD7, 0x00))
+ : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xFF, 0xFF, 0xFF))
+ };
+ filterButton.ToolTip = hasActive && filter != null
+ ? $"Filter: {filter.DisplayText}\n(Click to modify)"
+ : "Click to filter";
+ }
+ }
+ }
+ }
+
+ #endregion
+
+ #region Context Menu Handlers
+
+ private void CopyCell_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.CurrentCell.Item != null)
+ {
+ var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell);
+ if (!string.IsNullOrEmpty(cellContent))
+ Clipboard.SetDataObject(cellContent, false);
+ }
+ }
+ }
+
+ private void CopyRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid?.SelectedItem != null)
+ Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false);
+ }
+ }
+
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var sb = new StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ {
+ if (column is DataGridBoundColumn)
+ headers.Add(DataGridClipboardBehavior.GetHeaderText(column));
+ }
+ sb.AppendLine(string.Join("\t", headers));
+ foreach (var item in dataGrid.Items)
+ sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item));
+ Clipboard.SetDataObject(sb.ToString(), false);
+ }
+ }
+ }
+
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu)
+ {
+ var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu);
+ if (dataGrid != null && dataGrid.Items.Count > 0)
+ {
+ var dialog = new SaveFileDialog
+ {
+ FileName = $"wait_drill_down_{_waitType}_{DateTime.Now:yyyyMMdd_HHmmss}.csv",
+ DefaultExt = ".csv",
+ Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*"
+ };
+
+ if (dialog.ShowDialog() == true)
+ {
+ try
+ {
+ var sb = new StringBuilder();
+ var headers = new List();
+ foreach (var column in dataGrid.Columns)
+ {
+ if (column is DataGridBoundColumn)
+ headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column)));
+ }
+ sb.AppendLine(string.Join(",", headers));
+ foreach (var item in dataGrid.Items)
+ {
+ var values = TabHelpers.GetRowValues(dataGrid, item);
+ sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v))));
+ }
+ System.IO.File.WriteAllText(dialog.FileName, sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Export failed: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+ }
+ }
+ }
+
+ #endregion
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj
index eef02bc1..c69063ee 100644
--- a/Installer/PerformanceMonitorInstaller.csproj
+++ b/Installer/PerformanceMonitorInstaller.csproj
@@ -20,10 +20,10 @@
PerformanceMonitorInstaller
SQL Server Performance Monitor Installer
- 2.1.0
- 2.1.0.0
- 2.1.0.0
- 2.1.0
+ 2.2.0
+ 2.2.0.0
+ 2.2.0.0
+ 2.2.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 d16303ae..74710e41 100644
--- a/Installer/Program.cs
+++ b/Installer/Program.cs
@@ -19,14 +19,75 @@
namespace PerformanceMonitorInstaller
{
- class Program
+ partial class Program
{
+ ///
+ /// Complete uninstall SQL: stops traces, deletes all 3 Agent jobs,
+ /// drops both XE sessions, and drops the database.
+ ///
+ private const string UninstallSql = @"
+/*
+Remove SQL Agent jobs
+*/
+USE msdb;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1;
+ PRINT 'Deleted job: PerformanceMonitor - Collection';
+END;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1;
+ PRINT 'Deleted job: PerformanceMonitor - Data Retention';
+END;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1;
+ PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor';
+END;
+
+/*
+Drop Extended Events sessions
+*/
+USE master;
+
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+ ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;
+ PRINT 'Dropped XE session: PerformanceMonitor_BlockedProcess';
+END;
+
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+ ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;
+ PRINT 'Dropped XE session: PerformanceMonitor_Deadlock';
+END;
+
+/*
+Drop the database
+*/
+IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
+BEGIN
+ ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE PerformanceMonitor;
+ PRINT 'PerformanceMonitor database dropped';
+END
+ELSE
+BEGIN
+ PRINT 'PerformanceMonitor database does not exist';
+END;";
+
/*
Pre-compiled regex patterns for performance
*/
- private static readonly Regex GoBatchPattern = new Regex(
- @"^\s*GO\s*(?:--[^\r\n]*)?\s*$",
- RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
+ private static readonly Regex GoBatchPattern = GoBatchRegExp();
private static readonly Regex SqlFileNamePattern = new Regex(
@"^\d{2}[a-z]?_.*\.sql$",
@@ -55,6 +116,7 @@ private static class ExitCodes
public const int PartialInstallation = 4;
public const int VersionCheckFailed = 5;
public const int SqlFilesNotFound = 6;
+ public const int UninstallFailed = 7;
}
static async Task Main(string[] args)
@@ -90,13 +152,16 @@ static async Task Main(string[] args)
Console.WriteLine(" PerformanceMonitorInstaller.exe [options] Windows Auth");
Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth");
Console.WriteLine(" PerformanceMonitorInstaller.exe SQL Auth (password via env var)");
+ Console.WriteLine(" PerformanceMonitorInstaller.exe --entra Entra ID (MFA)");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" -h, --help Show this help message");
Console.WriteLine(" --reinstall Drop existing database and perform clean install");
+ Console.WriteLine(" --uninstall Remove database, Agent jobs, and XE sessions");
Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults");
Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict");
Console.WriteLine(" --trust-cert Trust server certificate without validation");
+ Console.WriteLine(" --entra Use Microsoft Entra ID interactive authentication (MFA)");
Console.WriteLine();
Console.WriteLine("Environment Variables:");
Console.WriteLine(" PM_SQL_PASSWORD SQL Auth password (avoids passing on command line)");
@@ -109,13 +174,27 @@ static async Task Main(string[] args)
Console.WriteLine(" 4 Partial installation (non-critical failures)");
Console.WriteLine(" 5 Version check failed");
Console.WriteLine(" 6 SQL files not found");
+ Console.WriteLine(" 7 Uninstall failed");
return 0;
}
bool automatedMode = args.Length > 0;
bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase));
+ bool uninstallMode = args.Any(a => a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase));
bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase));
bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase));
+ bool entraMode = args.Any(a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
+
+ /*Parse --entra email (the argument following --entra)*/
+ string? entraEmail = null;
+ if (entraMode)
+ {
+ int entraIndex = Array.FindIndex(args, a => a.Equals("--entra", StringComparison.OrdinalIgnoreCase));
+ if (entraIndex >= 0 && entraIndex + 1 < args.Length && !args[entraIndex + 1].StartsWith("--", StringComparison.Ordinal))
+ {
+ entraEmail = args[entraIndex + 1];
+ }
+ }
/*Parse encryption option (default: Mandatory)*/
var encryptArg = args.FirstOrDefault(a => a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase));
@@ -132,16 +211,28 @@ static async Task Main(string[] args)
}
/*Filter out option flags to get positional arguments*/
- var filteredArgs = args
+ /*Filter out option flags and --entra to get positional arguments*/
+ var filteredArgsList = args
.Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase))
+ .Where(a => !a.Equals("--uninstall", StringComparison.OrdinalIgnoreCase))
.Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase))
.Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase))
.Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase))
- .ToArray();
+ .Where(a => !a.Equals("--entra", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ /*Remove the entra email from positional args if present*/
+ if (entraEmail != null)
+ {
+ filteredArgsList.Remove(entraEmail);
+ }
+
+ var filteredArgs = filteredArgsList.ToArray();
string? serverName;
string? username = null;
string? password = null;
bool useWindowsAuth;
+ bool useEntraAuth = false;
if (automatedMode)
{
@@ -150,7 +241,25 @@ Automated mode with command-line arguments
*/
serverName = filteredArgs.Length > 0 ? filteredArgs[0] : null;
- if (filteredArgs.Length >= 2)
+ if (entraMode)
+ {
+ /*Microsoft Entra ID interactive authentication*/
+ useWindowsAuth = false;
+ useEntraAuth = true;
+ username = entraEmail;
+
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ Console.WriteLine("Error: Email address is required for Entra ID authentication.");
+ Console.WriteLine("Usage: PerformanceMonitorInstaller.exe --entra ");
+ return ExitCodes.InvalidArguments;
+ }
+
+ Console.WriteLine($"Server: {serverName}");
+ Console.WriteLine($"Authentication: Microsoft Entra ID ({username})");
+ Console.WriteLine("A browser window will open for interactive authentication...");
+ }
+ else if (filteredArgs.Length >= 2)
{
/*SQL Authentication - password from env var or command-line*/
useWindowsAuth = false;
@@ -219,13 +328,37 @@ Automated mode with command-line arguments
return ExitCodes.InvalidArguments;
}
- Console.Write("Use Windows Authentication? (Y/N, default Y): ");
- string? authResponse = Console.ReadLine();
- useWindowsAuth = string.IsNullOrWhiteSpace(authResponse) ||
- authResponse.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase);
+ Console.WriteLine("Authentication type:");
+ Console.WriteLine(" [W] Windows Authentication (default)");
+ Console.WriteLine(" [S] SQL Server Authentication");
+ Console.WriteLine(" [E] Microsoft Entra ID (interactive MFA)");
+ Console.Write("Choice (W/S/E, default W): ");
+ string? authResponse = Console.ReadLine()?.Trim();
- if (!useWindowsAuth)
+ if (string.IsNullOrWhiteSpace(authResponse) || authResponse.Equals("W", StringComparison.OrdinalIgnoreCase))
{
+ useWindowsAuth = true;
+ }
+ else if (authResponse.Equals("E", StringComparison.OrdinalIgnoreCase))
+ {
+ useWindowsAuth = false;
+ useEntraAuth = true;
+
+ Console.Write("Email address (UPN): ");
+ username = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ Console.WriteLine("Error: Email address is required for Entra ID authentication.");
+ WaitForExit();
+ return ExitCodes.InvalidArguments;
+ }
+
+ Console.WriteLine("A browser window will open for interactive authentication...");
+ }
+ else
+ {
+ useWindowsAuth = false;
+
Console.Write("SQL Server login: ");
username = Console.ReadLine();
if (string.IsNullOrWhiteSpace(username))
@@ -256,11 +389,19 @@ Build connection string
DataSource = serverName,
InitialCatalog = "master",
Encrypt = encryptOption,
- TrustServerCertificate = trustCert,
- IntegratedSecurity = useWindowsAuth
+ TrustServerCertificate = trustCert
};
- if (!useWindowsAuth)
+ if (useEntraAuth)
+ {
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+ builder.UserID = username;
+ }
+ else if (useWindowsAuth)
+ {
+ builder.IntegratedSecurity = true;
+ }
+ else
{
builder.UserID = username;
builder.Password = password;
@@ -310,6 +451,14 @@ Test connection and get SQL Server version
return ExitCodes.ConnectionFailed;
}
+ /*
+ Handle --uninstall mode (no SQL files needed)
+ */
+ if (uninstallMode)
+ {
+ return await PerformUninstallAsync(builder.ConnectionString, automatedMode);
+ }
+
/*
Find SQL files to execute (do this once before the installation loop)
Search current directory and up to 5 parent directories
@@ -468,41 +617,7 @@ Traces are server-level and persist after database drops
/*Database or procedure doesn't exist - no traces to clean*/
}
- string cleanupSql = @"
-/*
-Remove any existing Agent jobs
-*/
-USE msdb;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection';
- PRINT 'Dropped PerformanceMonitor - Collection job';
-END;
-
-IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
-BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention';
- PRINT 'Dropped PerformanceMonitor - Data Retention job';
-END;
-
-/*
-Drop the database
-*/
-USE master;
-
-IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
-BEGIN
- ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
- DROP DATABASE PerformanceMonitor;
- PRINT 'PerformanceMonitor database dropped successfully';
-END
-ELSE
-BEGIN
- PRINT 'PerformanceMonitor database does not exist';
-END";
-
- using (var command = new SqlCommand(cleanupSql, connection))
+ using (var command = new SqlCommand(UninstallSql, connection))
{
command.CommandTimeout = ShortTimeoutSeconds;
await command.ExecuteNonQueryAsync().ConfigureAwait(false);
@@ -1088,6 +1203,86 @@ Log installation history to database
Tracks version, duration, success/failure, and upgrade detection
*/
+ ///
+ /// Performs a complete uninstall: stops traces, removes jobs, XE sessions, and database.
+ ///
+ private static async Task PerformUninstallAsync(string connectionString, bool automatedMode)
+ {
+ Console.WriteLine();
+ Console.WriteLine("================================================================================");
+ Console.WriteLine("UNINSTALL MODE");
+ Console.WriteLine("================================================================================");
+ Console.WriteLine();
+
+ if (!automatedMode)
+ {
+ Console.WriteLine("This will remove:");
+ Console.WriteLine(" - SQL Agent jobs (Collection, Data Retention, Hung Job Monitor)");
+ Console.WriteLine(" - Extended Events sessions (BlockedProcess, Deadlock)");
+ Console.WriteLine(" - Server-side traces");
+ Console.WriteLine(" - PerformanceMonitor database and ALL collected data");
+ Console.WriteLine();
+ Console.Write("Are you sure you want to continue? (Y/N, default N): ");
+ string? confirm = Console.ReadLine();
+ if (!confirm?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? true)
+ {
+ Console.WriteLine("Uninstall cancelled.");
+ WaitForExit();
+ return ExitCodes.Success;
+ }
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Uninstalling Performance Monitor...");
+
+ try
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync().ConfigureAwait(false);
+
+ /*Stop traces first (procedure lives in the database)*/
+ try
+ {
+ using var traceCmd = new SqlCommand(
+ "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
+ connection);
+ traceCmd.CommandTimeout = ShortTimeoutSeconds;
+ await traceCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ Console.WriteLine("✓ Stopped server-side traces");
+ }
+ catch (SqlException)
+ {
+ Console.WriteLine(" No traces to stop (database or procedure not found)");
+ }
+
+ /*Remove jobs, XE sessions, and database*/
+ using var command = new SqlCommand(UninstallSql, connection);
+ command.CommandTimeout = ShortTimeoutSeconds;
+ await command.ExecuteNonQueryAsync().ConfigureAwait(false);
+
+ Console.WriteLine();
+ Console.WriteLine("✓ Uninstall completed successfully");
+ Console.WriteLine();
+ Console.WriteLine("Note: blocked process threshold (s) was NOT reset.");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Uninstall failed: {ex.Message}");
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return ExitCodes.UninstallFailed;
+ }
+
+ if (!automatedMode)
+ {
+ WaitForExit();
+ }
+ return ExitCodes.Success;
+ }
+
/*
Get currently installed version from database
Returns null if not installed (database or table doesn't exist)
@@ -1800,5 +1995,8 @@ Write file
return reportPath;
}
+
+ [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 5a1fbd4f..237ad822 100644
--- a/InstallerGui/InstallerGui.csproj
+++ b/InstallerGui/InstallerGui.csproj
@@ -8,10 +8,10 @@
PerformanceMonitorInstallerGui
PerformanceMonitorInstallerGui
SQL Server Performance Monitor Installer
- 2.1.0
- 2.1.0.0
- 2.1.0.0
- 2.1.0
+ 2.2.0
+ 2.2.0.0
+ 2.2.0.0
+ 2.2.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
diff --git a/InstallerGui/MainWindow.xaml b/InstallerGui/MainWindow.xaml
index 61b4046f..1e428e38 100644
--- a/InstallerGui/MainWindow.xaml
+++ b/InstallerGui/MainWindow.xaml
@@ -81,6 +81,12 @@
Content="SQL Server Authentication"
GroupName="Auth"
Checked="AuthType_Changed"
+ Margin="0,0,20,0"
+ Foreground="{DynamicResource ForegroundBrush}"/>
+
@@ -258,6 +264,15 @@
Margin="0,0,10,0"
Click="ViewReport_Click"
IsEnabled="False"/>
+
@@ -189,10 +192,20 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
}
bool useWindowsAuth = WindowsAuthRadio.IsChecked == true;
- string? username = useWindowsAuth ? null : UsernameTextBox.Text.Trim();
- string? password = useWindowsAuth ? null : PasswordBox.Password;
+ bool useEntraAuth = EntraAuthRadio.IsChecked == true;
+ string? username = (useWindowsAuth) ? null : UsernameTextBox.Text.Trim();
+ string? password = (useWindowsAuth || useEntraAuth) ? null : PasswordBox.Password;
- if (!useWindowsAuth)
+ if (useEntraAuth)
+ {
+ if (string.IsNullOrEmpty(username))
+ {
+ MessageBox.Show(this, "Please enter an email address for Entra ID authentication.",
+ "Validation Error", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ }
+ else if (!useWindowsAuth)
{
if (string.IsNullOrEmpty(username))
{
@@ -220,7 +233,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
Logger.LogToFile("TestConnection_Click", $"Encryption: {encryption}, TrustCert: {trustCertificate}");
- _connectionString = InstallationService.BuildConnectionString(server, useWindowsAuth, username, password, encryption, trustCertificate);
+ _connectionString = InstallationService.BuildConnectionString(server, useWindowsAuth, username, password, encryption, trustCertificate, useEntraAuth);
Logger.LogToFile("TestConnection_Click", "Connection string built, clearing log");
@@ -283,6 +296,7 @@ private async void TestConnection_Click(object sender, RoutedEventArgs e)
}
InstallButton.IsEnabled = _sqlFiles != null && _sqlFiles.Count > 0;
+ UninstallButton.IsEnabled = _installedVersion != null;
/*Show confirmation MessageBox*/
string installedVersionText = _installedVersion != null
@@ -416,6 +430,25 @@ await _installationService.InstallDependenciesAsync(
},
cancellationToken);
+ /*
+ Log installation history to database
+ */
+ try
+ {
+ await InstallationService.LogInstallationHistoryAsync(
+ _connectionString,
+ AppAssemblyVersion,
+ AppVersion,
+ _installationResult.StartTime,
+ _installationResult.FilesSucceeded,
+ _installationResult.FilesFailed,
+ _installationResult.Success);
+ }
+ catch (Exception ex)
+ {
+ LogMessage($"Warning: Could not log installation history: {ex.Message}", "Warning");
+ }
+
/*
Run validation if requested
*/
@@ -527,6 +560,98 @@ dialog cannot block SetUIState(false) in the finally block.
}
}
+ ///
+ /// Uninstall button click
+ ///
+ private async void Uninstall_Click(object sender, RoutedEventArgs e)
+ {
+ if (_connectionString == null || _installedVersion == null)
+ {
+ MessageBox.Show(this, "No installation detected.", "Info",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var result = MessageBox.Show(this,
+ "WARNING: This will permanently remove the PerformanceMonitor database,\n" +
+ "all SQL Agent jobs, Extended Events sessions, and ALL collected data.\n\n" +
+ "This action CANNOT be undone!\n\n" +
+ $"Installed version: {_installedVersion}\n\n" +
+ "Are you sure you want to continue?",
+ "Confirm Uninstall",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning,
+ MessageBoxResult.No);
+
+ if (result != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = new CancellationTokenSource();
+ var cancellationToken = _cancellationTokenSource.Token;
+
+ SetUIState(installing: true);
+ ClearLog();
+
+ LogMessage($"Performance Monitor Uninstaller v{AppVersion}", "Info");
+ LogMessage("", "Info");
+
+ var progress = new Progress(ReportProgress);
+
+ try
+ {
+ bool success = await InstallationService.ExecuteUninstallAsync(
+ _connectionString,
+ progress,
+ cancellationToken);
+
+ if (success)
+ {
+ LogMessage("", "Info");
+ LogMessage("================================================================================", "Info");
+ LogMessage("Uninstall completed successfully!", "Success");
+ LogMessage("================================================================================", "Info");
+ LogMessage("", "Info");
+ LogMessage("Note: blocked process threshold (s) was NOT reset.", "Info");
+
+ _installedVersion = null;
+ ProgressBar.Value = 100;
+ ProgressText.Text = "100%";
+
+ MessageBox.Show(this,
+ "Uninstall completed successfully!\n\n" +
+ "Database, Agent jobs, and XE sessions have been removed.",
+ "Uninstall Complete",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ LogMessage("", "Info");
+ LogMessage("Uninstall cancelled by user.", "Warning");
+ }
+ catch (Exception ex)
+ {
+ LogMessage("", "Info");
+ LogMessage($"Uninstall failed: {ex.Message}", "Error");
+
+ MessageBox.Show(this,
+ $"Uninstall failed:\n\n{ex.Message}",
+ "Uninstall Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ finally
+ {
+ SetUIState(installing: false);
+ _cancellationTokenSource?.Dispose();
+ _cancellationTokenSource = null;
+ }
+ }
+
///
/// Troubleshoot button click - runs 99_troubleshooting.sql
///
@@ -690,7 +815,8 @@ private void SetUIState(bool installing)
ServerTextBox.IsEnabled = !installing;
WindowsAuthRadio.IsEnabled = !installing;
SqlAuthRadio.IsEnabled = !installing;
- UsernameTextBox.IsEnabled = !installing && SqlAuthRadio.IsChecked == true;
+ EntraAuthRadio.IsEnabled = !installing;
+ UsernameTextBox.IsEnabled = !installing && (SqlAuthRadio.IsChecked == true || EntraAuthRadio.IsChecked == true);
PasswordBox.IsEnabled = !installing && SqlAuthRadio.IsChecked == true;
TestConnectionButton.IsEnabled = !installing;
CleanInstallCheckBox.IsEnabled = !installing;
@@ -701,6 +827,7 @@ private void SetUIState(bool installing)
TroubleshootButton.IsEnabled = !installing && _installationResult?.Success == true;
ViewReportButton.IsEnabled = !installing && _installationResult?.ReportPath != null;
+ UninstallButton.IsEnabled = !installing && _installedVersion != null;
if (!installing)
{
diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs
index 2935702e..e95c07d0 100644
--- a/InstallerGui/Services/InstallationService.cs
+++ b/InstallerGui/Services/InstallationService.cs
@@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
+using System.Data;
using System.IO;
using System.Linq;
using System.Net.Http;
@@ -61,7 +62,7 @@ public class InstallationResult
///
/// Service for installing the Performance Monitor database
///
- public class InstallationService : IDisposable
+ public partial class InstallationService : IDisposable
{
private readonly HttpClient _httpClient;
private bool _disposed;
@@ -69,9 +70,7 @@ public class InstallationService : IDisposable
/*
Compiled regex patterns for better performance
*/
- private static readonly Regex SqlFilePattern = new(
- @"^\d{2}[a-z]?_.*\.sql$",
- RegexOptions.Compiled);
+ private static readonly Regex SqlFilePattern = SqlFileRegExp();
private static readonly Regex SqlCmdDirectivePattern = new(
@"^:r\s+.*$",
@@ -100,14 +99,14 @@ public static string BuildConnectionString(
string? username = null,
string? password = null,
string encryption = "Mandatory",
- bool trustCertificate = false)
+ bool trustCertificate = false,
+ bool useEntraAuth = false)
{
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = "master",
- TrustServerCertificate = trustCertificate,
- IntegratedSecurity = useWindowsAuth
+ TrustServerCertificate = trustCertificate
};
/*Set encryption mode: Optional, Mandatory, or Strict*/
@@ -118,7 +117,16 @@ public static string BuildConnectionString(
_ => SqlConnectionEncryptOption.Mandatory
};
- if (!useWindowsAuth)
+ if (useEntraAuth)
+ {
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+ builder.UserID = username;
+ }
+ else if (useWindowsAuth)
+ {
+ builder.IntegratedSecurity = true;
+ }
+ else
{
builder.UserID = username;
builder.Password = password;
@@ -283,23 +291,42 @@ Stop any existing traces before dropping database
}
/*
- Remove Agent jobs and database
+ Remove Agent jobs, XE sessions, and database
*/
string cleanupSql = @"
USE msdb;
IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Collection')
BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection';
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Collection', @delete_unused_schedule = 1;
END;
IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Data Retention')
BEGIN
- EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention';
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Data Retention', @delete_unused_schedule = 1;
+END;
+
+IF EXISTS (SELECT 1 FROM msdb.dbo.sysjobs WHERE name = N'PerformanceMonitor - Hung Job Monitor')
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job @job_name = N'PerformanceMonitor - Hung Job Monitor', @delete_unused_schedule = 1;
END;
USE master;
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_BlockedProcess')
+ ALTER EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_BlockedProcess] ON SERVER;
+END;
+
+IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+BEGIN
+ IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = N'PerformanceMonitor_Deadlock')
+ ALTER EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER STATE = STOP;
+ DROP EVENT SESSION [PerformanceMonitor_Deadlock] ON SERVER;
+END;
+
IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
BEGIN
ALTER DATABASE PerformanceMonitor SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
@@ -312,11 +339,70 @@ IF EXISTS (SELECT 1 FROM sys.databases WHERE name = N'PerformanceMonitor')
progress?.Report(new InstallationProgress
{
- Message = "Clean install completed (jobs and database removed)",
+ Message = "Clean install completed (jobs, XE sessions, and database removed)",
Status = "Success"
});
}
+ ///
+ /// Perform complete uninstall (remove database, jobs, XE sessions, and traces)
+ ///
+ public static async Task ExecuteUninstallAsync(
+ string connectionString,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Uninstalling Performance Monitor...",
+ Status = "Info"
+ });
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ /*
+ Stop existing traces before dropping database
+ */
+ try
+ {
+ using var traceCmd = new SqlCommand(
+ "EXECUTE PerformanceMonitor.collect.trace_management_collector @action = 'STOP';",
+ connection);
+ traceCmd.CommandTimeout = 60;
+ await traceCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Stopped server-side traces",
+ Status = "Success"
+ });
+ }
+ catch (SqlException)
+ {
+ progress?.Report(new InstallationProgress
+ {
+ Message = "No traces to stop (database or procedure not found)",
+ Status = "Info"
+ });
+ }
+
+ /*
+ Remove Agent jobs, XE sessions, and database
+ */
+ await CleanInstallAsync(connectionString, progress, cancellationToken)
+ .ConfigureAwait(false);
+
+ progress?.Report(new InstallationProgress
+ {
+ Message = "Uninstall completed successfully",
+ Status = "Success",
+ ProgressPercent = 100
+ });
+
+ return true;
+ }
+
///
/// Execute SQL installation files
///
@@ -1318,5 +1404,118 @@ public void Dispose()
}
GC.SuppressFinalize(this);
}
+
+ ///
+ /// Log installation history to config.installation_history
+ /// Mirrors CLI installer's LogInstallationHistory method
+ ///
+ public static async Task LogInstallationHistoryAsync(
+ string connectionString,
+ string assemblyVersion,
+ string infoVersion,
+ DateTime startTime,
+ int filesExecuted,
+ int filesFailed,
+ bool isSuccess)
+ {
+ try
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync().ConfigureAwait(false);
+
+ /*Check if this is an upgrade by checking for existing installation*/
+ string? previousVersion = null;
+ string installationType = "INSTALL";
+
+ try
+ {
+ using var checkCmd = new SqlCommand(@"
+ SELECT TOP 1 installer_version
+ FROM PerformanceMonitor.config.installation_history
+ WHERE installation_status = 'SUCCESS'
+ ORDER BY installation_date DESC;", connection);
+
+ var result = await checkCmd.ExecuteScalarAsync().ConfigureAwait(false);
+ if (result != null && result != DBNull.Value)
+ {
+ previousVersion = result.ToString();
+ bool isSameVersion = Version.TryParse(previousVersion, out var prevVer)
+ && Version.TryParse(assemblyVersion, out var currVer)
+ && prevVer == currVer;
+ installationType = isSameVersion ? "REINSTALL" : "UPGRADE";
+ }
+ }
+ catch (SqlException)
+ {
+ /*Table might not exist yet on first install*/
+ }
+
+ /*Get SQL Server version info*/
+ string sqlVersion = "";
+ string sqlEdition = "";
+
+ using (var versionCmd = new SqlCommand("SELECT @@VERSION, SERVERPROPERTY('Edition');", connection))
+ using (var reader = await versionCmd.ExecuteReaderAsync().ConfigureAwait(false))
+ {
+ if (await reader.ReadAsync().ConfigureAwait(false))
+ {
+ sqlVersion = reader.GetString(0);
+ sqlEdition = reader.GetString(1);
+ }
+ }
+
+ long durationMs = (long)(DateTime.Now - startTime).TotalMilliseconds;
+ string status = isSuccess ? "SUCCESS" : (filesFailed > 0 ? "PARTIAL" : "FAILED");
+
+ var insertSql = @"
+ INSERT INTO PerformanceMonitor.config.installation_history
+ (
+ installer_version,
+ installer_info_version,
+ sql_server_version,
+ sql_server_edition,
+ installation_type,
+ previous_version,
+ installation_status,
+ files_executed,
+ files_failed,
+ installation_duration_ms
+ )
+ VALUES
+ (
+ @installer_version,
+ @installer_info_version,
+ @sql_server_version,
+ @sql_server_edition,
+ @installation_type,
+ @previous_version,
+ @installation_status,
+ @files_executed,
+ @files_failed,
+ @installation_duration_ms
+ );";
+
+ using var insertCmd = new SqlCommand(insertSql, connection);
+ insertCmd.Parameters.Add(new SqlParameter("@installer_version", SqlDbType.NVarChar, 50) { Value = assemblyVersion });
+ insertCmd.Parameters.Add(new SqlParameter("@installer_info_version", SqlDbType.NVarChar, 100) { Value = (object?)infoVersion ?? DBNull.Value });
+ insertCmd.Parameters.Add(new SqlParameter("@sql_server_version", SqlDbType.NVarChar, 500) { Value = sqlVersion });
+ insertCmd.Parameters.Add(new SqlParameter("@sql_server_edition", SqlDbType.NVarChar, 128) { Value = sqlEdition });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_type", SqlDbType.VarChar, 20) { Value = installationType });
+ insertCmd.Parameters.Add(new SqlParameter("@previous_version", SqlDbType.NVarChar, 50) { Value = (object?)previousVersion ?? DBNull.Value });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_status", SqlDbType.VarChar, 20) { Value = status });
+ insertCmd.Parameters.Add(new SqlParameter("@files_executed", SqlDbType.Int) { Value = filesExecuted });
+ insertCmd.Parameters.Add(new SqlParameter("@files_failed", SqlDbType.Int) { Value = filesFailed });
+ insertCmd.Parameters.Add(new SqlParameter("@installation_duration_ms", SqlDbType.BigInt) { Value = durationMs });
+
+ await insertCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
+ }
+ catch
+ {
+ /*Don't let history logging failure break the installation*/
+ }
+ }
+
+ [GeneratedRegex(@"^\d{2}[a-z]?_.*\.sql$")]
+ private static partial Regex SqlFileRegExp();
}
}
diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs
index a0460b36..a1b68f58 100644
--- a/Lite.Tests/DuckDbSchemaTests.cs
+++ b/Lite.Tests/DuckDbSchemaTests.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DuckDB.NET.Data;
using PerformanceMonitorLite.Database;
@@ -68,7 +69,8 @@ public async Task InitializeAsync_CreatesAllTables()
"database_scoped_config",
"trace_flags",
"running_jobs",
- "config_alert_log"
+ "config_alert_log",
+ "config_mute_rules"
};
using var connection = new DuckDBConnection($"Data Source={_dbPath}");
@@ -136,8 +138,8 @@ public void SchemaStatements_MatchTableCount()
foreach (var _ in Schema.GetAllTableStatements())
tableCount++;
- /* 24 tables from Schema (schema_version is created separately by DuckDbInitializer) */
- Assert.Equal(24, tableCount);
+ /* 28 tables from Schema (schema_version is created separately by DuckDbInitializer) */
+ Assert.Equal(28, tableCount);
}
[Fact]
@@ -157,4 +159,57 @@ public async Task InitializeAsync_CreatesIndexes()
/* We create 18 indexes */
Assert.True(indexCount >= 18, $"Expected >= 18 indexes, found {indexCount}");
}
+
+ ///
+ /// DuckDB does not support NOT NULL on ALTER TABLE ADD COLUMN.
+ /// This test scans the migration source code to prevent regressions,
+ /// including multi-line statements where ADD COLUMN and NOT NULL
+ /// appear on different lines within the same SQL statement.
+ ///
+ [Fact]
+ public void Migrations_DoNotUseNotNullOnAlterTableAddColumn()
+ {
+ var sourceFile = FindSourceFile("DuckDbInitializer.cs");
+ Assert.True(sourceFile != null, "Could not find DuckDbInitializer.cs in the Lite project tree");
+
+ var content = File.ReadAllText(sourceFile!);
+
+ // Strip line comments (// ...) and block comments (/* ... */)
+ var stripped = Regex.Replace(content, @"//[^\r\n]*", " ");
+ stripped = Regex.Replace(stripped, @"/\*.*?\*/", " ", RegexOptions.Singleline);
+
+ // Match ADD COLUMN ... NOT NULL within the same SQL statement (up to the next semicolon).
+ // RegexOptions.IgnoreCase + Singleline so . matches newlines.
+ var pattern = @"ADD\s+COLUMN\b[^;]*?\bNOT\s+NULL\b";
+ var matches = Regex.Matches(stripped, pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline);
+
+ var violations = new System.Collections.Generic.List();
+ foreach (Match m in matches)
+ {
+ // Find the line number in the original content for a useful error message
+ int lineNum = content[..m.Index].Split('\n').Length;
+ var snippet = m.Value.Replace("\r", "").Replace("\n", " ");
+ if (snippet.Length > 120) snippet = snippet[..120] + "...";
+ violations.Add($"Line ~{lineNum}: {snippet}");
+ }
+
+ Assert.True(violations.Count == 0,
+ "DuckDB does not support NOT NULL on ALTER TABLE ADD COLUMN. " +
+ "Use a nullable column with DEFAULT instead.\n\nViolations:\n" +
+ string.Join("\n", violations));
+ }
+
+ private static string? FindSourceFile(string fileName)
+ {
+ var dir = AppContext.BaseDirectory;
+ for (int i = 0; i < 8; i++)
+ {
+ var candidate = Path.Combine(dir, "Lite", "Database", fileName);
+ if (File.Exists(candidate)) return candidate;
+ var parent = Directory.GetParent(dir);
+ if (parent == null) break;
+ dir = parent.FullName;
+ }
+ return null;
+ }
}
diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj
index 86268874..874fd31f 100644
--- a/Lite.Tests/Lite.Tests.csproj
+++ b/Lite.Tests/Lite.Tests.csproj
@@ -9,7 +9,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index e775a91e..1631db19 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -64,10 +64,18 @@ public partial class App : Application
public static int AlertPoisonWaitThresholdMs { get; set; } = 500;
public static bool AlertLongRunningQueryEnabled { get; set; } = true;
public static int AlertLongRunningQueryThresholdMinutes { get; set; } = 30;
+ public static int AlertLongRunningQueryMaxResults { get; set; } = 5;
+ public static bool AlertLongRunningQueryExcludeSpServerDiagnostics { get; set; } = true;
+ public static bool AlertLongRunningQueryExcludeWaitFor { get; set; } = true;
+ public static bool AlertLongRunningQueryExcludeBackups { get; set; } = true;
+ public static bool AlertLongRunningQueryExcludeMiscWaits { get; set; } = true;
+ public static List AlertExcludedDatabases { get; set; } = new();
public static bool AlertTempDbSpaceEnabled { get; set; } = true;
public static int AlertTempDbSpaceThresholdPercent { get; set; } = 80;
public static bool AlertLongRunningJobEnabled { get; set; } = true;
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
/* Connection settings */
public static int ConnectionTimeoutSeconds { get; set; } = 5;
@@ -181,6 +189,8 @@ protected override void OnStartup(StartupEventArgs e)
// Initialize logging
var logDirectory = Path.Combine(exeDirectory, "logs");
AppLogger.Initialize(logDirectory);
+ Helpers.MethodProfiler.Initialize(logDirectory);
+ Helpers.QueryLogger.Initialize(logDirectory);
AppLogger.Info("App", $"Starting PerformanceMonitorLite v{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}");
AppLogger.Info("App", $"Data directory: {DataDirectory}");
@@ -242,10 +252,26 @@ public static void LoadAlertSettings()
if (root.TryGetProperty("alert_poison_wait_threshold_ms", out v)) AlertPoisonWaitThresholdMs = v.GetInt32();
if (root.TryGetProperty("alert_long_running_query_enabled", out v)) AlertLongRunningQueryEnabled = v.GetBoolean();
if (root.TryGetProperty("alert_long_running_query_threshold_minutes", out v)) AlertLongRunningQueryThresholdMinutes = v.GetInt32();
+ if (root.TryGetProperty("alert_long_running_query_max_results", out v)) AlertLongRunningQueryMaxResults = (int)Math.Clamp(v.GetInt64(), 1, 1000);
+ if (root.TryGetProperty("alert_long_running_query_exclude_sp_server_diagnostics", out v)) AlertLongRunningQueryExcludeSpServerDiagnostics = v.GetBoolean();
+ if (root.TryGetProperty("alert_long_running_query_exclude_waitfor", out v)) AlertLongRunningQueryExcludeWaitFor = v.GetBoolean();
+ if (root.TryGetProperty("alert_long_running_query_exclude_backups", out v)) AlertLongRunningQueryExcludeBackups = v.GetBoolean();
+ if (root.TryGetProperty("alert_long_running_query_exclude_misc_waits", out v)) AlertLongRunningQueryExcludeMiscWaits = v.GetBoolean();
+ if (root.TryGetProperty("alert_excluded_databases", out v) && v.ValueKind == System.Text.Json.JsonValueKind.Array)
+ {
+ AlertExcludedDatabases = new List();
+ foreach (var elem in v.EnumerateArray())
+ {
+ var db = elem.GetString();
+ if (!string.IsNullOrWhiteSpace(db)) AlertExcludedDatabases.Add(db);
+ }
+ }
if (root.TryGetProperty("alert_tempdb_space_enabled", out v)) AlertTempDbSpaceEnabled = v.GetBoolean();
if (root.TryGetProperty("alert_tempdb_space_threshold_percent", out v)) AlertTempDbSpaceThresholdPercent = v.GetInt32();
if (root.TryGetProperty("alert_long_running_job_enabled", out v)) AlertLongRunningJobEnabled = v.GetBoolean();
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);
/* Connection settings */
if (root.TryGetProperty("connection_timeout_seconds", out v))
@@ -314,6 +340,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
+ /* Silently swallow Hardcodet TrayToolTip race condition (issue #422).
+ The crash occurs in Popup.CreateWindow when showing the custom visual tooltip
+ and is harmless — the tooltip simply doesn't show that one time. */
+ if (IsTrayToolTipCrash(e.Exception))
+ {
+ AppLogger.Warn("Dispatcher", "Suppressed Hardcodet TrayToolTip crash (issue #422)");
+ e.Handled = true;
+ return;
+ }
+
AppLogger.Error("Dispatcher", "Unhandled exception", e.Exception);
AppLogger.Flush();
@@ -334,6 +370,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv
e.SetObserved(); /* Prevent process termination */
}
+ ///
+ /// Detects the Hardcodet TrayToolTip race condition crash (issue #422).
+ ///
+ private static bool IsTrayToolTipCrash(Exception ex)
+ {
+ return ex is System.ArgumentException
+ && ex.Message.Contains("VisualTarget")
+ && ex.StackTrace?.Contains("TaskbarIcon") == true;
+ }
+
private static string FormatExceptionDetails(Exception? ex)
{
if (ex == null) return "Unknown error";
diff --git a/Lite/Controls/AlertsHistoryTab.xaml b/Lite/Controls/AlertsHistoryTab.xaml
index 2602362a..1bdfda7d 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml
+++ b/Lite/Controls/AlertsHistoryTab.xaml
@@ -8,6 +8,10 @@
+
+
@@ -21,6 +25,13 @@
+
+
+
@@ -88,6 +103,7 @@
HeadersVisibility="Column"
SelectionMode="Extended"
SelectionChanged="AlertsDataGrid_SelectionChanged"
+ MouseDoubleClick="AlertsDataGrid_MouseDoubleClick"
RowStyle="{StaticResource AlertRowStyle}">
diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs
index 62a59ff7..f4e86126 100644
--- a/Lite/Controls/AlertsHistoryTab.xaml.cs
+++ b/Lite/Controls/AlertsHistoryTab.xaml.cs
@@ -29,6 +29,8 @@ public partial class AlertsHistoryTab : UserControl
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
+ public MuteRuleService? MuteRuleService { get; set; }
+
public AlertsHistoryTab()
{
InitializeComponent();
@@ -396,4 +398,80 @@ private static string CsvEscape(string value)
}
#endregion
+
+ #region Mute Handlers
+
+ private void AlertsDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ {
+ if (sender is not DataGrid) return;
+
+ // Walk up the visual tree from the click target to find the DataGridRow
+ var source = e.OriginalSource as DependencyObject;
+ while (source != null && source is not DataGridRow && source is not DataGridColumnHeader)
+ source = System.Windows.Media.VisualTreeHelper.GetParent(source);
+
+ // Ignore clicks on column headers or outside rows
+ if (source is not DataGridRow row) return;
+ if (row.DataContext is not AlertHistoryRow item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new Windows.AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private void ViewAlertDetails_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var owner = Window.GetWindow(this);
+ var detailWindow = new Windows.AlertDetailWindow(item);
+ if (owner != null) detailWindow.Owner = owner;
+ detailWindow.ShowDialog();
+ }
+
+ private async void MuteThisAlert_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var context = new AlertMuteContext
+ {
+ ServerName = item.ServerName,
+ MetricName = item.MetricName
+ };
+
+ var dialog = new Windows.MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ await MuteRuleService.AddRuleAsync(dialog.Rule);
+ await LoadAlertsAsync();
+ }
+ }
+
+ private async void MuteSimilarAlerts_Click(object sender, RoutedEventArgs e)
+ {
+ if (MuteRuleService == null) return;
+ if (sender is not MenuItem menuItem) return;
+ var dataGrid = FindParentDataGrid(menuItem);
+ if (dataGrid?.SelectedItem is not AlertHistoryRow item) return;
+
+ var context = new AlertMuteContext
+ {
+ MetricName = item.MetricName
+ };
+
+ var dialog = new Windows.MuteRuleDialog(context) { Owner = Window.GetWindow(this) };
+ if (dialog.ShowDialog() == true)
+ {
+ await MuteRuleService.AddRuleAsync(dialog.Rule);
+ await LoadAlertsAsync();
+ }
+ }
+
+ #endregion
}
diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml
new file mode 100644
index 00000000..b431d949
--- /dev/null
+++ b/Lite/Controls/FinOpsTab.xaml
@@ -0,0 +1,1423 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs
new file mode 100644
index 00000000..d0f529fb
--- /dev/null
+++ b/Lite/Controls/FinOpsTab.xaml.cs
@@ -0,0 +1,781 @@
+/*
+ * 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 System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using Microsoft.Win32;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Controls;
+
+public partial class FinOpsTab : UserControl
+{
+ private LocalDataService? _dataService;
+ private ServerManager? _serverManager;
+ private CredentialService? _credentialService;
+ private List? _serverInventoryCache;
+ private DateTime _serverInventoryCacheTime;
+
+ public FinOpsTab()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Initializes the control with required dependencies.
+ ///
+ public void Initialize(LocalDataService dataService, ServerManager serverManager)
+ {
+ _dataService = dataService;
+ _serverManager = serverManager;
+ _credentialService = serverManager.CredentialService;
+
+ PopulateServerSelector();
+ RefreshData();
+ }
+
+ ///
+ /// Refreshes the server dropdown from the current server list.
+ /// Called when servers are added or removed.
+ ///
+ public void RefreshServerList()
+ {
+ if (_serverManager == null) return;
+ _serverInventoryCache = null; // Invalidate cache when server list changes
+
+ var previousSelection = ServerSelector.SelectedItem as ServerConnection;
+ var servers = _serverManager.GetAllServers();
+ ServerSelector.ItemsSource = servers;
+
+ if (previousSelection != null)
+ {
+ var match = servers.FirstOrDefault(s => s.Id == previousSelection.Id);
+ if (match != null)
+ {
+ ServerSelector.SelectedItem = match;
+ return;
+ }
+ }
+
+ if (servers.Count > 0)
+ ServerSelector.SelectedIndex = 0;
+ }
+
+ private void PopulateServerSelector()
+ {
+ if (_serverManager == null) return;
+
+ var servers = _serverManager.GetAllServers();
+ ServerSelector.ItemsSource = servers;
+ if (servers.Count > 0)
+ ServerSelector.SelectedIndex = 0;
+ }
+
+ private int GetSelectedServerId()
+ {
+ if (ServerSelector.SelectedItem is ServerConnection server)
+ return RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
+ return 0;
+ }
+
+ ///
+ /// Refreshes all FinOps data.
+ ///
+ public async void RefreshData()
+ {
+ await LoadServerInventoryAsync();
+ await LoadPerServerDataAsync();
+ }
+
+ #region Data Loading
+
+ private async System.Threading.Tasks.Task LoadPerServerDataAsync()
+ {
+ using var _profiler = Helpers.MethodProfiler.StartTiming("FinOps-PerServerData");
+ var serverId = GetSelectedServerId();
+ if (serverId == 0 || _dataService == null) return;
+
+ await LoadUtilizationAsync(serverId);
+ await LoadDatabaseResourcesAsync(serverId);
+ await LoadApplicationConnectionsAsync(serverId);
+ await LoadDatabaseSizesAsync(serverId);
+ }
+
+ private async System.Threading.Tasks.Task LoadUtilizationAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetUtilizationEfficiencyAsync(serverId);
+ UpdateUtilizationSummary(data);
+ NoUtilizationMessage.Visibility = data == null ? Visibility.Visible : Visibility.Collapsed;
+ SummaryContent.Visibility = data == null ? Visibility.Collapsed : Visibility.Visible;
+
+ if (data != null)
+ {
+ TopTotalGrid.ItemsSource = await _dataService.GetTopResourceConsumersByTotalAsync(serverId);
+ TopAvgGrid.ItemsSource = await _dataService.GetTopResourceConsumersByAvgAsync(serverId);
+ DbSizeChart.ItemsSource = await _dataService.GetDatabaseSizeSummaryAsync(serverId);
+ ProvisioningTrendGrid.ItemsSource = await _dataService.GetProvisioningTrendAsync(serverId);
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load utilization: {ex.Message}");
+ }
+ }
+
+ private void UpdateUtilizationSummary(UtilizationEfficiencyRow? data)
+ {
+ if (data == null)
+ {
+ ProvisioningStatusText.Text = "No Data";
+ ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray);
+ AvgCpuText.Text = P95CpuText.Text = MaxCpuText.Text = CpuSamplesText.Text = "-";
+ CpuCountText.Text = "-";
+ WorkerThreadsText.Text = "-";
+ AvgCpuBar.Width = P95CpuBar.Width = MaxCpuBar.Width = 0;
+ MemoryUtilBar.Width = MemoryRatioBar.Width = 0;
+ MemoryUtilText.Text = MemoryRatioText.Text = "-";
+ PhysicalMemoryText.Text = TargetMemoryText.Text = TotalMemoryText.Text = BufferPoolText.Text = "-";
+ ClassificationExplanation.Text = "";
+ UtilizationContent.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ UtilizationContent.Visibility = Visibility.Visible;
+
+ ProvisioningStatusText.Text = data.ProvisioningStatus.Replace("_", " ");
+ switch (data.ProvisioningStatus)
+ {
+ case "RIGHT_SIZED":
+ ProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#27AE60"));
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ case "OVER_PROVISIONED":
+ ProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F39C12"));
+ ProvisioningStatusText.Foreground = Brushes.Black;
+ break;
+ case "UNDER_PROVISIONED":
+ ProvisioningStatusBorder.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E74C3C"));
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ default:
+ ProvisioningStatusBorder.Background = new SolidColorBrush(Colors.Gray);
+ ProvisioningStatusText.Foreground = Brushes.White;
+ break;
+ }
+
+ /* CPU text + bars */
+ AvgCpuText.Text = $"{data.AvgCpuPct:N2}%";
+ P95CpuText.Text = $"{data.P95CpuPct:N2}%";
+ MaxCpuText.Text = $"{data.MaxCpuPct}%";
+ CpuSamplesText.Text = data.CpuSamples.ToString("N0");
+ CpuCountText.Text = data.CpuCount.ToString("N0");
+ WorkerThreadsText.Text = $"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}";
+
+ SetBar(AvgCpuBar, AvgCpuFilled, AvgCpuEmpty, (double)data.AvgCpuPct);
+ SetBar(P95CpuBar, P95CpuFilled, P95CpuEmpty, (double)data.P95CpuPct);
+ SetBar(MaxCpuBar, MaxCpuFilled, MaxCpuEmpty, data.MaxCpuPct);
+
+ /* Stolen Memory % = (Total Server Memory - Buffer Pool) / Total Server Memory */
+ var stolenPct = data.TotalMemoryMb > 0
+ ? (double)(data.TotalMemoryMb - data.BufferPoolMb) / data.TotalMemoryMb * 100.0
+ : 0;
+ MemoryUtilText.Text = $"{stolenPct:N0}%";
+ SetBar(MemoryUtilBar, MemUtilFilled, MemUtilEmpty, stolenPct);
+
+ /* Buffer Pool % = Buffer Pool / Physical Memory */
+ var bpPct = data.PhysicalMemoryMb > 0
+ ? (double)data.BufferPoolMb / data.PhysicalMemoryMb * 100.0
+ : 0;
+ MemoryRatioText.Text = $"{bpPct:N0}%";
+ SetBar(MemoryRatioBar, MemRatioFilled, MemRatioEmpty, bpPct);
+
+ PhysicalMemoryText.Text = $"{data.PhysicalMemoryMb:N0} MB";
+ TargetMemoryText.Text = $"{data.TargetMemoryMb:N0} MB";
+ TotalMemoryText.Text = $"{data.TotalMemoryMb:N0} MB";
+ BufferPoolText.Text = $"{data.BufferPoolMb:N0} MB";
+
+ /* Contextual explanation — one sentence describing WHY this classification */
+ ClassificationExplanation.Text = data.ProvisioningStatus switch
+ {
+ "RIGHT_SIZED" => $"CPU is moderately loaded (avg {data.AvgCpuPct:N1}%, p95 {data.P95CpuPct:N1}%) and memory is well-utilized (buffer pool uses {bpPct:N0}% of physical RAM). No action needed.",
+ "OVER_PROVISIONED" => $"CPU is lightly loaded (avg {data.AvgCpuPct:N1}%, max {data.MaxCpuPct}%) and buffer pool uses only {bpPct:N0}% of physical RAM. This server may have more resources than it needs.",
+ "UNDER_PROVISIONED" => data.P95CpuPct > 85
+ ? $"CPU p95 is {data.P95CpuPct:N1}% (threshold: 85%). This server may need more CPU capacity."
+ : $"Buffer pool uses {bpPct:N0}% of physical RAM and memory ratio is {data.MemoryRatio:N2} (threshold: 0.95). Memory pressure is high.",
+ _ => ""
+ };
+ }
+
+ private static void SetBar(Border bar, ColumnDefinition filled, ColumnDefinition empty, double pct)
+ {
+ var clamped = Math.Max(0, Math.Min(100, pct));
+
+ /* Color thresholds: green < 60, orange 60-85, red > 85 */
+ var color = clamped switch
+ {
+ > 85 => "#E74C3C",
+ > 60 => "#F39C12",
+ _ => "#27AE60"
+ };
+ bar.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
+
+ /* Use star-width proportions — the layout engine handles sizing natively */
+ filled.Width = new GridLength(Math.Max(clamped, 0.1), GridUnitType.Star);
+ empty.Width = new GridLength(Math.Max(100 - clamped, 0.1), GridUnitType.Star);
+ }
+
+ private int GetResourceUsageHoursBack()
+ {
+ return ResourceUsageTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private async void ResourceUsageTimeRange_Changed(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _dataService == null) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ await LoadDatabaseResourcesAsync(serverId);
+ }
+
+ private async System.Threading.Tasks.Task LoadDatabaseResourcesAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var hoursBack = GetResourceUsageHoursBack();
+ var data = await _dataService.GetDatabaseResourceUsageAsync(serverId, hoursBack);
+ DatabaseResourcesDataGrid.ItemsSource = data;
+ NoDatabaseResourcesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ DbResourcesCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load database resources: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadApplicationConnectionsAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetApplicationConnectionsAsync(serverId);
+ ApplicationConnectionsDataGrid.ItemsSource = data;
+ NoAppConnectionsMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ AppConnectionsCountIndicator.Text = data.Count > 0 ? $"{data.Count} application(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load application connections: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadDatabaseSizesAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetDatabaseSizeLatestAsync(serverId);
+ DatabaseSizesDataGrid.ItemsSource = data;
+
+ NoDbSizesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ DbSizeCountIndicator.Text = data.Count > 0 ? $"{data.Count} file(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load database sizes: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadServerInventoryAsync(bool forceRefresh = false)
+ {
+ using var _profiler = Helpers.MethodProfiler.StartTiming("FinOps-ServerInventory");
+ if (_dataService == null || _serverManager == null || _credentialService == null) return;
+
+ // Use cache if available and less than 5 minutes old
+ if (!forceRefresh && _serverInventoryCache != null
+ && (DateTime.Now - _serverInventoryCacheTime).TotalMinutes < 5)
+ {
+ ServerInventoryDataGrid.ItemsSource = _serverInventoryCache;
+ NoServerInventoryMessage.Visibility = _serverInventoryCache.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ServerInventoryCountIndicator.Text = _serverInventoryCache.Count > 0 ? $"{_serverInventoryCache.Count} server(s)" : "";
+ return;
+ }
+
+ try
+ {
+ var servers = _serverManager.GetAllServers();
+
+ var tasks = servers.Select(async server =>
+ {
+ try
+ {
+ var connStr = server.GetConnectionString(_credentialService);
+
+ // Step 1: Query live server properties
+ var item = await LocalDataService.GetServerPropertiesLiveAsync(connStr);
+ item.ServerName = server.DisplayName;
+
+ // Step 2: Get collected metrics from DuckDB
+ try
+ {
+ var serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
+ var (avgCpu, storageGb, idleDbs, status) = await _dataService!.GetServerMetricsAsync(serverId);
+ if (avgCpu.HasValue) item.AvgCpuPct = avgCpu;
+ if (storageGb.HasValue) item.StorageTotalGb = storageGb;
+ if (idleDbs.HasValue) item.IdleDbCount = idleDbs;
+ if (status != null) item.ProvisioningStatus = status;
+ }
+ catch
+ {
+ // DuckDB metrics may not exist yet — that's OK
+ }
+
+ return item;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to query {server.DisplayName}: {ex.Message}");
+ return (ServerPropertyRow?)null;
+ }
+ });
+
+ var results = await System.Threading.Tasks.Task.WhenAll(tasks);
+ var data = results.Where(r => r != null).Cast().ToList();
+
+ _serverInventoryCache = data;
+ _serverInventoryCacheTime = DateTime.Now;
+
+ ServerInventoryDataGrid.ItemsSource = data;
+ NoServerInventoryMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ServerInventoryCountIndicator.Text = data.Count > 0 ? $"{data.Count} server(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load server inventory: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadStorageGrowthAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetStorageGrowthAsync(serverId);
+ StorageGrowthDataGrid.ItemsSource = data;
+ NoStorageGrowthMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ StorageGrowthCountIndicator.Text = data.Count > 0 ? $"{data.Count} database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load storage growth: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadIdleDatabasesAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetIdleDatabasesAsync(serverId);
+ IdleDatabasesDataGrid.ItemsSource = data;
+ IdleDatabasesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ IdleDatabasesCountIndicator.Text = data.Count > 0 ? $"{data.Count} idle database(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load idle databases: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadTempdbSummaryAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetTempdbSummaryAsync(serverId);
+ TempdbPressureDataGrid.ItemsSource = data;
+ TempdbPressureNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load tempdb summary: {ex.Message}");
+ }
+ }
+
+ private int GetWaitStatsHoursBack()
+ {
+ return WaitStatsTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private int GetExpensiveQueriesHoursBack()
+ {
+ return ExpensiveQueriesTimeRangeCombo.SelectedIndex switch
+ {
+ 0 => 1,
+ 1 => 4,
+ 2 => 12,
+ 3 => 24,
+ 4 => 168,
+ _ => 24
+ };
+ }
+
+ private async System.Threading.Tasks.Task LoadWaitCategorySummaryAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var hoursBack = GetWaitStatsHoursBack();
+ var data = await _dataService.GetWaitCategorySummaryAsync(serverId, hoursBack);
+ WaitCategorySummaryDataGrid.ItemsSource = data;
+ WaitCategorySummaryNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load wait category summary: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadExpensiveQueriesAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var hoursBack = GetExpensiveQueriesHoursBack();
+ var data = await _dataService.GetExpensiveQueriesAsync(serverId, hoursBack);
+ ExpensiveQueriesDataGrid.ItemsSource = data;
+ ExpensiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ ExpensiveQueriesCountIndicator.Text = data.Count > 0 ? $"{data.Count} query(s)" : "";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load expensive queries: {ex.Message}");
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadMemoryGrantEfficiencyAsync(int serverId)
+ {
+ if (_dataService == null) return;
+
+ try
+ {
+ var data = await _dataService.GetMemoryGrantEfficiencyAsync(serverId);
+ MemoryGrantEfficiencyDataGrid.ItemsSource = data;
+ MemoryGrantEfficiencyNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to load memory grant efficiency: {ex.Message}");
+ }
+ }
+
+ #endregion
+
+ #region Event Handlers
+
+ private async void ServerSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ await LoadPerServerDataAsync();
+ }
+
+ private async void RefreshUtilization_Click(object sender, RoutedEventArgs e)
+ {
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadUtilizationAsync(serverId);
+ }
+
+ private async void RefreshDatabaseResources_Click(object sender, RoutedEventArgs e)
+ {
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadDatabaseResourcesAsync(serverId);
+ }
+
+ private async void RefreshApplicationConnections_Click(object sender, RoutedEventArgs e)
+ {
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadApplicationConnectionsAsync(serverId);
+ }
+
+ private async void RefreshDatabaseSizes_Click(object sender, RoutedEventArgs e)
+ {
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadDatabaseSizesAsync(serverId);
+ }
+
+ private async void RefreshServerInventory_Click(object sender, RoutedEventArgs e)
+ {
+ await LoadServerInventoryAsync(forceRefresh: true);
+ }
+
+ private async void RefreshStorageGrowth_Click(object sender, RoutedEventArgs e)
+ {
+ var serverId = GetSelectedServerId();
+ if (serverId != 0) await LoadStorageGrowthAsync(serverId);
+ }
+
+ private async void WaitStatsTimeRange_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _dataService == null) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ await LoadWaitCategorySummaryAsync(serverId);
+ }
+
+ private async void ExpensiveQueriesTimeRange_Changed(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _dataService == null) return;
+ var serverId = GetSelectedServerId();
+ if (serverId == 0) return;
+ await LoadExpensiveQueriesAsync(serverId);
+ }
+
+ private async void OptimizationRefresh_Click(object sender, RoutedEventArgs e)
+ {
+ using var _profiler = Helpers.MethodProfiler.StartTiming("FinOps-OptimizationRefresh");
+ var serverId = GetSelectedServerId();
+ if (serverId == 0 || _dataService == null) return;
+
+ await System.Threading.Tasks.Task.WhenAll(
+ LoadIdleDatabasesAsync(serverId),
+ LoadTempdbSummaryAsync(serverId),
+ LoadWaitCategorySummaryAsync(serverId),
+ LoadExpensiveQueriesAsync(serverId),
+ LoadMemoryGrantEfficiencyAsync(serverId)
+ );
+ }
+
+ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e)
+ {
+ using var _profiler = Helpers.MethodProfiler.StartTiming("FinOps-IndexAnalysis");
+ if (_serverManager == null || _credentialService == null) return;
+
+ var server = ServerSelector.SelectedItem as ServerConnection;
+ if (server == null) return;
+
+ try
+ {
+ var connectionString = server.GetConnectionString(_credentialService);
+
+ var exists = await LocalDataService.CheckSpIndexCleanupExistsAsync(connectionString);
+ if (!exists)
+ {
+ IndexAnalysisNotInstalledMessage.Visibility = Visibility.Visible;
+ IndexAnalysisNoDataMessage.Visibility = Visibility.Collapsed;
+ IndexAnalysisSummaryGrid.ItemsSource = null;
+ IndexAnalysisDetailGrid.ItemsSource = null;
+ return;
+ }
+
+ IndexAnalysisNotInstalledMessage.Visibility = Visibility.Collapsed;
+
+ RunIndexAnalysisButton.IsEnabled = false;
+ IndexAnalysisStatusText.Text = "Running analysis...";
+
+ var databaseName = IndexAnalysisDatabaseInput.Text?.Trim();
+ var getAllDatabases = IndexAnalysisAllDatabases.IsChecked == true;
+
+ var (details, summaries) = await LocalDataService.RunIndexAnalysisAsync(
+ connectionString,
+ string.IsNullOrWhiteSpace(databaseName) ? null : databaseName,
+ getAllDatabases);
+
+ IndexAnalysisSummaryGrid.ItemsSource = summaries;
+ IndexAnalysisDetailGrid.ItemsSource = details;
+ IndexAnalysisNoDataMessage.Visibility = details.Count == 0 && summaries.Count == 0
+ ? Visibility.Visible : Visibility.Collapsed;
+ IndexAnalysisStatusText.Text = details.Count > 0
+ ? $"{details.Count} index(es) found"
+ : "Analysis complete — no index issues found";
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("FinOps", $"Failed to run index analysis: {ex.Message}");
+ IndexAnalysisStatusText.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ RunIndexAnalysisButton.IsEnabled = true;
+ }
+ }
+
+ #endregion
+
+ #region Context Menu Handlers
+
+ private void CopyCell_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var grid = FindParentDataGrid(menuItem);
+ if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return;
+
+ var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem);
+ if (value.Length > 0) Clipboard.SetDataObject(value, false);
+ }
+
+ private void CopyRow_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var grid = FindParentDataGrid(menuItem);
+ if (grid?.CurrentItem == null) return;
+
+ var sb = new StringBuilder();
+ foreach (var col in grid.Columns)
+ {
+ sb.Append(GetCellValue(col, grid.CurrentItem));
+ sb.Append('\t');
+ }
+ Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false);
+ }
+
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var grid = FindParentDataGrid(menuItem);
+ if (grid?.Items == null) return;
+
+ var sb = new StringBuilder();
+
+ foreach (var col in grid.Columns)
+ {
+ sb.Append(col.Header?.ToString() ?? "");
+ sb.Append('\t');
+ }
+ sb.AppendLine();
+
+ foreach (var item in grid.Items)
+ {
+ foreach (var col in grid.Columns)
+ {
+ sb.Append(GetCellValue(col, item));
+ sb.Append('\t');
+ }
+ sb.AppendLine();
+ }
+
+ Clipboard.SetDataObject(sb.ToString(), false);
+ }
+
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ var grid = FindParentDataGrid(menuItem);
+ if (grid?.Items == null || grid.Items.Count == 0) return;
+
+ var prefix = grid.Name switch
+ {
+ nameof(DatabaseSizesDataGrid) => "database_sizes",
+ nameof(ServerInventoryDataGrid) => "server_inventory",
+ nameof(DatabaseResourcesDataGrid) => "database_resources",
+ nameof(ApplicationConnectionsDataGrid) => "application_connections",
+ _ => "finops_export"
+ };
+
+ var dialog = new SaveFileDialog
+ {
+ Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
+ DefaultExt = ".csv",
+ FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv"
+ };
+
+ if (dialog.ShowDialog() != true) return;
+
+ var sb = new StringBuilder();
+
+ var headers = new List();
+ foreach (var col in grid.Columns)
+ headers.Add(CsvEscape(col.Header?.ToString() ?? ""));
+ sb.AppendLine(string.Join(",", headers));
+
+ foreach (var item in grid.Items)
+ {
+ var values = new List();
+ foreach (var col in grid.Columns)
+ values.Add(CsvEscape(GetCellValue(col, item)));
+ sb.AppendLine(string.Join(",", values));
+ }
+
+ try
+ {
+ File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Failed to export: {ex.Message}", "Export Error",
+ MessageBoxButton.OK, MessageBoxImage.Error);
+ }
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static DataGrid? FindParentDataGrid(MenuItem menuItem)
+ {
+ var contextMenu = menuItem.Parent as ContextMenu;
+ var target = contextMenu?.PlacementTarget as FrameworkElement;
+ while (target != null && target is not DataGrid)
+ target = VisualTreeHelper.GetParent(target) as FrameworkElement;
+ return target as DataGrid;
+ }
+
+ private static string GetCellValue(DataGridColumn col, object item)
+ {
+ if (col is DataGridBoundColumn boundCol && boundCol.Binding is Binding binding)
+ {
+ var prop = item.GetType().GetProperty(binding.Path.Path);
+ return prop?.GetValue(item)?.ToString() ?? "";
+ }
+ return "";
+ }
+
+ private static string CsvEscape(string value)
+ {
+ if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r'))
+ return "\"" + value.Replace("\"", "\"\"") + "\"";
+ return value;
+ }
+
+ #endregion
+}
diff --git a/Lite/Controls/PlanViewerControl.xaml.cs b/Lite/Controls/PlanViewerControl.xaml.cs
index 0603ba59..b4f0875b 100644
--- a/Lite/Controls/PlanViewerControl.xaml.cs
+++ b/Lite/Controls/PlanViewerControl.xaml.cs
@@ -199,12 +199,24 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
BorderThickness = new Thickness(isExpensive ? 2 : 1),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(6, 4, 6, 4),
- ToolTip = BuildNodeTooltip(node),
Cursor = Cursors.Hand,
SnapsToDevicePixels = true,
Tag = node
};
+ // Tooltip — root node includes statement-level PlanWarnings
+ if (totalWarningCount > 0 && _currentStatement != null)
+ {
+ var allWarnings = new List();
+ allWarnings.AddRange(_currentStatement.PlanWarnings);
+ CollectWarnings(node, allWarnings);
+ border.ToolTip = BuildNodeTooltip(node, allWarnings);
+ }
+ else
+ {
+ border.ToolTip = BuildNodeTooltip(node);
+ }
+
// Click to select + show properties
border.MouseLeftButtonUp += Node_Click;
@@ -447,21 +459,87 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child)
figure.Segments.Add(new LineSegment(new Point(childLeft, childCenterY), true));
geometry.Figures.Add(figure);
- var rowText = child.HasActualStats
- ? $"Actual Rows: {child.ActualRows:N0}"
- : $"Estimated Rows: {child.EstimateRows:N0}";
-
return new WpfPath
{
Data = geometry,
Stroke = EdgeBrush,
StrokeThickness = thickness,
StrokeLineJoin = PenLineJoin.Round,
- ToolTip = rowText,
+ ToolTip = BuildEdgeTooltipContent(child),
SnapsToDevicePixels = true
};
}
+ private object BuildEdgeTooltipContent(PlanNode child)
+ {
+ var grid = new Grid { MinWidth = 240 };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ int row = 0;
+
+ void AddRow(string label, string value)
+ {
+ grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ var lbl = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 12,
+ Margin = new Thickness(0, 1, 12, 1)
+ };
+ var val = new TextBlock
+ {
+ Text = value,
+ Foreground = new SolidColorBrush(Colors.White),
+ FontSize = 12,
+ FontWeight = FontWeights.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetRow(lbl, row);
+ Grid.SetColumn(lbl, 0);
+ Grid.SetRow(val, row);
+ Grid.SetColumn(val, 1);
+ grid.Children.Add(lbl);
+ grid.Children.Add(val);
+ row++;
+ }
+
+ if (child.HasActualStats)
+ AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}");
+
+ AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}");
+
+ var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds;
+ var estimatedRowsAllExec = child.EstimateRows * executions;
+ AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}");
+
+ if (child.EstimatedRowSize > 0)
+ {
+ AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize));
+ var dataSize = estimatedRowsAllExec * child.EstimatedRowSize;
+ AddRow("Estimated Data Size", FormatBytes(dataSize));
+ }
+
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 6, 10, 6),
+ CornerRadius = new CornerRadius(4),
+ Child = grid
+ };
+ }
+
+ private static string FormatBytes(double bytes)
+ {
+ if (bytes < 1024) return $"{bytes:N0} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB";
+ if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB";
+ return $"{bytes / (1024L * 1024 * 1024):N1} GB";
+ }
+
#endregion
#region Node Selection & Properties Panel
@@ -550,7 +628,8 @@ private void ShowPropertiesPanel(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
headerText += $" ({node.LogicalOp})";
PropertiesHeader.Text = headerText;
PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
@@ -626,7 +705,7 @@ private void ShowPropertiesPanel(PlanNode node)
|| !string.IsNullOrEmpty(node.InnerSideJoinColumns)
|| !string.IsNullOrEmpty(node.OuterSideJoinColumns)
|| !string.IsNullOrEmpty(node.ActionColumn)
- || node.ManyToMany || node.BitmapCreator
+ || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator
|| node.SortDistinct || node.StartupExpression
|| node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
|| node.WithTies || node.Remoting || node.LocalParallelism
@@ -691,8 +770,10 @@ private void ShowPropertiesPanel(PlanNode node)
AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true);
if (!string.IsNullOrEmpty(node.OuterSideJoinColumns))
AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true);
- if (node.ManyToMany)
- AddPropertyRow("Many to Many", "True");
+ if (node.PhysicalOp == "Merge Join")
+ AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No");
+ else if (node.ManyToMany)
+ AddPropertyRow("Many to Many", "Yes");
if (!string.IsNullOrEmpty(node.ConstantScanValues))
AddPropertyRow("Values", node.ConstantScanValues, isCode: true);
if (!string.IsNullOrEmpty(node.UdxUsedColumns))
@@ -1477,7 +1558,7 @@ private void ClosePropertiesPanel()
#region Tooltips
- private ToolTip BuildNodeTooltip(PlanNode node)
+ private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = null)
{
var tip = new ToolTip
{
@@ -1492,7 +1573,8 @@ private ToolTip BuildNodeTooltip(PlanNode node)
// Header
var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
headerText += $" ({node.LogicalOp})";
stack.Children.Add(new TextBlock
{
@@ -1616,22 +1698,51 @@ private ToolTip BuildNodeTooltip(PlanNode node)
AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true);
}
- // Warnings
- if (node.HasWarnings)
+ // Warnings — use allWarnings (includes statement-level) for root, node.Warnings for others
+ var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null);
+ if (warnings != null && warnings.Count > 0)
{
stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) });
- foreach (var w in node.Warnings)
+
+ if (allWarnings != null)
{
- var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
- : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- stack.Children.Add(new TextBlock
+ // Root node: show distinct warning type names only
+ var distinct = warnings
+ .GroupBy(w => w.WarningType)
+ .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count()))
+ .OrderByDescending(g => g.MaxSeverity)
+ .ThenBy(g => g.Type);
+
+ foreach (var (type, severity, count) in distinct)
{
- Text = $"\u26A0 {w.WarningType}: {w.Message}",
- Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
- FontSize = 11,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 2, 0, 0)
- });
+ var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
+ : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}";
+ stack.Children.Add(new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
+ FontSize = 11,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
+ }
+ else
+ {
+ // Individual node: show full warning messages
+ foreach (var w in warnings)
+ {
+ var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
+ : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"\u26A0 {w.WarningType}: {w.Message}",
+ Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(warnColor)),
+ FontSize = 11,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
}
}
@@ -1934,7 +2045,7 @@ void AddRow(string label, string value)
if (statement.MemoryGrant != null)
{
var mg = statement.MemoryGrant;
- AddRow("Memory grant", $"{mg.GrantedMemoryKB:N0} KB granted, {mg.MaxUsedMemoryKB:N0} KB used");
+ AddRow("Memory grant", $"{FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used");
if (mg.GrantWaitTimeMs > 0)
AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms");
}
@@ -1978,6 +2089,19 @@ void AddRow(string label, string value)
RuntimeSummaryContent.Children.Add(grid);
}
+ ///
+ /// Formats a memory value given in KB to a human-readable string.
+ /// Under 1,024 KB: show KB. 1,024-1,048,576 KB: show MB (1 decimal). Over 1,048,576 KB: show GB (2 decimals).
+ ///
+ private static string FormatMemoryGrantKB(long kb)
+ {
+ if (kb < 1024)
+ return $"{kb:N0} KB";
+ if (kb < 1024 * 1024)
+ return $"{kb / 1024.0:N1} MB";
+ return $"{kb / (1024.0 * 1024.0):N2} GB";
+ }
+
private void UpdateInsightsHeader()
{
InsightsPanel.Visibility = Visibility.Visible;
@@ -2022,9 +2146,30 @@ private void PlanScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventAr
private void PlanViewerControl_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
+ // Don't steal focus from interactive controls (ComboBox, TextBox, Button, etc.)
+ // ComboBox dropdown items live in a separate visual tree (Popup), so also check
+ // for ComboBoxItem to avoid stealing focus when selecting dropdown items.
+ if (e.OriginalSource is System.Windows.Controls.Primitives.TextBoxBase
+ || e.OriginalSource is ComboBox
+ || e.OriginalSource is ComboBoxItem
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null
+ || FindVisualParent(e.OriginalSource as DependencyObject) != null)
+ return;
+
Focus();
}
+ private static T? FindVisualParent(DependencyObject? child) where T : DependencyObject
+ {
+ while (child != null)
+ {
+ if (child is T parent) return parent;
+ child = VisualTreeHelper.GetParent(child);
+ }
+ return null;
+ }
+
private void PlanViewerControl_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 7cd927f0..35b41532 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -119,7 +119,7 @@
+ BorderThickness="0" Padding="0" SelectionChanged="MainTabControl_SelectionChanged">
@@ -179,7 +179,7 @@
-
+
@@ -245,7 +245,7 @@
-
+
@@ -697,7 +697,7 @@
-
+
@@ -890,7 +890,7 @@
-
+
@@ -942,7 +942,7 @@
-
+
@@ -1183,13 +1183,13 @@
-
+
-
+
-
+
@@ -1403,7 +1403,7 @@
-
+
@@ -1459,7 +1459,7 @@
-
+
@@ -1500,13 +1500,13 @@
-
+
-
+
-
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index 62a72dd0..b1c10dcc 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -36,8 +36,10 @@ public partial class ServerTab : UserControl
private readonly LocalDataService _dataService;
private readonly int _serverId;
public int ServerId => _serverId;
+ public ServerConnection Server => _server;
private readonly CredentialService _credentialService;
private readonly DispatcherTimer _refreshTimer;
+ private bool _isRefreshing;
private readonly Dictionary _legendPanels = new();
private List _waitTypeItems = new();
private List _perfmonCounterItems = new();
@@ -116,13 +118,13 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
_server = server;
_dataService = new LocalDataService(duckDb);
- _serverId = RemoteCollectorService.GetDeterministicHashCode(server.ServerName);
+ _serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
_credentialService = credentialService;
UtcOffsetMinutes = utcOffsetMinutes;
ServerTimeHelper.UtcOffsetMinutes = utcOffsetMinutes;
- ServerNameText.Text = server.DisplayName;
- ConnectionStatusText.Text = server.ServerName;
+ ServerNameText.Text = server.ReadOnlyIntent ? $"{server.DisplayName} (Read-Only)" : server.DisplayName;
+ ConnectionStatusText.Text = server.ServerNameDisplay;
/* Apply default time range from settings */
TimeRangeCombo.SelectedIndex = App.DefaultTimeRangeHours switch
@@ -140,7 +142,19 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
{
Interval = TimeSpan.FromSeconds(60)
};
- _refreshTimer.Tick += async (s, e) => await RefreshAllDataAsync();
+ _refreshTimer.Tick += async (s, e) =>
+ {
+ if (_isRefreshing) return;
+ _isRefreshing = true;
+ try
+ {
+ await RefreshAllDataAsync(fullRefresh: false);
+ }
+ finally
+ {
+ _isRefreshing = false;
+ }
+ };
_refreshTimer.Start();
/* Initialize time picker ComboBoxes */
@@ -209,7 +223,8 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
_currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions");
/* Chart context menus (right-click save/export) */
- Helpers.ContextMenuHelper.SetupChartContextMenu(WaitStatsChart, "Wait_Stats");
+ var waitStatsMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(WaitStatsChart, "Wait_Stats");
+ AddWaitDrillDownMenuItem(WaitStatsChart, waitStatsMenu);
Helpers.ContextMenuHelper.SetupChartContextMenu(QueryDurationTrendChart, "Query_Duration_Trends");
Helpers.ContextMenuHelper.SetupChartContextMenu(ProcDurationTrendChart, "Procedure_Duration_Trends");
Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends");
@@ -378,7 +393,8 @@ private async void RefreshDataButton_Click(object sender, RoutedEventArgs e)
{
await ManualRefreshRequested.Invoke();
}
- await RefreshAllDataAsync();
+ /* Manual refresh loads all sub-tabs of the visible tab, not all 13 tabs */
+ await RefreshAllDataAsync(fullRefresh: false);
}
finally
{
@@ -413,7 +429,7 @@ private async void TimeRangeCombo_SelectionChanged(object sender, SelectionChang
if (!isCustom)
{
- await RefreshAllDataAsync();
+ await RefreshAllDataAsync(fullRefresh: false);
}
}
@@ -422,7 +438,7 @@ private async void CustomDateRange_Changed(object sender, SelectionChangedEventA
if (!IsLoaded) return;
if (FromDatePicker?.SelectedDate != null && ToDatePicker?.SelectedDate != null)
{
- await RefreshAllDataAsync();
+ await RefreshAllDataAsync(fullRefresh: false);
}
}
@@ -432,7 +448,7 @@ private async void CustomTimeCombo_Changed(object sender, SelectionChangedEventA
/* Only refresh if we have valid dates selected */
if (FromDatePicker?.SelectedDate != null && ToDatePicker?.SelectedDate != null)
{
- await RefreshAllDataAsync();
+ await RefreshAllDataAsync(fullRefresh: false);
}
}
@@ -534,14 +550,18 @@ private void ApplyThemeRecursively(DependencyObject parent, Brush primaryBg, Bru
///
/// Public entry point to trigger a data refresh from outside.
+ /// Loads only the visible tab — other tabs load on demand when clicked.
///
public async void RefreshData()
{
- await RefreshAllDataAsync();
+ await RefreshAllDataAsync(fullRefresh: false);
}
- private async System.Threading.Tasks.Task RefreshAllDataAsync()
+ private async System.Threading.Tasks.Task RefreshAllDataAsync(bool fullRefresh = false)
{
+ if (_isRefreshing) return;
+ _isRefreshing = true;
+
var hoursBack = GetHoursBack();
/* Get custom date range if selected, converting local picker dates/times to server time */
@@ -560,134 +580,477 @@ private async System.Threading.Tasks.Task RefreshAllDataAsync()
try
{
- var loadSw = Stopwatch.StartNew();
+ using var _profiler = Helpers.MethodProfiler.StartTiming($"ServerTab-{_server?.DisplayName}");
+
+ if (fullRefresh)
+ {
+ await RefreshAllTabsAsync(hoursBack, fromDate, toDate);
+ }
+ else
+ {
+ await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true);
+ /* Always keep alert badge current even when Blocking tab is not visible */
+ if (MainTabControl.SelectedIndex != 7)
+ await RefreshAlertCountsAsync(hoursBack, fromDate, toDate);
+ }
+
+ var tz = ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode);
+ ConnectionStatusText.Text = $"{_server.ServerNameDisplay} - Last refresh: {DateTime.Now:HH:mm:ss} ({tz})";
+ }
+ catch (Exception ex)
+ {
+ ConnectionStatusText.Text = $"Error: {ex.Message}";
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAllDataAsync failed: {ex}");
+ }
+ finally
+ {
+ _isRefreshing = false;
+ }
+ }
+
+ private async System.Threading.Tasks.Task RefreshVisibleTabAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false)
+ {
+ switch (MainTabControl.SelectedIndex)
+ {
+ case 0: await RefreshWaitStatsAsync(hoursBack, fromDate, toDate); break;
+ case 1: await RefreshQueriesAsync(hoursBack, fromDate, toDate, subTabOnly); break;
+ case 2: break; // Plan Viewer — no queries
+ case 3: await RefreshCpuAsync(hoursBack, fromDate, toDate); break;
+ case 4: await RefreshMemoryAsync(hoursBack, fromDate, toDate, subTabOnly); break;
+ case 5: await RefreshFileIoAsync(hoursBack, fromDate, toDate); break;
+ case 6: await RefreshTempDbAsync(hoursBack, fromDate, toDate); break;
+ case 7: await RefreshBlockingAsync(hoursBack, fromDate, toDate, subTabOnly); break;
+ case 8: await RefreshPerfmonAsync(hoursBack, fromDate, toDate); break;
+ case 9: await RefreshRunningJobsAsync(hoursBack, fromDate, toDate); break;
+ case 10: await RefreshConfigurationAsync(hoursBack, fromDate, toDate); break;
+ case 11: await RefreshDailySummaryAsync(hoursBack, fromDate, toDate); break;
+ case 12: await RefreshCollectionHealthAsync(hoursBack, fromDate, toDate); break;
+ }
+ }
+
+ ///
+ /// Lightweight alert-only refresh — fetches blocking + deadlock counts and fires AlertCountsChanged.
+ /// Runs on every timer tick when the Blocking tab is NOT visible so the tab badge stays current.
+ ///
+ private async System.Threading.Tasks.Task RefreshAlertCountsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var (blockingCount, deadlockCount, latestEventTime) = await _dataService.GetAlertCountsAsync(_serverId, hoursBack, fromDate, toDate);
+ AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAlertCountsAsync failed: {ex.Message}");
+ }
+ }
- /* Load all tabs in parallel */
+ ///
+ /// Full refresh of all tabs — used for first load, manual refresh, and time range changes.
+ ///
+ private async System.Threading.Tasks.Task RefreshAllTabsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ var loadSw = Stopwatch.StartNew();
+
+ /* Load all tabs in parallel */
+ var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate);
+ var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId);
+ var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
+ var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
+ var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate);
+ var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate);
+ var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate);
+ var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate);
+ var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate);
+ var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate);
+ var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId));
+ var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId));
+ var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId));
+ var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId));
+ var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId));
+ var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId));
+ var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack));
+ var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate);
+ /* Core data tasks */
+ await System.Threading.Tasks.Task.WhenAll(
+ snapshotsTask, cpuTask, memoryTask, memoryTrendTask,
+ queryStatsTask, procStatsTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask,
+ deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask,
+ queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask,
+ serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask,
+ runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask);
+
+ /* Trend chart tasks - run separately so failures don't kill the whole refresh */
+ var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate));
+
+ await System.Threading.Tasks.Task.WhenAll(
+ lockWaitTrendTask, blockingTrendTask, deadlockTrendTask,
+ queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask,
+ currentWaitsDurationTask, currentWaitsBlockedTask);
+
+ loadSw.Stop();
+
+ /* Log data counts and timing for diagnostics */
+ AppLogger.DataDiag("ServerTab", $"[{_server.DisplayName}] serverId={_serverId} hoursBack={hoursBack} dataLoad={loadSw.ElapsedMilliseconds}ms");
+ AppLogger.DataDiag("ServerTab", $" Snapshots: {snapshotsTask.Result.Count}, CPU: {cpuTask.Result.Count}");
+ AppLogger.DataDiag("ServerTab", $" Memory: {(memoryTask.Result != null ? "1" : "null")}, MemoryTrend: {memoryTrendTask.Result.Count}");
+ AppLogger.DataDiag("ServerTab", $" QueryStats: {queryStatsTask.Result.Count}, ProcStats: {procStatsTask.Result.Count}");
+ AppLogger.DataDiag("ServerTab", $" FileIoTrend: {fileIoTrendTask.Result.Count}");
+ AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}");
+ AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}");
+
+ /* Update grids (via filter managers to preserve active filters) */
+ _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
+ LiveSnapshotIndicator.Text = "";
+ _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
+ SetInitialSort(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _procStatsFilterMgr!.UpdateData(procStatsTask.Result);
+ SetInitialSort(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
+ _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
+ _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
+ _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
+ SetInitialSort(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
+ _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
+ _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
+ _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
+ _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
+ _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
+ _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
+ _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
+ var dailySummary = await dailySummaryTask;
+ DailySummaryGrid.ItemsSource = dailySummary != null
+ ? new List { dailySummary } : null;
+ DailySummaryNoData.Visibility = dailySummary == null
+ ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed;
+ UpdateCollectorDurationChart(collectionLogTask.Result);
+
+ /* Update memory summary */
+ UpdateMemorySummary(memoryTask.Result);
+
+ /* Update charts */
+ UpdateCpuChart(cpuTask.Result);
+ UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result);
+ UpdateTempDbChart(tempDbTask.Result);
+ UpdateTempDbFileIoChart(tempDbFileIoTask.Result);
+ UpdateFileIoCharts(fileIoTrendTask.Result);
+ UpdateFileIoThroughputCharts(fileIoThroughputTask.Result);
+ UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate);
+ UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate);
+ UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate);
+ UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate);
+ UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate);
+ UpdateQueryDurationTrendChart(queryDurationTrendTask.Result);
+ UpdateProcDurationTrendChart(procDurationTrendTask.Result);
+ UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result);
+ UpdateExecutionCountTrendChart(executionCountTrendTask.Result);
+ UpdateMemoryGrantCharts(memoryGrantChartTask.Result);
+
+ /* Populate pickers (preserve selections) */
+ PopulateWaitTypePicker(waitTypesTask.Result);
+ PopulateMemoryClerkPicker(memoryClerkTypesTask.Result);
+ PopulatePerfmonPicker(perfmonCountersTask.Result);
+
+ /* Update picker-driven charts */
+ await UpdateWaitStatsChartFromPickerAsync();
+ await UpdateMemoryClerksChartFromPickerAsync();
+ await UpdatePerfmonChartFromPickerAsync();
+
+ /* Notify parent of alert counts for tab badge.
+ Include the latest event timestamp so acknowledgement is only
+ cleared when genuinely new events arrive, not when the time range changes. */
+ var blockingCount = blockedProcessTask.Result.Count;
+ var deadlockCount = deadlockTask.Result.Count;
+ DateTime? latestEventTime = null;
+ if (blockingCount > 0 || deadlockCount > 0)
+ {
+ var latestBlocking = blockedProcessTask.Result.Max(r => (DateTime?)r.EventTime);
+ var latestDeadlock = deadlockTask.Result.Max(r => (DateTime?)r.DeadlockTime);
+ latestEventTime = latestBlocking > latestDeadlock ? latestBlocking : latestDeadlock;
+ }
+ AlertCountsChanged?.Invoke(blockingCount, deadlockCount, latestEventTime);
+ }
+
+ /* ───────────────────────────── Per-tab refresh methods ───────────────────────────── */
+
+ /// Tab 0 — Wait Stats
+ private async System.Threading.Tasks.Task RefreshWaitStatsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate);
+ await waitTypesTask;
+ PopulateWaitTypePicker(waitTypesTask.Result);
+ await UpdateWaitStatsChartFromPickerAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshWaitStatsAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 1 — Queries
+ private async System.Threading.Tasks.Task RefreshQueriesAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false)
+ {
+ try
+ {
+ if (subTabOnly)
+ {
+ /* Timer tick: only refresh the visible sub-tab (8 queries → 1-4) */
+ switch (QueriesSubTabControl.SelectedIndex)
+ {
+ case 0: // Performance Trends — 4 trend charts
+ var qdt = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var pdt = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var qsdt = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var ect = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ await System.Threading.Tasks.Task.WhenAll(qdt, pdt, qsdt, ect);
+ UpdateQueryDurationTrendChart(qdt.Result);
+ UpdateProcDurationTrendChart(pdt.Result);
+ UpdateQueryStoreDurationTrendChart(qsdt.Result);
+ UpdateExecutionCountTrendChart(ect.Result);
+ break;
+ case 1: // Active Queries
+ var snapshots = await _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate);
+ _querySnapshotsFilterMgr!.UpdateData(snapshots);
+ LiveSnapshotIndicator.Text = "";
+ 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);
+ 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);
+ 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);
+ break;
+ }
+ return;
+ }
+
+ /* Full refresh: load all sub-tabs */
var snapshotsTask = _dataService.GetLatestQuerySnapshotsAsync(_serverId, hoursBack, fromDate, toDate);
- var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate);
- var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId);
- var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate);
var queryStatsTask = _dataService.GetTopQueriesByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
var procStatsTask = _dataService.GetTopProceduresByCpuAsync(_serverId, hoursBack, 50, fromDate, toDate, UtcOffsetMinutes);
- var fileIoTask = _dataService.GetLatestFileIoStatsAsync(_serverId);
- var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate);
- var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate);
- var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate);
- var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate);
- var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate);
- var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate);
- var waitTypesTask = _dataService.GetDistinctWaitTypesAsync(_serverId, hoursBack, fromDate, toDate);
- var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate);
- var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate);
var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate);
- var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate);
- var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate);
- var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId));
- var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId));
- var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId));
- var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId));
- var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId));
- var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId));
- var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack));
- var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate);
- /* Core data tasks */
- await System.Threading.Tasks.Task.WhenAll(
- snapshotsTask, cpuTask, memoryTask, memoryTrendTask,
- queryStatsTask, procStatsTask, fileIoTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask,
- deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask,
- queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask,
- serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask,
- runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask);
-
- /* Trend chart tasks - run separately so failures don't kill the whole refresh */
- var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate));
- var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate));
- var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate));
var queryDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate));
var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate));
- var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate));
- var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate));
await System.Threading.Tasks.Task.WhenAll(
- lockWaitTrendTask, blockingTrendTask, deadlockTrendTask,
- queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask,
- currentWaitsDurationTask, currentWaitsBlockedTask);
+ snapshotsTask, queryStatsTask, procStatsTask, queryStoreTask,
+ queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask);
- loadSw.Stop();
-
- /* Log data counts and timing for diagnostics */
- AppLogger.DataDiag("ServerTab", $"[{_server.DisplayName}] serverId={_serverId} hoursBack={hoursBack} dataLoad={loadSw.ElapsedMilliseconds}ms");
- AppLogger.DataDiag("ServerTab", $" Snapshots: {snapshotsTask.Result.Count}, CPU: {cpuTask.Result.Count}");
- AppLogger.DataDiag("ServerTab", $" Memory: {(memoryTask.Result != null ? "1" : "null")}, MemoryTrend: {memoryTrendTask.Result.Count}");
- AppLogger.DataDiag("ServerTab", $" QueryStats: {queryStatsTask.Result.Count}, ProcStats: {procStatsTask.Result.Count}");
- AppLogger.DataDiag("ServerTab", $" FileIo: {fileIoTask.Result.Count}, FileIoTrend: {fileIoTrendTask.Result.Count}");
- AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}");
- AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}");
-
- /* Update grids (via filter managers to preserve active filters) */
_querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
LiveSnapshotIndicator.Text = "";
_queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
SetInitialSort(QueryStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
_procStatsFilterMgr!.UpdateData(procStatsTask.Result);
SetInitialSort(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending);
- _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
- _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
_queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
SetInitialSort(QueryStoreGrid, "TotalDurationMs", ListSortDirection.Descending);
- _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
- _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
- _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
- _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
- _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
- _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
- _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
- var dailySummary = await dailySummaryTask;
- DailySummaryGrid.ItemsSource = dailySummary != null
- ? new List { dailySummary } : null;
- DailySummaryNoData.Visibility = dailySummary == null
- ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed;
- UpdateCollectorDurationChart(collectionLogTask.Result);
- /* Update memory summary */
- UpdateMemorySummary(memoryTask.Result);
+ UpdateQueryDurationTrendChart(queryDurationTrendTask.Result);
+ UpdateProcDurationTrendChart(procDurationTrendTask.Result);
+ UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result);
+ UpdateExecutionCountTrendChart(executionCountTrendTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshQueriesAsync failed: {ex.Message}");
+ }
+ }
- /* Update charts */
+ /// Tab 3 — CPU
+ private async System.Threading.Tasks.Task RefreshCpuAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var cpuTask = _dataService.GetCpuUtilizationAsync(_serverId, hoursBack, fromDate, toDate);
+ await cpuTask;
UpdateCpuChart(cpuTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCpuAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 4 — Memory
+ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false)
+ {
+ try
+ {
+ if (subTabOnly)
+ {
+ /* Timer tick: only refresh the visible sub-tab (5 queries → 1-2) */
+ switch (MemorySubTabControl.SelectedIndex)
+ {
+ case 0: // Overview — memory stats + trend
+ var memStats = await _dataService.GetLatestMemoryStatsAsync(_serverId);
+ var memTrend = await _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var memGrantTrend = await _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ UpdateMemorySummary(memStats);
+ UpdateMemoryChart(memTrend, memGrantTrend);
+ break;
+ case 1: // Memory Clerks
+ var clerkTypes = await _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate);
+ PopulateMemoryClerkPicker(clerkTypes);
+ await UpdateMemoryClerksChartFromPickerAsync();
+ break;
+ case 2: // Memory Grants
+ var grantChart = await _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate);
+ UpdateMemoryGrantCharts(grantChart);
+ break;
+ }
+ return;
+ }
+
+ /* Full refresh: load all sub-tabs */
+ var memoryTask = _dataService.GetLatestMemoryStatsAsync(_serverId);
+ var memoryTrendTask = _dataService.GetMemoryTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate);
+
+ await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask);
+
+ UpdateMemorySummary(memoryTask.Result);
UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result);
- UpdateTempDbChart(tempDbTask.Result);
- UpdateTempDbFileIoChart(tempDbFileIoTask.Result);
+ UpdateMemoryGrantCharts(memoryGrantChartTask.Result);
+ PopulateMemoryClerkPicker(memoryClerkTypesTask.Result);
+ await UpdateMemoryClerksChartFromPickerAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshMemoryAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 5 — File I/O
+ private async System.Threading.Tasks.Task RefreshFileIoAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var fileIoTrendTask = _dataService.GetFileIoLatencyTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var fileIoThroughputTask = _dataService.GetFileIoThroughputTrendAsync(_serverId, hoursBack, fromDate, toDate);
+
+ await System.Threading.Tasks.Task.WhenAll(fileIoTrendTask, fileIoThroughputTask);
+
UpdateFileIoCharts(fileIoTrendTask.Result);
UpdateFileIoThroughputCharts(fileIoThroughputTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshFileIoAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 6 — TempDB
+ private async System.Threading.Tasks.Task RefreshTempDbAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var tempDbTask = _dataService.GetTempDbTrendAsync(_serverId, hoursBack, fromDate, toDate);
+ var tempDbFileIoTask = _dataService.GetTempDbFileIoTrendAsync(_serverId, hoursBack, fromDate, toDate);
+
+ await System.Threading.Tasks.Task.WhenAll(tempDbTask, tempDbFileIoTask);
+
+ UpdateTempDbChart(tempDbTask.Result);
+ UpdateTempDbFileIoChart(tempDbFileIoTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshTempDbAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 7 — Blocking
+ private async System.Threading.Tasks.Task RefreshBlockingAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, bool subTabOnly = false)
+ {
+ try
+ {
+ if (subTabOnly)
+ {
+ /* Timer tick: only refresh the visible sub-tab (7 queries → 1-3) + lightweight alert counts */
+ switch (BlockingSubTabControl.SelectedIndex)
+ {
+ case 0: // Trends — 3 trend charts
+ var lwt = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var bt = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var dt = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ await System.Threading.Tasks.Task.WhenAll(lwt, bt, dt);
+ UpdateLockWaitTrendChart(lwt.Result, hoursBack, fromDate, toDate);
+ UpdateBlockingTrendChart(bt.Result, hoursBack, fromDate, toDate);
+ UpdateDeadlockTrendChart(dt.Result, hoursBack, fromDate, toDate);
+ break;
+ case 1: // Current Waits — 2 charts
+ var cwd = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var cwb = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ await System.Threading.Tasks.Task.WhenAll(cwd, cwb);
+ UpdateCurrentWaitsDurationChart(cwd.Result, hoursBack, fromDate, toDate);
+ UpdateCurrentWaitsBlockedChart(cwb.Result, hoursBack, fromDate, toDate);
+ break;
+ case 2: // Blocked Process Reports
+ var bpr = await _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate);
+ _blockedProcessFilterMgr!.UpdateData(bpr);
+ break;
+ case 3: // Deadlocks
+ var dlr = await _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate);
+ _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(dlr));
+ break;
+ }
+ /* Always keep alert badge current when Blocking tab is visible */
+ await RefreshAlertCountsAsync(hoursBack, fromDate, toDate);
+ return;
+ }
+
+ /* Full refresh: load all sub-tabs */
+ var blockedProcessTask = _dataService.GetRecentBlockedProcessReportsAsync(_serverId, hoursBack, fromDate, toDate);
+ var deadlockTask = _dataService.GetRecentDeadlocksAsync(_serverId, hoursBack, fromDate, toDate);
+ var lockWaitTrendTask = SafeQueryAsync(() => _dataService.GetLockWaitTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var deadlockTrendTask = SafeQueryAsync(() => _dataService.GetDeadlockTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate));
+ var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate));
+
+ await System.Threading.Tasks.Task.WhenAll(
+ blockedProcessTask, deadlockTask,
+ lockWaitTrendTask, blockingTrendTask, deadlockTrendTask,
+ currentWaitsDurationTask, currentWaitsBlockedTask);
+
+ _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
+ _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
+
UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate);
UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate);
UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate);
UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate);
UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate);
- UpdateQueryDurationTrendChart(queryDurationTrendTask.Result);
- UpdateProcDurationTrendChart(procDurationTrendTask.Result);
- UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result);
- UpdateExecutionCountTrendChart(executionCountTrendTask.Result);
- UpdateMemoryGrantCharts(memoryGrantChartTask.Result);
- /* Populate pickers (preserve selections) */
- PopulateWaitTypePicker(waitTypesTask.Result);
- PopulateMemoryClerkPicker(memoryClerkTypesTask.Result);
- PopulatePerfmonPicker(perfmonCountersTask.Result);
-
- /* Update picker-driven charts */
- await UpdateWaitStatsChartFromPickerAsync();
- await UpdateMemoryClerksChartFromPickerAsync();
- await UpdatePerfmonChartFromPickerAsync();
-
- var tz = ServerTimeHelper.GetTimezoneLabel(ServerTimeHelper.CurrentDisplayMode);
- ConnectionStatusText.Text = $"{_server.ServerName} - Last refresh: {DateTime.Now:HH:mm:ss} ({tz})";
-
- /* Notify parent of alert counts for tab badge.
- Include the latest event timestamp so acknowledgement is only
- cleared when genuinely new events arrive, not when the time range changes. */
+ /* Notify parent of alert counts for tab badge */
var blockingCount = blockedProcessTask.Result.Count;
var deadlockCount = deadlockTask.Result.Count;
DateTime? latestEventTime = null;
@@ -701,11 +1064,129 @@ Include the latest event timestamp so acknowledgement is only
}
catch (Exception ex)
{
- ConnectionStatusText.Text = $"Error: {ex.Message}";
- AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshAllDataAsync failed: {ex}");
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshBlockingAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 8 — Perfmon
+ private async System.Threading.Tasks.Task RefreshPerfmonAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var perfmonCountersTask = _dataService.GetDistinctPerfmonCountersAsync(_serverId, hoursBack, fromDate, toDate);
+ await perfmonCountersTask;
+ PopulatePerfmonPicker(perfmonCountersTask.Result);
+ await UpdatePerfmonChartFromPickerAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshPerfmonAsync failed: {ex.Message}");
}
}
+ /// Tab 9 — Running Jobs
+ private async System.Threading.Tasks.Task RefreshRunningJobsAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId));
+ await runningJobsTask;
+ _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshRunningJobsAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 10 — Configuration
+ private async System.Threading.Tasks.Task RefreshConfigurationAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId));
+ var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId));
+ var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId));
+ var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId));
+
+ await System.Threading.Tasks.Task.WhenAll(serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask);
+
+ _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
+ _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
+ _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
+ _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshConfigurationAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 11 — Daily Summary
+ private async System.Threading.Tasks.Task RefreshDailySummaryAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var dailySummaryTask = _dataService.GetDailySummaryAsync(_serverId, _dailySummaryDate);
+ var dailySummary = await dailySummaryTask;
+ DailySummaryGrid.ItemsSource = dailySummary != null
+ ? new List { dailySummary } : null;
+ DailySummaryNoData.Visibility = dailySummary == null
+ ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshDailySummaryAsync failed: {ex.Message}");
+ }
+ }
+
+ /// Tab 12 — Collection Health
+ private async System.Threading.Tasks.Task RefreshCollectionHealthAsync(int hoursBack, DateTime? fromDate, DateTime? toDate)
+ {
+ try
+ {
+ var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId));
+ var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack));
+
+ await System.Threading.Tasks.Task.WhenAll(collectionHealthTask, collectionLogTask);
+
+ _collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
+ _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
+ UpdateCollectorDurationChart(collectionLogTask.Result);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Info("ServerTab", $"[{_server.DisplayName}] RefreshCollectionHealthAsync failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// When the user switches main tabs or sub-tabs, refresh only the visible sub-tab.
+ /// All sub-tabs are loaded on first load and manual refresh — tab/sub-tab switches
+ /// only need to refresh the one the user is looking at.
+ ///
+ private async void MainTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (!IsLoaded || _dataService == null) return;
+ if (_isRefreshing) return;
+ if (e.Source != MainTabControl && e.Source != QueriesSubTabControl
+ && e.Source != MemorySubTabControl && e.Source != BlockingSubTabControl) return;
+
+ 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);
+ }
+ }
+ await RefreshVisibleTabAsync(hoursBack, fromDate, toDate, subTabOnly: true);
+ }
+
///
/// Wraps a query in a try/catch so it returns an empty list on failure instead of faulting.
///
@@ -1735,6 +2216,48 @@ private void WaitType_CheckChanged(object sender, RoutedEventArgs e)
_ = UpdateWaitStatsChartFromPickerAsync();
}
+ private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu)
+ {
+ contextMenu.Items.Insert(0, new Separator());
+ var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" };
+ drillDownItem.Click += ShowQueriesForWaitType_Click;
+ contextMenu.Items.Insert(0, drillDownItem);
+
+ contextMenu.Opened += (s, _) =>
+ {
+ if (s is not ContextMenu cm) return;
+ var pos = System.Windows.Input.Mouse.GetPosition(chart);
+ var nearest = _waitStatsHover?.GetNearestSeries(pos);
+ if (nearest.HasValue)
+ {
+ drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time);
+ drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}";
+ drillDownItem.IsEnabled = true;
+ }
+ else
+ {
+ drillDownItem.Tag = null;
+ drillDownItem.Header = "Show Queries With This Wait";
+ drillDownItem.IsEnabled = false;
+ }
+ };
+ }
+
+ private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem menuItem) return;
+ if (menuItem.Tag is not (string waitType, DateTime time)) return;
+
+ // ±15 minute window around the clicked point (already in server local time from chart)
+ var fromDate = time.AddMinutes(-15);
+ var toDate = time.AddMinutes(15);
+
+ var window = new Windows.WaitDrillDownWindow(
+ _dataService, _serverId, waitType, 1, fromDate, toDate);
+ window.Owner = Window.GetWindow(this);
+ window.ShowDialog();
+ }
+
private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
{
try
diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs
index 953401fe..ca7f7914 100644
--- a/Lite/Database/DuckDbInitializer.cs
+++ b/Lite/Database/DuckDbInitializer.cs
@@ -27,10 +27,22 @@ public class DuckDbInitializer
///
/// Acquires a read lock on the database. Multiple readers can hold this concurrently.
/// Dispose the returned object to release the lock.
+ /// If the current thread already owns a read lock (e.g., leaked by an unhandled exception),
+ /// returns a no-op disposable to allow the operation to proceed.
///
public IDisposable AcquireReadLock()
{
- s_dbLock.EnterReadLock();
+ try
+ {
+ s_dbLock.EnterReadLock();
+ }
+ catch (LockRecursionException)
+ {
+ /* The current thread already owns a read lock — likely leaked by an unhandled
+ exception that prevented Dispose(). Since we're already protected by a read lock,
+ return a no-op disposable so the caller can proceed normally. */
+ return NoOpDisposable.Instance;
+ }
return new LockReleaser(s_dbLock, write: false);
}
@@ -44,6 +56,12 @@ public IDisposable AcquireWriteLock()
return new LockReleaser(s_dbLock, write: true);
}
+ private sealed class NoOpDisposable : IDisposable
+ {
+ public static readonly NoOpDisposable Instance = new();
+ public void Dispose() { }
+ }
+
private sealed class LockReleaser : IDisposable
{
private readonly ReaderWriterLockSlim _lock;
@@ -68,7 +86,7 @@ public void Dispose()
///
/// Current schema version. Increment this when schema changes require table rebuilds.
///
- internal const int CurrentSchemaVersion = 15;
+ internal const int CurrentSchemaVersion = 21;
private readonly string _archivePath;
@@ -79,13 +97,18 @@ public DuckDbInitializer(string databasePath, ILogger? logger
_archivePath = Path.Combine(Path.GetDirectoryName(databasePath) ?? ".", "archive");
}
- /* Tables that have parquet archives — views are created to UNION hot data with archived parquet files */
+ /* Tables that have parquet archives — views are created to UNION hot data with archived parquet files.
+ IMPORTANT: Must match ArchiveService.ArchivableTables — every archived table needs an archive view. */
private static readonly string[] ArchivableTables =
[
"wait_stats", "query_stats", "procedure_stats", "query_store_stats",
"query_snapshots", "cpu_utilization_stats", "file_io_stats", "memory_stats",
"memory_clerks", "tempdb_stats", "perfmon_stats", "deadlocks",
- "blocked_process_reports", "collection_log"
+ "blocked_process_reports", "memory_grant_stats", "waiting_tasks",
+ "running_jobs", "database_size_stats", "server_properties",
+ "session_stats", "server_config", "database_config",
+ "database_scoped_config", "trace_flags", "config_alert_log",
+ "collection_log"
];
///
@@ -299,6 +322,12 @@ private async Task SetSchemaVersionAsync(DuckDBConnection connection, int versio
///
/// Runs schema migrations from the given version up to CurrentSchemaVersion.
/// Each migration drops and recreates affected tables.
+ ///
+ /// IMPORTANT: When adding a new data collection table, you must also register it in:
+ /// 1. Schema.cs — GetAllTableStatements() and GetAllIndexStatements()
+ /// 2. DuckDbInitializer.cs — ArchivableTables (archive view creation)
+ /// 3. ArchiveService.cs — ArchivableTables (parquet export + purge)
+ /// Forgetting any of these causes unbounded growth and 512 MB reset loops.
///
private async Task RunMigrationsAsync(DuckDBConnection connection, int fromVersion)
{
@@ -479,6 +508,79 @@ Must drop/recreate because DuckDB appender writes by position. */
_logger?.LogInformation("Running migration to v15: rebuilding file_io_stats for queued I/O columns");
await ExecuteNonQueryAsync(connection, "DROP TABLE IF EXISTS file_io_stats");
}
+
+ if (fromVersion < 16)
+ {
+ /* v16: Added database_size_stats and server_properties tables for FinOps monitoring.
+ New tables only — no existing table changes needed. Tables created by
+ GetAllTableStatements() during initialization. */
+ _logger?.LogInformation("Running migration to v16: adding FinOps tables (database_size_stats, server_properties)");
+ }
+
+ if (fromVersion < 17)
+ {
+ /* v17: Added volume-level drive space columns to database_size_stats.
+ Columns appended at end — safe for DuckDB appender positional writes. */
+ _logger?.LogInformation("Running migration to v17: adding volume stats columns to database_size_stats");
+ try
+ {
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_mount_point VARCHAR");
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_total_mb DECIMAL(19,2)");
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE database_size_stats ADD COLUMN IF NOT EXISTS volume_free_mb DECIMAL(19,2)");
+ }
+ catch
+ {
+ /* Table doesn't exist yet — will be created with correct schema below */
+ }
+ }
+
+ if (fromVersion < 18)
+ {
+ /* v18: Added session_stats table for per-application connection tracking
+ from sys.dm_exec_sessions. New table only — created by GetAllTableStatements(). */
+ _logger?.LogInformation("Running migration to v18: adding session_stats table for application connections");
+ }
+
+ if (fromVersion < 19)
+ {
+ _logger?.LogInformation("Running migration to v19: adding worker thread columns to memory_stats");
+ try
+ {
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE memory_stats ADD COLUMN IF NOT EXISTS max_workers_count INTEGER");
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE memory_stats ADD COLUMN IF NOT EXISTS current_workers_count INTEGER");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning("Migration to v19 encountered an error (non-fatal): {Error}", ex.Message);
+ }
+ }
+
+ if (fromVersion < 20)
+ {
+ _logger?.LogInformation("Running migration to v20: adding mute rules table and muted column to alert log");
+ try
+ {
+ /* DuckDB does not support ADD COLUMN with NOT NULL — use nullable with DEFAULT */
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE config_alert_log ADD COLUMN IF NOT EXISTS muted BOOLEAN DEFAULT false");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning("Migration to v20 encountered an error (non-fatal): {Error}", ex.Message);
+ }
+ }
+
+ if (fromVersion < 21)
+ {
+ _logger?.LogInformation("Running migration to v21: adding detail_text column to alert log");
+ try
+ {
+ await ExecuteNonQueryAsync(connection, "ALTER TABLE config_alert_log ADD COLUMN IF NOT EXISTS detail_text VARCHAR");
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogWarning("Migration to v21 encountered an error (non-fatal): {Error}", ex.Message);
+ }
+ }
}
///
@@ -657,6 +759,31 @@ public double GetDatabaseSizeMb()
return fileInfo.Length / (1024.0 * 1024.0);
}
+ ///
+ /// Gets the actual used data size inside the database by querying pragma_database_size().
+ /// Returns null if the query fails (e.g., database busy).
+ ///
+ public double? GetUsedDataSizeMb()
+ {
+ try
+ {
+ using var connection = CreateConnection();
+ connection.Open();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "SELECT (used_blocks * block_size)::BIGINT FROM pragma_database_size()";
+ var result = cmd.ExecuteScalar();
+ if (result != null && result != DBNull.Value)
+ {
+ return Convert.ToInt64(result) / (1024.0 * 1024.0);
+ }
+ }
+ catch
+ {
+ /* Database may be busy — fall back to null */
+ }
+ return null;
+ }
+
///
/// Deletes the database and WAL files, then reinitializes with fresh empty tables
/// and archive views pointing at the parquet files.
diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs
index af4f3912..90ac3492 100644
--- a/Lite/Database/Schema.cs
+++ b/Lite/Database/Schema.cs
@@ -175,7 +175,9 @@ available_page_file_mb DECIMAL(18,2),
target_server_memory_mb DECIMAL(18,2),
total_server_memory_mb DECIMAL(18,2),
buffer_pool_mb DECIMAL(18,2),
- plan_cache_mb DECIMAL(18,2)
+ plan_cache_mb DECIMAL(18,2),
+ max_workers_count INTEGER,
+ current_workers_count INTEGER
)";
public const string CreateMemoryClerksTable = @"
@@ -591,6 +593,78 @@ percent_of_average DECIMAL(10,1)
public const string CreateRunningJobsIndex = @"
CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time)";
+ public const string CreateDatabaseSizeStatsTable = @"
+CREATE TABLE IF NOT EXISTS database_size_stats (
+ collection_id BIGINT PRIMARY KEY,
+ collection_time TIMESTAMP NOT NULL,
+ server_id INTEGER NOT NULL,
+ server_name VARCHAR NOT NULL,
+ database_name VARCHAR NOT NULL,
+ database_id INTEGER NOT NULL,
+ file_id INTEGER NOT NULL,
+ file_type_desc VARCHAR NOT NULL,
+ file_name VARCHAR NOT NULL,
+ physical_name VARCHAR NOT NULL,
+ total_size_mb DECIMAL(19,2) NOT NULL,
+ used_size_mb DECIMAL(19,2),
+ auto_growth_mb DECIMAL(19,2),
+ max_size_mb DECIMAL(19,2),
+ recovery_model_desc VARCHAR,
+ compatibility_level INTEGER,
+ state_desc VARCHAR,
+ volume_mount_point VARCHAR,
+ volume_total_mb DECIMAL(19,2),
+ volume_free_mb DECIMAL(19,2)
+)";
+
+ public const string CreateDatabaseSizeStatsIndex = @"
+CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time)";
+
+ public const string CreateServerPropertiesTable = @"
+CREATE TABLE IF NOT EXISTS server_properties (
+ collection_id BIGINT PRIMARY KEY,
+ collection_time TIMESTAMP NOT NULL,
+ server_id INTEGER NOT NULL,
+ server_name VARCHAR NOT NULL,
+ edition VARCHAR NOT NULL,
+ product_version VARCHAR NOT NULL,
+ product_level VARCHAR NOT NULL,
+ product_update_level VARCHAR,
+ engine_edition INTEGER NOT NULL,
+ cpu_count INTEGER NOT NULL,
+ hyperthread_ratio INTEGER NOT NULL,
+ physical_memory_mb BIGINT NOT NULL,
+ socket_count INTEGER,
+ cores_per_socket INTEGER,
+ is_hadr_enabled BOOLEAN,
+ is_clustered BOOLEAN,
+ enterprise_features VARCHAR,
+ service_objective VARCHAR
+)";
+
+ public const string CreateServerPropertiesIndex = @"
+CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time)";
+
+ public const string CreateSessionStatsTable = @"
+CREATE TABLE IF NOT EXISTS session_stats (
+ collection_id BIGINT PRIMARY KEY,
+ collection_time TIMESTAMP NOT NULL,
+ server_id INTEGER NOT NULL,
+ server_name VARCHAR NOT NULL,
+ program_name VARCHAR NOT NULL,
+ connection_count INTEGER NOT NULL,
+ running_count INTEGER NOT NULL,
+ sleeping_count INTEGER NOT NULL,
+ dormant_count INTEGER NOT NULL,
+ total_cpu_time_ms BIGINT,
+ total_reads BIGINT,
+ total_writes BIGINT,
+ total_logical_reads BIGINT
+)";
+
+ public const string CreateSessionStatsIndex = @"
+CREATE INDEX IF NOT EXISTS idx_session_stats_time ON session_stats(server_id, collection_time)";
+
public const string CreateAlertLogTable = @"
CREATE TABLE IF NOT EXISTS config_alert_log (
alert_time TIMESTAMP NOT NULL,
@@ -602,7 +676,24 @@ CREATE TABLE IF NOT EXISTS config_alert_log (
alert_sent BOOLEAN NOT NULL DEFAULT false,
notification_type VARCHAR NOT NULL DEFAULT 'tray',
send_error VARCHAR,
- dismissed BOOLEAN NOT NULL DEFAULT false
+ dismissed BOOLEAN NOT NULL DEFAULT false,
+ muted BOOLEAN NOT NULL DEFAULT false,
+ detail_text VARCHAR
+)";
+
+ public const string CreateMuteRulesTable = @"
+CREATE TABLE IF NOT EXISTS config_mute_rules (
+ id VARCHAR NOT NULL PRIMARY KEY,
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ created_at_utc TIMESTAMP NOT NULL,
+ expires_at_utc TIMESTAMP,
+ reason VARCHAR,
+ server_name VARCHAR,
+ metric_name VARCHAR,
+ database_pattern VARCHAR,
+ query_text_pattern VARCHAR,
+ wait_type_pattern VARCHAR,
+ job_name_pattern VARCHAR
)";
///
@@ -633,7 +724,11 @@ public static IEnumerable GetAllTableStatements()
yield return CreateDatabaseScopedConfigTable;
yield return CreateTraceFlagsTable;
yield return CreateRunningJobsTable;
+ yield return CreateDatabaseSizeStatsTable;
+ yield return CreateServerPropertiesTable;
+ yield return CreateSessionStatsTable;
yield return CreateAlertLogTable;
+ yield return CreateMuteRulesTable;
}
///
@@ -660,5 +755,8 @@ public static IEnumerable GetAllIndexStatements()
yield return CreateDatabaseScopedConfigIndex;
yield return CreateTraceFlagsIndex;
yield return CreateRunningJobsIndex;
+ yield return CreateDatabaseSizeStatsIndex;
+ yield return CreateServerPropertiesIndex;
+ yield return CreateSessionStatsIndex;
}
}
diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs
index 207f374a..4450cfba 100644
--- a/Lite/Helpers/ChartHoverHelper.cs
+++ b/Lite/Helpers/ChartHoverHelper.cs
@@ -61,6 +61,45 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
_scatters.Add((scatter, label));
+ ///
+ /// Returns the nearest series label and data-point time for the given mouse position,
+ /// or null if no series is close enough.
+ ///
+ public (string Label, DateTime Time)? GetNearestSeries(Point mousePos)
+ {
+ if (_scatters.Count == 0) return null;
+ var dpi = VisualTreeHelper.GetDpi(_chart);
+ var pixel = new ScottPlot.Pixel(
+ (float)(mousePos.X * dpi.DpiScaleX),
+ (float)(mousePos.Y * dpi.DpiScaleY));
+ var mouseCoords = _chart.Plot.GetCoordinates(pixel);
+
+ double bestDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestLabel = "";
+
+ foreach (var (scatter, label) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+ var nearestPixel = _chart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = nearestPixel.X - pixel.X;
+ double dy = nearestPixel.Y - pixel.Y;
+ double dist = dx * dx + dy * dy;
+ if (dist < bestDistance)
+ {
+ bestDistance = dist;
+ bestPoint = nearest;
+ bestLabel = label;
+ }
+ }
+
+ if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius
+ return (bestLabel, DateTime.FromOADate(bestPoint.X));
+ return null;
+ }
+
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_scatters.Count == 0) return;
@@ -69,9 +108,10 @@ private void OnMouseMove(object sender, MouseEventArgs e)
_lastUpdate = now;
var pos = e.GetPosition(_chart);
+ var dpi = VisualTreeHelper.GetDpi(_chart);
var pixel = new ScottPlot.Pixel(
- (float)(pos.X * _chart.DisplayScale),
- (float)(pos.Y * _chart.DisplayScale));
+ (float)(pos.X * dpi.DpiScaleX),
+ (float)(pos.Y * dpi.DpiScaleY));
var mouseCoords = _chart.Plot.GetCoordinates(pixel);
double bestDistance = double.MaxValue;
diff --git a/Lite/Helpers/ContextMenuHelper.cs b/Lite/Helpers/ContextMenuHelper.cs
index 01bfe4de..70e0ecf3 100644
--- a/Lite/Helpers/ContextMenuHelper.cs
+++ b/Lite/Helpers/ContextMenuHelper.cs
@@ -181,7 +181,7 @@ private static string CsvEscape(string value, string separator)
/// Sets up a context menu for a ScottPlot chart with standard options:
/// Copy Image, Save Image As, Open in New Window, Revert, Export Data to CSV.
///
- public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
+ public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
{
var contextMenu = new ContextMenu();
@@ -369,5 +369,7 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string
chart.Plot.Axes.AutoScale();
chart.Refresh();
};
+
+ return contextMenu;
}
}
diff --git a/Lite/Helpers/MethodProfiler.cs b/Lite/Helpers/MethodProfiler.cs
new file mode 100644
index 00000000..d1db0ec1
--- /dev/null
+++ b/Lite/Helpers/MethodProfiler.cs
@@ -0,0 +1,152 @@
+/*
+ * SQL Server Performance Monitor Lite
+ *
+ * Method profiler for tracking slow application code
+ */
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace PerformanceMonitorLite.Helpers;
+
+///
+/// Profiles method execution time and logs slow methods.
+/// Usage: using var _ = MethodProfiler.StartTiming("context");
+///
+public static class MethodProfiler
+{
+ private static string s_logDirectory = "";
+ private static readonly object _lock = new();
+ private static volatile bool _isEnabled = true;
+ private static double _thresholdMs = 500;
+
+ public static void Initialize(string logDirectory)
+ {
+ s_logDirectory = logDirectory;
+ try
+ {
+ if (!Directory.Exists(s_logDirectory))
+ Directory.CreateDirectory(s_logDirectory);
+ CleanOldLogs();
+ }
+ catch { }
+ }
+
+ public static string GetCurrentLogFile()
+ => Path.Combine(s_logDirectory, $"MethodProfile_{DateTime.Now:yyyyMMdd}.log");
+
+ public static string GetLogDirectory() => s_logDirectory;
+
+ public static void SetEnabled(bool enabled) => _isEnabled = enabled;
+ public static bool IsEnabled => _isEnabled;
+
+ public static void SetThresholdMs(double thresholdMs) => _thresholdMs = thresholdMs;
+ public static double ThresholdMs => _thresholdMs;
+
+ public static MethodTimingContext StartTiming(
+ string? context = null,
+ [CallerMemberName] string memberName = "",
+ [CallerFilePath] string filePath = "",
+ [CallerLineNumber] int lineNumber = 0)
+ {
+ return new MethodTimingContext(context, memberName, filePath, lineNumber);
+ }
+
+ internal static void LogSlowMethod(
+ DateTime startTime,
+ DateTime endTime,
+ double elapsedMs,
+ string? context,
+ string memberName,
+ string filePath,
+ int lineNumber)
+ {
+ if (!_isEnabled || string.IsNullOrEmpty(s_logDirectory))
+ return;
+
+ if (elapsedMs < _thresholdMs)
+ return;
+
+ try
+ {
+ lock (_lock)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "SLOW METHOD: {0:F0}ms - {1}", elapsedMs, memberName));
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Start Time: {0:yyyy-MM-dd HH:mm:ss.fff}", startTime));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "End Time: {0:yyyy-MM-dd HH:mm:ss.fff}", endTime));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Elapsed: {0:F0}ms", elapsedMs));
+
+ if (!string.IsNullOrEmpty(context))
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Context: {0}", context));
+
+ var fileName = Path.GetFileName(filePath);
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Location: {0}:{1}", fileName, lineNumber));
+ sb.AppendLine();
+
+ File.AppendAllText(GetCurrentLogFile(), sb.ToString());
+ }
+ }
+ catch { }
+ }
+
+ private static void CleanOldLogs()
+ {
+ try
+ {
+ var files = Directory.GetFiles(s_logDirectory, "MethodProfile_*.log");
+ var cutoff = DateTime.Now.AddDays(-7);
+ foreach (var file in files)
+ {
+ if (new FileInfo(file).CreationTime < cutoff)
+ File.Delete(file);
+ }
+ }
+ catch { }
+ }
+}
+
+///
+/// Disposable context for timing method execution.
+///
+public sealed class MethodTimingContext : IDisposable
+{
+ private readonly DateTime _startTime;
+ private readonly Stopwatch _stopwatch;
+ private readonly string? _context;
+ private readonly string _memberName;
+ private readonly string _filePath;
+ private readonly int _lineNumber;
+ private bool _disposed;
+
+ internal MethodTimingContext(string? context, string memberName, string filePath, int lineNumber)
+ {
+ _startTime = DateTime.Now;
+ _stopwatch = Stopwatch.StartNew();
+ _context = context;
+ _memberName = memberName;
+ _filePath = filePath;
+ _lineNumber = lineNumber;
+ }
+
+ public double ElapsedMs => _stopwatch.Elapsed.TotalMilliseconds;
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _stopwatch.Stop();
+
+ MethodProfiler.LogSlowMethod(
+ _startTime, DateTime.Now, _stopwatch.Elapsed.TotalMilliseconds,
+ _context, _memberName, _filePath, _lineNumber);
+
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/Lite/Helpers/QueryLogger.cs b/Lite/Helpers/QueryLogger.cs
new file mode 100644
index 00000000..d3198bf2
--- /dev/null
+++ b/Lite/Helpers/QueryLogger.cs
@@ -0,0 +1,163 @@
+/*
+ * SQL Server Performance Monitor Lite
+ *
+ * Query logger for tracking slow DuckDB and SQL Server queries
+ */
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+
+namespace PerformanceMonitorLite.Helpers;
+
+///
+/// Logs slow queries to a dedicated log file for performance analysis.
+/// Covers both DuckDB reads and SQL Server collector queries.
+/// Usage: using var _ = QueryLogger.StartQuery("context", sql, source: "DuckDB");
+///
+public static class QueryLogger
+{
+ private static string s_logDirectory = "";
+ private static readonly object _lock = new();
+ private static volatile bool _isEnabled = true;
+ private static double _thresholdSeconds = 0.5;
+
+ public static void Initialize(string logDirectory)
+ {
+ s_logDirectory = logDirectory;
+ try
+ {
+ if (!Directory.Exists(s_logDirectory))
+ Directory.CreateDirectory(s_logDirectory);
+ CleanOldLogs();
+ }
+ catch { }
+ }
+
+ public static string GetCurrentLogFile()
+ => Path.Combine(s_logDirectory, $"SlowQueries_{DateTime.Now:yyyyMMdd}.log");
+
+ public static string GetLogDirectory() => s_logDirectory;
+
+ public static void SetEnabled(bool enabled) => _isEnabled = enabled;
+ public static bool IsEnabled => _isEnabled;
+
+ public static void SetThreshold(double thresholdSeconds) => _thresholdSeconds = thresholdSeconds;
+ public static double ThresholdSeconds => _thresholdSeconds;
+
+ public static void LogSlowQuery(
+ DateTime startTime,
+ DateTime endTime,
+ double elapsedMs,
+ string context,
+ string queryText,
+ string? source = null,
+ string? serverName = null)
+ {
+ if (!_isEnabled || string.IsNullOrEmpty(s_logDirectory))
+ return;
+
+ double elapsedSeconds = elapsedMs / 1000.0;
+ if (elapsedSeconds < _thresholdSeconds)
+ return;
+
+ try
+ {
+ lock (_lock)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("================================================================================");
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "SLOW QUERY DETECTED - {0:F3} seconds", elapsedSeconds));
+ sb.AppendLine("================================================================================");
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Start Time: {0:yyyy-MM-dd HH:mm:ss.fff}", startTime));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "End Time: {0:yyyy-MM-dd HH:mm:ss.fff}", endTime));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Elapsed: {0:F3} seconds ({1:N0} ms)", elapsedSeconds, elapsedMs));
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Context: {0}", context));
+
+ if (!string.IsNullOrEmpty(source))
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Source: {0}", source));
+
+ if (!string.IsNullOrEmpty(serverName))
+ sb.AppendLine(string.Format(CultureInfo.InvariantCulture, "Server: {0}", serverName));
+
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine("Query:");
+ sb.AppendLine("--------------------------------------------------------------------------------");
+ sb.AppendLine(queryText);
+ sb.AppendLine("================================================================================");
+ sb.AppendLine();
+
+ File.AppendAllText(GetCurrentLogFile(), sb.ToString());
+ }
+ }
+ catch { }
+ }
+
+ ///
+ /// Creates a query execution context for timing queries.
+ ///
+ public static QueryExecutionContext StartQuery(
+ string context,
+ string queryText,
+ string? source = null,
+ string? serverName = null)
+ {
+ return new QueryExecutionContext(context, queryText, source, serverName);
+ }
+
+ private static void CleanOldLogs()
+ {
+ try
+ {
+ var files = Directory.GetFiles(s_logDirectory, "SlowQueries_*.log");
+ var cutoff = DateTime.Now.AddDays(-7);
+ foreach (var file in files)
+ {
+ if (new FileInfo(file).CreationTime < cutoff)
+ File.Delete(file);
+ }
+ }
+ catch { }
+ }
+}
+
+///
+/// Disposable context for timing query execution.
+///
+public sealed class QueryExecutionContext : IDisposable
+{
+ private readonly DateTime _startTime;
+ private readonly Stopwatch _stopwatch;
+ private readonly string _context;
+ private readonly string _queryText;
+ private readonly string? _source;
+ private readonly string? _serverName;
+ private bool _disposed;
+
+ internal QueryExecutionContext(string context, string queryText, string? source, string? serverName)
+ {
+ _startTime = DateTime.Now;
+ _stopwatch = Stopwatch.StartNew();
+ _context = context;
+ _queryText = queryText;
+ _source = source;
+ _serverName = serverName;
+ }
+
+ public double ElapsedMs => _stopwatch.Elapsed.TotalMilliseconds;
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _stopwatch.Stop();
+
+ QueryLogger.LogSlowQuery(
+ _startTime, DateTime.Now, _stopwatch.Elapsed.TotalMilliseconds,
+ _context, _queryText, _source, _serverName);
+
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/Lite/Helpers/WaitDrillDownHelper.cs b/Lite/Helpers/WaitDrillDownHelper.cs
new file mode 100644
index 00000000..fec73a60
--- /dev/null
+++ b/Lite/Helpers/WaitDrillDownHelper.cs
@@ -0,0 +1,169 @@
+/*
+ * 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 System.Collections.Generic;
+using System.Linq;
+
+namespace PerformanceMonitorLite.Helpers;
+
+///
+/// Classifies wait types for drill-down behavior and walks blocking chains
+/// to find head blockers. Used by WaitDrillDownWindow.
+///
+public static class WaitDrillDownHelper
+{
+ public enum WaitCategory
+ {
+ /// Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric.
+ Correlated,
+ /// Walk blocking chain to find head blockers (LCK_M_*).
+ Chain,
+ /// Sessions may lack worker threads, unlikely to appear in snapshots.
+ Uncapturable,
+ /// Attempt direct wait_type filter; may return empty for brief waits.
+ Filtered
+ }
+
+ public sealed record WaitClassification(
+ WaitCategory Category,
+ string SortProperty,
+ string Description
+ );
+
+ ///
+ /// Lightweight result from the chain walker — just the head blocker identity and blocked count.
+ /// Callers look up the original full row by (CollectionTime, SessionId).
+ ///
+ public sealed record HeadBlockerInfo(
+ DateTime CollectionTime,
+ int SessionId,
+ int BlockedSessionCount,
+ string BlockingPath
+ );
+
+ public sealed record SnapshotInfo
+ {
+ public int SessionId { get; init; }
+ public int BlockingSessionId { get; init; }
+ public DateTime CollectionTime { get; init; }
+ public string DatabaseName { get; init; } = "";
+ public string Status { get; init; } = "";
+ public string QueryText { get; init; } = "";
+ public string? WaitType { get; init; }
+ public long WaitTimeMs { get; init; }
+ public long CpuTimeMs { get; init; }
+ public long Reads { get; init; }
+ public long Writes { get; init; }
+ public long LogicalReads { get; init; }
+ }
+
+ private const int MaxChainDepth = 20;
+
+ public static WaitClassification Classify(string waitType)
+ {
+ if (string.IsNullOrEmpty(waitType))
+ return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown");
+
+ return waitType switch
+ {
+ "SOS_SCHEDULER_YIELD" =>
+ new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"),
+ "WRITELOG" =>
+ new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"),
+ "CXPACKET" or "CXCONSUMER" =>
+ new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"),
+ "RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" =>
+ new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"),
+ "THREADPOOL" =>
+ new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"),
+ "LATCH_EX" or "LATCH_UP" =>
+ new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"),
+ _ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) =>
+ new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"),
+ _ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) =>
+ new(WaitCategory.Chain, "", "Lock contention — showing head blockers"),
+ _ =>
+ new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type")
+ };
+ }
+
+ ///
+ /// Walks blocking chains to find head blockers.
+ /// Returns lightweight HeadBlockerInfo records — callers look up original full rows
+ /// by (CollectionTime, SessionId) to preserve all columns.
+ ///
+ public static List WalkBlockingChains(
+ IEnumerable waiters,
+ IEnumerable allSnapshots)
+ {
+ var byTime = allSnapshots
+ .GroupBy(s => s.CollectionTime)
+ .ToDictionary(
+ g => g.Key,
+ g => g.ToDictionary(s => s.SessionId));
+
+ var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet BlockedSessions)>();
+
+ foreach (var waiter in waiters)
+ {
+ if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime))
+ continue;
+
+ var head = FindHeadBlocker(waiter, sessionsAtTime);
+ if (head == null)
+ continue;
+
+ var key = (waiter.CollectionTime, head.SessionId);
+ if (!headBlockers.TryGetValue(key, out var existing))
+ {
+ existing = (head, new HashSet());
+ headBlockers[key] = existing;
+ }
+
+ existing.BlockedSessions.Add(waiter.SessionId);
+ }
+
+ return headBlockers.Values
+ .Select(hb => new HeadBlockerInfo(
+ hb.Info.CollectionTime,
+ hb.Info.SessionId,
+ hb.BlockedSessions.Count,
+ $"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)"))
+ .OrderByDescending(r => r.BlockedSessionCount)
+ .ThenByDescending(r => r.CollectionTime)
+ .ToList();
+ }
+
+ private static SnapshotInfo? FindHeadBlocker(
+ SnapshotInfo waiter,
+ Dictionary sessionsAtTime)
+ {
+ var visited = new HashSet();
+ var current = waiter;
+
+ for (int depth = 0; depth < MaxChainDepth; depth++)
+ {
+ if (!visited.Add(current.SessionId))
+ return current; // cycle detected — treat current as head
+
+ var blockerId = current.BlockingSessionId;
+
+ // Head blocker: not blocked by anyone, or blocked by self, or blocker not found
+ if (blockerId <= 0 || blockerId == current.SessionId)
+ return current;
+
+ if (!sessionsAtTime.TryGetValue(blockerId, out var blocker))
+ return current; // blocker not in snapshot — treat current as head
+
+ current = blocker;
+ }
+
+ return current; // max depth — treat current as head
+ }
+}
diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml
index d29fda09..72b1b361 100644
--- a/Lite/MainWindow.xaml
+++ b/Lite/MainWindow.xaml
@@ -121,14 +121,14 @@
@@ -279,6 +279,9 @@
+
+
+
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index 2f537d2f..197fc19c 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -10,6 +10,8 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
+using System.Xml.Linq;
+using System.Net;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
@@ -43,11 +45,11 @@ public partial class MainWindow : Window
private readonly Dictionary _lastLongRunningQueryAlert = new();
private readonly Dictionary _lastTempDbSpaceAlert = new();
private readonly Dictionary _lastLongRunningJobAlert = new();
- private static readonly TimeSpan AlertCooldown = TimeSpan.FromMinutes(5);
private readonly DispatcherTimer _statusTimer;
private LocalDataService? _dataService;
private McpHostService? _mcpService;
private readonly AlertStateService _alertStateService = new();
+ private readonly MuteRuleService _muteRuleService;
private EmailAlertService _emailAlertService;
/* Track active alert states for resolved notifications */
@@ -66,6 +68,7 @@ public MainWindow()
// Initialize services (with loggers wired to AppLogger)
_databaseInitializer = new DuckDbInitializer(App.DatabasePath, new AppLoggerAdapter());
_emailAlertService = new EmailAlertService(_databaseInitializer);
+ _muteRuleService = new MuteRuleService(_databaseInitializer);
_serverManager = new ServerManager(App.ConfigDirectory, logger: new AppLoggerAdapter());
_scheduleManager = new ScheduleManager(App.ConfigDirectory);
@@ -122,16 +125,18 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
// Initialize data service for overview
_dataService = new LocalDataService(_databaseInitializer);
+ // Load mute rules from database
+ await _muteRuleService.LoadAsync();
+
// Initialize alerts history tab
AlertsHistoryContent.Initialize(_dataService);
+ AlertsHistoryContent.MuteRuleService = _muteRuleService;
+
+ // Initialize FinOps tab
+ FinOpsContent.Initialize(_dataService, _serverManager);
// Start MCP server if enabled
- var mcpSettings = McpSettings.Load(App.ConfigDirectory);
- if (mcpSettings.Enabled)
- {
- _mcpService = new McpHostService(_dataService, _serverManager, mcpSettings.Port);
- _ = _mcpService.StartAsync(_backgroundCts!.Token);
- }
+ await StartMcpServerAsync();
// Load servers
RefreshServerList();
@@ -195,18 +200,7 @@ private async void MainWindow_Closing(object? sender, System.ComponentModel.Canc
// Stop background collection with timeout
_backgroundCts?.Cancel();
- if (_mcpService != null)
- {
- try
- {
- using var mcpShutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
- await _mcpService.StopAsync(mcpShutdownCts.Token);
- }
- catch (OperationCanceledException)
- {
- /* MCP shutdown timed out */
- }
- }
+ await StopMcpServerAsync();
if (_backgroundService != null)
{
@@ -235,10 +229,14 @@ private async void MainWindow_Closing(object? sender, System.ComponentModel.Canc
private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
+ // Only respond to tab selection changes, not child control selection events that bubble up
+ if (e.OriginalSource != ServerTabControl) return;
+
/* Restore the selected tab's UTC offset so charts use the correct server timezone */
if (ServerTabControl.SelectedItem is TabItem { Content: ServerTab serverTab })
{
ServerTimeHelper.UtcOffsetMinutes = serverTab.UtcOffsetMinutes;
+ StatusText.Text = $"Connected to {serverTab.Server.DisplayNameWithIntent}";
}
/* Refresh alerts tab when selected */
@@ -250,6 +248,46 @@ private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEv
UpdateCollectorHealth();
}
+ private async Task StartMcpServerAsync()
+ {
+ var mcpSettings = McpSettings.Load(App.ConfigDirectory);
+ if (!mcpSettings.Enabled) return;
+
+ try
+ {
+ bool portInUse = await PortUtilityService.IsTcpPortListeningAsync(mcpSettings.Port, IPAddress.Loopback);
+ if (portInUse)
+ {
+ AppLogger.Error("MCP", $"Port {mcpSettings.Port} is already in use — MCP server not started");
+ return;
+ }
+
+ _mcpService = new McpHostService(_dataService!, _serverManager, _muteRuleService, mcpSettings.Port);
+ _ = _mcpService.StartAsync(_backgroundCts!.Token);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("MCP", $"Failed to start MCP server: {ex.Message}");
+ }
+ }
+
+ private async Task StopMcpServerAsync()
+ {
+ if (_mcpService != null)
+ {
+ try
+ {
+ using var shutdownCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await _mcpService.StopAsync(shutdownCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ /* MCP shutdown timed out */
+ }
+ _mcpService = null;
+ }
+ }
+
private void RefreshServerList()
{
var servers = _serverManager.GetAllServers();
@@ -276,6 +314,9 @@ private void RefreshServerList()
ServerCountText.Text = $"Servers: {servers.Count}";
+ // Refresh FinOps server dropdown when server list changes
+ FinOpsContent.RefreshServerList();
+
// Refresh overview when server list changes
_ = RefreshOverviewAsync();
}
@@ -283,10 +324,18 @@ private void RefreshServerList()
private void UpdateStatusBar()
{
// Update database size
- var sizeMb = _databaseInitializer.GetDatabaseSizeMb();
- DatabaseSizeText.Text = sizeMb > 0
- ? $"Database: {sizeMb:F1} MB"
- : "Database: New";
+ var fileSizeMb = _databaseInitializer.GetDatabaseSizeMb();
+ var usedSizeMb = _databaseInitializer.GetUsedDataSizeMb();
+ if (fileSizeMb > 0)
+ {
+ DatabaseSizeText.Text = usedSizeMb.HasValue
+ ? $"Database: {usedSizeMb.Value:F1} / {fileSizeMb:F1} MB"
+ : $"Database: {fileSizeMb:F1} MB";
+ }
+ else
+ {
+ DatabaseSizeText.Text = "Database: New";
+ }
// Update collection status
if (_backgroundService != null)
@@ -377,8 +426,8 @@ private async Task RefreshOverviewAsync()
{
try
{
- var serverId = RemoteCollectorService.GetDeterministicHashCode(server.ServerName);
- var summary = await _dataService.GetServerSummaryAsync(serverId, server.DisplayName);
+ var serverId = RemoteCollectorService.GetDeterministicHashCode(RemoteCollectorService.GetServerNameForStorage(server));
+ var summary = await _dataService.GetServerSummaryAsync(serverId, server.DisplayNameWithIntent);
if (summary != null)
{
summary.ServerName = server.ServerName;
@@ -524,23 +573,23 @@ private async void ConnectToServer(ServerConnection server)
// Then collect fresh data and refresh again
if (_collectorService != null)
{
- StatusText.Text = $"Collecting data from {server.DisplayName}...";
+ StatusText.Text = $"Collecting data from {server.DisplayNameWithIntent}...";
try
{
await _collectorService.RunAllCollectorsForServerAsync(server);
- StatusText.Text = $"Connected to {server.DisplayName} - Data loaded";
+ StatusText.Text = $"Connected to {server.DisplayNameWithIntent} - Data loaded";
serverTab.RefreshData();
UpdateCollectorHealth();
_ = RefreshOverviewAsync();
}
catch (Exception ex)
{
- StatusText.Text = $"Connected to {server.DisplayName} - Collection error: {ex.Message}";
+ StatusText.Text = $"Connected to {server.DisplayNameWithIntent} - Collection error: {ex.Message}";
}
}
else
{
- StatusText.Text = $"Connected to {server.DisplayName}";
+ StatusText.Text = $"Connected to {server.DisplayNameWithIntent}";
}
}
@@ -548,9 +597,10 @@ private StackPanel CreateTabHeader(ServerConnection server)
{
var panel = new StackPanel { Orientation = Orientation.Horizontal };
+ var tabLabel = server.ReadOnlyIntent ? $"{server.DisplayName} (RO)" : server.DisplayName;
panel.Children.Add(new TextBlock
{
- Text = server.DisplayName,
+ Text = tabLabel,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0)
});
@@ -756,7 +806,7 @@ private void AddServerButton_Click(object sender, RoutedEventArgs e)
if (dialog.ShowDialog() == true && dialog.AddedServer != null)
{
RefreshServerList();
- StatusText.Text = $"Added server: {dialog.AddedServer.DisplayName}";
+ StatusText.Text = $"Added server: {dialog.AddedServer.DisplayNameWithIntent}";
}
}
@@ -771,11 +821,17 @@ private void ManageServersButton_Click(object sender, RoutedEventArgs e)
}
}
- private void SettingsButton_Click(object sender, RoutedEventArgs e)
+ private async void SettingsButton_Click(object sender, RoutedEventArgs e)
{
- var window = new SettingsWindow(_scheduleManager, _backgroundService, _mcpService) { Owner = this };
+ var window = new SettingsWindow(_scheduleManager, _backgroundService, _mcpService, _muteRuleService) { Owner = this };
window.ShowDialog();
UpdateStatusBar();
+
+ if (window.McpSettingsChanged)
+ {
+ await StopMcpServerAsync();
+ await StartMcpServerAsync();
+ }
}
private void AboutButton_Click(object sender, RoutedEventArgs e)
@@ -835,7 +891,7 @@ private void ServerContextMenu_Remove_Click(object sender, RoutedEventArgs e)
if (server == null) return;
var result = MessageBox.Show(
- $"Remove server '{server.DisplayName}'?",
+ $"Remove server '{server.DisplayNameWithIntent}'?",
"Remove Server",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
@@ -845,7 +901,7 @@ private void ServerContextMenu_Remove_Click(object sender, RoutedEventArgs e)
CloseServerTab(server.Id);
_serverManager.DeleteServer(server.Id);
RefreshServerList();
- StatusText.Text = $"Removed server: {server.DisplayName}";
+ StatusText.Text = $"Removed server: {server.DisplayNameWithIntent}";
}
}
@@ -916,14 +972,14 @@ private void CheckConnectionsAndNotify()
{
_trayService?.ShowNotification(
"Server Offline",
- $"{server.DisplayName} is unreachable: {status.ErrorMessage ?? "unknown error"}",
+ $"{server.DisplayNameWithIntent} is unreachable: {status.ErrorMessage ?? "unknown error"}",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
}
else if (!wasOnline && isOnline)
{
_trayService?.ShowNotification(
"Server Online",
- $"{server.DisplayName} is back online",
+ $"{server.DisplayNameWithIntent} is back online",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Info);
}
}
@@ -963,6 +1019,7 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary)
var key = summary.ServerId.ToString();
var now = DateTime.UtcNow;
+ var alertCooldown = TimeSpan.FromMinutes(App.AlertCooldownMinutes);
/* Skip popup/email alerts if user has acknowledged or silenced this server */
bool suppressPopups = !_alertStateService.ShouldShowAlerts(key);
@@ -975,20 +1032,30 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary)
if (cpuExceeded)
{
_activeCpuAlert[key] = true;
- if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= AlertCooldown))
+ if (!suppressPopups && (!_lastCpuAlert.TryGetValue(key, out var lastCpu) || now - lastCpu >= alertCooldown))
{
- _trayService.ShowNotification(
- "High CPU",
- $"{summary.DisplayName}: CPU at {summary.CpuPercent:F0}% (threshold: {App.AlertCpuThreshold}%)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "High CPU" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastCpuAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "High CPU",
+ $"{summary.DisplayName}: CPU at {summary.CpuPercent:F0}% (threshold: {App.AlertCpuThreshold}%)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
+ var cpuDetailText = $" CPU: {summary.CpuPercent:F0}%\n Threshold: {App.AlertCpuThreshold}%";
+
await _emailAlertService.TrySendAlertEmailAsync(
"High CPU",
summary.DisplayName,
$"{summary.CpuPercent:F0}%",
$"{App.AlertCpuThreshold}%",
- summary.ServerId);
+ summary.ServerId,
+ muted: isMuted,
+ detailText: cpuDetailText);
}
}
else if (_activeCpuAlert.TryGetValue(key, out var wasCpu) && wasCpu)
@@ -1001,29 +1068,56 @@ await _emailAlertService.TrySendAlertEmailAsync(
}
/* Blocking alerts */
+ var effectiveBlockingCount = summary.BlockingCount;
+ if (App.AlertBlockingEnabled && App.AlertExcludedDatabases.Count > 0
+ && summary.BlockingCount >= App.AlertBlockingThreshold && _dataService != null)
+ {
+ try
+ {
+ var blockingRows = await _dataService.GetRecentBlockedProcessReportsAsync(summary.ServerId, hoursBack: 1);
+ effectiveBlockingCount = blockingRows
+ .Count(r => string.IsNullOrEmpty(r.DatabaseName) ||
+ !App.AlertExcludedDatabases.Any(e =>
+ string.Equals(e, r.DatabaseName, StringComparison.OrdinalIgnoreCase)));
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("Alerts", $"Failed to filter blocking count for {summary.DisplayName}: {ex.Message}");
+ }
+ }
+
bool blockingExceeded = App.AlertBlockingEnabled
- && summary.BlockingCount >= App.AlertBlockingThreshold;
+ && effectiveBlockingCount >= App.AlertBlockingThreshold;
if (blockingExceeded)
{
_activeBlockingAlert[key] = true;
- if (!suppressPopups && (!_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= AlertCooldown))
+ if (!suppressPopups && (!_lastBlockingAlert.TryGetValue(key, out var lastBlocking) || now - lastBlocking >= alertCooldown))
{
- _trayService.ShowNotification(
- "Blocking Detected",
- $"{summary.DisplayName}: {summary.BlockingCount} blocking session(s)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Blocking Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastBlockingAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Blocking Detected",
+ $"{summary.DisplayName}: {effectiveBlockingCount} blocking session(s)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var blockingContext = await BuildBlockingContextAsync(summary.ServerId);
+ var detailText = ContextToDetailText(blockingContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Blocking Detected",
summary.DisplayName,
- summary.BlockingCount.ToString(),
+ effectiveBlockingCount.ToString(),
App.AlertBlockingThreshold.ToString(),
summary.ServerId,
- blockingContext);
+ blockingContext,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeBlockingAlert.TryGetValue(key, out var wasBlocking) && wasBlocking)
@@ -1036,29 +1130,54 @@ await _emailAlertService.TrySendAlertEmailAsync(
}
/* Deadlock alerts */
+ var effectiveDeadlockCount = summary.DeadlockCount;
+ if (App.AlertDeadlockEnabled && App.AlertExcludedDatabases.Count > 0
+ && summary.DeadlockCount >= App.AlertDeadlockThreshold && _dataService != null)
+ {
+ try
+ {
+ var deadlockRows = await _dataService.GetRecentDeadlocksAsync(summary.ServerId, hoursBack: 1);
+ effectiveDeadlockCount = deadlockRows
+ .Count(r => !IsDeadlockExcluded(r, App.AlertExcludedDatabases));
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("Alerts", $"Failed to filter deadlock count for {summary.DisplayName}: {ex.Message}");
+ }
+ }
+
bool deadlocksExceeded = App.AlertDeadlockEnabled
- && summary.DeadlockCount >= App.AlertDeadlockThreshold;
+ && effectiveDeadlockCount >= App.AlertDeadlockThreshold;
if (deadlocksExceeded)
{
_activeDeadlockAlert[key] = true;
- if (!suppressPopups && (!_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= AlertCooldown))
+ if (!suppressPopups && (!_lastDeadlockAlert.TryGetValue(key, out var lastDeadlock) || now - lastDeadlock >= alertCooldown))
{
- _trayService.ShowNotification(
- "Deadlocks Detected",
- $"{summary.DisplayName}: {summary.DeadlockCount} deadlock(s) in the last hour",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Deadlocks Detected" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastDeadlockAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Deadlocks Detected",
+ $"{summary.DisplayName}: {effectiveDeadlockCount} deadlock(s) in the last hour",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ }
+
var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId);
+ var detailText = ContextToDetailText(deadlockContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Deadlocks Detected",
summary.DisplayName,
- summary.DeadlockCount.ToString(),
+ effectiveDeadlockCount.ToString(),
App.AlertDeadlockThreshold.ToString(),
summary.ServerId,
- deadlockContext);
+ deadlockContext,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeDeadlockAlert.TryGetValue(key, out var wasDeadlock) && wasDeadlock)
@@ -1081,17 +1200,29 @@ await _emailAlertService.TrySendAlertEmailAsync(
if (triggered.Count > 0)
{
_activePoisonWaitAlert[key] = true;
- if (!suppressPopups && (!_lastPoisonWaitAlert.TryGetValue(key, out var lastPoisonWait) || now - lastPoisonWait >= AlertCooldown))
+ if (!suppressPopups && (!_lastPoisonWaitAlert.TryGetValue(key, out var lastPoisonWait) || now - lastPoisonWait >= alertCooldown))
{
var worst = triggered[0];
var allWaitNames = string.Join(", ", triggered.ConvertAll(w => $"{w.WaitType} ({w.AvgMsPerWait:F0}ms)"));
- _trayService.ShowNotification(
- "Poison Wait",
- $"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+
+ /* Poison wait mute check uses the worst (highest avg ms/wait) triggered wait type.
+ Limitation: if a user mutes a specific wait type that isn't the worst, the alert
+ still fires. Conversely, muting the worst type suppresses the entire alert even
+ if other unmuted poison waits are present. */
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Poison Wait", WaitType = worst.WaitType };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastPoisonWaitAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Poison Wait",
+ $"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
+ }
+
var poisonContext = BuildPoisonWaitContext(triggered);
+ var detailText = ContextToDetailText(poisonContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Poison Wait",
@@ -1099,7 +1230,11 @@ await _emailAlertService.TrySendAlertEmailAsync(
allWaitNames,
$"{App.AlertPoisonWaitThresholdMs}ms avg",
summary.ServerId,
- poisonContext);
+ poisonContext,
+ numericCurrentValue: worst.AvgMsPerWait,
+ numericThresholdValue: App.AlertPoisonWaitThresholdMs,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activePoisonWaitAlert.TryGetValue(key, out var wasPoisonWait) && wasPoisonWait)
@@ -1122,24 +1257,47 @@ await _emailAlertService.TrySendAlertEmailAsync(
{
try
{
- var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes);
+ var longRunning = await _dataService.GetLongRunningQueriesAsync(summary.ServerId, App.AlertLongRunningQueryThresholdMinutes, App.AlertLongRunningQueryMaxResults, App.AlertLongRunningQueryExcludeSpServerDiagnostics, App.AlertLongRunningQueryExcludeWaitFor, App.AlertLongRunningQueryExcludeBackups, App.AlertLongRunningQueryExcludeMiscWaits);
+
+ if (App.AlertExcludedDatabases.Count > 0)
+ {
+ longRunning = longRunning
+ .Where(q => string.IsNullOrEmpty(q.DatabaseName) ||
+ !App.AlertExcludedDatabases.Any(e =>
+ string.Equals(e, q.DatabaseName, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+ }
if (longRunning.Count > 0)
{
_activeLongRunningQueryAlert[key] = true;
- if (!suppressPopups && (!_lastLongRunningQueryAlert.TryGetValue(key, out var lastLrq) || now - lastLrq >= AlertCooldown))
+ if (!suppressPopups && (!_lastLongRunningQueryAlert.TryGetValue(key, out var lastLrq) || now - lastLrq >= alertCooldown))
{
var worst = longRunning[0];
var elapsedMinutes = worst.ElapsedSeconds / 60;
var preview = TruncateText(worst.QueryText, 80);
var previewSuffix = string.IsNullOrEmpty(preview) ? "" : $" — {preview}";
- _trayService.ShowNotification(
- "Long-Running Query",
- $"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+
+ var muteCtx = new AlertMuteContext
+ {
+ ServerName = summary.DisplayName,
+ MetricName = "Long-Running Query",
+ DatabaseName = worst.DatabaseName,
+ QueryText = worst.QueryText
+ };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningQueryAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Long-Running Query",
+ $"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var lrqContext = BuildLongRunningQueryContext(longRunning);
+ var detailText = ContextToDetailText(lrqContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Long-Running Query",
@@ -1147,7 +1305,11 @@ await _emailAlertService.TrySendAlertEmailAsync(
$"{longRunning.Count} query(s), longest {elapsedMinutes}m",
$"{App.AlertLongRunningQueryThresholdMinutes}m",
summary.ServerId,
- lrqContext);
+ lrqContext,
+ numericCurrentValue: elapsedMinutes,
+ numericThresholdValue: App.AlertLongRunningQueryThresholdMinutes,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeLongRunningQueryAlert.TryGetValue(key, out var wasLongRunning) && wasLongRunning)
@@ -1175,15 +1337,22 @@ await _emailAlertService.TrySendAlertEmailAsync(
if (tempDb != null && tempDb.UsedPercent >= App.AlertTempDbSpaceThresholdPercent)
{
_activeTempDbSpaceAlert[key] = true;
- if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= AlertCooldown))
+ if (!suppressPopups && (!_lastTempDbSpaceAlert.TryGetValue(key, out var lastTempDb) || now - lastTempDb >= alertCooldown))
{
- _trayService.ShowNotification(
- "TempDB Space",
- $"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "TempDB Space" };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastTempDbSpaceAlert[key] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "TempDB Space",
+ $"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var tempDbContext = BuildTempDbSpaceContext(tempDb);
+ var detailText = ContextToDetailText(tempDbContext);
await _emailAlertService.TrySendAlertEmailAsync(
"TempDB Space",
@@ -1191,7 +1360,11 @@ await _emailAlertService.TrySendAlertEmailAsync(
$"{tempDb.UsedPercent:F0}% used ({tempDb.TotalReservedMb:F0} MB)",
$"{App.AlertTempDbSpaceThresholdPercent}%",
summary.ServerId,
- tempDbContext);
+ tempDbContext,
+ numericCurrentValue: tempDb.UsedPercent,
+ numericThresholdValue: App.AlertTempDbSpaceThresholdPercent,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeTempDbSpaceAlert.TryGetValue(key, out var wasTempDb) && wasTempDb)
@@ -1223,16 +1396,24 @@ await _emailAlertService.TrySendAlertEmailAsync(
var worst = anomalousJobs[0];
var jobKey = $"{key}:{worst.JobId}:{worst.StartTime:O}";
- if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= AlertCooldown))
+ if (!suppressPopups && (!_lastLongRunningJobAlert.TryGetValue(jobKey, out var lastJob) || now - lastJob >= alertCooldown))
{
var currentMinutes = worst.CurrentDurationSeconds / 60;
- _trayService.ShowNotification(
- "Long-Running Job",
- $"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
- Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+
+ var muteCtx = new AlertMuteContext { ServerName = summary.DisplayName, MetricName = "Long-Running Job", JobName = worst.JobName };
+ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx);
_lastLongRunningJobAlert[jobKey] = now;
+ if (!isMuted)
+ {
+ _trayService.ShowNotification(
+ "Long-Running Job",
+ $"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
+ Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
+ }
+
var jobContext = BuildAnomalousJobContext(anomalousJobs);
+ var detailText = ContextToDetailText(jobContext);
await _emailAlertService.TrySendAlertEmailAsync(
"Long-Running Job",
@@ -1240,7 +1421,11 @@ await _emailAlertService.TrySendAlertEmailAsync(
$"{anomalousJobs.Count} job(s) exceeding {App.AlertLongRunningJobMultiplier}x average",
$"{App.AlertLongRunningJobMultiplier}x historical avg",
summary.ServerId,
- jobContext);
+ jobContext,
+ numericCurrentValue: (double)(worst.PercentOfAverage ?? 0),
+ numericThresholdValue: App.AlertLongRunningJobMultiplier * 100,
+ muted: isMuted,
+ detailText: detailText);
}
}
else if (_activeLongRunningJobAlert.TryGetValue(key, out var wasJob) && wasJob)
@@ -1266,6 +1451,20 @@ private static string TruncateText(string text, int maxLength = 300)
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
}
+ private static string? ContextToDetailText(AlertContext? context)
+ {
+ if (context == null || context.Details.Count == 0) return null;
+ var sb = new System.Text.StringBuilder();
+ foreach (var detail in context.Details)
+ {
+ if (sb.Length > 0) sb.AppendLine();
+ sb.AppendLine(detail.Heading);
+ foreach (var (label, value) in detail.Fields)
+ sb.AppendLine($" {label}: {value}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
private async Task BuildBlockingContextAsync(int serverId)
{
try
@@ -1275,6 +1474,16 @@ private static string TruncateText(string text, int maxLength = 300)
var events = await _dataService.GetRecentBlockedProcessReportsAsync(serverId, hoursBack: 1);
if (events == null || events.Count == 0) return null;
+ if (App.AlertExcludedDatabases.Count > 0)
+ {
+ events = events
+ .Where(e => string.IsNullOrEmpty(e.DatabaseName) ||
+ !App.AlertExcludedDatabases.Any(ex =>
+ string.Equals(ex, e.DatabaseName, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+ if (events.Count == 0) return null;
+ }
+
var context = new AlertContext();
var firstXml = (string?)null;
@@ -1325,6 +1534,14 @@ private static string TruncateText(string text, int maxLength = 300)
var deadlocks = await _dataService.GetRecentDeadlocksAsync(serverId, hoursBack: 1);
if (deadlocks == null || deadlocks.Count == 0) return null;
+ if (App.AlertExcludedDatabases.Count > 0)
+ {
+ deadlocks = deadlocks
+ .Where(d => !IsDeadlockExcluded(d, App.AlertExcludedDatabases))
+ .ToList();
+ if (deadlocks.Count == 0) return null;
+ }
+
var context = new AlertContext();
var firstGraph = (string?)null;
@@ -1361,6 +1578,24 @@ private static string TruncateText(string text, int maxLength = 300)
}
}
+ private static bool IsDeadlockExcluded(DeadlockRow row, List excludedDatabases)
+ {
+ if (string.IsNullOrEmpty(row.DeadlockGraphXml)) return false;
+ try
+ {
+ var doc = XElement.Parse(row.DeadlockGraphXml);
+ var dbNames = doc.Descendants("process")
+ .Select(p => p.Attribute("currentdbname")?.Value)
+ .Where(n => !string.IsNullOrEmpty(n))
+ .Cast()
+ .ToList();
+ if (dbNames.Count == 0) return false;
+ return dbNames.All(db => excludedDatabases.Any(e =>
+ string.Equals(e, db, StringComparison.OrdinalIgnoreCase)));
+ }
+ catch { return false; }
+ }
+
private static AlertContext? BuildPoisonWaitContext(List triggeredWaits)
{
if (triggeredWaits.Count == 0) return null;
diff --git a/Lite/Mcp/McpAlertTools.cs b/Lite/Mcp/McpAlertTools.cs
index d590ddd5..fcc03f94 100644
--- a/Lite/Mcp/McpAlertTools.cs
+++ b/Lite/Mcp/McpAlertTools.cs
@@ -1,6 +1,5 @@
using System.ComponentModel;
using System.Text.Json;
-using DuckDB.NET.Data;
using ModelContextProtocol.Server;
using PerformanceMonitorLite.Services;
@@ -23,50 +22,27 @@ public static async Task GetAlertHistory(
var limitError = McpHelpers.ValidateTop(limit);
if (limitError != null) return limitError;
- using var connection = await dataService.OpenConnectionAsync();
+ var rows = await dataService.GetAlertHistoryAsync(hours_back, limit);
- using var command = connection.CreateCommand();
- command.CommandText = @"
-SELECT
- alert_time,
- server_id,
- server_name,
- metric_name,
- current_value,
- threshold_value,
- alert_sent,
- notification_type,
- send_error
-FROM config_alert_log
-WHERE alert_time >= $1
-ORDER BY alert_time DESC
-LIMIT $2";
-
- command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hours_back) });
- command.Parameters.Add(new DuckDBParameter { Value = limit });
-
- var alerts = new List
public string? DatabaseName { get; set; }
+ ///
+ /// When true, sets ApplicationIntent=ReadOnly on the connection string.
+ /// Required for connecting to AG listener read-only replicas and
+ /// Azure SQL Business Critical / Managed Instance built-in read replicas.
+ ///
+ public bool ReadOnlyIntent { get; set; } = false;
+
+ ///
+ /// Server name with "(Read-Only)" suffix when ReadOnlyIntent is enabled.
+ /// Used for sidebar subtitle and status text.
+ ///
+ [JsonIgnore]
+ public string ServerNameDisplay => ReadOnlyIntent ? $"{ServerName} (Read-Only)" : ServerName;
+
+ ///
+ /// Display name with "(Read-Only)" suffix when ReadOnlyIntent is enabled.
+ /// Used for alerts, tray notifications, status bar, and overview cards.
+ ///
+ [JsonIgnore]
+ public string DisplayNameWithIntent => ReadOnlyIntent ? $"{DisplayName} (Read-Only)" : DisplayName;
+
///
/// Display-only property for showing authentication type in UI.
///
@@ -153,7 +174,8 @@ private string BuildConnectionString(string? username, string? password)
ConnectTimeout = 15,
CommandTimeout = 60,
TrustServerCertificate = TrustServerCertificate,
- MultipleActiveResultSets = true
+ MultipleActiveResultSets = true,
+ ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite
};
// Set encryption mode
diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj
index 88ee7eb2..d0be7ba2 100644
--- a/Lite/PerformanceMonitorLite.csproj
+++ b/Lite/PerformanceMonitorLite.csproj
@@ -7,10 +7,10 @@
PerformanceMonitorLite
PerformanceMonitorLite
SQL Server Performance Monitor Lite
- 2.1.0
- 2.1.0.0
- 2.1.0.0
- 2.1.0
+ 2.2.0
+ 2.2.0.0
+ 2.2.0.0
+ 2.2.0
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
Lightweight SQL Server performance monitoring - no installation required on target servers
@@ -36,8 +36,8 @@
-
-
+
+
diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs
index 34ddb761..cbc199f9 100644
--- a/Lite/Services/ArchiveService.cs
+++ b/Lite/Services/ArchiveService.cs
@@ -7,7 +7,11 @@
*/
using System;
+using System.Collections.Generic;
+using System.Globalization;
using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DuckDB.NET.Data;
@@ -26,7 +30,9 @@ public class ArchiveService
private readonly ILogger? _logger;
private static readonly SemaphoreSlim s_archiveLock = new(1, 1);
- /* Tables eligible for archival with their time column */
+ /* Tables eligible for archival with their time column.
+ IMPORTANT: Every table with time-series data must be listed here,
+ or it will grow unbounded and push the DB past the 512 MB reset threshold. */
private static readonly (string Table, string TimeColumn)[] ArchivableTables =
[
("wait_stats", "collection_time"),
@@ -42,6 +48,17 @@ private static readonly (string Table, string TimeColumn)[] ArchivableTables =
("perfmon_stats", "collection_time"),
("deadlocks", "collection_time"),
("blocked_process_reports", "collection_time"),
+ ("memory_grant_stats", "collection_time"),
+ ("waiting_tasks", "collection_time"),
+ ("running_jobs", "collection_time"),
+ ("database_size_stats", "collection_time"),
+ ("server_properties", "collection_time"),
+ ("session_stats", "collection_time"),
+ ("server_config", "capture_time"),
+ ("database_config", "capture_time"),
+ ("database_scoped_config", "capture_time"),
+ ("trace_flags", "capture_time"),
+ ("config_alert_log", "alert_time"),
("collection_log", "collection_time")
];
@@ -121,6 +138,9 @@ Archive views use glob (*_table.parquet) to pick up all files. */
}
}
+ /* Compact per-cycle files into monthly parquet before refreshing views */
+ CompactParquetFiles();
+
/* Refresh archive views outside write lock — view creation is fast and safe */
await _duckDb.CreateArchiveViewsAsync();
}
@@ -148,6 +168,218 @@ private static async Task ExportToParquet(DuckDBConnection connection, string ta
await cmd.ExecuteNonQueryAsync();
}
+ /* Columns to exclude during compaction — dead weight from legacy archives */
+ private static readonly Dictionary CompactionExcludeColumns = new()
+ {
+ ["query_store_stats"] = ["query_plan_text"]
+ };
+
+ ///
+ /// Compacts all per-cycle parquet files into monthly files (YYYYMM_tablename.parquet).
+ /// This keeps the archive directory small (~75 files for 3 months of 25 tables)
+ /// and dramatically improves DuckDB read_parquet glob performance.
+ ///
+ private void CompactParquetFiles()
+ {
+ if (!Directory.Exists(_archivePath))
+ {
+ return;
+ }
+
+ var allFiles = Directory.GetFiles(_archivePath, "*.parquet")
+ .Select(f => Path.GetFileName(f))
+ .ToList();
+
+ /* Group files by (month, table). Recognized formats:
+ - YYYYMMDD_HHMM_tablename.parquet (per-cycle)
+ - YYYYMMDD_tablename.parquet (consolidated daily)
+ - YYYY-MM_tablename.parquet (legacy monthly)
+ - all_tablename.parquet (manual consolidation)
+ - YYYYMM_tablename.parquet (monthly — our target format) */
+ var groups = new Dictionary<(string Month, string Table), List>();
+
+ foreach (var file in allFiles)
+ {
+ var name = Path.GetFileNameWithoutExtension(file);
+
+ string? month = null;
+ string? table = null;
+
+ /* YYYYMMDD_HHMM_tablename */
+ var m = Regex.Match(name, @"^(\d{8})_\d{4}_(.+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value[..6]; /* YYYYMM */
+ table = m.Groups[2].Value;
+ }
+
+ /* YYYYMMDD_tablename (no HHMM) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^(\d{8})_([a-z].+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value[..6];
+ table = m.Groups[2].Value;
+ }
+ }
+
+ /* YYYY-MM_tablename (legacy monthly) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^(\d{4})-(\d{2})_(.+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value + m.Groups[2].Value;
+ table = m.Groups[3].Value;
+ }
+ }
+
+ /* all_tablename (manual consolidation from earlier) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^all_(.+)$");
+ if (m.Success)
+ {
+ /* Put in the earliest month we can find, or current month */
+ month = "orphan";
+ table = m.Groups[1].Value;
+ }
+ }
+
+ /* YYYYMM_tablename (already monthly — our target format) */
+ if (month == null)
+ {
+ m = Regex.Match(name, @"^(\d{6})_(.+)$");
+ if (m.Success)
+ {
+ month = m.Groups[1].Value;
+ table = m.Groups[2].Value;
+ }
+ }
+
+ if (month != null && table != null)
+ {
+ var key = (month, table);
+ if (!groups.ContainsKey(key))
+ {
+ groups[key] = [];
+ }
+ groups[key].Add(file);
+ }
+ else
+ {
+ _logger?.LogWarning("Unrecognized parquet file format: {File}", file);
+ }
+ }
+
+ /* Compact each group that has more than one file (or any non-monthly files) */
+ using var con = new DuckDBConnection("DataSource=:memory:");
+ con.Open();
+
+ var totalMerged = 0;
+ var totalRemoved = 0;
+
+ foreach (var ((month, table), files) in groups)
+ {
+ /* If there's exactly one file and it's already in monthly format, skip */
+ if (files.Count == 1)
+ {
+ var name = Path.GetFileNameWithoutExtension(files[0]);
+ if (Regex.IsMatch(name, @"^\d{6}_"))
+ {
+ continue;
+ }
+ }
+
+ /* Resolve month for orphan files — use current month */
+ var targetMonth = month == "orphan"
+ ? DateTime.UtcNow.ToString("yyyyMM")
+ : month;
+
+ var targetFile = $"{targetMonth}_{table}.parquet";
+ var targetPath = Path.Combine(_archivePath, targetFile).Replace("\\", "/");
+ var tempPath = targetPath + ".tmp";
+
+ try
+ {
+ var sourcePaths = files
+ .Select(f => Path.Combine(_archivePath, f).Replace("\\", "/"))
+ .ToList();
+ var pathList = string.Join(", ", sourcePaths.Select(p => $"'{p}'"));
+
+ /* Build SELECT with column exclusions for specific tables.
+ Only exclude columns that actually exist in the source files
+ (they may have been stripped in a previous compaction). */
+ var selectClause = "*";
+ if (CompactionExcludeColumns.TryGetValue(table, out var excludeCols))
+ {
+ using var schemaCmd = con.CreateCommand();
+ schemaCmd.CommandText = $"SELECT column_name FROM (DESCRIBE SELECT * FROM read_parquet([{pathList}], union_by_name=true))";
+ using var reader = schemaCmd.ExecuteReader();
+ var existingCols = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (reader.Read()) existingCols.Add(reader.GetString(0));
+
+ var colsToExclude = excludeCols.Where(c => existingCols.Contains(c)).ToArray();
+ if (colsToExclude.Length > 0)
+ {
+ selectClause = $"* EXCLUDE ({string.Join(", ", colsToExclude)})";
+ }
+ }
+
+ using var cmd = con.CreateCommand();
+ cmd.CommandText = $"COPY (SELECT {selectClause} FROM read_parquet([{pathList}], union_by_name=true)) " +
+ $"TO '{tempPath}' (FORMAT PARQUET, COMPRESSION ZSTD, ROW_GROUP_SIZE 122880)";
+ cmd.ExecuteNonQuery();
+
+ /* Remove originals */
+ var removed = 0;
+ foreach (var f in files)
+ {
+ var fullPath = Path.Combine(_archivePath, f);
+ try
+ {
+ File.Delete(fullPath);
+ removed++;
+ }
+ catch (IOException ex)
+ {
+ _logger?.LogWarning("Could not delete {File} during compaction: {Message}", f, ex.Message);
+ }
+ }
+
+ /* Rename temp to final */
+ if (File.Exists(targetPath))
+ {
+ File.Delete(targetPath);
+ }
+ File.Move(tempPath, targetPath);
+
+ totalMerged++;
+ totalRemoved += removed;
+
+ _logger?.LogDebug("Compacted {Count} files into {Target}", files.Count, targetFile);
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Failed to compact {Month}/{Table} ({Count} files)", month, table, files.Count);
+
+ /* Clean up temp file on failure */
+ if (File.Exists(tempPath))
+ {
+ try { File.Delete(tempPath); } catch { /* best effort */ }
+ }
+ }
+ }
+
+ if (totalMerged > 0)
+ {
+ var remaining = Directory.GetFiles(_archivePath, "*.parquet").Length;
+ _logger?.LogInformation("Parquet compaction complete: merged {Groups} groups, removed {Removed} files, {Remaining} files remaining",
+ totalMerged, totalRemoved, remaining);
+ }
+ }
+
///
/// Archives ALL data from every table to parquet, then deletes and reinitializes the database.
/// Called when the database exceeds the size threshold. Data remains queryable through archive views.
@@ -201,6 +433,12 @@ Archive views use glob (*_table.parquet) to pick up all files. */
}
}
+ /* Compact per-cycle files into monthly parquet files before reset.
+ This runs outside the write lock using an in-memory DuckDB connection
+ and only touches filesystem files — no contention with collectors. */
+ _logger?.LogInformation("Compacting parquet files into monthly archives");
+ CompactParquetFiles();
+
/* Nuke and reinitialize outside the using-connection scope so all handles are closed */
_logger?.LogInformation("Deleting and reinitializing database");
await _duckDb.ResetDatabaseAsync();
diff --git a/Lite/Services/CollectionBackgroundService.cs b/Lite/Services/CollectionBackgroundService.cs
index 4641dd61..6db67e31 100644
--- a/Lite/Services/CollectionBackgroundService.cs
+++ b/Lite/Services/CollectionBackgroundService.cs
@@ -173,7 +173,7 @@ private void RunRetentionIfDue()
try
{
- _retentionService.CleanupOldArchives(retentionDays: 90);
+ _retentionService.CleanupOldArchives(retentionMonths: 3);
_lastRetentionTime = DateTime.UtcNow;
}
catch (Exception ex)
diff --git a/Lite/Services/DeltaCalculator.cs b/Lite/Services/DeltaCalculator.cs
index 55ff9105..377130d2 100644
--- a/Lite/Services/DeltaCalculator.cs
+++ b/Lite/Services/DeltaCalculator.cs
@@ -65,7 +65,7 @@ public async Task SeedFromDatabaseAsync(DuckDbInitializer duckDb)
/// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn.
/// Thread-safe via atomic AddOrUpdate.
///
- public long CalculateDelta(int serverId, string collectorName, string key, long currentValue)
+ public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, bool baselineOnly = false)
{
var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>());
var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary());
@@ -74,11 +74,12 @@ public long CalculateDelta(int serverId, string collectorName, string key, long
collectorCache.AddOrUpdate(
key,
- /* Add: first time seeing this key — use current value as delta
- so queries that execute once still surface in top-N views */
+ /* Add: first time seeing this key.
+ baselineOnly = true: store baseline only, return 0 (for cumulative counters like perfmon).
+ baselineOnly = false: use current value as delta so single-execution queries surface. */
_ =>
{
- delta = currentValue;
+ delta = baselineOnly ? 0 : currentValue;
return currentValue;
},
/* Update: compute delta atomically */
@@ -126,13 +127,37 @@ FROM wait_stats
if (count > 0) _logger?.LogDebug("Seeded {Count} wait_stats baseline rows", count);
}
- private Task SeedFileIoStatsAsync(DuckDBConnection connection)
+ private async Task SeedFileIoStatsAsync(DuckDBConnection connection)
{
- /* File I/O collector uses "{database_id}_{file_id}" as delta key,
- but we don't store those IDs in DuckDB. Seeding for file I/O
- is skipped — the first collection after restart will have delta=0,
- and the second collection will produce accurate deltas. */
- return Task.CompletedTask;
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT server_id, database_name, file_name,
+ num_of_reads, num_of_writes, read_bytes, write_bytes,
+ io_stall_read_ms, io_stall_write_ms,
+ io_stall_queued_read_ms, io_stall_queued_write_ms
+FROM file_io_stats
+WHERE (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time) FROM file_io_stats GROUP BY server_id
+)";
+ using var reader = await cmd.ExecuteReaderAsync();
+ var count = 0;
+ while (await reader.ReadAsync())
+ {
+ var serverId = reader.GetInt32(0);
+ var dbName = reader.IsDBNull(1) ? "" : reader.GetString(1);
+ var fileName = reader.IsDBNull(2) ? "" : reader.GetString(2);
+ var deltaKey = $"{dbName}|{fileName}";
+ Seed(serverId, "file_io_reads", deltaKey, reader.IsDBNull(3) ? 0 : reader.GetInt64(3));
+ Seed(serverId, "file_io_writes", deltaKey, reader.IsDBNull(4) ? 0 : reader.GetInt64(4));
+ Seed(serverId, "file_io_read_bytes", deltaKey, reader.IsDBNull(5) ? 0 : reader.GetInt64(5));
+ Seed(serverId, "file_io_write_bytes", deltaKey, reader.IsDBNull(6) ? 0 : reader.GetInt64(6));
+ Seed(serverId, "file_io_stall_read", deltaKey, reader.IsDBNull(7) ? 0 : reader.GetInt64(7));
+ Seed(serverId, "file_io_stall_write", deltaKey, reader.IsDBNull(8) ? 0 : reader.GetInt64(8));
+ Seed(serverId, "file_io_stall_queued_read", deltaKey, reader.IsDBNull(9) ? 0 : reader.GetInt64(9));
+ Seed(serverId, "file_io_stall_queued_write", deltaKey, reader.IsDBNull(10) ? 0 : reader.GetInt64(10));
+ count++;
+ }
+ if (count > 0) _logger?.LogDebug("Seeded {Count} file_io_stats baseline rows", count);
}
private async Task SeedPerfmonStatsAsync(DuckDBConnection connection)
diff --git a/Lite/Services/EmailAlertService.cs b/Lite/Services/EmailAlertService.cs
index f837221e..9dffe138 100644
--- a/Lite/Services/EmailAlertService.cs
+++ b/Lite/Services/EmailAlertService.cs
@@ -25,7 +25,6 @@ namespace PerformanceMonitorLite.Services;
public class EmailAlertService
{
private readonly ConcurrentDictionary _cooldowns = new();
- private static readonly TimeSpan CooldownPeriod = TimeSpan.FromMinutes(15);
private readonly DuckDbInitializer? _duckDb;
/* Failure tracking for louder logging */
@@ -48,23 +47,27 @@ public async Task TrySendAlertEmailAsync(
string currentValue,
string thresholdValue,
int serverId = 0,
- AlertContext? context = null)
+ AlertContext? context = null,
+ double? numericCurrentValue = null,
+ double? numericThresholdValue = null,
+ bool muted = false,
+ string? detailText = null)
{
try
{
string? sendError = null;
bool sent = false;
- var notificationType = "tray";
+ var notificationType = muted ? "muted" : "tray";
- /* Attempt email delivery if SMTP is fully configured */
- if (App.SmtpEnabled &&
+ /* Attempt email delivery if SMTP is fully configured and alert is not muted */
+ if (!muted && App.SmtpEnabled &&
!string.IsNullOrWhiteSpace(App.SmtpServer) &&
!string.IsNullOrWhiteSpace(App.SmtpFromAddress) &&
!string.IsNullOrWhiteSpace(App.SmtpRecipients))
{
var cooldownKey = $"{serverId}:{metricName}";
var withinCooldown = _cooldowns.TryGetValue(cooldownKey, out var lastSent) &&
- DateTime.UtcNow - lastSent < CooldownPeriod;
+ DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(App.EmailCooldownMinutes);
if (!withinCooldown)
{
@@ -72,7 +75,7 @@ public async Task TrySendAlertEmailAsync(
var subject = $"[SQL Monitor Alert] {metricName} on {serverName}";
var (htmlBody, plainTextBody) = EmailTemplateBuilder.BuildAlertEmail(
- metricName, serverName, currentValue, thresholdValue, context);
+ metricName, serverName, currentValue, thresholdValue, App.EmailCooldownMinutes, context);
try
{
@@ -108,10 +111,12 @@ public async Task TrySendAlertEmailAsync(
}
/* Always log the alert to DuckDB, regardless of email status */
+ var logCurrent = numericCurrentValue
+ ?? (double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0);
+ var logThreshold = numericThresholdValue
+ ?? (double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0);
await LogAlertAsync(serverId, serverName, metricName,
- double.TryParse(currentValue.TrimEnd('%'), out var cv) ? cv : 0,
- double.TryParse(thresholdValue.TrimEnd('%'), out var tv) ? tv : 0,
- sent, notificationType, sendError);
+ logCurrent, logThreshold, sent, notificationType, sendError, muted, detailText);
}
catch (Exception ex)
{
@@ -197,7 +202,7 @@ private static async Task SendEmailAsync(string subject, string htmlBody, string
/// Reuses the injected DuckDbInitializer instead of creating a new one each time.
///
private async Task LogAlertAsync(int serverId, string serverName, string metricName,
- double currentValue, double thresholdValue, bool alertSent, string notificationType, string? sendError)
+ double currentValue, double thresholdValue, bool alertSent, string notificationType, string? sendError, bool muted = false, string? detailText = null)
{
try
{
@@ -210,13 +215,14 @@ private async Task LogAlertAsync(int serverId, string serverName, string metricN
duckDb = new DuckDbInitializer(dbPath);
}
+ using var writeLock = duckDb.AcquireWriteLock();
using var connection = duckDb.CreateConnection();
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = @"
-INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, alert_sent, notification_type, send_error)
-VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";
+INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value, alert_sent, notification_type, send_error, muted, detail_text)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = DateTime.UtcNow });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId });
@@ -227,6 +233,8 @@ INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, c
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = alertSent });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = notificationType });
command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = sendError ?? (object)DBNull.Value });
+ command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = muted });
+ command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = detailText ?? (object)DBNull.Value });
await command.ExecuteNonQueryAsync();
diff --git a/Lite/Services/EmailTemplateBuilder.cs b/Lite/Services/EmailTemplateBuilder.cs
index 478f72d5..7dd55962 100644
--- a/Lite/Services/EmailTemplateBuilder.cs
+++ b/Lite/Services/EmailTemplateBuilder.cs
@@ -29,6 +29,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail(
string serverName,
string currentValue,
string thresholdValue,
+ int emailCooldownMinutes,
AlertContext? context = null)
{
var utcNow = DateTime.UtcNow;
@@ -36,7 +37,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail(
var (accentColor, badgeText) = GetSeverity(metricName);
var html = BuildHtmlBody(metricName, serverName, currentValue,
- thresholdValue, utcNow, localNow, accentColor, badgeText, context: context);
+ thresholdValue, utcNow, localNow, accentColor, badgeText, context: context, emailCooldownMinutes: emailCooldownMinutes);
var plain = BuildPlainTextBody(metricName, serverName, currentValue,
thresholdValue, utcNow, localNow, context);
@@ -89,7 +90,8 @@ private static string BuildHtmlBody(
string accentColor,
string badgeText,
bool isTest = false,
- AlertContext? context = null)
+ AlertContext? context = null,
+ int emailCooldownMinutes = 15)
{
var sb = new StringBuilder(2048);
@@ -169,7 +171,7 @@ private static string BuildHtmlBody(
sb.Append($"Sent by {WebUtility.HtmlEncode(EditionName)}");
if (!isTest)
{
- sb.Append(" · 15-minute cooldown between repeat alerts");
+ sb.Append($" · {emailCooldownMinutes}-minute cooldown between repeat alerts");
}
sb.Append("");
sb.Append("");
diff --git a/Lite/Services/LocalDataService.AlertHistory.cs b/Lite/Services/LocalDataService.AlertHistory.cs
index 38dfc239..0d42cdc5 100644
--- a/Lite/Services/LocalDataService.AlertHistory.cs
+++ b/Lite/Services/LocalDataService.AlertHistory.cs
@@ -37,8 +37,10 @@ public async Task> GetAlertHistoryAsync(int hoursBack = 24
threshold_value,
alert_sent,
notification_type,
- send_error
-FROM config_alert_log
+ send_error,
+ muted,
+ detail_text
+FROM v_config_alert_log
WHERE alert_time >= $1
AND server_id = $2
AND dismissed = FALSE
@@ -60,8 +62,10 @@ ORDER BY alert_time DESC
threshold_value,
alert_sent,
notification_type,
- send_error
-FROM config_alert_log
+ send_error,
+ muted,
+ detail_text
+FROM v_config_alert_log
WHERE alert_time >= $1
AND dismissed = FALSE
ORDER BY alert_time DESC
@@ -84,7 +88,9 @@ ORDER BY alert_time DESC
ThresholdValue = Convert.ToDouble(reader.GetValue(5)),
AlertSent = reader.GetBoolean(6),
NotificationType = reader.GetString(7),
- SendError = reader.IsDBNull(8) ? null : reader.GetString(8)
+ SendError = reader.IsDBNull(8) ? null : reader.GetString(8),
+ Muted = !reader.IsDBNull(9) && reader.GetBoolean(9),
+ DetailText = reader.IsDBNull(10) ? null : reader.GetString(10)
});
}
@@ -163,6 +169,8 @@ public class AlertHistoryRow
public bool AlertSent { get; set; }
public string NotificationType { get; set; } = "";
public string? SendError { get; set; }
+ public bool Muted { get; set; }
+ public string? DetailText { get; set; }
public string TimeLocal => AlertTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
public string CurrentValueDisplay => FormatValue(MetricName, CurrentValue);
diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs
index 99ba8e14..4f9cb7a6 100644
--- a/Lite/Services/LocalDataService.Blocking.cs
+++ b/Lite/Services/LocalDataService.Blocking.cs
@@ -136,6 +136,7 @@ ORDER BY deadlock_time DESC
///
public async Task> GetLatestQuerySnapshotsAsync(int serverId, int hoursBack = 4, DateTime? fromDate = null, DateTime? toDate = null)
{
+ using var _q = TimeQuery("GetLatestQuerySnapshotsAsync", "v_query_snapshots latest");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -218,6 +219,46 @@ AND query_text NOT LIKE 'WAITFOR%'
return items;
}
+ ///
+ /// Gets lightweight blocking + deadlock counts and latest event time for alert badge updates.
+ /// Much cheaper than fetching full rows with XML — just COUNT(*) and MAX(time).
+ ///
+ public async Task<(int blockingCount, int deadlockCount, DateTime? latestEventTime)> GetAlertCountsAsync(int serverId, int hoursBack = 24, 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
+ (SELECT COUNT(*) FROM v_blocked_process_reports
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3) AS blocking_count,
+ (SELECT COUNT(*) FROM v_deadlocks
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3) AS deadlock_count,
+ (SELECT MAX(t) FROM (
+ SELECT MAX(event_time) AS t FROM v_blocked_process_reports
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3
+ UNION ALL
+ SELECT MAX(deadlock_time) AS t FROM v_deadlocks
+ WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3
+ )) AS latest_event_time";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+
+ using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync())
+ return (0, 0, null);
+
+ var blockingCount = reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0));
+ var deadlockCount = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1));
+ var latestEventTime = reader.IsDBNull(2) ? (DateTime?)null : reader.GetDateTime(2);
+
+ return (blockingCount, deadlockCount, latestEventTime);
+ }
+
///
/// Gets recent blocked process reports from the XE-based collector.
///
@@ -788,4 +829,8 @@ public class QuerySnapshotRow
public bool HasQueryPlan => !string.IsNullOrEmpty(QueryPlan);
public bool HasLiveQueryPlan => !string.IsNullOrEmpty(LiveQueryPlan);
public string CollectionTimeLocal => CollectionTime == DateTime.MinValue ? "" : ServerTimeHelper.FormatServerTime(CollectionTime);
+
+ // Chain mode — set by WaitDrillDownWindow when showing head blockers
+ public int ChainBlockedCount { get; set; }
+ public string ChainBlockingPath { get; set; } = "";
}
diff --git a/Lite/Services/LocalDataService.Config.cs b/Lite/Services/LocalDataService.Config.cs
index 82b9e9ee..2c5cfdd2 100644
--- a/Lite/Services/LocalDataService.Config.cs
+++ b/Lite/Services/LocalDataService.Config.cs
@@ -24,9 +24,9 @@ public async Task> GetLatestServerConfigAsync(int serverId
using var command = connection.CreateCommand();
command.CommandText = @"
SELECT configuration_name, value_configured, value_in_use, is_dynamic, is_advanced
-FROM server_config
+FROM v_server_config
WHERE server_id = $1
-AND capture_time = (SELECT MAX(capture_time) FROM server_config WHERE server_id = $1)
+AND capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1)
ORDER BY configuration_name";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
@@ -64,9 +64,9 @@ public async Task> GetLatestDatabaseConfigAsync(int serv
is_broker_enabled, is_cdc_enabled, is_mixed_page_allocation_on,
log_reuse_wait_desc, page_verify_option, target_recovery_time_seconds, delayed_durability,
is_accelerated_database_recovery_on, is_memory_optimized_enabled, is_optimized_locking_on
-FROM database_config
+FROM v_database_config
WHERE server_id = $1
-AND capture_time = (SELECT MAX(capture_time) FROM database_config WHERE server_id = $1)
+AND capture_time = (SELECT MAX(capture_time) FROM v_database_config WHERE server_id = $1)
ORDER BY database_name";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
@@ -121,9 +121,9 @@ public async Task> GetLatestDatabaseScopedConfigAs
using var command = connection.CreateCommand();
command.CommandText = @"
SELECT database_name, configuration_name, value, value_for_secondary
-FROM database_scoped_config
+FROM v_database_scoped_config
WHERE server_id = $1
-AND capture_time = (SELECT MAX(capture_time) FROM database_scoped_config WHERE server_id = $1)
+AND capture_time = (SELECT MAX(capture_time) FROM v_database_scoped_config WHERE server_id = $1)
ORDER BY database_name, configuration_name";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
@@ -153,9 +153,9 @@ public async Task> GetLatestTraceFlagsAsync(int serverId)
using var command = connection.CreateCommand();
command.CommandText = @"
SELECT trace_flag, status, is_global, is_session
-FROM trace_flags
+FROM v_trace_flags
WHERE server_id = $1
-AND capture_time = (SELECT MAX(capture_time) FROM trace_flags WHERE server_id = $1)
+AND capture_time = (SELECT MAX(capture_time) FROM v_trace_flags WHERE server_id = $1)
ORDER BY trace_flag";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
diff --git a/Lite/Services/LocalDataService.DailySummary.cs b/Lite/Services/LocalDataService.DailySummary.cs
index fdd2dd0e..eb2acce5 100644
--- a/Lite/Services/LocalDataService.DailySummary.cs
+++ b/Lite/Services/LocalDataService.DailySummary.cs
@@ -20,6 +20,7 @@ public partial class LocalDataService
///
public async Task GetDailySummaryAsync(int serverId, DateTime? summaryDate = null)
{
+ using var _q = TimeQuery("GetDailySummaryAsync", "daily summary aggregation");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs
new file mode 100644
index 00000000..68aacea6
--- /dev/null
+++ b/Lite/Services/LocalDataService.FinOps.cs
@@ -0,0 +1,1712 @@
+/*
+ * 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 System.Collections.Generic;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using Microsoft.Data.SqlClient;
+
+namespace PerformanceMonitorLite.Services;
+
+public partial class LocalDataService
+{
+ ///
+ /// Gets the latest database size snapshot per server per file (cross-server).
+ ///
+ public async Task> GetDatabaseSizeLatestAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = @"
+SELECT
+ database_name,
+ file_type_desc,
+ file_name,
+ total_size_mb,
+ used_size_mb,
+ volume_mount_point,
+ volume_total_mb,
+ volume_free_mb,
+ recovery_model_desc
+FROM v_database_size_stats
+WHERE server_id = $1
+AND collection_time = (
+ SELECT MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+)
+ORDER BY database_name, file_type_desc, file_name";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new DatabaseSizeRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ FileTypeDesc = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ FileName = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ TotalSizeMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ UsedSizeMb = reader.IsDBNull(4) ? null : Convert.ToDecimal(reader.GetValue(4)),
+ VolumeMountPoint = reader.IsDBNull(5) ? null : reader.GetString(5),
+ VolumeTotalMb = reader.IsDBNull(6) ? null : Convert.ToDecimal(reader.GetValue(6)),
+ VolumeFreeMb = reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)),
+ RecoveryModel = reader.IsDBNull(8) ? null : reader.GetString(8)
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Queries a SQL Server directly for its properties via SERVERPROPERTY + sys.dm_os_sys_info.
+ /// Works from any database context — no PerformanceMonitor DB required.
+ ///
+ public static async Task GetServerPropertiesLiveAsync(string connectionString)
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+
+ const string query = @"
+SELECT
+ CONVERT(nvarchar(256), SERVERPROPERTY('Edition')),
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')),
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductLevel')),
+ CONVERT(nvarchar(128), SERVERPROPERTY('ProductUpdateLevel')),
+ si.cpu_count,
+ si.physical_memory_kb / 1024,
+ si.sqlserver_start_time,
+ (SELECT SUM(CAST(size AS bigint)) * 8.0 / 1024.0 / 1024.0 FROM sys.master_files),
+ si.socket_count,
+ si.cores_per_socket,
+ CONVERT(int, SERVERPROPERTY('EngineEdition')),
+ CONVERT(int, SERVERPROPERTY('IsHadrEnabled')),
+ CONVERT(int, SERVERPROPERTY('IsClustered'))
+FROM sys.dm_os_sys_info AS si;";
+
+ using var command = new SqlCommand(query, connection) { CommandTimeout = 30 };
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ var version = reader.IsDBNull(1) ? "" : reader.GetString(1);
+ var level = reader.IsDBNull(2) ? "" : reader.GetString(2);
+ var updateLevel = reader.IsDBNull(3) ? null : reader.GetString(3);
+ var versionDisplay = !string.IsNullOrEmpty(updateLevel)
+ ? $"{version} - {updateLevel}"
+ : $"{version} - {level}";
+
+ return new ServerPropertyRow
+ {
+ Edition = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ ProductVersion = versionDisplay,
+ CpuCount = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)),
+ PhysicalMemoryMb = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)),
+ SqlServerStartTime = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
+ StorageTotalGb = reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)),
+ SocketCount = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
+ CoresPerSocket = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)),
+ EngineEdition = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)),
+ IsHadrEnabled = reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)) == 1,
+ IsClustered = reader.IsDBNull(12) ? null : Convert.ToInt32(reader.GetValue(12)) == 1,
+ LastUpdated = DateTime.Now
+ };
+ }
+
+ return new ServerPropertyRow();
+ }
+
+ ///
+ /// Gets collected metrics (CPU, storage, idle DBs) for a specific server from DuckDB.
+ ///
+ public async Task<(decimal? AvgCpuPct, decimal? StorageTotalGb, int? IdleDbCount, string? ProvisioningStatus)> GetServerMetricsAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cpuCutoff = DateTime.UtcNow.AddHours(-24);
+ var idleCutoff = DateTime.UtcNow.AddDays(-7);
+
+ command.CommandText = @"
+WITH cpu_24h AS (
+ SELECT
+ AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct,
+ MAX(sqlserver_cpu_utilization) AS max_cpu_pct,
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct
+ FROM v_cpu_utilization_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+),
+mem_latest AS (
+ SELECT
+ CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio
+ FROM v_memory_stats
+ WHERE server_id = $1
+ AND (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_memory_stats
+ WHERE server_id = $1
+ GROUP BY server_id
+ )
+),
+storage_totals AS (
+ SELECT
+ SUM(total_size_mb) / 1024.0 AS total_storage_gb
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ GROUP BY server_id
+ )
+),
+idle_dbs AS (
+ SELECT
+ COUNT(DISTINCT database_name) AS idle_db_count
+ FROM (
+ SELECT database_name
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ GROUP BY server_id
+ )
+ AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb')
+ EXCEPT
+ SELECT DISTINCT database_name
+ FROM v_query_stats
+ WHERE server_id = $1
+ AND collection_time >= $3
+ AND delta_execution_count > 0
+ ) AS idle
+)
+SELECT
+ c.avg_cpu_pct,
+ st.total_storage_gb,
+ id.idle_db_count,
+ CASE
+ WHEN c.avg_cpu_pct < 15 AND c.max_cpu_pct < 40 AND COALESCE(m.memory_ratio, 0) < 0.5
+ THEN 'OVER_PROVISIONED'
+ WHEN c.p95_cpu_pct > 85 OR COALESCE(m.memory_ratio, 0) > 0.95
+ THEN 'UNDER_PROVISIONED'
+ ELSE 'RIGHT_SIZED'
+ END AS provisioning_status
+FROM (SELECT 1) AS anchor
+LEFT JOIN cpu_24h c ON true
+LEFT JOIN mem_latest m ON true
+LEFT JOIN storage_totals st ON true
+LEFT JOIN idle_dbs id ON true";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cpuCutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = idleCutoff });
+
+ using var reader = await command.ExecuteReaderAsync();
+ if (await reader.ReadAsync())
+ {
+ return (
+ reader.IsDBNull(0) ? null : Convert.ToDecimal(reader.GetValue(0)),
+ reader.IsDBNull(1) ? null : Convert.ToDecimal(reader.GetValue(1)),
+ reader.IsDBNull(2) ? null : Convert.ToInt32(reader.GetValue(2)),
+ reader.IsDBNull(3) ? null : reader.GetString(3)
+ );
+ }
+
+ return (null, null, null, null);
+ }
+
+ ///
+ /// Gets the latest server properties snapshot per server (cross-server) from DuckDB.
+ /// Fallback for when live query is not available.
+ ///
+ public async Task> GetServerPropertiesLatestAsync(IEnumerable? activeServerIds = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cpuCutoff = DateTime.UtcNow.AddHours(-24);
+ var idleCutoff = DateTime.UtcNow.AddDays(-7);
+ var recentCutoff = DateTime.UtcNow.AddHours(-24);
+
+ // Build server ID filter — integers only, safe to inline
+ var serverFilter = "";
+ if (activeServerIds != null)
+ {
+ var idList = string.Join(",", activeServerIds);
+ if (!string.IsNullOrEmpty(idList))
+ serverFilter = $"AND server_id IN ({idList})";
+ }
+
+ command.CommandText = $@"
+WITH active_servers AS (
+ SELECT DISTINCT server_id, server_name
+ FROM v_cpu_utilization_stats
+ WHERE collection_time >= $3
+ {serverFilter}
+),
+latest_props AS (
+ SELECT *
+ FROM v_server_properties
+ WHERE (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_server_properties
+ GROUP BY server_id
+ )
+),
+cpu_24h AS (
+ SELECT
+ server_id,
+ AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct,
+ MAX(sqlserver_cpu_utilization) AS max_cpu_pct,
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct
+ FROM v_cpu_utilization_stats
+ WHERE collection_time >= $1
+ GROUP BY server_id
+),
+mem_latest AS (
+ SELECT
+ server_id,
+ CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio
+ FROM v_memory_stats
+ WHERE (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_memory_stats
+ GROUP BY server_id
+ )
+),
+storage_totals AS (
+ SELECT
+ server_id,
+ SUM(total_size_mb) / 1024.0 AS total_storage_gb
+ FROM v_database_size_stats
+ WHERE (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_database_size_stats
+ GROUP BY server_id
+ )
+ GROUP BY server_id
+),
+idle_dbs AS (
+ SELECT
+ server_id,
+ COUNT(DISTINCT database_name) AS idle_db_count
+ FROM (
+ SELECT server_id, database_name
+ FROM v_database_size_stats
+ WHERE (server_id, collection_time) IN (
+ SELECT server_id, MAX(collection_time)
+ FROM v_database_size_stats
+ GROUP BY server_id
+ )
+ AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb')
+ EXCEPT
+ SELECT DISTINCT server_id, database_name
+ FROM v_query_stats
+ WHERE collection_time >= $2
+ AND delta_execution_count > 0
+ ) AS idle
+ GROUP BY server_id
+)
+SELECT
+ a.server_name,
+ sp.edition,
+ sp.product_version,
+ sp.product_level,
+ sp.product_update_level,
+ sp.engine_edition,
+ sp.cpu_count,
+ sp.physical_memory_mb,
+ sp.socket_count,
+ sp.cores_per_socket,
+ sp.is_hadr_enabled,
+ sp.is_clustered,
+ c.avg_cpu_pct,
+ st.total_storage_gb,
+ id.idle_db_count,
+ CASE
+ WHEN c.avg_cpu_pct < 15 AND c.max_cpu_pct < 40 AND COALESCE(m.memory_ratio, 0) < 0.5
+ THEN 'OVER_PROVISIONED'
+ WHEN c.p95_cpu_pct > 85 OR COALESCE(m.memory_ratio, 0) > 0.95
+ THEN 'UNDER_PROVISIONED'
+ ELSE 'RIGHT_SIZED'
+ END AS provisioning_status
+FROM active_servers a
+LEFT JOIN latest_props sp ON sp.server_id = a.server_id
+LEFT JOIN cpu_24h c ON c.server_id = a.server_id
+LEFT JOIN mem_latest m ON m.server_id = a.server_id
+LEFT JOIN storage_totals st ON st.server_id = a.server_id
+LEFT JOIN idle_dbs id ON id.server_id = a.server_id
+ORDER BY a.server_name";
+
+ command.Parameters.Add(new DuckDBParameter { Value = cpuCutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = idleCutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = recentCutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new ServerPropertyRow
+ {
+ ServerName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ Edition = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ ProductVersion = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3),
+ ProductUpdateLevel = reader.IsDBNull(4) ? null : reader.GetString(4),
+ EngineEdition = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ CpuCount = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)),
+ PhysicalMemoryMb = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)),
+ SocketCount = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)),
+ CoresPerSocket = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)),
+ IsHadrEnabled = reader.IsDBNull(10) ? null : reader.GetBoolean(10),
+ IsClustered = reader.IsDBNull(11) ? null : reader.GetBoolean(11),
+ AvgCpuPct = reader.IsDBNull(12) ? null : Convert.ToDecimal(reader.GetValue(12)),
+ StorageTotalGb = reader.IsDBNull(13) ? null : Convert.ToDecimal(reader.GetValue(13)),
+ IdleDbCount = reader.IsDBNull(14) ? null : Convert.ToInt32(reader.GetValue(14)),
+ ProvisioningStatus = reader.IsDBNull(15) ? null : reader.GetString(15)
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets database size trend (total_size_mb per database per collection) for a specific server.
+ ///
+ public async Task> GetDatabaseSizeTrendAsync(int serverId, int daysBack = 30)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddDays(-daysBack);
+
+ command.CommandText = @"
+SELECT
+ collection_time,
+ database_name,
+ SUM(total_size_mb) AS total_size_mb
+FROM v_database_size_stats
+WHERE server_id = $1
+AND collection_time >= $2
+GROUP BY collection_time, database_name
+ORDER BY collection_time, database_name";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new DatabaseSizeTrendPoint
+ {
+ CollectionTime = reader.GetDateTime(0),
+ DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ TotalSizeMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2))
+ });
+ }
+
+ return items;
+ }
+ ///
+ /// Computes utilization efficiency from cpu_utilization_stats + memory_stats (last 24 hours).
+ ///
+ public async Task GetUtilizationEfficiencyAsync(int serverId)
+ {
+ using var _q = TimeQuery("GetUtilizationEfficiencyAsync", "utilization efficiency stats");
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-24);
+
+ command.CommandText = @"
+WITH cpu_stats AS (
+ SELECT
+ AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct,
+ MAX(sqlserver_cpu_utilization) AS max_cpu_pct,
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct,
+ COUNT(*) AS cpu_samples
+ FROM v_cpu_utilization_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+),
+mem_latest AS (
+ SELECT
+ total_server_memory_mb,
+ target_server_memory_mb,
+ total_physical_memory_mb,
+ buffer_pool_mb,
+ max_workers_count,
+ current_workers_count,
+ CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio
+ FROM v_memory_stats
+ WHERE server_id = $1
+ ORDER BY collection_time DESC
+ LIMIT 1
+),
+server_info AS (
+ SELECT cpu_count
+ FROM v_server_properties
+ WHERE server_id = $1
+ ORDER BY collection_time DESC
+ LIMIT 1
+)
+SELECT
+ c.avg_cpu_pct,
+ c.max_cpu_pct,
+ c.p95_cpu_pct,
+ c.cpu_samples,
+ m.total_server_memory_mb,
+ m.target_server_memory_mb,
+ m.total_physical_memory_mb,
+ m.buffer_pool_mb,
+ m.memory_ratio,
+ m.max_workers_count,
+ m.current_workers_count,
+ s.cpu_count
+FROM cpu_stats c
+CROSS JOIN mem_latest m
+CROSS JOIN server_info s";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync()) return null;
+
+ var avgCpu = reader.IsDBNull(0) ? 0m : Convert.ToDecimal(reader.GetValue(0));
+ var maxCpu = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1));
+ var p95Cpu = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2));
+ var memRatio = reader.IsDBNull(8) ? 0m : Convert.ToDecimal(reader.GetValue(8));
+
+ var status = "RIGHT_SIZED";
+ if (avgCpu < 15 && maxCpu < 40 && memRatio < 0.5m)
+ status = "OVER_PROVISIONED";
+ else if (p95Cpu > 85 || memRatio > 0.95m)
+ status = "UNDER_PROVISIONED";
+
+ return new UtilizationEfficiencyRow
+ {
+ AvgCpuPct = avgCpu,
+ MaxCpuPct = maxCpu,
+ P95CpuPct = p95Cpu,
+ CpuSamples = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)),
+ TotalMemoryMb = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)),
+ TargetMemoryMb = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)),
+ PhysicalMemoryMb = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)),
+ BufferPoolMb = reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7)),
+ MemoryRatio = memRatio,
+ ProvisioningStatus = status,
+ MaxWorkersCount = reader.IsDBNull(9) ? 0 : Convert.ToInt32(reader.GetValue(9)),
+ CurrentWorkersCount = reader.IsDBNull(10) ? 0 : Convert.ToInt32(reader.GetValue(10)),
+ CpuCount = reader.IsDBNull(11) ? 0 : Convert.ToInt32(reader.GetValue(11))
+ };
+ }
+
+ ///
+ /// Computes per-database resource usage from query_stats + file_io_stats deltas.
+ ///
+ public async Task> GetDatabaseResourceUsageAsync(int serverId, int hoursBack = 24)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+WITH workload AS (
+ SELECT
+ database_name,
+ SUM(delta_worker_time) / 1000 AS cpu_time_ms,
+ SUM(delta_logical_reads) AS logical_reads,
+ SUM(delta_physical_reads) AS physical_reads,
+ SUM(delta_logical_writes) AS logical_writes,
+ SUM(delta_execution_count) AS execution_count
+ FROM v_query_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_worker_time IS NOT NULL
+ GROUP BY database_name
+),
+io AS (
+ SELECT
+ database_name,
+ SUM(delta_read_bytes) / 1048576.0 AS io_read_mb,
+ SUM(delta_write_bytes) / 1048576.0 AS io_write_mb,
+ SUM(delta_stall_read_ms + delta_stall_write_ms) AS io_stall_ms
+ FROM v_file_io_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_read_bytes IS NOT NULL
+ GROUP BY database_name
+),
+combined AS (
+ SELECT
+ COALESCE(w.database_name, i.database_name) AS database_name,
+ COALESCE(w.cpu_time_ms, 0) AS cpu_time_ms,
+ COALESCE(w.logical_reads, 0) AS logical_reads,
+ COALESCE(w.physical_reads, 0) AS physical_reads,
+ COALESCE(w.logical_writes, 0) AS logical_writes,
+ COALESCE(w.execution_count, 0) AS execution_count,
+ COALESCE(i.io_read_mb, 0) AS io_read_mb,
+ COALESCE(i.io_write_mb, 0) AS io_write_mb,
+ COALESCE(i.io_stall_ms, 0) AS io_stall_ms
+ FROM workload w
+ FULL JOIN io i ON i.database_name = w.database_name
+),
+totals AS (
+ SELECT
+ NULLIF(SUM(cpu_time_ms), 0) AS total_cpu,
+ NULLIF(SUM(io_read_mb + io_write_mb), 0) AS total_io
+ FROM combined
+)
+SELECT
+ c.database_name,
+ c.cpu_time_ms,
+ c.logical_reads,
+ c.physical_reads,
+ c.logical_writes,
+ c.execution_count,
+ CAST(c.io_read_mb AS DECIMAL(19,2)),
+ CAST(c.io_write_mb AS DECIMAL(19,2)),
+ c.io_stall_ms,
+ CAST(c.cpu_time_ms * 100.0 / t.total_cpu AS DECIMAL(5,2)) AS pct_cpu_share,
+ CAST((c.io_read_mb + c.io_write_mb) * 100.0 / t.total_io AS DECIMAL(5,2)) AS pct_io_share
+FROM combined c
+CROSS JOIN totals t
+WHERE c.database_name IS NOT NULL
+ORDER BY c.cpu_time_ms DESC";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new DatabaseResourceUsageRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)),
+ LogicalReads = reader.IsDBNull(2) ? 0L : ToInt64(reader.GetValue(2)),
+ PhysicalReads = reader.IsDBNull(3) ? 0L : ToInt64(reader.GetValue(3)),
+ LogicalWrites = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)),
+ ExecutionCount = reader.IsDBNull(5) ? 0L : ToInt64(reader.GetValue(5)),
+ IoReadMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ IoWriteMb = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7)),
+ IoStallMs = reader.IsDBNull(8) ? 0L : ToInt64(reader.GetValue(8)),
+ PctCpuShare = reader.IsDBNull(9) ? 0m : Convert.ToDecimal(reader.GetValue(9)),
+ PctIoShare = reader.IsDBNull(10) ? 0m : Convert.ToDecimal(reader.GetValue(10))
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets per-application connection counts from session_stats (last 24 hours).
+ /// Aggregates snapshots of sys.dm_exec_sessions grouped by program_name.
+ ///
+ public async Task> GetApplicationConnectionsAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-24);
+
+ command.CommandText = @"
+SELECT
+ program_name,
+ CAST(AVG(connection_count) AS INTEGER) AS avg_connections,
+ MAX(connection_count) AS max_connections,
+ COUNT(*) AS sample_count,
+ MIN(collection_time) AS first_seen,
+ MAX(collection_time) AS last_seen
+FROM v_session_stats
+WHERE server_id = $1
+AND collection_time >= $2
+GROUP BY program_name
+ORDER BY max_connections DESC";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new ApplicationConnectionRow
+ {
+ ApplicationName = reader.GetString(0),
+ AvgConnections = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1)),
+ MaxConnections = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ SampleCount = reader.IsDBNull(3) ? 0 : ToInt64(reader.GetValue(3)),
+ FirstSeen = reader.GetDateTime(4),
+ LastSeen = reader.GetDateTime(5)
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets top N databases by total CPU for the utilization summary.
+ ///
+ public async Task> GetTopResourceConsumersByTotalAsync(int serverId, int hoursBack = 24, int topN = 5)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+WITH workload AS (
+ SELECT
+ database_name,
+ SUM(delta_worker_time) / 1000 AS cpu_time_ms,
+ SUM(delta_execution_count) AS execution_count
+ FROM v_query_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_worker_time IS NOT NULL
+ GROUP BY database_name
+),
+io AS (
+ SELECT
+ database_name,
+ SUM(delta_read_bytes + delta_write_bytes) / 1048576.0 AS io_total_mb
+ FROM v_file_io_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_read_bytes IS NOT NULL
+ GROUP BY database_name
+),
+combined AS (
+ SELECT
+ COALESCE(w.database_name, i.database_name) AS database_name,
+ COALESCE(w.cpu_time_ms, 0) AS cpu_time_ms,
+ COALESCE(w.execution_count, 0) AS execution_count,
+ COALESCE(i.io_total_mb, 0) AS io_total_mb
+ FROM workload w
+ FULL JOIN io i ON i.database_name = w.database_name
+),
+totals AS (
+ SELECT
+ NULLIF(SUM(cpu_time_ms), 0) AS total_cpu,
+ NULLIF(SUM(io_total_mb), 0) AS total_io
+ FROM combined
+)
+SELECT
+ c.database_name,
+ c.cpu_time_ms,
+ c.execution_count,
+ CAST(c.io_total_mb AS DECIMAL(19,2)),
+ CAST(c.cpu_time_ms * 100.0 / t.total_cpu AS DECIMAL(5,2)),
+ CAST(c.io_total_mb * 100.0 / t.total_io AS DECIMAL(5,2))
+FROM combined c
+CROSS JOIN totals t
+WHERE c.database_name IS NOT NULL
+ORDER BY c.cpu_time_ms DESC
+LIMIT $3";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = topN });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new TopResourceConsumerRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0 : ToInt64(reader.GetValue(1)),
+ ExecutionCount = reader.IsDBNull(2) ? 0 : ToInt64(reader.GetValue(2)),
+ IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ PctCpu = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ PctIo = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets top N databases by average CPU per execution for the utilization summary.
+ ///
+ public async Task> GetTopResourceConsumersByAvgAsync(int serverId, int hoursBack = 24, int topN = 5)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+WITH workload AS (
+ SELECT
+ database_name,
+ SUM(delta_worker_time) / 1000 AS cpu_time_ms,
+ SUM(delta_execution_count) AS execution_count
+ FROM v_query_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_worker_time IS NOT NULL
+ GROUP BY database_name
+ HAVING SUM(delta_execution_count) > 0
+),
+io AS (
+ SELECT
+ database_name,
+ SUM(delta_read_bytes + delta_write_bytes) / 1048576.0 AS io_total_mb
+ FROM v_file_io_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_read_bytes IS NOT NULL
+ GROUP BY database_name
+)
+SELECT
+ w.database_name,
+ CAST(w.cpu_time_ms * 1.0 / w.execution_count AS DECIMAL(19,2)) AS avg_cpu_ms,
+ w.execution_count,
+ CAST(COALESCE(i.io_total_mb, 0) AS DECIMAL(19,2)),
+ w.cpu_time_ms,
+ CAST(COALESCE(i.io_total_mb, 0) * 1.0 / w.execution_count AS DECIMAL(19,4)) AS avg_io_mb
+FROM workload w
+LEFT JOIN io i ON i.database_name = w.database_name
+ORDER BY avg_cpu_ms DESC
+LIMIT $3";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = topN });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new TopResourceConsumerRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CpuTimeMs = reader.IsDBNull(1) ? 0 : ToInt64(reader.GetValue(1)),
+ ExecutionCount = reader.IsDBNull(2) ? 0 : ToInt64(reader.GetValue(2)),
+ IoTotalMb = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TotalCpuTimeMs = reader.IsDBNull(4) ? 0 : ToInt64(reader.GetValue(4)),
+ AvgIoMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets per-database total allocated and used space for the utilization size chart.
+ /// Aggregates across all files per database for the selected server.
+ ///
+ public async Task> GetDatabaseSizeSummaryAsync(int serverId, int topN = 10)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ command.CommandText = @"
+SELECT
+ database_name,
+ SUM(total_size_mb) AS total_mb,
+ SUM(used_size_mb) AS used_mb
+FROM v_database_size_stats
+WHERE server_id = $1
+AND collection_time = (
+ SELECT MAX(collection_time) FROM v_database_size_stats WHERE server_id = $1
+)
+GROUP BY database_name
+ORDER BY total_mb DESC
+LIMIT $2";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = topN });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new DatabaseSizeSummaryRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ UsedMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets per-database storage growth trends comparing current size to 7d and 30d ago.
+ ///
+ public async Task> GetStorageGrowthAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var now = DateTime.UtcNow;
+ var cutoff7d = now.AddDays(-7);
+ var cutoff30d = now.AddDays(-30);
+
+ command.CommandText = @"
+WITH latest AS (
+ SELECT
+ database_name,
+ SUM(total_size_mb) AS current_size_mb
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time = (
+ SELECT MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ )
+ GROUP BY database_name
+),
+past_7d AS (
+ SELECT
+ database_name,
+ SUM(total_size_mb) AS size_mb
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time = (
+ SELECT MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time <= $2
+ )
+ GROUP BY database_name
+),
+past_30d AS (
+ SELECT
+ database_name,
+ SUM(total_size_mb) AS size_mb
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time = (
+ SELECT MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time <= $3
+ )
+ GROUP BY database_name
+)
+SELECT
+ l.database_name,
+ l.current_size_mb,
+ p7.size_mb,
+ p30.size_mb,
+ l.current_size_mb - COALESCE(p7.size_mb, l.current_size_mb) AS growth_7d_mb,
+ l.current_size_mb - COALESCE(p30.size_mb, l.current_size_mb) AS growth_30d_mb,
+ CASE
+ WHEN p30.size_mb IS NOT NULL
+ THEN (l.current_size_mb - p30.size_mb) / 30.0
+ WHEN p7.size_mb IS NOT NULL
+ THEN (l.current_size_mb - p7.size_mb) / 7.0
+ ELSE 0
+ END AS daily_growth_rate_mb,
+ CASE
+ WHEN p30.size_mb IS NOT NULL AND p30.size_mb > 0
+ THEN (l.current_size_mb - p30.size_mb) * 100.0 / p30.size_mb
+ ELSE 0
+ END AS growth_pct_30d
+FROM latest l
+LEFT JOIN past_7d p7 ON p7.database_name = l.database_name
+LEFT JOIN past_30d p30 ON p30.database_name = l.database_name
+ORDER BY growth_30d_mb DESC";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff7d });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff30d });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new StorageGrowthRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CurrentSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ Size7dAgoMb = reader.IsDBNull(2) ? null : Convert.ToDecimal(reader.GetValue(2)),
+ Size30dAgoMb = reader.IsDBNull(3) ? null : Convert.ToDecimal(reader.GetValue(3)),
+ Growth7dMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ Growth30dMb = reader.IsDBNull(5) ? 0m : Convert.ToDecimal(reader.GetValue(5)),
+ DailyGrowthRateMb = reader.IsDBNull(6) ? 0m : Convert.ToDecimal(reader.GetValue(6)),
+ GrowthPct30d = reader.IsDBNull(7) ? 0m : Convert.ToDecimal(reader.GetValue(7))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Detects databases with zero query executions over the last N days.
+ ///
+ public async Task> GetIdleDatabasesAsync(int serverId, int daysBack = 7)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddDays(-daysBack);
+
+ command.CommandText = @"
+WITH db_sizes AS (
+ SELECT
+ database_name,
+ SUM(total_size_mb) AS total_size_mb,
+ COUNT(*) AS file_count
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ AND collection_time = (
+ SELECT MAX(collection_time)
+ FROM v_database_size_stats
+ WHERE server_id = $1
+ )
+ GROUP BY database_name
+),
+db_activity AS (
+ SELECT
+ database_name,
+ SUM(delta_execution_count) AS total_executions,
+ MAX(last_execution_time) AS last_execution
+ FROM v_query_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_execution_count IS NOT NULL
+ GROUP BY database_name
+)
+SELECT
+ ds.database_name,
+ ds.total_size_mb,
+ ds.file_count,
+ a.last_execution
+FROM db_sizes ds
+LEFT JOIN db_activity a ON a.database_name = ds.database_name
+WHERE COALESCE(a.total_executions, 0) = 0
+AND ds.database_name NOT IN ('master', 'model', 'msdb', 'tempdb')
+ORDER BY ds.total_size_mb DESC";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new IdleDatabaseRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalSizeMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ FileCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)),
+ LastExecutionTime = reader.IsDBNull(3) ? null : reader.GetDateTime(3)
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets tempdb pressure summary: latest and 24h peak values.
+ ///
+ public async Task> GetTempdbSummaryAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-24);
+
+ command.CommandText = @"
+WITH latest AS (
+ SELECT
+ user_object_reserved_mb,
+ internal_object_reserved_mb,
+ version_store_reserved_mb,
+ total_reserved_mb
+ FROM v_tempdb_stats
+ WHERE server_id = $1
+ ORDER BY collection_time DESC
+ LIMIT 1
+),
+peak AS (
+ SELECT
+ MAX(user_object_reserved_mb) AS max_user_mb,
+ MAX(internal_object_reserved_mb) AS max_internal_mb,
+ MAX(version_store_reserved_mb) AS max_version_store_mb,
+ MAX(total_reserved_mb) AS max_total_mb
+ FROM v_tempdb_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+)
+SELECT 'User Objects', l.user_object_reserved_mb, p.max_user_mb,
+ CASE WHEN p.max_user_mb > 1024 THEN 'High user object usage' ELSE '' END
+FROM latest l CROSS JOIN peak p
+UNION ALL
+SELECT 'Internal Objects', l.internal_object_reserved_mb, p.max_internal_mb,
+ CASE WHEN p.max_internal_mb > 1024 THEN 'High internal object usage (sorts/hashes)' ELSE '' END
+FROM latest l CROSS JOIN peak p
+UNION ALL
+SELECT 'Version Store', l.version_store_reserved_mb, p.max_version_store_mb,
+ CASE WHEN p.max_version_store_mb > 2048 THEN 'Version store pressure — check long-running transactions' ELSE '' END
+FROM latest l CROSS JOIN peak p
+UNION ALL
+SELECT 'Total Reserved', l.total_reserved_mb, p.max_total_mb, ''
+FROM latest l CROSS JOIN peak p";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new TempdbSummaryRow
+ {
+ Metric = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ CurrentMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ Peak24hMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ Warning = reader.IsDBNull(3) ? "" : reader.GetString(3)
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets wait stats grouped by cost category over the last 24 hours.
+ ///
+ public async Task> GetWaitCategorySummaryAsync(int serverId, int hoursBack = 24)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+WITH categorized AS (
+ SELECT
+ CASE
+ WHEN wait_type IN ('SOS_SCHEDULER_YIELD', 'CXPACKET', 'CXCONSUMER', 'CXSYNC_PORT', 'CXSYNC_CONSUMER') THEN 'CPU'
+ WHEN wait_type ILIKE 'PAGEIOLATCH%'
+ OR wait_type IN ('WRITELOG', 'IO_COMPLETION', 'ASYNC_IO_COMPLETION') THEN 'Storage'
+ WHEN wait_type IN ('RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE', 'CMEMTHREAD') THEN 'Memory'
+ WHEN wait_type = 'ASYNC_NETWORK_IO' THEN 'Network'
+ WHEN wait_type ILIKE 'LCK_M_%' THEN 'Locks'
+ ELSE 'Other'
+ END AS category,
+ wait_type,
+ SUM(delta_wait_time_ms) AS wait_time_ms,
+ SUM(delta_waiting_tasks) AS waiting_tasks
+ FROM v_wait_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND delta_wait_time_ms IS NOT NULL
+ AND delta_wait_time_ms > 0
+ GROUP BY
+ CASE
+ WHEN wait_type IN ('SOS_SCHEDULER_YIELD', 'CXPACKET', 'CXCONSUMER', 'CXSYNC_PORT', 'CXSYNC_CONSUMER') THEN 'CPU'
+ WHEN wait_type ILIKE 'PAGEIOLATCH%'
+ OR wait_type IN ('WRITELOG', 'IO_COMPLETION', 'ASYNC_IO_COMPLETION') THEN 'Storage'
+ WHEN wait_type IN ('RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE', 'CMEMTHREAD') THEN 'Memory'
+ WHEN wait_type = 'ASYNC_NETWORK_IO' THEN 'Network'
+ WHEN wait_type ILIKE 'LCK_M_%' THEN 'Locks'
+ ELSE 'Other'
+ END,
+ wait_type
+),
+ranked AS (
+ SELECT
+ *,
+ ROW_NUMBER() OVER (PARTITION BY category ORDER BY wait_time_ms DESC) AS rn
+ FROM categorized
+),
+by_category AS (
+ SELECT
+ category,
+ SUM(wait_time_ms) AS total_wait_time_ms,
+ SUM(waiting_tasks) AS total_waiting_tasks,
+ MAX(CASE WHEN rn = 1 THEN wait_type END) AS top_wait_type,
+ MAX(CASE WHEN rn = 1 THEN wait_time_ms END) AS top_wait_time_ms
+ FROM ranked
+ GROUP BY category
+),
+grand_total AS (
+ SELECT NULLIF(SUM(total_wait_time_ms), 0) AS total
+ FROM by_category
+)
+SELECT
+ bc.category,
+ bc.total_wait_time_ms,
+ bc.total_waiting_tasks,
+ CAST(bc.total_wait_time_ms * 100.0 / gt.total AS DECIMAL(5,1)),
+ bc.top_wait_type,
+ bc.top_wait_time_ms
+FROM by_category bc
+CROSS JOIN grand_total gt
+ORDER BY bc.total_wait_time_ms DESC";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new WaitCategorySummaryRow
+ {
+ Category = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalWaitTimeMs = reader.IsDBNull(1) ? 0 : ToInt64(reader.GetValue(1)),
+ WaitingTasks = reader.IsDBNull(2) ? 0 : ToInt64(reader.GetValue(2)),
+ PctOfTotal = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ TopWaitType = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ TopWaitTimeMs = reader.IsDBNull(5) ? 0 : ToInt64(reader.GetValue(5))
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets top 20 most expensive queries by total CPU over the last 24 hours.
+ ///
+ public async Task> GetExpensiveQueriesAsync(int serverId, int hoursBack = 24, int topN = 20)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+SELECT
+ database_name,
+ SUM(delta_worker_time) / 1000 AS total_cpu_ms,
+ CAST(SUM(delta_worker_time) / 1000.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,2)) AS avg_cpu_ms,
+ SUM(delta_logical_reads) AS total_reads,
+ CAST(SUM(delta_logical_reads) * 1.0 / NULLIF(SUM(delta_execution_count), 0) AS DECIMAL(19,0)) AS avg_reads,
+ SUM(delta_execution_count) AS executions,
+ LEFT(query_text, 200) AS query_preview
+FROM v_query_stats
+WHERE server_id = $1
+AND collection_time >= $2
+AND delta_worker_time IS NOT NULL
+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";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+ command.Parameters.Add(new DuckDBParameter { Value = topN });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new ExpensiveQueryRow
+ {
+ DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ TotalCpuMs = reader.IsDBNull(1) ? 0 : ToInt64(reader.GetValue(1)),
+ AvgCpuMsPerExec = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ TotalReads = reader.IsDBNull(3) ? 0 : ToInt64(reader.GetValue(3)),
+ AvgReadsPerExec = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ Executions = reader.IsDBNull(5) ? 0 : ToInt64(reader.GetValue(5)),
+ QueryPreview = reader.IsDBNull(6) ? "" : reader.GetString(6)
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Checks if sp_IndexCleanup is installed on the target SQL Server.
+ ///
+ public static async Task CheckSpIndexCleanupExistsAsync(string connectionString)
+ {
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+ using var command = new SqlCommand("SELECT OBJECT_ID('dbo.sp_IndexCleanup', 'P')", connection) { CommandTimeout = 30 };
+ var result = await command.ExecuteScalarAsync();
+ return result != null && result != DBNull.Value;
+ }
+
+ ///
+ /// Runs sp_IndexCleanup on the remote SQL Server and returns detail + summary result sets.
+ ///
+ public static async Task<(List Details, List Summaries)> RunIndexAnalysisAsync(
+ string connectionString, string? databaseName, bool getAllDatabases)
+ {
+ var details = new List();
+ var summaries = new List();
+
+ using var connection = new SqlConnection(connectionString);
+ await connection.OpenAsync();
+
+ using var command = new SqlCommand("dbo.sp_IndexCleanup", connection);
+ command.CommandType = System.Data.CommandType.StoredProcedure;
+ command.CommandTimeout = 300;
+
+ if (getAllDatabases)
+ {
+ command.Parameters.AddWithValue("@get_all_databases", 1);
+ }
+ else if (!string.IsNullOrWhiteSpace(databaseName))
+ {
+ command.Parameters.AddWithValue("@database_name", databaseName);
+ }
+
+ using var reader = await command.ExecuteReaderAsync();
+
+ while (await reader.ReadAsync())
+ {
+ details.Add(new IndexCleanupResultRow
+ {
+ ScriptType = reader.IsDBNull(0) ? "" : reader.GetValue(0).ToString() ?? "",
+ AdditionalInfo = reader.IsDBNull(1) ? "" : reader.GetValue(1).ToString() ?? "",
+ DatabaseName = reader.IsDBNull(2) ? "" : reader.GetValue(2).ToString() ?? "",
+ SchemaName = reader.IsDBNull(3) ? "" : reader.GetValue(3).ToString() ?? "",
+ TableName = reader.IsDBNull(4) ? "" : reader.GetValue(4).ToString() ?? "",
+ IndexName = reader.IsDBNull(5) ? "" : reader.GetValue(5).ToString() ?? "",
+ ConsolidationRule = reader.IsDBNull(6) ? "" : reader.GetValue(6).ToString() ?? "",
+ TargetIndexName = reader.IsDBNull(7) ? "" : reader.GetValue(7).ToString() ?? "",
+ SupersededInfo = reader.IsDBNull(8) ? "" : reader.GetValue(8).ToString() ?? "",
+ IndexSizeGb = reader.IsDBNull(9) ? "" : reader.GetValue(9).ToString() ?? "",
+ IndexRows = reader.IsDBNull(10) ? "" : reader.GetValue(10).ToString() ?? "",
+ IndexReads = reader.IsDBNull(11) ? "" : reader.GetValue(11).ToString() ?? "",
+ IndexWrites = reader.IsDBNull(12) ? "" : reader.GetValue(12).ToString() ?? "",
+ OriginalIndexDefinition = reader.IsDBNull(13) ? "" : reader.GetValue(13).ToString() ?? "",
+ Script = reader.IsDBNull(14) ? "" : reader.GetValue(14).ToString() ?? ""
+ });
+ }
+
+ if (await reader.NextResultAsync())
+ {
+ while (await reader.ReadAsync())
+ {
+ var fc = reader.FieldCount;
+ string Col(int i) => fc > i && !reader.IsDBNull(i) ? reader.GetValue(i).ToString() ?? "" : "";
+ summaries.Add(new IndexCleanupSummaryRow
+ {
+ Level = Col(0),
+ DatabaseInfo = Col(1),
+ SchemaName = Col(2),
+ TableName = Col(3),
+ TablesAnalyzed = Col(4),
+ TotalIndexes = Col(5),
+ RemovableIndexes = Col(6),
+ MergeableIndexes = Col(7),
+ CompressableIndexes = Col(8),
+ PercentRemovable = Col(9),
+ CurrentSizeGb = Col(10),
+ SizeAfterCleanupGb = Col(11),
+ SpaceSavedGb = Col(12),
+ SpaceReductionPercent = Col(13),
+ CompressionSavingsPotential = Col(14),
+ CompressionSavingsPotentialTotal = Col(15),
+ ComputedColumnsWithUdfs = Col(16),
+ CheckConstraintsWithUdfs = Col(17),
+ FilteredIndexesNeedingIncludes = Col(18),
+ TotalRows = Col(19),
+ ReadsBreakdown = Col(20),
+ Writes = Col(21),
+ DailyWriteOpsSaved = Col(22),
+ LockWaitCount = Col(23),
+ DailyLockWaitsSaved = Col(24),
+ AvgLockWaitMs = Col(25),
+ LatchWaitCount = Col(26),
+ DailyLatchWaitsSaved = Col(27),
+ AvgLatchWaitMs = Col(28)
+ });
+ }
+ }
+
+ return (details, summaries);
+ }
+
+ ///
+ /// Gets 7-day daily provisioning classification trend.
+ ///
+ public async Task> GetProvisioningTrendAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddDays(-7);
+
+ command.CommandText = @"
+WITH daily_cpu AS (
+ SELECT
+ CAST(collection_time AS DATE) AS day,
+ AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct,
+ MAX(sqlserver_cpu_utilization) AS max_cpu_pct,
+ PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct
+ FROM v_cpu_utilization_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ GROUP BY CAST(collection_time AS DATE)
+),
+daily_mem AS (
+ SELECT
+ CAST(collection_time AS DATE) AS day,
+ AVG(CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0)) AS avg_memory_ratio
+ FROM v_memory_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ GROUP BY CAST(collection_time AS DATE)
+)
+SELECT
+ c.day,
+ c.avg_cpu_pct,
+ c.max_cpu_pct,
+ c.p95_cpu_pct,
+ COALESCE(m.avg_memory_ratio, 0)
+FROM daily_cpu c
+LEFT JOIN daily_mem m ON m.day = c.day
+ORDER BY c.day";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ var avgCpu = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1));
+ var maxCpu = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2));
+ var p95Cpu = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3));
+ var memRatio = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4));
+
+ var status = "RIGHT_SIZED";
+ if (avgCpu < 15 && maxCpu < 40 && memRatio < 0.5m)
+ status = "OVER_PROVISIONED";
+ else if (p95Cpu > 85 || memRatio > 0.95m)
+ status = "UNDER_PROVISIONED";
+
+ items.Add(new ProvisioningTrendRow
+ {
+ Day = reader.GetDateTime(0),
+ AvgCpuPct = avgCpu,
+ MaxCpuPct = maxCpu,
+ P95CpuPct = p95Cpu,
+ MemoryRatio = memRatio,
+ Status = status
+ });
+ }
+ return items;
+ }
+
+ ///
+ /// Gets memory grant efficiency stats for the Optimization tab.
+ /// Shows pool-level grant vs used efficiency from resource semaphore snapshots.
+ ///
+ public async Task> GetMemoryGrantEfficiencyAsync(int serverId, int hoursBack = 24)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
+
+ command.CommandText = @"
+SELECT
+ CAST(collection_time AS DATE) AS day,
+ AVG(granted_memory_mb) AS avg_granted_mb,
+ AVG(used_memory_mb) AS avg_used_mb,
+ CAST(AVG(used_memory_mb) * 100.0 / NULLIF(AVG(granted_memory_mb), 0) AS DECIMAL(5,1)) AS efficiency_pct,
+ MAX(granted_memory_mb) AS peak_granted_mb,
+ SUM(grantee_count) AS total_grantees,
+ SUM(waiter_count) AS total_waiters,
+ SUM(timeout_error_count_delta) AS timeout_errors,
+ SUM(forced_grant_count_delta) AS forced_grants
+FROM v_memory_grant_stats
+WHERE server_id = $1
+AND collection_time >= $2
+GROUP BY CAST(collection_time AS DATE)
+ORDER BY CAST(collection_time AS DATE)";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = cutoff });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new MemoryGrantEfficiencyRow
+ {
+ Day = reader.GetDateTime(0),
+ AvgGrantedMb = reader.IsDBNull(1) ? 0m : Convert.ToDecimal(reader.GetValue(1)),
+ AvgUsedMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)),
+ EfficiencyPct = reader.IsDBNull(3) ? 0m : Convert.ToDecimal(reader.GetValue(3)),
+ PeakGrantedMb = reader.IsDBNull(4) ? 0m : Convert.ToDecimal(reader.GetValue(4)),
+ TotalGrantees = reader.IsDBNull(5) ? 0 : ToInt64(reader.GetValue(5)),
+ TotalWaiters = reader.IsDBNull(6) ? 0 : ToInt64(reader.GetValue(6)),
+ TimeoutErrors = reader.IsDBNull(7) ? 0 : ToInt64(reader.GetValue(7)),
+ ForcedGrants = reader.IsDBNull(8) ? 0 : ToInt64(reader.GetValue(8))
+ });
+ }
+ return items;
+ }
+}
+
+public class ProvisioningTrendRow
+{
+ public DateTime Day { get; set; }
+ public decimal AvgCpuPct { get; set; }
+ public int MaxCpuPct { get; set; }
+ public decimal P95CpuPct { get; set; }
+ public decimal MemoryRatio { get; set; }
+ public string Status { get; set; } = "";
+ public string DayDisplay => Day.ToString("ddd MM/dd");
+ public string StatusDisplay => Status.Replace("_", " ");
+}
+
+public class MemoryGrantEfficiencyRow
+{
+ public DateTime Day { get; set; }
+ public decimal AvgGrantedMb { get; set; }
+ public decimal AvgUsedMb { get; set; }
+ public decimal EfficiencyPct { get; set; }
+ public decimal PeakGrantedMb { get; set; }
+ public long TotalGrantees { get; set; }
+ public long TotalWaiters { get; set; }
+ public long TimeoutErrors { get; set; }
+ public long ForcedGrants { get; set; }
+ public string DayDisplay => Day.ToString("ddd MM/dd");
+ public decimal WastedMb => AvgGrantedMb - AvgUsedMb;
+}
+
+public class TopResourceConsumerRow
+{
+ public string DatabaseName { get; set; } = "";
+ public long CpuTimeMs { get; set; }
+ public long ExecutionCount { get; set; }
+ public decimal IoTotalMb { get; set; }
+ public decimal PctCpu { get; set; }
+ public decimal PctIo { get; set; }
+ public long TotalCpuTimeMs { get; set; }
+ public decimal AvgIoMb { get; set; }
+}
+
+public class DatabaseSizeSummaryRow
+{
+ public string DatabaseName { get; set; } = "";
+ public decimal TotalMb { get; set; }
+ public decimal? UsedMb { get; set; }
+ public decimal FreeMb => UsedMb.HasValue ? TotalMb - UsedMb.Value : TotalMb;
+ public decimal UsedPct => TotalMb > 0 && UsedMb.HasValue ? Math.Round(UsedMb.Value * 100m / TotalMb, 1) : 0;
+
+ /* Star-width GridLength for XAML binding — drives the stacked bar proportions */
+ public System.Windows.GridLength UsedStarWidth =>
+ new(Math.Max((double)(UsedMb ?? 0m), 0.1), System.Windows.GridUnitType.Star);
+ public System.Windows.GridLength FreeStarWidth =>
+ new(Math.Max((double)FreeMb, 0.1), System.Windows.GridUnitType.Star);
+}
+
+public class UtilizationEfficiencyRow
+{
+ public decimal AvgCpuPct { get; set; }
+ public int MaxCpuPct { get; set; }
+ public decimal P95CpuPct { get; set; }
+ public long CpuSamples { get; set; }
+ public int TotalMemoryMb { get; set; }
+ public int TargetMemoryMb { get; set; }
+ public int PhysicalMemoryMb { get; set; }
+ public int BufferPoolMb { get; set; }
+ public decimal MemoryRatio { get; set; }
+ public int MaxWorkersCount { get; set; }
+ public int CurrentWorkersCount { get; set; }
+ public int CpuCount { get; set; }
+ public string ProvisioningStatus { get; set; } = "";
+}
+
+public class DatabaseResourceUsageRow
+{
+ public string DatabaseName { get; set; } = "";
+ public long CpuTimeMs { get; set; }
+ public long LogicalReads { get; set; }
+ public long PhysicalReads { get; set; }
+ public long LogicalWrites { get; set; }
+ public long ExecutionCount { get; set; }
+ public decimal IoReadMb { get; set; }
+ public decimal IoWriteMb { get; set; }
+ public long IoStallMs { get; set; }
+ public decimal PctCpuShare { get; set; }
+ public decimal PctIoShare { get; set; }
+}
+
+public class ApplicationConnectionRow
+{
+ public string ApplicationName { get; set; } = "";
+ public int AvgConnections { get; set; }
+ public int MaxConnections { get; set; }
+ public long SampleCount { get; set; }
+ public DateTime FirstSeen { get; set; }
+ public DateTime LastSeen { get; set; }
+ public DateTime FirstSeenLocal => FirstSeen.ToLocalTime();
+ public DateTime LastSeenLocal => LastSeen.ToLocalTime();
+}
+
+public class DatabaseSizeRow
+{
+ public string DatabaseName { get; set; } = "";
+ public string FileTypeDesc { get; set; } = "";
+ public string FileName { get; set; } = "";
+ public decimal TotalSizeMb { get; set; }
+ public decimal? UsedSizeMb { get; set; }
+ public decimal? FreeSpaceMb => UsedSizeMb.HasValue ? TotalSizeMb - UsedSizeMb.Value : null;
+ public decimal? UsedPct => UsedSizeMb.HasValue && TotalSizeMb > 0 ? Math.Round(UsedSizeMb.Value * 100m / TotalSizeMb, 1) : null;
+ public string? VolumeMountPoint { get; set; }
+ public decimal? VolumeTotalMb { get; set; }
+ public decimal? VolumeFreeMb { get; set; }
+ public string? RecoveryModel { get; set; }
+}
+
+public class ServerPropertyRow
+{
+ public string ServerName { get; set; } = "";
+ public string Edition { get; set; } = "";
+ public string ProductVersion { get; set; } = "";
+ public string? ProductLevel { get; set; }
+ public string? ProductUpdateLevel { get; set; }
+ public int EngineEdition { get; set; }
+ public int CpuCount { get; set; }
+ public long PhysicalMemoryMb { get; set; }
+ public int? SocketCount { get; set; }
+ public int? CoresPerSocket { get; set; }
+ public DateTime? SqlServerStartTime { get; set; }
+ public DateTime? LastUpdated { get; set; }
+ public bool? IsHadrEnabled { get; set; }
+ public bool? IsClustered { get; set; }
+
+ public decimal? AvgCpuPct { get; set; }
+ public decimal? StorageTotalGb { get; set; }
+ public int? IdleDbCount { get; set; }
+ public string? ProvisioningStatus { get; set; }
+
+ public string UptimeDisplay
+ {
+ get
+ {
+ if (SqlServerStartTime == null) return "";
+ var uptime = DateTime.Now - SqlServerStartTime.Value;
+ return $"{(int)uptime.TotalDays}d {uptime.Hours}h";
+ }
+ }
+ public string HadrDisplay => IsHadrEnabled.HasValue ? (IsHadrEnabled.Value ? "Yes" : "No") : "";
+ public string ClusteredDisplay => IsClustered.HasValue ? (IsClustered.Value ? "Yes" : "No") : "";
+ public string ProvisioningDisplay => ProvisioningStatus?.Replace("_", " ") ?? "";
+}
+
+public class DatabaseSizeTrendPoint
+{
+ public DateTime CollectionTime { get; set; }
+ public string DatabaseName { get; set; } = "";
+ public decimal TotalSizeMb { get; set; }
+}
+
+public class StorageGrowthRow
+{
+ public string DatabaseName { get; set; } = "";
+ public decimal CurrentSizeMb { get; set; }
+ public decimal? Size7dAgoMb { get; set; }
+ public decimal? Size30dAgoMb { get; set; }
+ public decimal Growth7dMb { get; set; }
+ public decimal Growth30dMb { get; set; }
+ public decimal DailyGrowthRateMb { get; set; }
+ public decimal GrowthPct30d { get; set; }
+}
+
+public class IdleDatabaseRow
+{
+ public string DatabaseName { get; set; } = "";
+ public decimal TotalSizeMb { get; set; }
+ public int FileCount { get; set; }
+ public DateTime? LastExecutionTime { get; set; }
+}
+
+public class TempdbSummaryRow
+{
+ public string Metric { get; set; } = "";
+ public decimal CurrentMb { get; set; }
+ public decimal Peak24hMb { get; set; }
+ public string Warning { get; set; } = "";
+}
+
+public class WaitCategorySummaryRow
+{
+ public string Category { get; set; } = "";
+ public long TotalWaitTimeMs { get; set; }
+ public long WaitingTasks { get; set; }
+ public decimal PctOfTotal { get; set; }
+ public string TopWaitType { get; set; } = "";
+ public long TopWaitTimeMs { get; set; }
+}
+
+public class ExpensiveQueryRow
+{
+ public string DatabaseName { get; set; } = "";
+ public long TotalCpuMs { get; set; }
+ public decimal AvgCpuMsPerExec { get; set; }
+ public long TotalReads { get; set; }
+ public decimal AvgReadsPerExec { get; set; }
+ public long Executions { get; set; }
+ public string QueryPreview { get; set; } = "";
+}
+
+public class IndexCleanupResultRow
+{
+ public string ScriptType { get; set; } = "";
+ public string AdditionalInfo { get; set; } = "";
+ public string DatabaseName { get; set; } = "";
+ public string SchemaName { get; set; } = "";
+ public string TableName { get; set; } = "";
+ public string IndexName { get; set; } = "";
+ public string ConsolidationRule { get; set; } = "";
+ public string TargetIndexName { get; set; } = "";
+ public string SupersededInfo { get; set; } = "";
+ public string IndexSizeGb { get; set; } = "";
+ public string IndexRows { get; set; } = "";
+ public string IndexReads { get; set; } = "";
+ public string IndexWrites { get; set; } = "";
+ public string OriginalIndexDefinition { get; set; } = "";
+ public string Script { get; set; } = "";
+}
+
+public class IndexCleanupSummaryRow
+{
+ public string Level { get; set; } = "";
+ public string DatabaseInfo { get; set; } = "";
+ public string SchemaName { get; set; } = "";
+ public string TableName { get; set; } = "";
+ public string TablesAnalyzed { get; set; } = "";
+ public string TotalIndexes { get; set; } = "";
+ public string RemovableIndexes { get; set; } = "";
+ public string MergeableIndexes { get; set; } = "";
+ public string CompressableIndexes { get; set; } = "";
+ public string PercentRemovable { get; set; } = "";
+ public string CurrentSizeGb { get; set; } = "";
+ public string SizeAfterCleanupGb { get; set; } = "";
+ public string SpaceSavedGb { get; set; } = "";
+ public string SpaceReductionPercent { get; set; } = "";
+ public string CompressionSavingsPotential { get; set; } = "";
+ public string CompressionSavingsPotentialTotal { get; set; } = "";
+ public string ComputedColumnsWithUdfs { get; set; } = "";
+ public string CheckConstraintsWithUdfs { get; set; } = "";
+ public string FilteredIndexesNeedingIncludes { get; set; } = "";
+ public string TotalRows { get; set; } = "";
+ public string ReadsBreakdown { get; set; } = "";
+ public string Writes { get; set; } = "";
+ public string DailyWriteOpsSaved { get; set; } = "";
+ public string LockWaitCount { get; set; } = "";
+ public string DailyLockWaitsSaved { get; set; } = "";
+ public string AvgLockWaitMs { get; set; } = "";
+ public string LatchWaitCount { get; set; } = "";
+ public string DailyLatchWaitsSaved { get; set; } = "";
+ public string AvgLatchWaitMs { get; set; } = "";
+}
diff --git a/Lite/Services/LocalDataService.MemoryGrants.cs b/Lite/Services/LocalDataService.MemoryGrants.cs
index d9d50686..993b4927 100644
--- a/Lite/Services/LocalDataService.MemoryGrants.cs
+++ b/Lite/Services/LocalDataService.MemoryGrants.cs
@@ -32,7 +32,7 @@ public async Task> GetMemoryGrantTrendAsync(int serverId,
0 AS target_server_memory_mb,
0 AS buffer_pool_mb,
SUM(granted_memory_mb) AS total_granted_mb
-FROM memory_grant_stats
+FROM v_memory_grant_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
@@ -78,7 +78,7 @@ public async Task> GetMemoryGrantChartDataAsync(int
SUM(waiter_count) AS waiter_count,
SUM(timeout_error_count_delta) AS timeout_error_count_delta,
SUM(forced_grant_count_delta) AS forced_grant_count_delta
-FROM memory_grant_stats
+FROM v_memory_grant_stats
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
diff --git a/Lite/Services/LocalDataService.QueryStats.cs b/Lite/Services/LocalDataService.QueryStats.cs
index 74a7467f..3783e9f0 100644
--- a/Lite/Services/LocalDataService.QueryStats.cs
+++ b/Lite/Services/LocalDataService.QueryStats.cs
@@ -38,6 +38,7 @@ FROM sys.databases AS d
///
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");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -381,6 +382,7 @@ ps.total_elapsed_time DESC
///
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");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs
index 368a64b3..a6dde182 100644
--- a/Lite/Services/LocalDataService.QueryStore.cs
+++ b/Lite/Services/LocalDataService.QueryStore.cs
@@ -23,6 +23,7 @@ public partial class LocalDataService
///
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");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -49,7 +50,7 @@ public async Task> GetQueryStoreTopQueriesAsync(int serverId
MAX(query_plan_hash) AS query_plan_hash,
MAX(CASE WHEN is_forced_plan THEN TRUE ELSE FALSE END) AS is_forced_plan,
MAX(plan_forcing_type) AS plan_forcing_type,
- MAX(query_plan_text) AS query_plan_text,
+ NULL AS query_plan_text,
MAX(execution_type_desc) AS execution_type_desc,
MIN(first_execution_time) AS first_execution_time,
AVG(CAST(avg_clr_time_us AS DOUBLE)) / 1000.0 AS avg_clr_time_ms,
diff --git a/Lite/Services/LocalDataService.RunningJobs.cs b/Lite/Services/LocalDataService.RunningJobs.cs
index 158df4a5..2413c538 100644
--- a/Lite/Services/LocalDataService.RunningJobs.cs
+++ b/Lite/Services/LocalDataService.RunningJobs.cs
@@ -36,11 +36,11 @@ public async Task> GetRunningJobsAsync(int serverId)
successful_run_count,
is_running_long,
percent_of_average
-FROM running_jobs
+FROM v_running_jobs
WHERE server_id = $1
AND collection_time = (
SELECT MAX(collection_time)
- FROM running_jobs
+ FROM v_running_jobs
WHERE server_id = $1
)
ORDER BY current_duration_seconds DESC";
@@ -90,9 +90,9 @@ public async Task> GetAnomalousJobsAsync(int serverId, in
p95_duration_seconds,
percent_of_average,
start_time
-FROM running_jobs
+FROM v_running_jobs
WHERE server_id = $1
-AND collection_time = (SELECT MAX(collection_time) FROM running_jobs WHERE server_id = $1)
+AND collection_time = (SELECT MAX(collection_time) FROM v_running_jobs WHERE server_id = $1)
AND avg_duration_seconds >= 60
AND percent_of_average >= $2
ORDER BY percent_of_average DESC
diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs
index e18b4f5e..5a744c91 100644
--- a/Lite/Services/LocalDataService.WaitStats.cs
+++ b/Lite/Services/LocalDataService.WaitStats.cs
@@ -20,6 +20,7 @@ public partial class LocalDataService
///
public async Task> GetWaitStatsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null)
{
+ using var _q = TimeQuery("GetWaitStatsAsync", "v_wait_stats top by delta");
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
@@ -170,6 +171,7 @@ FROM v_wait_stats
WHERE server_id = $1
AND wait_type IN ('THREADPOOL', 'RESOURCE_SEMAPHORE', 'RESOURCE_SEMAPHORE_QUERY_COMPILE')
AND delta_waiting_tasks > 0
+AND collection_time >= NOW() - INTERVAL '10 minutes'
ORDER BY collection_time DESC
LIMIT 3";
@@ -192,27 +194,214 @@ ORDER BY collection_time DESC
}
///
- /// Gets long-running queries from the latest collection snapshot.
- /// Returns sessions whose total elapsed time exceeds the given threshold.
+ /// Gets query snapshots filtered by wait type, for the wait drill-down feature.
+ /// Returns sessions that were experiencing the specified wait type during the time range.
///
- public async Task> GetLongRunningQueriesAsync(int serverId, int thresholdMinutes)
+ public async Task> GetQuerySnapshotsByWaitTypeAsync(
+ int serverId, string waitType, int hoursBack = 24,
+ DateTime? fromDate = null, DateTime? toDate = null)
{
using var connection = await OpenConnectionAsync();
using var command = connection.CreateCommand();
- var thresholdMs = (long)thresholdMinutes * 60 * 1000;
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
+
+ command.CommandText = @"
+SELECT
+ session_id,
+ database_name,
+ elapsed_time_formatted,
+ query_text,
+ status,
+ blocking_session_id,
+ wait_type,
+ wait_time_ms,
+ wait_resource,
+ cpu_time_ms,
+ total_elapsed_time_ms,
+ reads,
+ writes,
+ logical_reads,
+ granted_query_memory_gb,
+ transaction_isolation_level,
+ dop,
+ parallel_worker_count,
+ query_plan,
+ live_query_plan,
+ collection_time,
+ login_name,
+ host_name,
+ program_name,
+ open_transaction_count,
+ percent_complete
+FROM v_query_snapshots
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+AND wait_type = $4
+ORDER BY wait_time_ms DESC
+LIMIT 500";
- // Exclude internal SP_SERVER_DIAGNOSTICS queries by default, as they often run long and aren't actionable.
- string spServerDiagnosticsFilter = "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'";
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = startTime });
+ command.Parameters.Add(new DuckDBParameter { Value = endTime });
+ command.Parameters.Add(new DuckDBParameter { Value = waitType });
- // Exclude WAITFOR queries by default, as they can run indefinitely and may not indicate a problem.
- string waitForFilter = "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')";
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new QuerySnapshotRow
+ {
+ SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
+ DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3),
+ Status = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5),
+ WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
+ WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8),
+ CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9),
+ TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10),
+ Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11),
+ Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12),
+ LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13),
+ GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)),
+ TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15),
+ Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16),
+ ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17),
+ QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18),
+ LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19),
+ CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20),
+ LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21),
+ HostName = reader.IsDBNull(22) ? "" : reader.GetString(22),
+ ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23),
+ OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24),
+ PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25))
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets ALL query snapshots in a time range (for chain walking).
+ /// Used when a chain wait type (LCK_M_*, LATCH_EX/UP) needs blocking chain traversal.
+ ///
+ public async Task> GetAllQuerySnapshotsInRangeAsync(
+ int serverId, int hoursBack = 24,
+ DateTime? fromDate = null, DateTime? toDate = null)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate);
- // Exclude backup waits if specified, as they can run long and aren't typically actionable in this context.
- string backupsFilter = "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')";
+ command.CommandText = @"
+SELECT
+ session_id,
+ database_name,
+ elapsed_time_formatted,
+ query_text,
+ status,
+ blocking_session_id,
+ wait_type,
+ wait_time_ms,
+ wait_resource,
+ cpu_time_ms,
+ total_elapsed_time_ms,
+ reads,
+ writes,
+ logical_reads,
+ granted_query_memory_gb,
+ transaction_isolation_level,
+ dop,
+ parallel_worker_count,
+ query_plan,
+ live_query_plan,
+ collection_time,
+ login_name,
+ host_name,
+ program_name,
+ open_transaction_count,
+ percent_complete
+FROM v_query_snapshots
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+ORDER BY collection_time DESC
+LIMIT 2000";
+
+ 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 QuerySnapshotRow
+ {
+ SessionId = reader.IsDBNull(0) ? 0 : reader.GetInt32(0),
+ DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ ElapsedTimeFormatted = reader.IsDBNull(2) ? "" : reader.GetString(2),
+ QueryText = reader.IsDBNull(3) ? "" : reader.GetString(3),
+ Status = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ BlockingSessionId = reader.IsDBNull(5) ? 0 : reader.GetInt32(5),
+ WaitType = reader.IsDBNull(6) ? "" : reader.GetString(6),
+ WaitTimeMs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
+ WaitResource = reader.IsDBNull(8) ? "" : reader.GetString(8),
+ CpuTimeMs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9),
+ TotalElapsedTimeMs = reader.IsDBNull(10) ? 0 : reader.GetInt64(10),
+ Reads = reader.IsDBNull(11) ? 0 : reader.GetInt64(11),
+ Writes = reader.IsDBNull(12) ? 0 : reader.GetInt64(12),
+ LogicalReads = reader.IsDBNull(13) ? 0 : reader.GetInt64(13),
+ GrantedQueryMemoryGb = reader.IsDBNull(14) ? 0 : ToDouble(reader.GetValue(14)),
+ TransactionIsolationLevel = reader.IsDBNull(15) ? "" : reader.GetString(15),
+ Dop = reader.IsDBNull(16) ? 0 : reader.GetInt32(16),
+ ParallelWorkerCount = reader.IsDBNull(17) ? 0 : reader.GetInt32(17),
+ QueryPlan = reader.IsDBNull(18) ? null : reader.GetString(18),
+ LiveQueryPlan = reader.IsDBNull(19) ? null : reader.GetString(19),
+ CollectionTime = reader.IsDBNull(20) ? DateTime.MinValue : reader.GetDateTime(20),
+ LoginName = reader.IsDBNull(21) ? "" : reader.GetString(21),
+ HostName = reader.IsDBNull(22) ? "" : reader.GetString(22),
+ ProgramName = reader.IsDBNull(23) ? "" : reader.GetString(23),
+ OpenTransactionCount = reader.IsDBNull(24) ? 0 : reader.GetInt32(24),
+ PercentComplete = reader.IsDBNull(25) ? 0m : Convert.ToDecimal(reader.GetValue(25))
+ });
+ }
+
+ return items;
+ }
+
+ ///
+ /// Gets long-running queries from the latest collection snapshot.
+ /// Returns sessions whose total elapsed time exceeds the given threshold.
+ ///
+ public async Task> GetLongRunningQueriesAsync(
+ int serverId,
+ int thresholdMinutes,
+ int maxResults = 5,
+ bool excludeSpServerDiagnostics = true,
+ bool excludeWaitFor = true,
+ bool excludeBackups = true,
+ bool excludeMiscWaits = true)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+
+ var thresholdMs = (long)thresholdMinutes * 60 * 1000;
- // Exclude miscellaneous wait type that aren't typically actionable
- string miscWaitsFilter = "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')";
+ string spServerDiagnosticsFilter = excludeSpServerDiagnostics
+ ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : "";
+ string waitForFilter = excludeWaitFor
+ ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : "";
+ string backupsFilter = excludeBackups
+ ? "AND r.wait_type NOT IN (N'BACKUPTHREAD', N'BACKUPIO')" : "";
+ string miscWaitsFilter = excludeMiscWaits
+ ? "AND r.wait_type NOT IN (N'XE_LIVE_TARGET_TVF')" : "";
+ maxResults = Math.Clamp(maxResults, 1, 1000);
command.CommandText = @$"
SELECT
@@ -235,10 +424,11 @@ AND r.session_id > 50
{miscWaitsFilter}
AND r.total_elapsed_time_ms >= $2
ORDER BY r.total_elapsed_time_ms DESC
- LIMIT 5;";
+ LIMIT $3;";
command.Parameters.Add(new DuckDBParameter { Value = serverId });
command.Parameters.Add(new DuckDBParameter { Value = thresholdMs });
+ command.Parameters.Add(new DuckDBParameter { Value = maxResults });
var items = new List();
using var reader = await command.ExecuteReaderAsync();
diff --git a/Lite/Services/LocalDataService.WaitingTasks.cs b/Lite/Services/LocalDataService.WaitingTasks.cs
index 0f0a3202..8d102c9f 100644
--- a/Lite/Services/LocalDataService.WaitingTasks.cs
+++ b/Lite/Services/LocalDataService.WaitingTasks.cs
@@ -31,7 +31,7 @@ public async Task> GetWaitingTasksAsync(int serverId, int h
blocking_session_id,
resource_description,
database_name
-FROM waiting_tasks
+FROM v_waiting_tasks
WHERE server_id = $1
AND collection_time >= $2
ORDER BY collection_time DESC, wait_duration_ms DESC";
@@ -73,7 +73,7 @@ public async Task> GetWaitingTaskTrendAsync(int serv
collection_time,
wait_type,
SUM(wait_duration_ms) AS total_wait_ms
-FROM waiting_tasks
+FROM v_waiting_tasks
WHERE server_id = $1
AND collection_time >= $2
AND collection_time <= $3
@@ -118,7 +118,7 @@ public async Task> GetBlockedSessionTrendAsync(in
collection_time,
database_name,
COUNT(*) AS blocked_count
-FROM waiting_tasks
+FROM v_waiting_tasks
WHERE server_id = $1
AND blocking_session_id > 0
AND collection_time >= $2
diff --git a/Lite/Services/LocalDataService.cs b/Lite/Services/LocalDataService.cs
index ca65309b..d0aae7dc 100644
--- a/Lite/Services/LocalDataService.cs
+++ b/Lite/Services/LocalDataService.cs
@@ -107,4 +107,13 @@ protected static (DateTime startTime, DateTime endTime) GetTimeRangeServerLocal(
return (serverNow.AddHours(-hoursBack), serverNow);
}
+ ///
+ /// Starts query timing for performance logging. Use with 'using' statement.
+ /// Only logs queries that exceed the slow query threshold (default 500ms).
+ ///
+ protected static Helpers.QueryExecutionContext TimeQuery(string context, string sql)
+ {
+ return Helpers.QueryLogger.StartQuery(context, sql, source: "DuckDB");
+ }
+
}
diff --git a/Lite/Services/MuteRuleService.cs b/Lite/Services/MuteRuleService.cs
new file mode 100644
index 00000000..4bf3d5c6
--- /dev/null
+++ b/Lite/Services/MuteRuleService.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitorLite.Database;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services
+{
+ ///
+ /// Manages alert mute rules with DuckDB persistence.
+ /// Rules are cached in memory for fast matching and synced to DuckDB on changes.
+ /// Thread-safe: all operations are protected by _lock.
+ /// Database operations use the LockedConnection pattern to coordinate with CHECKPOINT.
+ ///
+ public class MuteRuleService
+ {
+ private readonly DuckDbInitializer _dbInitializer;
+ private readonly object _lock = new object();
+ private List _rules = new();
+
+ public MuteRuleService(DuckDbInitializer dbInitializer)
+ {
+ _dbInitializer = dbInitializer;
+ }
+
+ public bool IsAlertMuted(AlertMuteContext context)
+ {
+ lock (_lock)
+ {
+ return _rules.Any(r => r.Matches(context));
+ }
+ }
+
+ public List GetRules()
+ {
+ lock (_lock)
+ {
+ return _rules.ToList();
+ }
+ }
+
+ public List GetActiveRules()
+ {
+ lock (_lock)
+ {
+ return _rules.Where(r => r.Enabled && !r.IsExpired).ToList();
+ }
+ }
+
+ public async Task LoadAsync()
+ {
+ var rules = new List();
+ try
+ {
+ using var readLock = _dbInitializer.AcquireReadLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ SELECT id, enabled, created_at_utc, expires_at_utc, reason,
+ server_name, metric_name, database_pattern,
+ query_text_pattern, wait_type_pattern, job_name_pattern
+ FROM config_mute_rules
+ ORDER BY created_at_utc DESC";
+
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ rules.Add(new MuteRule
+ {
+ Id = reader.GetString(0),
+ Enabled = reader.GetBoolean(1),
+ CreatedAtUtc = reader.GetDateTime(2),
+ ExpiresAtUtc = reader.IsDBNull(3) ? null : reader.GetDateTime(3),
+ Reason = reader.IsDBNull(4) ? null : reader.GetString(4),
+ ServerName = reader.IsDBNull(5) ? null : reader.GetString(5),
+ MetricName = reader.IsDBNull(6) ? null : reader.GetString(6),
+ DatabasePattern = reader.IsDBNull(7) ? null : reader.GetString(7),
+ QueryTextPattern = reader.IsDBNull(8) ? null : reader.GetString(8),
+ WaitTypePattern = reader.IsDBNull(9) ? null : reader.GetString(9),
+ JobNamePattern = reader.IsDBNull(10) ? null : reader.GetString(10)
+ });
+ }
+ }
+ catch
+ {
+ /* Non-fatal — start with empty rules if DB not ready */
+ }
+
+ lock (_lock)
+ {
+ _rules = rules;
+ }
+
+ /* Purge expired rules on startup */
+ await PurgeExpiredRulesAsync();
+ }
+
+ public async Task AddRuleAsync(MuteRule rule)
+ {
+ try
+ {
+ await PersistRuleAsync(rule);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to persist new mute rule to DuckDB — rule will not be saved: {ex.Message}");
+ return;
+ }
+
+ lock (_lock)
+ {
+ _rules.Add(rule);
+ }
+ }
+
+ public async Task RemoveRuleAsync(string ruleId)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM config_mute_rules WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = ruleId });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to delete mute rule from DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => r.Id == ruleId);
+ }
+ }
+
+ public async Task UpdateRuleAsync(MuteRule updated)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ UPDATE config_mute_rules SET
+ enabled = $2, expires_at_utc = $3, reason = $4,
+ server_name = $5, metric_name = $6, database_pattern = $7,
+ query_text_pattern = $8, wait_type_pattern = $9, job_name_pattern = $10
+ WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = updated.Id });
+ cmd.Parameters.Add(new DuckDBParameter { Value = updated.Enabled });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.ExpiresAtUtc ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.Reason ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.ServerName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.MetricName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.DatabasePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.QueryTextPattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.WaitTypePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)updated.JobNamePattern ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to update mute rule in DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ var index = _rules.FindIndex(r => r.Id == updated.Id);
+ if (index >= 0)
+ _rules[index] = updated;
+ }
+ }
+
+ public async Task SetRuleEnabledAsync(string ruleId, bool enabled)
+ {
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "UPDATE config_mute_rules SET enabled = $2 WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = ruleId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = enabled });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to update mute rule enabled state in DuckDB: {ex.Message}");
+ }
+
+ lock (_lock)
+ {
+ var rule = _rules.FirstOrDefault(r => r.Id == ruleId);
+ if (rule != null) rule.Enabled = enabled;
+ }
+ }
+
+ public async Task PurgeExpiredRulesAsync()
+ {
+ List expiredIds;
+ lock (_lock)
+ {
+ expiredIds = _rules.Where(r => r.IsExpired).Select(r => r.Id).ToList();
+ if (expiredIds.Count == 0) return 0;
+ }
+
+ try
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ foreach (var id in expiredIds)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM config_mute_rules WHERE id = $1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = id });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("MuteRuleService", $"Failed to purge expired mute rules from DuckDB: {ex.Message}");
+ return 0;
+ }
+
+ lock (_lock)
+ {
+ _rules.RemoveAll(r => expiredIds.Contains(r.Id));
+ }
+
+ return expiredIds.Count;
+ }
+
+ private async Task PersistRuleAsync(MuteRule rule)
+ {
+ using var writeLock = _dbInitializer.AcquireWriteLock();
+ using var connection = _dbInitializer.CreateConnection();
+ await connection.OpenAsync();
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+ INSERT INTO config_mute_rules
+ (id, enabled, created_at_utc, expires_at_utc, reason,
+ server_name, metric_name, database_pattern,
+ query_text_pattern, wait_type_pattern, job_name_pattern)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)";
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.Id });
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.Enabled });
+ cmd.Parameters.Add(new DuckDBParameter { Value = rule.CreatedAtUtc });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.ExpiresAtUtc ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.Reason ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.ServerName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.MetricName ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.DatabasePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.QueryTextPattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.WaitTypePattern ?? DBNull.Value });
+ cmd.Parameters.Add(new DuckDBParameter { Value = (object?)rule.JobNamePattern ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+}
diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs
index 8031874d..0905dd78 100644
--- a/Lite/Services/PlanAnalyzer.cs
+++ b/Lite/Services/PlanAnalyzer.cs
@@ -10,24 +10,16 @@ namespace PerformanceMonitorLite.Services;
/// Post-parse analysis pass that walks a parsed plan tree and adds warnings
/// for common performance anti-patterns. Called after ShowPlanParser.Parse().
///
-public static class PlanAnalyzer
+public static partial class PlanAnalyzer
{
- private static readonly Regex FunctionInPredicateRegex = new(
- @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex FunctionInPredicateRegex = FunctionInPredicateRegExp();
- private static readonly Regex LeadingWildcardLikeRegex = new(
- @"\blike\b[^'""]*?N?'%",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex LeadingWildcardLikeRegex = LeadingWildcardLikeRegExp();
- private static readonly Regex CaseInPredicateRegex = new(
- @"\bCASE\s+(WHEN\b|$)",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex CaseInPredicateRegex = CaseInPredicateRegExp();
// Matches CTE definitions: WITH name AS ( or , name AS (
- private static readonly Regex CteDefinitionRegex = new(
- @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex CteDefinitionRegex = CteDefinitionRegExp();
public static void Analyze(ParsedPlan plan)
{
@@ -186,7 +178,7 @@ private static void AnalyzeStatement(PlanStatement stmt)
// Rule 27: OPTIMIZE FOR UNKNOWN in statement text
if (!string.IsNullOrEmpty(stmt.StatementText) &&
- Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase))
+ OptimizeForUnknownRegExp().IsMatch(stmt.StatementText))
{
stmt.PlanWarnings.Add(new PlanWarning
{
@@ -196,24 +188,44 @@ private static void AnalyzeStatement(PlanStatement stmt)
});
}
- // Rule 25: Ineffective parallelism — parallel plan where CPU ≈ elapsed
+ // Rule 25: Ineffective parallelism — DOP-aware efficiency scoring
+ // Efficiency = (speedup - 1) / (DOP - 1) * 100
+ // where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%).
+ // Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working.
if (stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null)
{
var cpu = stmt.QueryTimeStats.CpuTimeMs;
var elapsed = stmt.QueryTimeStats.ElapsedTimeMs;
+ var dop = stmt.DegreeOfParallelism;
if (elapsed >= 1000 && cpu > 0)
{
- var ratio = (double)cpu / elapsed;
- if (ratio <= 1.3)
+ var speedup = (double)cpu / elapsed;
+ var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0));
+
+ if (speedup < 0.5)
+ {
+ // CPU well below Elapsed: threads are waiting, not doing CPU work
+ var waitPct = (1.0 - speedup) * 100;
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Parallel Wait Bottleneck",
+ Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " +
+ $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " +
+ $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+ else if (efficiency < 40)
{
+ // CPU >= Elapsed but well below DOP potential — parallelism is ineffective
stmt.PlanWarnings.Add(new PlanWarning
{
WarningType = "Ineffective Parallelism",
- Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) but CPU time ({cpu:N0}ms) is nearly equal to elapsed time ({elapsed:N0}ms). " +
- $"The work ran essentially serially despite the overhead of parallelism. " +
+ Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " +
+ $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " +
$"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.",
- Severity = PlanWarningSeverity.Warning
+ Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
@@ -279,6 +291,53 @@ private static void AnalyzeStatement(PlanStatement stmt)
}
}
}
+
+ // Rule 22 (statement-level): Table variable warnings
+ if (stmt.RootNode != null)
+ {
+ var hasTableVar = false;
+ var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE";
+ var modifiesTableVar = false;
+ CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar);
+
+ if (hasTableVar && !modifiesTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+
+ if (modifiesTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.",
+ Severity = PlanWarningSeverity.Critical
+ });
+ }
+ }
+ }
+
+ private static void CheckForTableVariables(PlanNode node, bool isModification,
+ ref bool hasTableVar, ref bool modifiesTableVar)
+ {
+ if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
+ {
+ hasTableVar = true;
+ if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
+ {
+ modifiesTableVar = true;
+ }
+ }
+ foreach (var child in node.Children)
+ CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
}
private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt)
@@ -296,10 +355,16 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate))
{
var impact = QuantifyFilterImpact(node);
+ var predicate = Truncate(node.Predicate, 200);
+ var message = "Filter operator discarding rows late in the plan.";
+ if (!string.IsNullOrEmpty(impact))
+ message += $"\n{impact}";
+ message += $"\nPredicate: {predicate}";
+
node.Warnings.Add(new PlanWarning
{
WarningType = "Filter Operator",
- Message = $"Filter operator discarding rows late in the plan.{impact} Predicate: {Truncate(node.Predicate, 200)}",
+ Message = message,
Severity = PlanWarningSeverity.Warning
});
}
@@ -366,12 +431,12 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
var direction = ratio >= 10.0 ? "underestimated" : "overestimated";
var factor = ratio >= 10.0 ? ratio : 1.0 / ratio;
var actualDisplay = executions > 1
- ? $"actual {actualPerExec:N0}/exec ({node.ActualRows:N0} total across {executions:N0} executions)"
- : $"actual {node.ActualRows:N0}";
+ ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)"
+ : $"Actual {node.ActualRows:N0}";
node.Warnings.Add(new PlanWarning
{
WarningType = "Row Estimate Mismatch",
- Message = $"Estimated {node.EstimateRows:N0} rows, {actualDisplay} ({factor:F0}x {direction}). {harm}",
+ Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}",
Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
@@ -444,15 +509,19 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
// Rule 8: Parallel thread skew (actual plans with per-thread stats)
// Only warn when there are enough rows to meaningfully distribute across threads
+ // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators
if (node.PerThreadStats.Count > 1)
{
- var totalRows = node.PerThreadStats.Sum(t => t.ActualRows);
- var minRowsForSkew = node.PerThreadStats.Count * 1000;
+ var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList();
+ if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback
+ var totalRows = workerThreads.Sum(t => t.ActualRows);
+ var minRowsForSkew = workerThreads.Count * 1000;
if (totalRows >= minRowsForSkew)
{
- var maxThread = node.PerThreadStats.OrderByDescending(t => t.ActualRows).First();
+ var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First();
var skewRatio = (double)maxThread.ActualRows / totalRows;
- var skewThreshold = node.PerThreadStats.Count == 2 ? 0.75 : 0.50;
+ // At DOP 2, a 60/40 split is normal — use higher threshold
+ var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50;
if (skewRatio >= skewThreshold)
{
node.Warnings.Add(new PlanWarning
@@ -467,7 +536,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
// Rule 10: Key Lookup / RID Lookup with residual predicate
// Check RID Lookup first — it's more specific (PhysicalOp) and also has Lookup=true
- if (node.PhysicalOp == "RID Lookup")
+ if (node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase))
{
var message = "RID Lookup — this table is a heap (no clustered index). SQL Server found rows via a nonclustered index but had to follow row identifiers back to unordered heap pages. Heap lookups are more expensive than key lookups because pages are not sorted and may have forwarding pointers. Add a clustered index to the table.";
if (!string.IsNullOrEmpty(node.Predicate))
@@ -485,8 +554,8 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt)
node.Warnings.Add(new PlanWarning
{
WarningType = "Key Lookup",
- Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column. Predicate: {Truncate(node.Predicate, 200)}",
- Severity = PlanWarningSeverity.Warning
+ Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column.\nPredicate: {Truncate(node.Predicate, 200)}",
+ Severity = PlanWarningSeverity.Critical
});
}
@@ -513,7 +582,7 @@ _ when nonSargableReason.StartsWith("Function call") =>
node.Warnings.Add(new PlanWarning
{
WarningType = "Non-SARGable Predicate",
- Message = $"{nonSargableAdvice} Predicate: {Truncate(node.Predicate!, 200)}",
+ Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
@@ -523,10 +592,11 @@ _ when nonSargableReason.StartsWith("Function call") =>
if (nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate) &&
!IsProbeOnly(node.Predicate))
{
+ var displayPredicate = StripProbeExpressions(node.Predicate);
node.Warnings.Add(new PlanWarning
{
WarningType = "Scan With Predicate",
- Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Create an index on the predicate columns. Predicate: {Truncate(node.Predicate, 200)}",
+ Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: {Truncate(displayPredicate, 200)}",
Severity = PlanWarningSeverity.Warning
});
}
@@ -554,7 +624,7 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio
// Rebinds = cache misses (child re-executes), rewinds = cache hits (reuse cached result)
- if (node.LogicalOp == "Lazy Spool")
+ if (node.LogicalOp == "Lazy Spool" && !node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase))
{
var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds;
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
@@ -686,13 +756,19 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 22: Table variables (Object name starts with @)
if (!string.IsNullOrEmpty(node.ObjectName) &&
- node.ObjectName.StartsWith("@"))
+ node.ObjectName.StartsWith('@'))
{
+ var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase);
+
node.Warnings.Add(new PlanWarning
{
WarningType = "Table Variable",
- Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
- Severity = PlanWarningSeverity.Warning
+ Message = isModificationOp
+ ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution."
+ : "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
+ Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
@@ -708,42 +784,48 @@ _ when nonSargableReason.StartsWith("Function call") =>
});
}
- // Rule 24: Top above a scan on the inner side of Nested Loops
- // This pattern means the scan executes once per outer row, and the Top
- // limits each iteration — but with no supporting index the scan is a
- // linear search repeated potentially millions of times.
- if (node.PhysicalOp == "Nested Loops" && node.Children.Count >= 2)
+ // Rule 24: Top above a scan
+ // Detects Top or Top N Sort operators feeding from a scan. This often means the
+ // query is scanning the entire table/index and sorting just to return a few rows,
+ // when an appropriate index could satisfy the request directly.
{
- var inner = node.Children[1];
-
- // Walk through pass-through operators to find Top
- while (inner.PhysicalOp == "Compute Scalar" && inner.Children.Count > 0)
- inner = inner.Children[0];
+ var isTop = node.PhysicalOp == "Top";
+ var isTopNSort = node.LogicalOp == "Top N Sort";
- if (inner.PhysicalOp == "Top" && inner.Children.Count > 0)
+ if ((isTop || isTopNSort) && node.Children.Count > 0)
{
// Walk through pass-through operators below the Top to find the scan
- var scanCandidate = inner.Children[0];
- while (scanCandidate.PhysicalOp == "Compute Scalar" && scanCandidate.Children.Count > 0)
+ var scanCandidate = node.Children[0];
+ while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism")
+ && scanCandidate.Children.Count > 0)
scanCandidate = scanCandidate.Children[0];
if (IsScanOperator(scanCandidate))
{
+ var topLabel = isTopNSort ? "Top N Sort" : "Top";
+ var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2
+ && node.Parent.Children[1] == node;
+ var innerNote = onInner
+ ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row."
+ : "";
var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate)
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
: "";
- inner.Warnings.Add(new PlanWarning
+ node.Warnings.Add(new PlanWarning
{
WarningType = "Top Above Scan",
- Message = $"Top operator reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}) on the inner side of Nested Loops (Node {node.NodeId}).{predInfo} Create an index on the predicate columns to convert the scan into a seek.",
- Severity = PlanWarningSeverity.Warning
+ Message = $"{topLabel} reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}).{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.",
+ Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
}
// Rule 26: Row Goal (informational) — optimizer reduced estimate due to TOP/EXISTS/IN
- if (node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 &&
+ // Only surface on data access operators (seeks/scans) where the row goal actually matters
+ var isDataAccess = node.PhysicalOp != null &&
+ (node.PhysicalOp.Contains("Scan") || node.PhysicalOp.Contains("Seek"));
+ if (isDataAccess && node.EstimateRowsWithoutRowGoal > 0 && node.EstimateRows > 0 &&
node.EstimateRowsWithoutRowGoal > node.EstimateRows)
{
var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows;
@@ -758,7 +840,7 @@ _ when nonSargableReason.StartsWith("Function call") =>
// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
- if (node.PhysicalOp == "Row Count Spool")
+ if (node.PhysicalOp.Contains("Row Count Spool"))
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
@@ -793,7 +875,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt)
{
// Check statement text for NOT IN
if (string.IsNullOrEmpty(stmt.StatementText) ||
- !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase))
+ !NotInRegExp().IsMatch(stmt.StatementText))
return false;
// Walk up the tree checking ancestors and their children
@@ -854,6 +936,21 @@ private static bool IsProbeOnly(string predicate)
return stripped.Length == 0;
}
+ ///
+ /// Strips PROBE(...) bitmap filter expressions from a predicate for display,
+ /// leaving only the real residual predicate columns.
+ ///
+ private static string StripProbeExpressions(string predicate)
+ {
+ var stripped = Regex.Replace(predicate, @"\s*AND\s+PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
+ RegexOptions.IgnoreCase);
+ stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)\s*AND\s+", "",
+ RegexOptions.IgnoreCase);
+ stripped = Regex.Replace(stripped, @"PROBE\s*\([^()]*(?:\([^()]*\)[^()]*)*\)", "",
+ RegexOptions.IgnoreCase);
+ return stripped.Trim();
+ }
+
///
/// Returns true for any scan operator including columnstore.
/// Excludes spools and constant scans.
@@ -890,7 +987,7 @@ private static bool IsScanOperator(PlanNode node)
return "Implicit conversion (CONVERT_IMPLICIT)";
// ISNULL / COALESCE wrapping column
- if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase))
+ if (IsNullCoalesceRegExp().IsMatch(predicate))
return "ISNULL/COALESCE wrapping column";
// Common function calls on columns
@@ -930,7 +1027,7 @@ private static void DetectMultiReferenceCte(PlanStatement stmt)
var refPattern = new Regex(
$@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b",
RegexOptions.IgnoreCase);
- var refCount = refPattern.Matches(text).Count;
+ var refCount = refPattern.Count(text);
if (refCount > 1)
{
@@ -955,8 +1052,8 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode)
while (parent != null && parent.PhysicalOp == "Compute Scalar")
parent = parent.Parent;
- // Expect TopN Sort
- if (parent == null || parent.LogicalOp != "TopN Sort")
+ // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort")
+ if (parent == null || parent.LogicalOp != "Top N Sort")
return false;
// Walk up to Merge Interval
@@ -1133,7 +1230,7 @@ private static string QuantifyFilterImpact(PlanNode filterNode)
if (parts.Count == 0)
return "";
- return $" Subtree cost to produce filtered rows: {string.Join(", ", parts)}.";
+ return string.Join("\n", parts.Select(p => "• " + p));
}
private static long SumSubtreeReads(PlanNode node)
@@ -1176,8 +1273,8 @@ private static long SumSubtreeReads(PlanNode node)
if (node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && !node.IsAdaptive)
{
return ratio >= 10.0
- ? "The underestimate may have caused the optimizer to choose a suboptimal join strategy."
- : "The overestimate may have caused the optimizer to choose a suboptimal join strategy.";
+ ? "The underestimate may have caused the optimizer to make poor choices."
+ : "The overestimate may have caused the optimizer to make poor choices.";
}
// Walk up to check if a parent was harmed by this bad estimate
@@ -1204,8 +1301,8 @@ private static long SumSubtreeReads(PlanNode node)
return null; // Adaptive join self-corrects — no harm
return ratio >= 10.0
- ? $"The underestimate may have caused the optimizer to choose {ancestor.PhysicalOp} when a different join type would be more efficient."
- : $"The overestimate may have caused the optimizer to choose {ancestor.PhysicalOp} when a different join type would be more efficient.";
+ ? "The underestimate may have caused the optimizer to make poor choices."
+ : "The overestimate may have caused the optimizer to make poor choices.";
}
// Parent Sort/Hash that spilled — downstream bad estimate caused the spill
@@ -1243,4 +1340,19 @@ private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength ? value : value[..maxLength] + "...";
}
+
+ [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex FunctionInPredicateRegExp();
+ [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)]
+ private static partial Regex LeadingWildcardLikeRegExp();
+ [GeneratedRegex(@"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase)]
+ private static partial Regex CaseInPredicateRegExp();
+ [GeneratedRegex(@"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex CteDefinitionRegExp();
+ [GeneratedRegex(@"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)]
+ private static partial Regex IsNullCoalesceRegExp();
+ [GeneratedRegex(@"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)]
+ private static partial Regex OptimizeForUnknownRegExp();
+ [GeneratedRegex(@"\bNOT\s+IN\b", RegexOptions.IgnoreCase)]
+ private static partial Regex NotInRegExp();
}
diff --git a/Lite/Services/PlanIconMapper.cs b/Lite/Services/PlanIconMapper.cs
index 7c542857..f187eda1 100644
--- a/Lite/Services/PlanIconMapper.cs
+++ b/Lite/Services/PlanIconMapper.cs
@@ -30,6 +30,8 @@ public static class PlanIconMapper
["Index Scan"] = "index_scan",
["Index Seek"] = "index_seek",
["Index Spool"] = "index_spool",
+ ["Eager Index Spool"] = "index_spool",
+ ["Lazy Index Spool"] = "index_spool",
["Index Update"] = "index_update",
// Columnstore
@@ -74,7 +76,11 @@ public static class PlanIconMapper
// Spool
["Table Spool"] = "table_spool",
+ ["Eager Table Spool"] = "table_spool",
+ ["Lazy Table Spool"] = "table_spool",
["Row Count Spool"] = "row_count_spool",
+ ["Eager Row Count Spool"] = "row_count_spool",
+ ["Lazy Row Count Spool"] = "row_count_spool",
["Window Spool"] = "table_spool",
["Eager Spool"] = "table_spool",
["Lazy Spool"] = "table_spool",
diff --git a/Lite/Services/PortUtilityService.cs b/Lite/Services/PortUtilityService.cs
new file mode 100644
index 00000000..461ce398
--- /dev/null
+++ b/Lite/Services/PortUtilityService.cs
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PerformanceMonitorLite.Services;
+
+public static partial class PortUtilityService
+{
+ // Checks whether something is currently LISTENING on this TCP port.
+ // Note: the underlying API is synchronous; we yield once to keep an async signature.
+ public static async Task IsTcpPortListeningAsync(
+ int port,
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
+ throw new ArgumentOutOfRangeException(nameof(port));
+
+ address ??= IPAddress.Any;
+
+ var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners();
+
+ return listeners.Any(ep =>
+ ep.Port == port &&
+ (ep.Address.Equals(address) ||
+ ep.Address.Equals(IPAddress.Any) ||
+ ep.Address.Equals(IPAddress.IPv6Any)));
+ }
+
+ // Definitive check: attempt to bind.
+ // Note: the underlying API is synchronous; we yield once to keep an async signature.
+ public static async Task CanBindTcpPortAsync(
+ int port,
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ if (port < IPEndPoint.MinPort || port > IPEndPoint.MaxPort)
+ throw new ArgumentOutOfRangeException(nameof(port));
+
+ address ??= IPAddress.Any;
+
+ try
+ {
+ var listener = new TcpListener(address, port);
+ listener.Start();
+ listener.Stop();
+ return true;
+ }
+ catch (SocketException)
+ {
+ return false;
+ }
+ }
+
+ // Let the OS choose a free port (0), then read it back.
+ // Note: this is still synchronous under the hood; we yield once to keep an async signature.
+ public static async Task GetFreeTcpPortAsync(
+ IPAddress? address = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.Yield();
+
+ address ??= IPAddress.Loopback;
+
+ var listener = new TcpListener(address, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
index b47a9284..cd5c90a7 100644
--- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
+++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs
@@ -418,7 +418,7 @@ as it lingers in the ring buffer across collection cycles. */
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(parsed.EventTime)
.AppendValue(parsed.DatabaseName)
.AppendValue(parsed.BlockedSpid)
diff --git a/Lite/Services/RemoteCollectorService.Cpu.cs b/Lite/Services/RemoteCollectorService.Cpu.cs
index 800fb01c..a46c0681 100644
--- a/Lite/Services/RemoteCollectorService.Cpu.cs
+++ b/Lite/Services/RemoteCollectorService.Cpu.cs
@@ -147,7 +147,7 @@ drs.end_time DESC
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(sampleTime)
.AppendValue(reader.IsDBNull(1) ? 0 : reader.GetInt32(1))
.AppendValue(reader.IsDBNull(2) ? 0 : reader.GetInt32(2))
diff --git a/Lite/Services/RemoteCollectorService.DatabaseSize.cs b/Lite/Services/RemoteCollectorService.DatabaseSize.cs
new file mode 100644
index 00000000..7005819f
--- /dev/null
+++ b/Lite/Services/RemoteCollectorService.DatabaseSize.cs
@@ -0,0 +1,264 @@
+/*
+ * 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 System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services;
+
+public partial class RemoteCollectorService
+{
+ ///
+ /// Collects per-file database sizes for growth trending and capacity planning.
+ /// On-prem: queries sys.master_files + sys.databases + dm_os_volume_stats for file and drive context.
+ /// Azure SQL DB: queries sys.database_files for the single database (no volume stats available).
+ ///
+ private async Task CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken)
+ {
+ var serverStatus = _serverManager.GetConnectionStatus(server.Id);
+ bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;
+
+ const string onPremQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+SET NOCOUNT ON;
+
+CREATE TABLE #file_space
+(
+ database_id int NOT NULL,
+ file_id int NOT NULL,
+ used_size_mb decimal(19,2) NULL
+);
+
+DECLARE
+ @db_name sysname,
+ @sql nvarchar(MAX);
+
+DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
+ SELECT
+ d.name
+ FROM sys.databases AS d
+ WHERE d.state_desc = N'ONLINE'
+ AND d.database_id > 0
+ AND HAS_DBACCESS(d.name) = 1
+ ORDER BY
+ d.name;
+
+OPEN db_cursor;
+FETCH NEXT FROM db_cursor INTO @db_name;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ BEGIN TRY
+ SET @sql = N'EXECUTE ' + QUOTENAME(@db_name) + N'.sys.sp_executesql N''
+INSERT #file_space (database_id, file_id, used_size_mb)
+SELECT
+ DB_ID(),
+ df.file_id,
+ CONVERT(decimal(19,2), FILEPROPERTY(df.name, N''''SpaceUsed'''') * 8.0 / 1024.0)
+FROM sys.database_files AS df;'';';
+
+ EXECUTE sys.sp_executesql @sql;
+ END TRY
+ BEGIN CATCH
+ END CATCH;
+
+ FETCH NEXT FROM db_cursor INTO @db_name;
+END;
+
+CLOSE db_cursor;
+DEALLOCATE db_cursor;
+
+SELECT
+ database_name = d.name,
+ database_id = d.database_id,
+ file_id = mf.file_id,
+ file_type_desc = mf.type_desc,
+ file_name = mf.name,
+ physical_name = mf.physical_name,
+ total_size_mb =
+ CONVERT(decimal(19,2), mf.size * 8.0 / 1024.0),
+ used_size_mb =
+ fs.used_size_mb,
+ auto_growth_mb =
+ CASE
+ WHEN mf.is_percent_growth = 1
+ THEN CONVERT(decimal(19,2), NULL)
+ ELSE CONVERT(decimal(19,2), mf.growth * 8.0 / 1024.0)
+ END,
+ max_size_mb =
+ CASE
+ WHEN mf.max_size = -1
+ THEN CONVERT(decimal(19,2), -1)
+ WHEN mf.max_size = 268435456
+ THEN CONVERT(decimal(19,2), 2097152)
+ ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0)
+ END,
+ recovery_model_desc =
+ d.recovery_model_desc,
+ compatibility_level =
+ CONVERT(int, d.compatibility_level),
+ state_desc =
+ d.state_desc,
+ volume_mount_point =
+ RTRIM(vs.volume_mount_point),
+ volume_total_mb =
+ CONVERT(decimal(19,2), vs.total_bytes / 1048576.0),
+ volume_free_mb =
+ CONVERT(decimal(19,2), vs.available_bytes / 1048576.0)
+FROM sys.master_files AS mf
+JOIN sys.databases AS d
+ ON d.database_id = mf.database_id
+CROSS APPLY sys.dm_os_volume_stats(mf.database_id, mf.file_id) AS vs
+LEFT JOIN #file_space AS fs
+ ON fs.database_id = mf.database_id
+ AND fs.file_id = mf.file_id
+WHERE d.state_desc = N'ONLINE'
+ORDER BY
+ d.name,
+ mf.file_id
+OPTION(RECOMPILE);";
+
+ const string azureSqlDbQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ database_name = DB_NAME(),
+ database_id = DB_ID(),
+ file_id = df.file_id,
+ file_type_desc = df.type_desc,
+ file_name = df.name,
+ physical_name = df.physical_name,
+ total_size_mb =
+ CONVERT(decimal(19,2), df.size * 8.0 / 1024.0),
+ used_size_mb =
+ CONVERT(decimal(19,2), FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0),
+ auto_growth_mb =
+ CASE
+ WHEN df.is_percent_growth = 1
+ THEN CONVERT(decimal(19,2), NULL)
+ ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0)
+ END,
+ max_size_mb =
+ CASE
+ WHEN df.max_size = -1
+ THEN CONVERT(decimal(19,2), -1)
+ WHEN df.max_size = 268435456
+ THEN CONVERT(decimal(19,2), 2097152)
+ ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0)
+ END,
+ recovery_model_desc =
+ CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')),
+ compatibility_level =
+ CONVERT(int, NULL),
+ state_desc =
+ N'ONLINE',
+ volume_mount_point =
+ CONVERT(nvarchar(256), NULL),
+ volume_total_mb =
+ CONVERT(decimal(19,2), NULL),
+ volume_free_mb =
+ CONVERT(decimal(19,2), NULL)
+FROM sys.database_files AS df
+ORDER BY
+ df.file_id
+OPTION(RECOMPILE);";
+
+ string query = isAzureSqlDb ? azureSqlDbQuery : onPremQuery;
+
+ var serverId = GetServerId(server);
+ var collectionTime = DateTime.UtcNow;
+ var rowsCollected = 0;
+ _lastSqlMs = 0;
+ _lastDuckDbMs = 0;
+
+ var rows = new List<(string DatabaseName, int DatabaseId, int FileId, string FileTypeDesc,
+ string FileName, string PhysicalName, decimal TotalSizeMb, decimal? UsedSizeMb,
+ decimal? AutoGrowthMb, decimal? MaxSizeMb, string? RecoveryModel,
+ int? CompatibilityLevel, string? StateDesc, string? VolumeMountPoint,
+ decimal? VolumeTotalMb, decimal? VolumeFreeMb)>();
+
+ var sqlSw = Stopwatch.StartNew();
+ using var sqlConnection = await CreateConnectionAsync(server, cancellationToken);
+ using var command = new SqlCommand(query, sqlConnection);
+ command.CommandTimeout = CommandTimeoutSeconds;
+
+ using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ rows.Add((
+ reader.GetString(0),
+ Convert.ToInt32(reader.GetValue(1)),
+ Convert.ToInt32(reader.GetValue(2)),
+ reader.GetString(3),
+ reader.GetString(4),
+ reader.GetString(5),
+ reader.GetDecimal(6),
+ reader.IsDBNull(7) ? null : reader.GetDecimal(7),
+ reader.IsDBNull(8) ? null : reader.GetDecimal(8),
+ reader.IsDBNull(9) ? null : reader.GetDecimal(9),
+ reader.IsDBNull(10) ? null : reader.GetString(10),
+ reader.IsDBNull(11) ? null : Convert.ToInt32(reader.GetValue(11)),
+ reader.IsDBNull(12) ? null : reader.GetString(12),
+ reader.IsDBNull(13) ? null : reader.GetString(13),
+ reader.IsDBNull(14) ? null : reader.GetDecimal(14),
+ reader.IsDBNull(15) ? null : reader.GetDecimal(15)));
+ }
+ sqlSw.Stop();
+
+ var duckSw = Stopwatch.StartNew();
+
+ using (var duckConnection = _duckDb.CreateConnection())
+ {
+ await duckConnection.OpenAsync(cancellationToken);
+
+ using (var appender = duckConnection.CreateAppender("database_size_stats"))
+ {
+ foreach (var r in rows)
+ {
+ var row = appender.CreateRow();
+ row.AppendValue(GenerateCollectionId())
+ .AppendValue(collectionTime)
+ .AppendValue(serverId)
+ .AppendValue(GetServerNameForStorage(server))
+ .AppendValue(r.DatabaseName)
+ .AppendValue(r.DatabaseId)
+ .AppendValue(r.FileId)
+ .AppendValue(r.FileTypeDesc)
+ .AppendValue(r.FileName)
+ .AppendValue(r.PhysicalName)
+ .AppendValue(r.TotalSizeMb)
+ .AppendValue(r.UsedSizeMb)
+ .AppendValue(r.AutoGrowthMb)
+ .AppendValue(r.MaxSizeMb)
+ .AppendValue(r.RecoveryModel)
+ .AppendValue(r.CompatibilityLevel)
+ .AppendValue(r.StateDesc)
+ .AppendValue(r.VolumeMountPoint)
+ .AppendValue(r.VolumeTotalMb)
+ .AppendValue(r.VolumeFreeMb)
+ .EndRow();
+ rowsCollected++;
+ }
+ }
+ }
+
+ duckSw.Stop();
+ _lastSqlMs = sqlSw.ElapsedMilliseconds;
+ _lastDuckDbMs = duckSw.ElapsedMilliseconds;
+
+ _logger?.LogDebug("Collected {RowCount} database size rows for server '{Server}'", rowsCollected, server.DisplayName);
+ return rowsCollected;
+ }
+}
diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs
index 883341b7..22c8e4df 100644
--- a/Lite/Services/RemoteCollectorService.Deadlocks.cs
+++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs
@@ -401,7 +401,7 @@ and was previously misattributed as DuckDB time. */
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(deadlockTime)
.AppendValue(victimProcessId)
.AppendValue(victimSqlText)
diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs
index 0039422a..5ce5540b 100644
--- a/Lite/Services/RemoteCollectorService.FileIo.cs
+++ b/Lite/Services/RemoteCollectorService.FileIo.cs
@@ -138,21 +138,21 @@ AND vfs.database_id < 32761
{
foreach (var stat in fileStats)
{
- var deltaKey = $"{stat.DatabaseId}_{stat.FileId}";
- var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads);
- var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites);
- var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes);
- var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes);
- var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs);
- var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs);
- var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs);
- var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs);
+ 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 row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(stat.DatabaseName)
.AppendValue(stat.FileName)
.AppendValue(stat.FileType)
diff --git a/Lite/Services/RemoteCollectorService.Memory.cs b/Lite/Services/RemoteCollectorService.Memory.cs
index e9ac7e90..4af53786 100644
--- a/Lite/Services/RemoteCollectorService.Memory.cs
+++ b/Lite/Services/RemoteCollectorService.Memory.cs
@@ -44,7 +44,9 @@ Azure MI (edition 8) HAS dm_os_sys_memory, sql_memory_model_desc, and behaves li
target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0),
total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0),
buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0),
- plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0)
+ plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0),
+ max_workers_count = osi.max_workers_count,
+ current_workers_count = w.current_workers
FROM sys.dm_os_sys_info AS osi
CROSS JOIN
(
@@ -71,6 +73,12 @@ FROM sys.dm_os_performance_counters
WHERE counter_name = N'Cache Pages'
AND object_name LIKE N'%:Plan Cache%'
) AS pc_plan
+CROSS JOIN
+(
+ SELECT current_workers = SUM(active_workers_count)
+ FROM sys.dm_os_schedulers
+ WHERE status = N'VISIBLE ONLINE'
+) AS w
OPTION(RECOMPILE);"
: @"
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
@@ -85,7 +93,9 @@ FROM sys.dm_os_performance_counters
target_server_memory_mb = CONVERT(decimal(18,2), pc_target.cntr_value / 1024.0),
total_server_memory_mb = CONVERT(decimal(18,2), pc_total.cntr_value / 1024.0),
buffer_pool_mb = CONVERT(decimal(18,2), pc_buffer.cntr_value / 1024.0),
- plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0)
+ plan_cache_mb = CONVERT(decimal(18,2), pc_plan.cntr_value * 8.0 / 1024.0),
+ max_workers_count = osi.max_workers_count,
+ current_workers_count = w.current_workers
FROM sys.dm_os_sys_memory AS osm
CROSS JOIN sys.dm_os_sys_info AS osi
CROSS JOIN
@@ -113,6 +123,12 @@ FROM sys.dm_os_performance_counters
WHERE counter_name = N'Cache Pages'
AND object_name LIKE N'%:Plan Cache%'
) AS pc_plan
+CROSS JOIN
+(
+ SELECT current_workers = SUM(active_workers_count)
+ FROM sys.dm_os_schedulers
+ WHERE status = N'VISIBLE ONLINE'
+) AS w
OPTION(RECOMPILE);";
var serverId = GetServerId(server);
@@ -142,6 +158,8 @@ FROM sys.dm_os_performance_counters
var totalServerMemoryMb = reader.IsDBNull(7) ? 0m : reader.GetDecimal(7);
var bufferPoolMb = reader.IsDBNull(8) ? 0m : reader.GetDecimal(8);
var planCacheMb = reader.IsDBNull(9) ? 0m : reader.GetDecimal(9);
+ var maxWorkersCount = reader.IsDBNull(10) ? 0 : reader.GetInt32(10);
+ var currentWorkersCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11);
sqlSw.Stop();
/* Insert into DuckDB using Appender */
@@ -157,7 +175,7 @@ FROM sys.dm_os_performance_counters
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(totalPhysicalMb)
.AppendValue(availablePhysicalMb)
.AppendValue(totalPageFileMb)
@@ -168,6 +186,8 @@ FROM sys.dm_os_performance_counters
.AppendValue(totalServerMemoryMb)
.AppendValue(bufferPoolMb)
.AppendValue(planCacheMb)
+ .AppendValue(maxWorkersCount)
+ .AppendValue(currentWorkersCount)
.EndRow();
}
}
@@ -229,7 +249,7 @@ ORDER BY
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(reader.GetString(0))
.AppendValue(reader.GetDecimal(1))
.EndRow();
diff --git a/Lite/Services/RemoteCollectorService.MemoryGrants.cs b/Lite/Services/RemoteCollectorService.MemoryGrants.cs
index 53b3438a..244e08b2 100644
--- a/Lite/Services/RemoteCollectorService.MemoryGrants.cs
+++ b/Lite/Services/RemoteCollectorService.MemoryGrants.cs
@@ -92,14 +92,14 @@ 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);
- var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount);
+ 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 row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(r.ResourceSemaphoreId)
.AppendValue(r.PoolId)
.AppendValue(r.TargetMb)
diff --git a/Lite/Services/RemoteCollectorService.Perfmon.cs b/Lite/Services/RemoteCollectorService.Perfmon.cs
index 9c39a86f..8e0b1ecb 100644
--- a/Lite/Services/RemoteCollectorService.Perfmon.cs
+++ b/Lite/Services/RemoteCollectorService.Perfmon.cs
@@ -180,13 +180,13 @@ WHERE pc.counter_name IN (
/* Delta for per-second counters */
var deltaKey = $"{objectName}|{counterName}|{instanceName}";
- var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue);
+ var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, baselineOnly: true);
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(objectName)
.AppendValue(counterName)
.AppendValue(instanceName)
diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs
index 349e2518..e46b71ab 100644
--- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs
+++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs
@@ -278,19 +278,19 @@ 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);
- var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime);
- var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime);
- var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads);
- var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites);
- var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads);
+ 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);
/* Appender column order must match DuckDB table definition exactly */
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId()) /* collection_id */
.AppendValue(collectionTime) /* collection_time */
.AppendValue(serverId) /* server_id */
- .AppendValue(server.ServerName) /* server_name */
+ .AppendValue(GetServerNameForStorage(server)) /* server_name */
.AppendValue(dbName) /* database_name */
.AppendValue(schemaName) /* schema_name */
.AppendValue(objectName) /* object_name */
diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
index b85500c1..da031295 100644
--- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
+++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs
@@ -8,6 +8,7 @@
using System;
using System.Diagnostics;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using DuckDB.NET.Data;
@@ -19,7 +20,8 @@ namespace PerformanceMonitorLite.Services;
public partial class RemoteCollectorService
{
- private const string QuerySnapshotsBase = @"
+ private const string QuerySnapshotsBase = """
+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET LOCK_TIMEOUT 1000;
@@ -80,7 +82,9 @@ WHERE der.session_id <> @@SPID
AND dest.text IS NOT NULL
AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0)
ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC
-OPTION(MAXDOP 1, RECOMPILE);";
+OPTION(MAXDOP 1, RECOMPILE);
+""";
+ private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase);
///
/// Builds the query snapshots SQL with or without live query plan support.
@@ -89,8 +93,8 @@ AND dest.text IS NOT NULL
internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan)
{
return supportsLiveQueryPlan
- ? string.Format(QuerySnapshotsBase, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs")
- : string.Format(QuerySnapshotsBase, "live_query_plan = CONVERT(xml, NULL),", "");
+ ? string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = deqs.query_plan,", "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs")
+ : string.Format(null, QuerySnapshotsBaseFormat, "live_query_plan = CONVERT(xml, NULL),", "");
}
///
@@ -126,44 +130,42 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc
{
await duckConnection.OpenAsync(cancellationToken);
- using (var appender = duckConnection.CreateAppender("query_snapshots"))
+ using var appender = duckConnection.CreateAppender("query_snapshots");
+ while (await reader.ReadAsync(cancellationToken))
{
- while (await reader.ReadAsync(cancellationToken))
- {
- var row = appender.CreateRow();
- row.AppendValue(GenerateCollectionId())
- .AppendValue(collectionTime)
- .AppendValue(serverId)
- .AppendValue(server.ServerName)
- .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */
- .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */
- .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */
- .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */
- .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */
- .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */
- .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */
- .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */
- .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */
- .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */
- .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */
- .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */
- .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */
- .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */
- .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */
- .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */
- .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */
- .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */
- .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */
- .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */
- .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */
- .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */
- .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */
- .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */
- .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */
- .EndRow();
-
- rowsCollected++;
- }
+ var row = appender.CreateRow();
+ row.AppendValue(GenerateCollectionId())
+ .AppendValue(collectionTime)
+ .AppendValue(serverId)
+ .AppendValue(GetServerNameForStorage(server))
+ .AppendValue(Convert.ToInt32(reader.GetValue(0))) /* session_id */
+ .AppendValue(reader.IsDBNull(1) ? (string?)null : reader.GetString(1)) /* database_name */
+ .AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* elapsed_time_formatted */
+ .AppendValue(reader.IsDBNull(3) ? (string?)null : reader.GetString(3)) /* query_text */
+ .AppendValue(reader.IsDBNull(4) ? (string?)null : reader.GetString(4)) /* query_plan */
+ .AppendValue(reader.IsDBNull(5) ? (string?)null : reader.GetValue(5)?.ToString()) /* live_query_plan (xml) */
+ .AppendValue(reader.IsDBNull(6) ? (string?)null : reader.GetString(6)) /* status */
+ .AppendValue(reader.IsDBNull(7) ? 0 : Convert.ToInt32(reader.GetValue(7))) /* blocking_session_id */
+ .AppendValue(reader.IsDBNull(8) ? (string?)null : reader.GetString(8)) /* wait_type */
+ .AppendValue(reader.IsDBNull(9) ? 0L : Convert.ToInt64(reader.GetValue(9))) /* wait_time_ms */
+ .AppendValue(reader.IsDBNull(10) ? (string?)null : reader.GetString(10)) /* wait_resource */
+ .AppendValue(reader.IsDBNull(11) ? 0L : Convert.ToInt64(reader.GetValue(11))) /* cpu_time_ms */
+ .AppendValue(reader.IsDBNull(12) ? 0L : Convert.ToInt64(reader.GetValue(12))) /* total_elapsed_time_ms */
+ .AppendValue(reader.IsDBNull(13) ? 0L : Convert.ToInt64(reader.GetValue(13))) /* reads */
+ .AppendValue(reader.IsDBNull(14) ? 0L : Convert.ToInt64(reader.GetValue(14))) /* writes */
+ .AppendValue(reader.IsDBNull(15) ? 0L : Convert.ToInt64(reader.GetValue(15))) /* logical_reads */
+ .AppendValue(reader.IsDBNull(16) ? 0m : reader.GetDecimal(16)) /* granted_query_memory_gb */
+ .AppendValue(reader.IsDBNull(17) ? (string?)null : reader.GetString(17)) /* transaction_isolation_level */
+ .AppendValue(reader.IsDBNull(18) ? 0 : Convert.ToInt32(reader.GetValue(18))) /* dop */
+ .AppendValue(reader.IsDBNull(19) ? 0 : Convert.ToInt32(reader.GetValue(19))) /* parallel_worker_count */
+ .AppendValue(reader.IsDBNull(20) ? (string?)null : reader.GetString(20)) /* login_name */
+ .AppendValue(reader.IsDBNull(21) ? (string?)null : reader.GetString(21)) /* host_name */
+ .AppendValue(reader.IsDBNull(22) ? (string?)null : reader.GetString(22)) /* program_name */
+ .AppendValue(reader.IsDBNull(23) ? 0 : Convert.ToInt32(reader.GetValue(23))) /* open_transaction_count */
+ .AppendValue(reader.IsDBNull(24) ? 0m : Convert.ToDecimal(reader.GetValue(24))) /* percent_complete */
+ .EndRow();
+
+ rowsCollected++;
}
}
diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs
index b8b0962d..cb0f535c 100644
--- a/Lite/Services/RemoteCollectorService.QueryStats.cs
+++ b/Lite/Services/RemoteCollectorService.QueryStats.cs
@@ -239,21 +239,21 @@ 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);
- var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime);
- var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime);
- var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads);
- var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites);
- var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads);
- var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows);
- var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills);
+ 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);
/* Appender column order must match DuckDB table definition exactly */
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId()) /* collection_id */
.AppendValue(collectionTime) /* collection_time */
.AppendValue(serverId) /* server_id */
- .AppendValue(server.ServerName) /* server_name */
+ .AppendValue(GetServerNameForStorage(server)) /* server_name */
.AppendValue(reader.IsDBNull(0) ? (string?)null : reader.GetString(0)) /* database_name */
.AppendValue(queryHash) /* query_hash */
.AppendValue(reader.IsDBNull(2) ? (string?)null : reader.GetString(2)) /* query_plan_hash */
diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs
index d466c47b..612653c3 100644
--- a/Lite/Services/RemoteCollectorService.QueryStore.cs
+++ b/Lite/Services/RemoteCollectorService.QueryStore.cs
@@ -29,7 +29,10 @@ private async Task CollectQueryStoreAsync(ServerConnection server, Cancella
/* First, get databases with Query Store actually enabled.
Uses sys.database_query_store_options.actual_state instead of
sys.databases.is_query_store_on, which can be out of sync on Azure SQL DB. */
- const string dbQuery = @"
+ var serverStatus = _serverManager.GetConnectionStatus(server.Id);
+ bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;
+
+ const string onPremDbQuery = @"
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
@@ -38,7 +41,8 @@ Uses sys.database_query_store_options.actual_state instead of
DECLARE
@db sysname,
- @sql NVARCHAR(500);
+ @sql NVARCHAR(500),
+ @exec_sp nvarchar(256);
DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR
SELECT /* PerformanceMonitorLite */
@@ -67,8 +71,7 @@ FROM db_check
WHILE @@FETCH_STATUS = 0
BEGIN
BEGIN TRY
- SET @sql =
- N'USE ' + QUOTENAME(@db) + N';
+ SET @sql = N'
SELECT ' + QUOTENAME(@db, '''') + N'
WHERE EXISTS
(
@@ -78,8 +81,10 @@ FROM sys.database_query_store_options
WHERE actual_state > 0
);';
+ SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql';
+
INSERT @result (name)
- EXEC(@sql);
+ EXECUTE @exec_sp @sql;
END TRY
BEGIN CATCH
END CATCH;
@@ -98,6 +103,71 @@ FROM @result
ORDER BY
name;";
+ const string azureDbQuery = @"
+SET NOCOUNT ON;
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+DECLARE
+ @result TABLE (name sysname);
+
+DECLARE
+ @db sysname,
+ @sql NVARCHAR(500),
+ @exec_sp nvarchar(256);
+
+DECLARE db_check CURSOR LOCAL FAST_FORWARD FOR
+ SELECT /* PerformanceMonitorLite */
+ d.name
+ FROM sys.databases AS d
+ WHERE d.database_id > 4
+ AND d.database_id < 32761
+ AND d.state_desc = N'ONLINE'
+ AND d.name <> N'PerformanceMonitor'
+ OPTION(RECOMPILE);
+
+OPEN db_check;
+
+FETCH NEXT
+FROM db_check
+INTO @db;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ BEGIN TRY
+ SET @sql = N'
+ SELECT ' + QUOTENAME(@db, '''') + N'
+ WHERE EXISTS
+ (
+ SELECT
+ 1
+ FROM sys.database_query_store_options
+ WHERE actual_state > 0
+ );';
+
+ SET @exec_sp = QUOTENAME(@db) + N'.sys.sp_executesql';
+
+ INSERT @result (name)
+ EXECUTE @exec_sp @sql;
+ END TRY
+ BEGIN CATCH
+ END CATCH;
+
+ FETCH NEXT
+ FROM db_check
+ INTO @db;
+END;
+
+CLOSE db_check;
+DEALLOCATE db_check;
+
+SELECT
+ name
+FROM @result
+ORDER BY
+ name;";
+
+ string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery;
+
var serverId = GetServerId(server);
var collectionTime = DateTime.UtcNow;
var totalRows = 0;
@@ -136,7 +206,6 @@ ORDER BY
Controls: avg_num_physical_io_reads, avg_log_bytes_used, avg_tempdb_space_used, plan_forcing_type_desc.
@hasPlanType = true for SQL Server 2022+ (product version >= 16).
Controls: plan_type_desc. */
- var serverStatus = _serverManager.GetConnectionStatus(server.Id);
int productVersion = 13; /* default to SQL 2016 */
try
{
@@ -246,7 +315,7 @@ ELSE COALESCE(
force_failure_count = qsp.force_failure_count,
last_force_failure_reason = qsp.last_force_failure_reason_desc,
compatibility_level = qsp.compatibility_level,
- query_plan_text = CONVERT(nvarchar(max), qsp.query_plan),
+ query_plan_text = CONVERT(nvarchar(1), NULL),
query_plan_hash = CONVERT(varchar(64), qsp.query_plan_hash, 1)
FROM sys.query_store_runtime_stats AS qsrs
JOIN sys.query_store_plan AS qsp
@@ -257,7 +326,7 @@ JOIN sys.query_store_query_text AS qst
ON qst.query_text_id = qsq.query_text_id
WHERE qsrs.last_execution_time > @cutoff_time
AND qst.query_sql_text NOT LIKE N''%PerformanceMonitorLite%''
- OPTION(RECOMPILE);',
+ OPTION(RECOMPILE, LOOP JOIN);',
N'@cutoff_time datetime2(7)',
@cutoff_time;";
@@ -270,11 +339,18 @@ AND qst.query_sql_text NOT LIKE N''%PerformanceMonitorLite%''
sqlSw.Stop();
duckSw.Start();
+ var flushSw = new Stopwatch();
+ var readerSw = new Stopwatch();
+ var appendSw = new Stopwatch();
using (var appender = duckConnection.CreateAppender("query_store_stats"))
{
- while (await reader.ReadAsync(cancellationToken))
+ while (true)
{
+ readerSw.Start();
+ var hasRow = await reader.ReadAsync(cancellationToken);
+ readerSw.Stop();
+ if (!hasRow) break;
/* Reader ordinals match SELECT column order:
0=query_id, 1=plan_id, 2=execution_type_desc,
3=first_execution_time (dto), 4=last_execution_time (dto),
@@ -295,11 +371,12 @@ AND qst.query_sql_text NOT LIKE N''%PerformanceMonitorLite%''
46=is_forced_plan, 47=force_failure_count, 48=last_force_failure_reason,
49=compatibility_level, 50=query_plan_text, 51=query_plan_hash */
+ appendSw.Start();
var row = appender.CreateRow();
row.AppendValue(GenerateCollectionId()) /* collection_id */
.AppendValue(collectionTime) /* collection_time */
.AppendValue(serverId) /* server_id */
- .AppendValue(server.ServerName) /* server_name */
+ .AppendValue(GetServerNameForStorage(server)) /* server_name */
.AppendValue(dbName) /* database_name */
.AppendValue(reader.GetInt64(0)) /* query_id */
.AppendValue(reader.GetInt64(1)) /* plan_id */
@@ -354,12 +431,28 @@ AND qst.query_sql_text NOT LIKE N''%PerformanceMonitorLite%''
.AppendValue(reader.IsDBNull(50) ? (string?)null : reader.GetString(50)) /* query_plan_text */
.AppendValue(reader.IsDBNull(51) ? (string?)null : reader.GetString(51)) /* query_plan_hash */
.EndRow();
+ appendSw.Stop();
totalRows++;
}
- }
+
+ flushSw.Start();
+ } /* appender.Dispose() flushes here */
+ flushSw.Stop();
duckSw.Stop();
+
+ if (duckSw.ElapsedMilliseconds > 2000)
+ {
+ _logger?.LogWarning(
+ "Query Store DuckDB write spike: {TotalMs}ms total (reader: {ReaderMs}ms, append: {AppendMs}ms, flush: {FlushMs}ms, rows: {Rows}, db: {Db})",
+ duckSw.ElapsedMilliseconds,
+ readerSw.ElapsedMilliseconds,
+ appendSw.ElapsedMilliseconds,
+ flushSw.ElapsedMilliseconds,
+ totalRows,
+ dbName);
+ }
}
catch (SqlException ex)
{
diff --git a/Lite/Services/RemoteCollectorService.RunningJobs.cs b/Lite/Services/RemoteCollectorService.RunningJobs.cs
index 00d26b87..9b56ca75 100644
--- a/Lite/Services/RemoteCollectorService.RunningJobs.cs
+++ b/Lite/Services/RemoteCollectorService.RunningJobs.cs
@@ -165,7 +165,7 @@ rj.current_duration_seconds DESC
var row = appender.CreateRow();
row.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(r.JobName)
.AppendValue(r.JobId)
.AppendValue(r.JobEnabled)
diff --git a/Lite/Services/RemoteCollectorService.ServerConfig.cs b/Lite/Services/RemoteCollectorService.ServerConfig.cs
index 4e32c6ba..66f4b604 100644
--- a/Lite/Services/RemoteCollectorService.ServerConfig.cs
+++ b/Lite/Services/RemoteCollectorService.ServerConfig.cs
@@ -76,7 +76,7 @@ ORDER BY c.name
row.AppendValue(GenerateCollectionId())
.AppendValue(captureTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(r.Name)
.AppendValue(r.ValueConfigured)
.AppendValue(r.ValueInUse)
@@ -233,7 +233,7 @@ ORDER BY d.name
row.AppendValue(GenerateCollectionId())
.AppendValue(captureTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(r.DbName)
.AppendValue(r.StateDesc)
.AppendValue(r.CompatLevel)
@@ -326,7 +326,10 @@ private class DatabaseConfigCollected
///
private async Task CollectDatabaseScopedConfigAsync(ServerConnection server, CancellationToken cancellationToken)
{
- const string dbQuery = @"
+ var serverStatus = _serverManager.GetConnectionStatus(server.Id);
+ bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;
+
+ const string onPremDbQuery = @"
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
@@ -347,6 +350,21 @@ drs.database_id IS NULL /*not in any AG*/
ORDER BY d.name
OPTION(RECOMPILE);";
+ const string azureDbQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ d.name
+FROM sys.databases AS d
+WHERE (d.database_id > 4 OR d.database_id = 2)
+AND d.database_id < 32761
+AND d.name <> N'PerformanceMonitor'
+AND d.state_desc = N'ONLINE'
+ORDER BY d.name
+OPTION(RECOMPILE);";
+
+ string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery;
+
var serverId = GetServerId(server);
var captureTime = DateTime.UtcNow;
var totalRows = 0;
@@ -425,7 +443,7 @@ ORDER BY dsc.name
row.AppendValue(GenerateCollectionId())
.AppendValue(captureTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(dbName)
.AppendValue(configName)
.AppendValue(value)
@@ -517,7 +535,7 @@ ORDER BY tf.trace_flag
row.AppendValue(GenerateCollectionId())
.AppendValue(captureTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(r.TraceFlag)
.AppendValue(r.Status)
.AppendValue(r.IsGlobal)
diff --git a/Lite/Services/RemoteCollectorService.ServerProperties.cs b/Lite/Services/RemoteCollectorService.ServerProperties.cs
new file mode 100644
index 00000000..40bfd944
--- /dev/null
+++ b/Lite/Services/RemoteCollectorService.ServerProperties.cs
@@ -0,0 +1,146 @@
+/*
+ * 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 System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services;
+
+public partial class RemoteCollectorService
+{
+ ///
+ /// Collects server edition, version, CPU/memory hardware metadata for
+ /// license audit and FinOps cost attribution. On-load only collector.
+ ///
+ private async Task CollectServerPropertiesAsync(ServerConnection server, CancellationToken cancellationToken)
+ {
+ var serverStatus = _serverManager.GetConnectionStatus(server.Id);
+ bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;
+
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ server_name =
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ServerName')),
+ edition =
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition')),
+ product_version =
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')),
+ product_level =
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductLevel')),
+ product_update_level =
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductUpdateLevel')),
+ engine_edition =
+ CONVERT(int, SERVERPROPERTY(N'EngineEdition')),
+ cpu_count =
+ osi.cpu_count,
+ hyperthread_ratio =
+ osi.hyperthread_ratio,
+ physical_memory_mb =
+ osi.physical_memory_kb / 1024,
+ socket_count =
+ osi.socket_count,
+ cores_per_socket =
+ osi.cores_per_socket,
+ is_hadr_enabled =
+ CONVERT(bit, SERVERPROPERTY(N'IsHadrEnabled')),
+ is_clustered =
+ CONVERT(bit, SERVERPROPERTY(N'IsClustered')),
+ service_objective =
+ CASE
+ WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5
+ THEN CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'ServiceObjective'))
+ ELSE NULL
+ END
+FROM sys.dm_os_sys_info AS osi
+OPTION(RECOMPILE);";
+
+ var serverId = GetServerId(server);
+ var collectionTime = DateTime.UtcNow;
+ var rowsCollected = 0;
+ _lastSqlMs = 0;
+ _lastDuckDbMs = 0;
+
+ var sqlSw = Stopwatch.StartNew();
+ using var sqlConnection = await CreateConnectionAsync(server, cancellationToken);
+ using var command = new SqlCommand(query, sqlConnection);
+ command.CommandTimeout = CommandTimeoutSeconds;
+
+ using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (await reader.ReadAsync(cancellationToken))
+ {
+ var serverName = reader.GetString(0);
+ var edition = reader.GetString(1);
+ var productVersion = reader.GetString(2);
+ var productLevel = reader.GetString(3);
+ var productUpdateLevel = reader.IsDBNull(4) ? null : reader.GetString(4);
+ var engineEdition = reader.GetInt32(5);
+ var cpuCount = reader.GetInt32(6);
+ var hyperthreadRatio = reader.GetInt32(7);
+ var physicalMemoryMb = reader.GetInt64(8);
+ int? socketCount = reader.IsDBNull(9) ? null : reader.GetInt32(9);
+ int? coresPerSocket = reader.IsDBNull(10) ? null : reader.GetInt32(10);
+ bool? isHadrEnabled = reader.IsDBNull(11) ? null : reader.GetBoolean(11);
+ bool? isClustered = reader.IsDBNull(12) ? null : reader.GetBoolean(12);
+ var serviceObjective = reader.IsDBNull(13) ? null : reader.GetString(13);
+
+ sqlSw.Stop();
+
+ var duckSw = Stopwatch.StartNew();
+
+ using (var duckConnection = _duckDb.CreateConnection())
+ {
+ await duckConnection.OpenAsync(cancellationToken);
+
+ using (var appender = duckConnection.CreateAppender("server_properties"))
+ {
+ var row = appender.CreateRow();
+ row.AppendValue(GenerateCollectionId())
+ .AppendValue(collectionTime)
+ .AppendValue(serverId)
+ .AppendValue(GetServerNameForStorage(server))
+ .AppendValue(edition)
+ .AppendValue(productVersion)
+ .AppendValue(productLevel)
+ .AppendValue(productUpdateLevel)
+ .AppendValue(engineEdition)
+ .AppendValue(cpuCount)
+ .AppendValue(hyperthreadRatio)
+ .AppendValue(physicalMemoryMb)
+ .AppendValue(socketCount)
+ .AppendValue(coresPerSocket)
+ .AppendValue(isHadrEnabled)
+ .AppendValue(isClustered)
+ .AppendValue((string?)null) // enterprise_features — not collected in Lite (requires cross-database cursor)
+ .AppendValue(serviceObjective)
+ .EndRow();
+ rowsCollected++;
+ }
+ }
+
+ duckSw.Stop();
+ _lastDuckDbMs = duckSw.ElapsedMilliseconds;
+ }
+ else
+ {
+ sqlSw.Stop();
+ }
+
+ _lastSqlMs = sqlSw.ElapsedMilliseconds;
+
+ _logger?.LogDebug("Collected {RowCount} server properties row(s) for server '{Server}'", rowsCollected, server.DisplayName);
+ return rowsCollected;
+ }
+}
diff --git a/Lite/Services/RemoteCollectorService.SessionStats.cs b/Lite/Services/RemoteCollectorService.SessionStats.cs
new file mode 100644
index 00000000..3eade6ec
--- /dev/null
+++ b/Lite/Services/RemoteCollectorService.SessionStats.cs
@@ -0,0 +1,146 @@
+/*
+ * 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 System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using Microsoft.Data.SqlClient;
+using Microsoft.Extensions.Logging;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services;
+
+public partial class RemoteCollectorService
+{
+ ///
+ /// Collects per-application session statistics from sys.dm_exec_sessions.
+ /// Groups by program_name to track connection counts, status breakdown,
+ /// and cumulative resource usage per application.
+ ///
+ private async Task CollectSessionStatsAsync(ServerConnection server, CancellationToken cancellationToken)
+ {
+ const string query = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ program_name =
+ ISNULL(des.program_name, N''),
+ connection_count =
+ COUNT_BIG(*),
+ running_count =
+ SUM
+ (
+ CASE
+ WHEN des.status = N'running'
+ THEN 1
+ ELSE 0
+ END
+ ),
+ sleeping_count =
+ SUM
+ (
+ CASE
+ WHEN des.status = N'sleeping'
+ THEN 1
+ ELSE 0
+ END
+ ),
+ dormant_count =
+ SUM
+ (
+ CASE
+ WHEN des.status = N'dormant'
+ THEN 1
+ ELSE 0
+ END
+ ),
+ total_cpu_time_ms =
+ SUM(des.cpu_time),
+ total_reads =
+ SUM(des.reads),
+ total_writes =
+ SUM(des.writes),
+ total_logical_reads =
+ SUM(des.logical_reads)
+FROM sys.dm_exec_sessions AS des
+WHERE des.session_id > 50
+AND des.is_user_process = 1
+AND des.program_name IS NOT NULL
+AND des.program_name <> N''
+AND des.program_name NOT LIKE N'PerformanceMonitor%'
+GROUP BY
+ des.program_name
+ORDER BY
+ COUNT_BIG(*) DESC
+OPTION(RECOMPILE);";
+
+ var serverId = GetServerId(server);
+ var collectionTime = DateTime.UtcNow;
+ var rowsCollected = 0;
+ _lastSqlMs = 0;
+ _lastDuckDbMs = 0;
+
+ var sqlSw = Stopwatch.StartNew();
+ using var sqlConnection = await CreateConnectionAsync(server, cancellationToken);
+ using var command = new SqlCommand(query, sqlConnection);
+ command.CommandTimeout = CommandTimeoutSeconds;
+
+ using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ sqlSw.Stop();
+ _lastSqlMs = sqlSw.ElapsedMilliseconds;
+
+ var duckSw = Stopwatch.StartNew();
+
+ using (var duckConnection = _duckDb.CreateConnection())
+ {
+ await duckConnection.OpenAsync(cancellationToken);
+
+ using (var appender = duckConnection.CreateAppender("session_stats"))
+ {
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ var programName = reader.GetString(0);
+ var connectionCount = Convert.ToInt32(reader.GetValue(1));
+ var runningCount = Convert.ToInt32(reader.GetValue(2));
+ var sleepingCount = Convert.ToInt32(reader.GetValue(3));
+ var dormantCount = Convert.ToInt32(reader.GetValue(4));
+ long? totalCpuTimeMs = reader.IsDBNull(5) ? null : Convert.ToInt64(reader.GetValue(5));
+ long? totalReads = reader.IsDBNull(6) ? null : Convert.ToInt64(reader.GetValue(6));
+ long? totalWrites = reader.IsDBNull(7) ? null : Convert.ToInt64(reader.GetValue(7));
+ long? totalLogicalReads = reader.IsDBNull(8) ? null : Convert.ToInt64(reader.GetValue(8));
+
+ var row = appender.CreateRow();
+ row.AppendValue(GenerateCollectionId())
+ .AppendValue(collectionTime)
+ .AppendValue(serverId)
+ .AppendValue(GetServerNameForStorage(server))
+ .AppendValue(programName)
+ .AppendValue(connectionCount)
+ .AppendValue(runningCount)
+ .AppendValue(sleepingCount)
+ .AppendValue(dormantCount)
+ .AppendValue(totalCpuTimeMs)
+ .AppendValue(totalReads)
+ .AppendValue(totalWrites)
+ .AppendValue(totalLogicalReads)
+ .EndRow();
+
+ rowsCollected++;
+ }
+ }
+ }
+
+ duckSw.Stop();
+ _lastDuckDbMs = duckSw.ElapsedMilliseconds;
+
+ _logger?.LogDebug("Collected {RowCount} session stats rows for server '{Server}'", rowsCollected, server.DisplayName);
+ return rowsCollected;
+ }
+}
diff --git a/Lite/Services/RemoteCollectorService.TempDb.cs b/Lite/Services/RemoteCollectorService.TempDb.cs
index 1f600121..d36c8da1 100644
--- a/Lite/Services/RemoteCollectorService.TempDb.cs
+++ b/Lite/Services/RemoteCollectorService.TempDb.cs
@@ -90,7 +90,7 @@ ORDER BY (ssu.user_objects_alloc_page_count + ssu.internal_objects_alloc_page_co
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue(userObjMb)
.AppendValue(internalObjMb)
.AppendValue(versionStoreMb)
diff --git a/Lite/Services/RemoteCollectorService.WaitStats.cs b/Lite/Services/RemoteCollectorService.WaitStats.cs
index 2ba17854..35ade9ab 100644
--- a/Lite/Services/RemoteCollectorService.WaitStats.cs
+++ b/Lite/Services/RemoteCollectorService.WaitStats.cs
@@ -127,15 +127,15 @@ 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);
- var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs);
- var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs);
+ 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 row = appender.CreateRow();
row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */
.AppendValue(collectionTime) /* collection_time TIMESTAMP */
.AppendValue(serverId) /* server_id INTEGER */
- .AppendValue(server.ServerName) /* server_name VARCHAR */
+ .AppendValue(GetServerNameForStorage(server)) /* server_name VARCHAR */
.AppendValue(stat.WaitType) /* wait_type VARCHAR */
.AppendValue(stat.WaitingTasks) /* waiting_tasks_count BIGINT */
.AppendValue(stat.WaitTimeMs) /* wait_time_ms BIGINT */
diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs
index d68fc1ee..bd9373b1 100644
--- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs
+++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs
@@ -80,7 +80,7 @@ AND wt.wait_type IS NOT NULL
row.AppendValue(GenerateCollectionId())
.AppendValue(collectionTime)
.AppendValue(serverId)
- .AppendValue(server.ServerName)
+ .AppendValue(GetServerNameForStorage(server))
.AppendValue((int)sessionId)
.AppendValue(waitType)
.AppendValue(waitDurationMs)
diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs
index fc9fe77f..3a10c56d 100644
--- a/Lite/Services/RemoteCollectorService.cs
+++ b/Lite/Services/RemoteCollectorService.cs
@@ -356,6 +356,9 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam
"database_scoped_config" => await CollectDatabaseScopedConfigAsync(server, cancellationToken),
"trace_flags" => await CollectTraceFlagsAsync(server, cancellationToken),
"running_jobs" => await CollectRunningJobsAsync(server, cancellationToken),
+ "database_size_stats" => await CollectDatabaseSizeStatsAsync(server, cancellationToken),
+ "server_properties" => await CollectServerPropertiesAsync(server, cancellationToken),
+ "session_stats" => await CollectSessionStatsAsync(server, cancellationToken),
_ => throw new ArgumentException($"Unknown collector: {collectorName}")
};
@@ -565,12 +568,22 @@ protected static long GenerateCollectionId()
return Interlocked.Increment(ref s_idCounter);
}
+ ///
+ /// Gets the server name used for DuckDB storage and hashing.
+ /// Appends ":RO" for ReadOnlyIntent connections so they get a
+ /// different server_id than read-write connections to the same host.
+ ///
+ internal static string GetServerNameForStorage(ServerConnection server)
+ {
+ return server.ReadOnlyIntent ? server.ServerName + ":RO" : server.ServerName;
+ }
+
///
/// Gets the numeric server ID from the server connection.
///
protected static int GetServerId(ServerConnection server)
{
- return GetDeterministicHashCode(server.ServerName);
+ return GetDeterministicHashCode(GetServerNameForStorage(server));
}
///
diff --git a/Lite/Services/ReproScriptBuilder.cs b/Lite/Services/ReproScriptBuilder.cs
index 6a9a35a9..a1fef754 100644
--- a/Lite/Services/ReproScriptBuilder.cs
+++ b/Lite/Services/ReproScriptBuilder.cs
@@ -19,7 +19,7 @@ namespace PerformanceMonitorLite.Services;
/// Builds paste-ready T-SQL reproduction scripts from query text and plan XML.
/// Extracts parameters from plan XML ParameterList (same approach as sp_QueryReproBuilder).
///
-public static class ReproScriptBuilder
+public static partial class ReproScriptBuilder
{
///
/// Builds a complete reproduction script from available query data.
@@ -397,7 +397,7 @@ private static List FindUnresolvedVariables(string queryText, List(parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase);
/* Find all @variable references in the query text */
- var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase);
+ var matches = AtVariableRegExp().Matches(queryText);
var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (Match match in matches)
@@ -427,6 +427,9 @@ private static List FindUnresolvedVariables(string queryText, List
diff --git a/Lite/Services/RetentionService.cs b/Lite/Services/RetentionService.cs
index 743c8346..a6447027 100644
--- a/Lite/Services/RetentionService.cs
+++ b/Lite/Services/RetentionService.cs
@@ -29,54 +29,67 @@ public RetentionService(string archivePath, ILogger? logger =
///
/// Deletes Parquet files older than the specified retention period.
- /// Supports two naming formats:
+ /// Supports naming formats:
+ /// - Monthly compacted: "202602_wait_stats.parquet" (yyyyMM prefix)
/// - Timestamped: "20260221_1328_wait_stats.parquet" (yyyyMMdd prefix)
+ /// - Consolidated daily: "20260221_wait_stats.parquet" (yyyyMMdd prefix)
/// - Legacy monthly: "2026-02_wait_stats.parquet" (yyyy-MM prefix)
///
- public void CleanupOldArchives(int retentionDays = 90)
+ public void CleanupOldArchives(int retentionMonths = 3)
{
if (!Directory.Exists(_archivePath))
{
return;
}
- var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
+ var cutoffDate = DateTime.UtcNow.AddMonths(-retentionMonths);
foreach (var file in Directory.GetFiles(_archivePath, "*.parquet"))
{
try
{
var fileName = Path.GetFileNameWithoutExtension(file);
+ DateTime? fileDate = null;
- /* Try timestamped format first: "20260221_1328_wait_stats" -> "20260221" */
- if (fileName.Length >= 8 &&
+ /* Monthly compacted format: "202602_wait_stats" -> "202602" */
+ if (fileName.Length >= 6 &&
+ DateTime.TryParseExact(
+ fileName[..6],
+ "yyyyMM",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None,
+ out var monthDate) &&
+ fileName.Length > 6 && fileName[6] == '_')
+ {
+ fileDate = monthDate;
+ }
+ /* Timestamped or daily format: "20260221..." -> "20260221" */
+ else if (fileName.Length >= 8 &&
DateTime.TryParseExact(
fileName[..8],
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
- out var fileDate))
+ out var dayDate))
{
- if (fileDate < cutoffDate)
- {
- File.Delete(file);
- _logger?.LogInformation("Deleted expired archive: {File}", file);
- }
+ fileDate = dayDate;
}
- /* Fall back to legacy monthly format: "2026-02_wait_stats" -> "2026-02" */
+ /* Legacy monthly format: "2026-02_wait_stats" -> "2026-02" */
else if (fileName.Length >= 7 &&
DateTime.TryParseExact(
fileName[..7],
"yyyy-MM",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
- out var fileMonth))
+ out var legacyMonth))
+ {
+ fileDate = legacyMonth;
+ }
+
+ if (fileDate.HasValue && fileDate.Value < cutoffDate)
{
- if (fileMonth < cutoffDate)
- {
- File.Delete(file);
- _logger?.LogInformation("Deleted expired archive: {File}", file);
- }
+ File.Delete(file);
+ _logger?.LogInformation("Deleted expired archive: {File}", file);
}
}
catch (Exception ex)
diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs
index ff96fe63..6fd8c848 100644
--- a/Lite/Services/ScheduleManager.cs
+++ b/Lite/Services/ScheduleManager.cs
@@ -28,6 +28,39 @@ public class ScheduleManager
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
+ public static readonly string[] PresetNames = ["Low-Impact", "Balanced", "Aggressive"];
+
+ private static readonly Dictionary> s_presets = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Aggressive"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1,
+ ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1,
+ ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2,
+ ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1,
+ ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1,
+ ["blocked_process_report"] = 1, ["running_jobs"] = 2
+ },
+ ["Balanced"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1,
+ ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1,
+ ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5,
+ ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1,
+ ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1,
+ ["blocked_process_report"] = 1, ["running_jobs"] = 5
+ },
+ ["Low-Impact"] = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["wait_stats"] = 5, ["query_stats"] = 10, ["procedure_stats"] = 10,
+ ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5,
+ ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30,
+ ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5,
+ ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5,
+ ["blocked_process_report"] = 5, ["running_jobs"] = 30
+ }
+ };
+
private readonly string _schedulePath;
private readonly ILogger? _logger;
private List _schedules;
@@ -160,6 +193,61 @@ public void UpdateSchedule(string collectorName, bool? enabled = null, int? freq
}
}
+ ///
+ /// Detects which preset matches the current intervals, or returns "Custom".
+ ///
+ public string GetActivePreset()
+ {
+ lock (_lock)
+ {
+ foreach (var (presetName, intervals) in s_presets)
+ {
+ bool matches = true;
+ foreach (var (collector, freq) in intervals)
+ {
+ var schedule = _schedules.FirstOrDefault(s =>
+ s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase));
+ if (schedule != null && schedule.FrequencyMinutes != freq)
+ {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) return presetName;
+ }
+ return "Custom";
+ }
+ }
+
+ ///
+ /// Applies a named preset, changing all scheduled collector frequencies.
+ /// Does not modify enabled/disabled state or on-load (frequency=0) collectors.
+ ///
+ public void ApplyPreset(string presetName)
+ {
+ if (!s_presets.TryGetValue(presetName, out var intervals))
+ {
+ throw new ArgumentException($"Unknown preset: {presetName}");
+ }
+
+ lock (_lock)
+ {
+ foreach (var (collector, freq) in intervals)
+ {
+ var schedule = _schedules.FirstOrDefault(s =>
+ s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase));
+ if (schedule != null)
+ {
+ schedule.FrequencyMinutes = freq;
+ }
+ }
+
+ SaveSchedules();
+
+ _logger?.LogInformation("Applied collection preset '{Preset}'", presetName);
+ }
+ }
+
///
/// Loads schedules from the JSON config file.
///
@@ -298,7 +386,10 @@ private static List GetDefaultSchedules()
new() { Name = "blocked_process_report", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Blocked process reports from XE ring buffer session (opt-out)" },
new() { Name = "database_scoped_config", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Database-scoped configurations (on-load only)" },
new() { Name = "trace_flags", Enabled = true, FrequencyMinutes = 0, RetentionDays = 30, Description = "Active trace flags via DBCC TRACESTATUS (on-load only)" },
- new() { Name = "running_jobs", Enabled = true, FrequencyMinutes = 5, RetentionDays = 7, Description = "Currently running SQL Agent jobs with duration comparison" }
+ new() { Name = "running_jobs", Enabled = true, FrequencyMinutes = 5, RetentionDays = 7, Description = "Currently running SQL Agent jobs with duration comparison" },
+ new() { Name = "database_size_stats", Enabled = true, FrequencyMinutes = 60, RetentionDays = 90, Description = "Database file sizes for growth trending and capacity planning" },
+ new() { Name = "server_properties", Enabled = true, FrequencyMinutes = 0, RetentionDays = 365, Description = "Server edition, licensing, CPU/memory hardware metadata (on-load only)" },
+ new() { Name = "session_stats", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Per-application session counts from sys.dm_exec_sessions" }
};
}
diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs
index 11c0f9ec..82add3c4 100644
--- a/Lite/Services/ShowPlanParser.cs
+++ b/Lite/Services/ShowPlanParser.cs
@@ -631,6 +631,19 @@ private static PlanNode ParseRelOp(XElement relOpEl)
StatsCollectionId = ParseLong(relOpEl.Attribute("StatsCollectionId")?.Value)
};
+ // Spool operators: prepend Eager/Lazy from LogicalOp to PhysicalOp
+ // XML has PhysicalOp="Index Spool" but LogicalOp="Eager Spool" — show "Eager Index Spool"
+ if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase)
+ && node.LogicalOp.StartsWith("Eager", StringComparison.OrdinalIgnoreCase))
+ {
+ node.PhysicalOp = "Eager " + node.PhysicalOp;
+ }
+ else if (node.PhysicalOp.EndsWith("Spool", StringComparison.OrdinalIgnoreCase)
+ && node.LogicalOp.StartsWith("Lazy", StringComparison.OrdinalIgnoreCase))
+ {
+ node.PhysicalOp = "Lazy " + node.PhysicalOp;
+ }
+
// Map to icon
node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp);
@@ -638,6 +651,10 @@ private static PlanNode ParseRelOp(XElement relOpEl)
var physicalOpEl = GetOperatorElement(relOpEl);
if (physicalOpEl != null)
{
+ // Top N Sort — XML element is but PhysicalOp is "Sort"
+ if (physicalOpEl.Name.LocalName == "TopSort")
+ node.LogicalOp = "Top N Sort";
+
// Object reference (table/index name) — scoped to stop at child RelOps
var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault();
if (objEl != null)
@@ -699,16 +716,35 @@ private static PlanNode ParseRelOp(XElement relOpEl)
var seekParts = new List();
foreach (var sp in seekPreds)
{
- var scalarOps = sp.Descendants(Ns + "ScalarOperator");
- foreach (var so in scalarOps)
+ foreach (var seekKeys in sp.Elements(Ns + "SeekKeys"))
{
- var val = so.Attribute("ScalarString")?.Value;
- if (!string.IsNullOrEmpty(val))
- seekParts.Add(val);
+ foreach (var range in seekKeys.Elements())
+ {
+ var scanType = range.Attribute("ScanType")?.Value;
+ var cols = range.Element(Ns + "RangeColumns")?
+ .Elements(Ns + "ColumnReference")
+ .Select(FormatColumnRef)
+ .ToList();
+ var exprs = range.Element(Ns + "RangeExpressions")?
+ .Elements(Ns + "ScalarOperator")
+ .Select(so => so.Attribute("ScalarString")?.Value ?? "?")
+ .ToList();
+
+ if (cols != null && exprs != null)
+ {
+ var op = scanType switch
+ {
+ "EQ" => "=", "GT" => ">", "GE" => ">=",
+ "LT" => "<", "LE" => "<=", _ => scanType ?? "="
+ };
+ for (int ci = 0; ci < cols.Count && ci < exprs.Count; ci++)
+ seekParts.Add($"{cols[ci]} {op} {exprs[ci]}");
+ }
+ }
}
}
if (seekParts.Count > 0)
- node.SeekPredicates = string.Join(" AND ", seekParts);
+ node.SeekPredicates = string.Join(", ", seekParts);
// GuessedSelectivity — check if optimizer guessed selectivity on predicates
if (ScopedDescendants(physicalOpEl, Ns + "GuessedSelectivity").Any())
@@ -832,6 +868,19 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.Lookup = physicalOpEl.Attribute("Lookup")?.Value is "true" or "1";
node.DynamicSeek = physicalOpEl.Attribute("DynamicSeek")?.Value is "true" or "1";
+ // Override PhysicalOp, LogicalOp, and icon when Lookup=true.
+ // SQL Server's XML emits PhysicalOp="Clustered Index Seek" with
+ // rather than "Key Lookup (Clustered)" — correct the label here so all display
+ // paths (node card, tooltip, properties panel) show the right operator name.
+ if (node.Lookup)
+ {
+ var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true
+ || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase);
+ node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)";
+ node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup";
+ node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup";
+ }
+
// Table cardinality and rows to be read (on per XSD)
node.TableCardinality = ParseDouble(relOpEl.Attribute("TableCardinality")?.Value);
node.EstimatedRowsRead = ParseDouble(relOpEl.Attribute("EstimatedRowsRead")?.Value);
@@ -1399,8 +1448,8 @@ private static List ParseWarningsFromElement(XElement warningsEl)
result.Add(new PlanWarning
{
WarningType = "No Join Predicate",
- Message = "This join has no join predicate (possible cross join)",
- Severity = PlanWarningSeverity.Critical
+ Message = "This join triggered a no join predicate warning, which is worth checking on, but is often misleading. The optimizer may have removed a redundant predicate after simplification.",
+ Severity = PlanWarningSeverity.Warning
});
}
@@ -1416,10 +1465,32 @@ private static List ParseWarningsFromElement(XElement warningsEl)
if (warningsEl.Attribute("UnmatchedIndexes")?.Value is "true" or "1")
{
+ var unmatchedMsg = "Indexes could not be matched due to parameterization";
+ var unmatchedEl = warningsEl.Element(Ns + "UnmatchedIndexes");
+ if (unmatchedEl != null)
+ {
+ var unmatchedDetails = new List();
+ foreach (var paramEl in unmatchedEl.Elements(Ns + "Parameterization"))
+ {
+ var db = paramEl.Attribute("Database")?.Value?.Replace("[", "").Replace("]", "");
+ var schema = paramEl.Attribute("Schema")?.Value?.Replace("[", "").Replace("]", "");
+ var table = paramEl.Attribute("Table")?.Value?.Replace("[", "").Replace("]", "");
+ var index = paramEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", "");
+ var parts = new List();
+ if (!string.IsNullOrEmpty(db)) parts.Add(db);
+ if (!string.IsNullOrEmpty(schema)) parts.Add(schema);
+ if (!string.IsNullOrEmpty(table)) parts.Add(table);
+ if (!string.IsNullOrEmpty(index)) parts.Add(index);
+ if (parts.Count > 0)
+ unmatchedDetails.Add(string.Join(".", parts));
+ }
+ if (unmatchedDetails.Count > 0)
+ unmatchedMsg += ": " + string.Join(", ", unmatchedDetails);
+ }
result.Add(new PlanWarning
{
WarningType = "Unmatched Indexes",
- Message = "Indexes could not be matched due to parameterization",
+ Message = unmatchedMsg,
Severity = PlanWarningSeverity.Warning
});
}
diff --git a/Lite/Services/SystemTrayService.cs b/Lite/Services/SystemTrayService.cs
index a54bfc19..8c1968b5 100644
--- a/Lite/Services/SystemTrayService.cs
+++ b/Lite/Services/SystemTrayService.cs
@@ -45,7 +45,10 @@ public void Initialize()
bool HasLightBackground = Helpers.ThemeManager.HasLightBackground;
- /* Custom tooltip styled to match current theme */
+ /* Custom tooltip styled to match current theme.
+ Note: Hardcodet TrayToolTip can rarely trigger a race condition in Popup.CreateWindow
+ that throws "The root Visual of a VisualTarget cannot have a parent." (issue #422).
+ The DispatcherUnhandledException handler silently swallows this specific crash. */
_tooltipText = new TextBlock
{
Text = "Performance Monitor Lite",
diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml
index d02d51dd..e63f7b9c 100644
--- a/Lite/Windows/AddServerDialog.xaml
+++ b/Lite/Windows/AddServerDialog.xaml
@@ -88,6 +88,9 @@
+
diff --git a/Lite/Windows/AddServerDialog.xaml.cs b/Lite/Windows/AddServerDialog.xaml.cs
index 84fa183f..de2c52b9 100644
--- a/Lite/Windows/AddServerDialog.xaml.cs
+++ b/Lite/Windows/AddServerDialog.xaml.cs
@@ -59,6 +59,7 @@ public AddServerDialog(ServerManager serverManager, ServerConnection existing) :
FavoriteCheckBox.IsChecked = existing.IsFavorite;
DescriptionTextBox.Text = existing.Description ?? "";
DatabaseNameBox.Text = existing.DatabaseName ?? "";
+ ReadOnlyIntentCheckBox.IsChecked = existing.ReadOnlyIntent;
// Set authentication mode
if (existing.AuthenticationType == AuthenticationTypes.EntraMFA)
@@ -140,7 +141,10 @@ private SqlConnectionStringBuilder BuildConnectionBuilder()
ApplicationName = "PerformanceMonitorLite",
ConnectTimeout = 10,
TrustServerCertificate = TrustCertCheckBox.IsChecked == true,
- Encrypt = ParseEncryptOption(GetSelectedEncryptMode())
+ Encrypt = ParseEncryptOption(GetSelectedEncryptMode()),
+ ApplicationIntent = ReadOnlyIntentCheckBox.IsChecked == true
+ ? ApplicationIntent.ReadOnly
+ : ApplicationIntent.ReadWrite
};
if (WindowsAuthRadio.IsChecked == true)
@@ -342,6 +346,7 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e)
AddedServer.IsFavorite = FavoriteCheckBox.IsChecked == true;
AddedServer.Description = DescriptionTextBox.Text.Trim();
AddedServer.DatabaseName = string.IsNullOrWhiteSpace(DatabaseNameBox.Text) ? null : DatabaseNameBox.Text.Trim();
+ AddedServer.ReadOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true;
_serverManager.UpdateServer(AddedServer, username, password);
}
@@ -358,7 +363,8 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e)
EncryptMode = GetSelectedEncryptMode(),
IsFavorite = FavoriteCheckBox.IsChecked == true,
Description = DescriptionTextBox.Text.Trim(),
- DatabaseName = string.IsNullOrWhiteSpace(DatabaseNameBox.Text) ? null : DatabaseNameBox.Text.Trim()
+ DatabaseName = string.IsNullOrWhiteSpace(DatabaseNameBox.Text) ? null : DatabaseNameBox.Text.Trim(),
+ ReadOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true
};
_serverManager.AddServer(AddedServer, username, password);
diff --git a/Lite/Windows/AlertDetailWindow.xaml b/Lite/Windows/AlertDetailWindow.xaml
new file mode 100644
index 00000000..7e7f4226
--- /dev/null
+++ b/Lite/Windows/AlertDetailWindow.xaml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/AlertDetailWindow.xaml.cs b/Lite/Windows/AlertDetailWindow.xaml.cs
new file mode 100644
index 00000000..8eeba67e
--- /dev/null
+++ b/Lite/Windows/AlertDetailWindow.xaml.cs
@@ -0,0 +1,37 @@
+/*
+ * 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.Windows;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class AlertDetailWindow : Window
+{
+ public AlertDetailWindow(AlertHistoryRow item)
+ {
+ InitializeComponent();
+
+ TimeText.Text = item.TimeLocal;
+ ServerText.Text = item.ServerName;
+ MetricText.Text = item.MetricName;
+ CurrentValueText.Text = item.CurrentValueDisplay;
+ ThresholdText.Text = item.ThresholdValueDisplay;
+ NotificationText.Text = item.NotificationType;
+ StatusText.Text = item.StatusDisplay;
+
+ if (item.Muted)
+ MutedBanner.Visibility = Visibility.Visible;
+
+ if (!string.IsNullOrWhiteSpace(item.DetailText))
+ {
+ DetailTextBox.Text = item.DetailText;
+ DetailPanel.Visibility = Visibility.Visible;
+ }
+ }
+}
diff --git a/Lite/Windows/CollectionLogWindow.xaml b/Lite/Windows/CollectionLogWindow.xaml
index 845c5f04..dafbdc6c 100644
--- a/Lite/Windows/CollectionLogWindow.xaml
+++ b/Lite/Windows/CollectionLogWindow.xaml
@@ -65,17 +65,17 @@
-
+
-
+
-
+
diff --git a/Lite/Windows/ManageMuteRulesWindow.xaml b/Lite/Windows/ManageMuteRulesWindow.xaml
new file mode 100644
index 00000000..b424f180
--- /dev/null
+++ b/Lite/Windows/ManageMuteRulesWindow.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/ManageMuteRulesWindow.xaml.cs b/Lite/Windows/ManageMuteRulesWindow.xaml.cs
new file mode 100644
index 00000000..8cda3323
--- /dev/null
+++ b/Lite/Windows/ManageMuteRulesWindow.xaml.cs
@@ -0,0 +1,119 @@
+/*
+ * 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.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class ManageMuteRulesWindow : Window
+{
+ private readonly MuteRuleService _muteRuleService;
+ private readonly ObservableCollection _rules;
+
+ public ManageMuteRulesWindow(MuteRuleService muteRuleService)
+ {
+ InitializeComponent();
+ _muteRuleService = muteRuleService;
+ _rules = new ObservableCollection(_muteRuleService.GetRules());
+ RulesGrid.ItemsSource = _rules;
+ }
+
+ private async void AddRule_Click(object sender, RoutedEventArgs e)
+ {
+ var dialog = new MuteRuleDialog { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ await _muteRuleService.AddRuleAsync(dialog.Rule);
+ _rules.Add(dialog.Rule);
+ }
+ }
+
+ private async void EditRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var dialog = new MuteRuleDialog(selected) { Owner = this };
+ if (dialog.ShowDialog() == true)
+ {
+ await _muteRuleService.UpdateRuleAsync(dialog.Rule);
+ RefreshList();
+ }
+ }
+
+ private async void ToggleRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ await _muteRuleService.SetRuleEnabledAsync(selected.Id, !selected.Enabled);
+ RefreshList();
+ if (index < _rules.Count) RulesGrid.SelectedIndex = index;
+ RulesGrid.Focus();
+ }
+
+ private async void DeleteRule_Click(object sender, RoutedEventArgs e)
+ {
+ if (RulesGrid.SelectedItem is not MuteRule selected) return;
+ var index = RulesGrid.SelectedIndex;
+ var result = MessageBox.Show(
+ $"Delete this mute rule?\n\n{selected.Summary}",
+ "Confirm Delete",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ await _muteRuleService.RemoveRuleAsync(selected.Id);
+ _rules.Remove(selected);
+ if (_rules.Count > 0)
+ RulesGrid.SelectedIndex = Math.Min(index, _rules.Count - 1);
+ RulesGrid.Focus();
+ }
+ }
+
+ private async void PurgeExpired_Click(object sender, RoutedEventArgs e)
+ {
+ int removed = await _muteRuleService.PurgeExpiredRulesAsync();
+ if (removed > 0)
+ {
+ RefreshList();
+ MessageBox.Show($"Removed {removed} expired rule(s).", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ else
+ {
+ MessageBox.Show("No expired rules to remove.", "Purge Complete",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+ }
+
+ private async void EnabledCheckBox_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is CheckBox cb && cb.DataContext is MuteRule rule)
+ {
+ await _muteRuleService.SetRuleEnabledAsync(rule.Id, cb.IsChecked == true);
+ }
+ }
+
+ private void RulesGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ EditRule_Click(sender, e);
+ }
+
+ private void RefreshList()
+ {
+ _rules.Clear();
+ foreach (var rule in _muteRuleService.GetRules())
+ _rules.Add(rule);
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/Lite/Windows/ManageServersWindow.xaml b/Lite/Windows/ManageServersWindow.xaml
index 0060bd7f..63ad4208 100644
--- a/Lite/Windows/ManageServersWindow.xaml
+++ b/Lite/Windows/ManageServersWindow.xaml
@@ -47,8 +47,8 @@
MouseDoubleClick="ServersGrid_MouseDoubleClick"
ContextMenu="{StaticResource DataGridContextMenu}">
-
-
+
+
diff --git a/Lite/Windows/ManageServersWindow.xaml.cs b/Lite/Windows/ManageServersWindow.xaml.cs
index 8ef3447c..03b6b8e8 100644
--- a/Lite/Windows/ManageServersWindow.xaml.cs
+++ b/Lite/Windows/ManageServersWindow.xaml.cs
@@ -78,7 +78,7 @@ private void DeleteButton_Click(object sender, RoutedEventArgs e)
}
var result = MessageBox.Show(
- $"Delete server '{selected.DisplayName}'?\n\nThis will remove the server and its stored credentials.",
+ $"Delete server '{selected.DisplayNameWithIntent}'?\n\nThis will remove the server and its stored credentials.",
"Delete Server",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
diff --git a/Lite/Windows/MuteRuleDialog.xaml b/Lite/Windows/MuteRuleDialog.xaml
new file mode 100644
index 00000000..8d67fdf4
--- /dev/null
+++ b/Lite/Windows/MuteRuleDialog.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/MuteRuleDialog.xaml.cs b/Lite/Windows/MuteRuleDialog.xaml.cs
new file mode 100644
index 00000000..2fea9e53
--- /dev/null
+++ b/Lite/Windows/MuteRuleDialog.xaml.cs
@@ -0,0 +1,157 @@
+/*
+ * 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 System.Windows;
+using System.Windows.Controls;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class MuteRuleDialog : Window
+{
+ public MuteRule Rule { get; private set; }
+
+ public MuteRuleDialog(MuteRule? existingRule = null)
+ {
+ InitializeComponent();
+
+ if (existingRule != null)
+ {
+ Title = "Edit Mute Rule";
+ HeaderText.Text = "Edit Mute Rule";
+ Rule = existingRule.Clone();
+ PopulateFromRule(Rule);
+ }
+ else
+ {
+ Rule = new MuteRule();
+ }
+ }
+
+ ///
+ /// Creates a dialog pre-populated for muting from an alert context.
+ ///
+ public MuteRuleDialog(AlertMuteContext context) : this()
+ {
+ if (!string.IsNullOrEmpty(context.ServerName))
+ ServerNameBox.Text = context.ServerName;
+ if (!string.IsNullOrEmpty(context.MetricName))
+ SelectMetric(context.MetricName);
+ if (!string.IsNullOrEmpty(context.DatabaseName))
+ DatabasePatternBox.Text = context.DatabaseName;
+ if (!string.IsNullOrEmpty(context.QueryText))
+ QueryTextPatternBox.Text = context.QueryText.Length > 200
+ ? context.QueryText.Substring(0, 200)
+ : context.QueryText;
+ if (!string.IsNullOrEmpty(context.WaitType))
+ WaitTypePatternBox.Text = context.WaitType;
+ if (!string.IsNullOrEmpty(context.JobName))
+ JobNamePatternBox.Text = context.JobName;
+ }
+
+ private void PopulateFromRule(MuteRule rule)
+ {
+ ReasonBox.Text = rule.Reason ?? "";
+ ServerNameBox.Text = rule.ServerName ?? "";
+ DatabasePatternBox.Text = rule.DatabasePattern ?? "";
+ QueryTextPatternBox.Text = rule.QueryTextPattern ?? "";
+ WaitTypePatternBox.Text = rule.WaitTypePattern ?? "";
+ JobNamePatternBox.Text = rule.JobNamePattern ?? "";
+
+ if (!string.IsNullOrEmpty(rule.MetricName))
+ SelectMetric(rule.MetricName);
+
+ if (rule.ExpiresAtUtc == null)
+ ExpirationCombo.SelectedIndex = 3;
+ else
+ {
+ var remaining = rule.ExpiresAtUtc.Value - DateTime.UtcNow;
+ if (remaining.TotalHours <= 1.5) ExpirationCombo.SelectedIndex = 0;
+ else if (remaining.TotalHours <= 25) ExpirationCombo.SelectedIndex = 1;
+ else ExpirationCombo.SelectedIndex = 2;
+ }
+ }
+
+ private void SelectMetric(string metricName)
+ {
+ for (int i = 0; i < MetricCombo.Items.Count; i++)
+ {
+ if (MetricCombo.Items[i] is ComboBoxItem item &&
+ string.Equals(item.Content?.ToString(), metricName, StringComparison.OrdinalIgnoreCase))
+ {
+ MetricCombo.SelectedIndex = i;
+ return;
+ }
+ }
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ Rule.Reason = string.IsNullOrWhiteSpace(ReasonBox.Text) ? null : ReasonBox.Text.Trim();
+ Rule.ServerName = string.IsNullOrWhiteSpace(ServerNameBox.Text) ? null : ServerNameBox.Text.Trim();
+
+ Rule.DatabasePattern = DatabasePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(DatabasePatternBox.Text)
+ ? DatabasePatternBox.Text.Trim() : null;
+ Rule.QueryTextPattern = QueryTextPatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(QueryTextPatternBox.Text)
+ ? QueryTextPatternBox.Text.Trim() : null;
+ Rule.WaitTypePattern = WaitTypePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(WaitTypePatternBox.Text)
+ ? WaitTypePatternBox.Text.Trim() : null;
+ Rule.JobNamePattern = JobNamePatternBox.Visibility == Visibility.Visible && !string.IsNullOrWhiteSpace(JobNamePatternBox.Text)
+ ? JobNamePatternBox.Text.Trim() : null;
+
+ if (MetricCombo.SelectedIndex > 0 && MetricCombo.SelectedItem is ComboBoxItem selected)
+ Rule.MetricName = selected.Content?.ToString();
+ else
+ Rule.MetricName = null;
+
+ Rule.ExpiresAtUtc = ExpirationCombo.SelectedIndex switch
+ {
+ 0 => DateTime.UtcNow.AddHours(1),
+ 1 => DateTime.UtcNow.AddHours(24),
+ 2 => DateTime.UtcNow.AddDays(7),
+ _ => null
+ };
+
+ if (Rule.ServerName == null && Rule.MetricName == null && Rule.DatabasePattern == null
+ && Rule.QueryTextPattern == null && Rule.WaitTypePattern == null && Rule.JobNamePattern == null)
+ {
+ var result = MessageBox.Show(
+ "This rule has no filters and will mute ALL alerts. Are you sure?",
+ "Mute All Alerts", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ if (result != MessageBoxResult.Yes) return;
+ }
+
+ DialogResult = true;
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ private void MetricCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (DatabasePatternBox == null) return;
+
+ var metric = (MetricCombo.SelectedItem as ComboBoxItem)?.Content?.ToString();
+
+ bool showDatabase = metric is null or "(any)" or "Blocking Detected" or "Deadlocks Detected" or "Long-Running Query";
+ bool showWaitType = metric is null or "(any)" or "Poison Wait" or "Long-Running Query";
+ bool showQueryText = metric is null or "(any)" or "Blocking Detected" or "Long-Running Query";
+ bool showJobName = metric is null or "(any)" or "Long-Running Job";
+
+ DatabaseLabel.Visibility = DatabasePatternBox.Visibility = showDatabase ? Visibility.Visible : Visibility.Collapsed;
+ WaitTypeLabel.Visibility = WaitTypePatternBox.Visibility = showWaitType ? Visibility.Visible : Visibility.Collapsed;
+ QueryTextLabel.Visibility = QueryTextPatternBox.Visibility = showQueryText ? Visibility.Visible : Visibility.Collapsed;
+ JobNameLabel.Visibility = JobNamePatternBox.Visibility = showJobName ? Visibility.Visible : Visibility.Collapsed;
+
+ PatternFieldsGrid.Visibility = (showDatabase || showWaitType || showQueryText || showJobName)
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+}
diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml
index 4412f023..40ef0ef1 100644
--- a/Lite/Windows/SettingsWindow.xaml
+++ b/Lite/Windows/SettingsWindow.xaml
@@ -68,6 +68,10 @@
+
@@ -181,9 +185,29 @@
-
+
+
+
+
+
+
+
+
+
@@ -198,8 +222,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -272,8 +329,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SaveMcpSettingsAsync()
{
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
@@ -150,22 +212,56 @@ private bool SaveMcpSettings()
var newEnabled = McpEnabledCheckBox.IsChecked == true;
int.TryParse(McpPortTextBox.Text, out var newPort);
+ if (newEnabled && (newPort != oldPort || !oldEnabled))
+ {
+ if (newPort < 1024 || newPort > IPEndPoint.MaxPort)
+ {
+ MessageBox.Show(
+ $"MCP port must be between 1024 and {IPEndPoint.MaxPort}.\nPorts 0–1023 are well-known privileged ports reserved by the operating system.",
+ "Validation", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return (true, false);
+ }
+
+ // CanBindTcpPortAsync attempts an actual bind, which is more reliable
+ // than checking listeners (TOCTOU is still possible but less likely)
+ bool canBind = await PortUtilityService.CanBindTcpPortAsync(newPort, IPAddress.Loopback);
+ if (!canBind)
+ {
+ MessageBox.Show(
+ $"Port {newPort} is already in use. Choose a different port for the MCP server.",
+ "Port Conflict", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return (true, false);
+ }
+ }
+
root["mcp_enabled"] = newEnabled;
- if (newPort > 0 && newPort < 65536)
+ bool portValid = true;
+ if (newPort >= 1024 && newPort <= IPEndPoint.MaxPort)
{
root["mcp_port"] = newPort;
}
+ else
+ {
+ portValid = false;
+ }
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(options));
- return oldEnabled != newEnabled || oldPort != newPort;
+ if (!portValid)
+ {
+ MessageBox.Show(
+ "MCP port failed validation - must be a valid TCP port number.\nOther MCP settings were saved.",
+ "Settings", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+
+ return (oldEnabled != newEnabled || oldPort != newPort, portValid);
}
catch (Exception ex)
{
AppLogger.Error("Settings", $"Failed to save MCP settings: {ex.Message}");
- return false;
+ return (false, true);
}
}
@@ -230,6 +326,20 @@ private void CopyMcpCommandButton_Click(object sender, RoutedEventArgs e)
McpStatusText.Text = "Copied to clipboard!";
}
+ private async void AutoPortButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ int port = await PortUtilityService.GetFreeTcpPortAsync();
+ McpPortTextBox.Text = port.ToString();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"Could not find an available port: {ex.Message}",
+ "Error", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
private void LoadConnectionTimeout()
{
ConnectionTimeoutBox.Text = App.ConnectionTimeoutSeconds.ToString();
@@ -316,6 +426,7 @@ private void SaveCsvSeparator()
private bool _isLoadingTheme;
private readonly string _originalTheme = Helpers.ThemeManager.CurrentTheme;
private bool _saved;
+ public bool McpSettingsChanged { get; private set; }
private void ColorThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
@@ -436,14 +547,22 @@ private void LoadAlertSettings()
AlertPoisonWaitThresholdBox.Text = App.AlertPoisonWaitThresholdMs.ToString();
AlertLongRunningQueryCheckBox.IsChecked = App.AlertLongRunningQueryEnabled;
AlertLongRunningQueryThresholdBox.Text = App.AlertLongRunningQueryThresholdMinutes.ToString();
+ AlertLongRunningQueryMaxResultsBox.Text = App.AlertLongRunningQueryMaxResults.ToString();
+ LrqExcludeSpServerDiagnosticsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeSpServerDiagnostics;
+ LrqExcludeWaitForCheckBox.IsChecked = App.AlertLongRunningQueryExcludeWaitFor;
+ LrqExcludeBackupsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeBackups;
+ LrqExcludeMiscWaitsCheckBox.IsChecked = App.AlertLongRunningQueryExcludeMiscWaits;
+ AlertExcludedDatabasesBox.Text = string.Join(", ", App.AlertExcludedDatabases);
AlertTempDbSpaceCheckBox.IsChecked = App.AlertTempDbSpaceEnabled;
AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString();
AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled;
AlertLongRunningJobMultiplierBox.Text = App.AlertLongRunningJobMultiplier.ToString();
+ AlertCooldownBox.Text = App.AlertCooldownMinutes.ToString();
+ EmailCooldownBox.Text = App.EmailCooldownMinutes.ToString();
UpdateAlertControlStates();
}
- private void SaveAlertSettings()
+ private bool SaveAlertSettings()
{
App.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked == true;
App.AlertsEnabled = AlertsEnabledCheckBox.IsChecked == true;
@@ -463,12 +582,32 @@ private void SaveAlertSettings()
App.AlertLongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true;
if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0)
App.AlertLongRunningQueryThresholdMinutes = lrq;
+ if (int.TryParse(AlertLongRunningQueryMaxResultsBox.Text, out var lrqMax) && lrqMax >= 1 && lrqMax <= int.MaxValue)
+ App.AlertLongRunningQueryMaxResults = lrqMax;
+ App.AlertLongRunningQueryExcludeSpServerDiagnostics = LrqExcludeSpServerDiagnosticsCheckBox.IsChecked == true;
+ App.AlertLongRunningQueryExcludeWaitFor = LrqExcludeWaitForCheckBox.IsChecked == true;
+ App.AlertLongRunningQueryExcludeBackups = LrqExcludeBackupsCheckBox.IsChecked == true;
+ App.AlertLongRunningQueryExcludeMiscWaits = LrqExcludeMiscWaitsCheckBox.IsChecked == true;
+ App.AlertExcludedDatabases = AlertExcludedDatabasesBox.Text
+ .Split(',')
+ .Select(s => s.Trim())
+ .Where(s => s.Length > 0)
+ .ToList();
App.AlertTempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true;
if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb > 0 && tempDb <= 100)
App.AlertTempDbSpaceThresholdPercent = tempDb;
App.AlertLongRunningJobEnabled = AlertLongRunningJobCheckBox.IsChecked == true;
if (int.TryParse(AlertLongRunningJobMultiplierBox.Text, out var jobMult) && jobMult >= 2 && jobMult <= 20)
App.AlertLongRunningJobMultiplier = jobMult;
+ var validationErrors = new List();
+ if (int.TryParse(AlertCooldownBox.Text, out var alertCooldown) && alertCooldown >= 1 && alertCooldown <= 120)
+ App.AlertCooldownMinutes = alertCooldown;
+ else
+ validationErrors.Add("Tray notification cooldown must be between 1 and 120 minutes.");
+ if (int.TryParse(EmailCooldownBox.Text, out var emailCooldown) && emailCooldown >= 1 && emailCooldown <= 120)
+ App.EmailCooldownMinutes = emailCooldown;
+ else
+ validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes.");
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
try
@@ -497,10 +636,20 @@ private void SaveAlertSettings()
root["alert_poison_wait_threshold_ms"] = App.AlertPoisonWaitThresholdMs;
root["alert_long_running_query_enabled"] = App.AlertLongRunningQueryEnabled;
root["alert_long_running_query_threshold_minutes"] = App.AlertLongRunningQueryThresholdMinutes;
+ root["alert_long_running_query_max_results"] = App.AlertLongRunningQueryMaxResults;
+ root["alert_long_running_query_exclude_sp_server_diagnostics"] = App.AlertLongRunningQueryExcludeSpServerDiagnostics;
+ root["alert_long_running_query_exclude_waitfor"] = App.AlertLongRunningQueryExcludeWaitFor;
+ root["alert_long_running_query_exclude_backups"] = App.AlertLongRunningQueryExcludeBackups;
+ root["alert_long_running_query_exclude_misc_waits"] = App.AlertLongRunningQueryExcludeMiscWaits;
+ var dbArray = new System.Text.Json.Nodes.JsonArray();
+ foreach (var db in App.AlertExcludedDatabases) dbArray.Add(db);
+ root["alert_excluded_databases"] = dbArray;
root["alert_tempdb_space_enabled"] = App.AlertTempDbSpaceEnabled;
root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent;
root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled;
root["alert_long_running_job_multiplier"] = App.AlertLongRunningJobMultiplier;
+ root["alert_cooldown_minutes"] = App.AlertCooldownMinutes;
+ root["email_cooldown_minutes"] = App.EmailCooldownMinutes;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(options));
@@ -509,6 +658,17 @@ private void SaveAlertSettings()
{
AppLogger.Error("Settings", $"Failed to save alert settings: {ex.Message}");
}
+
+ if (validationErrors.Count > 0)
+ {
+ MessageBox.Show(
+ "Some alert settings have invalid values and were not changed:\n\n" +
+ string.Join("\n", validationErrors),
+ "Settings", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return false;
+ }
+
+ return true;
}
private void AlertsEnabledCheckBox_Changed(object sender, RoutedEventArgs e)
@@ -525,9 +685,19 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e)
AlertLongRunningQueryThresholdBox.Text = "30";
AlertTempDbSpaceThresholdBox.Text = "80";
AlertLongRunningJobMultiplierBox.Text = "3";
+ AlertCooldownBox.Text = "5";
+ EmailCooldownBox.Text = "15";
+ AlertExcludedDatabasesBox.Text = "";
UpdateAlertPreviewText();
}
+ private void ManageMuteRulesButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_muteRuleService == null) return;
+ var window = new ManageMuteRulesWindow(_muteRuleService) { Owner = this };
+ window.ShowDialog();
+ }
+
private void UpdateAlertPreviewText()
{
var parts = new System.Collections.Generic.List();
diff --git a/Lite/Windows/WaitDrillDownWindow.xaml b/Lite/Windows/WaitDrillDownWindow.xaml
new file mode 100644
index 00000000..11e159e9
--- /dev/null
+++ b/Lite/Windows/WaitDrillDownWindow.xaml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Windows/WaitDrillDownWindow.xaml.cs b/Lite/Windows/WaitDrillDownWindow.xaml.cs
new file mode 100644
index 00000000..ada381d1
--- /dev/null
+++ b/Lite/Windows/WaitDrillDownWindow.xaml.cs
@@ -0,0 +1,415 @@
+/*
+ * 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 System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using PerformanceMonitorLite.Controls;
+using PerformanceMonitorLite.Helpers;
+using PerformanceMonitorLite.Models;
+using PerformanceMonitorLite.Services;
+using static PerformanceMonitorLite.Helpers.WaitDrillDownHelper;
+
+namespace PerformanceMonitorLite.Windows;
+
+public partial class WaitDrillDownWindow : Window
+{
+ private readonly LocalDataService _dataService;
+ private readonly int _serverId;
+ private readonly string _waitType;
+ private readonly int _hoursBack;
+ private readonly DateTime? _fromDate;
+ private readonly DateTime? _toDate;
+
+ // Filter state
+ private DataGridFilterManager? _filterManager;
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+
+ public WaitDrillDownWindow(
+ LocalDataService dataService,
+ int serverId,
+ string waitType,
+ int hoursBack,
+ DateTime? fromDate = null,
+ DateTime? toDate = null)
+ {
+ InitializeComponent();
+ _dataService = dataService;
+ _serverId = serverId;
+ _waitType = waitType;
+ _hoursBack = hoursBack;
+ _fromDate = fromDate;
+ _toDate = toDate;
+
+ _filterManager = new DataGridFilterManager(ResultsDataGrid);
+
+ Title = $"Wait Drill-Down: {waitType}";
+
+ var classification = Classify(waitType);
+ HeaderText.Text = classification.Category == WaitCategory.Correlated
+ ? $"Queries active during {waitType} spike"
+ : $"Queries experiencing {waitType}";
+
+ Loaded += async (_, _) => await LoadDataAsync();
+ ThemeManager.ThemeChanged += OnThemeChanged;
+ Closed += (_, _) => ThemeManager.ThemeChanged -= OnThemeChanged;
+ }
+
+ private async System.Threading.Tasks.Task LoadDataAsync()
+ {
+ SummaryText.Text = "Loading...";
+
+ try
+ {
+ var classification = Classify(_waitType);
+ SetWarningBanner(classification);
+
+ if (classification.Category == WaitCategory.Chain)
+ {
+ await LoadChainDataAsync(classification);
+ }
+ else if (classification.Category == WaitCategory.Correlated || classification.Category == WaitCategory.Uncapturable)
+ {
+ await LoadCorrelatedDataAsync(classification);
+ }
+ else
+ {
+ await LoadDirectDataAsync(classification);
+ }
+ }
+ catch (Exception ex)
+ {
+ SummaryText.Text = $"Error: {ex.Message}";
+ }
+ }
+
+ private async System.Threading.Tasks.Task LoadDirectDataAsync(WaitClassification classification)
+ {
+ var data = await _dataService.GetQuerySnapshotsByWaitTypeAsync(
+ _serverId, _waitType, _hoursBack, _fromDate, _toDate);
+
+ if (data.Count == 0)
+ {
+ SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range.";
+ return;
+ }
+
+ // Sort by the classified column
+ data = SortByProperty(data, classification.SortProperty);
+
+ _filterManager!.UpdateData(data);
+
+ var timeRange = GetTimeRangeDescription(data);
+ var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
+ SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
+
+ // Set initial sort on the DataGrid
+ ApplyInitialSort(classification.SortProperty);
+ }
+
+ private async System.Threading.Tasks.Task LoadCorrelatedDataAsync(WaitClassification classification)
+ {
+ // Fetch ALL queries in the time range (no wait type filter)
+ var data = await _dataService.GetAllQuerySnapshotsInRangeAsync(
+ _serverId, _hoursBack, _fromDate, _toDate);
+
+ if (data.Count == 0)
+ {
+ SummaryText.Text = $"No query snapshots found in the selected time range.";
+ return;
+ }
+
+ data = SortByProperty(data, classification.SortProperty);
+ _filterManager!.UpdateData(data);
+
+ var timeRange = GetTimeRangeDescription(data);
+ var truncated = data.Count >= 500 ? " (limited to 500 rows)" : "";
+ SummaryText.Text = $"{data.Count} snapshot(s) | {classification.Description} | {timeRange}{truncated}";
+
+ ApplyInitialSort(classification.SortProperty);
+ }
+
+ private async System.Threading.Tasks.Task LoadChainDataAsync(WaitClassification classification)
+ {
+ // Get waiters with the target wait type
+ var waiters = await _dataService.GetQuerySnapshotsByWaitTypeAsync(
+ _serverId, _waitType, _hoursBack, _fromDate, _toDate);
+
+ if (waiters.Count == 0)
+ {
+ SummaryText.Text = $"No query-level data found for {_waitType} in the selected time range.";
+ return;
+ }
+
+ // Get all snapshots in range for chain walking
+ var allSnapshots = await _dataService.GetAllQuerySnapshotsInRangeAsync(
+ _serverId, _hoursBack, _fromDate, _toDate);
+
+ // Map to SnapshotInfo for the chain walker
+ var waiterInfos = waiters.Select(ToSnapshotInfo).ToList();
+ var allInfos = allSnapshots.Select(ToSnapshotInfo).ToList();
+
+ var headBlockerInfos = WalkBlockingChains(waiterInfos, allInfos);
+
+ if (headBlockerInfos.Count == 0)
+ {
+ // No chain found — fall back to showing the waiters directly
+ _filterManager!.UpdateData(waiters);
+ var timeRange = GetTimeRangeDescription(waiters);
+ SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {timeRange} | No blocking chains found, showing waiters";
+ return;
+ }
+
+ // Look up original full rows for each head blocker and set chain metadata
+ var snapshotLookup = allSnapshots
+ .GroupBy(s => (s.CollectionTime, s.SessionId))
+ .ToDictionary(g => g.Key, g => g.First());
+
+ var headBlockerRows = new List();
+ foreach (var hb in headBlockerInfos)
+ {
+ if (snapshotLookup.TryGetValue((hb.CollectionTime, hb.SessionId), out var row))
+ {
+ row.ChainBlockedCount = hb.BlockedSessionCount;
+ row.ChainBlockingPath = hb.BlockingPath;
+ headBlockerRows.Add(row);
+ }
+ }
+
+ if (headBlockerRows.Count == 0)
+ {
+ // Head blockers not found in snapshots — show waiters instead
+ _filterManager!.UpdateData(waiters);
+ var timeRange = GetTimeRangeDescription(waiters);
+ SummaryText.Text = $"{waiters.Count} snapshot(s) | {classification.Description} | {timeRange} | Head blockers not in snapshots, showing waiters";
+ return;
+ }
+
+ // Add chain columns to the existing XAML-defined columns
+ InsertChainColumns();
+
+ _filterManager!.UpdateData(headBlockerRows);
+
+ var timeRangeDesc = GetTimeRangeDescription(headBlockerRows);
+ SummaryText.Text = $"{headBlockerRows.Count} head blocker(s) from {waiters.Count} waiting session(s) | " +
+ $"{classification.Description} | {timeRangeDesc}";
+ }
+
+ private void InsertChainColumns()
+ {
+ // Insert "Blocked Sessions" and "Blocking Path" columns at the beginning of the grid
+ var blockedCountCol = CreateFilterColumn("Blocked Sessions", "ChainBlockedCount", 105, isNumeric: true);
+ var blockingPathCol = CreateFilterColumn("Blocking Path", "ChainBlockingPath", 250);
+
+ ResultsDataGrid.Columns.Insert(0, blockedCountCol);
+ ResultsDataGrid.Columns.Insert(1, blockingPathCol);
+ }
+
+ private DataGridTextColumn CreateFilterColumn(string headerText, string bindingPath, int width,
+ bool isNumeric = false, string? stringFormat = null)
+ {
+ var filterButton = new Button { Tag = bindingPath, Margin = new Thickness(0, 0, 4, 0) };
+ filterButton.SetResourceReference(StyleProperty, "ColumnFilterButtonStyle");
+ filterButton.Click += Filter_Click;
+
+ var header = new StackPanel { Orientation = Orientation.Horizontal };
+ header.Children.Add(filterButton);
+ header.Children.Add(new System.Windows.Controls.TextBlock
+ {
+ Text = headerText,
+ FontWeight = FontWeights.Bold,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+
+ var binding = new System.Windows.Data.Binding(bindingPath);
+ if (stringFormat != null) binding.StringFormat = stringFormat;
+
+ var column = new DataGridTextColumn
+ {
+ Header = header,
+ Binding = binding,
+ Width = new DataGridLength(width)
+ };
+
+ if (isNumeric)
+ {
+ var numericStyle = (Style?)FindResource("NumericCell");
+ if (numericStyle != null) column.ElementStyle = numericStyle;
+ }
+
+ return column;
+ }
+
+ private void SetWarningBanner(WaitClassification classification)
+ {
+ if (classification.Category == WaitCategory.Uncapturable)
+ {
+ WarningText.Text = $"Sessions experiencing {_waitType} waits may not be captured in query snapshots " +
+ "because they may lack assigned worker threads. Showing all queries in this time range.";
+ WarningBanner.Visibility = Visibility.Visible;
+ }
+ else if (classification.Category == WaitCategory.Correlated)
+ {
+ WarningText.Text = $"{_waitType} waits are too brief to appear in query snapshots. " +
+ "Showing all queries active during this period, sorted by the most correlated metric.";
+ WarningBanner.Visibility = Visibility.Visible;
+ WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
+ WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
+ WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
+ }
+ else if (classification.Category == WaitCategory.Chain)
+ {
+ WarningText.Text = $"Showing head blockers (the cause of {_waitType} waits), not the waiting sessions themselves.";
+ WarningBanner.Visibility = Visibility.Visible;
+ WarningBanner.Background = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x3D, 0x00, 0x33, 0x66));
+ WarningBanner.BorderBrush = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromArgb(0x66, 0x00, 0x55, 0x99));
+ WarningText.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0xFF));
+ }
+ }
+
+ private static SnapshotInfo ToSnapshotInfo(QuerySnapshotRow row) => new()
+ {
+ SessionId = row.SessionId,
+ BlockingSessionId = row.BlockingSessionId,
+ CollectionTime = row.CollectionTime,
+ DatabaseName = row.DatabaseName,
+ Status = row.Status,
+ QueryText = row.QueryText,
+ WaitType = row.WaitType,
+ WaitTimeMs = row.WaitTimeMs,
+ CpuTimeMs = row.CpuTimeMs,
+ Reads = row.Reads,
+ Writes = row.Writes,
+ LogicalReads = row.LogicalReads
+ };
+
+ private static List SortByProperty(List data, string property) =>
+ property switch
+ {
+ "CpuTimeMs" => data.OrderByDescending(r => r.CpuTimeMs).ToList(),
+ "Reads" => data.OrderByDescending(r => r.Reads).ToList(),
+ "Writes" => data.OrderByDescending(r => r.Writes).ToList(),
+ "Dop" => data.OrderByDescending(r => r.Dop).ToList(),
+ "GrantedQueryMemoryGb" => data.OrderByDescending(r => r.GrantedQueryMemoryGb).ToList(),
+ "WaitTimeMs" => data.OrderByDescending(r => r.WaitTimeMs).ToList(),
+ _ => data
+ };
+
+ private void ApplyInitialSort(string property)
+ {
+ var columnHeader = property switch
+ {
+ "CpuTimeMs" => "CPU (ms)",
+ "Reads" => "Reads",
+ "Writes" => "Writes",
+ "Dop" => "DOP",
+ "GrantedQueryMemoryGb" => "Memory (GB)",
+ "WaitTimeMs" => "Wait (ms)",
+ _ => null
+ };
+
+ if (columnHeader == null) return;
+
+ foreach (var column in ResultsDataGrid.Columns)
+ {
+ if (column.Header is StackPanel sp)
+ {
+ var textBlock = sp.Children.OfType().FirstOrDefault();
+ if (textBlock?.Text == columnHeader)
+ {
+ column.SortDirection = ListSortDirection.Descending;
+ break;
+ }
+ }
+ }
+ }
+
+ private static string GetTimeRangeDescription(List data)
+ {
+ if (data.Count == 0) return "";
+ var first = data.Min(r => r.CollectionTime);
+ var last = data.Max(r => r.CollectionTime);
+ return $"{ServerTimeHelper.FormatServerTime(first)} to {ServerTimeHelper.FormatServerTime(last)}";
+ }
+
+
+ private void OnThemeChanged(string _)
+ {
+ _filterManager?.UpdateFilterButtonStyles();
+ }
+
+ #region Column Filter Popup
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+ }
+
+ private void Filter_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnName) return;
+ if (_filterManager == null) return;
+
+ EnsureFilterPopup();
+
+ // Detach/reattach to avoid double-fire
+ _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared;
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ _filterManager.Filters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent.Initialize(columnName, existingFilter);
+
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+
+ _filterManager?.SetFilter(e.FilterState);
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+ }
+
+ #endregion
+
+ private void CopyCell_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyCell(sender);
+ private void CopyRow_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyRow(sender);
+ private void CopyAllRows_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyAllRows(sender);
+ private void ExportToCsv_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.ExportToCsv(sender, "wait_drill_down");
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/README.md b/README.md
index 6b383ad9..f2900422 100644
--- a/README.md
+++ b/README.md
@@ -19,11 +19,13 @@
|---|---|---|
| **What it does** | Installs a `PerformanceMonitor` database with 30 T-SQL collectors running via SQL Agent. Separate dashboard app connects to view everything. | Single desktop app that monitors remotely. Stores data locally in DuckDB + Parquet. Nothing touches your server. |
| **Best for** | Production 24/7 monitoring, long-term baselining | Quick triage, Azure SQL DB, locked-down servers, consultants, firefighting |
-| **Requires** | sysadmin + SQL Agent running | `VIEW SERVER STATE` (that's it) |
+| **Requires** | SQL Agent running ([see permissions](#permissions)) | `VIEW SERVER STATE` ([see permissions](#permissions)) |
| **Get started** | Run the installer, open the dashboard | Download, run, add a server, done |
Both editions include real-time alerts (system tray + email), charts and graphs, dark and light themes, CSV export, and a built-in MCP server for AI-powered analysis with tools like Claude.
+All release binaries are digitally signed via [SignPath](https://signpath.io) — no more Windows SmartScreen warnings.
+
---
## What People Are Saying
@@ -38,7 +40,7 @@ Both editions include real-time alerts (system tray + email), charts and graphs,
## What You Get
-🔍 **32 specialized T-SQL collectors** running on configurable schedules — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments.
+🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments.
🚨 **Real-time alerts** for blocking, deadlocks, and high CPU — system tray notifications plus styled HTML emails with full XML attachments for offline analysis
@@ -46,7 +48,7 @@ Both editions include real-time alerts (system tray + email), charts and graphs,
📋 **Graphical plan viewer** with native ShowPlan rendering, 30-rule PlanAnalyzer, operator-level cost breakdown, and a standalone mode for opening `.sqlplan` files without a server connection
-🤖 **Built-in MCP server** with 27-31 read-only tools for AI analysis — ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data
+🤖 **Built-in MCP server** with 28-32 read-only tools for AI analysis — ask Claude Code or Cursor "what are the top wait types on my server?" and get answers from your actual monitoring data
🧰 **Community tools installed automatically** — sp_WhoIsActive, sp_BlitzLock, sp_HealthParser, sp_HumanEventsBlockViewer
@@ -79,9 +81,11 @@ Both editions include real-time alerts (system tray + email), charts and graphs,
Data starts flowing within 1–5 minutes. That's it. No installation on your server, no Agent jobs, no sysadmin required.
+**Always On AG?** Enable **ReadOnlyIntent** in the connection settings to route Lite's monitoring queries to a readable secondary, keeping the primary clear.
+
### Lite Collectors
-20 collectors run on independent, configurable schedules:
+23 collectors run on independent, configurable schedules:
| Collector | Default | Source |
|---|---|---|
@@ -98,9 +102,12 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser
| tempdb_stats | 1 min | `sys.dm_db_file_space_usage` |
| perfmon_stats | 1 min | `sys.dm_os_performance_counters` (deltas) |
| deadlocks | 1 min | `system_health` Extended Events session |
+| session_stats | 1 min | `sys.dm_exec_sessions` active session tracking |
| memory_clerks | 5 min | `sys.dm_os_memory_clerks` |
| query_store | 5 min | Query Store DMVs (per database) |
| running_jobs | 5 min | `msdb` job history with duration vs avg/p95 |
+| database_size_stats | 15 min | `sys.master_files` + `FILEPROPERTY` + `dm_os_volume_stats` |
+| server_properties | 15 min | `SERVERPROPERTY()` hardware and licensing metadata |
| server_config | On connect | `sys.configurations` |
| database_config | On connect | `sys.databases` |
| database_scoped_config | On connect | Database-scoped configurations |
@@ -108,8 +115,9 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser
### Lite Data Storage
-- **Hot data** in DuckDB (7–90 days, configurable)
-- **Archive** to Parquet with ZSTD compression (~10x reduction, 30–180 days configurable)
+- **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
- Typical size: ~50–200 MB per server per week
### Lite Configuration
@@ -141,6 +149,12 @@ SQL Authentication:
PerformanceMonitorInstaller.exe YourServerName sa YourPassword
```
+Entra ID (MFA) Authentication:
+
+```
+PerformanceMonitorInstaller.exe YourServerName --entra user@domain.com
+```
+
Clean reinstall (drops existing database and all collected data):
```
@@ -148,6 +162,13 @@ PerformanceMonitorInstaller.exe YourServerName --reinstall
PerformanceMonitorInstaller.exe YourServerName sa YourPassword --reinstall
```
+Uninstall (removes database, Agent jobs, and XE sessions):
+
+```
+PerformanceMonitorInstaller.exe YourServerName --uninstall
+PerformanceMonitorInstaller.exe YourServerName sa YourPassword --uninstall
+```
+
The installer automatically tests the connection, executes SQL scripts, downloads community dependencies, creates SQL Agent jobs, and runs initial data collection. A GUI installer (`InstallerGui/`) is also available with the same functionality.
### CLI Installer Options
@@ -156,7 +177,10 @@ The installer automatically tests the connection, executes SQL scripts, download
|---|---|
| `SERVER` | SQL Server instance name (positional, required) |
| `USERNAME PASSWORD` | SQL Authentication credentials (positional, optional) |
+| `--entra EMAIL` | Microsoft Entra ID interactive authentication (MFA) |
| `--reinstall` | Drop existing database and perform clean install |
+| `--uninstall` | Remove database, Agent jobs, and XE sessions |
+| `--reset-schedule` | Reset collection schedule to recommended defaults |
| `--preserve-jobs` | Keep existing SQL Agent job schedules during upgrade |
| `--encrypt=optional\|mandatory\|strict` | Connection encryption level (default: mandatory) |
| `--trust-cert` | Trust server certificate without validation (default: require valid cert) |
@@ -175,6 +199,7 @@ The installer automatically tests the connection, executes SQL scripts, download
| `4` | Partial installation (non-critical failures) |
| `5` | Version check failed |
| `6` | SQL files not found |
+| `7` | Uninstall failed |
### Post-Installation
@@ -203,7 +228,7 @@ ORDER BY collection_time DESC;
### Data Retention
-Default: 30 days (configurable per table in `config.retention_settings`).
+Default: 30 days (configurable per collector via the `retention_days` column in `config.collection_schedule`).
Storage estimates: 5–10 GB per week, 20–40 GB per month.
@@ -264,7 +289,7 @@ The Full Edition supports Azure SQL Managed Instance and AWS RDS for SQL Server
| AWS RDS for SQL Server | Supported | Supported |
| Azure SQL Database | Not supported | Supported |
| Multi-server from one seat | Per-server install | Built-in |
-| Collectors | 32 | 20 |
+| Collectors | 32 | 23 |
| Agent job monitoring | Duration vs historical avg/p95 | Duration vs historical avg/p95 |
| Data storage | SQL Server (on target) | DuckDB + Parquet (local) |
| Execution plans | Collected and stored (can be disabled per-collector) | Download on demand |
@@ -275,7 +300,7 @@ The Full Edition supports Azure SQL Managed Instance and AWS RDS for SQL Server
| Dashboard | Separate app | Built-in |
| Themes | Dark and light | Dark and light |
| Portability | Server-bound | Single executable |
-| MCP server (LLM integration) | Built into Dashboard (27 tools) | Built-in (31 tools) |
+| MCP server (LLM integration) | Built into Dashboard (28 tools) | Built-in (32 tools) |
---
@@ -308,6 +333,7 @@ Plus a NOC-style landing page with server health cards (green/yellow/red severit
| **Blocking** | Blocking/deadlock trends, blocked process reports, deadlock history |
| **Perfmon** | Selectable SQL Server performance counters over time |
| **Configuration** | Server configuration, database configuration, scoped configuration, trace flags |
+| **FinOps** | Utilization & provisioning analysis, database resource breakdown, storage growth (7d/30d), idle database detection, index analysis via sp_IndexCleanup, application connections, wait/query/TempDB/memory grant optimization |
Both editions feature auto-refresh, configurable time ranges, right-click CSV export, system tray integration, dark and light themes, and timezone display options (server time, local time, or UTC).
@@ -323,15 +349,23 @@ Both editions include a real-time alert engine that monitors for performance iss
|---|---|---|
| **Blocking** | 30 seconds (Full), 5 seconds (Lite) | Fires when the longest blocked session exceeds the threshold |
| **Deadlocks** | 1 | Fires when new deadlocks are detected since the last check |
+| **Poison waits** | 100 ms avg | Fires when any poison wait type exceeds the average-ms-per-wait threshold |
+| **Long-running queries** | 5 minutes | Fires when any query exceeds the elapsed-time threshold |
+| **TempDB space** | 80% | Fires when TempDB usage exceeds the percentage threshold |
+| **Long-running agent jobs** | 3× average | Fires when a job's current duration exceeds a multiple of its historical average |
| **High CPU** | 90% (Full), 80% (Lite) | Fires when total CPU (SQL + other) exceeds the threshold |
| **Connection changes** | N/A | Fires when a monitored server goes offline or comes back online |
All thresholds are configurable in Settings.
+**Poison wait types** monitored: [`THREADPOOL`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#threadpool) (worker thread exhaustion), [`RESOURCE_SEMAPHORE`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#resource_semaphore) (memory grant pressure), and [`RESOURCE_SEMAPHORE_QUERY_COMPILE`](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-os-wait-stats-transact-sql#resource_semaphore_query_compile) (compilation memory pressure). These waits indicate severe resource starvation and should never occur under normal operation.
+
### Notification Channels
-- **System tray** — balloon notifications with 5-minute per-metric cooldown
-- **Email (SMTP)** — styled HTML emails with 15-minute per-metric cooldown, configurable SMTP settings (server, port, SSL, authentication, recipients)
+- **System tray** — balloon notifications with a configurable per-metric cooldown (default: 5 minutes)
+- **Email (SMTP)** — styled HTML emails with a configurable per-metric cooldown (default: 15 minutes), plus configurable SMTP settings (server, port, SSL, authentication, recipients)
+
+Both cooldown periods are independently configurable in Settings under the Performance Alerts section.
### Email Alerts
@@ -347,6 +381,8 @@ Alert emails include:
- **Server silencing** — right-click a server tab to acknowledge alerts, silence all alerts, or unsilence
- **Always-on** — the Dashboard alert engine runs independently of which tab is active, including when minimized to the system tray. The Lite edition's alert engine also runs regardless of tab visibility.
- **Alert history** — Dashboard keeps an in-memory alert log (accessible via MCP). Lite logs alerts to DuckDB (`config_alert_log`).
+- **Alert muting** — create rules to suppress specific recurring alerts while still logging them. Rules match on server name, metric type, database, query text, wait type, or job name (AND logic across fields). Access via Settings → Manage Mute Rules, or right-click an alert in the Alert History tab. The context menu offers two muting options: **Mute This Alert** (pre-fills server + metric for a targeted rule) and **Mute Similar Alerts** (pre-fills metric only, matching across all servers). Muted alerts appear grayed out in alert history and are still recorded for auditability. Rules support optional expiration (1h, 24h, 7 days, or permanent).
+- **Alert details** — right-click any alert in the Alert History tab and choose **View Details** to open a detail window. The window shows core alert fields (time, server, metric, value, threshold, notification type, status) plus context-sensitive details that vary by metric: query text and session info for long-running queries, job name and duration stats for anomalous agent jobs, per-wait-type breakdowns for poison waits, space usage by category for TempDB, and blocking/deadlock session counts.
---
@@ -374,6 +410,9 @@ Both editions include an embedded [Model Context Protocol](https://modelcontextp
### Setup
1. Enable the MCP server in Settings (checkbox + port, default `5151`)
+ - The port must be between **1024** and **65535**. Ports 0–1023 are well-known privileged ports reserved by the operating system.
+ - On save, the app checks whether the chosen port is already in use and warns you if there is a conflict.
+ - On startup, the app verifies the port is available before starting the MCP server.
2. Register with Claude Code:
```
@@ -389,13 +428,13 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/
### Available Tools
-Full Edition exposes 27 tools, Lite Edition exposes 31. Core tools are shared across both editions.
+Full Edition exposes 28 tools, Lite Edition exposes 32. Core tools are shared across both editions.
| Category | Tools |
|---|---|
| Discovery | `list_servers` |
| Health | `get_server_summary`\*, `get_daily_summary`\*\*, `get_collection_health` |
-| Alerts | `get_alert_history`, `get_alert_settings` |
+| Alerts | `get_alert_history`, `get_alert_settings`, `get_mute_rules` |
| Waits | `get_wait_stats`, `get_wait_types`\*, `get_wait_trend`, `get_waiting_tasks`\* |
| Queries | `get_top_queries_by_cpu`, `get_top_procedures_by_cpu`, `get_query_store_top`, `get_expensive_queries`\*\*, `get_query_duration_trend`\*, `get_query_trend` |
| CPU | `get_cpu_utilization` |
@@ -461,7 +500,76 @@ Common issues:
1. **No data after connecting** — Wait for the first collection cycle (1–5 minutes). Check logs for connection errors.
2. **Query Store tab empty** — Query Store must be enabled on the target database (`ALTER DATABASE [YourDB] SET QUERY_STORE = ON`).
3. **Blocked process reports empty** — Both editions attempt to auto-configure the blocked process threshold to 5 seconds via `sp_configure`. On **AWS RDS**, `sp_configure` is not available — you must set `blocked process threshold (s)` through an RDS Parameter Group (see "AWS RDS Parameter Group Configuration" above). On **Azure SQL Database**, the threshold is fixed at 20 seconds and cannot be changed. If you still see no data on other platforms, verify the login has `ALTER SETTINGS` permission.
-4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has `VIEW SERVER STATE`.
+4. **Connection failures** — Verify network connectivity, firewall rules, and that the login has the required [permissions](#permissions). For Azure SQL Database, use a contained database user with `VIEW DATABASE STATE`.
+
+---
+
+## Permissions
+
+### Full Edition (On-Premises)
+
+The installer needs `sysadmin` to create the database, Agent jobs, and configure `sp_configure` settings. After installation, the collection jobs can run under a **least-privilege login** with these grants:
+
+```sql
+USE [master];
+CREATE LOGIN [SQLServerPerfMon] WITH PASSWORD = N'YourStrongPassword';
+GRANT VIEW SERVER STATE TO [SQLServerPerfMon];
+
+USE [PerformanceMonitor];
+CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon];
+ALTER ROLE [db_owner] ADD MEMBER [SQLServerPerfMon];
+
+USE [msdb];
+CREATE USER [SQLServerPerfMon] FOR LOGIN [SQLServerPerfMon];
+ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [SQLServerPerfMon];
+```
+
+| Grant | Why |
+|---|---|
+| `VIEW SERVER STATE` | All DMV access (wait stats, query stats, memory, CPU, file I/O, etc.) |
+| `db_owner` on PerformanceMonitor | Collectors insert data, create/alter tables, execute procedures. Scoped to just this database — not sysadmin. |
+| `SQLAgentReaderRole` on msdb | Read `sysjobs`, `sysjobactivity`, `sysjobhistory` for the running jobs collector |
+
+**Optional** (gracefully skipped if missing):
+- `ALTER SETTINGS` — installer sets `blocked process threshold` via `sp_configure`. Skipped with a warning if unavailable.
+- `ALTER TRACE` — default trace collector. Skipped if denied.
+- `DBCC TRACESTATUS` — server config collector skips trace flag detection if denied.
+
+Change the SQL Agent job owner to the new login after installation if you want to run under least privilege end-to-end.
+
+### Lite Edition (On-Premises)
+
+Nothing is installed on the target server. The login only needs:
+
+```sql
+USE [master];
+GRANT VIEW SERVER STATE TO [YourLogin];
+
+-- Optional: for SQL Agent job monitoring
+USE [msdb];
+CREATE USER [YourLogin] FOR LOGIN [YourLogin];
+ALTER ROLE [SQLAgentReaderRole] ADD MEMBER [YourLogin];
+```
+
+### Azure SQL Database (Lite Only)
+
+Azure SQL Database doesn't support server-level logins. Create a **contained database user** directly on the target database:
+
+```sql
+-- Connect to your target database (not master)
+CREATE USER [SQLServerPerfMon] WITH PASSWORD = 'YourStrongPassword';
+GRANT VIEW DATABASE STATE TO [SQLServerPerfMon];
+```
+
+When connecting in Lite, specify the database name in the connection. SQL Agent and msdb are not available on Azure SQL Database — those collectors are skipped automatically.
+
+### Azure SQL Managed Instance
+
+Works like on-premises. Use server-level logins with `VIEW SERVER STATE`. SQL Agent is available.
+
+### AWS RDS for SQL Server
+
+Use the RDS master user for installation. The master user has the necessary permissions. For ongoing collection, `VIEW SERVER STATE` and msdb access work the same as on-premises, but `sp_configure` is not available (use RDS Parameter Groups instead — see above).
---
@@ -471,7 +579,7 @@ Common issues:
Monitor/
│
│ Full Edition (server-installed collectors + separate dashboard)
-├── install/ # 54 SQL installation scripts
+├── install/ # 58 SQL installation scripts
├── upgrades/ # Version-specific upgrade scripts
├── Installer/ # CLI installer for Full Edition database (C#)
├── InstallerGui/ # GUI installer for Full Edition database (WPF)
@@ -508,14 +616,27 @@ dotnet publish InstallerGui/InstallerGui.csproj -c Release -r win-x64 --self-con
## Support & Sponsorship
-**This project is free and open source.** If you find it valuable, consider supporting continued development:
+**This project is free and open source under the MIT License.** The software is fully functional with no features withheld — every user gets the same tool, same collectors, same MCP integration.
+
+However, some organizations have procurement or compliance policies that require a formal vendor relationship, a support agreement, or an invoice on file before software can be deployed to production. If that sounds familiar, two commercial support tiers are available:
+
+| Tier | Annual Cost | What You Get |
+|------|-------------|--------------|
+| **Supported** | $500/year | Email support (2-business-day response), compatibility guarantees for new SQL Server versions, vendor agreement and invoices for compliance, unlimited instances |
+| **Priority** | $2,500/year | Next-business-day email response, quarterly live Q&A sessions, early access to new features, roadmap input, unlimited instances |
+
+Both tiers cover unlimited SQL Server instances. The software itself is identical — commercial support is about the relationship, not a feature gate.
+
+**[Read more about the free tool and commercial options](https://erikdarling.com/free-sql-server-performance-monitoring/)** | **[Purchase a support subscription](https://training.erikdarling.com/sql-monitoring)**
+
+If you find the project valuable, you can also support continued development:
| | |
|---|---|
| **Sponsor on GitHub** | [Become a sponsor](https://github.com/sponsors/erikdarlingdata) to fund new features, ongoing maintenance, and SQL Server version support. |
| **Consulting Services** | [Hire me](https://training.erikdarling.com/sqlconsulting) for hands-on consulting if you need help analyzing the data this tool collects? Want expert assistance fixing the issues it uncovers? |
-Neither is required — use the tool freely. Sponsorship and consulting keep this project alive.
+Neither sponsorship nor consulting is required — use the tool freely.
---
diff --git a/install/00_uninstall.sql b/install/00_uninstall.sql
new file mode 100644
index 00000000..31aa0063
--- /dev/null
+++ b/install/00_uninstall.sql
@@ -0,0 +1,246 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ *
+ * Uninstall script - removes all Performance Monitor objects from SQL Server.
+ *
+ * Removes:
+ * - Server-side traces (must happen before database drop)
+ * - SQL Agent jobs (3 jobs in msdb)
+ * - Extended Events sessions (2 server-level sessions)
+ * - PerformanceMonitor database
+ *
+ * Does NOT reset:
+ * - blocked process threshold (s) sp_configure setting
+ * (other monitoring tools may depend on it)
+ *
+ * Safe to run multiple times (all operations are idempotent).
+ */
+
+USE master;
+GO
+
+SET NOCOUNT ON;
+GO
+
+PRINT '================================================================================';
+PRINT 'Performance Monitor Uninstaller';
+PRINT '================================================================================';
+PRINT '';
+GO
+
+/*
+Stop server-side traces before dropping database.
+The trace_management_collector procedure lives in the PerformanceMonitor database,
+so this must happen first.
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.databases AS d
+ WHERE d.name = N'PerformanceMonitor'
+)
+AND OBJECT_ID(N'PerformanceMonitor.collect.trace_management_collector', N'P') IS NOT NULL
+BEGIN
+ PRINT 'Stopping server-side traces...';
+
+ BEGIN TRY
+ EXECUTE PerformanceMonitor.collect.trace_management_collector
+ @action = 'STOP';
+
+ PRINT 'Server-side traces stopped';
+ END TRY
+ BEGIN CATCH
+ PRINT 'Note: Could not stop traces (may not be running)';
+ END CATCH;
+END;
+ELSE
+BEGIN
+ PRINT 'No traces to stop (database or procedure not found)';
+END;
+GO
+
+PRINT '';
+GO
+
+/*
+Delete SQL Agent jobs from msdb.
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM msdb.dbo.sysjobs AS sj
+ WHERE sj.name = N'PerformanceMonitor - Collection'
+)
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job
+ @job_name = N'PerformanceMonitor - Collection',
+ @delete_unused_schedule = 1;
+
+ PRINT 'Deleted job: PerformanceMonitor - Collection';
+END;
+ELSE
+BEGIN
+ PRINT 'Job not found: PerformanceMonitor - Collection';
+END;
+
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM msdb.dbo.sysjobs AS sj
+ WHERE sj.name = N'PerformanceMonitor - Data Retention'
+)
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job
+ @job_name = N'PerformanceMonitor - Data Retention',
+ @delete_unused_schedule = 1;
+
+ PRINT 'Deleted job: PerformanceMonitor - Data Retention';
+END;
+ELSE
+BEGIN
+ PRINT 'Job not found: PerformanceMonitor - Data Retention';
+END;
+
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM msdb.dbo.sysjobs AS sj
+ WHERE sj.name = N'PerformanceMonitor - Hung Job Monitor'
+)
+BEGIN
+ EXECUTE msdb.dbo.sp_delete_job
+ @job_name = N'PerformanceMonitor - Hung Job Monitor',
+ @delete_unused_schedule = 1;
+
+ PRINT 'Deleted job: PerformanceMonitor - Hung Job Monitor';
+END;
+ELSE
+BEGIN
+ PRINT 'Job not found: PerformanceMonitor - Hung Job Monitor';
+END;
+GO
+
+PRINT '';
+GO
+
+/*
+Drop Extended Events sessions.
+Stop running sessions before dropping.
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.server_event_sessions AS ses
+ WHERE ses.name = N'PerformanceMonitor_BlockedProcess'
+)
+BEGIN
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.dm_xe_sessions AS dxs
+ WHERE dxs.name = N'PerformanceMonitor_BlockedProcess'
+ )
+ BEGIN
+ ALTER EVENT SESSION
+ [PerformanceMonitor_BlockedProcess]
+ ON SERVER
+ STATE = STOP;
+ END;
+
+ DROP EVENT SESSION
+ [PerformanceMonitor_BlockedProcess]
+ ON SERVER;
+
+ PRINT 'Dropped Extended Events session: PerformanceMonitor_BlockedProcess';
+END;
+ELSE
+BEGIN
+ PRINT 'XE session not found: PerformanceMonitor_BlockedProcess';
+END;
+
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.server_event_sessions AS ses
+ WHERE ses.name = N'PerformanceMonitor_Deadlock'
+)
+BEGIN
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.dm_xe_sessions AS dxs
+ WHERE dxs.name = N'PerformanceMonitor_Deadlock'
+ )
+ BEGIN
+ ALTER EVENT SESSION
+ [PerformanceMonitor_Deadlock]
+ ON SERVER
+ STATE = STOP;
+ END;
+
+ DROP EVENT SESSION
+ [PerformanceMonitor_Deadlock]
+ ON SERVER;
+
+ PRINT 'Dropped Extended Events session: PerformanceMonitor_Deadlock';
+END;
+ELSE
+BEGIN
+ PRINT 'XE session not found: PerformanceMonitor_Deadlock';
+END;
+GO
+
+PRINT '';
+GO
+
+/*
+Drop the PerformanceMonitor database.
+SET SINGLE_USER forces all connections closed.
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.databases AS d
+ WHERE d.name = N'PerformanceMonitor'
+)
+BEGIN
+ PRINT 'Dropping PerformanceMonitor database...';
+
+ ALTER DATABASE [PerformanceMonitor]
+ SET SINGLE_USER
+ WITH ROLLBACK IMMEDIATE;
+
+ DROP DATABASE [PerformanceMonitor];
+
+ PRINT 'PerformanceMonitor database dropped';
+END;
+ELSE
+BEGIN
+ PRINT 'PerformanceMonitor database not found';
+END;
+GO
+
+PRINT '';
+PRINT '================================================================================';
+PRINT 'Uninstall complete';
+PRINT '================================================================================';
+PRINT '';
+PRINT 'Note: blocked process threshold (s) was NOT reset.';
+PRINT 'If no other tools use it, you can reset it manually:';
+PRINT ' EXECUTE sp_configure ''show advanced options'', 1; RECONFIGURE;';
+PRINT ' EXECUTE sp_configure ''blocked process threshold (s)'', 0; RECONFIGURE;';
+PRINT ' EXECUTE sp_configure ''show advanced options'', 0; RECONFIGURE;';
+GO
diff --git a/install/01_install_database.sql b/install/01_install_database.sql
index db141d73..2d718205 100644
--- a/install/01_install_database.sql
+++ b/install/01_install_database.sql
@@ -274,6 +274,10 @@ BEGIN
DEFAULT 5,
retention_days integer NOT NULL
DEFAULT 30,
+ collect_query bit NOT NULL
+ DEFAULT CONVERT(bit, 'true'),
+ collect_plan bit NOT NULL
+ DEFAULT CONVERT(bit, 'true'),
[description] nvarchar(500) NULL,
created_date datetime2(7) NOT NULL
DEFAULT SYSDATETIME(),
diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql
index 4c2cb619..d4e75624 100644
--- a/install/02_create_tables.sql
+++ b/install/02_create_tables.sql
@@ -168,10 +168,11 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Query text and execution plan*/
- query_text nvarchar(MAX) NULL,
- query_plan_text nvarchar(MAX) NULL,
- query_plan xml NULL,
+ /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_text varbinary(max) NULL,
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_query_stats
PRIMARY KEY CLUSTERED
@@ -183,6 +184,34 @@ BEGIN
PRINT 'Created collect.query_stats table';
END;
+/*
+2b. Query Stats Dedup Tracking
+One row per natural key, updated on each collection cycle
+*/
+IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.query_stats_latest_hash
+ (
+ sql_handle varbinary(64) NOT NULL,
+ statement_start_offset integer NOT NULL,
+ statement_end_offset integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (sql_handle, statement_start_offset,
+ statement_end_offset, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_stats_latest_hash table';
+END;
+
/*
3. Memory Pressure
*/
@@ -429,9 +458,10 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Execution plan*/
- query_plan_text nvarchar(max) NULL,
- query_plan xml NULL,
+ /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_procedure_stats
PRIMARY KEY CLUSTERED
@@ -443,6 +473,32 @@ BEGIN
PRINT 'Created collect.procedure_stats table';
END;
+/*
+9b. Procedure Stats Dedup Tracking
+One row per natural key, updated on each collection cycle
+*/
+IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.procedure_stats_latest_hash
+ (
+ database_name sysname NOT NULL,
+ object_id integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_procedure_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, object_id, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.procedure_stats_latest_hash table';
+END;
+
/*
10. Currently Executing Query Snapshots
Table is created dynamically by sp_WhoIsActive on first collection
@@ -473,7 +529,7 @@ BEGIN
server_first_execution_time datetime2(7) NOT NULL,
server_last_execution_time datetime2(7) NOT NULL,
module_name nvarchar(261) NULL,
- query_sql_text nvarchar(max) NULL,
+ query_sql_text varbinary(max) NULL,
query_hash binary(8) NULL,
/*Execution count*/
count_executions bigint NOT NULL,
@@ -531,9 +587,11 @@ BEGIN
last_force_failure_reason_desc nvarchar(128) NULL,
plan_forcing_type nvarchar(60) NULL,
compatibility_level smallint NULL,
- query_plan_text nvarchar(max) NULL,
- compilation_metrics xml NULL,
+ query_plan_text varbinary(max) NULL,
+ compilation_metrics varbinary(max) NULL,
query_plan_hash binary(8) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_query_store_data
PRIMARY KEY CLUSTERED
@@ -545,6 +603,32 @@ BEGIN
PRINT 'Created collect.query_store_data table';
END;
+/*
+11b. Query Store Data Dedup Tracking
+One row per natural key, updated on each collection cycle
+*/
+IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.query_store_data_latest_hash
+ (
+ database_name sysname NOT NULL,
+ query_id bigint NOT NULL,
+ plan_id bigint NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_store_data_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, query_id, plan_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_store_data_latest_hash table';
+END;
+
/*
Trace analysis table - stores processed trace file data
*/
@@ -1322,5 +1406,91 @@ BEGIN
PRINT 'Created collect.running_jobs table';
END;
+/*
+Database Size Statistics Table (FinOps)
+*/
+IF OBJECT_ID(N'collect.database_size_stats', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.database_size_stats
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ database_name sysname NOT NULL,
+ database_id integer NOT NULL,
+ file_id integer NOT NULL,
+ file_type_desc nvarchar(60) NOT NULL,
+ file_name sysname NOT NULL,
+ physical_name nvarchar(260) NOT NULL,
+ total_size_mb decimal(19,2) NOT NULL,
+ used_size_mb decimal(19,2) NULL,
+ auto_growth_mb decimal(19,2) NULL,
+ max_size_mb decimal(19,2) NULL,
+ recovery_model_desc nvarchar(12) NULL,
+ compatibility_level integer NULL,
+ state_desc nvarchar(60) NULL,
+ volume_mount_point nvarchar(256) NULL,
+ volume_total_mb decimal(19,2) NULL,
+ volume_free_mb decimal(19,2) NULL,
+ /*Analysis helpers - computed columns*/
+ free_space_mb AS
+ (
+ total_size_mb - used_size_mb
+ ),
+ used_pct AS
+ (
+ used_size_mb * 100.0 /
+ NULLIF(total_size_mb, 0)
+ ),
+ CONSTRAINT
+ PK_database_size_stats
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.database_size_stats table';
+END;
+
+/*
+Server Properties Table (FinOps)
+*/
+IF OBJECT_ID(N'collect.server_properties', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.server_properties
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ server_name sysname NOT NULL,
+ edition sysname NOT NULL,
+ product_version sysname NOT NULL,
+ product_level sysname NOT NULL,
+ product_update_level sysname NULL,
+ engine_edition integer NOT NULL,
+ cpu_count integer NOT NULL,
+ hyperthread_ratio integer NOT NULL,
+ physical_memory_mb bigint NOT NULL,
+ socket_count integer NULL,
+ cores_per_socket integer NULL,
+ is_hadr_enabled bit NULL,
+ is_clustered bit NULL,
+ enterprise_features nvarchar(max) NULL,
+ service_objective sysname NULL,
+ row_hash binary(32) NULL,
+ CONSTRAINT
+ PK_server_properties
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.server_properties table';
+END;
+
PRINT 'All collection tables created successfully';
GO
diff --git a/install/03_create_config_tables.sql b/install/03_create_config_tables.sql
index 6fdb40d4..f81960ec 100644
--- a/install/03_create_config_tables.sql
+++ b/install/03_create_config_tables.sql
@@ -202,7 +202,9 @@ BEGIN
(N'plan_cache_stats_collector', 1, 60, 5, 30, N'Plan cache composition statistics - single-use plans and plan cache bloat detection'),
(N'session_stats_collector', 1, 5, 2, 30, N'Session and connection statistics - connection leaks and application patterns'),
(N'waiting_tasks_collector', 1, 5, 2, 30, N'Currently waiting tasks - blocking chains and wait analysis'),
- (N'running_jobs_collector', 1, 5, 2, 7, N'Currently running SQL Agent jobs with historical duration comparison');
+ (N'running_jobs_collector', 1, 5, 2, 7, N'Currently running SQL Agent jobs with historical duration comparison'),
+ (N'database_size_stats_collector', 1, 60, 10, 90, N'Database file sizes for growth trending and capacity planning'),
+ (N'server_properties_collector', 1, 1440, 5, 365, N'Server edition, licensing, CPU/memory hardware metadata for license audit');
/*
Stagger initial run times
diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql
index 024d693a..3fad7f29 100644
--- a/install/06_ensure_collection_table.sql
+++ b/install/06_ensure_collection_table.sql
@@ -265,10 +265,11 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Query text and execution plan*/
- query_text nvarchar(max) NULL,
- query_plan_text nvarchar(max) NULL,
- query_plan xml NULL,
+ /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_text varbinary(max) NULL,
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_query_stats
PRIMARY KEY CLUSTERED
@@ -446,9 +447,10 @@ BEGIN
total_worker_time_delta /
NULLIF(sample_interval_seconds, 0) / 1000.
),
- /*Execution plan*/
- query_plan_text nvarchar(max) NULL,
- query_plan xml NULL,
+ /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_procedure_stats
PRIMARY KEY CLUSTERED
@@ -491,7 +493,7 @@ BEGIN
server_first_execution_time datetime2(7) NOT NULL,
server_last_execution_time datetime2(7) NOT NULL,
module_name nvarchar(261) NULL,
- query_sql_text nvarchar(max) NULL,
+ query_sql_text varbinary(max) NULL,
query_hash binary(8) NULL,
/*Execution count*/
count_executions bigint NOT NULL,
@@ -549,9 +551,11 @@ BEGIN
last_force_failure_reason_desc nvarchar(128) NULL,
plan_forcing_type nvarchar(60) NULL,
compatibility_level smallint NULL,
- query_plan_text nvarchar(max) NULL,
- compilation_metrics xml NULL,
+ query_plan_text varbinary(max) NULL,
+ compilation_metrics varbinary(max) NULL,
query_plan_hash binary(8) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
CONSTRAINT
PK_query_store_data
PRIMARY KEY CLUSTERED
@@ -1084,10 +1088,85 @@ BEGIN
(DATA_COMPRESSION = PAGE)
);
+ END;
+ ELSE IF @table_name = N'database_size_stats'
+ BEGIN
+ CREATE TABLE
+ collect.database_size_stats
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ database_name sysname NOT NULL,
+ database_id integer NOT NULL,
+ file_id integer NOT NULL,
+ file_type_desc nvarchar(60) NOT NULL,
+ file_name sysname NOT NULL,
+ physical_name nvarchar(260) NOT NULL,
+ total_size_mb decimal(19,2) NOT NULL,
+ used_size_mb decimal(19,2) NULL,
+ auto_growth_mb decimal(19,2) NULL,
+ max_size_mb decimal(19,2) NULL,
+ recovery_model_desc nvarchar(12) NULL,
+ compatibility_level integer NULL,
+ state_desc nvarchar(60) NULL,
+ volume_mount_point nvarchar(256) NULL,
+ volume_total_mb decimal(19,2) NULL,
+ volume_free_mb decimal(19,2) NULL,
+ free_space_mb AS
+ (
+ total_size_mb - used_size_mb
+ ),
+ used_pct AS
+ (
+ used_size_mb * 100.0 /
+ NULLIF(total_size_mb, 0)
+ ),
+ CONSTRAINT
+ PK_database_size_stats
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ END;
+ ELSE IF @table_name = N'server_properties'
+ BEGIN
+ CREATE TABLE
+ collect.server_properties
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ server_name sysname NOT NULL,
+ edition sysname NOT NULL,
+ product_version sysname NOT NULL,
+ product_level sysname NOT NULL,
+ product_update_level sysname NULL,
+ engine_edition integer NOT NULL,
+ cpu_count integer NOT NULL,
+ hyperthread_ratio integer NOT NULL,
+ physical_memory_mb bigint NOT NULL,
+ socket_count integer NULL,
+ cores_per_socket integer NULL,
+ is_hadr_enabled bit NULL,
+ is_clustered bit NULL,
+ enterprise_features nvarchar(max) NULL,
+ service_objective sysname NULL,
+ row_hash binary(32) NULL,
+ CONSTRAINT
+ PK_server_properties
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
END;
ELSE
BEGIN
- SET @error_message = N'Unknown table name: ' + @table_name + N'. Valid table names are: wait_stats, query_stats, memory_stats, memory_pressure_events, deadlock_xml, blocked_process_xml, procedure_stats, query_snapshots, query_store_data, trace_analysis, default_trace_events, file_io_stats, memory_grant_stats, cpu_scheduler_stats, memory_clerks_stats, perfmon_stats, cpu_utilization_stats, blocking_deadlock_stats, latch_stats, spinlock_stats, tempdb_stats, plan_cache_stats, session_stats, waiting_tasks, running_jobs';
+ SET @error_message = N'Unknown table name: ' + @table_name + N'. Valid table names are: wait_stats, query_stats, memory_stats, memory_pressure_events, deadlock_xml, blocked_process_xml, procedure_stats, query_snapshots, query_store_data, trace_analysis, default_trace_events, file_io_stats, memory_grant_stats, cpu_scheduler_stats, memory_clerks_stats, perfmon_stats, cpu_utilization_stats, blocking_deadlock_stats, latch_stats, spinlock_stats, tempdb_stats, plan_cache_stats, session_stats, waiting_tasks, running_jobs, database_size_stats, server_properties';
RAISERROR(@error_message, 16, 1);
RETURN;
END;
diff --git a/install/08_collect_query_stats.sql b/install/08_collect_query_stats.sql
index b264867d..98ee8769 100644
--- a/install/08_collect_query_stats.sql
+++ b/install/08_collect_query_stats.sql
@@ -22,6 +22,8 @@ GO
Query performance collector
Collects query execution statistics from sys.dm_exec_query_stats
Captures min/max values for parameter sensitivity detection
+LOB columns are compressed with COMPRESS() before storage
+Unchanged rows are skipped via row_hash deduplication
*/
IF OBJECT_ID(N'collect.query_stats_collector', N'P') IS NULL
@@ -48,7 +50,9 @@ BEGIN
@last_collection_time datetime2(7),
@cutoff_time datetime2(7),
@frequency_minutes integer,
- @error_message nvarchar(4000);
+ @error_message nvarchar(4000),
+ @collect_query bit = 1,
+ @collect_plan bit = 1;
BEGIN TRY
BEGIN TRANSACTION;
@@ -106,6 +110,15 @@ BEGIN
END;
END;
+ /*
+ Read collection flags for optional query text and plan collection
+ */
+ SELECT
+ @collect_query = cs.collect_query,
+ @collect_plan = cs.collect_plan
+ FROM config.collection_schedule AS cs
+ WHERE cs.collector_name = N'query_stats_collector';
+
/*
First run detection - collect all queries if this is the first execution
*/
@@ -154,12 +167,63 @@ BEGIN
END;
/*
- Collect query statistics directly from DMV
- Only collects queries executed since last collection
- Excludes PerformanceMonitor and system databases (including 32761, 32767)
+ Stage 1: Collect query statistics into temp table
+ Temp table stays nvarchar(max) — COMPRESS happens at INSERT to permanent table
*/
+ CREATE TABLE
+ #query_stats_staging
+ (
+ server_start_time datetime2(7) NOT NULL,
+ database_name sysname NOT NULL,
+ sql_handle varbinary(64) NOT NULL,
+ statement_start_offset integer NOT NULL,
+ statement_end_offset integer NOT NULL,
+ plan_generation_num bigint NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ creation_time datetime2(7) NOT NULL,
+ last_execution_time datetime2(7) NOT NULL,
+ execution_count bigint NOT NULL,
+ total_worker_time bigint NOT NULL,
+ min_worker_time bigint NOT NULL,
+ max_worker_time bigint NOT NULL,
+ total_physical_reads bigint NOT NULL,
+ min_physical_reads bigint NOT NULL,
+ max_physical_reads bigint NOT NULL,
+ total_logical_writes bigint NOT NULL,
+ total_logical_reads bigint NOT NULL,
+ total_clr_time bigint NOT NULL,
+ total_elapsed_time bigint NOT NULL,
+ min_elapsed_time bigint NOT NULL,
+ max_elapsed_time bigint NOT NULL,
+ query_hash binary(8) NULL,
+ query_plan_hash binary(8) NULL,
+ total_rows bigint NOT NULL,
+ min_rows bigint NOT NULL,
+ max_rows bigint NOT NULL,
+ statement_sql_handle varbinary(64) NULL,
+ statement_context_id bigint NULL,
+ min_dop smallint NOT NULL,
+ max_dop smallint NOT NULL,
+ min_grant_kb bigint NOT NULL,
+ max_grant_kb bigint NOT NULL,
+ min_used_grant_kb bigint NOT NULL,
+ max_used_grant_kb bigint NOT NULL,
+ min_ideal_grant_kb bigint NOT NULL,
+ max_ideal_grant_kb bigint NOT NULL,
+ min_reserved_threads integer NOT NULL,
+ max_reserved_threads integer NOT NULL,
+ min_used_threads integer NOT NULL,
+ max_used_threads integer NOT NULL,
+ total_spills bigint NOT NULL,
+ min_spills bigint NOT NULL,
+ max_spills bigint NOT NULL,
+ query_text nvarchar(max) NULL,
+ query_plan_text nvarchar(max) NULL,
+ row_hash binary(32) NULL
+ );
+
INSERT INTO
- collect.query_stats
+ #query_stats_staging
(
server_start_time,
database_name,
@@ -255,6 +319,8 @@ BEGIN
max_spills = qs.max_spills,
query_text =
CASE
+ WHEN @collect_query = 0
+ THEN NULL
WHEN qs.statement_start_offset = 0
AND qs.statement_end_offset = -1
THEN st.text
@@ -272,7 +338,12 @@ BEGIN
) / 2 + 1
)
END,
- query_plan_text = tqp.query_plan
+ query_plan_text =
+ CASE
+ WHEN @collect_plan = 1
+ THEN tqp.query_plan
+ ELSE NULL
+ END
FROM sys.dm_exec_query_stats AS qs
OUTER APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
OUTER APPLY
@@ -284,7 +355,7 @@ BEGIN
) AS tqp
CROSS APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -301,8 +372,237 @@ BEGIN
AND pa.dbid < 32761 /*exclude contained AG system databases*/
OPTION(RECOMPILE);
+ /*
+ Stage 2: Compute row_hash on staging data
+ Hash of cumulative metric columns — changes when query executes
+ Binary concat: works on SQL 2016+, no CONCAT_WS dependency
+ */
+ UPDATE
+ #query_stats_staging
+ SET
+ row_hash =
+ HASHBYTES
+ (
+ 'SHA2_256',
+ CAST(execution_count AS binary(8)) +
+ CAST(total_worker_time AS binary(8)) +
+ CAST(total_elapsed_time AS binary(8)) +
+ CAST(total_logical_reads AS binary(8)) +
+ CAST(total_physical_reads AS binary(8)) +
+ CAST(total_logical_writes AS binary(8)) +
+ CAST(total_rows AS binary(8)) +
+ CAST(total_spills AS binary(8))
+ );
+
+ /*
+ Ensure tracking table exists
+ */
+ IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
+ BEGIN
+ CREATE TABLE
+ collect.query_stats_latest_hash
+ (
+ sql_handle varbinary(64) NOT NULL,
+ statement_start_offset integer NOT NULL,
+ statement_end_offset integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (sql_handle, statement_start_offset,
+ statement_end_offset, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+ END;
+
+ /*
+ Stage 3: INSERT only changed rows with COMPRESS on LOB columns
+ A row is "changed" if its natural key is new or its hash differs
+ */
+ INSERT INTO
+ collect.query_stats
+ (
+ server_start_time,
+ database_name,
+ sql_handle,
+ statement_start_offset,
+ statement_end_offset,
+ plan_generation_num,
+ plan_handle,
+ creation_time,
+ last_execution_time,
+ execution_count,
+ total_worker_time,
+ min_worker_time,
+ max_worker_time,
+ total_physical_reads,
+ min_physical_reads,
+ max_physical_reads,
+ total_logical_writes,
+ total_logical_reads,
+ total_clr_time,
+ total_elapsed_time,
+ min_elapsed_time,
+ max_elapsed_time,
+ query_hash,
+ query_plan_hash,
+ total_rows,
+ min_rows,
+ max_rows,
+ statement_sql_handle,
+ statement_context_id,
+ min_dop,
+ max_dop,
+ min_grant_kb,
+ max_grant_kb,
+ min_used_grant_kb,
+ max_used_grant_kb,
+ min_ideal_grant_kb,
+ max_ideal_grant_kb,
+ min_reserved_threads,
+ max_reserved_threads,
+ min_used_threads,
+ max_used_threads,
+ total_spills,
+ min_spills,
+ max_spills,
+ query_text,
+ query_plan_text,
+ row_hash
+ )
+ SELECT
+ s.server_start_time,
+ s.database_name,
+ s.sql_handle,
+ s.statement_start_offset,
+ s.statement_end_offset,
+ s.plan_generation_num,
+ s.plan_handle,
+ s.creation_time,
+ s.last_execution_time,
+ s.execution_count,
+ s.total_worker_time,
+ s.min_worker_time,
+ s.max_worker_time,
+ s.total_physical_reads,
+ s.min_physical_reads,
+ s.max_physical_reads,
+ s.total_logical_writes,
+ s.total_logical_reads,
+ s.total_clr_time,
+ s.total_elapsed_time,
+ s.min_elapsed_time,
+ s.max_elapsed_time,
+ s.query_hash,
+ s.query_plan_hash,
+ s.total_rows,
+ s.min_rows,
+ s.max_rows,
+ s.statement_sql_handle,
+ s.statement_context_id,
+ s.min_dop,
+ s.max_dop,
+ s.min_grant_kb,
+ s.max_grant_kb,
+ s.min_used_grant_kb,
+ s.max_used_grant_kb,
+ s.min_ideal_grant_kb,
+ s.max_ideal_grant_kb,
+ s.min_reserved_threads,
+ s.max_reserved_threads,
+ s.min_used_threads,
+ s.max_used_threads,
+ s.total_spills,
+ s.min_spills,
+ s.max_spills,
+ COMPRESS(s.query_text),
+ COMPRESS(s.query_plan_text),
+ s.row_hash
+ FROM #query_stats_staging AS s
+ LEFT JOIN collect.query_stats_latest_hash AS h
+ ON h.sql_handle = s.sql_handle
+ AND h.statement_start_offset = s.statement_start_offset
+ AND h.statement_end_offset = s.statement_end_offset
+ AND h.plan_handle = s.plan_handle
+ AND h.row_hash = s.row_hash
+ WHERE h.sql_handle IS NULL /*no match = new or changed*/
+ OPTION(RECOMPILE);
+
SET @rows_collected = ROWCOUNT_BIG();
+ /*
+ Stage 4: Update tracking table with current hashes
+ */
+ MERGE collect.query_stats_latest_hash AS t
+ USING
+ (
+ SELECT
+ sql_handle,
+ statement_start_offset,
+ statement_end_offset,
+ plan_handle,
+ row_hash
+ FROM
+ (
+ SELECT
+ s2.sql_handle,
+ s2.statement_start_offset,
+ s2.statement_end_offset,
+ s2.plan_handle,
+ s2.row_hash,
+ rn = ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ s2.sql_handle,
+ s2.statement_start_offset,
+ s2.statement_end_offset,
+ s2.plan_handle
+ ORDER BY
+ s2.last_execution_time DESC
+ )
+ FROM #query_stats_staging AS s2
+ ) AS ranked
+ WHERE ranked.rn = 1
+ ) AS s
+ ON t.sql_handle = s.sql_handle
+ AND t.statement_start_offset = s.statement_start_offset
+ AND t.statement_end_offset = s.statement_end_offset
+ AND t.plan_handle = s.plan_handle
+ WHEN MATCHED
+ THEN UPDATE SET
+ t.row_hash = s.row_hash,
+ t.last_seen = SYSDATETIME()
+ WHEN NOT MATCHED
+ THEN INSERT
+ (
+ sql_handle,
+ statement_start_offset,
+ statement_end_offset,
+ plan_handle,
+ row_hash,
+ last_seen
+ )
+ VALUES
+ (
+ s.sql_handle,
+ s.statement_start_offset,
+ s.statement_end_offset,
+ s.plan_handle,
+ s.row_hash,
+ SYSDATETIME()
+ );
+
+ IF @debug = 1
+ BEGIN
+ DECLARE @staging_count bigint;
+ SELECT @staging_count = COUNT_BIG(*) FROM #query_stats_staging;
+ RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
+ END;
+
/*
Calculate deltas for the newly inserted data
*/
@@ -371,5 +671,5 @@ GO
PRINT 'Query stats collector created successfully';
PRINT 'Collects queries executed since last collection from sys.dm_exec_query_stats';
-PRINT 'Includes min/max values for parameter sensitivity detection';
+PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
GO
diff --git a/install/09_collect_query_store.sql b/install/09_collect_query_store.sql
index 769ccfd4..df1643d8 100644
--- a/install/09_collect_query_store.sql
+++ b/install/09_collect_query_store.sql
@@ -274,7 +274,8 @@ BEGIN
compatibility_level smallint NULL,
query_plan_text nvarchar(max) NULL,
compilation_metrics xml NULL,
- query_plan_hash binary(8) NULL
+ query_plan_hash binary(8) NULL,
+ row_hash binary(32) NULL
);
/*
@@ -323,8 +324,7 @@ BEGIN
WHILE @@FETCH_STATUS = 0
BEGIN
BEGIN TRY
- SET @qs_check_sql =
- N'USE ' + QUOTENAME(@database_name) + N';
+ SET @qs_check_sql = N'
SELECT ' + QUOTENAME(@database_name, '''') + N'
WHERE EXISTS
(
@@ -334,8 +334,10 @@ BEGIN
WHERE actual_state > 0
);';
+ DECLARE @qs_exec_sp nvarchar(256) = QUOTENAME(@database_name) + N'.sys.sp_executesql';
+
INSERT @qs_databases (name)
- EXEC(@qs_check_sql);
+ EXECUTE @qs_exec_sp @qs_check_sql;
END TRY
BEGIN CATCH
END CATCH;
@@ -665,8 +667,53 @@ BEGIN
INTO @database_name;
END;
+ /*
+ Compute row_hash on staging data
+ Hash of metric columns that change between collection cycles
+ Binary concat: works on SQL 2016+, no CONCAT_WS dependency
+ */
+ UPDATE
+ #query_store_data
+ SET
+ row_hash =
+ HASHBYTES
+ (
+ 'SHA2_256',
+ CAST(count_executions AS binary(8)) +
+ CAST(avg_duration AS binary(8)) +
+ CAST(avg_cpu_time AS binary(8)) +
+ CAST(avg_logical_io_reads AS binary(8)) +
+ CAST(avg_logical_io_writes AS binary(8)) +
+ CAST(avg_physical_io_reads AS binary(8)) +
+ CAST(avg_rowcount AS binary(8))
+ );
+
+ /*
+ Ensure tracking table exists
+ */
+ IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
+ BEGIN
+ CREATE TABLE
+ collect.query_store_data_latest_hash
+ (
+ database_name sysname NOT NULL,
+ query_id bigint NOT NULL,
+ plan_id bigint NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_store_data_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, query_id, plan_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+ END;
+
/*
Insert collected data into the permanent table
+ COMPRESS on LOB columns, skip unchanged rows via hash comparison
*/
INSERT INTO
collect.query_store_data
@@ -726,7 +773,8 @@ BEGIN
compatibility_level,
query_plan_text,
compilation_metrics,
- query_plan_hash
+ query_plan_hash,
+ row_hash
)
SELECT
qsd.database_name,
@@ -738,7 +786,7 @@ BEGIN
qsd.server_first_execution_time,
qsd.server_last_execution_time,
qsd.module_name,
- qsd.query_sql_text,
+ COMPRESS(qsd.query_sql_text),
qsd.query_hash,
qsd.count_executions,
qsd.avg_duration,
@@ -782,14 +830,84 @@ BEGIN
qsd.last_force_failure_reason_desc,
qsd.plan_forcing_type,
qsd.compatibility_level,
- qsd.query_plan_text,
- qsd.compilation_metrics,
- qsd.query_plan_hash
+ COMPRESS(qsd.query_plan_text),
+ COMPRESS(CAST(qsd.compilation_metrics AS nvarchar(max))),
+ qsd.query_plan_hash,
+ qsd.row_hash
FROM #query_store_data AS qsd
+ LEFT JOIN collect.query_store_data_latest_hash AS h
+ ON h.database_name = qsd.database_name
+ AND h.query_id = qsd.query_id
+ AND h.plan_id = qsd.plan_id
+ AND h.row_hash = qsd.row_hash
+ WHERE h.database_name IS NULL /*no match = new or changed*/
OPTION(RECOMPILE, KEEPFIXED PLAN);
SET @rows_collected = ROWCOUNT_BIG();
+ /*
+ Update tracking table with current hashes
+ */
+ MERGE collect.query_store_data_latest_hash AS t
+ USING
+ (
+ SELECT
+ database_name,
+ query_id,
+ plan_id,
+ row_hash
+ FROM
+ (
+ SELECT
+ qsd.database_name,
+ qsd.query_id,
+ qsd.plan_id,
+ qsd.row_hash,
+ rn = ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ qsd.database_name,
+ qsd.query_id,
+ qsd.plan_id
+ ORDER BY
+ qsd.utc_last_execution_time DESC
+ )
+ FROM #query_store_data AS qsd
+ ) AS ranked
+ WHERE ranked.rn = 1
+ ) AS s
+ ON t.database_name = s.database_name
+ AND t.query_id = s.query_id
+ AND t.plan_id = s.plan_id
+ WHEN MATCHED
+ THEN UPDATE SET
+ t.row_hash = s.row_hash,
+ t.last_seen = SYSDATETIME()
+ WHEN NOT MATCHED
+ THEN INSERT
+ (
+ database_name,
+ query_id,
+ plan_id,
+ row_hash,
+ last_seen
+ )
+ VALUES
+ (
+ s.database_name,
+ s.query_id,
+ s.plan_id,
+ s.row_hash,
+ SYSDATETIME()
+ );
+
+ IF @debug = 1
+ BEGIN
+ DECLARE @staging_count bigint;
+ SELECT @staging_count = COUNT_BIG(*) FROM #query_store_data;
+ RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
+ END;
+
/*
Log successful collection
*/
@@ -848,4 +966,5 @@ GO
PRINT 'Query Store collector created successfully';
PRINT 'Collects comprehensive runtime statistics from all Query Store enabled databases';
+PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
GO
diff --git a/install/10_collect_procedure_stats.sql b/install/10_collect_procedure_stats.sql
index 7f8e4119..ad36e2f5 100644
--- a/install/10_collect_procedure_stats.sql
+++ b/install/10_collect_procedure_stats.sql
@@ -1,4 +1,4 @@
-/*
+/*
Copyright 2026 Darling Data, LLC
https://www.erikdarling.com/
@@ -20,9 +20,10 @@ GO
/*
Procedure, trigger, and function stats collector
-Collects execution statistics from sys.dm_exec_procedure_stats,
+Collects execution statistics from sys.dm_exec_procedure_stats,
sys.dm_exec_trigger_stats, and sys.dm_exec_function_stats
-Includes execution plans for performance analysis
+LOB columns are compressed with COMPRESS() before storage
+Unchanged rows are skipped via row_hash deduplication
*/
IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL
@@ -48,7 +49,9 @@ BEGIN
@server_start_time datetime2(7),
@last_collection_time datetime2(7) = NULL,
@frequency_minutes integer = NULL,
- @cutoff_time datetime2(7) = NULL;
+ @cutoff_time datetime2(7) = NULL,
+ @collect_query bit = 1,
+ @collect_plan bit = 1;
BEGIN TRY
BEGIN TRANSACTION;
@@ -106,6 +109,15 @@ BEGIN
END;
END;
+ /*
+ Read collection flags for optional plan collection
+ */
+ SELECT
+ @collect_query = cs.collect_query,
+ @collect_plan = cs.collect_plan
+ FROM config.collection_schedule AS cs
+ WHERE cs.collector_name = N'procedure_stats_collector';
+
/*
First run detection - collect all procedures if this is the first execution
*/
@@ -154,11 +166,48 @@ BEGIN
END;
/*
- Collect procedure, trigger, and function statistics
- Single query with UNION ALL to collect from all three DMVs
+ Stage 1: Collect procedure, trigger, and function statistics into temp table
+ Temp table stays nvarchar(max) — COMPRESS happens at INSERT to permanent table
*/
+ CREATE TABLE
+ #procedure_stats_staging
+ (
+ server_start_time datetime2(7) NOT NULL,
+ object_type nvarchar(20) NOT NULL,
+ database_name sysname NOT NULL,
+ object_id integer NOT NULL,
+ object_name sysname NULL,
+ schema_name sysname NULL,
+ type_desc nvarchar(60) NULL,
+ sql_handle varbinary(64) NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ cached_time datetime2(7) NOT NULL,
+ last_execution_time datetime2(7) NOT NULL,
+ execution_count bigint NOT NULL,
+ total_worker_time bigint NOT NULL,
+ min_worker_time bigint NOT NULL,
+ max_worker_time bigint NOT NULL,
+ total_elapsed_time bigint NOT NULL,
+ min_elapsed_time bigint NOT NULL,
+ max_elapsed_time bigint NOT NULL,
+ total_logical_reads bigint NOT NULL,
+ min_logical_reads bigint NOT NULL,
+ max_logical_reads bigint NOT NULL,
+ total_physical_reads bigint NOT NULL,
+ min_physical_reads bigint NOT NULL,
+ max_physical_reads bigint NOT NULL,
+ total_logical_writes bigint NOT NULL,
+ min_logical_writes bigint NOT NULL,
+ max_logical_writes bigint NOT NULL,
+ total_spills bigint NULL,
+ min_spills bigint NULL,
+ max_spills bigint NULL,
+ query_plan_text nvarchar(max) NULL,
+ row_hash binary(32) NULL
+ );
+
INSERT INTO
- collect.procedure_stats
+ #procedure_stats_staging
(
server_start_time,
object_type,
@@ -223,7 +272,12 @@ BEGIN
total_spills = ps.total_spills,
min_spills = ps.min_spills,
max_spills = ps.max_spills,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ query_plan_text =
+ CASE
+ WHEN @collect_plan = 1
+ THEN CONVERT(nvarchar(max), tqp.query_plan)
+ ELSE NULL
+ END
FROM sys.dm_exec_procedure_stats AS ps
OUTER APPLY
sys.dm_exec_text_query_plan
@@ -234,7 +288,7 @@ BEGIN
) AS tqp
OUTER APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(ps.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -386,7 +440,12 @@ BEGIN
total_spills = ts.total_spills,
min_spills = ts.min_spills,
max_spills = ts.max_spills,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ query_plan_text =
+ CASE
+ WHEN @collect_plan = 1
+ THEN CONVERT(nvarchar(max), tqp.query_plan)
+ ELSE NULL
+ END
FROM sys.dm_exec_trigger_stats AS ts
CROSS APPLY sys.dm_exec_sql_text(ts.sql_handle) AS st
OUTER APPLY
@@ -446,7 +505,12 @@ BEGIN
total_spills = NULL,
min_spills = NULL,
max_spills = NULL,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ query_plan_text =
+ CASE
+ WHEN @collect_plan = 1
+ THEN CONVERT(nvarchar(max), tqp.query_plan)
+ ELSE NULL
+ END
FROM sys.dm_exec_function_stats AS fs
OUTER APPLY
sys.dm_exec_text_query_plan
@@ -457,7 +521,7 @@ BEGIN
) AS tqp
OUTER APPLY
(
- SELECT
+ SELECT
dbid = CONVERT(integer, pa.value)
FROM sys.dm_exec_plan_attributes(fs.plan_handle) AS pa
WHERE pa.attribute = N'dbid'
@@ -473,9 +537,197 @@ BEGIN
)
AND pa.dbid < 32761 /*exclude contained AG system databases*/
OPTION(RECOMPILE);
-
+
+ /*
+ Stage 2: Compute row_hash on staging data
+ Hash of cumulative metric columns — changes when procedure executes
+ total_spills is nullable (functions don't have spills), use ISNULL
+ */
+ UPDATE
+ #procedure_stats_staging
+ SET
+ row_hash =
+ HASHBYTES
+ (
+ 'SHA2_256',
+ CAST(execution_count AS binary(8)) +
+ CAST(total_worker_time AS binary(8)) +
+ CAST(total_elapsed_time AS binary(8)) +
+ CAST(total_logical_reads AS binary(8)) +
+ CAST(total_physical_reads AS binary(8)) +
+ CAST(total_logical_writes AS binary(8)) +
+ ISNULL(CAST(total_spills AS binary(8)), 0x0000000000000000)
+ );
+
+ /*
+ Ensure tracking table exists
+ */
+ IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
+ BEGIN
+ CREATE TABLE
+ collect.procedure_stats_latest_hash
+ (
+ database_name sysname NOT NULL,
+ object_id integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_procedure_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, object_id, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+ END;
+
+ /*
+ Stage 3: INSERT only changed rows with COMPRESS on LOB columns
+ */
+ INSERT INTO
+ collect.procedure_stats
+ (
+ server_start_time,
+ object_type,
+ database_name,
+ object_id,
+ object_name,
+ schema_name,
+ type_desc,
+ sql_handle,
+ plan_handle,
+ cached_time,
+ last_execution_time,
+ execution_count,
+ total_worker_time,
+ min_worker_time,
+ max_worker_time,
+ total_elapsed_time,
+ min_elapsed_time,
+ max_elapsed_time,
+ total_logical_reads,
+ min_logical_reads,
+ max_logical_reads,
+ total_physical_reads,
+ min_physical_reads,
+ max_physical_reads,
+ total_logical_writes,
+ min_logical_writes,
+ max_logical_writes,
+ total_spills,
+ min_spills,
+ max_spills,
+ query_plan_text,
+ row_hash
+ )
+ SELECT
+ s.server_start_time,
+ s.object_type,
+ s.database_name,
+ s.object_id,
+ s.object_name,
+ s.schema_name,
+ s.type_desc,
+ s.sql_handle,
+ s.plan_handle,
+ s.cached_time,
+ s.last_execution_time,
+ s.execution_count,
+ s.total_worker_time,
+ s.min_worker_time,
+ s.max_worker_time,
+ s.total_elapsed_time,
+ s.min_elapsed_time,
+ s.max_elapsed_time,
+ s.total_logical_reads,
+ s.min_logical_reads,
+ s.max_logical_reads,
+ s.total_physical_reads,
+ s.min_physical_reads,
+ s.max_physical_reads,
+ s.total_logical_writes,
+ s.min_logical_writes,
+ s.max_logical_writes,
+ s.total_spills,
+ s.min_spills,
+ s.max_spills,
+ COMPRESS(s.query_plan_text),
+ s.row_hash
+ FROM #procedure_stats_staging AS s
+ LEFT JOIN collect.procedure_stats_latest_hash AS h
+ ON h.database_name = s.database_name
+ AND h.object_id = s.object_id
+ AND h.plan_handle = s.plan_handle
+ AND h.row_hash = s.row_hash
+ WHERE h.database_name IS NULL /*no match = new or changed*/
+ OPTION(RECOMPILE);
+
SET @rows_collected = ROWCOUNT_BIG();
-
+
+ /*
+ Stage 4: Update tracking table with current hashes
+ */
+ MERGE collect.procedure_stats_latest_hash AS t
+ USING
+ (
+ SELECT
+ database_name,
+ object_id,
+ plan_handle,
+ row_hash
+ FROM
+ (
+ SELECT
+ s2.database_name,
+ s2.object_id,
+ s2.plan_handle,
+ s2.row_hash,
+ rn = ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ s2.database_name,
+ s2.object_id,
+ s2.plan_handle
+ ORDER BY
+ s2.last_execution_time DESC
+ )
+ FROM #procedure_stats_staging AS s2
+ ) AS ranked
+ WHERE ranked.rn = 1
+ ) AS s
+ ON t.database_name = s.database_name
+ AND t.object_id = s.object_id
+ AND t.plan_handle = s.plan_handle
+ WHEN MATCHED
+ THEN UPDATE SET
+ t.row_hash = s.row_hash,
+ t.last_seen = SYSDATETIME()
+ WHEN NOT MATCHED
+ THEN INSERT
+ (
+ database_name,
+ object_id,
+ plan_handle,
+ row_hash,
+ last_seen
+ )
+ VALUES
+ (
+ s.database_name,
+ s.object_id,
+ s.plan_handle,
+ s.row_hash,
+ SYSDATETIME()
+ );
+
+ IF @debug = 1
+ BEGIN
+ DECLARE @staging_count bigint;
+ SELECT @staging_count = COUNT_BIG(*) FROM #procedure_stats_staging;
+ RAISERROR(N'Staged %I64d rows, inserted %I64d changed rows', 0, 1, @staging_count, @rows_collected) WITH NOWAIT;
+ END;
+
/*
Calculate deltas for the newly inserted data
*/
@@ -483,7 +735,7 @@ BEGIN
@table_name = N'procedure_stats',
@debug = @debug;
- /*Tie statement sto procedures when possible*/
+ /*Tie statements to procedures when possible*/
UPDATE
qs
SET
@@ -499,7 +751,6 @@ BEGIN
AND qs.object_name IS NULL
OPTION(RECOMPILE);
-
/*
Log successful collection
*/
@@ -518,24 +769,24 @@ BEGIN
@rows_collected,
DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
);
-
+
IF @debug = 1
BEGIN
RAISERROR(N'Collected %d procedure/trigger/function stats rows', 0, 1, @rows_collected) WITH NOWAIT;
END;
-
+
COMMIT TRANSACTION;
-
+
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
BEGIN
ROLLBACK TRANSACTION;
END;
-
+
DECLARE
@error_message nvarchar(4000) = ERROR_MESSAGE();
-
+
/*
Log the error
*/
@@ -554,11 +805,12 @@ BEGIN
DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
@error_message
);
-
+
RAISERROR(N'Error in procedure stats collector: %s', 16, 1, @error_message);
END CATCH;
END;
GO
PRINT 'Procedure stats collector created successfully';
+PRINT 'LOB columns compressed with COMPRESS(), unchanged rows skipped via row_hash';
GO
diff --git a/install/37_collect_waiting_tasks.sql b/install/37_collect_waiting_tasks.sql
index 34b0ecfc..e7564d89 100644
--- a/install/37_collect_waiting_tasks.sql
+++ b/install/37_collect_waiting_tasks.sql
@@ -163,7 +163,8 @@ BEGIN
LEFT JOIN sys.dm_exec_requests AS der
ON der.session_id = wt.session_id
LEFT JOIN sys.databases AS d
- ON d.database_id = der.database_id
+ ON d.database_id = der.database_id
+ AND d.state = 0 /*ONLINE only — skip RESTORING databases (mirroring/AG secondary)*/
OUTER APPLY sys.dm_exec_sql_text(der.sql_handle) AS dest
OUTER APPLY sys.dm_exec_text_query_plan
(
diff --git a/install/41_schedule_management.sql b/install/41_schedule_management.sql
index 80799b43..46c93b8a 100644
--- a/install/41_schedule_management.sql
+++ b/install/41_schedule_management.sql
@@ -48,7 +48,7 @@ BEGIN
DECLARE
@rows_updated bigint = 0;
-
+
BEGIN TRY
UPDATE
config.collection_schedule
@@ -57,24 +57,24 @@ BEGIN
enabled = ISNULL(@enabled, enabled),
max_duration_minutes = ISNULL(@max_duration_minutes, max_duration_minutes),
modified_date = SYSDATETIME(),
- next_run_time =
- CASE
- WHEN ISNULL(@enabled, enabled) = 1
- THEN SYSDATETIME()
- ELSE next_run_time
+ next_run_time =
+ CASE
+ WHEN ISNULL(@enabled, enabled) = 1
+ THEN SYSDATETIME()
+ ELSE next_run_time
END
WHERE collector_name = @collector_name;
-
+
SET @rows_updated = ROWCOUNT_BIG();
-
+
IF @rows_updated = 0
BEGIN
RAISERROR(N'Collector "%s" not found in schedule', 16, 1, @collector_name);
RETURN;
END;
-
+
PRINT 'Updated ' + @collector_name + ' frequency to ' + CONVERT(varchar(10), @frequency_minutes) + ' minutes';
-
+
END TRY
BEGIN CATCH
DECLARE @error_message nvarchar(4000) = ERROR_MESSAGE();
@@ -106,32 +106,32 @@ BEGIN
DECLARE
@rows_updated bigint = 0;
-
+
BEGIN TRY
UPDATE
config.collection_schedule
SET
enabled = @enabled,
modified_date = SYSDATETIME(),
- next_run_time =
- CASE
- WHEN @enabled = 1
- THEN SYSDATETIME()
- ELSE NULL
+ next_run_time =
+ CASE
+ WHEN @enabled = 1
+ THEN SYSDATETIME()
+ ELSE NULL
END
WHERE collector_name = @collector_name;
-
+
SET @rows_updated = ROWCOUNT_BIG();
-
+
IF @rows_updated = 0
BEGIN
RAISERROR(N'Collector "%s" not found in schedule', 16, 1, @collector_name);
RETURN;
END;
-
- PRINT @collector_name + ' collector ' +
+
+ PRINT @collector_name + ' collector ' +
CASE WHEN @enabled = 1 THEN 'enabled' ELSE 'disabled' END;
-
+
END TRY
BEGIN CATCH
DECLARE @error_message nvarchar(4000) = ERROR_MESSAGE();
@@ -141,157 +141,235 @@ END;
GO
/*
-Set up real-time monitoring profile (frequent collection)
+Apply a named collection preset
+Changes all scheduled collector frequencies in one operation.
+Does not modify enabled/disabled state or daily/on-load collectors.
+
+Valid preset names: Aggressive, Balanced, Low-Impact
*/
-IF OBJECT_ID(N'config.enable_realtime_monitoring', N'P') IS NULL
+IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL
BEGIN
- EXECUTE(N'CREATE PROCEDURE config.enable_realtime_monitoring AS RETURN 138;');
+ EXECUTE(N'CREATE PROCEDURE config.apply_collection_preset AS RETURN 138;');
END;
GO
ALTER PROCEDURE
- config.enable_realtime_monitoring
+ config.apply_collection_preset
+(
+ @preset_name sysname,
+ @debug bit = 0
+)
WITH RECOMPILE
AS
BEGIN
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ DECLARE
+ @rows_updated bigint = 0;
+
BEGIN TRY
- /*High frequency for real-time dashboard*/
- EXECUTE config.update_collector_frequency N'query_snapshots_collector', 1, 1;
- EXECUTE config.update_collector_frequency N'wait_stats_collector', 1, 1;
- EXECUTE config.update_collector_frequency N'query_stats_collector', 1, 1;
- EXECUTE config.update_collector_frequency N'procedure_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'query_store_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'blocked_process_xml_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'cpu_utilization_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'memory_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'perfmon_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'file_io_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'deadlock_xml_collector', 5, 1;
-
- /*Medium frequency for context*/
- EXECUTE config.update_collector_frequency N'memory_grant_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'cpu_scheduler_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'memory_clerks_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'latch_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'spinlock_stats_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'default_trace_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'system_health_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_pressure_events_collector', 2, 1;
- EXECUTE config.update_collector_frequency N'plan_cache_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'blocking_deadlock_analyzer', 2, 1;
- EXECUTE config.update_collector_frequency N'process_blocked_process_xml', 2, 1;
- EXECUTE config.update_collector_frequency N'process_deadlock_xml', 2, 1;
- EXECUTE config.update_collector_frequency N'trace_analysis_collector', 5, 1;
-
- PRINT 'Real-time monitoring profile enabled';
- PRINT 'Query/procedure stats every 1-2 minutes, everything else 2-5 minutes';
-
- END TRY
- BEGIN CATCH
- DECLARE @error_message nvarchar(4000) = ERROR_MESSAGE();
- RAISERROR(N'Error enabling real-time monitoring: %s', 16, 1, @error_message);
- END CATCH;
-END;
-GO
+ IF @preset_name NOT IN (N'Aggressive', N'Balanced', N'Low-Impact')
+ BEGIN
+ RAISERROR(N'Invalid preset name "%s". Valid presets: Aggressive, Balanced, Low-Impact', 16, 1, @preset_name);
+ RETURN;
+ END;
-/*
-Set up consulting analysis profile (balanced collection during business hours)
-*/
-IF OBJECT_ID(N'config.enable_consulting_analysis', N'P') IS NULL
-BEGIN
- EXECUTE(N'CREATE PROCEDURE config.enable_consulting_analysis AS RETURN 138;');
-END;
-GO
+ DECLARE
+ @preset TABLE
+ (
+ collector_name sysname NOT NULL,
+ frequency_minutes integer NOT NULL
+ );
-ALTER PROCEDURE
- config.enable_consulting_analysis
-WITH RECOMPILE
-AS
-BEGIN
- SET NOCOUNT ON;
- SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ IF @preset_name = N'Aggressive'
+ BEGIN
+ INSERT INTO
+ @preset
+ (
+ collector_name,
+ frequency_minutes
+ )
+ VALUES
+ (N'wait_stats_collector', 1),
+ (N'query_stats_collector', 1),
+ (N'memory_stats_collector', 1),
+ (N'memory_pressure_events_collector', 1),
+ (N'system_health_collector', 2),
+ (N'blocked_process_xml_collector', 1),
+ (N'deadlock_xml_collector', 1),
+ (N'process_blocked_process_xml', 2),
+ (N'blocking_deadlock_analyzer', 2),
+ (N'process_deadlock_xml', 2),
+ (N'query_store_collector', 2),
+ (N'procedure_stats_collector', 1),
+ (N'query_snapshots_collector', 1),
+ (N'file_io_stats_collector', 1),
+ (N'memory_grant_stats_collector', 1),
+ (N'cpu_scheduler_stats_collector', 1),
+ (N'memory_clerks_stats_collector', 2),
+ (N'perfmon_stats_collector', 1),
+ (N'cpu_utilization_stats_collector', 1),
+ (N'trace_analysis_collector', 1),
+ (N'default_trace_collector', 2),
+ (N'configuration_issues_analyzer', 1),
+ (N'latch_stats_collector', 1),
+ (N'spinlock_stats_collector', 1),
+ (N'tempdb_stats_collector', 1),
+ (N'plan_cache_stats_collector', 2),
+ (N'session_stats_collector', 1),
+ (N'waiting_tasks_collector', 1),
+ (N'running_jobs_collector', 2);
+ END;
+
+ IF @preset_name = N'Balanced'
+ BEGIN
+ INSERT INTO
+ @preset
+ (
+ collector_name,
+ frequency_minutes
+ )
+ VALUES
+ (N'wait_stats_collector', 1),
+ (N'query_stats_collector', 2),
+ (N'memory_stats_collector', 1),
+ (N'memory_pressure_events_collector', 1),
+ (N'system_health_collector', 5),
+ (N'blocked_process_xml_collector', 1),
+ (N'deadlock_xml_collector', 1),
+ (N'process_blocked_process_xml', 5),
+ (N'blocking_deadlock_analyzer', 5),
+ (N'process_deadlock_xml', 5),
+ (N'query_store_collector', 2),
+ (N'procedure_stats_collector', 2),
+ (N'query_snapshots_collector', 1),
+ (N'file_io_stats_collector', 1),
+ (N'memory_grant_stats_collector', 1),
+ (N'cpu_scheduler_stats_collector', 1),
+ (N'memory_clerks_stats_collector', 5),
+ (N'perfmon_stats_collector', 5),
+ (N'cpu_utilization_stats_collector', 1),
+ (N'trace_analysis_collector', 2),
+ (N'default_trace_collector', 5),
+ (N'configuration_issues_analyzer', 1),
+ (N'latch_stats_collector', 1),
+ (N'spinlock_stats_collector', 1),
+ (N'tempdb_stats_collector', 1),
+ (N'plan_cache_stats_collector', 5),
+ (N'session_stats_collector', 1),
+ (N'waiting_tasks_collector', 1),
+ (N'running_jobs_collector', 1);
+ END;
+
+ IF @preset_name = N'Low-Impact'
+ BEGIN
+ INSERT INTO
+ @preset
+ (
+ collector_name,
+ frequency_minutes
+ )
+ VALUES
+ (N'wait_stats_collector', 5),
+ (N'query_stats_collector', 10),
+ (N'memory_stats_collector', 10),
+ (N'memory_pressure_events_collector', 5),
+ (N'system_health_collector', 15),
+ (N'blocked_process_xml_collector', 5),
+ (N'deadlock_xml_collector', 5),
+ (N'process_blocked_process_xml', 10),
+ (N'blocking_deadlock_analyzer', 10),
+ (N'process_deadlock_xml', 10),
+ (N'query_store_collector', 30),
+ (N'procedure_stats_collector', 10),
+ (N'query_snapshots_collector', 5),
+ (N'file_io_stats_collector', 10),
+ (N'memory_grant_stats_collector', 5),
+ (N'cpu_scheduler_stats_collector', 5),
+ (N'memory_clerks_stats_collector', 30),
+ (N'perfmon_stats_collector', 5),
+ (N'cpu_utilization_stats_collector', 5),
+ (N'trace_analysis_collector', 10),
+ (N'default_trace_collector', 15),
+ (N'configuration_issues_analyzer', 5),
+ (N'latch_stats_collector', 5),
+ (N'spinlock_stats_collector', 5),
+ (N'tempdb_stats_collector', 5),
+ (N'plan_cache_stats_collector', 15),
+ (N'session_stats_collector', 5),
+ (N'waiting_tasks_collector', 5),
+ (N'running_jobs_collector', 30);
+ END;
+
+ /*
+ Apply the preset to all matching collectors.
+ Only updates frequency - does not change enabled/disabled state.
+ Skips daily/on-load collectors not in the preset.
+ */
+ UPDATE
+ cs
+ SET
+ cs.frequency_minutes = p.frequency_minutes,
+ cs.next_run_time =
+ CASE
+ WHEN cs.enabled = 1
+ THEN SYSDATETIME()
+ ELSE cs.next_run_time
+ END,
+ cs.modified_date = SYSDATETIME()
+ FROM config.collection_schedule AS cs
+ JOIN @preset AS p
+ ON p.collector_name = cs.collector_name;
+
+ SET @rows_updated = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Applied "%s" preset to %I64d collectors', 0, 1, @preset_name, @rows_updated) WITH NOWAIT;
+
+ SELECT
+ cs.collector_name,
+ cs.enabled,
+ cs.frequency_minutes,
+ cs.next_run_time,
+ cs.description
+ FROM config.collection_schedule AS cs
+ WHERE cs.frequency_minutes < 1440
+ ORDER BY
+ cs.collector_name;
+ END;
+
+ PRINT 'Applied "' + @preset_name + '" collection preset (' + CONVERT(varchar(10), @rows_updated) + ' collectors updated)';
- BEGIN TRY
- /*Balanced frequencies for consulting work*/
- EXECUTE config.update_collector_frequency N'query_snapshots_collector', 1, 1;
- EXECUTE config.update_collector_frequency N'wait_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'query_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'procedure_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'query_store_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'cpu_utilization_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'blocked_process_xml_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'perfmon_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'file_io_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_grant_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'cpu_scheduler_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_clerks_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'deadlock_xml_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'latch_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'spinlock_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'default_trace_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'system_health_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_pressure_events_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'plan_cache_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'blocking_deadlock_analyzer', 5, 1;
- EXECUTE config.update_collector_frequency N'process_blocked_process_xml', 5, 1;
- EXECUTE config.update_collector_frequency N'process_deadlock_xml', 5, 1;
- EXECUTE config.update_collector_frequency N'trace_analysis_collector', 5, 1;
-
- PRINT 'Consulting analysis profile enabled';
- PRINT 'Balanced collection frequencies for comprehensive analysis';
-
END TRY
BEGIN CATCH
- DECLARE @error_message nvarchar(4000) = ERROR_MESSAGE();
- RAISERROR(N'Error enabling consulting analysis: %s', 16, 1, @error_message);
+ DECLARE
+ @error_message nvarchar(4000) = ERROR_MESSAGE();
+
+ RAISERROR(N'Error applying collection preset: %s', 16, 1, @error_message);
END CATCH;
END;
GO
/*
-Set up baseline monitoring profile (minimal resource usage)
+Drop legacy profile procedures replaced by config.apply_collection_preset
*/
-IF OBJECT_ID(N'config.enable_baseline_monitoring', N'P') IS NULL
+IF OBJECT_ID(N'config.enable_realtime_monitoring', N'P') IS NOT NULL
BEGIN
- EXECUTE(N'CREATE PROCEDURE config.enable_baseline_monitoring AS RETURN 138;');
+ DROP PROCEDURE config.enable_realtime_monitoring;
END;
GO
-ALTER PROCEDURE
- config.enable_baseline_monitoring
-WITH RECOMPILE
-AS
+IF OBJECT_ID(N'config.enable_consulting_analysis', N'P') IS NOT NULL
BEGIN
- SET NOCOUNT ON;
- SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ DROP PROCEDURE config.enable_consulting_analysis;
+END;
+GO
- BEGIN TRY
- /*Baseline: max 5 minutes for everything*/
- EXECUTE config.update_collector_frequency N'wait_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'query_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'procedure_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'query_store_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'memory_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'cpu_utilization_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'system_health_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'plan_cache_stats_collector', 5, 1;
- EXECUTE config.update_collector_frequency N'blocking_deadlock_analyzer', 5, 1;
-
- /*Disable high-frequency collectors*/
- EXECUTE config.set_collector_enabled N'query_snapshots_collector', 0;
-
- PRINT 'Baseline monitoring profile enabled';
- PRINT 'All collectors at 5-minute intervals, snapshots disabled';
-
- END TRY
- BEGIN CATCH
- DECLARE @error_message nvarchar(4000) = ERROR_MESSAGE();
- RAISERROR(N'Error enabling baseline monitoring: %s', 16, 1, @error_message);
- END CATCH;
+IF OBJECT_ID(N'config.enable_baseline_monitoring', N'P') IS NOT NULL
+BEGIN
+ DROP PROCEDURE config.enable_baseline_monitoring;
END;
GO
@@ -317,11 +395,11 @@ BEGIN
enabled,
frequency_minutes,
next_run_time,
- minutes_until_next_run =
- CASE
- WHEN enabled = 1 AND next_run_time IS NOT NULL
+ minutes_until_next_run =
+ CASE
+ WHEN enabled = 1 AND next_run_time IS NOT NULL
THEN DATEDIFF(MINUTE, SYSDATETIME(), next_run_time)
- ELSE NULL
+ ELSE NULL
END,
last_run_time,
max_duration_minutes,
@@ -338,8 +416,11 @@ PRINT '';
PRINT 'Available procedures:';
PRINT '- config.update_collector_frequency - Change frequency for specific collector';
PRINT '- config.set_collector_enabled - Enable/disable specific collector';
-PRINT '- config.enable_realtime_monitoring - High frequency for dashboards';
-PRINT '- config.enable_consulting_analysis - Balanced for analysis work';
-PRINT '- config.enable_baseline_monitoring - Minimal overhead monitoring';
+PRINT '- config.apply_collection_preset - Apply a named preset (Aggressive, Balanced, Low-Impact)';
PRINT '- config.show_collection_schedule - Display current schedule';
+PRINT '';
+PRINT 'Examples:';
+PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Aggressive'', @debug = 1;';
+PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Balanced'';';
+PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Low-Impact'';';
GO
diff --git a/install/42_scheduled_master_collector.sql b/install/42_scheduled_master_collector.sql
index a192a90e..565b6592 100644
--- a/install/42_scheduled_master_collector.sql
+++ b/install/42_scheduled_master_collector.sql
@@ -315,6 +315,14 @@ BEGIN
BEGIN
EXECUTE collect.running_jobs_collector @debug = @debug;
END;
+ ELSE IF @collector_name = N'database_size_stats_collector'
+ BEGIN
+ EXECUTE collect.database_size_stats_collector @debug = @debug;
+ END;
+ ELSE IF @collector_name = N'server_properties_collector'
+ BEGIN
+ EXECUTE collect.server_properties_collector @debug = @debug;
+ END;
ELSE
BEGIN
RAISERROR(N'Unknown collector: %s', 11, 1, @collector_name);
diff --git a/install/43_data_retention.sql b/install/43_data_retention.sql
index dc47775c..09fa8666 100644
--- a/install/43_data_retention.sql
+++ b/install/43_data_retention.sql
@@ -78,6 +78,55 @@ BEGIN
RAISERROR(N'Starting data retention: keeping data newer than %s', 0, 1, @retention_date_string) WITH NOWAIT;
END;
+ /*
+ Purge processed XE staging rows early.
+ After parsers set is_processed = 1 the raw XML is never read again.
+ Keep a 1-day grace period for re-parsing failures.
+ */
+ DECLARE
+ @staging_deleted bigint = 0;
+
+ IF OBJECT_ID(N'collect.deadlock_xml', N'U') IS NOT NULL
+ AND EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.columns AS c
+ WHERE c.object_id = OBJECT_ID(N'collect.deadlock_xml')
+ AND c.name = N'is_processed'
+ )
+ BEGIN
+ DELETE FROM collect.deadlock_xml
+ WHERE is_processed = 1
+ AND collection_time < DATEADD(DAY, -1, SYSDATETIME());
+
+ SET @staging_deleted += ROWCOUNT_BIG();
+ END;
+
+ IF OBJECT_ID(N'collect.blocked_process_xml', N'U') IS NOT NULL
+ AND EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.columns AS c
+ WHERE c.object_id = OBJECT_ID(N'collect.blocked_process_xml')
+ AND c.name = N'is_processed'
+ )
+ BEGIN
+ DELETE FROM collect.blocked_process_xml
+ WHERE is_processed = 1
+ AND collection_time < DATEADD(DAY, -1, SYSDATETIME());
+
+ SET @staging_deleted += ROWCOUNT_BIG();
+ END;
+
+ IF @debug = 1 AND @staging_deleted > 0
+ BEGIN
+ RAISERROR(N'Purged %I64d processed XE staging rows (older than 1 day)', 0, 1, @staging_deleted) WITH NOWAIT;
+ END;
+
+ SET @total_deleted += @staging_deleted;
+
/*
Create temp table to hold list of tables to clean
*/
diff --git a/install/46_create_query_plan_views.sql b/install/46_create_query_plan_views.sql
index 29646c40..c6ad5d9f 100644
--- a/install/46_create_query_plan_views.sql
+++ b/install/46_create_query_plan_views.sql
@@ -30,12 +30,12 @@ CREATE OR ALTER VIEW
report.query_stats_with_formatted_plans
AS
SELECT
- *,
+ qs.*,
query_plan_formatted =
CASE
- WHEN TRY_CAST(qs.query_plan_text AS xml) IS NOT NULL
- THEN TRY_CAST(qs.query_plan_text AS xml)
- WHEN TRY_CAST(qs.query_plan_text AS xml) IS NULL
+ WHEN TRY_CAST(d.plan_text AS xml) IS NOT NULL
+ THEN TRY_CAST(d.plan_text AS xml)
+ WHEN TRY_CAST(d.plan_text AS xml) IS NULL
THEN
(
SELECT
@@ -44,14 +44,19 @@ SELECT
N'-- This is a huge query plan.' + NCHAR(13) + NCHAR(10) +
N'-- Remove the headers and footers, save it as a .sqlplan file, and re-open it.' + NCHAR(13) + NCHAR(10) +
NCHAR(13) + NCHAR(10) +
- REPLACE(qs.query_plan_text, N'= DATEADD(DAY, -7, SYSDATETIME())
diff --git a/install/52_collect_database_size_stats.sql b/install/52_collect_database_size_stats.sql
new file mode 100644
index 00000000..d65158d2
--- /dev/null
+++ b/install/52_collect_database_size_stats.sql
@@ -0,0 +1,371 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+*/
+
+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
+
+/*******************************************************************************
+Collector: database_size_stats_collector
+Purpose: Captures per-file database sizes for growth trending and capacity
+ planning. Collects total allocated size and used space per file.
+Collection Type: Point-in-time snapshot (no deltas)
+Target Table: collect.database_size_stats
+Frequency: Every 60 minutes
+Dependencies: sys.master_files, sys.databases, sys.dm_db_file_space_used
+Notes: Uses cursor with dynamic SQL for cross-database used space collection.
+ Azure SQL DB uses sys.database_files (single database scope).
+*******************************************************************************/
+
+IF OBJECT_ID(N'collect.database_size_stats_collector', N'P') IS NULL
+BEGIN
+ EXECUTE(N'CREATE PROCEDURE collect.database_size_stats_collector AS RETURN 138;');
+END;
+GO
+
+ALTER PROCEDURE
+ collect.database_size_stats_collector
+(
+ @debug bit = 0 /*Print debugging information*/
+)
+WITH RECOMPILE
+AS
+BEGIN
+ SET NOCOUNT ON;
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ DECLARE
+ @rows_collected bigint = 0,
+ @start_time datetime2(7) = SYSDATETIME(),
+ @error_message nvarchar(4000),
+ @engine_edition integer =
+ CONVERT(integer, SERVERPROPERTY(N'EngineEdition'));
+
+ BEGIN TRY
+ /*
+ Ensure target table exists
+ */
+ IF OBJECT_ID(N'collect.database_size_stats', N'U') IS NULL
+ BEGIN
+ INSERT INTO
+ config.collection_log
+ (
+ collection_time,
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ @start_time,
+ N'database_size_stats_collector',
+ N'TABLE_MISSING',
+ 0,
+ 0,
+ N'Table collect.database_size_stats does not exist, calling ensure procedure'
+ );
+
+ EXECUTE config.ensure_collection_table
+ @table_name = N'database_size_stats',
+ @debug = @debug;
+
+ IF OBJECT_ID(N'collect.database_size_stats', N'U') IS NULL
+ BEGIN
+ RAISERROR(N'Table collect.database_size_stats still missing after ensure procedure', 16, 1);
+ RETURN;
+ END;
+ END;
+
+ /*
+ Azure SQL DB: single database scope
+ */
+ IF @engine_edition = 5
+ BEGIN
+ INSERT INTO
+ collect.database_size_stats
+ (
+ collection_time,
+ database_name,
+ database_id,
+ file_id,
+ file_type_desc,
+ file_name,
+ physical_name,
+ total_size_mb,
+ used_size_mb,
+ auto_growth_mb,
+ max_size_mb,
+ recovery_model_desc,
+ compatibility_level,
+ state_desc,
+ volume_mount_point,
+ volume_total_mb,
+ volume_free_mb
+ )
+ SELECT
+ collection_time = @start_time,
+ database_name = DB_NAME(),
+ database_id = DB_ID(),
+ file_id = df.file_id,
+ file_type_desc = df.type_desc,
+ file_name = df.name,
+ physical_name = df.physical_name,
+ total_size_mb =
+ CONVERT(decimal(19,2), df.size * 8.0 / 1024.0),
+ used_size_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0
+ ),
+ auto_growth_mb =
+ CASE
+ WHEN df.is_percent_growth = 1
+ THEN NULL
+ ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0)
+ END,
+ max_size_mb =
+ CASE
+ WHEN df.max_size = -1
+ THEN CONVERT(decimal(19,2), -1)
+ WHEN df.max_size = 268435456
+ THEN CONVERT(decimal(19,2), 2097152) /*2 TB*/
+ ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0)
+ END,
+ recovery_model_desc =
+ CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')),
+ compatibility_level = NULL,
+ state_desc = N'ONLINE',
+ volume_mount_point = NULL,
+ volume_total_mb = NULL,
+ volume_free_mb = NULL
+ FROM sys.database_files AS df
+ OPTION(RECOMPILE);
+
+ SET @rows_collected = ROWCOUNT_BIG();
+ END;
+ ELSE
+ BEGIN
+ /*
+ On-prem / Azure MI / AWS RDS: cursor over all online databases
+ Collect file sizes from sys.master_files and used space via
+ dynamic SQL executing FILEPROPERTY in each database context
+ */
+ DECLARE
+ @db_name sysname,
+ @db_id integer,
+ @sql nvarchar(max);
+
+ DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
+ SELECT
+ d.name,
+ d.database_id
+ FROM sys.databases AS d
+ WHERE d.state_desc = N'ONLINE'
+ AND d.database_id > 0
+ AND HAS_DBACCESS(d.name) = 1
+ ORDER BY
+ d.database_id;
+
+ OPEN db_cursor;
+ FETCH NEXT FROM db_cursor INTO @db_name, @db_id;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ SET @sql = N'
+ INSERT INTO
+ PerformanceMonitor.collect.database_size_stats
+ (
+ collection_time,
+ database_name,
+ database_id,
+ file_id,
+ file_type_desc,
+ file_name,
+ physical_name,
+ total_size_mb,
+ used_size_mb,
+ auto_growth_mb,
+ max_size_mb,
+ recovery_model_desc,
+ compatibility_level,
+ state_desc,
+ volume_mount_point,
+ volume_total_mb,
+ volume_free_mb
+ )
+ SELECT
+ collection_time = @start_time,
+ database_name = DB_NAME(),
+ database_id = DB_ID(),
+ file_id = df.file_id,
+ file_type_desc = df.type_desc,
+ file_name = df.name,
+ physical_name = df.physical_name,
+ total_size_mb =
+ CONVERT(decimal(19,2), df.size * 8.0 / 1024.0),
+ used_size_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ FILEPROPERTY(df.name, N''SpaceUsed'') * 8.0 / 1024.0
+ ),
+ auto_growth_mb =
+ CASE
+ WHEN df.is_percent_growth = 1
+ THEN NULL
+ ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0)
+ END,
+ max_size_mb =
+ CASE
+ WHEN df.max_size = -1
+ THEN CONVERT(decimal(19,2), -1)
+ WHEN df.max_size = 268435456
+ THEN CONVERT(decimal(19,2), 2097152)
+ ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0)
+ END,
+ recovery_model_desc = d.recovery_model_desc,
+ compatibility_level = d.compatibility_level,
+ state_desc = d.state_desc,
+ volume_mount_point =
+ RTRIM(vs.volume_mount_point),
+ volume_total_mb =
+ CONVERT(decimal(19,2), vs.total_bytes / 1048576.0),
+ volume_free_mb =
+ CONVERT(decimal(19,2), vs.available_bytes / 1048576.0)
+ FROM sys.database_files AS df
+ CROSS JOIN sys.databases AS d
+ CROSS APPLY sys.dm_os_volume_stats(DB_ID(), df.file_id) AS vs
+ WHERE d.database_id = DB_ID();';
+
+ DECLARE @exec_sql nvarchar(max) = QUOTENAME(@db_name) + N'.sys.sp_executesql';
+
+ EXECUTE @exec_sql
+ @sql,
+ N'@start_time datetime2(7)',
+ @start_time = @start_time;
+
+ SET @rows_collected = @rows_collected + ROWCOUNT_BIG();
+ END TRY
+ BEGIN CATCH
+ /*
+ Log per-database errors but continue with remaining databases
+ */
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Error collecting size stats for database [%s]: %s', 0, 1, @db_name, @error_message) WITH NOWAIT;
+ END;
+ END CATCH;
+
+ FETCH NEXT FROM db_cursor INTO @db_name, @db_id;
+ END;
+
+ CLOSE db_cursor;
+ DEALLOCATE db_cursor;
+ END;
+
+ /*
+ Debug output
+ */
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Collected %d database size rows', 0, 1, @rows_collected) WITH NOWAIT;
+
+ SELECT TOP (20)
+ dss.database_name,
+ dss.file_type_desc,
+ dss.file_name,
+ dss.total_size_mb,
+ dss.used_size_mb,
+ dss.free_space_mb,
+ dss.used_pct,
+ dss.volume_mount_point,
+ dss.volume_total_mb,
+ dss.volume_free_mb
+ FROM collect.database_size_stats AS dss
+ WHERE dss.collection_time = @start_time
+ ORDER BY
+ dss.total_size_mb DESC;
+ END;
+
+ /*
+ Log successful collection
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms
+ )
+ VALUES
+ (
+ N'database_size_stats_collector',
+ N'SUCCESS',
+ @rows_collected,
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
+ );
+
+ END TRY
+ BEGIN CATCH
+ IF @@TRANCOUNT > 0
+ BEGIN
+ ROLLBACK TRANSACTION;
+ END;
+
+ /*
+ Clean up cursor if open
+ */
+ IF CURSOR_STATUS(N'local', N'db_cursor') >= 0
+ BEGIN
+ CLOSE db_cursor;
+ DEALLOCATE db_cursor;
+ END;
+
+ SET @error_message = ERROR_MESSAGE();
+
+ /*
+ Log the error
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'database_size_stats_collector',
+ N'ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ @error_message
+ );
+
+ RAISERROR(N'Error in database size stats collector: %s', 16, 1, @error_message);
+ END CATCH;
+END;
+GO
+
+PRINT 'Database size stats collector created successfully';
+PRINT 'Captures per-file database sizes for growth trending and capacity planning';
+PRINT 'Use: EXECUTE collect.database_size_stats_collector @debug = 1;';
+GO
diff --git a/install/53_collect_server_properties.sql b/install/53_collect_server_properties.sql
new file mode 100644
index 00000000..6bdce6fb
--- /dev/null
+++ b/install/53_collect_server_properties.sql
@@ -0,0 +1,405 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+*/
+
+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
+
+/*******************************************************************************
+Collector: server_properties_collector
+Purpose: Captures server edition, version, CPU/memory hardware metadata, and
+ Enterprise feature usage for license audit and FinOps cost attribution.
+Collection Type: Deduplication snapshot (skip if unchanged)
+Target Table: collect.server_properties
+Frequency: Daily (1440 minutes)
+Dependencies: SERVERPROPERTY, sys.dm_os_sys_info, sys.dm_db_persisted_sku_features
+Notes: Enterprise features enumeration gated by DMV existence.
+ Uses FOR XML PATH for SQL 2016, STRING_AGG for 2017+.
+ Azure SQL DB uses DATABASEPROPERTYEX for service objective.
+*******************************************************************************/
+
+IF OBJECT_ID(N'collect.server_properties_collector', N'P') IS NULL
+BEGIN
+ EXECUTE(N'CREATE PROCEDURE collect.server_properties_collector AS RETURN 138;');
+END;
+GO
+
+ALTER PROCEDURE
+ collect.server_properties_collector
+(
+ @debug bit = 0 /*Print debugging information*/
+)
+WITH RECOMPILE
+AS
+BEGIN
+ SET NOCOUNT ON;
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ DECLARE
+ @rows_collected bigint = 0,
+ @start_time datetime2(7) = SYSDATETIME(),
+ @error_message nvarchar(4000),
+ @engine_edition integer =
+ CONVERT(integer, SERVERPROPERTY(N'EngineEdition')),
+ @major_version integer;
+
+ /*
+ Parse major version for feature gating
+ */
+ SET @major_version =
+ CONVERT
+ (
+ integer,
+ PARSENAME
+ (
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')),
+ 4
+ )
+ );
+
+ BEGIN TRY
+ /*
+ Ensure target table exists
+ */
+ IF OBJECT_ID(N'collect.server_properties', N'U') IS NULL
+ BEGIN
+ INSERT INTO
+ config.collection_log
+ (
+ collection_time,
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ @start_time,
+ N'server_properties_collector',
+ N'TABLE_MISSING',
+ 0,
+ 0,
+ N'Table collect.server_properties does not exist, calling ensure procedure'
+ );
+
+ EXECUTE config.ensure_collection_table
+ @table_name = N'server_properties',
+ @debug = @debug;
+
+ IF OBJECT_ID(N'collect.server_properties', N'U') IS NULL
+ BEGIN
+ RAISERROR(N'Table collect.server_properties still missing after ensure procedure', 16, 1);
+ RETURN;
+ END;
+ END;
+
+ /*
+ Collect enterprise features in use across databases
+ sys.dm_db_persisted_sku_features lists Enterprise features per database
+ Not available on Azure SQL DB (engine edition 5)
+ */
+ DECLARE
+ @enterprise_features nvarchar(max) = NULL;
+
+ IF @engine_edition <> 5
+ AND OBJECT_ID(N'sys.dm_db_persisted_sku_features', N'V') IS NOT NULL
+ BEGIN
+ CREATE TABLE
+ #sku_features
+ (
+ database_name sysname NOT NULL,
+ feature_name sysname NOT NULL
+ );
+
+ DECLARE
+ @db_name sysname,
+ @sql nvarchar(max);
+
+ DECLARE sku_cursor CURSOR LOCAL FAST_FORWARD FOR
+ SELECT
+ d.name
+ FROM sys.databases AS d
+ WHERE d.state_desc = N'ONLINE'
+ AND d.database_id > 4 /*Skip system databases*/
+ ORDER BY
+ d.database_id;
+
+ OPEN sku_cursor;
+ FETCH NEXT FROM sku_cursor INTO @db_name;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ SET @sql = N'
+ SELECT
+ database_name = ' + QUOTENAME(@db_name, N'''') + N',
+ feature_name = f.feature_name
+ FROM ' + QUOTENAME(@db_name) + N'.sys.dm_db_persisted_sku_features AS f;';
+
+ INSERT INTO #sku_features
+ (
+ database_name,
+ feature_name
+ )
+ EXECUTE sys.sp_executesql @sql;
+ END TRY
+ BEGIN CATCH
+ /*Skip databases we cannot query*/
+ IF @debug = 1
+ BEGIN
+ DECLARE @sku_err nvarchar(4000) = ERROR_MESSAGE();
+ RAISERROR(N'SKU features error for [%s]: %s', 0, 1, @db_name, @sku_err) WITH NOWAIT;
+ END;
+ END CATCH;
+
+ FETCH NEXT FROM sku_cursor INTO @db_name;
+ END;
+
+ CLOSE sku_cursor;
+ DEALLOCATE sku_cursor;
+
+ /*
+ Aggregate features into comma-delimited string
+ Format: "DatabaseName: Feature1, Feature2; DatabaseName2: Feature3"
+ Use FOR XML PATH (works on SQL 2016+)
+ */
+ SELECT
+ @enterprise_features =
+ STUFF
+ (
+ (
+ SELECT
+ N'; ' + sf.database_name + N': ' + sf.feature_name
+ FROM #sku_features AS sf
+ ORDER BY
+ sf.database_name,
+ sf.feature_name
+ FOR XML PATH(N''), TYPE
+ ).value(N'.', N'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ );
+
+ DROP TABLE #sku_features;
+ END;
+
+ /*
+ Deduplication: check if anything changed since last collection
+ */
+ DECLARE
+ @current_hash binary(32),
+ @last_hash binary(32);
+
+ SELECT
+ @current_hash =
+ HASHBYTES
+ (
+ N'SHA2_256',
+ CONCAT
+ (
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition')), N'|',
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')), N'|',
+ CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductLevel')), N'|',
+ @engine_edition, N'|',
+ (SELECT osi.cpu_count FROM sys.dm_os_sys_info AS osi), N'|',
+ (SELECT osi.physical_memory_kb FROM sys.dm_os_sys_info AS osi), N'|',
+ ISNULL(@enterprise_features, N'')
+ )
+ );
+
+ SELECT TOP (1)
+ @last_hash = sp.row_hash
+ FROM collect.server_properties AS sp
+ ORDER BY
+ sp.collection_time DESC;
+
+ IF @current_hash = @last_hash
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Server properties unchanged since last collection, skipping', 0, 1) WITH NOWAIT;
+ END;
+
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'server_properties_collector',
+ N'SKIPPED',
+ 0,
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'Properties unchanged since last collection'
+ );
+
+ RETURN;
+ END;
+
+ /*
+ Insert new row
+ */
+ INSERT INTO
+ collect.server_properties
+ (
+ collection_time,
+ server_name,
+ edition,
+ product_version,
+ product_level,
+ product_update_level,
+ engine_edition,
+ cpu_count,
+ hyperthread_ratio,
+ physical_memory_mb,
+ socket_count,
+ cores_per_socket,
+ is_hadr_enabled,
+ is_clustered,
+ enterprise_features,
+ service_objective,
+ row_hash
+ )
+ SELECT
+ collection_time = @start_time,
+ server_name =
+ CONVERT(sysname, SERVERPROPERTY(N'ServerName')),
+ edition =
+ CONVERT(sysname, SERVERPROPERTY(N'Edition')),
+ product_version =
+ CONVERT(sysname, SERVERPROPERTY(N'ProductVersion')),
+ product_level =
+ CONVERT(sysname, SERVERPROPERTY(N'ProductLevel')),
+ product_update_level =
+ CONVERT(sysname, SERVERPROPERTY(N'ProductUpdateLevel')),
+ engine_edition = @engine_edition,
+ cpu_count = osi.cpu_count,
+ hyperthread_ratio = osi.hyperthread_ratio,
+ physical_memory_mb =
+ osi.physical_memory_kb / 1024,
+ socket_count = osi.socket_count,
+ cores_per_socket = osi.cores_per_socket,
+ is_hadr_enabled =
+ CONVERT(bit, SERVERPROPERTY(N'IsHadrEnabled')),
+ is_clustered =
+ CONVERT(bit, SERVERPROPERTY(N'IsClustered')),
+ enterprise_features = @enterprise_features,
+ service_objective =
+ CASE
+ WHEN @engine_edition = 5
+ THEN CONVERT(sysname, DATABASEPROPERTYEX(DB_NAME(), N'ServiceObjective'))
+ ELSE NULL
+ END,
+ row_hash = @current_hash
+ FROM sys.dm_os_sys_info AS osi
+ OPTION(RECOMPILE);
+
+ SET @rows_collected = ROWCOUNT_BIG();
+
+ /*
+ Debug output
+ */
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Collected %d server properties row(s)', 0, 1, @rows_collected) WITH NOWAIT;
+
+ SELECT TOP (1)
+ sp.server_name,
+ sp.edition,
+ sp.product_version,
+ sp.cpu_count,
+ sp.hyperthread_ratio,
+ sp.physical_memory_mb,
+ sp.socket_count,
+ sp.cores_per_socket,
+ sp.enterprise_features,
+ sp.service_objective
+ FROM collect.server_properties AS sp
+ WHERE sp.collection_time = @start_time;
+ END;
+
+ /*
+ Log successful collection
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms
+ )
+ VALUES
+ (
+ N'server_properties_collector',
+ N'SUCCESS',
+ @rows_collected,
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
+ );
+
+ END TRY
+ BEGIN CATCH
+ IF @@TRANCOUNT > 0
+ BEGIN
+ ROLLBACK TRANSACTION;
+ END;
+
+ /*
+ Clean up cursor if open
+ */
+ IF CURSOR_STATUS(N'local', N'sku_cursor') >= 0
+ BEGIN
+ CLOSE sku_cursor;
+ DEALLOCATE sku_cursor;
+ END;
+
+ SET @error_message = ERROR_MESSAGE();
+
+ /*
+ Log the error
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'server_properties_collector',
+ N'ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ @error_message
+ );
+
+ RAISERROR(N'Error in server properties collector: %s', 16, 1, @error_message);
+ END CATCH;
+END;
+GO
+
+PRINT 'Server properties collector created successfully';
+PRINT 'Captures edition, version, CPU/memory hardware, and Enterprise feature usage';
+PRINT 'Use: EXECUTE collect.server_properties_collector @debug = 1;';
+GO
diff --git a/install/54_create_finops_views.sql b/install/54_create_finops_views.sql
new file mode 100644
index 00000000..b452eabe
--- /dev/null
+++ b/install/54_create_finops_views.sql
@@ -0,0 +1,419 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+FinOps Reporting Views
+Provides cost allocation, utilization scoring, peak analysis,
+and application attribution from existing collected data.
+*/
+
+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
+
+/*******************************************************************************
+View 1: Per-Database Resource Usage
+Shows CPU time, logical reads, execution counts, and I/O per database
+for cost allocation and showback reporting.
+Source: collect.query_stats, collect.procedure_stats, collect.file_io_stats
+*******************************************************************************/
+
+CREATE OR ALTER VIEW
+ report.finops_database_resource_usage
+AS
+WITH
+ /*
+ Combine query and procedure stats deltas by database
+ Filter to last 24 hours with valid deltas
+ */
+ workload_stats AS
+ (
+ SELECT
+ database_name = qs.database_name,
+ cpu_time_ms =
+ SUM(qs.total_worker_time_delta) / 1000,
+ logical_reads =
+ SUM(qs.total_logical_reads_delta),
+ physical_reads =
+ SUM(qs.total_physical_reads_delta),
+ logical_writes =
+ SUM(qs.total_logical_writes_delta),
+ execution_count =
+ SUM(qs.execution_count_delta)
+ FROM collect.query_stats AS qs
+ WHERE qs.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ AND qs.total_worker_time_delta IS NOT NULL
+ GROUP BY
+ qs.database_name
+ ),
+ /*
+ File I/O deltas by database
+ */
+ io_stats AS
+ (
+ SELECT
+ database_name = fio.database_name,
+ io_read_bytes =
+ SUM(fio.num_of_bytes_read_delta),
+ io_write_bytes =
+ SUM(fio.num_of_bytes_written_delta),
+ io_stall_ms =
+ SUM(fio.io_stall_ms_delta)
+ FROM collect.file_io_stats AS fio
+ WHERE fio.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ AND fio.num_of_bytes_read_delta IS NOT NULL
+ GROUP BY
+ fio.database_name
+ ),
+ /*
+ Server-wide totals for percentage calculations
+ */
+ totals AS
+ (
+ SELECT
+ total_cpu_ms =
+ NULLIF(SUM(ws.cpu_time_ms), 0),
+ total_io_bytes =
+ NULLIF
+ (
+ SUM(ios.io_read_bytes) +
+ SUM(ios.io_write_bytes),
+ 0
+ )
+ FROM workload_stats AS ws
+ FULL JOIN io_stats AS ios
+ ON ios.database_name = ws.database_name
+ )
+SELECT
+ database_name =
+ COALESCE(ws.database_name, ios.database_name),
+ cpu_time_ms =
+ ISNULL(ws.cpu_time_ms, 0),
+ logical_reads =
+ ISNULL(ws.logical_reads, 0),
+ physical_reads =
+ ISNULL(ws.physical_reads, 0),
+ logical_writes =
+ ISNULL(ws.logical_writes, 0),
+ execution_count =
+ ISNULL(ws.execution_count, 0),
+ io_read_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ ISNULL(ios.io_read_bytes, 0) / 1048576.0
+ ),
+ io_write_mb =
+ CONVERT
+ (
+ decimal(19,2),
+ ISNULL(ios.io_write_bytes, 0) / 1048576.0
+ ),
+ io_stall_ms =
+ ISNULL(ios.io_stall_ms, 0),
+ pct_cpu_share =
+ CONVERT
+ (
+ decimal(5,2),
+ ISNULL(ws.cpu_time_ms, 0) * 100.0 /
+ t.total_cpu_ms
+ ),
+ pct_io_share =
+ CONVERT
+ (
+ decimal(5,2),
+ (ISNULL(ios.io_read_bytes, 0) + ISNULL(ios.io_write_bytes, 0)) * 100.0 /
+ t.total_io_bytes
+ )
+FROM workload_stats AS ws
+FULL JOIN io_stats AS ios
+ ON ios.database_name = ws.database_name
+CROSS JOIN totals AS t;
+GO
+
+PRINT 'Created report.finops_database_resource_usage view';
+GO
+
+/*******************************************************************************
+View 2: Utilization Efficiency Score
+Calculates whether the server is over-provisioned, right-sized, or
+under-provisioned based on CPU, memory, and worker thread utilization.
+Source: collect.cpu_utilization_stats, collect.memory_stats,
+ collect.cpu_scheduler_stats
+*******************************************************************************/
+
+CREATE OR ALTER VIEW
+ report.finops_utilization_efficiency
+AS
+WITH
+ /*
+ CPU p95 via window function (must be separate from aggregates)
+ */
+ cpu_p95 AS
+ (
+ SELECT
+ p95_cpu_pct =
+ CONVERT
+ (
+ decimal(5,2),
+ PERCENTILE_CONT(0.95) WITHIN GROUP
+ (
+ ORDER BY
+ cus.sqlserver_cpu_utilization
+ ) OVER ()
+ )
+ FROM collect.cpu_utilization_stats AS cus
+ WHERE cus.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ ),
+ /*
+ CPU aggregates
+ */
+ cpu_agg AS
+ (
+ SELECT
+ avg_cpu_pct =
+ AVG(CONVERT(decimal(5,2), cus.sqlserver_cpu_utilization)),
+ max_cpu_pct =
+ MAX(cus.sqlserver_cpu_utilization),
+ sample_count =
+ COUNT_BIG(*)
+ FROM collect.cpu_utilization_stats AS cus
+ WHERE cus.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+ ),
+ /*
+ Combine CPU stats
+ */
+ cpu_dedup AS
+ (
+ SELECT
+ ca.avg_cpu_pct,
+ ca.max_cpu_pct,
+ p95_cpu_pct =
+ (SELECT TOP (1) cp.p95_cpu_pct FROM cpu_p95 AS cp),
+ ca.sample_count
+ FROM cpu_agg AS ca
+ ),
+ /*
+ Latest memory stats
+ */
+ memory_latest AS
+ (
+ SELECT TOP (1)
+ ms.total_memory_mb,
+ ms.committed_target_memory_mb,
+ ms.total_physical_memory_mb,
+ ms.buffer_pool_mb,
+ ms.memory_utilization_percentage,
+ memory_ratio =
+ CONVERT
+ (
+ decimal(5,2),
+ ms.total_memory_mb /
+ NULLIF(ms.committed_target_memory_mb, 0)
+ )
+ FROM collect.memory_stats AS ms
+ ORDER BY
+ ms.collection_time DESC
+ ),
+ /*
+ Latest scheduler stats
+ */
+ scheduler_latest AS
+ (
+ SELECT TOP (1)
+ ss.total_current_workers_count,
+ ss.max_workers_count,
+ ss.cpu_count,
+ worker_ratio =
+ CONVERT
+ (
+ decimal(5,2),
+ ss.total_current_workers_count * 1.0 /
+ NULLIF(ss.max_workers_count, 0)
+ )
+ FROM collect.cpu_scheduler_stats AS ss
+ ORDER BY
+ ss.collection_time DESC
+ )
+SELECT
+ avg_cpu_pct =
+ cd.avg_cpu_pct,
+ max_cpu_pct =
+ cd.max_cpu_pct,
+ p95_cpu_pct =
+ cd.p95_cpu_pct,
+ cpu_samples =
+ cd.sample_count,
+ total_memory_mb =
+ ml.total_memory_mb,
+ target_memory_mb =
+ ml.committed_target_memory_mb,
+ physical_memory_mb =
+ ml.total_physical_memory_mb,
+ memory_ratio =
+ ml.memory_ratio,
+ memory_utilization_pct =
+ ml.memory_utilization_percentage,
+ worker_threads_current =
+ sl.total_current_workers_count,
+ worker_threads_max =
+ sl.max_workers_count,
+ worker_thread_ratio =
+ sl.worker_ratio,
+ cpu_count =
+ sl.cpu_count,
+ provisioning_status =
+ CASE
+ WHEN cd.avg_cpu_pct < 15
+ AND cd.max_cpu_pct < 40
+ AND ml.memory_ratio < 0.5
+ THEN N'OVER_PROVISIONED'
+ WHEN cd.p95_cpu_pct > 85
+ OR ml.memory_ratio > 0.95
+ OR sl.worker_ratio > 0.8
+ THEN N'UNDER_PROVISIONED'
+ ELSE N'RIGHT_SIZED'
+ END
+FROM cpu_dedup AS cd
+CROSS JOIN memory_latest AS ml
+CROSS JOIN scheduler_latest AS sl;
+GO
+
+PRINT 'Created report.finops_utilization_efficiency view';
+GO
+
+/*******************************************************************************
+View 3: Peak Utilization Windows
+Shows average and maximum CPU/memory utilization per hour of day (0-23)
+to identify peak and idle windows for capacity planning.
+Source: collect.cpu_utilization_stats, collect.memory_stats (last 7 days)
+*******************************************************************************/
+
+CREATE OR ALTER VIEW
+ report.finops_peak_utilization
+AS
+WITH
+ /*
+ CPU utilization bucketed by hour of day
+ */
+ cpu_by_hour AS
+ (
+ SELECT
+ hour_of_day =
+ DATEPART(HOUR, cus.collection_time),
+ avg_cpu_pct =
+ AVG(CONVERT(decimal(5,2), cus.sqlserver_cpu_utilization)),
+ max_cpu_pct =
+ MAX(cus.sqlserver_cpu_utilization),
+ sample_count =
+ COUNT_BIG(*)
+ FROM collect.cpu_utilization_stats AS cus
+ WHERE cus.collection_time >= DATEADD(DAY, -7, SYSDATETIME())
+ GROUP BY
+ DATEPART(HOUR, cus.collection_time)
+ ),
+ /*
+ Memory utilization bucketed by hour of day
+ */
+ memory_by_hour AS
+ (
+ SELECT
+ hour_of_day =
+ DATEPART(HOUR, ms.collection_time),
+ avg_memory_pct =
+ AVG(CONVERT(decimal(5,2), ms.memory_utilization_percentage)),
+ max_memory_pct =
+ MAX(ms.memory_utilization_percentage)
+ FROM collect.memory_stats AS ms
+ WHERE ms.collection_time >= DATEADD(DAY, -7, SYSDATETIME())
+ GROUP BY
+ DATEPART(HOUR, ms.collection_time)
+ ),
+ /*
+ Overall averages for classification
+ */
+ overall AS
+ (
+ SELECT
+ overall_avg_cpu =
+ NULLIF(AVG(cbh.avg_cpu_pct), 0)
+ FROM cpu_by_hour AS cbh
+ )
+SELECT
+ hour_of_day =
+ cbh.hour_of_day,
+ avg_cpu_pct =
+ cbh.avg_cpu_pct,
+ max_cpu_pct =
+ cbh.max_cpu_pct,
+ avg_memory_pct =
+ ISNULL(mbh.avg_memory_pct, 0),
+ max_memory_pct =
+ ISNULL(mbh.max_memory_pct, 0),
+ cpu_samples =
+ cbh.sample_count,
+ hour_classification =
+ CASE
+ WHEN cbh.avg_cpu_pct > (o.overall_avg_cpu * 1.5)
+ THEN N'PEAK'
+ WHEN cbh.avg_cpu_pct < (o.overall_avg_cpu * 0.3)
+ THEN N'IDLE'
+ ELSE N'NORMAL'
+ END
+FROM cpu_by_hour AS cbh
+LEFT JOIN memory_by_hour AS mbh
+ ON mbh.hour_of_day = cbh.hour_of_day
+CROSS JOIN overall AS o;
+GO
+
+PRINT 'Created report.finops_peak_utilization view';
+GO
+
+/*******************************************************************************
+View 4: Application Resource Usage (Connection-Level Attribution)
+Shows per-application connection patterns from session stats.
+Note: Plan cache (query_stats/procedure_stats) does not capture program_name.
+Full CPU/reads per application would require Resource Governor or Query Store.
+Source: collect.session_stats (last 24 hours)
+*******************************************************************************/
+
+CREATE OR ALTER VIEW
+ report.finops_application_resource_usage
+AS
+SELECT
+ application_name =
+ ss.top_application_name,
+ avg_connections =
+ AVG(ss.top_application_connections),
+ max_connections =
+ MAX(ss.top_application_connections),
+ sample_count =
+ COUNT_BIG(*),
+ first_seen =
+ MIN(ss.collection_time),
+ last_seen =
+ MAX(ss.collection_time)
+FROM collect.session_stats AS ss
+WHERE ss.collection_time >= DATEADD(HOUR, -24, SYSDATETIME())
+AND ss.top_application_name IS NOT NULL
+GROUP BY
+ ss.top_application_name;
+GO
+
+PRINT 'Created report.finops_application_resource_usage view';
+GO
+
+PRINT 'FinOps reporting views created successfully';
+PRINT 'Views: report.finops_database_resource_usage, report.finops_utilization_efficiency,';
+PRINT ' report.finops_peak_utilization, report.finops_application_resource_usage';
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql b/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql
new file mode 100644
index 00000000..b64ea271
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/01_compress_query_stats.sql
@@ -0,0 +1,386 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.1.0 to 2.2.0
+Migrates collect.query_stats to compressed LOB storage:
+ - query_text nvarchar(max) -> varbinary(max) via COMPRESS()
+ - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
+ - Drops unused query_plan xml column (never populated by collectors)
+ - Adds row_hash binary(32) for deduplication
+*/
+
+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
+
+/*
+Skip if already migrated (query_text is already varbinary)
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.columns
+ WHERE object_id = OBJECT_ID(N'collect.query_stats')
+ AND name = N'query_text'
+ AND system_type_id = 165 /*varbinary*/
+)
+BEGIN
+ PRINT 'collect.query_stats already migrated to compressed storage — skipping.';
+ RETURN;
+END;
+GO
+
+/*
+Skip if source table doesn't exist
+*/
+IF OBJECT_ID(N'collect.query_stats', N'U') IS NULL
+BEGIN
+ PRINT 'collect.query_stats does not exist — skipping.';
+ RETURN;
+END;
+GO
+
+PRINT '=== Migrating collect.query_stats to compressed LOB storage ===';
+PRINT '';
+GO
+
+BEGIN TRY
+
+ /*
+ Step 1: Create the _new table with compressed column types
+ */
+ IF OBJECT_ID(N'collect.query_stats_new', N'U') IS NOT NULL
+ BEGIN
+ DROP TABLE collect.query_stats_new;
+ PRINT 'Dropped existing collect.query_stats_new';
+ END;
+
+ CREATE TABLE
+ collect.query_stats_new
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ server_start_time datetime2(7) NOT NULL,
+ object_type nvarchar(20) NOT NULL
+ DEFAULT N'STATEMENT',
+ database_name sysname NOT NULL,
+ object_name sysname NULL,
+ schema_name sysname NULL,
+ sql_handle varbinary(64) NOT NULL,
+ statement_start_offset integer NOT NULL,
+ statement_end_offset integer NOT NULL,
+ plan_generation_num bigint NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ creation_time datetime2(7) NOT NULL,
+ last_execution_time datetime2(7) NOT NULL,
+ /*Raw cumulative values*/
+ execution_count bigint NOT NULL,
+ total_worker_time bigint NOT NULL,
+ min_worker_time bigint NOT NULL,
+ max_worker_time bigint NOT NULL,
+ total_physical_reads bigint NOT NULL,
+ min_physical_reads bigint NOT NULL,
+ max_physical_reads bigint NOT NULL,
+ total_logical_writes bigint NOT NULL,
+ total_logical_reads bigint NOT NULL,
+ total_clr_time bigint NOT NULL,
+ total_elapsed_time bigint NOT NULL,
+ min_elapsed_time bigint NOT NULL,
+ max_elapsed_time bigint NOT NULL,
+ query_hash binary(8) NULL,
+ query_plan_hash binary(8) NULL,
+ total_rows bigint NOT NULL,
+ min_rows bigint NOT NULL,
+ max_rows bigint NOT NULL,
+ statement_sql_handle varbinary(64) NULL,
+ statement_context_id bigint NULL,
+ min_dop smallint NOT NULL,
+ max_dop smallint NOT NULL,
+ min_grant_kb bigint NOT NULL,
+ max_grant_kb bigint NOT NULL,
+ min_used_grant_kb bigint NOT NULL,
+ max_used_grant_kb bigint NOT NULL,
+ min_ideal_grant_kb bigint NOT NULL,
+ max_ideal_grant_kb bigint NOT NULL,
+ min_reserved_threads integer NOT NULL,
+ max_reserved_threads integer NOT NULL,
+ min_used_threads integer NOT NULL,
+ max_used_threads integer NOT NULL,
+ total_spills bigint NOT NULL,
+ min_spills bigint NOT NULL,
+ max_spills bigint NOT NULL,
+ /*Delta calculations*/
+ execution_count_delta bigint NULL,
+ total_worker_time_delta bigint NULL,
+ total_elapsed_time_delta bigint NULL,
+ total_logical_reads_delta bigint NULL,
+ total_physical_reads_delta bigint NULL,
+ total_logical_writes_delta bigint NULL,
+ sample_interval_seconds integer NULL,
+ /*Analysis helpers - computed columns*/
+ avg_rows AS
+ (
+ total_rows /
+ NULLIF(execution_count, 0)
+ ),
+ avg_worker_time_ms AS
+ (
+ total_worker_time /
+ NULLIF(execution_count, 0) / 1000.
+ ),
+ avg_elapsed_time_ms AS
+ (
+ total_elapsed_time /
+ NULLIF(execution_count, 0) / 1000.
+ ),
+ avg_physical_reads AS
+ (
+ total_physical_reads /
+ NULLIF(execution_count, 0)
+ ),
+ worker_time_per_second AS
+ (
+ total_worker_time_delta /
+ NULLIF(sample_interval_seconds, 0) / 1000.
+ ),
+ /*Query text and execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_text varbinary(max) NULL,
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
+ CONSTRAINT
+ PK_query_stats_new
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_stats_new';
+
+ /*
+ Step 2: Reseed IDENTITY to continue from the old table
+ */
+ DECLARE
+ @max_id bigint;
+
+ SELECT
+ @max_id = ISNULL(MAX(collection_id), 0)
+ FROM collect.query_stats;
+
+ DBCC CHECKIDENT(N'collect.query_stats_new', RESEED, @max_id);
+
+ PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
+
+ /*
+ Step 3: Migrate data in batches with COMPRESS on LOB columns
+ Omits query_plan xml (never populated, dropping it)
+ Omits computed columns (avg_rows, avg_worker_time_ms, avg_elapsed_time_ms,
+ avg_physical_reads, worker_time_per_second) — can't appear in OUTPUT
+ */
+ DECLARE
+ @batch_size integer = 10000,
+ @rows_moved bigint = 0,
+ @batch_rows integer = 1;
+
+ PRINT '';
+ PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
+
+ SET IDENTITY_INSERT collect.query_stats_new ON;
+
+ WHILE @batch_rows > 0
+ BEGIN
+ DELETE TOP (@batch_size)
+ FROM collect.query_stats
+ OUTPUT
+ deleted.collection_id,
+ deleted.collection_time,
+ deleted.server_start_time,
+ deleted.object_type,
+ deleted.database_name,
+ deleted.object_name,
+ deleted.schema_name,
+ deleted.sql_handle,
+ deleted.statement_start_offset,
+ deleted.statement_end_offset,
+ deleted.plan_generation_num,
+ deleted.plan_handle,
+ deleted.creation_time,
+ deleted.last_execution_time,
+ deleted.execution_count,
+ deleted.total_worker_time,
+ deleted.min_worker_time,
+ deleted.max_worker_time,
+ deleted.total_physical_reads,
+ deleted.min_physical_reads,
+ deleted.max_physical_reads,
+ deleted.total_logical_writes,
+ deleted.total_logical_reads,
+ deleted.total_clr_time,
+ deleted.total_elapsed_time,
+ deleted.min_elapsed_time,
+ deleted.max_elapsed_time,
+ deleted.query_hash,
+ deleted.query_plan_hash,
+ deleted.total_rows,
+ deleted.min_rows,
+ deleted.max_rows,
+ deleted.statement_sql_handle,
+ deleted.statement_context_id,
+ deleted.min_dop,
+ deleted.max_dop,
+ deleted.min_grant_kb,
+ deleted.max_grant_kb,
+ deleted.min_used_grant_kb,
+ deleted.max_used_grant_kb,
+ deleted.min_ideal_grant_kb,
+ deleted.max_ideal_grant_kb,
+ deleted.min_reserved_threads,
+ deleted.max_reserved_threads,
+ deleted.min_used_threads,
+ deleted.max_used_threads,
+ deleted.total_spills,
+ deleted.min_spills,
+ deleted.max_spills,
+ deleted.execution_count_delta,
+ deleted.total_worker_time_delta,
+ deleted.total_elapsed_time_delta,
+ deleted.total_logical_reads_delta,
+ deleted.total_physical_reads_delta,
+ deleted.total_logical_writes_delta,
+ deleted.sample_interval_seconds,
+ COMPRESS(deleted.query_text),
+ COMPRESS(deleted.query_plan_text)
+ INTO collect.query_stats_new
+ (
+ collection_id,
+ collection_time,
+ server_start_time,
+ object_type,
+ database_name,
+ object_name,
+ schema_name,
+ sql_handle,
+ statement_start_offset,
+ statement_end_offset,
+ plan_generation_num,
+ plan_handle,
+ creation_time,
+ last_execution_time,
+ execution_count,
+ total_worker_time,
+ min_worker_time,
+ max_worker_time,
+ total_physical_reads,
+ min_physical_reads,
+ max_physical_reads,
+ total_logical_writes,
+ total_logical_reads,
+ total_clr_time,
+ total_elapsed_time,
+ min_elapsed_time,
+ max_elapsed_time,
+ query_hash,
+ query_plan_hash,
+ total_rows,
+ min_rows,
+ max_rows,
+ statement_sql_handle,
+ statement_context_id,
+ min_dop,
+ max_dop,
+ min_grant_kb,
+ max_grant_kb,
+ min_used_grant_kb,
+ max_used_grant_kb,
+ min_ideal_grant_kb,
+ max_ideal_grant_kb,
+ min_reserved_threads,
+ max_reserved_threads,
+ min_used_threads,
+ max_used_threads,
+ total_spills,
+ min_spills,
+ max_spills,
+ execution_count_delta,
+ total_worker_time_delta,
+ total_elapsed_time_delta,
+ total_logical_reads_delta,
+ total_physical_reads_delta,
+ total_logical_writes_delta,
+ sample_interval_seconds,
+ query_text,
+ query_plan_text
+ );
+
+ SET @batch_rows = @@ROWCOUNT;
+ SET @rows_moved += @batch_rows;
+
+ IF @batch_rows > 0
+ BEGIN
+ RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
+ END;
+ END;
+
+ SET IDENTITY_INSERT collect.query_stats_new OFF;
+
+ PRINT '';
+ PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
+
+ /*
+ Step 4: Rename old -> _old, new -> original
+ */
+ EXEC sp_rename
+ N'collect.query_stats',
+ N'query_stats_old',
+ N'OBJECT';
+
+ /* Rename old table's PK first to free the name */
+ EXEC sp_rename
+ N'collect.query_stats_old.PK_query_stats',
+ N'PK_query_stats_old',
+ N'INDEX';
+
+ EXEC sp_rename
+ N'collect.query_stats_new',
+ N'query_stats',
+ N'OBJECT';
+
+ EXEC sp_rename
+ N'collect.query_stats.PK_query_stats_new',
+ N'PK_query_stats',
+ N'INDEX';
+
+ PRINT '';
+ PRINT 'Renamed tables: query_stats -> query_stats_old, query_stats_new -> query_stats';
+ PRINT '';
+ PRINT '=== collect.query_stats migration complete ===';
+ PRINT '';
+ PRINT 'The old table is preserved as collect.query_stats_old.';
+ PRINT 'After verifying the migration, you can drop it:';
+ PRINT ' DROP TABLE IF EXISTS collect.query_stats_old;';
+
+END TRY
+BEGIN CATCH
+ PRINT '';
+ PRINT '*** ERROR migrating collect.query_stats ***';
+ PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
+ PRINT '';
+ PRINT 'The original table has not been renamed.';
+ PRINT 'If collect.query_stats_new exists, it contains partial data.';
+ PRINT 'Review and resolve the error, then re-run this script.';
+END CATCH;
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql b/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql
new file mode 100644
index 00000000..71dc9f1e
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/02_compress_query_store_data.sql
@@ -0,0 +1,368 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.1.0 to 2.2.0
+Migrates collect.query_store_data to compressed LOB storage:
+ - query_sql_text nvarchar(max) -> varbinary(max) via COMPRESS()
+ - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
+ - compilation_metrics xml -> varbinary(max) via COMPRESS(CAST(... AS nvarchar(max)))
+ - Adds row_hash binary(32) for deduplication
+*/
+
+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
+
+/*
+Skip if already migrated (query_sql_text is already varbinary)
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.columns
+ WHERE object_id = OBJECT_ID(N'collect.query_store_data')
+ AND name = N'query_sql_text'
+ AND system_type_id = 165 /*varbinary*/
+)
+BEGIN
+ PRINT 'collect.query_store_data already migrated to compressed storage — skipping.';
+ RETURN;
+END;
+GO
+
+/*
+Skip if source table doesn't exist
+*/
+IF OBJECT_ID(N'collect.query_store_data', N'U') IS NULL
+BEGIN
+ PRINT 'collect.query_store_data does not exist — skipping.';
+ RETURN;
+END;
+GO
+
+PRINT '=== Migrating collect.query_store_data to compressed LOB storage ===';
+PRINT '';
+GO
+
+BEGIN TRY
+
+ /*
+ Step 1: Create the _new table with compressed column types
+ */
+ IF OBJECT_ID(N'collect.query_store_data_new', N'U') IS NOT NULL
+ BEGIN
+ DROP TABLE collect.query_store_data_new;
+ PRINT 'Dropped existing collect.query_store_data_new';
+ END;
+
+ CREATE TABLE
+ collect.query_store_data_new
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ database_name sysname NOT NULL,
+ query_id bigint NOT NULL,
+ plan_id bigint NOT NULL,
+ execution_type_desc nvarchar(60) NULL,
+ utc_first_execution_time datetimeoffset(7) NOT NULL,
+ utc_last_execution_time datetimeoffset(7) NOT NULL,
+ server_first_execution_time datetime2(7) NOT NULL,
+ server_last_execution_time datetime2(7) NOT NULL,
+ module_name nvarchar(261) NULL,
+ query_sql_text varbinary(max) NULL,
+ query_hash binary(8) NULL,
+ /*Execution count*/
+ count_executions bigint NOT NULL,
+ /*Duration metrics (microseconds)*/
+ avg_duration bigint NOT NULL,
+ min_duration bigint NOT NULL,
+ max_duration bigint NOT NULL,
+ /*CPU time metrics (microseconds)*/
+ avg_cpu_time bigint NOT NULL,
+ min_cpu_time bigint NOT NULL,
+ max_cpu_time bigint NOT NULL,
+ /*Logical IO reads*/
+ avg_logical_io_reads bigint NOT NULL,
+ min_logical_io_reads bigint NOT NULL,
+ max_logical_io_reads bigint NOT NULL,
+ /*Logical IO writes*/
+ avg_logical_io_writes bigint NOT NULL,
+ min_logical_io_writes bigint NOT NULL,
+ max_logical_io_writes bigint NOT NULL,
+ /*Physical IO reads*/
+ avg_physical_io_reads bigint NOT NULL,
+ min_physical_io_reads bigint NOT NULL,
+ max_physical_io_reads bigint NOT NULL,
+ /*Number of physical IO reads - NULL on SQL 2016*/
+ avg_num_physical_io_reads bigint NULL,
+ min_num_physical_io_reads bigint NULL,
+ max_num_physical_io_reads bigint NULL,
+ /*CLR time (microseconds)*/
+ avg_clr_time bigint NOT NULL,
+ min_clr_time bigint NOT NULL,
+ max_clr_time bigint NOT NULL,
+ /*DOP (degree of parallelism)*/
+ min_dop bigint NOT NULL,
+ max_dop bigint NOT NULL,
+ /*Memory grant (8KB pages)*/
+ avg_query_max_used_memory bigint NOT NULL,
+ min_query_max_used_memory bigint NOT NULL,
+ max_query_max_used_memory bigint NOT NULL,
+ /*Row count*/
+ avg_rowcount bigint NOT NULL,
+ min_rowcount bigint NOT NULL,
+ max_rowcount bigint NOT NULL,
+ /*Log bytes used*/
+ avg_log_bytes_used bigint NULL,
+ min_log_bytes_used bigint NULL,
+ max_log_bytes_used bigint NULL,
+ /*Tempdb space used (8KB pages)*/
+ avg_tempdb_space_used bigint NULL,
+ min_tempdb_space_used bigint NULL,
+ max_tempdb_space_used bigint NULL,
+ /*Plan information*/
+ plan_type nvarchar(60) NULL,
+ is_forced_plan bit NOT NULL,
+ force_failure_count bigint NULL,
+ last_force_failure_reason_desc nvarchar(128) NULL,
+ plan_forcing_type nvarchar(60) NULL,
+ compatibility_level smallint NULL,
+ query_plan_text varbinary(max) NULL,
+ compilation_metrics varbinary(max) NULL,
+ query_plan_hash binary(8) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
+ CONSTRAINT
+ PK_query_store_data_new
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_store_data_new';
+
+ /*
+ Step 2: Reseed IDENTITY to continue from the old table
+ */
+ DECLARE
+ @max_id bigint;
+
+ SELECT
+ @max_id = ISNULL(MAX(collection_id), 0)
+ FROM collect.query_store_data;
+
+ DBCC CHECKIDENT(N'collect.query_store_data_new', RESEED, @max_id);
+
+ PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
+
+ /*
+ Step 3: Migrate data in batches with COMPRESS on LOB columns
+ compilation_metrics is xml, so CAST to nvarchar(max) before COMPRESS
+ */
+ DECLARE
+ @batch_size integer = 10000,
+ @rows_moved bigint = 0,
+ @batch_rows integer = 1;
+
+ PRINT '';
+ PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
+
+ SET IDENTITY_INSERT collect.query_store_data_new ON;
+
+ WHILE @batch_rows > 0
+ BEGIN
+ DELETE TOP (@batch_size)
+ FROM collect.query_store_data
+ OUTPUT
+ deleted.collection_id,
+ deleted.collection_time,
+ deleted.database_name,
+ deleted.query_id,
+ deleted.plan_id,
+ deleted.execution_type_desc,
+ deleted.utc_first_execution_time,
+ deleted.utc_last_execution_time,
+ deleted.server_first_execution_time,
+ deleted.server_last_execution_time,
+ deleted.module_name,
+ COMPRESS(deleted.query_sql_text),
+ deleted.query_hash,
+ deleted.count_executions,
+ deleted.avg_duration,
+ deleted.min_duration,
+ deleted.max_duration,
+ deleted.avg_cpu_time,
+ deleted.min_cpu_time,
+ deleted.max_cpu_time,
+ deleted.avg_logical_io_reads,
+ deleted.min_logical_io_reads,
+ deleted.max_logical_io_reads,
+ deleted.avg_logical_io_writes,
+ deleted.min_logical_io_writes,
+ deleted.max_logical_io_writes,
+ deleted.avg_physical_io_reads,
+ deleted.min_physical_io_reads,
+ deleted.max_physical_io_reads,
+ deleted.avg_num_physical_io_reads,
+ deleted.min_num_physical_io_reads,
+ deleted.max_num_physical_io_reads,
+ deleted.avg_clr_time,
+ deleted.min_clr_time,
+ deleted.max_clr_time,
+ deleted.min_dop,
+ deleted.max_dop,
+ deleted.avg_query_max_used_memory,
+ deleted.min_query_max_used_memory,
+ deleted.max_query_max_used_memory,
+ deleted.avg_rowcount,
+ deleted.min_rowcount,
+ deleted.max_rowcount,
+ deleted.avg_log_bytes_used,
+ deleted.min_log_bytes_used,
+ deleted.max_log_bytes_used,
+ deleted.avg_tempdb_space_used,
+ deleted.min_tempdb_space_used,
+ deleted.max_tempdb_space_used,
+ deleted.plan_type,
+ deleted.is_forced_plan,
+ deleted.force_failure_count,
+ deleted.last_force_failure_reason_desc,
+ deleted.plan_forcing_type,
+ deleted.compatibility_level,
+ COMPRESS(deleted.query_plan_text),
+ COMPRESS(CAST(deleted.compilation_metrics AS nvarchar(max))),
+ deleted.query_plan_hash
+ INTO collect.query_store_data_new
+ (
+ collection_id,
+ collection_time,
+ database_name,
+ query_id,
+ plan_id,
+ execution_type_desc,
+ utc_first_execution_time,
+ utc_last_execution_time,
+ server_first_execution_time,
+ server_last_execution_time,
+ module_name,
+ query_sql_text,
+ query_hash,
+ count_executions,
+ avg_duration,
+ min_duration,
+ max_duration,
+ avg_cpu_time,
+ min_cpu_time,
+ max_cpu_time,
+ avg_logical_io_reads,
+ min_logical_io_reads,
+ max_logical_io_reads,
+ avg_logical_io_writes,
+ min_logical_io_writes,
+ max_logical_io_writes,
+ avg_physical_io_reads,
+ min_physical_io_reads,
+ max_physical_io_reads,
+ avg_num_physical_io_reads,
+ min_num_physical_io_reads,
+ max_num_physical_io_reads,
+ avg_clr_time,
+ min_clr_time,
+ max_clr_time,
+ min_dop,
+ max_dop,
+ avg_query_max_used_memory,
+ min_query_max_used_memory,
+ max_query_max_used_memory,
+ avg_rowcount,
+ min_rowcount,
+ max_rowcount,
+ avg_log_bytes_used,
+ min_log_bytes_used,
+ max_log_bytes_used,
+ avg_tempdb_space_used,
+ min_tempdb_space_used,
+ max_tempdb_space_used,
+ plan_type,
+ is_forced_plan,
+ force_failure_count,
+ last_force_failure_reason_desc,
+ plan_forcing_type,
+ compatibility_level,
+ query_plan_text,
+ compilation_metrics,
+ query_plan_hash
+ );
+
+ SET @batch_rows = @@ROWCOUNT;
+ SET @rows_moved += @batch_rows;
+
+ IF @batch_rows > 0
+ BEGIN
+ RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
+ END;
+ END;
+
+ SET IDENTITY_INSERT collect.query_store_data_new OFF;
+
+ PRINT '';
+ PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
+
+ /*
+ Step 4: Rename old -> _old, new -> original
+ */
+ EXEC sp_rename
+ N'collect.query_store_data',
+ N'query_store_data_old',
+ N'OBJECT';
+
+ /* Rename old table's PK first to free the name */
+ EXEC sp_rename
+ N'collect.query_store_data_old.PK_query_store_data',
+ N'PK_query_store_data_old',
+ N'INDEX';
+
+ EXEC sp_rename
+ N'collect.query_store_data_new',
+ N'query_store_data',
+ N'OBJECT';
+
+ EXEC sp_rename
+ N'collect.query_store_data.PK_query_store_data_new',
+ N'PK_query_store_data',
+ N'INDEX';
+
+ PRINT '';
+ PRINT 'Renamed tables: query_store_data -> query_store_data_old, query_store_data_new -> query_store_data';
+ PRINT '';
+ PRINT '=== collect.query_store_data migration complete ===';
+ PRINT '';
+ PRINT 'The old table is preserved as collect.query_store_data_old.';
+ PRINT 'After verifying the migration, you can drop it:';
+ PRINT ' DROP TABLE IF EXISTS collect.query_store_data_old;';
+
+END TRY
+BEGIN CATCH
+ PRINT '';
+ PRINT '*** ERROR migrating collect.query_store_data ***';
+ PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
+ PRINT '';
+ PRINT 'The original table has not been renamed.';
+ PRINT 'If collect.query_store_data_new exists, it contains partial data.';
+ PRINT 'Review and resolve the error, then re-run this script.';
+END CATCH;
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql b/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql
new file mode 100644
index 00000000..dc672aad
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/03_compress_procedure_stats.sql
@@ -0,0 +1,325 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.1.0 to 2.2.0
+Migrates collect.procedure_stats to compressed LOB storage:
+ - query_plan_text nvarchar(max) -> varbinary(max) via COMPRESS()
+ - Drops unused query_plan xml column (never populated by collectors)
+ - Adds row_hash binary(32) for deduplication
+*/
+
+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
+
+/*
+Skip if already migrated (query_plan_text is already varbinary)
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.columns
+ WHERE object_id = OBJECT_ID(N'collect.procedure_stats')
+ AND name = N'query_plan_text'
+ AND system_type_id = 165 /*varbinary*/
+)
+BEGIN
+ PRINT 'collect.procedure_stats already migrated to compressed storage — skipping.';
+ RETURN;
+END;
+GO
+
+/*
+Skip if source table doesn't exist
+*/
+IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
+BEGIN
+ PRINT 'collect.procedure_stats does not exist — skipping.';
+ RETURN;
+END;
+GO
+
+PRINT '=== Migrating collect.procedure_stats to compressed LOB storage ===';
+PRINT '';
+GO
+
+BEGIN TRY
+
+ /*
+ Step 1: Create the _new table with compressed column types
+ */
+ IF OBJECT_ID(N'collect.procedure_stats_new', N'U') IS NOT NULL
+ BEGIN
+ DROP TABLE collect.procedure_stats_new;
+ PRINT 'Dropped existing collect.procedure_stats_new';
+ END;
+
+ CREATE TABLE
+ collect.procedure_stats_new
+ (
+ collection_id bigint IDENTITY NOT NULL,
+ collection_time datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ server_start_time datetime2(7) NOT NULL,
+ object_type nvarchar(20) NOT NULL,
+ database_name sysname NOT NULL,
+ object_id integer NOT NULL,
+ object_name sysname NULL,
+ schema_name sysname NULL,
+ type_desc nvarchar(60) NULL,
+ sql_handle varbinary(64) NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ cached_time datetime2(7) NOT NULL,
+ last_execution_time datetime2(7) NOT NULL,
+ /*Raw cumulative values*/
+ execution_count bigint NOT NULL,
+ total_worker_time bigint NOT NULL,
+ min_worker_time bigint NOT NULL,
+ max_worker_time bigint NOT NULL,
+ total_elapsed_time bigint NOT NULL,
+ min_elapsed_time bigint NOT NULL,
+ max_elapsed_time bigint NOT NULL,
+ total_logical_reads bigint NOT NULL,
+ min_logical_reads bigint NOT NULL,
+ max_logical_reads bigint NOT NULL,
+ total_physical_reads bigint NOT NULL,
+ min_physical_reads bigint NOT NULL,
+ max_physical_reads bigint NOT NULL,
+ total_logical_writes bigint NOT NULL,
+ min_logical_writes bigint NOT NULL,
+ max_logical_writes bigint NOT NULL,
+ total_spills bigint NULL,
+ min_spills bigint NULL,
+ max_spills bigint NULL,
+ /*Delta calculations*/
+ execution_count_delta bigint NULL,
+ total_worker_time_delta bigint NULL,
+ total_elapsed_time_delta bigint NULL,
+ total_logical_reads_delta bigint NULL,
+ total_physical_reads_delta bigint NULL,
+ total_logical_writes_delta bigint NULL,
+ sample_interval_seconds integer NULL,
+ /*Analysis helpers - computed columns*/
+ avg_worker_time_ms AS
+ (
+ total_worker_time /
+ NULLIF(execution_count, 0) / 1000.
+ ),
+ avg_elapsed_time_ms AS
+ (
+ total_elapsed_time /
+ NULLIF(execution_count, 0) / 1000.
+ ),
+ avg_physical_reads AS
+ (
+ total_physical_reads /
+ NULLIF(execution_count, 0)
+ ),
+ worker_time_per_second AS
+ (
+ total_worker_time_delta /
+ NULLIF(sample_interval_seconds, 0) / 1000.
+ ),
+ /*Execution plan (compressed with COMPRESS/DECOMPRESS)*/
+ query_plan_text varbinary(max) NULL,
+ /*Deduplication hash for skipping unchanged rows*/
+ row_hash binary(32) NULL,
+ CONSTRAINT
+ PK_procedure_stats_new
+ PRIMARY KEY CLUSTERED
+ (collection_time, collection_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.procedure_stats_new';
+
+ /*
+ Step 2: Reseed IDENTITY to continue from the old table
+ */
+ DECLARE
+ @max_id bigint;
+
+ SELECT
+ @max_id = ISNULL(MAX(collection_id), 0)
+ FROM collect.procedure_stats;
+
+ DBCC CHECKIDENT(N'collect.procedure_stats_new', RESEED, @max_id);
+
+ PRINT 'Reseeded IDENTITY to ' + CAST(@max_id AS varchar(20));
+
+ /*
+ Step 3: Migrate data in batches with COMPRESS on LOB columns
+ Omits query_plan xml (never populated, dropping it)
+ Omits computed columns (avg_worker_time_ms, avg_elapsed_time_ms,
+ avg_physical_reads, worker_time_per_second) — can't appear in OUTPUT
+ */
+ DECLARE
+ @batch_size integer = 10000,
+ @rows_moved bigint = 0,
+ @batch_rows integer = 1;
+
+ PRINT '';
+ PRINT 'Migrating data in batches of ' + CAST(@batch_size AS varchar(10)) + '...';
+
+ SET IDENTITY_INSERT collect.procedure_stats_new ON;
+
+ WHILE @batch_rows > 0
+ BEGIN
+ DELETE TOP (@batch_size)
+ FROM collect.procedure_stats
+ OUTPUT
+ deleted.collection_id,
+ deleted.collection_time,
+ deleted.server_start_time,
+ deleted.object_type,
+ deleted.database_name,
+ deleted.object_id,
+ deleted.object_name,
+ deleted.schema_name,
+ deleted.type_desc,
+ deleted.sql_handle,
+ deleted.plan_handle,
+ deleted.cached_time,
+ deleted.last_execution_time,
+ deleted.execution_count,
+ deleted.total_worker_time,
+ deleted.min_worker_time,
+ deleted.max_worker_time,
+ deleted.total_elapsed_time,
+ deleted.min_elapsed_time,
+ deleted.max_elapsed_time,
+ deleted.total_logical_reads,
+ deleted.min_logical_reads,
+ deleted.max_logical_reads,
+ deleted.total_physical_reads,
+ deleted.min_physical_reads,
+ deleted.max_physical_reads,
+ deleted.total_logical_writes,
+ deleted.min_logical_writes,
+ deleted.max_logical_writes,
+ deleted.total_spills,
+ deleted.min_spills,
+ deleted.max_spills,
+ deleted.execution_count_delta,
+ deleted.total_worker_time_delta,
+ deleted.total_elapsed_time_delta,
+ deleted.total_logical_reads_delta,
+ deleted.total_physical_reads_delta,
+ deleted.total_logical_writes_delta,
+ deleted.sample_interval_seconds,
+ COMPRESS(deleted.query_plan_text)
+ INTO collect.procedure_stats_new
+ (
+ collection_id,
+ collection_time,
+ server_start_time,
+ object_type,
+ database_name,
+ object_id,
+ object_name,
+ schema_name,
+ type_desc,
+ sql_handle,
+ plan_handle,
+ cached_time,
+ last_execution_time,
+ execution_count,
+ total_worker_time,
+ min_worker_time,
+ max_worker_time,
+ total_elapsed_time,
+ min_elapsed_time,
+ max_elapsed_time,
+ total_logical_reads,
+ min_logical_reads,
+ max_logical_reads,
+ total_physical_reads,
+ min_physical_reads,
+ max_physical_reads,
+ total_logical_writes,
+ min_logical_writes,
+ max_logical_writes,
+ total_spills,
+ min_spills,
+ max_spills,
+ execution_count_delta,
+ total_worker_time_delta,
+ total_elapsed_time_delta,
+ total_logical_reads_delta,
+ total_physical_reads_delta,
+ total_logical_writes_delta,
+ sample_interval_seconds,
+ query_plan_text
+ );
+
+ SET @batch_rows = @@ROWCOUNT;
+ SET @rows_moved += @batch_rows;
+
+ IF @batch_rows > 0
+ BEGIN
+ RAISERROR(N' Migrated %I64d rows so far...', 0, 1, @rows_moved) WITH NOWAIT;
+ END;
+ END;
+
+ SET IDENTITY_INSERT collect.procedure_stats_new OFF;
+
+ PRINT '';
+ PRINT 'Migration complete: ' + CAST(@rows_moved AS varchar(20)) + ' rows moved';
+
+ /*
+ Step 4: Rename old -> _old, new -> original
+ */
+ EXEC sp_rename
+ N'collect.procedure_stats',
+ N'procedure_stats_old',
+ N'OBJECT';
+
+ /* Rename old table's PK first to free the name */
+ EXEC sp_rename
+ N'collect.procedure_stats_old.PK_procedure_stats',
+ N'PK_procedure_stats_old',
+ N'INDEX';
+
+ EXEC sp_rename
+ N'collect.procedure_stats_new',
+ N'procedure_stats',
+ N'OBJECT';
+
+ EXEC sp_rename
+ N'collect.procedure_stats.PK_procedure_stats_new',
+ N'PK_procedure_stats',
+ N'INDEX';
+
+ PRINT '';
+ PRINT 'Renamed tables: procedure_stats -> procedure_stats_old, procedure_stats_new -> procedure_stats';
+ PRINT '';
+ PRINT '=== collect.procedure_stats migration complete ===';
+ PRINT '';
+ PRINT 'The old table is preserved as collect.procedure_stats_old.';
+ PRINT 'After verifying the migration, you can drop it:';
+ PRINT ' DROP TABLE IF EXISTS collect.procedure_stats_old;';
+
+END TRY
+BEGIN CATCH
+ PRINT '';
+ PRINT '*** ERROR migrating collect.procedure_stats ***';
+ PRINT 'Error ' + CAST(ERROR_NUMBER() AS varchar(10)) + ': ' + ERROR_MESSAGE();
+ PRINT '';
+ PRINT 'The original table has not been renamed.';
+ PRINT 'If collect.procedure_stats_new exists, it contains partial data.';
+ PRINT 'Review and resolve the error, then re-run this script.';
+END CATCH;
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql b/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql
new file mode 100644
index 00000000..dae8c832
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/04_create_tracking_tables.sql
@@ -0,0 +1,106 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.1.0 to 2.2.0
+Creates deduplication tracking tables for the three compressed collectors.
+Each table holds one row per natural key with the latest row_hash,
+allowing collectors to skip unchanged rows without scanning full history.
+*/
+
+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
+
+IF OBJECT_ID(N'collect.query_stats_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.query_stats_latest_hash
+ (
+ sql_handle varbinary(64) NOT NULL,
+ statement_start_offset integer NOT NULL,
+ statement_end_offset integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (sql_handle, statement_start_offset,
+ statement_end_offset, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_stats_latest_hash';
+END;
+ELSE
+BEGIN
+ PRINT 'collect.query_stats_latest_hash already exists — skipping.';
+END;
+GO
+
+IF OBJECT_ID(N'collect.procedure_stats_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.procedure_stats_latest_hash
+ (
+ database_name sysname NOT NULL,
+ object_id integer NOT NULL,
+ plan_handle varbinary(64) NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_procedure_stats_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, object_id, plan_handle)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.procedure_stats_latest_hash';
+END;
+ELSE
+BEGIN
+ PRINT 'collect.procedure_stats_latest_hash already exists — skipping.';
+END;
+GO
+
+IF OBJECT_ID(N'collect.query_store_data_latest_hash', N'U') IS NULL
+BEGIN
+ CREATE TABLE
+ collect.query_store_data_latest_hash
+ (
+ database_name sysname NOT NULL,
+ query_id bigint NOT NULL,
+ plan_id bigint NOT NULL,
+ row_hash binary(32) NOT NULL,
+ last_seen datetime2(7) NOT NULL
+ DEFAULT SYSDATETIME(),
+ CONSTRAINT
+ PK_query_store_data_latest_hash
+ PRIMARY KEY CLUSTERED
+ (database_name, query_id, plan_id)
+ WITH
+ (DATA_COMPRESSION = PAGE)
+ );
+
+ PRINT 'Created collect.query_store_data_latest_hash';
+END;
+ELSE
+BEGIN
+ PRINT 'collect.query_store_data_latest_hash already exists — skipping.';
+END;
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/05_add_finops_collectors.sql b/upgrades/2.1.0-to-2.2.0/05_add_finops_collectors.sql
new file mode 100644
index 00000000..b21ef865
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/05_add_finops_collectors.sql
@@ -0,0 +1,87 @@
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+Upgrade from 2.1.0 to 2.2.0
+Adds FinOps collector schedule entries for existing installations.
+Tables self-heal via ensure_collection_table; views use CREATE OR ALTER.
+Only the schedule entries need explicit insertion for upgrades.
+*/
+
+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
+
+IF NOT EXISTS
+(
+ SELECT
+ 1/0
+ FROM config.collection_schedule
+ WHERE collector_name = N'database_size_stats_collector'
+)
+BEGIN
+ INSERT INTO
+ config.collection_schedule
+ (
+ collector_name,
+ enabled,
+ frequency_minutes,
+ max_duration_minutes,
+ retention_days,
+ description
+ )
+ VALUES
+ (
+ N'database_size_stats_collector',
+ 1,
+ 60,
+ 10,
+ 90,
+ N'Database file sizes for growth trending and capacity planning'
+ );
+
+ PRINT 'Added database_size_stats_collector to collection schedule';
+END;
+GO
+
+IF NOT EXISTS
+(
+ SELECT
+ 1/0
+ FROM config.collection_schedule
+ WHERE collector_name = N'server_properties_collector'
+)
+BEGIN
+ INSERT INTO
+ config.collection_schedule
+ (
+ collector_name,
+ enabled,
+ frequency_minutes,
+ max_duration_minutes,
+ retention_days,
+ description
+ )
+ VALUES
+ (
+ N'server_properties_collector',
+ 1,
+ 1440,
+ 5,
+ 365,
+ N'Server edition, licensing, CPU/memory hardware metadata for license audit'
+ );
+
+ PRINT 'Added server_properties_collector to collection schedule';
+END;
+GO
diff --git a/upgrades/2.1.0-to-2.2.0/upgrade.txt b/upgrades/2.1.0-to-2.2.0/upgrade.txt
new file mode 100644
index 00000000..8f2a8d56
--- /dev/null
+++ b/upgrades/2.1.0-to-2.2.0/upgrade.txt
@@ -0,0 +1,5 @@
+01_compress_query_stats.sql
+02_compress_query_store_data.sql
+03_compress_procedure_stats.sql
+04_create_tracking_tables.sql
+05_add_finops_collectors.sql