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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +