From 66ae3f2c20e9f3c29e2a238e4abd167be851beeb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 05:50:53 -0400 Subject: [PATCH 001/145] Fix #1135: Lite index_object_stats collector times out as all-or-nothing sweep The v3.0.0 collector ran its entire multi-database sweep as ONE SqlCommand under the global 30s CommandTimeoutSeconds, cursoring every online database into a #temp and returning a single final SELECT. Because nothing streamed back until the end, the 30s was a cumulative, all-or-nothing budget across every database: on larger estates the sweep exceeded 30s, failed with SQL #-2 (Execution Timeout Expired), and discarded results from EVERY database, not just the slow one. Enabled by default and "never-run = due immediately," it failed on first connect after upgrade and kept retrying the timeout. Lite now collects one command per database, mirroring CollectQueryStoreAsync: - On-prem enumerates online/accessible databases into a list on one connection, then runs each via [db].sys.sp_executesql with its own command, a dedicated 300s timeout, and per-database try/catch. Azure SQL DB connects to each database individually. A slow or inaccessible database now fails only itself; the rest still persist. - Within each database the three DMVs (dm_db_partition_stats, dm_db_index_usage_stats, dm_db_index_operational_stats) are staged into #temp tables with single scans and then joined, giving the optimizer real cardinality instead of the bad plans the old monolithic multi-DMV join produced on large databases (the sp_IndexCleanup technique). - Dedicated 300s timeout (matching the FinOps sp_IndexCleanup path) replaces the 30s meant for lightweight DMV reads. The Dashboard's equivalent SQL collector (install/55) was not subject to the bug (it runs under SQL Agent and persists per database), but is brought to parity with the same DMV-staging technique for plan quality on large databases. Validated against SQL Server 2022: install proc collected 585 rows across 12 databases; the Lite [db].sys.sp_executesql + temp-staging wrapper returns rows in the correct database context. Lite build + 447 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + ...RemoteCollectorService.IndexObjectStats.cs | 428 +++++++----------- install/55_collect_index_object_stats.sql | 235 ++++++---- 3 files changed, 308 insertions(+), 362 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b28b938b9..62bf8d6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## [Unreleased] + +### Fixed + +- **Lite: the `index_object_stats` collector no longer times out and returns zero index data on larger estates** ([#1135]) — the v3.0.0 collector ran its entire multi-database sweep as **one** `SqlCommand` under the global 30s `CommandTimeoutSeconds`, cursoring over every online database into a `#temp` and returning a single final `SELECT`. Because nothing streamed back until the end, the 30s was a *cumulative, all-or-nothing* budget across every database — on a server with sizable/many databases the sweep blew past 30s, failed with `Execution Timeout Expired` (SQL `#-2`), and discarded results from **every** database, not just the slow one. Enabled by default and "never-run = due immediately," it failed on first connect right after upgrade and kept retrying the timeout. Now the collector runs **one command per database** (mirroring the Query Store collector): on-prem enumerates databases then sends each through `[db].sys.sp_executesql`, Azure SQL DB connects to each database individually, and each database has its own command, timeout, and `try/catch` — so a slow or inaccessible database fails only itself and the rest still persist. Within each database the three DMVs (`sys.dm_db_partition_stats`, `sys.dm_db_index_usage_stats`, `sys.dm_db_index_operational_stats`) are staged into `#temp` tables with single scans and then joined, giving the optimizer real cardinality and avoiding the bad plans the old single monolithic multi-DMV join produced on large databases (the `sp_IndexCleanup` technique). The collector also gets a dedicated 300s timeout (matching the FinOps `sp_IndexCleanup` path) instead of the 30s meant for lightweight DMV reads. The Dashboard's equivalent SQL collector (`install/55`) — which was not subject to the bug (it runs under SQL Agent and persists per database) — was brought to parity with the same DMV-staging technique for plan quality on large databases + ## [3.0.0] - 2026-06-15 ### Important @@ -81,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1116]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1116 [#1121]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1121 [#1122]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1122 +[#1135]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1135 ## [2.11.0] - 2026-05-19 diff --git a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs index 782ade43d..afe1e6c9a 100644 --- a/Lite/Services/RemoteCollectorService.IndexObjectStats.cs +++ b/Lite/Services/RemoteCollectorService.IndexObjectStats.cs @@ -20,239 +20,113 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { + /// + /// Command timeout for the index/object-stats collector. This sweep reads + /// sys.dm_db_index_operational_stats over every index in a database, which is + /// far heavier than the other DMV collectors. It now runs one command PER + /// DATABASE (see ), so this larger, + /// dedicated budget applies to a single database rather than a whole-instance + /// sweep. Matches the 300s the FinOps sp_IndexCleanup path already uses + /// (LocalDataService.FinOps.IndexAnalysis). The global 30s CommandTimeoutSeconds + /// was the root cause of #1135 (one cumulative all-or-nothing command timed out). + /// + private const int IndexObjectStatsCommandTimeoutSeconds = 300; + /// /// Collects per-table and per-index size, usage, and locking statistics for growth /// trending, unused-index detection, and contention analysis. /// Size columns are absolute point-in-time values; usage and locking counters are /// cumulative (reset on instance restart / DB detach / AUTO_CLOSE) - sqlserver_start_time /// carries the reset boundary so deltas can be computed safely in the read layer. - /// All three DMVs are database-scoped: on-prem iterates databases via a cursor + cross-DB - /// sp_executesql into a #temp; Azure SQL DB connects to each database individually. - /// In-Memory OLTP (Hekaton) objects are not represented by these DMVs. + /// All three DMVs are database-scoped, so collection runs ONE COMMAND PER DATABASE: + /// on-prem enumerates databases then sends each through [db].sys.sp_executesql; Azure + /// SQL DB connects to each database individually. Each database is collected with its + /// own command, timeout, and try/catch, so a slow or inaccessible database only fails + /// itself instead of discarding the whole instance's results (#1135). Within each + /// database, the three DMVs are staged into #temp tables with single scans and then + /// joined - this gives the optimizer real cardinality and avoids the bad plans the old + /// single monolithic multi-DMV join produced on large databases (the sp_IndexCleanup + /// technique). In-Memory OLTP (Hekaton) objects are not represented by these DMVs. /// private async Task CollectIndexObjectStatsAsync(ServerConnection server, CancellationToken cancellationToken) { var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - string onPremQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + /* + Per-database collection body. Runs inside a single database's context (the on-prem + path wraps it in [db].sys.sp_executesql; the Azure path connects to the database). + Each DMV is staged into its own #temp with one scan, then joined - sized/usage/ + locking counters get accurate cardinality so the final join gets a sane plan even + on very large databases. The final SELECT's column order MUST match the ordinals in + ReadIndexObjectStatRow (0..43). + */ + const string perDbStatsBody = @" SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -DECLARE - @sqlserver_start_time datetime2(7) = - (SELECT osi.sqlserver_start_time FROM sys.dm_os_sys_info AS osi), - @db_name sysname, - @sql nvarchar(MAX); - -CREATE TABLE #ios -( - database_name sysname NOT NULL, - database_id int NOT NULL, - schema_name sysname NOT NULL, - object_id int NOT NULL, - table_name sysname NOT NULL, - index_id int NOT NULL, - index_name sysname NULL, - index_type_desc nvarchar(60) NULL, - is_unique bit NULL, - is_primary_key bit NULL, - is_filtered bit NULL, - partition_count int NULL, - reserved_mb decimal(19,2) NULL, - used_mb decimal(19,2) NULL, - in_row_data_mb decimal(19,2) NULL, - lob_data_mb decimal(19,2) NULL, - row_overflow_mb decimal(19,2) NULL, - total_rows bigint NULL, - user_seeks bigint NULL, - user_scans bigint NULL, - user_lookups bigint NULL, - user_updates bigint NULL, - last_user_seek datetime2(7) NULL, - last_user_scan datetime2(7) NULL, - last_user_lookup datetime2(7) NULL, - last_user_update datetime2(7) NULL, - leaf_insert_count bigint NULL, - leaf_update_count bigint NULL, - leaf_delete_count bigint NULL, - range_scan_count bigint NULL, - singleton_lookup_count bigint NULL, - row_lock_count bigint NULL, - row_lock_wait_count bigint NULL, - row_lock_wait_in_ms bigint NULL, - page_lock_count bigint NULL, - page_lock_wait_count bigint NULL, - page_lock_wait_in_ms bigint NULL, - index_lock_promotion_attempt_count bigint NULL, - index_lock_promotion_count bigint NULL, - page_latch_wait_count bigint NULL, - page_latch_wait_in_ms bigint NULL, - page_io_latch_wait_count bigint NULL, - page_io_latch_wait_in_ms bigint NULL -); - -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 - /*EXCLUSION_FILTER_CURSOR*/ - 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 #ios -( - database_name, database_id, schema_name, object_id, table_name, index_id, index_name, - index_type_desc, is_unique, is_primary_key, is_filtered, partition_count, reserved_mb, - used_mb, in_row_data_mb, lob_data_mb, row_overflow_mb, total_rows, user_seeks, user_scans, - user_lookups, user_updates, last_user_seek, last_user_scan, last_user_lookup, last_user_update, - leaf_insert_count, leaf_update_count, leaf_delete_count, range_scan_count, singleton_lookup_count, - row_lock_count, row_lock_wait_count, row_lock_wait_in_ms, page_lock_count, page_lock_wait_count, - page_lock_wait_in_ms, index_lock_promotion_attempt_count, index_lock_promotion_count, - page_latch_wait_count, page_latch_wait_in_ms, page_io_latch_wait_count, page_io_latch_wait_in_ms -) +/* Size + row counts (one scan of dm_db_partition_stats) */ SELECT - DB_NAME(), DB_ID(), s.name, o.object_id, o.name, i.index_id, i.name, i.type_desc, - i.is_unique, i.is_primary_key, i.has_filter, ps.partition_count, - CONVERT(decimal(19,2), ps.reserved_pages * 8.0 / 1024.0), - CONVERT(decimal(19,2), ps.used_pages * 8.0 / 1024.0), - CONVERT(decimal(19,2), ps.in_row_pages * 8.0 / 1024.0), - CONVERT(decimal(19,2), ps.lob_pages * 8.0 / 1024.0), - CONVERT(decimal(19,2), ps.row_overflow_pages * 8.0 / 1024.0), - ps.total_rows, us.user_seeks, us.user_scans, us.user_lookups, us.user_updates, - us.last_user_seek, us.last_user_scan, us.last_user_lookup, us.last_user_update, - os.leaf_insert_count, os.leaf_update_count, os.leaf_delete_count, os.range_scan_count, - os.singleton_lookup_count, os.row_lock_count, os.row_lock_wait_count, os.row_lock_wait_in_ms, - os.page_lock_count, os.page_lock_wait_count, os.page_lock_wait_in_ms, - os.index_lock_promotion_attempt_count, os.index_lock_promotion_count, - os.page_latch_wait_count, os.page_latch_wait_in_ms, os.page_io_latch_wait_count, - os.page_io_latch_wait_in_ms -FROM sys.indexes AS i -JOIN sys.objects AS o - ON o.object_id = i.object_id -JOIN sys.schemas AS s - ON s.schema_id = o.schema_id -LEFT JOIN -( - SELECT - dps.object_id, dps.index_id, - partition_count = COUNT_BIG(*), - reserved_pages = SUM(dps.reserved_page_count), - used_pages = SUM(dps.used_page_count), - in_row_pages = SUM(dps.in_row_data_page_count), - lob_pages = SUM(dps.lob_used_page_count), - row_overflow_pages = SUM(dps.row_overflow_used_page_count), - total_rows = SUM(dps.row_count) - FROM sys.dm_db_partition_stats AS dps - GROUP BY dps.object_id, dps.index_id -) AS ps - ON ps.object_id = i.object_id AND ps.index_id = i.index_id -LEFT JOIN sys.dm_db_index_usage_stats AS us - ON us.database_id = DB_ID() AND us.object_id = i.object_id AND us.index_id = i.index_id -LEFT JOIN -( - SELECT - ios.object_id, ios.index_id, - leaf_insert_count = SUM(ios.leaf_insert_count), - leaf_update_count = SUM(ios.leaf_update_count), - leaf_delete_count = SUM(ios.leaf_delete_count), - range_scan_count = SUM(ios.range_scan_count), - singleton_lookup_count = SUM(ios.singleton_lookup_count), - row_lock_count = SUM(ios.row_lock_count), - row_lock_wait_count = SUM(ios.row_lock_wait_count), - row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), - page_lock_count = SUM(ios.page_lock_count), - page_lock_wait_count = SUM(ios.page_lock_wait_count), - page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), - index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), - index_lock_promotion_count = SUM(ios.index_lock_promotion_count), - page_latch_wait_count = SUM(ios.page_latch_wait_count), - page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), - page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), - page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) - FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios - GROUP BY ios.object_id, ios.index_id -) AS os - ON os.object_id = i.object_id AND os.index_id = i.index_id -WHERE o.is_ms_shipped = 0 -AND o.type IN (N''''U'''', N''''V'''') -OPTION(RECOMPILE);'';'; - - 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; + dps.object_id, + dps.index_id, + partition_count = COUNT_BIG(*), + reserved_pages = SUM(dps.reserved_page_count), + used_pages = SUM(dps.used_page_count), + in_row_pages = SUM(dps.in_row_data_page_count), + lob_pages = SUM(dps.lob_used_page_count), + row_overflow_pages = SUM(dps.row_overflow_used_page_count), + total_rows = SUM(dps.row_count) +INTO #sizes +FROM sys.dm_db_partition_stats AS dps +GROUP BY + dps.object_id, + dps.index_id +OPTION(RECOMPILE); +/* Usage counters (one scan of dm_db_index_usage_stats for this database) */ SELECT - sqlserver_start_time = @sqlserver_start_time, - x.database_name, - x.database_id, - x.schema_name, - x.object_id, - x.table_name, - x.index_id, - x.index_name, - x.index_type_desc, - x.is_unique, - x.is_primary_key, - x.is_filtered, - x.partition_count, - x.reserved_mb, - x.used_mb, - x.in_row_data_mb, - x.lob_data_mb, - x.row_overflow_mb, - x.total_rows, - x.user_seeks, - x.user_scans, - x.user_lookups, - x.user_updates, - x.last_user_seek, - x.last_user_scan, - x.last_user_lookup, - x.last_user_update, - x.leaf_insert_count, - x.leaf_update_count, - x.leaf_delete_count, - x.range_scan_count, - x.singleton_lookup_count, - x.row_lock_count, - x.row_lock_wait_count, - x.row_lock_wait_in_ms, - x.page_lock_count, - x.page_lock_wait_count, - x.page_lock_wait_in_ms, - x.index_lock_promotion_attempt_count, - x.index_lock_promotion_count, - x.page_latch_wait_count, - x.page_latch_wait_in_ms, - x.page_io_latch_wait_count, - x.page_io_latch_wait_in_ms -FROM #ios AS x -ORDER BY - x.reserved_mb DESC;"; - - var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - onPremQuery = onPremQuery.Replace("/*EXCLUSION_FILTER_CURSOR*/", exclusionClause); + us.object_id, + us.index_id, + us.user_seeks, + us.user_scans, + us.user_lookups, + us.user_updates, + us.last_user_seek, + us.last_user_scan, + us.last_user_lookup, + us.last_user_update +INTO #usage +FROM sys.dm_db_index_usage_stats AS us +WHERE us.database_id = DB_ID() +OPTION(RECOMPILE); - const string azureSqlDbQuery = @" -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +/* Locking/latch counters (one scan of dm_db_index_operational_stats - the heavy DMV) */ +SELECT + ios.object_id, + ios.index_id, + leaf_insert_count = SUM(ios.leaf_insert_count), + leaf_update_count = SUM(ios.leaf_update_count), + leaf_delete_count = SUM(ios.leaf_delete_count), + range_scan_count = SUM(ios.range_scan_count), + singleton_lookup_count = SUM(ios.singleton_lookup_count), + row_lock_count = SUM(ios.row_lock_count), + row_lock_wait_count = SUM(ios.row_lock_wait_count), + row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), + page_lock_count = SUM(ios.page_lock_count), + page_lock_wait_count = SUM(ios.page_lock_wait_count), + page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), + index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), + index_lock_promotion_count = SUM(ios.index_lock_promotion_count), + page_latch_wait_count = SUM(ios.page_latch_wait_count), + page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), + page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), + page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) +INTO #ops +FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios +GROUP BY + ios.object_id, + ios.index_id +OPTION(RECOMPILE); SELECT sqlserver_start_time = (SELECT osi.sqlserver_start_time FROM sys.dm_os_sys_info AS osi), @@ -304,52 +178,17 @@ JOIN sys.objects AS o ON o.object_id = i.object_id JOIN sys.schemas AS s ON s.schema_id = o.schema_id -LEFT JOIN -( - SELECT - dps.object_id, dps.index_id, - partition_count = COUNT_BIG(*), - reserved_pages = SUM(dps.reserved_page_count), - used_pages = SUM(dps.used_page_count), - in_row_pages = SUM(dps.in_row_data_page_count), - lob_pages = SUM(dps.lob_used_page_count), - row_overflow_pages = SUM(dps.row_overflow_used_page_count), - total_rows = SUM(dps.row_count) - FROM sys.dm_db_partition_stats AS dps - GROUP BY dps.object_id, dps.index_id -) AS ps - ON ps.object_id = i.object_id AND ps.index_id = i.index_id -LEFT JOIN sys.dm_db_index_usage_stats AS us - ON us.database_id = DB_ID() AND us.object_id = i.object_id AND us.index_id = i.index_id -LEFT JOIN -( - SELECT - ios.object_id, ios.index_id, - leaf_insert_count = SUM(ios.leaf_insert_count), - leaf_update_count = SUM(ios.leaf_update_count), - leaf_delete_count = SUM(ios.leaf_delete_count), - range_scan_count = SUM(ios.range_scan_count), - singleton_lookup_count = SUM(ios.singleton_lookup_count), - row_lock_count = SUM(ios.row_lock_count), - row_lock_wait_count = SUM(ios.row_lock_wait_count), - row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), - page_lock_count = SUM(ios.page_lock_count), - page_lock_wait_count = SUM(ios.page_lock_wait_count), - page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), - index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), - index_lock_promotion_count = SUM(ios.index_lock_promotion_count), - page_latch_wait_count = SUM(ios.page_latch_wait_count), - page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), - page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), - page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) - FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios - GROUP BY ios.object_id, ios.index_id -) AS os - ON os.object_id = i.object_id AND os.index_id = i.index_id +LEFT JOIN #sizes AS ps + ON ps.object_id = i.object_id + AND ps.index_id = i.index_id +LEFT JOIN #usage AS us + ON us.object_id = i.object_id + AND us.index_id = i.index_id +LEFT JOIN #ops AS os + ON os.object_id = i.object_id + AND os.index_id = i.index_id WHERE o.is_ms_shipped = 0 AND o.type IN (N'U', N'V') -ORDER BY - reserved_mb DESC OPTION(RECOMPILE);"; var serverId = GetServerId(server); @@ -364,20 +203,27 @@ reserved_mb DESC if (isAzureSqlDb) { + /* Azure SQL DB: one connection per database (cannot cross databases). Already + resilient - each database has its own command + try/catch. */ var databases = await GetAzureDatabaseListAsync(server, cancellationToken); foreach (var dbName in databases) { + cancellationToken.ThrowIfCancellationRequested(); try { using var dbConn = await OpenAzureDatabaseConnectionAsync(server, dbName, cancellationToken); - using var cmd = new SqlCommand(azureSqlDbQuery, dbConn); - cmd.CommandTimeout = CommandTimeoutSeconds; + using var cmd = new SqlCommand(perDbStatsBody, dbConn); + cmd.CommandTimeout = IndexObjectStatsCommandTimeoutSeconds; using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { rows.Add(ReadIndexObjectStatRow(reader)); } } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger?.LogDebug("Skipping database '{Database}' for index/object stats: {Error}", dbName, ex.Message); @@ -386,16 +232,62 @@ reserved_mb DESC } else { + /* On-prem / Azure MI / AWS RDS: one connection, enumerate databases, then collect + each one with its own command via [db].sys.sp_executesql. A slow or inaccessible + database fails only itself; the rest still persist (#1135). */ using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); - using var command = new SqlCommand(onPremQuery, sqlConnection); - command.CommandTimeout = CommandTimeoutSeconds; - var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); - foreach (var p in exclusionParams) command.Parameters.Add(p); - using var reader = await command.ExecuteReaderAsync(cancellationToken); - while (await reader.ReadAsync(cancellationToken)) + var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + var enumQuery = $@" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +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 +{exclusionClause} +ORDER BY + d.name;"; + + var databases = new List(); + using (var enumCommand = new SqlCommand(enumQuery, sqlConnection)) + { + enumCommand.CommandTimeout = CommandTimeoutSeconds; + foreach (var p in exclusionParams) enumCommand.Parameters.Add(p); + using var enumReader = await enumCommand.ExecuteReaderAsync(cancellationToken); + while (await enumReader.ReadAsync(cancellationToken)) + { + databases.Add(enumReader.GetString(0)); + } + } + + /* Double single quotes so the body survives nesting inside [db].sys.sp_executesql N'...' */ + var escapedBody = perDbStatsBody.Replace("'", "''"); + foreach (var dbName in databases) { - rows.Add(ReadIndexObjectStatRow(reader)); + cancellationToken.ThrowIfCancellationRequested(); + try + { + var escapedDbName = dbName.Replace("]", "]]"); + var perDbQuery = $"EXECUTE [{escapedDbName}].sys.sp_executesql N'{escapedBody}';"; + using var command = new SqlCommand(perDbQuery, sqlConnection); + command.CommandTimeout = IndexObjectStatsCommandTimeoutSeconds; + using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(ReadIndexObjectStatRow(reader)); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger?.LogWarning("Failed to collect index/object stats from [{Database}] on '{Server}': {Error}", + dbName, server.DisplayName, ex.Message); + } } } sqlSw.Stop(); diff --git a/install/55_collect_index_object_stats.sql b/install/55_collect_index_object_stats.sql index 67f920247..22cccaeaf 100644 --- a/install/55_collect_index_object_stats.sql +++ b/install/55_collect_index_object_stats.sql @@ -109,6 +109,71 @@ BEGIN */ IF @engine_edition = 5 BEGIN + /* + Stage each DMV with a single scan so the final join gets real cardinality. + A single monolithic multi-DMV join can pick a bad plan on large databases; + staging avoids that (the sp_IndexCleanup technique - see #1135). + */ + SELECT + dps.object_id, + dps.index_id, + partition_count = COUNT_BIG(*), + reserved_pages = SUM(dps.reserved_page_count), + used_pages = SUM(dps.used_page_count), + in_row_pages = SUM(dps.in_row_data_page_count), + lob_pages = SUM(dps.lob_used_page_count), + row_overflow_pages = SUM(dps.row_overflow_used_page_count), + total_rows = SUM(dps.row_count) + INTO #sizes + FROM sys.dm_db_partition_stats AS dps + GROUP BY + dps.object_id, + dps.index_id + OPTION(RECOMPILE); + + SELECT + us.object_id, + us.index_id, + us.user_seeks, + us.user_scans, + us.user_lookups, + us.user_updates, + us.last_user_seek, + us.last_user_scan, + us.last_user_lookup, + us.last_user_update + INTO #usage + FROM sys.dm_db_index_usage_stats AS us + WHERE us.database_id = DB_ID() + OPTION(RECOMPILE); + + SELECT + ios.object_id, + ios.index_id, + leaf_insert_count = SUM(ios.leaf_insert_count), + leaf_update_count = SUM(ios.leaf_update_count), + leaf_delete_count = SUM(ios.leaf_delete_count), + range_scan_count = SUM(ios.range_scan_count), + singleton_lookup_count = SUM(ios.singleton_lookup_count), + row_lock_count = SUM(ios.row_lock_count), + row_lock_wait_count = SUM(ios.row_lock_wait_count), + row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), + page_lock_count = SUM(ios.page_lock_count), + page_lock_wait_count = SUM(ios.page_lock_wait_count), + page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), + index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), + index_lock_promotion_count = SUM(ios.index_lock_promotion_count), + page_latch_wait_count = SUM(ios.page_latch_wait_count), + page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), + page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), + page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) + INTO #ops + FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios + GROUP BY + ios.object_id, + ios.index_id + OPTION(RECOMPILE); + INSERT INTO collect.index_object_stats ( @@ -209,56 +274,13 @@ BEGIN ON o.object_id = i.object_id JOIN sys.schemas AS s ON s.schema_id = o.schema_id - LEFT JOIN - ( - SELECT - dps.object_id, - dps.index_id, - partition_count = COUNT_BIG(*), - reserved_pages = SUM(dps.reserved_page_count), - used_pages = SUM(dps.used_page_count), - in_row_pages = SUM(dps.in_row_data_page_count), - lob_pages = SUM(dps.lob_used_page_count), - row_overflow_pages = SUM(dps.row_overflow_used_page_count), - total_rows = SUM(dps.row_count) - FROM sys.dm_db_partition_stats AS dps - GROUP BY - dps.object_id, - dps.index_id - ) AS ps + LEFT JOIN #sizes AS ps ON ps.object_id = i.object_id AND ps.index_id = i.index_id - LEFT JOIN sys.dm_db_index_usage_stats AS us - ON us.database_id = DB_ID() - AND us.object_id = i.object_id + LEFT JOIN #usage AS us + ON us.object_id = i.object_id AND us.index_id = i.index_id - LEFT JOIN - ( - SELECT - ios.object_id, - ios.index_id, - leaf_insert_count = SUM(ios.leaf_insert_count), - leaf_update_count = SUM(ios.leaf_update_count), - leaf_delete_count = SUM(ios.leaf_delete_count), - range_scan_count = SUM(ios.range_scan_count), - singleton_lookup_count = SUM(ios.singleton_lookup_count), - row_lock_count = SUM(ios.row_lock_count), - row_lock_wait_count = SUM(ios.row_lock_wait_count), - row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), - page_lock_count = SUM(ios.page_lock_count), - page_lock_wait_count = SUM(ios.page_lock_wait_count), - page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), - index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), - index_lock_promotion_count = SUM(ios.index_lock_promotion_count), - page_latch_wait_count = SUM(ios.page_latch_wait_count), - page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), - page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), - page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) - FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios - GROUP BY - ios.object_id, - ios.index_id - ) AS os + LEFT JOIN #ops AS os ON os.object_id = i.object_id AND os.index_id = i.index_id WHERE o.is_ms_shipped = 0 @@ -266,6 +288,8 @@ BEGIN OPTION(RECOMPILE); SET @rows_collected = ROWCOUNT_BIG(); + + DROP TABLE #sizes, #usage, #ops; END; ELSE BEGIN @@ -305,6 +329,72 @@ BEGIN BEGIN BEGIN TRY SET @sql = N' + SET NOCOUNT ON; + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + /* Stage each DMV with a single scan (real cardinality -> sane plan on + large databases; the sp_IndexCleanup technique - see #1135). These + #temps live only inside this per-database batch. */ + SELECT + dps.object_id, + dps.index_id, + partition_count = COUNT_BIG(*), + reserved_pages = SUM(dps.reserved_page_count), + used_pages = SUM(dps.used_page_count), + in_row_pages = SUM(dps.in_row_data_page_count), + lob_pages = SUM(dps.lob_used_page_count), + row_overflow_pages = SUM(dps.row_overflow_used_page_count), + total_rows = SUM(dps.row_count) + INTO #sizes + FROM sys.dm_db_partition_stats AS dps + GROUP BY + dps.object_id, + dps.index_id + OPTION(RECOMPILE); + + SELECT + us.object_id, + us.index_id, + us.user_seeks, + us.user_scans, + us.user_lookups, + us.user_updates, + us.last_user_seek, + us.last_user_scan, + us.last_user_lookup, + us.last_user_update + INTO #usage + FROM sys.dm_db_index_usage_stats AS us + WHERE us.database_id = DB_ID() + OPTION(RECOMPILE); + + SELECT + ios.object_id, + ios.index_id, + leaf_insert_count = SUM(ios.leaf_insert_count), + leaf_update_count = SUM(ios.leaf_update_count), + leaf_delete_count = SUM(ios.leaf_delete_count), + range_scan_count = SUM(ios.range_scan_count), + singleton_lookup_count = SUM(ios.singleton_lookup_count), + row_lock_count = SUM(ios.row_lock_count), + row_lock_wait_count = SUM(ios.row_lock_wait_count), + row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), + page_lock_count = SUM(ios.page_lock_count), + page_lock_wait_count = SUM(ios.page_lock_wait_count), + page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), + index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), + index_lock_promotion_count = SUM(ios.index_lock_promotion_count), + page_latch_wait_count = SUM(ios.page_latch_wait_count), + page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), + page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), + page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) + INTO #ops + FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios + GROUP BY + ios.object_id, + ios.index_id + OPTION(RECOMPILE); + INSERT INTO PerformanceMonitor.collect.index_object_stats ( @@ -405,56 +495,13 @@ BEGIN ON o.object_id = i.object_id JOIN sys.schemas AS s ON s.schema_id = o.schema_id - LEFT JOIN - ( - SELECT - dps.object_id, - dps.index_id, - partition_count = COUNT_BIG(*), - reserved_pages = SUM(dps.reserved_page_count), - used_pages = SUM(dps.used_page_count), - in_row_pages = SUM(dps.in_row_data_page_count), - lob_pages = SUM(dps.lob_used_page_count), - row_overflow_pages = SUM(dps.row_overflow_used_page_count), - total_rows = SUM(dps.row_count) - FROM sys.dm_db_partition_stats AS dps - GROUP BY - dps.object_id, - dps.index_id - ) AS ps + LEFT JOIN #sizes AS ps ON ps.object_id = i.object_id AND ps.index_id = i.index_id - LEFT JOIN sys.dm_db_index_usage_stats AS us - ON us.database_id = DB_ID() - AND us.object_id = i.object_id + LEFT JOIN #usage AS us + ON us.object_id = i.object_id AND us.index_id = i.index_id - LEFT JOIN - ( - SELECT - ios.object_id, - ios.index_id, - leaf_insert_count = SUM(ios.leaf_insert_count), - leaf_update_count = SUM(ios.leaf_update_count), - leaf_delete_count = SUM(ios.leaf_delete_count), - range_scan_count = SUM(ios.range_scan_count), - singleton_lookup_count = SUM(ios.singleton_lookup_count), - row_lock_count = SUM(ios.row_lock_count), - row_lock_wait_count = SUM(ios.row_lock_wait_count), - row_lock_wait_in_ms = SUM(ios.row_lock_wait_in_ms), - page_lock_count = SUM(ios.page_lock_count), - page_lock_wait_count = SUM(ios.page_lock_wait_count), - page_lock_wait_in_ms = SUM(ios.page_lock_wait_in_ms), - index_lock_promotion_attempt_count = SUM(ios.index_lock_promotion_attempt_count), - index_lock_promotion_count = SUM(ios.index_lock_promotion_count), - page_latch_wait_count = SUM(ios.page_latch_wait_count), - page_latch_wait_in_ms = SUM(ios.page_latch_wait_in_ms), - page_io_latch_wait_count = SUM(ios.page_io_latch_wait_count), - page_io_latch_wait_in_ms = SUM(ios.page_io_latch_wait_in_ms) - FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios - GROUP BY - ios.object_id, - ios.index_id - ) AS os + LEFT JOIN #ops AS os ON os.object_id = i.object_id AND os.index_id = i.index_id WHERE o.is_ms_shipped = 0 From b4b58821e9a4390ee216a29a284688a6952a4a8a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:21:04 -0400 Subject: [PATCH 002/145] Grade Volume Free Space alert severity WARNING/CRITICAL (#1136) The low-disk "Volume Free Space" alert was absent from the severity map and fell through to INFO for every breach, under-prioritizing a condition that can take a database into recovery/suspect and mis-routing severity-based webhooks. It now renders WARNING for a normal breach and CRITICAL when the worst breached volume is critically low (<=3% free or <=2GB free), via a shared LowDiskAlertGate.IsCriticallyLow rule and an AlertContext.SeverityOverride that rides through the email badge, Teams card, and Slack sidebar. The metric name is unchanged, so mute rules, cooldowns, and Alert-History matching are untouched. Fixed identically in Lite and Dashboard. Covered by AlertSeverityTests and LowDiskAlertGateTests. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++ Dashboard/MainWindow.xaml.cs | 7 ++ Lite.Tests/AlertSeverityTests.cs | 84 +++++++++++++++++++ Lite.Tests/LowDiskAlertGateTests.cs | 16 ++++ Lite/MainWindow.xaml.cs | 7 ++ .../AlertContext.cs | 10 +++ .../AlertSeverity.cs | 46 +++++++--- .../EmailTemplateBuilder.cs | 2 +- .../LowDiskAlertGate.cs | 24 ++++++ .../WebhookAlertService.cs | 4 +- 10 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 Lite.Tests/AlertSeverityTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bf8d6fc..14ad9b9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Lite and Dashboard: the low-disk (Volume Free Space) alert is now severity-graded instead of always INFO** ([#1136]) — the metric name was absent from the shared severity map, so every low-disk alert fell through to the default INFO tier (blue, lowest severity) regardless of how full the volume was. A volume nearing full stops data/log file growth — transactions fail and the database can go into recovery/suspect — so this under-prioritized a potentially critical condition, including for downstream severity-based webhook routing (Teams/Slack). The alert now renders **WARNING** for a normal breach and **CRITICAL** when the worst breached volume is critically low (≤ 3% free **or** ≤ 2 GB free — a second, lower tier beneath the user-configured fire threshold). The critical grading lives in the shared `LowDiskAlertGate.IsCriticallyLow`, and the tier rides through to the email badge/accent, Teams card, and Slack sidebar via an `AlertContext.SeverityOverride`, so the metric name (hence mute rules, cooldown, and Alert-History matching) is unchanged. The existing "notify only on a fresh or worsening breach" firing rule is untouched — this is purely the severity classification. Fixed identically in both apps. Covered by `AlertSeverityTests` and `LowDiskAlertGateTests` + ### Fixed - **Lite: the `index_object_stats` collector no longer times out and returns zero index data on larger estates** ([#1135]) — the v3.0.0 collector ran its entire multi-database sweep as **one** `SqlCommand` under the global 30s `CommandTimeoutSeconds`, cursoring over every online database into a `#temp` and returning a single final `SELECT`. Because nothing streamed back until the end, the 30s was a *cumulative, all-or-nothing* budget across every database — on a server with sizable/many databases the sweep blew past 30s, failed with `Execution Timeout Expired` (SQL `#-2`), and discarded results from **every** database, not just the slow one. Enabled by default and "never-run = due immediately," it failed on first connect right after upgrade and kept retrying the timeout. Now the collector runs **one command per database** (mirroring the Query Store collector): on-prem enumerates databases then sends each through `[db].sys.sp_executesql`, Azure SQL DB connects to each database individually, and each database has its own command, timeout, and `try/catch` — so a slow or inaccessible database fails only itself and the rest still persist. Within each database the three DMVs (`sys.dm_db_partition_stats`, `sys.dm_db_index_usage_stats`, `sys.dm_db_index_operational_stats`) are staged into `#temp` tables with single scans and then joined, giving the optimizer real cardinality and avoiding the bad plans the old single monolithic multi-DMV join produced on large databases (the `sp_IndexCleanup` technique). The collector also gets a dedicated 300s timeout (matching the FinOps `sp_IndexCleanup` path) instead of the 30s meant for lightweight DMV reads. The Dashboard's equivalent SQL collector (`install/55`) — which was not subject to the bug (it runs under SQL Agent and persists per database) — was brought to parity with the same DMV-staging technique for plan quality on large databases @@ -88,6 +92,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1121]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1121 [#1122]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1122 [#1135]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1135 +[#1136]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1136 ## [2.11.0] - 2026-05-19 diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index e3df8e476..6ff54dade 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -2010,6 +2010,13 @@ standing full volume (which also re-recorded a history row and made Dismiss feel _lastLowDiskAlert[serverId] = now; _lastAlertedLowDiskPercent[serverId] = worst.FreePercent; var lowDiskContext = BuildVolumeFreeSpaceContext(breachedVolumes); + /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is + critically low — so the email/webhook badge reflects how dire the breach is. + (lowDiskContext is non-null here — breachedVolumes.Count > 0 — but typed nullable.) */ + if (lowDiskContext is not null && LowDiskAlertGate.IsCriticallyLow(worst.FreePercent, worst.FreeGb)) + { + lowDiskContext.SeverityOverride = AlertSeverityLevel.Critical; + } var detailText = ContextToDetailText(lowDiskContext); var currentValue = $"{worst.MountPoint} {worst.FreePercent:F0}% free ({worst.FreeGb:F1} GB)"; var thresholdValue = FormatLowDiskThreshold(prefs); diff --git a/Lite.Tests/AlertSeverityTests.cs b/Lite.Tests/AlertSeverityTests.cs new file mode 100644 index 000000000..bf0de0ad7 --- /dev/null +++ b/Lite.Tests/AlertSeverityTests.cs @@ -0,0 +1,84 @@ +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Guards the runtime-graded severity for "Volume Free Space" (#1136). The metric used to be absent +/// from the map and fell through to INFO-blue (the lowest tier) for every +/// breach; it now renders WARNING by default and CRITICAL when the alert site sets +/// for a critically-low volume. The email body, Teams card, +/// and Slack sidebar all key severity off the shared map, so this one suite covers both apps' output. +/// +public class AlertSeverityTests +{ + private static readonly AlertBranding Branding = new("Test Edition", null); + + [Fact] + public void VolumeFreeSpace_NoOverride_IsWarningNotInfo() + { + var (hex, badge, _) = AlertSeverity.ForMetric("Volume Free Space"); + Assert.Equal("WARNING", badge); + Assert.Equal("#D97706", hex); + } + + [Fact] + public void VolumeFreeSpace_CriticalOverride_IsCritical() + { + var (hex, badge, _) = AlertSeverity.ForMetric("Volume Free Space", AlertSeverityLevel.Critical); + Assert.Equal("CRITICAL", badge); + Assert.Equal("#DC2626", hex); + } + + [Fact] + public void Override_IsAuthoritativeOverMetricMap() + { + // The override wins regardless of the metric's own mapped tier. + var (_, badge, _) = AlertSeverity.ForMetric("High CPU", AlertSeverityLevel.Critical); + Assert.Equal("CRITICAL", badge); + } + + [Fact] + public void EmailBody_CriticalOverride_RendersCriticalNotInfo() + { + var ctx = new AlertContext { SeverityOverride = AlertSeverityLevel.Critical }; + var (html, _) = EmailTemplateBuilder.BuildAlertEmail( + "Volume Free Space", "S1", "E:\\ 2% free (1.0 GB)", "10% / 5 GB", 15, Branding, ctx); + + Assert.Contains("CRITICAL", html); + Assert.Contains("#DC2626", html); + Assert.DoesNotContain("INFO", html); + } + + [Fact] + public void EmailBody_NoOverride_RendersWarningNotInfo() + { + var (html, _) = EmailTemplateBuilder.BuildAlertEmail( + "Volume Free Space", "S1", "E:\\ 8% free (40.0 GB)", "10% / 5 GB", 15, Branding, context: null); + + Assert.Contains("WARNING", html); + Assert.DoesNotContain("INFO", html); + } + + [Fact] + public void TeamsPayload_CriticalOverride_UsesCriticalColorAndBadge() + { + var ctx = new AlertContext { SeverityOverride = AlertSeverityLevel.Critical }; + var payload = WebhookAlertService.BuildTeamsPayload( + "Volume Free Space", "S1", "E:\\ 2% free (1.0 GB)", "10% / 5 GB", Branding, context: ctx); + + Assert.Contains("CRITICAL", payload); + Assert.Contains("DC2626", payload); // themeColor renders without the leading '#' + } + + [Fact] + public void SlackPayload_CriticalOverride_UsesCriticalColor() + { + var ctx = new AlertContext { SeverityOverride = AlertSeverityLevel.Critical }; + var payload = WebhookAlertService.BuildSlackPayload( + "Volume Free Space", "S1", "E:\\ 2% free (1.0 GB)", "10% / 5 GB", Branding, context: ctx); + + Assert.Contains("CRITICAL", payload); + Assert.Contains("#DC2626", payload); + } +} diff --git a/Lite.Tests/LowDiskAlertGateTests.cs b/Lite.Tests/LowDiskAlertGateTests.cs index cd316862f..e4c41ba5c 100644 --- a/Lite.Tests/LowDiskAlertGateTests.cs +++ b/Lite.Tests/LowDiskAlertGateTests.cs @@ -58,4 +58,20 @@ public void RespectsCustomMargin(double current, double last, double margin, boo { Assert.Equal(expected, LowDiskAlertGate.ShouldAlert(current, last, margin)); } + + /// + /// #1136: the critical tier is graded on EITHER dimension (OR semantics, matching the breach + /// test) — a low percentage on a huge volume OR a few GB free on any volume is CRITICAL. + /// + [Theory] + [InlineData(2.0, 100.0, true)] // 2% <= 3% floor (huge volume, low %) -> critical + [InlineData(3.0, 100.0, true)] // exactly at the percent floor + [InlineData(4.0, 100.0, false)] // 4% above floor, plenty of GB -> warning tier + [InlineData(8.0, 1.5, true)] // healthy %, but 1.5 GB <= 2 GB floor -> critical + [InlineData(8.0, 2.0, true)] // exactly at the GB floor + [InlineData(8.0, 5.0, false)] // above both floors -> warning tier + public void IsCriticallyLow_GradesEitherDimension(double freePercent, double freeGb, bool expected) + { + Assert.Equal(expected, LowDiskAlertGate.IsCriticallyLow(freePercent, freeGb)); + } } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 09ef711a1..da99e7744 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -2013,6 +2013,13 @@ standing full volume (which also re-recorded a history row and made Dismiss feel } var lowDiskContext = BuildVolumeFreeSpaceContext(breached); + /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is + critically low — so the email/webhook badge reflects how dire the breach is. + (lowDiskContext is non-null here — breached.Count > 0 — but typed nullable.) */ + if (lowDiskContext is not null && LowDiskAlertGate.IsCriticallyLow(worst.FreePercent, worst.FreeGb)) + { + lowDiskContext.SeverityOverride = AlertSeverityLevel.Critical; + } var detailText = ContextToDetailText(lowDiskContext); await _emailAlertService.TrySendAlertEmailAsync( diff --git a/PerformanceMonitor.Notifications/AlertContext.cs b/PerformanceMonitor.Notifications/AlertContext.cs index da0efcbb8..e97d91677 100644 --- a/PerformanceMonitor.Notifications/AlertContext.cs +++ b/PerformanceMonitor.Notifications/AlertContext.cs @@ -21,6 +21,16 @@ public class AlertContext public List Details { get; set; } = new(); public string? AttachmentXml { get; set; } public string? AttachmentFileName { get; set; } + + /// + /// Forces the rendered severity tier (email badge/color, Teams/Slack accent) regardless of + /// metric name, for metrics graded at runtime — low-disk fires WARNING normally and CRITICAL + /// when critically low (#1136). null = use the per-metric + /// map. Deliberately not persisted (like ): it drives the live + /// email/webhook render only, and the alert-history UI does not re-derive severity, so the + /// JSON projection () need not carry it. + /// + public AlertSeverityLevel? SeverityOverride { get; set; } } /// diff --git a/PerformanceMonitor.Notifications/AlertSeverity.cs b/PerformanceMonitor.Notifications/AlertSeverity.cs index df553417f..aad3630d4 100644 --- a/PerformanceMonitor.Notifications/AlertSeverity.cs +++ b/PerformanceMonitor.Notifications/AlertSeverity.cs @@ -8,6 +8,18 @@ namespace PerformanceMonitor.Notifications; +/// +/// Severity tier an alert site can force, independent of the metric-name map, for metrics whose +/// seriousness is graded at runtime rather than fixed (e.g. "Volume Free Space": WARNING below the +/// configured threshold, CRITICAL when critically low — #1136). Carried on +/// ; null falls back to the per-metric map. +/// +public enum AlertSeverityLevel +{ + Warning, + Critical +} + /// /// Single source of truth for per-metric alert severity styling: accent/hex color, badge text, /// and the webhook emoji. Both the email body () and the @@ -16,21 +28,33 @@ namespace PerformanceMonitor.Notifications; /// internal static class AlertSeverity { + /// + /// When non-null, forces the tier regardless of — for metrics + /// graded at runtime (#1136). null uses the per-metric map below. + /// /// /// (HexColor, BadgeText, Emoji) for the metric. Unknown metrics fall back to INFO-blue. /// Email ignores the emoji; webhooks use all three. /// - public static (string HexColor, string BadgeText, string Emoji) ForMetric(string metricName) => metricName switch + public static (string HexColor, string BadgeText, string Emoji) ForMetric( + string metricName, + AlertSeverityLevel? overrideLevel = null) => overrideLevel switch { - "Blocking Detected" => ("#D97706", "ALERT", "\U0001F7E0"), - "Deadlocks Detected" => ("#DC2626", "ALERT", "\U0001F534"), - "High CPU" => ("#F59E0B", "WARNING", "\U0001F7E1"), - "Poison Wait" => ("#DC2626", "CRITICAL", "\U0001F534"), - "Long-Running Query" => ("#D97706", "WARNING", "\U0001F7E0"), - "TempDB Space" => ("#D97706", "WARNING", "\U0001F7E0"), - "Long-Running Job" => ("#D97706", "WARNING", "\U0001F7E0"), - "Server Unreachable" => ("#DC2626", "CRITICAL", "\U0001F534"), - "Server Restored" => ("#16A34A", "RESOLVED", "\U0001F7E2"), - _ => ("#2eaef1", "INFO", "\U0001F535") + AlertSeverityLevel.Critical => ("#DC2626", "CRITICAL", "\U0001F534"), + AlertSeverityLevel.Warning => ("#D97706", "WARNING", "\U0001F7E0"), + _ => metricName switch + { + "Blocking Detected" => ("#D97706", "ALERT", "\U0001F7E0"), + "Deadlocks Detected" => ("#DC2626", "ALERT", "\U0001F534"), + "High CPU" => ("#F59E0B", "WARNING", "\U0001F7E1"), + "Poison Wait" => ("#DC2626", "CRITICAL", "\U0001F534"), + "Long-Running Query" => ("#D97706", "WARNING", "\U0001F7E0"), + "TempDB Space" => ("#D97706", "WARNING", "\U0001F7E0"), + "Long-Running Job" => ("#D97706", "WARNING", "\U0001F7E0"), + "Volume Free Space" => ("#D97706", "WARNING", "\U0001F7E0"), + "Server Unreachable" => ("#DC2626", "CRITICAL", "\U0001F534"), + "Server Restored" => ("#16A34A", "RESOLVED", "\U0001F7E2"), + _ => ("#2eaef1", "INFO", "\U0001F535") + } }; } diff --git a/PerformanceMonitor.Notifications/EmailTemplateBuilder.cs b/PerformanceMonitor.Notifications/EmailTemplateBuilder.cs index 4586d96eb..2638d080f 100644 --- a/PerformanceMonitor.Notifications/EmailTemplateBuilder.cs +++ b/PerformanceMonitor.Notifications/EmailTemplateBuilder.cs @@ -35,7 +35,7 @@ public static (string HtmlBody, string PlainTextBody) BuildAlertEmail( { var utcNow = DateTime.UtcNow; var localNow = DateTime.Now; - var (accentColor, badgeText, _) = AlertSeverity.ForMetric(metricName); + var (accentColor, badgeText, _) = AlertSeverity.ForMetric(metricName, context?.SeverityOverride); var html = BuildHtmlBody(metricName, serverName, currentValue, thresholdValue, utcNow, localNow, accentColor, badgeText, branding, context: context, emailCooldownMinutes: emailCooldownMinutes); diff --git a/PerformanceMonitor.Notifications/LowDiskAlertGate.cs b/PerformanceMonitor.Notifications/LowDiskAlertGate.cs index 797d9da05..13eceede5 100644 --- a/PerformanceMonitor.Notifications/LowDiskAlertGate.cs +++ b/PerformanceMonitor.Notifications/LowDiskAlertGate.cs @@ -31,6 +31,30 @@ public static class LowDiskAlertGate /// public const double DefaultWorseningMarginPercent = 1.0; + /// + /// Free-space percentage at or below which a breach is "critically low" (#1136) — a second, + /// lower tier beneath the user-configured fire threshold. Below this the database can no longer + /// grow data/log files, so transactions fail and the database can go into recovery/suspect; that + /// warrants CRITICAL, not the WARNING the normal breach renders. + /// + public const double CriticalFreePercent = 3.0; + + /// + /// Free-space GB at or below which a breach is "critically low" regardless of percentage — a + /// large volume sitting at a few GB free has no room for a single autogrow. See + /// . + /// + public const double CriticalFreeGb = 2.0; + + /// + /// True when the worst breached volume is critically low on EITHER dimension (mirrors the OR + /// semantics of the breach test itself): free space at/below + /// or at/below . Drives the CRITICAL severity tier (#1136). Shared + /// by Lite and Dashboard so the two apps grade low-disk identically. + /// + public static bool IsCriticallyLow(double freePercent, double freeGb) => + freePercent <= CriticalFreePercent || freeGb <= CriticalFreeGb; + /// /// Returns true when a low-disk alert should fire this cycle. /// diff --git a/PerformanceMonitor.Notifications/WebhookAlertService.cs b/PerformanceMonitor.Notifications/WebhookAlertService.cs index 83de8dcc9..ed297e05f 100644 --- a/PerformanceMonitor.Notifications/WebhookAlertService.cs +++ b/PerformanceMonitor.Notifications/WebhookAlertService.cs @@ -202,7 +202,7 @@ internal static string BuildTeamsPayload( bool isTest = false, AlertContext? context = null) { - var (hexColor, badgeText, emoji) = AlertSeverity.ForMetric(metricName); + var (hexColor, badgeText, emoji) = AlertSeverity.ForMetric(metricName, context?.SeverityOverride); var themeColor = hexColor.TrimStart('#'); var utcNow = DateTime.UtcNow; var localNow = DateTime.Now; @@ -349,7 +349,7 @@ internal static string BuildSlackPayload( bool isTest = false, AlertContext? context = null) { - var (hexColor, badgeText, emoji) = AlertSeverity.ForMetric(metricName); + var (hexColor, badgeText, emoji) = AlertSeverity.ForMetric(metricName, context?.SeverityOverride); var utcNow = DateTime.UtcNow; var localNow = DateTime.Now; From db59034c6b6d53ae79c5843538978cd09a906c73 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:03:27 -0400 Subject: [PATCH 003/145] #1140: shared dedup-fingerprint foundation (model, hashing, rendering, persistence) Adds the app-agnostic core for the alert dedup fingerprint + involved-objects feature, with no app wiring yet: - AlertIncident record + AlertContext.Incidents (the per-incident unit). - AlertFingerprint: ForObjects/ForKey/Hash. SHA-256 idiom reused from InferenceEngine; server+incident-type scoped; case/order/whitespace-insensitive; volatile per-sample fields excluded; original casing preserved for display. - AlertIncidentRenderer: projects Incidents into AlertContext.Details so the fingerprint renders on Teams/Slack/email(x2)/dialog with no renderer changes. - AlertContextSerializer: persists Incidents (trailing-optional DTO, backward- compatible round-trip). 19 unit tests (Lite.Tests): fingerprint determinism/order/case/scoping/volatile- exclusion, renderer projection across surfaces, serializer round-trip + legacy null. Refs #1140 Co-Authored-By: Claude Opus 4.8 (1M context) --- Lite.Tests/AlertFingerprintTests.cs | 116 ++++++++++++++++ Lite.Tests/AlertIncidentRenderTests.cs | 84 ++++++++++++ Lite.Tests/AnalysisNotificationTests.cs | 32 +++++ .../AlertContext.cs | 52 ++++++- .../AlertFingerprint.cs | 128 ++++++++++++++++++ .../AlertIncidentRenderer.cs | 58 ++++++++ 6 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 Lite.Tests/AlertFingerprintTests.cs create mode 100644 Lite.Tests/AlertIncidentRenderTests.cs create mode 100644 PerformanceMonitor.Notifications/AlertFingerprint.cs create mode 100644 PerformanceMonitor.Notifications/AlertIncidentRenderer.cs diff --git a/Lite.Tests/AlertFingerprintTests.cs b/Lite.Tests/AlertFingerprintTests.cs new file mode 100644 index 000000000..602295345 --- /dev/null +++ b/Lite.Tests/AlertFingerprintTests.cs @@ -0,0 +1,116 @@ +using System; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Guards (#1140): the stable dedup key must be deterministic, +/// order- and case-insensitive over the involved objects, scoped by server + incident type, and +/// must NOT move when volatile per-sample fields (occurrence count, wait range) change. The shared +/// helper covers both apps, so this one suite is the canonical fingerprint coverage. +/// +public class AlertFingerprintTests +{ + private const string Server = "SQL2022"; + + [Fact] + public void ForObjects_IsDeterministic() + { + var a = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "SalesDB.dbo.Orders" }); + var b = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "SalesDB.dbo.Orders" }); + Assert.NotNull(a); + Assert.NotNull(b); + Assert.Equal(a!.DedupKey, b!.DedupKey); + } + + [Fact] + public void ForObjects_IsOrderIndependent() + { + var a = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "db.dbo.A", "db.dbo.B" }); + var b = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "db.dbo.B", "db.dbo.A" }); + Assert.Equal(a!.DedupKey, b!.DedupKey); + } + + [Fact] + public void ForObjects_CollapsesDuplicatesCasingAndWhitespace() + { + var a = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "db.dbo.A" }); + var b = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { " DB.DBO.A ", "db.dbo.a", "db.dbo.A" }); + Assert.Equal(a!.DedupKey, b!.DedupKey); + } + + [Fact] + public void ForObjects_PreservesOriginalCasingForDisplay() + { + var a = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "SalesDB.dbo.Orders" }); + Assert.Equal(new[] { "SalesDB.dbo.Orders" }, a!.InvolvedObjects); + } + + [Fact] + public void ForObjects_DistinctIncidentType_DiffersKey() + { + var deadlock = AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "db.dbo.A" }); + var blocking = AlertFingerprint.ForObjects(Server, AlertFingerprint.Blocking, new[] { "db.dbo.A" }); + Assert.NotEqual(deadlock!.DedupKey, blocking!.DedupKey); + } + + [Fact] + public void ForObjects_DistinctServer_DiffersKey() + { + var a = AlertFingerprint.ForObjects("SQL2019", AlertFingerprint.Deadlock, new[] { "db.dbo.A" }); + var b = AlertFingerprint.ForObjects("SQL2022", AlertFingerprint.Deadlock, new[] { "db.dbo.A" }); + Assert.NotEqual(a!.DedupKey, b!.DedupKey); + } + + [Fact] + public void ForObjects_VolatileFields_DoNotAffectKey() + { + var a = AlertFingerprint.ForObjects(Server, AlertFingerprint.Blocking, new[] { "db.dbo.A" }, occurrenceCount: 3, waitRange: "80.8-90.8 s"); + var b = AlertFingerprint.ForObjects(Server, AlertFingerprint.Blocking, new[] { "db.dbo.A" }, occurrenceCount: 8, waitRange: "10-20 s"); + Assert.Equal(a!.DedupKey, b!.DedupKey); + Assert.Equal(3, a.OccurrenceCount); + Assert.Equal("80.8-90.8 s", a.WaitRange); + } + + [Fact] + public void ForObjects_NoUsableObjects_ReturnsNull() + { + Assert.Null(AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, new[] { "", " " })); + Assert.Null(AlertFingerprint.ForObjects(Server, AlertFingerprint.Deadlock, Array.Empty())); + } + + [Fact] + public void ForKey_IsDeterministicAndScoped() + { + var a = AlertFingerprint.ForKey(Server, AlertFingerprint.Query, "0xABCD"); + var b = AlertFingerprint.ForKey(Server, AlertFingerprint.Query, "0xabcd"); // case-normalized to the same key + Assert.Equal(a!.DedupKey, b!.DedupKey); + + var differentType = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "0xABCD"); + Assert.NotEqual(a.DedupKey, differentType!.DedupKey); + } + + [Fact] + public void ForKey_BlankKey_ReturnsNull() + { + Assert.Null(AlertFingerprint.ForKey(Server, AlertFingerprint.Disk, " ")); + } + + [Fact] + public void ForKey_DisplayObjects_DoNotAffectKey() + { + var a = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Nightly ETL", new[] { "shown A" }); + var b = AlertFingerprint.ForKey(Server, AlertFingerprint.Job, "Nightly ETL", new[] { "shown B" }); + Assert.Equal(a!.DedupKey, b!.DedupKey); + Assert.Equal(new[] { "shown A" }, a.InvolvedObjects); + } + + [Fact] + public void Hash_IsLowercaseHex64() + { + var h = AlertFingerprint.Hash("anything"); + Assert.Equal(64, h.Length); + Assert.Matches("^[0-9a-f]{64}$", h); + } +} diff --git a/Lite.Tests/AlertIncidentRenderTests.cs b/Lite.Tests/AlertIncidentRenderTests.cs new file mode 100644 index 000000000..c1ff10b82 --- /dev/null +++ b/Lite.Tests/AlertIncidentRenderTests.cs @@ -0,0 +1,84 @@ +using System; +using System.Linq; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Guards (#1140): incidents project into +/// so the dedup fingerprint renders on every surface (Teams / Slack / email HTML + plaintext) via the +/// shared detail-item path, without touching any individual renderer. +/// +public class AlertIncidentRenderTests +{ + private static readonly AlertBranding Branding = new("Test Edition", null); + + [Fact] + public void Apply_SetsIncidentsAndAppendsDetailWithoutDisturbingExistingOrder() + { + var ctx = new AlertContext(); + ctx.Details.Add(new AlertDetailItem { Heading = "Diagnosis" }); + + AlertIncidentRenderer.Apply(ctx, new[] { new AlertIncident("key1", new[] { "SalesDB.dbo.Orders" }) }); + + Assert.NotNull(ctx.Incidents); + Assert.Single(ctx.Incidents!); + Assert.Equal("Diagnosis", ctx.Details[0].Heading); // existing item untouched + var incident = ctx.Details.Single(d => d.Heading == "Incident"); + Assert.Contains(incident.Fields, f => f.Label == "Dedup Key" && f.Value == "key1"); + Assert.Contains(incident.Fields, f => f.Label == "Involved Objects" && f.Value == "SalesDB.dbo.Orders"); + } + + [Fact] + public void Apply_NoIncidents_IsNoOp() + { + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, null); + AlertIncidentRenderer.Apply(ctx, Array.Empty()); + Assert.Null(ctx.Incidents); + Assert.Empty(ctx.Details); + } + + [Fact] + public void Apply_MultipleIncidents_NumbersHeadingsShowsOccurrencesAndWaitRange() + { + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, new[] + { + new AlertIncident("k1", new[] { "db.dbo.A" }, OccurrenceCount: 8, WaitRange: "80-90 s"), + new AlertIncident("k2", new[] { "db.dbo.B" }) + }); + + var first = ctx.Details.Single(d => d.Heading == "Incident 1 of 2"); + var second = ctx.Details.Single(d => d.Heading == "Incident 2 of 2"); + Assert.Contains(first.Fields, f => f.Label == "Occurrences" && f.Value == "8"); + Assert.Contains(first.Fields, f => f.Label == "Wait Range" && f.Value == "80-90 s"); + Assert.DoesNotContain(second.Fields, f => f.Label == "Occurrences"); // count 1 is omitted + } + + [Fact] + public void Apply_UnresolvedObjects_RendersPlaceholder() + { + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, new[] { new AlertIncident("k", Array.Empty()) }); + var incident = ctx.Details.Single(d => d.Heading == "Incident"); + Assert.Contains(incident.Fields, f => f.Label == "Involved Objects" && f.Value == "(unresolved)"); + } + + [Fact] + public void DedupKey_RendersOnTeamsSlackAndBothEmailBodies() + { + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, new[] { new AlertIncident("fingerprint-abc", new[] { "SalesDB.dbo.Orders" }) }); + + var teams = WebhookAlertService.BuildTeamsPayload("Blocking Detected", "S1", "8", "n/a", Branding, context: ctx); + var slack = WebhookAlertService.BuildSlackPayload("Blocking Detected", "S1", "8", "n/a", Branding, context: ctx); + var (html, plain) = EmailTemplateBuilder.BuildAlertEmail("Blocking Detected", "S1", "8", "n/a", 15, Branding, ctx); + + Assert.Contains("fingerprint-abc", teams); + Assert.Contains("fingerprint-abc", slack); + Assert.Contains("fingerprint-abc", html); + Assert.Contains("fingerprint-abc", plain); + } +} diff --git a/Lite.Tests/AnalysisNotificationTests.cs b/Lite.Tests/AnalysisNotificationTests.cs index a8aa661b7..b645c0bab 100644 --- a/Lite.Tests/AnalysisNotificationTests.cs +++ b/Lite.Tests/AnalysisNotificationTests.cs @@ -306,6 +306,38 @@ public void AlertContext_SerializesAndDeserializes_PreservingFieldsBodyAndCodeBl Assert.Contains("sp_query_store_force_plan", tsql.Body); } + [Fact] + public void AlertContext_RoundTripsIncidents_AndLegacyJsonHasNullIncidents() + { + // #1140: the dedup incidents must survive the context_json round-trip (so the alert-history + // UI / MCP can show the key), and legacy contextJson written before the field existed must + // still deserialize with Incidents == null. + var context = new AlertContext + { + Incidents = new List + { + new("abc123", new[] { "SalesDB.dbo.Orders", "SalesDB.dbo.LineItems" }, OccurrenceCount: 8, WaitRange: "80.8-90.8 s"), + new("def456", new[] { "SalesDB.dbo.Inventory" }) + } + }; + + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + Assert.NotNull(restored.Incidents); + Assert.Equal(2, restored.Incidents!.Count); + Assert.Equal("abc123", restored.Incidents[0].DedupKey); + Assert.Equal(new[] { "SalesDB.dbo.Orders", "SalesDB.dbo.LineItems" }, restored.Incidents[0].InvolvedObjects); + Assert.Equal(8, restored.Incidents[0].OccurrenceCount); + Assert.Equal("80.8-90.8 s", restored.Incidents[0].WaitRange); + Assert.Equal("def456", restored.Incidents[1].DedupKey); + Assert.Equal(1, restored.Incidents[1].OccurrenceCount); + + // Backward-compat: legacy contextJson written before #1140 has no "Incidents" property. + Assert.True(AlertContextSerializer.TryDeserialize("{\"Details\":[]}", out var legacy)); + Assert.Null(legacy.Incidents); + } + /* ── AnalysisNotificationService: severity filter + cooldown ── */ /* TrySendAlertEmailAsync writes one config_alert_log row per call (regardless of whether a channel is configured), so the row count is an observable proxy for diff --git a/PerformanceMonitor.Notifications/AlertContext.cs b/PerformanceMonitor.Notifications/AlertContext.cs index e97d91677..b6822ad3a 100644 --- a/PerformanceMonitor.Notifications/AlertContext.cs +++ b/PerformanceMonitor.Notifications/AlertContext.cs @@ -31,8 +31,29 @@ public class AlertContext /// JSON projection () need not carry it. /// public AlertSeverityLevel? SeverityOverride { get; set; } + + /// + /// One entry per distinct grouped incident this alert covers (#1140). Carries the stable + /// dedup fingerprint + human-readable involved objects that downstream automation uses to + /// collapse recurrences of the same incident. null/empty = no fingerprintable incident + /// (alert type not wired, or no objects resolvable). Persisted in the alert-history context JSON. + /// + public List? Incidents { get; set; } } +/// +/// A single dedup-able incident within an alert (#1140): a stable fingerprint, the fully-qualified +/// objects it involves (human-readable, for the ticket body), the grouped occurrence count, and an +/// optional display-only wait range. Volatile per-sample fields (wait time, durations, SPIDs) are +/// never part of — only the identity members hashed by +/// . +/// +public sealed record AlertIncident( + string DedupKey, + IReadOnlyList InvolvedObjects, + int OccurrenceCount = 1, + string? WaitRange = null); + /// /// A single detail item (e.g., one blocking chain or one deadlock participant). /// @@ -75,10 +96,17 @@ public class AlertDetailItem /// / /// are deliberately not persisted (the dialog has no attachment surface). /// -public record AlertContextDto(List Details); +public record AlertContextDto(List Details, List? Incidents = null); public record AlertDetailItemDto(string Heading, List Fields, string? Body, bool IsCodeBlock, RemediationActionDto? Remediation = null); public record FieldDto(string Label, string Value); +/// +/// JSON mirror of (#1140). The trailing optional Incidents +/// member on keeps the round-trip backward-compatible: legacy +/// contextJson written before this field existed deserializes Incidents to null. +/// +public record AlertIncidentDto(string DedupKey, List InvolvedObjects, int OccurrenceCount = 1, string? WaitRange = null); + /// /// JSON mirror of / /// (PerformanceMonitor.Analysis). The trailing optional member on @@ -228,7 +256,12 @@ public static string Serialize(AlertContext context) d.Fields.ConvertAll(f => new FieldDto(f.Label, f.Value)), d.Body, d.IsCodeBlock, - ToDto(d.Remediation)))); + ToDto(d.Remediation))), + context.Incidents?.ConvertAll(i => new AlertIncidentDto( + i.DedupKey, + new List(i.InvolvedObjects), + i.OccurrenceCount, + i.WaitRange))); return JsonSerializer.Serialize(dto); } @@ -296,6 +329,21 @@ public static bool TryDeserialize(string? json, out AlertContext context) } context.Details.Add(item); } + + /* #1140: rehydrate the dedup incidents. Legacy contextJson without the field leaves + dto.Incidents null -> context.Incidents stays null (backward-compatible). */ + if (dto.Incidents is { Count: > 0 }) + { + context.Incidents = new List(dto.Incidents.Count); + foreach (var i in dto.Incidents) + { + context.Incidents.Add(new AlertIncident( + i.DedupKey ?? string.Empty, + i.InvolvedObjects ?? new List(), + i.OccurrenceCount, + i.WaitRange)); + } + } return true; } catch diff --git a/PerformanceMonitor.Notifications/AlertFingerprint.cs b/PerformanceMonitor.Notifications/AlertFingerprint.cs new file mode 100644 index 000000000..0b2bd5778 --- /dev/null +++ b/PerformanceMonitor.Notifications/AlertFingerprint.cs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PerformanceMonitor.Notifications; + +/// +/// Computes the stable, machine-readable dedup fingerprint and the human-readable involved-objects +/// list that ride on an (#1140). Downstream automation (e.g. a Logic App +/// creating Azure DevOps tickets) matches on the fingerprint to collapse recurrences of the same +/// incident instead of opening a new ticket each time. +/// +/// +/// The fingerprint hashes only STABLE identity members — the involved objects (or a natural key like +/// a query_hash / job name / mount point), scoped by server and incident type. Volatile per-sample +/// values (wait time, durations, SPIDs, counts) are deliberately excluded so the same incident hashes +/// identically across collection cycles. Reuses the SHA-256 idiom from +/// PerformanceMonitor.Analysis.InferenceEngine. +/// +/// +public static class AlertFingerprint +{ + /// Incident-type tags hashed into the key so the same objects under different incident + /// kinds (a deadlock vs a blocking chain on the same table) never collide. + public const string Deadlock = "deadlock"; + public const string Blocking = "blocking"; + public const string Query = "query"; + public const string Job = "job"; + public const string Disk = "disk"; + + private static readonly Regex s_whitespace = new(@"\s+", RegexOptions.Compiled); + + /// + /// Builds an incident from the set of fully-qualified objects it involves. The fingerprint is a + /// hash over (serverName, incidentType, sorted-distinct-normalized objects); the returned + /// preserves the original casing (deduped, ordered) + /// for display. Returns null when no usable object remains (caller emits no incident). + /// + public static AlertIncident? ForObjects( + string serverName, + string incidentType, + IEnumerable objects, + int occurrenceCount = 1, + string? waitRange = null) + { + // Dedup case-insensitively by normalized form, keep the first original casing for display, + // order by normalized form so both the hash input and the display list are stable. + var byNormalized = new SortedDictionary(StringComparer.Ordinal); + if (objects is not null) + { + foreach (var o in objects) + { + var normalized = Normalize(o); + if (normalized.Length == 0) + continue; + if (!byNormalized.ContainsKey(normalized)) + byNormalized[normalized] = (o ?? string.Empty).Trim(); + } + } + + if (byNormalized.Count == 0) + return null; + + var key = Hash(BuildInput(serverName, incidentType, byNormalized.Keys)); + return new AlertIncident(key, byNormalized.Values.ToList(), occurrenceCount, waitRange); + } + + /// + /// Builds an incident from a single natural key (a query_hash, job name, mount point, or a + /// "database:object_id" fallback) rather than an object set. The fingerprint hashes + /// (serverName, incidentType, normalized naturalKey); are + /// shown to the user but do NOT affect the key. Returns null when the key is blank. + /// + public static AlertIncident? ForKey( + string serverName, + string incidentType, + string naturalKey, + IReadOnlyList? displayObjects = null, + int occurrenceCount = 1, + string? waitRange = null) + { + var normalized = Normalize(naturalKey); + if (normalized.Length == 0) + return null; + + var key = Hash(BuildInput(serverName, incidentType, new[] { normalized })); + return new AlertIncident(key, displayObjects ?? Array.Empty(), occurrenceCount, waitRange); + } + + /// SHA-256 of as lowercase hex (64 chars). Public for tests. + public static string Hash(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input ?? string.Empty)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + // serverName | incidentType | identity members. serverName/incidentType are normalized here; + // members arrive already normalized. The server scope makes the same object name on two + // instances two distinct incidents; the type tag separates deadlock from blocking. + private static string BuildInput(string serverName, string incidentType, IEnumerable normalizedMembers) + { + var sb = new StringBuilder(); + sb.Append(Normalize(serverName)).Append('|').Append(Normalize(incidentType)); + foreach (var member in normalizedMembers) + sb.Append('|').Append(member); + return sb.ToString(); + } + + // Trim, collapse internal whitespace runs to a single space, lower-invariant. Stable identity + // at the cost of merging names that differ only by case (accepted; see plan SS9 Q3). + private static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + return s_whitespace.Replace(value.Trim(), " ").ToLowerInvariant(); + } +} diff --git a/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs b/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs new file mode 100644 index 000000000..60fbffcb1 --- /dev/null +++ b/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs @@ -0,0 +1,58 @@ +/* + * 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.Collections.Generic; + +namespace PerformanceMonitor.Notifications; + +/// +/// Projects the structured (#1140) into renderable +/// s so the dedup fingerprint + involved objects appear on every +/// surface (Teams facts, Slack fields, email HTML/plaintext, in-app dialog) WITHOUT touching any +/// renderer — they all iterate . +/// +/// Items are appended (not prepended) so the existing detail order — e.g. the finding path's +/// Diagnosis → Advice → T-SQL → drill-down — is preserved. Downstream automation keys on the fact +/// name ("Dedup Key"), and incidents are the only producer of that fact, so the first +/// "Dedup Key" fact is always the primary incident regardless of absolute position. +/// +/// +public static class AlertIncidentRenderer +{ + /// + /// Sets .Incidents and appends one detail item per incident. No-op when + /// there are no incidents. Call once from each alert builder after computing the incidents. + /// + public static void Apply(AlertContext context, IReadOnlyList? incidents) + { + if (incidents is not { Count: > 0 }) + return; + + context.Incidents = new List(incidents); + + for (int n = 0; n < incidents.Count; n++) + { + var incident = incidents[n]; + var item = new AlertDetailItem + { + Heading = incidents.Count == 1 ? "Incident" : $"Incident {n + 1} of {incidents.Count}" + }; + item.Fields.Add(("Dedup Key", incident.DedupKey)); + item.Fields.Add(("Involved Objects", + incident.InvolvedObjects.Count > 0 + ? string.Join(", ", incident.InvolvedObjects) + : "(unresolved)")); + if (incident.OccurrenceCount > 1) + item.Fields.Add(("Occurrences", incident.OccurrenceCount.ToString())); + if (!string.IsNullOrEmpty(incident.WaitRange)) + item.Fields.Add(("Wait Range", incident.WaitRange)); + + context.Details.Add(item); + } + } +} From f97a1407f8d96bf7fbc6a2565c1372f5484f4a0c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:09:39 -0400 Subject: [PATCH 004/145] #1140/#1141: shared blocking + deadlock incident groupers Extracts the grouping + fingerprint logic out of the (untestable) WPF builders into shared, unit-tested helpers both apps' live builders will call, so grouping/fingerprint are identical across Lite and Dashboard: - BlockingIncidentGrouper: collapses blocked-process samples that are one chain into a single incident with the true occurrence count + wait range (fixes gotqn's "same chain shown 3x, count says 8"). Identity = resolved contentious object, else database + literal-stripped blocked/blocking query pair. - DeadlockIncidentGrouper: groups deadlocks by sorted involved-object set (multi-DB deadlock = one incident; recurrences collapse with a count). - DeadlockObjectExtractor: pulls db.schema.object names from a deadlock graph resource-list (Lite's source; Dashboard already has DeadlockItem.ObjectNames). 10 unit tests covering chain collapse, literal-varying grouping, object-vs-query-pair identity, multi-DB deadlock, order-independence, and XML extraction. Refs #1140 #1141 Co-Authored-By: Claude Opus 4.8 (1M context) --- Lite.Tests/IncidentGroupingTests.cs | 152 +++++++++++++ .../DeadlockObjectExtractor.cs | 63 ++++++ .../IncidentGrouping.cs | 199 ++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 Lite.Tests/IncidentGroupingTests.cs create mode 100644 PerformanceMonitor.Notifications/DeadlockObjectExtractor.cs create mode 100644 PerformanceMonitor.Notifications/IncidentGrouping.cs diff --git a/Lite.Tests/IncidentGroupingTests.cs b/Lite.Tests/IncidentGroupingTests.cs new file mode 100644 index 000000000..247325a10 --- /dev/null +++ b/Lite.Tests/IncidentGroupingTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Guards the shared incident groupers (#1140 / #1141): blocking rows that are one chain collapse to a +/// single incident with the true count + wait range (gotqn's "shown 3x" report), and deadlocks group by +/// involved-object set. Both apps' live builders call these, so this is the canonical grouping coverage. +/// +public class IncidentGroupingTests +{ + private const string Server = "SQL2022"; + + private static BlockingIncidentGrouper.BlockedEvent Blocked( + string? db, string? obj, string? blocked, string? blocking, long waitMs) => + new(db, obj, blocked, blocking, waitMs); + + [Fact] + public void Blocking_SameChainAcrossSamples_CollapsesToOneIncidentWithCountAndWaitRange() + { + // gotqn: one card showed the same chain 3x differing only by wait time (90.8 / 85.8 / 80.8 s). + var events = new[] + { + Blocked("SalesDB", null, "UPDATE SurveyInstances SET x = 1", "SELECT * FROM A CROSS JOIN B", 90800), + Blocked("SalesDB", null, "UPDATE SurveyInstances SET x = 1", "SELECT * FROM A CROSS JOIN B", 85800), + Blocked("SalesDB", null, "UPDATE SurveyInstances SET x = 1", "SELECT * FROM A CROSS JOIN B", 80800), + }; + + var groups = BlockingIncidentGrouper.Group(Server, events); + + Assert.Single(groups); + Assert.Equal(3, groups[0].OccurrenceCount); + Assert.Equal(80800, groups[0].MinWaitMs); + Assert.Equal(90800, groups[0].MaxWaitMs); + Assert.Equal("80.8s-90.8s", groups[0].Incident.WaitRange); + Assert.Equal(3, groups[0].Incident.OccurrenceCount); + Assert.False(string.IsNullOrEmpty(groups[0].Incident.DedupKey)); + } + + [Fact] + public void Blocking_LiteralVaryingQueries_StillGroupTogether() + { + var events = new[] + { + Blocked("DB", null, "UPDATE T SET c = 1 WHERE id = 5", "SELECT * FROM T WHERE id = 5", 1000), + Blocked("DB", null, "UPDATE T SET c = 1 WHERE id = 99", "SELECT * FROM T WHERE id = 99", 2000), + }; + + var groups = BlockingIncidentGrouper.Group(Server, events); + Assert.Single(groups); + Assert.Equal(2, groups[0].OccurrenceCount); + } + + [Fact] + public void Blocking_DistinctChains_ProduceDistinctIncidents() + { + var events = new[] + { + Blocked("DB", null, "UPDATE A SET c = 1", "SELECT * FROM A", 1000), + Blocked("DB", null, "UPDATE B SET c = 1", "SELECT * FROM B", 1000), + }; + + var groups = BlockingIncidentGrouper.Group(Server, events); + Assert.Equal(2, groups.Count); + Assert.NotEqual(groups[0].Incident.DedupKey, groups[1].Incident.DedupKey); + } + + [Fact] + public void Blocking_ContentiousObject_DrivesIdentityAndInvolvedObjects() + { + // When the object is resolved, two different query pairs on the SAME object are one incident. + var events = new[] + { + Blocked("DB", "DB.dbo.Orders", "UPDATE Orders SET a = 1", "SELECT * FROM Orders WHERE x = 1", 1000), + Blocked("DB", "DB.dbo.Orders", "DELETE Orders WHERE z = 2", "SELECT TOP 1 * FROM Orders", 3000), + }; + + var groups = BlockingIncidentGrouper.Group(Server, events); + Assert.Single(groups); + Assert.Equal(2, groups[0].OccurrenceCount); + Assert.Equal(new[] { "DB.dbo.Orders" }, groups[0].Incident.InvolvedObjects); + } + + [Fact] + public void Blocking_EmptyInput_ReturnsEmpty() + { + Assert.Empty(BlockingIncidentGrouper.Group(Server, Array.Empty())); + } + + [Fact] + public void Deadlock_SameObjectSet_CollapsesAndCounts() + { + var events = new[] + { + new DeadlockIncidentGrouper.DeadlockEvent(new[] { "SalesDB.dbo.Orders", "SalesDB.dbo.LineItems" }), + new DeadlockIncidentGrouper.DeadlockEvent(new[] { "SalesDB.dbo.LineItems", "SalesDB.dbo.Orders" }), // order swapped + }; + + var groups = DeadlockIncidentGrouper.Group(Server, events); + Assert.Single(groups); + Assert.Equal(2, groups[0].OccurrenceCount); + Assert.Equal(2, groups[0].Incident.OccurrenceCount); + Assert.Equal(new[] { "SalesDB.dbo.LineItems", "SalesDB.dbo.Orders" }, groups[0].Incident.InvolvedObjects); + } + + [Fact] + public void Deadlock_DistinctObjectSets_ProduceDistinctIncidents() + { + var events = new[] + { + new DeadlockIncidentGrouper.DeadlockEvent(new[] { "DB.dbo.A" }), + new DeadlockIncidentGrouper.DeadlockEvent(new[] { "DB.dbo.B" }), + }; + + var groups = DeadlockIncidentGrouper.Group(Server, events); + Assert.Equal(2, groups.Count); + Assert.NotEqual(groups[0].Incident.DedupKey, groups[1].Incident.DedupKey); + } + + [Fact] + public void Deadlock_NoParseableObjects_AreSkipped() + { + var events = new[] { new DeadlockIncidentGrouper.DeadlockEvent(Array.Empty()) }; + Assert.Empty(DeadlockIncidentGrouper.Group(Server, events)); + } + + [Fact] + public void DeadlockObjectExtractor_PullsObjectNamesFromResourceList() + { + const string xml = @" + + + + + +"; + + var objects = DeadlockObjectExtractor.FromGraphXml(xml); + Assert.Equal(new[] { "SalesDB.dbo.LineItems", "SalesDB.dbo.Orders" }, objects); // distinct + sorted + } + + [Fact] + public void DeadlockObjectExtractor_MalformedOrEmpty_ReturnsEmpty() + { + Assert.Empty(DeadlockObjectExtractor.FromGraphXml(null)); + Assert.Empty(DeadlockObjectExtractor.FromGraphXml("not xml <<<")); + Assert.Empty(DeadlockObjectExtractor.FromGraphXml("")); + } +} diff --git a/PerformanceMonitor.Notifications/DeadlockObjectExtractor.cs b/PerformanceMonitor.Notifications/DeadlockObjectExtractor.cs new file mode 100644 index 000000000..84d349470 --- /dev/null +++ b/PerformanceMonitor.Notifications/DeadlockObjectExtractor.cs @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace PerformanceMonitor.Notifications; + +/// +/// Extracts the distinct fully-qualified object names (database.schema.object) from a deadlock +/// graph's <resource-list> for the #1140 fingerprint. Mirrors the object extraction the +/// Lite deadlock detail UI already does (LocalDataService deadlock parse) so Lite's live "Deadlocks +/// Detected" builder can feed without re-querying. Dashboard +/// already carries the equivalent on DeadlockItem.ObjectNames and need not parse XML. +/// +public static class DeadlockObjectExtractor +{ + // Lock resource elements that carry an objectname attribute (matches the Lite UI parser). + private static readonly string[] s_lockTypes = + { "objectlock", "pagelock", "keylock", "ridlock", "rowgrouplock" }; + + /// + /// Returns the distinct object names across all lock resources in the graph, or an empty list when + /// the XML is blank, unparseable, or carries no named objects. Never throws. + /// + public static IReadOnlyList FromGraphXml(string? graphXml) + { + if (string.IsNullOrWhiteSpace(graphXml)) + return Array.Empty(); + + try + { + var doc = XElement.Parse(graphXml); + var resourceList = doc.Descendants("resource-list").FirstOrDefault(); + if (resourceList is null) + return Array.Empty(); + + var names = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var lockType in s_lockTypes) + { + foreach (var node in resourceList.Elements(lockType)) + { + var objectName = node.Attribute("objectname")?.Value; + if (!string.IsNullOrWhiteSpace(objectName)) + names.Add(objectName.Trim()); + } + } + + return names.Count == 0 ? Array.Empty() : names.ToList(); + } + catch + { + return Array.Empty(); + } + } +} diff --git a/PerformanceMonitor.Notifications/IncidentGrouping.cs b/PerformanceMonitor.Notifications/IncidentGrouping.cs new file mode 100644 index 000000000..eabdce562 --- /dev/null +++ b/PerformanceMonitor.Notifications/IncidentGrouping.cs @@ -0,0 +1,199 @@ +/* + * 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.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace PerformanceMonitor.Notifications; + +/// +/// Groups raw blocked-process rows into distinct incidents (#1140 / #1141). Today's alert lists the +/// same blocking chain several times (one row per sample, differing only by wait time) and caps the +/// list at a few rows while the count says more — gotqn's report. This collapses rows that share an +/// incident identity into one group carrying the true occurrence count and wait range, and attaches +/// the stable dedup fingerprint. Both apps' live "Blocking Detected" builders call this, so the +/// grouping + fingerprint are identical across Lite and Dashboard. +/// +/// Incident identity (volatile fields excluded): the resolved contentious object when available +/// (Dashboard's contentious_object / Lite's event-resolved object — stable across index +/// rebuilds), else database + literal-stripped blocked/blocking query pair, matching plan §4.2. +/// +/// +public static class BlockingIncidentGrouper +{ + /// One blocked-process sample projected from each app's row model into a shared shape. + public readonly record struct BlockedEvent( + string? Database, + string? ContentiousObject, + string? BlockedQuery, + string? BlockingQuery, + long WaitTimeMs); + + /// One distinct blocking incident: a representative chain, its true occurrence count and + /// wait range, and the dedup . + public sealed record BlockingGroup( + string? Database, + string? ContentiousObject, + string? BlockedQuery, + string? BlockingQuery, + int OccurrenceCount, + long MinWaitMs, + long MaxWaitMs, + AlertIncident Incident); + + private static readonly Regex s_stringLiteral = new("'(?:[^']|'')*'", RegexOptions.Compiled); + private static readonly Regex s_numericLiteral = new(@"\b\d+(\.\d+)?\b", RegexOptions.Compiled); + private static readonly Regex s_whitespace = new(@"\s+", RegexOptions.Compiled); + + /// + /// Collapses the events into one group per distinct incident, preserving input order of first + /// appearance, with the highest occurrence count first. Empty input returns an empty list. + /// + public static List Group(string serverName, IEnumerable events) + { + var order = new List(); + var buckets = new Dictionary>(StringComparer.Ordinal); + + foreach (var e in events ?? Enumerable.Empty()) + { + var identity = IdentityKey(e); + if (!buckets.TryGetValue(identity, out var list)) + { + list = new List(); + buckets[identity] = list; + order.Add(identity); + } + list.Add(e); + } + + var groups = new List(order.Count); + foreach (var identity in order) + { + var rows = buckets[identity]; + var representative = rows[0]; + var minWait = rows.Min(r => r.WaitTimeMs); + var maxWait = rows.Max(r => r.WaitTimeMs); + var waitRange = FormatWaitRange(minWait, maxWait); + + var hasObject = !string.IsNullOrWhiteSpace(representative.ContentiousObject); + var incident = hasObject + ? AlertFingerprint.ForObjects( + serverName, AlertFingerprint.Blocking, + new[] { representative.ContentiousObject! }, rows.Count, waitRange) + : AlertFingerprint.ForKey( + serverName, AlertFingerprint.Blocking, + QueryPairKey(representative), + DatabaseDisplay(representative.Database), rows.Count, waitRange); + + // ForObjects/ForKey only return null on empty identity, which IdentityKey already excludes, + // so incident is non-null here; guard defensively rather than assert. + if (incident is null) + continue; + + groups.Add(new BlockingGroup( + representative.Database, representative.ContentiousObject, + representative.BlockedQuery, representative.BlockingQuery, + rows.Count, minWait, maxWait, incident)); + } + + groups.Sort((a, b) => b.OccurrenceCount.CompareTo(a.OccurrenceCount)); + return groups; + } + + private static string IdentityKey(BlockedEvent e) => + !string.IsNullOrWhiteSpace(e.ContentiousObject) + ? "obj|" + Norm(e.Database) + "|" + Norm(e.ContentiousObject) + : "qp|" + QueryPairKey(e); + + private static string QueryPairKey(BlockedEvent e) => + Norm(e.Database) + "|" + NormalizeQuery(e.BlockedQuery) + "|" + NormalizeQuery(e.BlockingQuery); + + private static string[] DatabaseDisplay(string? database) => + string.IsNullOrWhiteSpace(database) ? Array.Empty() : new[] { database! }; + + private static string Norm(string? s) => + string.IsNullOrWhiteSpace(s) ? string.Empty : s_whitespace.Replace(s.Trim(), " ").ToLowerInvariant(); + + // Strip string + numeric literals so chains differing only by parameter values group together + // ("WHERE id = 5" and "WHERE id = 9" -> the same key), then collapse whitespace + lower. + private static string NormalizeQuery(string? query) + { + if (string.IsNullOrWhiteSpace(query)) + return string.Empty; + var s = s_stringLiteral.Replace(query, "'?'"); + s = s_numericLiteral.Replace(s, "?"); + return s_whitespace.Replace(s.Trim(), " ").ToLowerInvariant(); + } + + private static string FormatWaitRange(long minMs, long maxMs) + { + string Sec(long ms) => (ms / 1000.0).ToString("F1", CultureInfo.InvariantCulture) + "s"; + return minMs == maxMs ? Sec(maxMs) : Sec(minMs) + "-" + Sec(maxMs); + } +} + +/// +/// Groups deadlock events by the sorted set of fully-qualified objects involved (#1140). One +/// deadlock spanning multiple databases/objects is a single incident listing them all; recurrences +/// over the same object set collapse to one fingerprint with the occurrence count. Both apps feed +/// the per-event object lists (Dashboard from DeadlockItem.ObjectNames, Lite via +/// ) so the fingerprint is identical across them. +/// +public static class DeadlockIncidentGrouper +{ + /// One deadlock event projected to the distinct fully-qualified objects it involved. + public readonly record struct DeadlockEvent(IReadOnlyList Objects); + + /// One distinct deadlock incident: the involved object set, occurrence count, and fingerprint. + public sealed record DeadlockGroup(IReadOnlyList Objects, int OccurrenceCount, AlertIncident Incident); + + /// + /// Collapses events sharing an involved-object set into one group (highest occurrence count first). + /// Events with no parseable objects are skipped (no fingerprintable identity); the builder still + /// displays them. Empty input returns an empty list. + /// + public static List Group(string serverName, IEnumerable events) + { + var order = new List(); + var counts = new Dictionary(StringComparer.Ordinal); + var incidents = new Dictionary(StringComparer.Ordinal); + + foreach (var e in events ?? Enumerable.Empty()) + { + // Probe identity first so we group by the SAME normalized set the fingerprint hashes. + var probe = AlertFingerprint.ForObjects(serverName, AlertFingerprint.Deadlock, e.Objects ?? Array.Empty()); + if (probe is null) + continue; // no parseable objects -> not fingerprintable + + var key = probe.DedupKey; + if (!counts.TryGetValue(key, out var current)) + { + order.Add(key); + incidents[key] = probe; + current = 0; + } + counts[key] = current + 1; + } + + var groups = new List(order.Count); + foreach (var key in order) + { + var count = counts[key]; + var baseIncident = incidents[key]; + // Re-stamp the occurrence count onto the incident (the probe used count 1). + var incident = baseIncident with { OccurrenceCount = count }; + groups.Add(new DeadlockGroup(incident.InvolvedObjects, count, incident)); + } + + groups.Sort((a, b) => b.OccurrenceCount.CompareTo(a.OccurrenceCount)); + return groups; + } +} From da88fea8dfc57dfcc45a153d294a33dbfc7957f9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:56:57 -0400 Subject: [PATCH 005/145] #1140/#1141: wire dedup fingerprint into Lite live alert builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the shared groupers/fingerprint into the live "Detected"/threshold builders in Lite (the path consumers actually receive): - BuildBlockingContextAsync: groups blocked-process samples via BlockingIncidentGrouper so one chain shows once with its true occurrence count + wait range (was listed once per sample, capped at 3 while the count said more), and surfaces "+N more" instead of silently dropping. Attaches the dedup fingerprint. (Object identity arrives once the Lite collector resolves contentious_object, plan §5.3; falls back to db+query-pair now.) - BuildDeadlockContextAsync: fingerprints by involved-object set parsed from the deadlock graph (DeadlockObjectExtractor) across ALL deadlocks in the window, grouped + counted. - BuildVolumeFreeSpaceContext / BuildAnomalousJobContext: per-volume / per-job dedup key. serverName threaded into all four builders (the fingerprint scopes on it). Lite builds clean, 0 warnings. LRQ builder still pending its query_hash collection change (§5.2). Refs #1140 #1141 Co-Authored-By: Claude Opus 4.8 (1M context) --- Lite/MainWindow.xaml.cs | 94 +++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index da99e7744..85a21f27f 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1665,7 +1665,7 @@ await _emailAlertService.TrySendAlertEmailAsync( _muteRuleService); } - var blockingContext = await BuildBlockingContextAsync(summary.ServerId); + var blockingContext = await BuildBlockingContextAsync(summary.ServerId, summary.DisplayName); var detailText = ContextToDetailText(blockingContext); await _emailAlertService.TrySendAlertEmailAsync( @@ -1736,7 +1736,7 @@ await _emailAlertService.TrySendAlertEmailAsync( _muteRuleService); } - var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId); + var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId, summary.DisplayName); var detailText = ContextToDetailText(deadlockContext); await _emailAlertService.TrySendAlertEmailAsync( @@ -2012,7 +2012,7 @@ standing full volume (which also re-recorded a history row and made Dismiss feel _muteRuleService); } - var lowDiskContext = BuildVolumeFreeSpaceContext(breached); + var lowDiskContext = BuildVolumeFreeSpaceContext(summary.DisplayName, breached); /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is critically low — so the email/webhook badge reflects how dire the breach is. (lowDiskContext is non-null here — breached.Count > 0 — but typed nullable.) */ @@ -2097,7 +2097,7 @@ that have aged past the cooldown each pass. */ _muteRuleService); } - var jobContext = BuildAnomalousJobContext(anomalousJobs); + var jobContext = BuildAnomalousJobContext(summary.DisplayName, anomalousJobs); var detailText = ContextToDetailText(jobContext); await _emailAlertService.TrySendAlertEmailAsync( @@ -2249,7 +2249,7 @@ private static string TruncateText(string text, int maxLength = 300) return sb.ToString().TrimEnd(); } - private async Task BuildBlockingContextAsync(int serverId) + private async Task BuildBlockingContextAsync(int serverId, string serverName) { try { @@ -2268,39 +2268,56 @@ private static string TruncateText(string text, int maxLength = 300) if (events.Count == 0) return null; } - var context = new AlertContext(); - var firstXml = (string?)null; + /* #1140/#1141: collapse samples of the same chain into one group (true occurrence count + + wait range) instead of listing it once per sample, and attach the dedup fingerprint. + ContentiousObject is null until Lite's blocked-process collector resolves it (plan + §5.3), so identity currently falls back to database + literal-stripped query pair. */ + var groups = BlockingIncidentGrouper.Group( + serverName, + events.Select(e => new BlockingIncidentGrouper.BlockedEvent( + e.DatabaseName, null, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs))); + + const int maxGroups = 10; + var shown = groups.Take(maxGroups).ToList(); - foreach (var e in events.Take(3)) + var context = new AlertContext(); + foreach (var g in shown) { var item = new AlertDetailItem { - Heading = $"Blocked #{e.BlockedSpid} by #{e.BlockingSpid}", + Heading = g.OccurrenceCount > 1 ? $"Blocking chain (x{g.OccurrenceCount})" : "Blocking chain", Fields = new() }; - - if (!string.IsNullOrEmpty(e.DatabaseName)) - item.Fields.Add(("Database", e.DatabaseName)); - if (!string.IsNullOrEmpty(e.BlockedSqlText)) - item.Fields.Add(("Blocked Query", TruncateText(e.BlockedSqlText))); - if (!string.IsNullOrEmpty(e.BlockingSqlText)) - item.Fields.Add(("Blocking Query", TruncateText(e.BlockingSqlText))); - item.Fields.Add(("Wait Time", e.WaitTimeFormatted)); - if (!string.IsNullOrEmpty(e.LockMode)) - item.Fields.Add(("Lock Mode", e.LockMode)); - + if (!string.IsNullOrEmpty(g.Database)) + item.Fields.Add(("Database", g.Database)); + if (!string.IsNullOrEmpty(g.BlockedQuery)) + item.Fields.Add(("Blocked Query", TruncateText(g.BlockedQuery))); + if (!string.IsNullOrEmpty(g.BlockingQuery)) + item.Fields.Add(("Blocking Query", TruncateText(g.BlockingQuery))); + item.Fields.Add(("Wait Range", g.Incident.WaitRange ?? g.MaxWaitMs.ToString())); context.Details.Add(item); - if (firstXml == null && e.HasReportXml) - firstXml = e.BlockedProcessReportXml; } + /* Surface the true total instead of silently dropping (gotqn's report). */ + if (groups.Count > maxGroups) + { + context.Details.Add(new AlertDetailItem + { + Heading = $"+{groups.Count - maxGroups} more distinct blocking incident(s)", + Fields = new() + }); + } + + var firstXml = events.FirstOrDefault(e => e.HasReportXml)?.BlockedProcessReportXml; if (!string.IsNullOrEmpty(firstXml)) { context.AttachmentXml = firstXml; context.AttachmentFileName = "blocked_process_report.xml"; } - return context; + AlertIncidentRenderer.Apply(context, shown.Select(g => g.Incident).ToList()); + + return context.Details.Count == 0 ? null : context; } catch (Exception ex) { @@ -2309,7 +2326,7 @@ private static string TruncateText(string text, int maxLength = 300) } } - private async Task BuildDeadlockContextAsync(int serverId) + private async Task BuildDeadlockContextAsync(int serverId, string serverName) { try { @@ -2353,6 +2370,15 @@ private static string TruncateText(string text, int maxLength = 300) context.AttachmentFileName = "deadlock_graph.xml"; } + /* #1140: fingerprint each deadlock by its sorted involved-object set (parsed from the + graph), across ALL deadlocks in the window — not just the 3 displayed — grouped so + recurrences over the same objects collapse to one incident with a count. */ + var groups = DeadlockIncidentGrouper.Group( + serverName, + deadlocks.Select(d => new DeadlockIncidentGrouper.DeadlockEvent( + DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml)))); + AlertIncidentRenderer.Apply(context, groups.Select(g => g.Incident).ToList()); + return context; } catch (Exception ex) @@ -2451,12 +2477,13 @@ private static string FormatLowDiskThreshold() return parts.Count > 0 ? string.Join(" / ", parts) : "—"; } - private static AlertContext? BuildVolumeFreeSpaceContext(List volumes) + private static AlertContext? BuildVolumeFreeSpaceContext(string serverName, List volumes) { if (volumes.Count == 0) return null; var context = new AlertContext(); - foreach (var v in volumes.GetRange(0, Math.Min(5, volumes.Count))) + var shown = volumes.GetRange(0, Math.Min(5, volumes.Count)); + foreach (var v in shown) { context.Details.Add(new AlertDetailItem { @@ -2469,6 +2496,11 @@ private static string FormatLowDiskThreshold() } }); } + + /* #1140: dedup key per volume (the drive/mount point). */ + AlertIncidentRenderer.Apply(context, shown + .Select(v => AlertFingerprint.ForKey(serverName, AlertFingerprint.Disk, v.MountPoint, new[] { v.MountPoint })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } @@ -2493,12 +2525,13 @@ private static string FormatLowDiskThreshold() return context; } - private static AlertContext? BuildAnomalousJobContext(List jobs) + private static AlertContext? BuildAnomalousJobContext(string serverName, List jobs) { if (jobs.Count == 0) return null; var context = new AlertContext(); - foreach (var j in jobs.GetRange(0, Math.Min(3, jobs.Count))) + var shown = jobs.GetRange(0, Math.Min(3, jobs.Count)); + foreach (var j in shown) { context.Details.Add(new AlertDetailItem { @@ -2513,6 +2546,11 @@ private static string FormatLowDiskThreshold() } }); } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName). */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } From 1e20c82f2c4482e4106cf62f1dfcf9c293620f88 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:22:20 -0400 Subject: [PATCH 006/145] #1140: wire dedup fingerprint into Dashboard live alert builders (parity with Lite) Mirrors the Lite wiring in the Dashboard's live builders so both apps emit identical fingerprints: - BuildBlockingContextAsync: dedup by the resolved contentious_object (already produced by sp_HumanEventsBlockViewer and surfaced on BlockingEventItem) via the shared grouper. - BuildDeadlockContextAsync: dedup by involved-object set parsed from the deadlock graph with the same shared DeadlockObjectExtractor Lite uses. - BuildLongRunningQueryContext: dedup key = query_hash, newly captured from sys.dm_exec_requests (CONVERT(varchar(18), r.query_hash, 1)) into LongRunningQueryInfo. - BuildVolumeFreeSpaceContext / BuildAnomalousJobContext: per-drive / per-job key. serverName threaded into all five builders + their five call sites. Dashboard needed no schema change (object_names + contentious_object already collected). Builds clean, 0 warnings. Refs #1140 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/MainWindow.xaml.cs | 64 +++++++++++++++---- Dashboard/Models/LongRunningQueryInfo.cs | 1 + .../Services/DatabaseService.NocHealth.cs | 6 +- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 6ff54dade..81d9dcd0a 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -1582,7 +1582,7 @@ either suppresses alerts (offset went back) or bypasses the cooldown (offset wen bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastBlockingAlert[serverId] = now; - var blockingContext = await BuildBlockingContextAsync(databaseService, prefs.AlertExcludedDatabases); + var blockingContext = await BuildBlockingContextAsync(serverName, databaseService, prefs.AlertExcludedDatabases); var detailText = ContextToDetailText(blockingContext) ?? $"Blocked Sessions: {(int)health.TotalBlocked}\nLongest Wait: {(int)health.LongestBlockedSeconds}s"; @@ -1648,7 +1648,7 @@ Falls back to the raw delta when no databases are excluded. */ bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastDeadlockAlert[serverId] = now; - var deadlockContext = await BuildDeadlockContextAsync(databaseService, prefs.AlertExcludedDatabases); + var deadlockContext = await BuildDeadlockContextAsync(serverName, databaseService, prefs.AlertExcludedDatabases); var detailText = ContextToDetailText(deadlockContext) ?? $"New Deadlocks: {effectiveDeadlockDelta}"; @@ -1897,7 +1897,7 @@ await _emailAlertService.TrySendAlertEmailAsync( }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLongRunningQueryAlert[serverId] = now; - var lrqContext = BuildLongRunningQueryContext(lrqList); + var lrqContext = BuildLongRunningQueryContext(serverName, lrqList); var detailText = ContextToDetailText(lrqContext); if (!isMuted) @@ -2009,7 +2009,7 @@ standing full volume (which also re-recorded a history row and made Dismiss feel bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLowDiskAlert[serverId] = now; _lastAlertedLowDiskPercent[serverId] = worst.FreePercent; - var lowDiskContext = BuildVolumeFreeSpaceContext(breachedVolumes); + var lowDiskContext = BuildVolumeFreeSpaceContext(serverName, breachedVolumes); /* #1136: grade the alert — WARNING normally, CRITICAL when the worst volume is critically low — so the email/webhook badge reflects how dire the breach is. (lowDiskContext is non-null here — breachedVolumes.Count > 0 — but typed nullable.) */ @@ -2082,7 +2082,7 @@ await _emailAlertService.TrySendAlertEmailAsync( var muteCtx = new AlertMuteContext { ServerName = serverName, MetricName = "Long-Running Job", JobName = worst.JobName }; bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastLongRunningJobAlert[jobKey] = now; - var jobContext = BuildAnomalousJobContext(health.AnomalousJobs); + var jobContext = BuildAnomalousJobContext(serverName, health.AnomalousJobs); var detailText = ContextToDetailText(jobContext); if (!isMuted) @@ -2195,7 +2195,7 @@ private static string Truncate(string text, int maxLength = 300) return sb.ToString().TrimEnd(); } - private static async Task BuildBlockingContextAsync(DatabaseService databaseService, List? excludedDatabases = null) + private static async Task BuildBlockingContextAsync(string serverName, DatabaseService databaseService, List? excludedDatabases = null) { try { @@ -2244,6 +2244,15 @@ private static string Truncate(string text, int maxLength = 300) context.AttachmentFileName = "blocked_process_report.xml"; } + /* #1140: dedup by the resolved contentious object across the blocked-process rows + (already populated by sp_HumanEventsBlockViewer); falls back to db+query-pair only + when an object did not resolve. Computed over ALL events, not just the 3 displayed. */ + AlertIncidentRenderer.Apply(context, BlockingIncidentGrouper.Group( + serverName, + events.Select(e => new BlockingIncidentGrouper.BlockedEvent( + e.DatabaseName, e.ContentiousObject, e.QueryText, null, e.WaitTimeMs ?? 0))) + .Select(g => g.Incident).ToList()); + return context; } catch (Exception ex) @@ -2253,7 +2262,7 @@ private static string Truncate(string text, int maxLength = 300) } } - private static async Task BuildDeadlockContextAsync(DatabaseService databaseService, List? excludedDatabases = null) + private static async Task BuildDeadlockContextAsync(string serverName, DatabaseService databaseService, List? excludedDatabases = null) { try { @@ -2312,6 +2321,16 @@ private static string Truncate(string text, int maxLength = 300) context.AttachmentFileName = "deadlock_graph.xml"; } + /* #1140: fingerprint each deadlock by its sorted involved-object set, parsed from the + deadlock graph (same DeadlockObjectExtractor Lite uses, for parity), grouped per + deadlock event across ALL events in the window. */ + AlertIncidentRenderer.Apply(context, DeadlockIncidentGrouper.Group( + serverName, + deadlocks.GroupBy(d => d.EventDate).Select(g => new DeadlockIncidentGrouper.DeadlockEvent( + DeadlockObjectExtractor.FromGraphXml( + g.Select(x => x.DeadlockGraph).FirstOrDefault(x => !string.IsNullOrEmpty(x)))))) + .Select(g => g.Incident).ToList()); + return context; } catch (Exception ex) @@ -2365,12 +2384,13 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List exclu return context; } - private static AlertContext? BuildLongRunningQueryContext(List queries) + private static AlertContext? BuildLongRunningQueryContext(string serverName, List queries) { if (queries.Count == 0) return null; var context = new AlertContext(); - foreach (var q in queries.GetRange(0, Math.Min(3, queries.Count))) + var shown = queries.GetRange(0, Math.Min(3, queries.Count)); + foreach (var q in shown) { var item = new AlertDetailItem { @@ -2394,15 +2414,22 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List exclu context.Details.Add(item); } + + /* #1140: dedup key = query_hash (stable across literals/plans). Null hash -> no incident. */ + AlertIncidentRenderer.Apply(context, shown + .Select(q => AlertFingerprint.ForKey(serverName, AlertFingerprint.Query, q.QueryHash ?? "", + string.IsNullOrEmpty(q.DatabaseName) ? System.Array.Empty() : new[] { q.DatabaseName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } - private static AlertContext? BuildAnomalousJobContext(List jobs) + private static AlertContext? BuildAnomalousJobContext(string serverName, List jobs) { if (jobs.Count == 0) return null; var context = new AlertContext(); - foreach (var j in jobs.GetRange(0, Math.Min(3, jobs.Count))) + var shown = jobs.GetRange(0, Math.Min(3, jobs.Count)); + foreach (var j in shown) { context.Details.Add(new AlertDetailItem { @@ -2417,6 +2444,11 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List exclu } }); } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName). */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } @@ -2464,12 +2496,13 @@ private static string FormatLowDiskThreshold(UserPreferences prefs) return parts.Count > 0 ? string.Join(" / ", parts) : "—"; } - private static AlertContext? BuildVolumeFreeSpaceContext(List volumes) + private static AlertContext? BuildVolumeFreeSpaceContext(string serverName, List volumes) { if (volumes.Count == 0) return null; var context = new AlertContext(); - foreach (var v in volumes.GetRange(0, Math.Min(5, volumes.Count))) + var shown = volumes.GetRange(0, Math.Min(5, volumes.Count)); + foreach (var v in shown) { context.Details.Add(new AlertDetailItem { @@ -2482,6 +2515,11 @@ private static string FormatLowDiskThreshold(UserPreferences prefs) } }); } + + /* #1140: dedup key per volume (the drive/mount point). */ + AlertIncidentRenderer.Apply(context, shown + .Select(v => AlertFingerprint.ForKey(serverName, AlertFingerprint.Disk, v.MountPoint, new[] { v.MountPoint })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } diff --git a/Dashboard/Models/LongRunningQueryInfo.cs b/Dashboard/Models/LongRunningQueryInfo.cs index db2d8f74e..4d9ee84c8 100644 --- a/Dashboard/Models/LongRunningQueryInfo.cs +++ b/Dashboard/Models/LongRunningQueryInfo.cs @@ -18,5 +18,6 @@ public class LongRunningQueryInfo public long Writes { get; set; } public string? WaitType { get; set; } public int? BlockingSessionId { get; set; } + public string? QueryHash { get; set; } } } diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/Dashboard/Services/DatabaseService.NocHealth.cs index 54bf90028..691d31fa9 100644 --- a/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/Dashboard/Services/DatabaseService.NocHealth.cs @@ -824,7 +824,8 @@ SELECT TOP(@maxResults) r.reads, r.writes, r.wait_type, - r.blocking_session_id + r.blocking_session_id, + CONVERT(varchar(18), r.query_hash, 1) AS query_hash FROM sys.dm_exec_requests AS r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id @@ -862,7 +863,8 @@ ORDER BY r.total_elapsed_time DESC Reads = Convert.ToInt64(reader.GetValue(6), System.Globalization.CultureInfo.InvariantCulture), Writes = Convert.ToInt64(reader.GetValue(7), System.Globalization.CultureInfo.InvariantCulture), WaitType = reader.IsDBNull(8) ? null : reader.GetString(8), - BlockingSessionId = reader.IsDBNull(9) ? null : (int?)Convert.ToInt32(reader.GetValue(9), System.Globalization.CultureInfo.InvariantCulture) + BlockingSessionId = reader.IsDBNull(9) ? null : (int?)Convert.ToInt32(reader.GetValue(9), System.Globalization.CultureInfo.InvariantCulture), + QueryHash = reader.IsDBNull(10) ? null : reader.GetString(10) }); } } From 2c4e55b3a6efd4c0ee858eb9365a7b2e902be8e4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:35:56 -0400 Subject: [PATCH 007/145] =?UTF-8?q?#1140:=20Lite=20collector=20changes=20?= =?UTF-8?q?=E2=80=94=20blocking=20contentious=5Fobject=20+=20LRQ=20query?= =?UTF-8?q?=5Fhash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Lite live-path parity by collecting the two identity fields Lite was missing (validated against SQL 2022): - blocked_process_reports: capture the blocked_process_report event's own object_id/database_id and resolve contentious_object server-side in the collection query, mirroring sp_HumanEventsBlockViewer EXACTLY (2-part schema.object + identical 'Unresolved: ...' fallback) so the fingerprint matches the Dashboard for the same object. New columns added at the end of the table + appender; v30 migration (ALTER ADD COLUMN); v_ views union BY NAME so old parquet reads back NULL. BuildBlockingContextAsync now uses the resolved object as the identity. - query_snapshots: capture query_hash (CONVERT(varchar(18), query_hash, 1)) in both the on-prem and Azure (#req) snapshot queries; surface it through GetLongRunningQueriesAsync; the Lite LRQ builder now emits a query_hash dedup key. Schema v29 -> v30. Lite builds clean, 0 warnings. Refs #1140 Co-Authored-By: Claude Opus 4.8 (1M context) --- Lite/Database/DuckDbInitializer.cs | 23 +++++- Lite/Database/Schema.cs | 8 +- Lite/MainWindow.xaml.cs | 19 +++-- Lite/Services/LocalDataService.Blocking.cs | 7 +- Lite/Services/LocalDataService.WaitStats.cs | 7 +- ...teCollectorService.BlockedProcessReport.cs | 80 +++++++++++++++---- .../RemoteCollectorService.QuerySnapshots.cs | 10 ++- 7 files changed, 124 insertions(+), 30 deletions(-) diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index ffa4e164d..ca5a4442b 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -97,7 +97,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 29; + internal const int CurrentSchemaVersion = 30; private readonly string _archivePath; @@ -749,6 +749,27 @@ Appended at the end to match the DuckDB appender's positional order. */ _logger?.LogWarning("Migration to v28 encountered an error (non-fatal): {Error}", ex.Message); } } + + if (fromVersion < 30) + { + /* v30 (#1140): dedup-fingerprint support. blocked_process_reports gains the contentious + object the blocked_process_report event already carries (object_id/database_id) plus the + resolved name; query_snapshots gains query_hash for the long-running-query dedup key. + Appended at the end to keep the positional appender aligned; the v_ views union BY NAME + so old parquet reads back NULL for these. */ + _logger?.LogInformation("Running migration to v30: dedup fingerprint columns (#1140)"); + try + { + await ExecuteNonQueryAsync(connection, "ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS object_id INTEGER"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS database_id INTEGER"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE blocked_process_reports ADD COLUMN IF NOT EXISTS contentious_object VARCHAR"); + await ExecuteNonQueryAsync(connection, "ALTER TABLE query_snapshots ADD COLUMN IF NOT EXISTS query_hash VARCHAR"); + } + catch (Exception ex) + { + _logger?.LogWarning("Migration to v30 encountered an error (non-fatal): {Error}", ex.Message); + } + } } /// diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 99707ca20..1b059697d 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -347,7 +347,8 @@ granted_query_memory_gb DECIMAL(18,2), program_name VARCHAR, open_transaction_count INTEGER, percent_complete DECIMAL(5,2), - is_cdc_capture BOOLEAN DEFAULT false + is_cdc_capture BOOLEAN DEFAULT false, + query_hash VARCHAR )"; public const string CreateTempdbStatsTable = @" @@ -468,7 +469,10 @@ CREATE TABLE IF NOT EXISTS blocked_process_reports ( blocking_last_batch_completed TIMESTAMP, blocked_priority INTEGER, blocking_priority INTEGER, - blocked_process_report_xml VARCHAR + blocked_process_report_xml VARCHAR, + object_id INTEGER, + database_id INTEGER, + contentious_object VARCHAR )"; public const string CreateDatabaseConfigTable = @" diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 85a21f27f..a9413270c 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1876,7 +1876,7 @@ await _emailAlertService.TrySendAlertEmailAsync( _muteRuleService); } - var lrqContext = BuildLongRunningQueryContext(longRunning); + var lrqContext = BuildLongRunningQueryContext(summary.DisplayName, longRunning); var detailText = ContextToDetailText(lrqContext); await _emailAlertService.TrySendAlertEmailAsync( @@ -2270,12 +2270,12 @@ private static string TruncateText(string text, int maxLength = 300) /* #1140/#1141: collapse samples of the same chain into one group (true occurrence count + wait range) instead of listing it once per sample, and attach the dedup fingerprint. - ContentiousObject is null until Lite's blocked-process collector resolves it (plan - §5.3), so identity currently falls back to database + literal-stripped query pair. */ + Identity is the resolved contentious object (collected server-side, §5.3), falling back + to database + literal-stripped query pair only when the object did not resolve. */ var groups = BlockingIncidentGrouper.Group( serverName, events.Select(e => new BlockingIncidentGrouper.BlockedEvent( - e.DatabaseName, null, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs))); + e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs))); const int maxGroups = 10; var shown = groups.Take(maxGroups).ToList(); @@ -2427,12 +2427,13 @@ private static bool IsDeadlockExcluded(DeadlockRow row, List excludedDat return context; } - private static AlertContext? BuildLongRunningQueryContext(List queries) + private static AlertContext? BuildLongRunningQueryContext(string serverName, List queries) { if (queries.Count == 0) return null; var context = new AlertContext(); - foreach (var q in queries.GetRange(0, Math.Min(3, queries.Count))) + var shown = queries.GetRange(0, Math.Min(3, queries.Count)); + foreach (var q in shown) { var item = new AlertDetailItem { @@ -2454,6 +2455,12 @@ private static bool IsDeadlockExcluded(DeadlockRow row, List excludedDat context.Details.Add(item); } + + /* #1140: dedup key = query_hash (stable across literals/plans). Null hash -> no incident. */ + AlertIncidentRenderer.Apply(context, shown + .Select(q => AlertFingerprint.ForKey(serverName, AlertFingerprint.Query, q.QueryHash ?? "", + string.IsNullOrEmpty(q.DatabaseName) ? System.Array.Empty() : new[] { q.DatabaseName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } diff --git a/Lite/Services/LocalDataService.Blocking.cs b/Lite/Services/LocalDataService.Blocking.cs index 0e2cce30a..4ecb09e22 100644 --- a/Lite/Services/LocalDataService.Blocking.cs +++ b/Lite/Services/LocalDataService.Blocking.cs @@ -369,7 +369,8 @@ public async Task> GetRecentBlockedProcessReportsA blocked_last_batch_completed, blocking_last_batch_completed, blocked_priority, - blocking_priority + blocking_priority, + contentious_object FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 @@ -421,7 +422,8 @@ ORDER BY event_time DESC BlockedLastBatchCompleted = reader.IsDBNull(31) ? null : reader.GetDateTime(31), BlockingLastBatchCompleted = reader.IsDBNull(32) ? null : reader.GetDateTime(32), BlockedPriority = reader.IsDBNull(33) ? 0 : reader.GetInt32(33), - BlockingPriority = reader.IsDBNull(34) ? 0 : reader.GetInt32(34) + BlockingPriority = reader.IsDBNull(34) ? 0 : reader.GetInt32(34), + ContentiousObject = reader.IsDBNull(35) ? "" : reader.GetString(35) }); } @@ -932,6 +934,7 @@ public class BlockedProcessReportRow public string BlockingLoginName { get; set; } = ""; public string BlockingSqlText { get; set; } = ""; public string BlockedProcessReportXml { get; set; } = ""; + public string ContentiousObject { get; set; } = ""; public string BlockedTransactionName { get; set; } = ""; public string BlockingTransactionName { get; set; } = ""; public DateTime? BlockedLastTranStarted { get; set; } diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index b82504eee..0edf4f899 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -468,7 +468,8 @@ public async Task> GetLongRunningQueriesAsync( r.reads, r.writes, r.wait_type, - r.blocking_session_id + r.blocking_session_id, + r.query_hash FROM v_query_snapshots AS r WHERE r.server_id = $1 AND r.collection_time = (SELECT MAX(vqs.collection_time) FROM v_query_snapshots AS vqs WHERE vqs.server_id = $1) @@ -501,7 +502,8 @@ ORDER BY r.total_elapsed_time_ms DESC Reads = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), Writes = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), WaitType = reader.IsDBNull(7) ? null : reader.GetString(7), - BlockingSessionId = reader.IsDBNull(8) ? null : (int?)reader.GetInt32(8) + BlockingSessionId = reader.IsDBNull(8) ? null : (int?)reader.GetInt32(8), + QueryHash = reader.IsDBNull(9) ? null : reader.GetString(9) }); } @@ -521,6 +523,7 @@ public class LongRunningQueryInfo public long Writes { get; set; } public string? WaitType { get; set; } public int? BlockingSessionId { get; set; } + public string? QueryHash { get; set; } } public class PoisonWaitDelta diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index 5894403a8..25e4a8ac5 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -302,16 +302,38 @@ JOIN sys.dm_xe_database_sessions AS xes OPTION(RECOMPILE); SELECT - event_time = evt.value('(@timestamp)[1]', 'datetime2'), - blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')) + x.event_time, + x.blocked_process_report_xml, + x.object_id, + x.database_id, + /* #1140: resolve the contentious object the blocked_process_report event already carries + (object_id/database_id). Mirrors sp_HumanEventsBlockViewer's contentious_object EXACTLY + (2-part schema.object + the same 'Unresolved: ...' fallback) so the dedup fingerprint + matches the Dashboard for the same object. OBJECT_NAME with the database_id arg resolves + cross-DB with no USE; NULL (cross-db perms / dropped object) -> the stable id fallback. */ + contentious_object = + ISNULL + ( + OBJECT_SCHEMA_NAME(x.object_id, x.database_id) + N'.' + OBJECT_NAME(x.object_id, x.database_id), + N'Unresolved: database: ' + ISNULL(DB_NAME(x.database_id), N'unknown') + + N' object_id: ' + ISNULL(CONVERT(nvarchar(20), x.object_id), N'unknown') + ) FROM ( SELECT - pmd.ring_buffer - FROM @PerformanceMonitor_BlockedProcess AS pmd -) AS rb -CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) -WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time + event_time = evt.value('(@timestamp)[1]', 'datetime2'), + blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')), + object_id = evt.value('(data[@name=""object_id""]/value/text())[1]', 'integer'), + database_id = evt.value('(data[@name=""database_id""]/value/text())[1]', 'integer') + FROM + ( + SELECT + pmd.ring_buffer + FROM @PerformanceMonitor_BlockedProcess AS pmd + ) AS rb + CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) + WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +) AS x OPTION(RECOMPILE);"; } else @@ -342,16 +364,38 @@ JOIN sys.dm_xe_sessions AS xes OPTION(RECOMPILE); SELECT - event_time = evt.value('(@timestamp)[1]', 'datetime2'), - blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')) + x.event_time, + x.blocked_process_report_xml, + x.object_id, + x.database_id, + /* #1140: resolve the contentious object the blocked_process_report event already carries + (object_id/database_id). Mirrors sp_HumanEventsBlockViewer's contentious_object EXACTLY + (2-part schema.object + the same 'Unresolved: ...' fallback) so the dedup fingerprint + matches the Dashboard for the same object. OBJECT_NAME with the database_id arg resolves + cross-DB with no USE; NULL (cross-db perms / dropped object) -> the stable id fallback. */ + contentious_object = + ISNULL + ( + OBJECT_SCHEMA_NAME(x.object_id, x.database_id) + N'.' + OBJECT_NAME(x.object_id, x.database_id), + N'Unresolved: database: ' + ISNULL(DB_NAME(x.database_id), N'unknown') + + N' object_id: ' + ISNULL(CONVERT(nvarchar(20), x.object_id), N'unknown') + ) FROM ( SELECT - pmd.ring_buffer - FROM @PerformanceMonitor_BlockedProcess AS pmd -) AS rb -CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) -WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time + event_time = evt.value('(@timestamp)[1]', 'datetime2'), + blocked_process_report_xml = CONVERT(nvarchar(max), evt.query('data[@name=""blocked_process""]/value/blocked-process-report')), + object_id = evt.value('(data[@name=""object_id""]/value/text())[1]', 'integer'), + database_id = evt.value('(data[@name=""database_id""]/value/text())[1]', 'integer') + FROM + ( + SELECT + pmd.ring_buffer + FROM @PerformanceMonitor_BlockedProcess AS pmd + ) AS rb + CROSS APPLY rb.ring_buffer.nodes('RingBufferTarget/event[@name=""blocked_process_report""]') AS q(evt) + WHERE evt.value('(@timestamp)[1]', 'datetime2') > @cutoff_time +) AS x OPTION(RECOMPILE);"; } @@ -408,6 +452,11 @@ as it lingers in the ring buffer across collection cycles. */ { var eventTime = reader.IsDBNull(0) ? (DateTime?)null : reader.GetDateTime(0); var reportXml = reader.IsDBNull(1) ? null : reader.GetString(1); + /* #1140: object_id/database_id are the event's own data fields and contentious_object + is resolved server-side in the collection query (cols 2-4), not parsed from the XML. */ + var objectId = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); + var databaseId = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3); + var contentiousObject = reader.IsDBNull(4) ? null : reader.GetString(4); if (string.IsNullOrEmpty(reportXml)) { @@ -460,6 +509,9 @@ as it lingers in the ring buffer across collection cycles. */ .AppendValue(parsed.BlockedPriority) .AppendValue(parsed.BlockingPriority) .AppendValue(reportXml) + .AppendValue(objectId) + .AppendValue(databaseId) + .AppendValue(contentiousObject) .EndRow(); rowsCollected++; diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index cc72dfe1c..63448c97d 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -93,7 +93,8 @@ AND dest.text IS NOT NULL AND (dest.text LIKE N'%sp_MScdc_capture_job%' OR dest.text LIKE N'%sp_cdc_scan%') THEN 1 ELSE 0 - END) + END), + query_hash = CONVERT(varchar(18), der.query_hash, 1) /* #1140 long-running-query dedup key */ FROM sys.dm_exec_requests AS der JOIN sys.dm_exec_sessions AS des ON des.session_id = der.session_id @@ -148,7 +149,8 @@ AND dest.text IS NOT NULL der.transaction_isolation_level, der.dop, der.parallel_worker_count, - der.percent_complete + der.percent_complete, + der.query_hash INTO #req FROM sys.dm_exec_requests AS der WHERE der.session_id <> @@SPID @@ -201,7 +203,8 @@ WHEN 5 THEN 'Snapshot' des.open_transaction_count, der.percent_complete, /* Azure SQL Database has no SQL Agent / msdb.dbo.cdc_jobs (CDC there is scheduler-based), so no capture job to exclude. */ - is_cdc_capture = CONVERT(bit, 0) + is_cdc_capture = CONVERT(bit, 0), + query_hash = CONVERT(varchar(18), der.query_hash, 1) /* #1140 long-running-query dedup key */ FROM #req AS der JOIN sys.dm_exec_sessions AS des ON des.session_id = der.session_id @@ -328,6 +331,7 @@ clause is "" so nothing changes. */ .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 */ .AppendValue(!reader.IsDBNull(25) && Convert.ToBoolean(reader.GetValue(25))) /* is_cdc_capture */ + .AppendValue(reader.IsDBNull(26) ? (string?)null : reader.GetString(26)) /* query_hash (#1140) */ .EndRow(); rowsCollected++; From fb3071f20dd9f7cc6188d0f597d2ff49b80bb4e4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:08:38 -0400 Subject: [PATCH 008/145] #1140: wire dedup fingerprints into the anomaly/finding alert path (both apps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes #1140 by giving the secondary anomaly path (ANOMALY_*_SPIKE / CPU findings via AnalysisNotificationService) the same fingerprints as the live "Detected" path: - DrillDownCollector (both apps): top_deadlocks now carries the involved objects (parsed from the deadlock graph via the shared DeadlockObjectExtractor — raw XML NOT surfaced), and top_blocking_chains now carries contentious_object. Source columns already existed. - FindingMessageFormatter.BuildContext (shared): derives context.Incidents from the drill-down — deadlock -> involved-object set, blocking -> contentious object / query pair, query/CPU -> distinct query_hash — reusing the same shared groupers/fingerprint as the live builders, so either path produces an identical key. Incidents are appended after the detail items (existing Diagnosis->Advice->drill-down order preserved). Shared code, so Lite/Dashboard parity is automatic. +4 finding-path tests; 1 existing count-based test updated (its top_cpu_queries drill-down now yields 2 query incidents). Lite 492 + Dashboard 487 tests green; both apps build 0-warnings. Refs #1140 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Analysis/SqlServerDrillDownCollector.cs | 16 ++- Lite.Tests/AnalysisNotificationTests.cs | 87 ++++++++++++++- Lite/Analysis/DrillDownCollector.cs | 16 ++- .../AnalysisNotificationService.cs | 104 ++++++++++++++++++ 4 files changed, 213 insertions(+), 10 deletions(-) diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.cs b/Dashboard/Analysis/SqlServerDrillDownCollector.cs index f3a3ba408..8623bcb50 100644 --- a/Dashboard/Analysis/SqlServerDrillDownCollector.cs +++ b/Dashboard/Analysis/SqlServerDrillDownCollector.cs @@ -10,6 +10,7 @@ using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Services; using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; namespace PerformanceMonitorDashboard.Analysis; @@ -163,7 +164,8 @@ SELECT TOP 3 collection_time, event_date, spid, - LEFT(CAST(query AS NVARCHAR(MAX)), 500) AS victim_sql + LEFT(CAST(query AS NVARCHAR(MAX)), 500) AS victim_sql, + CAST(deadlock_graph AS NVARCHAR(MAX)) AS deadlock_graph FROM collect.deadlocks WHERE collection_time >= @startTime AND collection_time <= @endTime ORDER BY collection_time DESC;"; @@ -175,12 +177,16 @@ FROM collect.deadlocks using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { + /* #1140: parse the involved objects from the graph for the dedup fingerprint + a readable + Objects field. The raw graph XML is NOT surfaced (it would bloat the alert detail). */ + var objects = DeadlockObjectExtractor.FromGraphXml(reader.IsDBNull(4) ? null : reader.GetString(4)); items.Add(new { time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), deadlock_time = reader.IsDBNull(1) ? "" : reader.GetDateTime(1).ToString("o"), victim = reader.IsDBNull(2) ? "" : reader.GetValue(2).ToString(), - victim_sql = reader.IsDBNull(3) ? "" : reader.GetString(3) + victim_sql = reader.IsDBNull(3) ? "" : reader.GetString(3), + objects = string.Join(", ", objects) }); } @@ -205,7 +211,8 @@ SELECT TOP 5 wait_time_ms, lock_mode, LEFT(CAST(query_text AS NVARCHAR(MAX)), 500) AS blocked_sql, - LEFT(blocking_tree, 500) AS blocking_sql + LEFT(blocking_tree, 500) AS blocking_sql, + contentious_object FROM collect.blocking_BlockedProcessReport WHERE collection_time >= @startTime AND collection_time <= @endTime ORDER BY wait_time_ms DESC;"; @@ -226,7 +233,8 @@ FROM collect.blocking_BlockedProcessReport wait_time_ms = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), lock_mode = reader.IsDBNull(5) ? "" : reader.GetString(5), blocked_sql = reader.IsDBNull(6) ? "" : reader.GetString(6), - blocking_sql = reader.IsDBNull(7) ? "" : reader.GetString(7) + blocking_sql = reader.IsDBNull(7) ? "" : reader.GetString(7), + contentious_object = reader.IsDBNull(8) ? "" : reader.GetString(8) }); } diff --git a/Lite.Tests/AnalysisNotificationTests.cs b/Lite.Tests/AnalysisNotificationTests.cs index b645c0bab..a73eaa19f 100644 --- a/Lite.Tests/AnalysisNotificationTests.cs +++ b/Lite.Tests/AnalysisNotificationTests.cs @@ -84,6 +84,84 @@ private static AnalysisFinding MakeFinding( }; } + /* ── #1140: finding-path dedup incidents (BuildContext derives them from the drill-down) ── */ + + [Fact] + public void BuildContext_DeadlockDrillDown_EmitsObjectSetIncident() + { + var finding = MakeFinding("dlfp000000000001", category: "deadlocks", rootFactKey: "DEADLOCKS", + drillDown: new Dictionary + { + ["top_deadlocks"] = new List + { + new { time = "t1", victim_sql = "x", objects = "SalesDB.dbo.Orders, SalesDB.dbo.LineItems" }, + new { time = "t2", victim_sql = "y", objects = "SalesDB.dbo.LineItems, SalesDB.dbo.Orders" } // same set, swapped order + } + }); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + Assert.NotNull(context.Incidents); + var incident = Assert.Single(context.Incidents!); + Assert.Equal(new[] { "SalesDB.dbo.LineItems", "SalesDB.dbo.Orders" }, incident.InvolvedObjects); + Assert.Equal(2, incident.OccurrenceCount); + Assert.Contains(context.Details, d => d.Fields.Any(f => f.Label == "Dedup Key" && f.Value == incident.DedupKey)); + } + + [Fact] + public void BuildContext_BlockingDrillDown_EmitsContentiousObjectIncident() + { + var finding = MakeFinding("blfp000000000001", category: "blocking_events", rootFactKey: "BLOCKING_EVENTS", + drillDown: new Dictionary + { + ["top_blocking_chains"] = new List + { + new { database = "DB", contentious_object = "DB.dbo.Orders", blocked_sql = "a", blocking_sql = "b", wait_time_ms = 5000L }, + new { database = "DB", contentious_object = "DB.dbo.Orders", blocked_sql = "c", blocking_sql = "d", wait_time_ms = 9000L } + } + }); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + var incident = Assert.Single(context.Incidents!); + Assert.Equal(new[] { "DB.dbo.Orders" }, incident.InvolvedObjects); + Assert.Equal(2, incident.OccurrenceCount); + } + + [Fact] + public void BuildContext_CpuDrillDown_EmitsDistinctQueryHashIncidents() + { + var finding = MakeFinding("cpufp00000000001", rootFactKey: "CPU_SPIKE", + drillDown: new Dictionary + { + ["top_cpu_queries"] = new List + { + new { database = "DB", query_hash = "0xAAA", total_cpu_ms = 1L }, + new { database = "DB", query_hash = "0xAAA", total_cpu_ms = 2L }, // duplicate hash -> one incident + new { database = "DB", query_hash = "0xBBB", total_cpu_ms = 3L } + } + }); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + Assert.NotNull(context.Incidents); + Assert.Equal(2, context.Incidents!.Count); // one per distinct query_hash + } + + [Fact] + public void BuildContext_NoFingerprintableDrillDown_LeavesIncidentsNull() + { + var finding = MakeFinding("nonefp0000000001", + drillDown: new Dictionary + { + ["some_metric"] = new List { new { foo = "bar", count = 3 } } + }); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + Assert.Null(context.Incidents); + } + /* ── FindingMessageFormatter ── */ [Fact] @@ -139,8 +217,9 @@ public void BuildContext_MapsAnonymousListAndObjectDrillDown() var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); - // Details[0] = Diagnosis, [1] = Advice (CPU_SPIKE has an advice block), then the two drill-downs. - Assert.Equal(4, context.Details.Count); + // Details[0] = Diagnosis, [1] = Advice (CPU_SPIKE has an advice block), then the two drill-downs, + // then (#1140) one appended incident detail per distinct query_hash in the drill-down. + Assert.Equal(6, context.Details.Count); Assert.Equal("Diagnosis", context.Details[0].Heading); Assert.NotNull(context.Details[1].Body); Assert.False(context.Details[1].IsCodeBlock); @@ -150,6 +229,10 @@ public void BuildContext_MapsAnonymousListAndObjectDrillDown() var peak = context.Details.Single(d => d.Heading == "Spike Peak"); Assert.Contains(peak.Fields, f => f.Label == "Cpu Percent" && f.Value == "99"); + + // #1140: the two distinct query_hash values became dedup incidents on the finding path. + Assert.NotNull(context.Incidents); + Assert.Equal(2, context.Incidents!.Count); } [Fact] diff --git a/Lite/Analysis/DrillDownCollector.cs b/Lite/Analysis/DrillDownCollector.cs index 325a31cbc..94b34b474 100644 --- a/Lite/Analysis/DrillDownCollector.cs +++ b/Lite/Analysis/DrillDownCollector.cs @@ -10,6 +10,7 @@ using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; using PerformanceMonitor.Common; +using PerformanceMonitor.Notifications; namespace PerformanceMonitorLite.Analysis; @@ -141,7 +142,8 @@ private async Task CollectTopDeadlocks(AnalysisFinding finding, AnalysisContext using var cmd = connection.CreateCommand(); cmd.CommandText = @" SELECT collection_time, deadlock_time, victim_process_id, - LEFT(victim_sql_text, 500) AS victim_sql + LEFT(victim_sql_text, 500) AS victim_sql, + deadlock_graph_xml FROM v_deadlocks WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 ORDER BY collection_time DESC @@ -155,12 +157,16 @@ ORDER BY collection_time DESC using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { + /* #1140: parse the involved objects from the graph for the dedup fingerprint + a readable + Objects field. The raw graph XML is NOT surfaced (it would bloat the alert detail). */ + var objects = DeadlockObjectExtractor.FromGraphXml(reader.IsDBNull(4) ? null : reader.GetString(4)); items.Add(new { time = reader.IsDBNull(0) ? "" : reader.GetDateTime(0).ToString("o"), deadlock_time = reader.IsDBNull(1) ? "" : reader.GetDateTime(1).ToString("o"), victim = reader.IsDBNull(2) ? "" : reader.GetString(2), - victim_sql = reader.IsDBNull(3) ? "" : reader.GetString(3) + victim_sql = reader.IsDBNull(3) ? "" : reader.GetString(3), + objects = string.Join(", ", objects) }); } @@ -179,7 +185,8 @@ private async Task CollectTopBlockingChains(AnalysisFinding finding, AnalysisCon SELECT collection_time, database_name, blocked_spid, blocking_spid, wait_time_ms, lock_mode, LEFT(blocked_sql_text, 500) AS blocked_sql, - LEFT(blocking_sql_text, 500) AS blocking_sql + LEFT(blocking_sql_text, 500) AS blocking_sql, + contentious_object FROM v_blocked_process_reports WHERE server_id = $1 AND collection_time >= $2 AND collection_time <= $3 ORDER BY wait_time_ms DESC @@ -202,7 +209,8 @@ ORDER BY wait_time_ms DESC wait_time_ms = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)), lock_mode = reader.IsDBNull(5) ? "" : reader.GetString(5), blocked_sql = reader.IsDBNull(6) ? "" : reader.GetString(6), - blocking_sql = reader.IsDBNull(7) ? "" : reader.GetString(7) + blocking_sql = reader.IsDBNull(7) ? "" : reader.GetString(7), + contentious_object = reader.IsDBNull(8) ? "" : reader.GetString(8) }); } diff --git a/PerformanceMonitor.Notifications/AnalysisNotificationService.cs b/PerformanceMonitor.Notifications/AnalysisNotificationService.cs index 3d3896640..e18c7ac63 100644 --- a/PerformanceMonitor.Notifications/AnalysisNotificationService.cs +++ b/PerformanceMonitor.Notifications/AnalysisNotificationService.cs @@ -430,9 +430,113 @@ which the MCP findings output also reads. */ } } + /* #1140: derive dedup incidents from the drill-down so this (secondary, anomaly) alert path + carries the SAME fingerprints as the live "Detected" path. Deadlock -> involved-object set, + blocking -> contentious object / query pair, query/CPU -> query_hash. Appended after the + detail items, so the existing Diagnosis->Advice->drill-down order is preserved. */ + AlertIncidentRenderer.Apply(context, BuildIncidents(finding)); + return context; } + /// + /// #1140: derives the dedup incidents for the anomaly-finding alert path from the finding's + /// drill-down, reusing the same shared groupers/fingerprint as the live builders so a deadlock, + /// blocking chain, or long-running query produces an identical key on either path. Returns an + /// empty list when the drill-down carries no fingerprintable identity. + /// + private static List BuildIncidents(AnalysisFinding finding) + { + var result = new List(); + if (finding.DrillDown is not { Count: > 0 }) + return result; + + var server = finding.ServerName ?? string.Empty; + + if (TryGetRows(finding.DrillDown, "top_deadlocks", out var deadlockRows)) + { + var events = deadlockRows.Select(r => + new DeadlockIncidentGrouper.DeadlockEvent(SplitObjects(GetField(r, "objects")))); + result.AddRange(DeadlockIncidentGrouper.Group(server, events).Select(g => g.Incident)); + return result; + } + + if (TryGetRows(finding.DrillDown, "top_blocking_chains", out var blockingRows)) + { + var events = blockingRows.Select(r => new BlockingIncidentGrouper.BlockedEvent( + GetField(r, "database"), GetField(r, "contentious_object"), + GetField(r, "blocked_sql"), GetField(r, "blocking_sql"), GetLongField(r, "wait_time_ms"))); + result.AddRange(BlockingIncidentGrouper.Group(server, events).Select(g => g.Incident)); + return result; + } + + /* Query / CPU findings: one incident per distinct query_hash across any drill-down section. */ + var seen = new HashSet(StringComparer.Ordinal); + foreach (var (_, value) in finding.DrillDown) + { + if (value is null) + continue; + foreach (var row in AsRows(value)) + { + var queryHash = GetField(row, "query_hash"); + if (string.IsNullOrEmpty(queryHash) || !seen.Add(queryHash)) + continue; + var db = GetField(row, "database"); + var incident = AlertFingerprint.ForKey(server, AlertFingerprint.Query, queryHash, + string.IsNullOrEmpty(db) ? System.Array.Empty() : new[] { db }); + if (incident is not null) + result.Add(incident); + } + } + return result; + } + + private static bool TryGetRows(Dictionary drillDown, string key, out List rows) + { + rows = (drillDown.TryGetValue(key, out var value) && value is not null) + ? AsRows(value) + : new List(); + return rows.Count > 0; + } + + /// Round-trips a drill-down value (anonymous object or List<object>) through JSON and + /// returns its object rows — the same robust shape-walk relies on. + private static List AsRows(object value) + { + var rows = new List(); + try + { + var element = JsonSerializer.SerializeToElement(value); + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + if (item.ValueKind == JsonValueKind.Object) + rows.Add(item); + } + else if (element.ValueKind == JsonValueKind.Object) + { + rows.Add(element); + } + } + catch { /* unexpected shape -> no rows */ } + return rows; + } + + private static string GetField(JsonElement row, string name) => + row.ValueKind == JsonValueKind.Object && row.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString() ?? string.Empty + : string.Empty; + + private static long GetLongField(JsonElement row, string name) => + row.ValueKind == JsonValueKind.Object && row.TryGetProperty(name, out var p) + && p.ValueKind == JsonValueKind.Number && p.TryGetInt64(out var v) + ? v : 0L; + + private static string[] SplitObjects(string joined) => + string.IsNullOrWhiteSpace(joined) + ? System.Array.Empty() + : joined.Split(", ", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + /// /// Renders the two-sided as read-only prose for the /// cross-surface (email / webhook) disclosure item (B3 Phase 3, §6). Sections are From 80ada70ce26f44f0c04c7a1a134615706f15418d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:43:21 -0400 Subject: [PATCH 009/145] #1141: per-event notification mode for deadlock/blocking alerts (global, both apps) Adds an opt-in Per-event delivery mode (default stays Summary) that sends one notification per distinct incident instead of the batched per-cycle card, so downstream automation can open/track one ticket per incident and count recurrences via the #1140 fingerprint. - PerEventNotification.Split (shared): one message per incident, capped at the configured max-per-cycle, with a trailing "+N more" message that still carries the remaining fingerprints so none are silently dropped. Recurrence handling is left to the existing edge-triggered gating + the consumer's fingerprint dedup. - Settings: AlertDeliveryMode (Summary|PerEvent) + AlertPerEventMaxPerCycle (default 10) in both apps (Lite App statics + JSON; Dashboard UserPreferences), with load/save/reset. - Settings UI: a delivery-mode dropdown + per-cycle cap in both SettingsWindows. - Firing: a SendDetectedAlertAsync helper in each MainWindow routes the "Blocking Detected" and "Deadlocks Detected" sends through Per-event when enabled; alert-history recording is unchanged (one row per fire). Scope: GLOBAL setting (per-server override is a tracked fast-follow). 5 unit tests for the split helper. Lite 497 + Dashboard 487 tests green; both apps build 0-warnings. Refs #1141 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/MainWindow.xaml.cs | 27 ++++++- Dashboard/Models/UserPreferences.cs | 11 +++ Dashboard/SettingsWindow.xaml | 11 +++ Dashboard/SettingsWindow.xaml.cs | 10 +++ Lite.Tests/PerEventNotificationTests.cs | 69 ++++++++++++++++ Lite/App.xaml.cs | 8 ++ Lite/MainWindow.xaml.cs | 37 +++++++-- Lite/Windows/SettingsWindow.xaml | 14 ++++ Lite/Windows/SettingsWindow.xaml.cs | 11 +++ .../PerEventNotification.cs | 80 +++++++++++++++++++ 10 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 Lite.Tests/PerEventNotificationTests.cs create mode 100644 PerformanceMonitor.Notifications/PerEventNotification.cs diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 81d9dcd0a..0184479b4 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -1603,7 +1603,7 @@ either suppresses alerts (offset went back) or bypasses the cooldown (offset wen if (!isMuted) { - await _emailAlertService.TrySendAlertEmailAsync( + await SendDetectedAlertAsync(prefs, "Blocking Detected", serverName, $"{(int)health.TotalBlocked} session(s), longest {(int)health.LongestBlockedSeconds}s", @@ -1670,7 +1670,7 @@ Falls back to the raw delta when no databases are excluded. */ if (!isMuted) { - await _emailAlertService.TrySendAlertEmailAsync( + await SendDetectedAlertAsync(prefs, "Deadlocks Detected", serverName, effectiveDeadlockDelta.ToString(), @@ -2181,6 +2181,29 @@ private static string Truncate(string text, int maxLength = 300) return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; } + /* #1141: in Per-event mode, deliver one notification per distinct incident (capped at + AlertPerEventMaxPerCycle, with a trailing "+N more" carrying the remaining fingerprints) + instead of one batched summary card. Falls back to the single summary send in Summary mode + or when there are no incidents. Recording to alert history is left to the one RecordAlert + call at the firing site; this only shapes the outbound send. */ + private async Task SendDetectedAlertAsync( + UserPreferences prefs, string metricName, string serverName, string summaryCurrentValue, + string thresholdValue, string serverId, AlertContext? context) + { + if (prefs.AlertDeliveryMode == AlertNotificationMode.PerEvent && context?.Incidents is { Count: > 0 }) + { + foreach (var msg in PerEventNotification.Split(context, prefs.AlertPerEventMaxPerCycle)) + { + await _emailAlertService.TrySendAlertEmailAsync( + metricName, serverName, msg.CurrentValue, thresholdValue, serverId, msg.Context); + } + return; + } + + await _emailAlertService.TrySendAlertEmailAsync( + metricName, serverName, summaryCurrentValue, thresholdValue, serverId, context); + } + private static string? ContextToDetailText(AlertContext? context) { if (context == null || context.Details.Count == 0) return null; diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index 46726e9a4..7dc8b4af3 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using PerformanceMonitor.Ui; +using PerformanceMonitor.Notifications; namespace PerformanceMonitorDashboard.Models { @@ -126,6 +127,16 @@ public int EmailCooldownMinutes set => _emailCooldownMinutes = Math.Clamp(value, 1, 120); } + /* #1141: deadlock/blocking notification delivery — Summary (one batched card per cycle, the + default) or PerEvent (one notification per distinct incident, capped). */ + public AlertNotificationMode AlertDeliveryMode { get; set; } = AlertNotificationMode.Summary; + private int _alertPerEventMaxPerCycle = 10; + public int AlertPerEventMaxPerCycle + { + get => _alertPerEventMaxPerCycle; + set => _alertPerEventMaxPerCycle = Math.Clamp(value, 1, 100); + } + // SMTP email alert settings public bool SmtpEnabled { get; set; } = false; public string SmtpServer { get; set; } = ""; diff --git a/Dashboard/SettingsWindow.xaml b/Dashboard/SettingsWindow.xaml index c7f4b3585..70ee6d131 100644 --- a/Dashboard/SettingsWindow.xaml +++ b/Dashboard/SettingsWindow.xaml @@ -320,6 +320,17 @@ + + + + + + + + + + + diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index f2659724b..ef49d728c 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -199,6 +199,8 @@ private void LoadSettings() FailedJobLookbackTextBox.Text = prefs.FailedJobLookbackMinutes.ToString(CultureInfo.InvariantCulture); AlertCooldownTextBox.Text = prefs.AlertCooldownMinutes.ToString(CultureInfo.InvariantCulture); EmailCooldownTextBox.Text = prefs.EmailCooldownMinutes.ToString(CultureInfo.InvariantCulture); + AlertDeliveryModeCombo.SelectedIndex = prefs.AlertDeliveryMode == AlertNotificationMode.PerEvent ? 1 : 0; + AlertPerEventMaxTextBox.Text = prefs.AlertPerEventMaxPerCycle.ToString(CultureInfo.InvariantCulture); MuteRuleDefaultExpirationCombo.SelectedIndex = prefs.MuteRuleDefaultExpiration switch { "1 hour" => 0, @@ -391,6 +393,8 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e) FailedJobLookbackTextBox.Text = "60"; AlertCooldownTextBox.Text = "5"; EmailCooldownTextBox.Text = "15"; + AlertDeliveryModeCombo.SelectedIndex = 0; + AlertPerEventMaxTextBox.Text = "10"; AlertExcludedDatabasesTextBox.Text = ""; MuteRuleDefaultExpirationCombo.SelectedIndex = 1; // 24 hours UpdateAlertPreviewText(); @@ -758,6 +762,12 @@ private async void OkButton_Click(object sender, RoutedEventArgs e) else validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes"); + prefs.AlertDeliveryMode = AlertDeliveryModeCombo.SelectedIndex == 1 ? AlertNotificationMode.PerEvent : AlertNotificationMode.Summary; + if (int.TryParse(AlertPerEventMaxTextBox.Text, out int perEventMax) && perEventMax >= 1 && perEventMax <= 100) + prefs.AlertPerEventMaxPerCycle = perEventMax; + else + validationErrors.Add("Per-event max-per-cycle must be between 1 and 100"); + prefs.MuteRuleDefaultExpiration = (MuteRuleDefaultExpirationCombo.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "24 hours"; MuteRuleDialog.DefaultExpiration = prefs.MuteRuleDefaultExpiration; prefs.LogAlertDismissals = LogAlertDismissalsCheckBox.IsChecked == true; diff --git a/Lite.Tests/PerEventNotificationTests.cs b/Lite.Tests/PerEventNotificationTests.cs new file mode 100644 index 000000000..018a1d7d9 --- /dev/null +++ b/Lite.Tests/PerEventNotificationTests.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Guards (#1141): one message per incident, capped, with a +/// trailing overflow batch so no #1140 fingerprint is ever dropped. +/// +public class PerEventNotificationTests +{ + private static AlertContext WithIncidents(int n) + { + var incidents = new List(); + for (int i = 0; i < n; i++) + incidents.Add(new AlertIncident($"key{i}", new[] { $"db.dbo.T{i}" }, OccurrenceCount: i + 1)); + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, incidents); + return ctx; + } + + [Fact] + public void Split_NoIncidents_ReturnsEmpty() + { + Assert.Empty(PerEventNotification.Split(new AlertContext(), 10)); + } + + [Fact] + public void Split_WithinCap_OneMessagePerIncident() + { + var messages = PerEventNotification.Split(WithIncidents(3), 10); + Assert.Equal(3, messages.Count); + Assert.All(messages, m => Assert.False(m.IsOverflow)); + Assert.All(messages, m => Assert.Single(m.Context.Incidents!)); + Assert.Equal("db.dbo.T0", messages[0].CurrentValue); + } + + [Fact] + public void Split_OverCap_CapsIndividualAndBatchesOverflow() + { + var messages = PerEventNotification.Split(WithIncidents(5), 2); + Assert.Equal(3, messages.Count); // 2 individual + 1 overflow + Assert.False(messages[0].IsOverflow); + Assert.False(messages[1].IsOverflow); + Assert.True(messages[2].IsOverflow); + Assert.Equal(3, messages[2].Context.Incidents!.Count); // overflow carries the remaining 3 + Assert.Contains("+3 more", messages[2].CurrentValue); + } + + [Fact] + public void Split_PreservesEveryFingerprint() + { + var source = WithIncidents(5); + var messages = PerEventNotification.Split(source, 2); + var emitted = messages.SelectMany(m => m.Context.Incidents!).Select(i => i.DedupKey).ToHashSet(); + var expected = source.Incidents!.Select(i => i.DedupKey).ToHashSet(); + Assert.Equal(expected, emitted); + } + + [Fact] + public void Split_CapZero_TreatedAsOne() + { + var messages = PerEventNotification.Split(WithIncidents(3), 0); + Assert.Equal(2, messages.Count); // 1 individual + overflow of 2 + Assert.True(messages[1].IsOverflow); + } +} diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index a904287ea..93af60889 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -14,6 +14,7 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; +using PerformanceMonitor.Notifications; using System.Windows.Threading; using PerformanceMonitorLite.Services; using PerformanceMonitor.Ui; @@ -114,6 +115,10 @@ public partial class App : Application public static int AlertFailedJobLookbackMinutes { get; set; } = 60; // Look back this many minutes for failed Agent job runs 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 + /* #1141: deadlock/blocking notification delivery — Summary (one batched card per cycle, the default) + or PerEvent (one notification per distinct incident, capped, for per-incident ticketing). */ + public static AlertNotificationMode AlertDeliveryMode { get; set; } = AlertNotificationMode.Summary; + public static int AlertPerEventMaxPerCycle { get; set; } = 10; // Max per-event notifications per cycle before "+N more" public static string MuteRuleDefaultExpiration { get; set; } = "24 hours"; // Default expiration for new mute rules public static bool LogAlertDismissals { get; set; } = true; // Log alert dismiss/mute actions to file @@ -499,6 +504,9 @@ public static void LoadAlertSettings() if (root.TryGetProperty("alert_failed_job_lookback_minutes", out v)) AlertFailedJobLookbackMinutes = (int)Math.Clamp(v.GetInt64(), 1, 1440); if (root.TryGetProperty("alert_cooldown_minutes", out v)) AlertCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120); if (root.TryGetProperty("email_cooldown_minutes", out v)) EmailCooldownMinutes = (int)Math.Clamp(v.GetInt64(), 1, 120); + if (root.TryGetProperty("alert_delivery_mode", out v) && Enum.TryParse(v.GetString(), out var deliveryMode)) + AlertDeliveryMode = deliveryMode; + if (root.TryGetProperty("alert_per_event_max_per_cycle", out v)) AlertPerEventMaxPerCycle = (int)Math.Clamp(v.GetInt64(), 1, 100); if (root.TryGetProperty("mute_rule_default_expiration", out v)) { var exp = v.GetString(); diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index a9413270c..d188c4353 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -1668,15 +1668,15 @@ await _emailAlertService.TrySendAlertEmailAsync( var blockingContext = await BuildBlockingContextAsync(summary.ServerId, summary.DisplayName); var detailText = ContextToDetailText(blockingContext); - await _emailAlertService.TrySendAlertEmailAsync( + await SendDetectedAlertAsync( "Blocking Detected", summary.DisplayName, effectiveBlockingCount.ToString(), App.AlertBlockingThreshold.ToString(), summary.ServerId, blockingContext, - muted: isMuted, - detailText: detailText); + isMuted, + detailText); } else if (!blockingDecision.Active && wasBlockingActive) { @@ -1739,15 +1739,15 @@ await _emailAlertService.TrySendAlertEmailAsync( var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId, summary.DisplayName); var detailText = ContextToDetailText(deadlockContext); - await _emailAlertService.TrySendAlertEmailAsync( + await SendDetectedAlertAsync( "Deadlocks Detected", summary.DisplayName, effectiveDeadlockCount.ToString(), App.AlertDeadlockThreshold.ToString(), summary.ServerId, deadlockContext, - muted: isMuted, - detailText: detailText); + isMuted, + detailText); } else if (!deadlockDecision.Active && wasDeadlockActive) { @@ -2235,6 +2235,31 @@ private static string TruncateText(string text, int maxLength = 300) return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; } + /* #1141: in Per-event mode, deliver one notification per distinct incident (capped at + AlertPerEventMaxPerCycle, with a trailing "+N more" that still carries the remaining + fingerprints) instead of one batched summary card. Falls back to the single summary send in + Summary mode or when there are no incidents. The existing edge-triggered gating still decides + whether to fire; this only shapes delivery. */ + private async Task SendDetectedAlertAsync( + string metricName, string serverName, string summaryCurrentValue, string thresholdValue, + int serverId, AlertContext? context, bool isMuted, string? summaryDetailText) + { + if (App.AlertDeliveryMode == AlertNotificationMode.PerEvent && context?.Incidents is { Count: > 0 }) + { + foreach (var msg in PerEventNotification.Split(context, App.AlertPerEventMaxPerCycle)) + { + await _emailAlertService.TrySendAlertEmailAsync( + metricName, serverName, msg.CurrentValue, thresholdValue, serverId, + msg.Context, muted: isMuted, detailText: ContextToDetailText(msg.Context)); + } + return; + } + + await _emailAlertService.TrySendAlertEmailAsync( + metricName, serverName, summaryCurrentValue, thresholdValue, serverId, + context, muted: isMuted, detailText: summaryDetailText); + } + private static string? ContextToDetailText(AlertContext? context) { if (context == null || context.Details.Count == 0) return null; diff --git a/Lite/Windows/SettingsWindow.xaml b/Lite/Windows/SettingsWindow.xaml index 0d7b5e103..b076ff541 100644 --- a/Lite/Windows/SettingsWindow.xaml +++ b/Lite/Windows/SettingsWindow.xaml @@ -260,6 +260,20 @@ + + + + + + + + + + + diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 647efa165..734efd911 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -606,6 +606,8 @@ private void LoadAlertSettings() AlertFailedJobLookbackBox.Text = App.AlertFailedJobLookbackMinutes.ToString(); AlertCooldownBox.Text = App.AlertCooldownMinutes.ToString(); EmailCooldownBox.Text = App.EmailCooldownMinutes.ToString(); + AlertDeliveryModeBox.SelectedIndex = App.AlertDeliveryMode == AlertNotificationMode.PerEvent ? 1 : 0; + AlertPerEventMaxBox.Text = App.AlertPerEventMaxPerCycle.ToString(); MuteRuleDefaultExpirationCombo.SelectedIndex = App.MuteRuleDefaultExpiration switch { "1 hour" => 0, @@ -677,6 +679,11 @@ private bool SaveAlertSettings() App.EmailCooldownMinutes = emailCooldown; else validationErrors.Add("Email alert cooldown must be between 1 and 120 minutes."); + App.AlertDeliveryMode = AlertDeliveryModeBox.SelectedIndex == 1 ? AlertNotificationMode.PerEvent : AlertNotificationMode.Summary; + if (int.TryParse(AlertPerEventMaxBox.Text, out var perEventMax) && perEventMax >= 1 && perEventMax <= 100) + App.AlertPerEventMaxPerCycle = perEventMax; + else + validationErrors.Add("Per-event max-per-cycle must be between 1 and 100."); App.MuteRuleDefaultExpiration = (MuteRuleDefaultExpirationCombo.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "24 hours"; App.LogAlertDismissals = LogAlertDismissalsCheckBox.IsChecked == true; App.AnalysisEnabled = AnalysisEnabledCheckBox.IsChecked == true; @@ -739,6 +746,8 @@ private bool SaveAlertSettings() root["alert_failed_job_lookback_minutes"] = App.AlertFailedJobLookbackMinutes; root["alert_cooldown_minutes"] = App.AlertCooldownMinutes; root["email_cooldown_minutes"] = App.EmailCooldownMinutes; + root["alert_delivery_mode"] = App.AlertDeliveryMode.ToString(); + root["alert_per_event_max_per_cycle"] = App.AlertPerEventMaxPerCycle; root["mute_rule_default_expiration"] = App.MuteRuleDefaultExpiration; root["log_alert_dismissals"] = App.LogAlertDismissals; root["analysis_enabled"] = App.AnalysisEnabled; @@ -786,6 +795,8 @@ private void RestoreAlertDefaultsButton_Click(object sender, RoutedEventArgs e) AlertFailedJobLookbackBox.Text = "60"; AlertCooldownBox.Text = "5"; EmailCooldownBox.Text = "15"; + AlertDeliveryModeBox.SelectedIndex = 0; + AlertPerEventMaxBox.Text = "10"; AnalysisIntervalBox.Text = "30"; AnalysisNotifySeverityBox.Text = "1.5"; AlertExcludedDatabasesBox.Text = ""; diff --git a/PerformanceMonitor.Notifications/PerEventNotification.cs b/PerformanceMonitor.Notifications/PerEventNotification.cs new file mode 100644 index 000000000..ded07b62e --- /dev/null +++ b/PerformanceMonitor.Notifications/PerEventNotification.cs @@ -0,0 +1,80 @@ +/* + * 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 PerformanceMonitor.Notifications; + +/// +/// How deadlock/blocking alerts are delivered (#1141). is the default — one +/// batched notification per alert cycle listing all incidents. sends one +/// notification per distinct incident so downstream automation (e.g. a Logic App) can open/track one +/// ticket per incident and count recurrences via the #1140 dedup fingerprint. +/// +public enum AlertNotificationMode +{ + Summary = 0, + PerEvent = 1 +} + +/// +/// Splits a built alert into per-incident messages for #1141 Per-event mode. +/// Each distinct incident (already grouped + fingerprinted by #1140) becomes one message carrying that +/// single incident; when the incident count exceeds the per-cycle cap, the overflow incidents are +/// batched into a final "+N more" message so no fingerprint is ever dropped (the requester's "don't +/// silently truncate"). Recurrence handling is left to the existing edge-triggered alert gating + the +/// consumer's fingerprint dedup — this helper only shapes delivery. +/// +public static class PerEventNotification +{ + /// One per-event notification to send: the single-incident (or overflow) context plus the + /// "current value" string the caller passes to its alert sender. + public sealed record Message(AlertContext Context, string CurrentValue, bool IsOverflow); + + /// + /// Produces one message per incident (capped at ), with a trailing + /// overflow message carrying any remaining incidents. Returns an empty list when the source has no + /// incidents — the caller then falls back to a single Summary send. Never mutates . + /// + public static List Split(AlertContext source, int maxPerCycle) + { + var messages = new List(); + if (source?.Incidents is not { Count: > 0 } incidents) + return messages; + + var cap = Math.Max(1, maxPerCycle); + + foreach (var incident in incidents.Take(cap)) + { + var ctx = new AlertContext { SeverityOverride = source.SeverityOverride }; + AlertIncidentRenderer.Apply(ctx, new[] { incident }); + messages.Add(new Message(ctx, DescribeIncident(incident), IsOverflow: false)); + } + + var overflow = incidents.Skip(cap).ToList(); + if (overflow.Count > 0) + { + var ctx = new AlertContext { SeverityOverride = source.SeverityOverride }; + AlertIncidentRenderer.Apply(ctx, overflow); + messages.Add(new Message(ctx, $"+{overflow.Count} more incident(s) this cycle", IsOverflow: true)); + } + + return messages; + } + + // The alert's "current value" string for a single-incident message: the involved objects, or the + // dedup key when no objects resolved, so the notification headline names what the incident is about. + private static string DescribeIncident(AlertIncident incident) + { + if (incident.InvolvedObjects.Count > 0) + return string.Join(", ", incident.InvolvedObjects); + return incident.DedupKey; + } +} From a56b10dae09d0940c8fb6de0ab6807291cd9189f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:03:32 -0400 Subject: [PATCH 010/145] #1141: fix per-event cards losing forensic detail + Current Value (gotqn feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gotqn's two findings from testing the dev build (the #1140 fingerprint itself tested great — stable key + climbing Occurrences): 1. Per-event cards no longer carry LESS detail than Summary. AlertIncident now carries transient DetailFields (forensic facts), populated by the groupers from the representative event: blocking -> Database / Contentious Object / Blocked Query / Blocking Query / Lock Mode; deadlock -> Victim SQL / Processes (Lite), Query / Wait Resource / Lock Mode (Dashboard). PerEventNotification.Split renders them onto each per-incident card and now also carries the source AttachmentXml/FileName so per-event email keeps the deadlock_graph.xml / blocked_process_report.xml. Summary rendering is untouched (AlertIncidentRenderer.Apply leaves DetailFields off to avoid duplicating the builder's own items), and DetailFields are not persisted. 2. Per-event "Current Value" is now the occurrence count (a number, matching Summary), not the involved-objects string (which already shows as its own fact). Lite 500 + Dashboard 487 tests green (3 new: detail preserved, attachment carried, Current Value = count); both apps build 0 warnings. Refs #1141 #1140 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/MainWindow.xaml.cs | 19 ++++++++- Lite.Tests/PerEventNotificationTests.cs | 39 ++++++++++++++++- Lite/MainWindow.xaml.cs | 15 ++++++- .../AlertContext.cs | 13 +++++- .../AlertIncidentRenderer.cs | 42 ++++++++++++------- .../IncidentGrouping.cs | 37 +++++++++++++--- .../PerEventNotification.cs | 32 +++++++++----- 7 files changed, 159 insertions(+), 38 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 0184479b4..84341f8c8 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -2273,7 +2273,7 @@ await _emailAlertService.TrySendAlertEmailAsync( AlertIncidentRenderer.Apply(context, BlockingIncidentGrouper.Group( serverName, events.Select(e => new BlockingIncidentGrouper.BlockedEvent( - e.DatabaseName, e.ContentiousObject, e.QueryText, null, e.WaitTimeMs ?? 0))) + e.DatabaseName, e.ContentiousObject, e.QueryText, null, e.WaitTimeMs ?? 0, e.LockMode))) .Select(g => g.Incident).ToList()); return context; @@ -2351,7 +2351,8 @@ deadlock event across ALL events in the window. */ serverName, deadlocks.GroupBy(d => d.EventDate).Select(g => new DeadlockIncidentGrouper.DeadlockEvent( DeadlockObjectExtractor.FromGraphXml( - g.Select(x => x.DeadlockGraph).FirstOrDefault(x => !string.IsNullOrEmpty(x)))))) + g.Select(x => x.DeadlockGraph).FirstOrDefault(x => !string.IsNullOrEmpty(x))), + DeadlockDetailFields(g)))) .Select(g => g.Incident).ToList()); return context; @@ -2368,6 +2369,20 @@ deadlock event across ALL events in the window. */ /// A deadlock is only excluded when ALL process nodes have a currentdbname in the excluded list. /// Cross-database deadlocks involving any non-excluded database will still be reported. /// + /* #1141: forensic detail carried on a deadlock incident so per-event cards keep the query + + wait resource + lock mode (Summary mode shows them via the builder's own items). */ + private static List? DeadlockDetailFields(IEnumerable participants) + { + var rep = participants.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.Query)) ?? participants.FirstOrDefault(); + if (rep is null) return null; + var f = new List(); + if (!string.IsNullOrWhiteSpace(rep.DatabaseName)) f.Add(new AlertIncidentField("Database", rep.DatabaseName)); + if (!string.IsNullOrWhiteSpace(rep.Query)) f.Add(new AlertIncidentField("Query", Truncate(rep.Query))); + if (!string.IsNullOrWhiteSpace(rep.WaitResource)) f.Add(new AlertIncidentField("Wait Resource", rep.WaitResource)); + if (!string.IsNullOrWhiteSpace(rep.LockMode)) f.Add(new AlertIncidentField("Lock Mode", rep.LockMode)); + return f.Count > 0 ? f : null; + } + private static bool IsDeadlockExcluded(DeadlockItem deadlock, List excludedDatabases) { if (string.IsNullOrEmpty(deadlock.DeadlockGraph)) return false; diff --git a/Lite.Tests/PerEventNotificationTests.cs b/Lite.Tests/PerEventNotificationTests.cs index 018a1d7d9..e2f5eadb5 100644 --- a/Lite.Tests/PerEventNotificationTests.cs +++ b/Lite.Tests/PerEventNotificationTests.cs @@ -34,7 +34,44 @@ public void Split_WithinCap_OneMessagePerIncident() Assert.Equal(3, messages.Count); Assert.All(messages, m => Assert.False(m.IsOverflow)); Assert.All(messages, m => Assert.Single(m.Context.Incidents!)); - Assert.Equal("db.dbo.T0", messages[0].CurrentValue); + Assert.Equal("1", messages[0].CurrentValue); // Current Value = occurrence count (incident 0 => 1), not the object list + } + + [Fact] + public void Split_PerEventCard_CarriesIncidentDetailFields() + { + var ctx = new AlertContext(); + var incident = new AlertIncident("k", new[] { "db.dbo.Orders" }, OccurrenceCount: 3, + DetailFields: new[] { new AlertIncidentField("Victim SQL", "UPDATE x"), new AlertIncidentField("Processes", "spids 51,52") }); + AlertIncidentRenderer.Apply(ctx, new[] { incident }); + + var msg = Assert.Single(PerEventNotification.Split(ctx, 10)); + var item = Assert.Single(msg.Context.Details); + Assert.Contains(item.Fields, f => f.Label == "Victim SQL" && f.Value == "UPDATE x"); + Assert.Contains(item.Fields, f => f.Label == "Processes" && f.Value == "spids 51,52"); + Assert.Contains(item.Fields, f => f.Label == "Dedup Key" && f.Value == "k"); + } + + [Fact] + public void Split_CarriesSourceAttachment() + { + var ctx = WithIncidents(2); + ctx.AttachmentXml = ""; + ctx.AttachmentFileName = "deadlock_graph.xml"; + foreach (var m in PerEventNotification.Split(ctx, 10)) + { + Assert.Equal("", m.Context.AttachmentXml); + Assert.Equal("deadlock_graph.xml", m.Context.AttachmentFileName); + } + } + + [Fact] + public void Split_CurrentValueIsOccurrenceCount() + { + var ctx = new AlertContext(); + AlertIncidentRenderer.Apply(ctx, new[] { new AlertIncident("k", new[] { "db.dbo.A" }, OccurrenceCount: 7) }); + var msg = Assert.Single(PerEventNotification.Split(ctx, 10)); + Assert.Equal("7", msg.CurrentValue); } [Fact] diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index d188c4353..6bc9298d8 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -2300,7 +2300,7 @@ to database + literal-stripped query pair only when the object did not resolve. var groups = BlockingIncidentGrouper.Group( serverName, events.Select(e => new BlockingIncidentGrouper.BlockedEvent( - e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs))); + e.DatabaseName, e.ContentiousObject, e.BlockedSqlText, e.BlockingSqlText, e.WaitTimeMs, e.LockMode))); const int maxGroups = 10; var shown = groups.Take(maxGroups).ToList(); @@ -2401,7 +2401,8 @@ recurrences over the same objects collapse to one incident with a count. */ var groups = DeadlockIncidentGrouper.Group( serverName, deadlocks.Select(d => new DeadlockIncidentGrouper.DeadlockEvent( - DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml)))); + DeadlockObjectExtractor.FromGraphXml(d.DeadlockGraphXml), + DeadlockDetailFields(d.VictimSqlText, d.ProcessSummary)))); AlertIncidentRenderer.Apply(context, groups.Select(g => g.Incident).ToList()); return context; @@ -2413,6 +2414,16 @@ recurrences over the same objects collapse to one incident with a count. */ } } + /* #1141: forensic detail carried on a deadlock incident so per-event cards keep the victim SQL + + process summary (Summary mode shows them via the builder's own items). */ + private static List? DeadlockDetailFields(string? victimSql, string? processes) + { + var f = new List(); + if (!string.IsNullOrWhiteSpace(victimSql)) f.Add(new AlertIncidentField("Victim SQL", TruncateText(victimSql))); + if (!string.IsNullOrWhiteSpace(processes)) f.Add(new AlertIncidentField("Processes", processes!)); + return f.Count > 0 ? f : null; + } + private static bool IsDeadlockExcluded(DeadlockRow row, List excludedDatabases) { if (string.IsNullOrEmpty(row.DeadlockGraphXml)) return false; diff --git a/PerformanceMonitor.Notifications/AlertContext.cs b/PerformanceMonitor.Notifications/AlertContext.cs index b6822ad3a..4ee9422e7 100644 --- a/PerformanceMonitor.Notifications/AlertContext.cs +++ b/PerformanceMonitor.Notifications/AlertContext.cs @@ -52,7 +52,18 @@ public sealed record AlertIncident( string DedupKey, IReadOnlyList InvolvedObjects, int OccurrenceCount = 1, - string? WaitRange = null); + string? WaitRange = null, + IReadOnlyList? DetailFields = null); + +/// +/// A forensic label/value pair carried on an for #1141 Per-event delivery +/// (e.g. Victim SQL / Processes for a deadlock; Database / Blocked Query / Blocking Query / Lock Mode +/// for a blocking chain). Transient: populated by the incident groupers, rendered into the per-event +/// card by , and deliberately NOT persisted (the Summary card already +/// lists the per-incident detail via the builders, and the per-event card's rendered Details are what +/// get saved). Summary rendering ignores it, so that path is unchanged. +/// +public sealed record AlertIncidentField(string Label, string Value); /// /// A single detail item (e.g., one blocking chain or one deadlock participant). diff --git a/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs b/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs index 60fbffcb1..f18ace9d6 100644 --- a/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs +++ b/PerformanceMonitor.Notifications/AlertIncidentRenderer.cs @@ -37,22 +37,34 @@ public static void Apply(AlertContext context, IReadOnlyList? inc for (int n = 0; n < incidents.Count; n++) { - var incident = incidents[n]; - var item = new AlertDetailItem - { - Heading = incidents.Count == 1 ? "Incident" : $"Incident {n + 1} of {incidents.Count}" - }; - item.Fields.Add(("Dedup Key", incident.DedupKey)); - item.Fields.Add(("Involved Objects", - incident.InvolvedObjects.Count > 0 - ? string.Join(", ", incident.InvolvedObjects) - : "(unresolved)")); - if (incident.OccurrenceCount > 1) - item.Fields.Add(("Occurrences", incident.OccurrenceCount.ToString())); - if (!string.IsNullOrEmpty(incident.WaitRange)) - item.Fields.Add(("Wait Range", incident.WaitRange)); + var heading = incidents.Count == 1 ? "Incident" : $"Incident {n + 1} of {incidents.Count}"; + // Summary mode leaves the forensic DetailFields off — the builder already lists the + // per-incident detail in its own items, so including them here would duplicate. + context.Details.Add(BuildItem(incidents[n], heading, includeDetailFields: false)); + } + } - context.Details.Add(item); + /// + /// Builds one detail item for an incident. When is true the + /// incident's forensic are emitted first — used by #1141 + /// Per-event delivery, where each card carries a single incident and has room for its full detail + /// (Victim SQL / Processes / queries) rather than only the dedup metadata. + /// + public static AlertDetailItem BuildItem(AlertIncident incident, string heading, bool includeDetailFields) + { + var item = new AlertDetailItem { Heading = heading }; + if (includeDetailFields && incident.DetailFields is { Count: > 0 }) + { + foreach (var f in incident.DetailFields) + item.Fields.Add((f.Label, f.Value)); } + item.Fields.Add(("Dedup Key", incident.DedupKey)); + item.Fields.Add(("Involved Objects", + incident.InvolvedObjects.Count > 0 ? string.Join(", ", incident.InvolvedObjects) : "(unresolved)")); + if (incident.OccurrenceCount > 1) + item.Fields.Add(("Occurrences", incident.OccurrenceCount.ToString())); + if (!string.IsNullOrEmpty(incident.WaitRange)) + item.Fields.Add(("Wait Range", incident.WaitRange)); + return item; } } diff --git a/PerformanceMonitor.Notifications/IncidentGrouping.cs b/PerformanceMonitor.Notifications/IncidentGrouping.cs index eabdce562..45ae8c03e 100644 --- a/PerformanceMonitor.Notifications/IncidentGrouping.cs +++ b/PerformanceMonitor.Notifications/IncidentGrouping.cs @@ -35,7 +35,8 @@ public readonly record struct BlockedEvent( string? ContentiousObject, string? BlockedQuery, string? BlockingQuery, - long WaitTimeMs); + long WaitTimeMs, + string? LockMode = null); /// One distinct blocking incident: a representative chain, its true occurrence count and /// wait range, and the dedup . @@ -98,10 +99,14 @@ public static List Group(string serverName, IEnumerable b.OccurrenceCount.CompareTo(a.OccurrenceCount)); @@ -138,6 +143,21 @@ private static string FormatWaitRange(long minMs, long maxMs) string Sec(long ms) => (ms / 1000.0).ToString("F1", CultureInfo.InvariantCulture) + "s"; return minMs == maxMs ? Sec(maxMs) : Sec(minMs) + "-" + Sec(maxMs); } + + // Forensic detail for a blocking incident's per-event card (#1141): the representative chain's + // database, contentious object, the blocked/blocking query pair (truncated), and lock mode. + private static List BlockingDetail(BlockedEvent e) + { + var f = new List(); + if (!string.IsNullOrWhiteSpace(e.Database)) f.Add(new AlertIncidentField("Database", e.Database!)); + if (!string.IsNullOrWhiteSpace(e.ContentiousObject)) f.Add(new AlertIncidentField("Contentious Object", e.ContentiousObject!)); + if (!string.IsNullOrWhiteSpace(e.BlockedQuery)) f.Add(new AlertIncidentField("Blocked Query", Truncate(e.BlockedQuery!))); + if (!string.IsNullOrWhiteSpace(e.BlockingQuery)) f.Add(new AlertIncidentField("Blocking Query", Truncate(e.BlockingQuery!))); + if (!string.IsNullOrWhiteSpace(e.LockMode)) f.Add(new AlertIncidentField("Lock Mode", e.LockMode!)); + return f; + } + + private static string Truncate(string s) => s.Length <= 300 ? s : s.Substring(0, 300) + "…"; } /// @@ -149,8 +169,11 @@ private static string FormatWaitRange(long minMs, long maxMs) /// public static class DeadlockIncidentGrouper { - /// One deadlock event projected to the distinct fully-qualified objects it involved. - public readonly record struct DeadlockEvent(IReadOnlyList Objects); + /// One deadlock event projected to the distinct fully-qualified objects it involved, plus + /// optional forensic detail (Victim SQL / Processes) carried onto the incident for per-event cards. + public readonly record struct DeadlockEvent( + IReadOnlyList Objects, + IReadOnlyList? DetailFields = null); /// One distinct deadlock incident: the involved object set, occurrence count, and fingerprint. public sealed record DeadlockGroup(IReadOnlyList Objects, int OccurrenceCount, AlertIncident Incident); @@ -165,6 +188,7 @@ public static List Group(string serverName, IEnumerable(); var counts = new Dictionary(StringComparer.Ordinal); var incidents = new Dictionary(StringComparer.Ordinal); + var details = new Dictionary?>(StringComparer.Ordinal); foreach (var e in events ?? Enumerable.Empty()) { @@ -178,6 +202,7 @@ public static List Group(string serverName, IEnumerable Group(string serverName, IEnumerable Split(AlertContext source, int maxPerCycle) foreach (var incident in incidents.Take(cap)) { - var ctx = new AlertContext { SeverityOverride = source.SeverityOverride }; - AlertIncidentRenderer.Apply(ctx, new[] { incident }); + var ctx = NewContext(source); + ctx.Incidents = new List { incident }; + // includeDetailFields: true — the per-event card has room for this one incident's full + // forensic detail (Victim SQL / Processes / queries), which Summary's batched card splits + // across the builder's own items. + ctx.Details.Add(AlertIncidentRenderer.BuildItem(incident, "Incident", includeDetailFields: true)); messages.Add(new Message(ctx, DescribeIncident(incident), IsOverflow: false)); } var overflow = incidents.Skip(cap).ToList(); if (overflow.Count > 0) { - var ctx = new AlertContext { SeverityOverride = source.SeverityOverride }; - AlertIncidentRenderer.Apply(ctx, overflow); + var ctx = NewContext(source); + ctx.Incidents = new List(overflow); + for (int n = 0; n < overflow.Count; n++) + ctx.Details.Add(AlertIncidentRenderer.BuildItem(overflow[n], $"Incident {n + 1} of {overflow.Count}", includeDetailFields: true)); messages.Add(new Message(ctx, $"+{overflow.Count} more incident(s) this cycle", IsOverflow: true)); } return messages; } - // The alert's "current value" string for a single-incident message: the involved objects, or the - // dedup key when no objects resolved, so the notification headline names what the incident is about. - private static string DescribeIncident(AlertIncident incident) + // A fresh per-incident context that carries over the source's severity override AND the attachment + // (deadlock_graph.xml / blocked_process_report.xml) so per-event email keeps the forensic file. + private static AlertContext NewContext(AlertContext source) => new() { - if (incident.InvolvedObjects.Count > 0) - return string.Join(", ", incident.InvolvedObjects); - return incident.DedupKey; - } + SeverityOverride = source.SeverityOverride, + AttachmentXml = source.AttachmentXml, + AttachmentFileName = source.AttachmentFileName + }; + + // The alert's "current value" for a single-incident card: the occurrence count (a number, matching + // the Summary card's count), not the involved-objects string (which already shows as its own fact). + private static string DescribeIncident(AlertIncident incident) => incident.OccurrenceCount.ToString(); } From 41aa9cd2868b76a73bd0e648528bbf911ccf34a0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:34:04 -0400 Subject: [PATCH 011/145] Fix #1145: Webhook alerts re-fire after an app restart #981 added restart-dedup for the email channel only; a restart cleared the two guards that suppress a webhook re-send, so reopening Lite re-posted a Teams/Slack alert already delivered before the restart (identical Dedup Key and Occurrences). Two-part fix: 1. Webhook cooldown seed (shared, BOTH apps). WebhookAlertService now seeds its per-(serverId, metricName) cooldown from alert history on first use, mirroring the email seed, via a new IAlertHistoryStore.GetLastWebhookSentUtcAsync. Lite filters notification_type IN ('webhook','email+webhook'); Dashboard filters NotificationType == "webhook". send_error is NOT filtered on -- it tracks the email channel, so an email-failed-but-webhook-sent row must still seed. Wired into the WebhookAlertService DI in both MainWindows. 2. Edge-trigger watermark persistence (Lite). The rolling-count gate's in-memory watermark (#1091) reset to 0 on restart, so the first sweep re-fired for events still in the 1-hour lookback -- and because that gap can exceed the cooldown, the seed alone (time-bounded) does not cover it. The watermark now persists to a new config_edge_trigger_watermarks DuckDB table (upsert on change), seeded before the first sweep at startup. Dashboard needs no watermark persistence: its deadlock gate re-baselines on restart (raw delta) or is 5-min-windowed (always within the cooldown the seed now covers), and blocking is level+cooldown -- none produce the byte-identical duplicate the Lite edge-trigger gate does. Tests: Lite 505 + Dashboard 487 green. New: webhook-row history filter + watermark save/load/upsert round-trips + WebhookAlertService seed-suppresses / seed-older-than-cooldown-does-not / null-store-attempts. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/MainWindow.xaml.cs | 7 +- Dashboard/Services/JsonAlertHistoryStore.cs | 29 ++++ Lite.Tests/DuckDbSchemaTests.cs | 5 +- Lite.Tests/StoreRoundTripTests.cs | 64 ++++++++ Lite.Tests/WebhookCooldownSeedTests.cs | 110 ++++++++++++++ Lite/Database/Schema.cs | 15 ++ Lite/MainWindow.xaml.cs | 69 ++++++++- Lite/Services/DuckDbAlertHistoryStore.cs | 137 ++++++++++++++++++ .../IAlertHistoryStore.cs | 10 ++ .../WebhookAlertService.cs | 27 +++- 10 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 Lite.Tests/WebhookCooldownSeedTests.cs diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 84341f8c8..54b811a38 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -169,8 +169,11 @@ reach it directly rather than forwarding through EmailAlertService (E3c Phase 6) _alertHistoryStore = new JsonAlertHistoryStore(_preferencesService); /* Webhook service is constructed first and injected into the email service (Plan E E3c): the shared lib service carries no Current static, so Dashboard - keeps this handle for the email fan-out and any MCP/health consumers. */ - _webhookAlertService = new WebhookAlertService(alertSettings, EmailAlertService.Branding, new LoggerAdapter()); + keeps this handle for the email fan-out and any MCP/health consumers. The + history store (built at line above) is passed so the webhook cooldown is seeded + across restart (#1145) — without it a restart inside the cooldown window + re-posts a Teams/Slack alert delivered just before the restart. */ + _webhookAlertService = new WebhookAlertService(alertSettings, EmailAlertService.Branding, new LoggerAdapter(), _alertHistoryStore); _emailAlertService = new EmailAlertService(alertSettings, _alertHistoryStore, _webhookAlertService, new LoggerAdapter()); _alertCheckTimer = new DispatcherTimer(); diff --git a/Dashboard/Services/JsonAlertHistoryStore.cs b/Dashboard/Services/JsonAlertHistoryStore.cs index 842084bc0..16b78e69c 100644 --- a/Dashboard/Services/JsonAlertHistoryStore.cs +++ b/Dashboard/Services/JsonAlertHistoryStore.cs @@ -124,6 +124,35 @@ public Task RecordAlertAsync(AlertHistoryRecord record) } } + /// + /// Returns the UTC time the most recent alert webhook was successfully + /// sent for this server/metric, scanned from the in-memory alert log + /// (loaded from alert_history.json on startup) — or null if none. Seeds + /// the webhook cooldown after restart so a Teams/Slack alert posted + /// shortly before a restart is not re-posted afterward (#1145, mirroring + /// the email seed #981). + /// + /// + /// Dashboard records webhook deliveries as their own alert-log rows with + /// NotificationType == "webhook" (written only on a successful post), so + /// the type alone implies success — no SendError filter is needed. + /// + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + { + lock (_alertLogLock) + { + DateTime? max = null; + foreach (var entry in _alertLog) + { + if (entry.ServerId != serverId) continue; + if (entry.MetricName != metricName) continue; + if (entry.NotificationType != "webhook") continue; + if (max == null || entry.AlertTime > max.Value) max = entry.AlertTime; + } + return Task.FromResult(max); + } + } + /// /// Returns the AlertTime of the most recent log entry for the given /// (serverId, metricName), regardless of notification channel or send diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs index f1084ad13..e18ea4c62 100644 --- a/Lite.Tests/DuckDbSchemaTests.cs +++ b/Lite.Tests/DuckDbSchemaTests.cs @@ -138,8 +138,9 @@ public void SchemaStatements_MatchTableCount() foreach (var _ in Schema.GetAllTableStatements()) tableCount++; - /* 31 tables from Schema (schema_version is created separately by DuckDbInitializer) */ - Assert.Equal(31, tableCount); + /* 32 tables from Schema (schema_version is created separately by DuckDbInitializer). + Includes config_edge_trigger_watermarks added for #1145. */ + Assert.Equal(32, tableCount); } [Fact] diff --git a/Lite.Tests/StoreRoundTripTests.cs b/Lite.Tests/StoreRoundTripTests.cs index 5646c2fb4..38c5e8d0d 100644 --- a/Lite.Tests/StoreRoundTripTests.cs +++ b/Lite.Tests/StoreRoundTripTests.cs @@ -124,6 +124,70 @@ unfiltered read still returns the row. */ Assert.Null(await store.GetLastAlertTimeAsync("3", "Missing")); } + [Fact] + public async Task GetLastWebhookSentUtc_FiltersToWebhookRows_IncludingEmailWebhook() + { + await _duckDb.InitializeAsync(); + var store = new DuckDbAlertHistoryStore(_duckDb); + + /* #1145: only rows whose notification_type implies a webhook delivered seed the webhook + cooldown. email-only / tray rows must NOT; an 'email+webhook' row counts even though its + send_error is the EMAIL failure (the webhook still delivered), because send_error tracks + the email channel, not the webhook. */ + await RecordAsync(store, "5", "Blocking Detected", "email", null); // email only + await RecordAsync(store, "5", "Blocking Detected", "tray", null); // tray only + await RecordAsync(store, "5", "Blocking Detected", "webhook", null); // webhook + await RecordAsync(store, "5", "Blocking Detected", "email+webhook", "smtp boom"); // webhook sent, email failed (latest) + + var lastWebhook = await store.GetLastWebhookSentUtcAsync("5", "Blocking Detected"); + var lastAny = await store.GetLastAlertTimeAsync("5", "Blocking Detected"); + + Assert.NotNull(lastWebhook); + /* The email+webhook row is the last written, so it is both the unfiltered max and the + webhook-filtered max. */ + Assert.Equal(lastAny!.Value, lastWebhook!.Value); + + /* A metric with only email/tray rows → no webhook seed. */ + await RecordAsync(store, "5", "EmailOnly", "email", null); + await RecordAsync(store, "5", "EmailOnly", "tray", null); + Assert.Null(await store.GetLastWebhookSentUtcAsync("5", "EmailOnly")); + + /* Unknown metric → null. */ + Assert.Null(await store.GetLastWebhookSentUtcAsync("5", "Missing")); + } + + [Fact] + public async Task EdgeTriggerWatermark_SaveLoad_RoundTripsAndUpserts() + { + await _duckDb.InitializeAsync(); + var store = new DuckDbAlertHistoryStore(_duckDb); + + /* #1145: empty table → empty load. */ + Assert.Empty(await store.LoadEdgeTriggerWatermarksAsync()); + + await store.SaveEdgeTriggerWatermarkAsync(1, "Blocking Detected", 4); + await store.SaveEdgeTriggerWatermarkAsync(1, "Deadlocks Detected", 2); + await store.SaveEdgeTriggerWatermarkAsync(2, "Blocking Detected", 7); + + var loaded = await store.LoadEdgeTriggerWatermarksAsync(); + Assert.Equal(3, loaded.Count); + Assert.Contains(loaded, r => r.ServerId == 1 && r.MetricName == "Blocking Detected" && r.Watermark == 4); + Assert.Contains(loaded, r => r.ServerId == 1 && r.MetricName == "Deadlocks Detected" && r.Watermark == 2); + Assert.Contains(loaded, r => r.ServerId == 2 && r.MetricName == "Blocking Detected" && r.Watermark == 7); + + /* Upsert on the (server_id, metric_name) primary key: same key overwrites, no dup row. */ + await store.SaveEdgeTriggerWatermarkAsync(1, "Blocking Detected", 9); + loaded = await store.LoadEdgeTriggerWatermarksAsync(); + Assert.Equal(3, loaded.Count); + Assert.Contains(loaded, r => r.ServerId == 1 && r.MetricName == "Blocking Detected" && r.Watermark == 9); + + /* A reset to 0 (the window drained) persists too — so a restart restores 0, not a stale count. */ + await store.SaveEdgeTriggerWatermarkAsync(1, "Blocking Detected", 0); + loaded = await store.LoadEdgeTriggerWatermarksAsync(); + Assert.Equal(3, loaded.Count); + Assert.Contains(loaded, r => r.ServerId == 1 && r.MetricName == "Blocking Detected" && r.Watermark == 0); + } + [Fact] public async Task MuteRuleStore_InsertUpdateSetEnabledDeleteExpire_RoundTrips() { diff --git a/Lite.Tests/WebhookCooldownSeedTests.cs b/Lite.Tests/WebhookCooldownSeedTests.cs new file mode 100644 index 000000000..e144c0c4e --- /dev/null +++ b/Lite.Tests/WebhookCooldownSeedTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using PerformanceMonitor.Notifications; +using PerformanceMonitorLite; +using PerformanceMonitorLite.Services; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #1145: the shared must seed its per-(serverId, metricName) +/// cooldown from alert history on first use, so a Teams/Slack alert posted shortly before an app +/// restart is not re-posted on the first post-restart sweep — the guarantee #981 gave the email +/// channel. The cooldown is time-bounded (EmailCooldownMinutes), so it only covers a restart +/// inside the cooldown window; the time-independent edge-trigger watermark persistence (Lite) +/// covers the rest. These tests use a dead webhook URL: a SUPPRESSED post never touches the +/// network (the cooldown short-circuits first), while an ATTEMPTED post fails against the dead +/// URL and increments the Teams failure counter — the observable proxy for "did it try to post". +/// +public class WebhookCooldownSeedTests +{ + private static WebhookAlertService MakeService(IAlertHistoryStore? history, FakeWebhookSettings settings) + => new(settings, EmailAlertService.Branding, new AppLoggerAdapter(), history); + + private static FakeWebhookSettings EnabledTeamsSettings() => new() + { + TeamsWebhookEnabled = true, + TeamsWebhookUrl = "http://localhost:1/never", // closed port -> connection refused, fast deterministic failure + EmailCooldownMinutes = 15 + }; + + [Fact] + public async Task SeedsCooldownFromHistory_WithinWindow_SuppressesRepostAfterRestart() + { + // A webhook delivered "just now", then a restart (fresh service = empty in-memory cooldown). + var history = new FakeHistoryStore { LastWebhookSent = DateTime.UtcNow }; + var svc = MakeService(history, EnabledTeamsSettings()); + + var sent = await svc.TrySendWebhookAlertsAsync("Deadlocks Detected", "Srv", "4", "1", "1"); + + Assert.False(sent); // suppressed + Assert.Equal(1, history.GetLastWebhookSentCallCount); // the seed was consulted + Assert.Equal(0, svc.GetTeamsHealth().ConsecutiveFailures); // and NO post was attempted + } + + [Fact] + public async Task SeedFromHistory_OlderThanCooldown_DoesNotSuppress() + { + // gotqn's repro: the restart is 17 min after the send, beyond the 15-min cooldown. The + // cooldown seed must NOT suppress here — that's exactly why the Lite watermark persistence + // is also needed. The post is attempted (and fails against the dead URL). + var history = new FakeHistoryStore { LastWebhookSent = DateTime.UtcNow.AddMinutes(-17) }; + var svc = MakeService(history, EnabledTeamsSettings()); + + var sent = await svc.TrySendWebhookAlertsAsync("Deadlocks Detected", "Srv", "4", "1", "1"); + + Assert.False(sent); // dead URL -> post failed + Assert.Equal(1, history.GetLastWebhookSentCallCount); // seed consulted + Assert.Equal(1, svc.GetTeamsHealth().ConsecutiveFailures); // but it WAS attempted (not suppressed) + } + + [Fact] + public async Task NullHistoryStore_NoSeed_AttemptsPost() + { + // The legacy/test path passes no history store: pre-#1145 in-memory-only cooldown, so a + // fresh service attempts the post. + var svc = MakeService(history: null, EnabledTeamsSettings()); + + var sent = await svc.TrySendWebhookAlertsAsync("Deadlocks Detected", "Srv", "4", "1", "1"); + + Assert.False(sent); + Assert.Equal(1, svc.GetTeamsHealth().ConsecutiveFailures); + } + + private sealed class FakeWebhookSettings : IAlertSettings + { + public bool SmtpEnabled => false; + public string SmtpServer => ""; + public int SmtpPort => 25; + public bool SmtpUseSsl => false; + public string SmtpUsername => ""; + public string SmtpFromAddress => ""; + public string SmtpRecipients => ""; + public string? GetSmtpPassword() => null; + public int EmailCooldownMinutes { get; set; } = 15; + public bool TeamsWebhookEnabled { get; set; } + public string TeamsWebhookUrl { get; set; } = ""; + public string TeamsProxyAddress => ""; + public bool SlackWebhookEnabled { get; set; } + public string SlackWebhookUrl { get; set; } = ""; + public string SlackProxyAddress => ""; + public double AnalysisNotifySeverity => 1.5; + public int AnalysisNotifyCooldownMinutes => 360; + } + + private sealed class FakeHistoryStore : IAlertHistoryStore + { + public DateTime? LastWebhookSent { get; set; } + public int GetLastWebhookSentCallCount { get; private set; } + + public Task RecordAlertAsync(AlertHistoryRecord record) => Task.CompletedTask; + public Task GetLastEmailSentUtcAsync(string serverId, string metricName) => Task.FromResult(null); + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + { + GetLastWebhookSentCallCount++; + return Task.FromResult(LastWebhookSent); + } + public Task GetLastAlertTimeAsync(string serverId, string metricName) => Task.FromResult(null); + } +} diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index 1b059697d..7ff8ce044 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -768,6 +768,20 @@ CREATE TABLE IF NOT EXISTS config_alert_log ( context_json VARCHAR )"; + /* Edge-trigger watermarks for the rolling-count blocking/deadlock alert gate (#1091). + Persisted so the watermark survives an app restart (#1145): without it the in-memory + watermark resets to 0 and the first post-restart sweep re-fires the same alert (and + re-posts the same webhook) for events still lingering in the 1-hour lookback window. + Keyed (server_id, metric_name); one short row per server/metric, upserted on change. */ + public const string CreateEdgeTriggerWatermarksTable = @" +CREATE TABLE IF NOT EXISTS config_edge_trigger_watermarks ( + server_id INTEGER NOT NULL, + metric_name VARCHAR NOT NULL, + watermark INTEGER NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (server_id, metric_name) +)"; + public const string CreateMuteRulesTable = @" CREATE TABLE IF NOT EXISTS config_mute_rules ( id VARCHAR NOT NULL PRIMARY KEY, @@ -829,6 +843,7 @@ public static IEnumerable GetAllTableStatements() yield return CreateServerPropertiesTable; yield return CreateSessionStatsTable; yield return CreateAlertLogTable; + yield return CreateEdgeTriggerWatermarksTable; yield return CreateMuteRulesTable; yield return CreateDismissedArchiveAlertsTable; } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 6bc9298d8..d9b7778f3 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -68,6 +68,8 @@ reflects all conditions. _lastBadgeCounts lets the sweep re-render with the last private readonly IAlertSettings _alertSettings = new AppAlertSettings(); private readonly MuteRuleService _muteRuleService; private EmailAlertService _emailAlertService; + /* Held so the edge-trigger watermark seed/persist (#1145) can reach the store directly. */ + private readonly DuckDbAlertHistoryStore _alertHistoryStore; /* Track active alert states for resolved notifications */ private readonly Dictionary _activeCpuAlert = new(); @@ -98,6 +100,14 @@ and reset to 0 when the window empties so the next event alerts again. */ private readonly Dictionary _lastAlertedBlockingCount = new(); private readonly Dictionary _lastAlertedDeadlockCount = new(); + /* Persistence for the two watermarks above (#1145): seeded from the alert store at + startup (SeedEdgeTriggerWatermarksAsync) and upserted on change, so a restart does + not reset the watermark to 0 and re-fire / re-post a webhook for events still + lingering in the rolling 1-hour lookback window. The metric_name values are the + persisted-row keys; they need not match the alert "Detected" metric names. */ + private const string BlockingWatermarkMetric = "Blocking Detected"; + private const string DeadlockWatermarkMetric = "Deadlocks Detected"; + public MainWindow() { InitializeComponent(); @@ -105,12 +115,14 @@ public MainWindow() // Initialize services (with loggers wired to AppLogger) _databaseInitializer = new DuckDbInitializer(App.DatabasePath, new AppLoggerAdapter()); /* Webhook service is constructed first and injected into the email service - (Plan E E3c): the shared send core fans out to it. */ + (Plan E E3c): the shared send core fans out to it. The history store is shared + by both so the webhook service can seed its cooldown across restart (#1145). */ + _alertHistoryStore = new DuckDbAlertHistoryStore(_databaseInitializer); var webhookAlertService = new WebhookAlertService( - _alertSettings, EmailAlertService.Branding, new AppLoggerAdapter()); + _alertSettings, EmailAlertService.Branding, new AppLoggerAdapter(), _alertHistoryStore); _emailAlertService = new EmailAlertService( _alertSettings, - new DuckDbAlertHistoryStore(_databaseInitializer), + _alertHistoryStore, webhookAlertService, new AppLoggerAdapter()); _muteRuleService = new MuteRuleService( @@ -169,6 +181,14 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) // Initialize the DuckDB database await _databaseInitializer.InitializeAsync(); + /* Restore edge-trigger watermarks now — after the DB (and the watermark table) + exist, but before ANY alert sweep can read the watermark dicts. RefreshServerList() + below ends in a fire-and-forget RefreshOverviewAsync() → CheckPerformanceAlerts, so + seeding here (not just before the explicit RefreshOverviewAsync later) keeps a + restart from re-firing / re-posting alerts for events still in the lookback + window (#1145), independent of whether the DuckDB reads ever yield. */ + await SeedEdgeTriggerWatermarksAsync(); + // Initialize the collection engine (with loggers wired to AppLogger) _collectorService = new RemoteCollectorService( _databaseInitializer, @@ -1536,6 +1556,36 @@ private void CheckConnectionsAndNotify() } } + /// + /// Seeds the in-memory edge-trigger watermarks from the persisted store (#1145) so a + /// restart does not reset them to 0 and re-fire / re-post a webhook for blocking/deadlock + /// events still lingering in the rolling 1-hour lookback window. Runs once at startup, + /// after the DB is initialized and before the first alert sweep. + /// + private async Task SeedEdgeTriggerWatermarksAsync() + { + try + { + var rows = await _alertHistoryStore.LoadEdgeTriggerWatermarksAsync(); + foreach (var (serverId, metricName, watermark) in rows) + { + var key = serverId.ToString(); + if (metricName == BlockingWatermarkMetric) + { + _lastAlertedBlockingCount[key] = watermark; + } + else if (metricName == DeadlockWatermarkMetric) + { + _lastAlertedDeadlockCount[key] = watermark; + } + } + } + catch (Exception ex) + { + AppLogger.Error("Alerts", $"Failed to seed edge-trigger watermarks: {ex.Message}"); + } + } + private async void CheckPerformanceAlerts(ServerSummaryItem summary) { if (!App.AlertsEnabled || _trayService == null) return; @@ -1644,6 +1694,13 @@ await _emailAlertService.TrySendAlertEmailAsync( ? RollingCountAlertGate.Evaluate(effectiveBlockingCount, App.AlertBlockingThreshold, blockingWatermark, blockingCooldownElapsed, suppressPopups) : new RollingCountAlertGate.Decision(false, false, 0); _lastAlertedBlockingCount[key] = blockingDecision.Watermark; + /* Persist the watermark across restart so the same blocked-process reports aren't + re-alerted (and re-posted to Teams/Slack) on the first post-restart sweep (#1145). + On-change only — the gate returns the same watermark on most sweeps. */ + if (blockingDecision.Watermark != blockingWatermark) + { + await _alertHistoryStore.SaveEdgeTriggerWatermarkAsync(summary.ServerId, BlockingWatermarkMetric, blockingDecision.Watermark); + } bool wasBlockingActive = _activeBlockingAlert.TryGetValue(key, out var wasBlocking) && wasBlocking; _activeBlockingAlert[key] = blockingDecision.Active; @@ -1715,6 +1772,12 @@ await SendDetectedAlertAsync( ? RollingCountAlertGate.Evaluate(effectiveDeadlockCount, App.AlertDeadlockThreshold, deadlockWatermark, deadlockCooldownElapsed, suppressPopups) : new RollingCountAlertGate.Decision(false, false, 0); _lastAlertedDeadlockCount[key] = deadlockDecision.Watermark; + /* Persist the watermark across restart so the same deadlocks aren't re-alerted (and + re-posted to Teams/Slack) on the first post-restart sweep (#1145). On-change only. */ + if (deadlockDecision.Watermark != deadlockWatermark) + { + await _alertHistoryStore.SaveEdgeTriggerWatermarkAsync(summary.ServerId, DeadlockWatermarkMetric, deadlockDecision.Watermark); + } bool wasDeadlockActive = _activeDeadlockAlert.TryGetValue(key, out var wasDeadlock) && wasDeadlock; _activeDeadlockAlert[key] = deadlockDecision.Active; diff --git a/Lite/Services/DuckDbAlertHistoryStore.cs b/Lite/Services/DuckDbAlertHistoryStore.cs index cbdb4d5eb..8fd4e1881 100644 --- a/Lite/Services/DuckDbAlertHistoryStore.cs +++ b/Lite/Services/DuckDbAlertHistoryStore.cs @@ -7,6 +7,7 @@ */ using System; +using System.Collections.Generic; using System.Threading.Tasks; using PerformanceMonitor.Notifications; using PerformanceMonitorLite.Database; @@ -155,6 +156,60 @@ is explicit (the cooldown subtraction is tick math regardless). */ } } + /// + /// Returns the UTC time the most recent alert webhook was successfully sent + /// for this server/metric, read from config_alert_log — or null if none. + /// Seeds the webhook cooldown after restart so a Teams/Slack alert posted + /// shortly before a restart is not re-posted afterward (#1145, mirroring the + /// email seed #981). + /// + public async Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + { + var sid = int.TryParse(serverId, out var s) ? s : 0; + try + { + /* Use injected initializer, fall back to creating one from App.DatabasePath */ + var duckDb = _duckDb; + if (duckDb == null) + { + var dbPath = App.DatabasePath; + if (string.IsNullOrEmpty(dbPath)) return null; + duckDb = new DuckDbInitializer(dbPath); + } + + using var readLock = duckDb.AcquireReadLock(); + using var connection = duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var command = connection.CreateCommand(); + /* A successful webhook send is logged with a notification_type of + 'webhook' / 'email+webhook' — those types are only ever written + when WebhookSent is true, so the type alone implies success. + send_error tracks the EMAIL channel, so it is NOT filtered on: + an email-failed-but-webhook-sent row must still seed the cooldown. */ + command.CommandText = @" +SELECT MAX(alert_time) +FROM config_alert_log +WHERE server_id = $1 +AND metric_name = $2 +AND notification_type IN ('webhook', 'email+webhook')"; + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = sid }); + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = metricName }); + + var result = await command.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) return null; + + /* alert_time is written as DateTime.UtcNow; tag it UTC so the kind + is explicit (the cooldown subtraction is tick math regardless). */ + return DateTime.SpecifyKind(Convert.ToDateTime(result), DateTimeKind.Utc); + } + catch (Exception ex) + { + AppLogger.Error("WebhookAlert", $"Could not read persisted webhook cooldown: {ex.Message}"); + return null; + } + } + /// /// Returns the UTC time of the most recent alert_log row for this /// (serverId, metricName), regardless of notification channel or @@ -207,4 +262,86 @@ FROM config_alert_log return null; } } + + /// + /// Loads all persisted edge-trigger watermarks (#1145), one entry per + /// (server_id, metric_name). The caller seeds its in-memory watermark dicts + /// from these at startup, before the first alert sweep, so a restart does not + /// reset the watermark to 0 and re-fire (and re-post a webhook for) events + /// still lingering in the rolling lookback window. + /// + public async Task> LoadEdgeTriggerWatermarksAsync() + { + var result = new List<(int, string, int)>(); + try + { + var duckDb = _duckDb; + if (duckDb == null) + { + var dbPath = App.DatabasePath; + if (string.IsNullOrEmpty(dbPath)) return result; + duckDb = new DuckDbInitializer(dbPath); + } + + using var readLock = duckDb.AcquireReadLock(); + using var connection = duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var command = connection.CreateCommand(); + command.CommandText = @" +SELECT server_id, metric_name, watermark +FROM config_edge_trigger_watermarks"; + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + result.Add((Convert.ToInt32(reader.GetValue(0)), reader.GetString(1), Convert.ToInt32(reader.GetValue(2)))); + } + } + catch (Exception ex) + { + AppLogger.Error("Alerts", $"Could not load edge-trigger watermarks: {ex.Message}"); + } + return result; + } + + /// + /// Upserts one edge-trigger watermark (#1145). Called on-change only — the gate + /// returns the same watermark on the vast majority of sweeps — so this is a + /// low-frequency write that piggybacks on the existing alert-store write lock. + /// + public async Task SaveEdgeTriggerWatermarkAsync(int serverId, string metricName, int watermark) + { + try + { + var duckDb = _duckDb; + if (duckDb == null) + { + var dbPath = App.DatabasePath; + if (string.IsNullOrEmpty(dbPath)) return; + duckDb = new DuckDbInitializer(dbPath); + } + + using var writeLock = duckDb.AcquireWriteLock(); + using var connection = duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var command = connection.CreateCommand(); + /* INSERT OR REPLACE upserts on the (server_id, metric_name) primary key — + one stable row per server/metric, overwritten each time the watermark moves. */ + command.CommandText = @" +INSERT OR REPLACE INTO config_edge_trigger_watermarks (server_id, metric_name, watermark, updated_at) +VALUES ($1, $2, $3, $4)"; + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = metricName }); + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = watermark }); + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = DateTime.UtcNow }); + + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + AppLogger.Error("Alerts", $"Could not persist edge-trigger watermark ({metricName}): {ex.Message}"); + } + } } diff --git a/PerformanceMonitor.Notifications/IAlertHistoryStore.cs b/PerformanceMonitor.Notifications/IAlertHistoryStore.cs index f017160d3..76c988005 100644 --- a/PerformanceMonitor.Notifications/IAlertHistoryStore.cs +++ b/PerformanceMonitor.Notifications/IAlertHistoryStore.cs @@ -41,6 +41,16 @@ public interface IAlertHistoryStore /// Task GetLastEmailSentUtcAsync(string serverId, string metricName); + /// + /// MAX(alert_time) filtered to a *successful webhook send* — seeds the webhook + /// cooldown across restart so a Teams/Slack alert delivered shortly before a restart + /// is not re-posted afterward (#1145, mirroring the email seed #981). The + /// notification_type already implies the webhook delivered (it's only written on a + /// successful post), and send_error tracks the EMAIL channel, so it is NOT filtered on. + /// Lite: notification_type IN ('webhook','email+webhook'). Dash: NotificationType == "webhook". + /// + Task GetLastWebhookSentUtcAsync(string serverId, string metricName); + /// /// MAX(alert_time) UNFILTERED (any channel/result) — seeds the analysis /// per-finding cooldown across restart. Stamped unconditionally upstream. diff --git a/PerformanceMonitor.Notifications/WebhookAlertService.cs b/PerformanceMonitor.Notifications/WebhookAlertService.cs index ed297e05f..506b07425 100644 --- a/PerformanceMonitor.Notifications/WebhookAlertService.cs +++ b/PerformanceMonitor.Notifications/WebhookAlertService.cs @@ -41,17 +41,28 @@ at the email / in-app dialog instead. */ private readonly IAlertSettings _settings; private readonly AlertBranding _branding; private readonly ILogger _logger; + private readonly IAlertHistoryStore? _historyStore; private int _consecutiveTeamsFailures; private string? _lastTeamsError; private int _consecutiveSlackFailures; private string? _lastSlackError; - public WebhookAlertService(IAlertSettings settings, AlertBranding branding, ILogger logger) + /// + /// Optional alert-history store used to seed the per-(serverId, metricName) webhook + /// cooldown across an app restart (#1145, mirroring the email seed #981). When null the + /// cooldown is purely in-memory (the pre-#1145 behavior) — the test call sites pass null. + /// + public WebhookAlertService( + IAlertSettings settings, + AlertBranding branding, + ILogger logger, + IAlertHistoryStore? historyStore = null) { _settings = settings; _branding = branding; _logger = logger; + _historyStore = historyStore; } /// @@ -69,6 +80,20 @@ public async Task TrySendWebhookAlertsAsync( try { var cooldownKey = $"webhook:{serverId}:{metricName}"; + + /* Seed the in-memory cooldown from the alert log the first time this key is + seen, so a Teams/Slack alert posted shortly before an app restart is not + immediately re-posted afterward (#1145, mirroring the email seed #981). The + in-memory dictionary is authoritative once seeded. */ + if (_historyStore is not null && !_cooldowns.ContainsKey(cooldownKey)) + { + var lastPersistedSend = await _historyStore.GetLastWebhookSentUtcAsync(serverId, metricName); + if (lastPersistedSend.HasValue) + { + _cooldowns.TryAdd(cooldownKey, lastPersistedSend.Value); + } + } + if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) && DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(_settings.EmailCooldownMinutes)) { From 3fb2997cc41e6ad865b76db48e51c6c5cd1fb4c7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:04:46 -0400 Subject: [PATCH 012/145] Single-instance upgrade handoff: close the stale instance instead of surfacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrading the app appeared not to take effect: the apps are single-instance (constant mutex) and minimize to tray, so an old build kept running after the user "closed" it, and launching the new build just surfaced the old in-memory version via the mutex. Fix: a version-aware handoff at startup — a newer build closes an older tray-resident one and takes over, instead of being handed back the stale version. Shared PerformanceMonitor.Ui: - SingleInstanceDecision: pure, unit-tested decision (older->take over; same/newer->surface; older-but-higher-integrity->actionable error). - ProcessInspector: Win32 — read the other instance's release version from its on-disk exe (QueryFullProcessImageNameW, cross-integrity for same user), measure integrity level directly, detect split-token admin. Fails closed. - SingleInstanceCoordinator: acquire-or-handoff; prompt; graceful exit signal (old runs its real shutdown), bounded wait, force-kill last resort; mutex take-over; elevated relaunch (--upgrade-takeover) for the elevated-old case. - MessageBoxHandoffPrompts: shared dialogs (both apps, parity). Both apps: OnStartup runs the coordinator (replacing the inline mutex/surface block) synchronously before any window/DB/port init; OnExit disposes it; MainWindow opens the exit-for-upgrade channel only after init (so a newer build won't disturb a mid-initializing instance). Scoped by exe name so Lite never targets Dashboard and vice-versa. Local\ session scoping kept intentionally. Two adversarial plan reviews + one implementation review folded in (version field = ProductVersion not FileVersion; mutex-throw vs an elevated instance -> integrity-error path not crash; deferred exit listener; direct IL measure; runas gated to split-token admins; UAC-cancel handled; handles disposed). Tests: Lite 524 + Dashboard 487 green; 0 new warnings. Manual smoke testing of the upgrade/elevation paths still required before merge (see plans/single-instance-upgrade-handoff.md). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/App.xaml.cs | 49 ++- Dashboard/MainWindow.xaml.cs | 5 + Lite.Tests/SingleInstanceDecisionTests.cs | 110 ++++++ Lite/App.xaml.cs | 50 ++- Lite/MainWindow.xaml.cs | 4 + .../MessageBoxHandoffPrompts.cs | 52 +++ PerformanceMonitor.Ui/ProcessInspector.cs | 299 ++++++++++++++ .../SingleInstanceCoordinator.cs | 371 ++++++++++++++++++ .../SingleInstanceDecision.cs | 118 ++++++ 9 files changed, 1028 insertions(+), 30 deletions(-) create mode 100644 Lite.Tests/SingleInstanceDecisionTests.cs create mode 100644 PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs create mode 100644 PerformanceMonitor.Ui/ProcessInspector.cs create mode 100644 PerformanceMonitor.Ui/SingleInstanceCoordinator.cs create mode 100644 PerformanceMonitor.Ui/SingleInstanceDecision.cs diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index 2f3e066e2..41fdf6b69 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -22,20 +22,39 @@ namespace PerformanceMonitorDashboard public partial class App : Application { private const string MutexName = "PerformanceMonitorDashboard_SingleInstance"; - private Mutex? _singleInstanceMutex; - private bool _ownsMutex; + /* Version-aware single-instance + upgrade handoff (plans/single-instance-upgrade-handoff.md): + a newer build launched over an older tray-resident one closes it and takes over instead of + being handed back the stale in-memory version. The coordinator owns the mutex + the + exit-for-upgrade listener for the life of the owning process. */ + private const string ExitForUpgradeEventName = "PerformanceMonitorDashboard_ExitForUpgrade"; + private SingleInstanceCoordinator? _instanceCoordinator; protected override void OnStartup(StartupEventArgs e) { NativeMethods.SetAppUserModelId("DarlingData.PerformanceMonitor.Dashboard"); - // Check for existing instance - _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex); - - if (!_ownsMutex) + /* Single-instance with upgrade handoff. Runs synchronously at the top of OnStartup before + base.OnStartup and any window/MCP init, so a stale older build is closed before we bind + the MCP port / touch shared config. A same/newer instance just surfaces (today's behavior + via WM_SHOWMONITOR); an older-but-elevated one raises an actionable error. */ + _instanceCoordinator = new SingleInstanceCoordinator(new SingleInstanceOptions + { + MutexName = MutexName, + ProcessName = "PerformanceMonitorDashboard", + ExitEventName = ExitForUpgradeEventName, + SurfaceRunningInstance = NativeMethods.BroadcastShowMessage, + GracefulSelfExit = () => Dispatcher.BeginInvoke(new Action(() => + { + if (MainWindow is MainWindow mw) mw.ExitApplication(); + else Shutdown(); + })), + Prompts = new MessageBoxHandoffPrompts("Performance Monitor Dashboard"), + AutoConfirm = Array.Exists(e.Args, a => string.Equals(a, HandoffArgs.AutoConfirm, StringComparison.OrdinalIgnoreCase)), + Log = msg => { try { Logger.Info($"[SingleInstance] {msg}"); } catch { /* logger not yet initialized */ } }, + }); + + if (!_instanceCoordinator.TryBecomeOwner()) { - // Another instance is already running - activate it and exit - NativeMethods.BroadcastShowMessage(); Shutdown(); return; } @@ -73,6 +92,13 @@ protected override void OnStartup(StartupEventArgs e) mainWindow.Show(); } + /// + /// Opens the upgrade-handoff "exit" channel once startup is past its risky init. Called by + /// after initialization so a newer build won't signal/kill us mid-init + /// (#single-instance-upgrade-handoff). Safe to call more than once. + /// + public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff(); + protected override void OnExit(ExitEventArgs e) { Logger.Info($"=== Application Exiting (Exit Code: {e.ApplicationExitCode}) ==="); @@ -83,11 +109,8 @@ protected override void OnExit(ExitEventArgs e) mainWin.ExitApplication(); } - if (_ownsMutex) - { - _singleInstanceMutex?.ReleaseMutex(); - } - _singleInstanceMutex?.Dispose(); + /* Releases the mutex + disposes the exit-for-upgrade listener. */ + _instanceCoordinator?.Dispose(); base.OnExit(e); } diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 54b811a38..ce828d9b5 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -277,6 +277,11 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) await CheckAllConnectionsAsync(); + /* Past startup init (MCP bound, services configured) — open the single-instance "exit for + upgrade" channel so a newer build can ask us to step aside cleanly + (#single-instance-upgrade-handoff). */ + (Application.Current as App)?.EnableUpgradeHandoff(); + _ = CheckForUpdatesOnStartupAsync(); } diff --git a/Lite.Tests/SingleInstanceDecisionTests.cs b/Lite.Tests/SingleInstanceDecisionTests.cs new file mode 100644 index 000000000..44893cee9 --- /dev/null +++ b/Lite.Tests/SingleInstanceDecisionTests.cs @@ -0,0 +1,110 @@ +using System; +using PerformanceMonitor.Ui; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// Unit coverage for the version-aware single-instance upgrade-handoff decision +/// (plans/single-instance-upgrade-handoff.md). The decision is a pure function over already-measured +/// inputs; the brittle Win32 measurement (Win32ProcessInspector) is exercised separately at runtime. +/// +public class SingleInstanceDecisionTests +{ + private const int Medium = 0x2000; + private const int High = 0x3000; + + private static Version V(string s) => Version.Parse(s); + + [Fact] + public void OlderAtSameIntegrity_TakesOver() + { + var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, Medium, canElevateSameUser: false); + Assert.Equal(HandoffAction.TakeOver, action); + } + + [Fact] + public void OlderAtLowerIntegrity_TakesOver() + { + // We're High, the old one is Medium — reachable. + var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), High, Medium, canElevateSameUser: false); + Assert.Equal(HandoffAction.TakeOver, action); + } + + [Fact] + public void OlderButHigherIntegrity_WhenCanElevate_OffersRunas() + { + var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, High, canElevateSameUser: true); + Assert.Equal(HandoffAction.IntegrityErrorWithRunas, action); + } + + [Fact] + public void OlderButHigherIntegrity_WhenCannotElevate_ManualOnly() + { + var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, High, canElevateSameUser: false); + Assert.Equal(HandoffAction.IntegrityErrorManualOnly, action); + } + + [Fact] + public void OlderButIntegrityUnmeasurable_Surfaces() + { + // Couldn't read the other instance's IL → don't guess; surface (no false UAC). + var action = SingleInstanceDecision.Decide(V("3.1.0"), V("3.0.0"), Medium, null, canElevateSameUser: true); + Assert.Equal(HandoffAction.Surface, action); + } + + [Fact] + public void SameVersion_Surfaces_RegardlessOfIntegrity() + { + Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), V("3.0.0"), Medium, Medium, false)); + // An already-running elevated same-version instance must NOT be misclassified as a mismatch. + Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), V("3.0.0"), Medium, High, true)); + } + + [Fact] + public void NewerRunningInstance_Surfaces() + { + // An older build launched over a newer one must not evict it. + var action = SingleInstanceDecision.Decide(V("3.0.0"), V("3.1.0"), Medium, Medium, canElevateSameUser: true); + Assert.Equal(HandoffAction.Surface, action); + } + + [Fact] + public void UnreadableVersions_Surface() + { + Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(null, V("3.0.0"), Medium, Medium, false)); + Assert.Equal(HandoffAction.Surface, SingleInstanceDecision.Decide(V("3.0.0"), null, Medium, Medium, false)); + } + + [Theory] + [InlineData("3.0.1", "3.0.1")] + [InlineData("3.0.1.4", "3.0.1.4")] + [InlineData("3.0.1-nightly.20260618", "3.0.1")] // pre-release suffix stripped + [InlineData("3.0.1+abc1234", "3.0.1")] // build metadata stripped + [InlineData(" 3.0.1 ", "3.0.1")] // trimmed + public void ParseProductVersion_ParsesNumericCore(string raw, string expected) + { + Assert.Equal(Version.Parse(expected), SingleInstanceDecision.ParseProductVersion(raw)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-version")] + [InlineData("-nightly")] + public void ParseProductVersion_ReturnsNullForUnparseable(string? raw) + { + Assert.Null(SingleInstanceDecision.ParseProductVersion(raw)); + } + + [Fact] + public void DriftedFileVersionButBumpedProductVersion_StillOlder() + { + // The C1 regression guard: compare ProductVersion (the bumped ), even if other + // version fields drift. Here the running build's resolved version is genuinely older. + var ours = SingleInstanceDecision.ParseProductVersion("3.1.0+ci"); + var other = SingleInstanceDecision.ParseProductVersion("3.0.0+ci"); + Assert.Equal(HandoffAction.TakeOver, SingleInstanceDecision.Decide(ours, other, Medium, Medium, false)); + } +} diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index 93af60889..70a3ce319 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -35,8 +35,12 @@ public partial class App : Application private static extern void SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string appId); private const string MutexName = "PerformanceMonitorLite_SingleInstance"; - private Mutex? _singleInstanceMutex; - private bool _ownsMutex; + /* Version-aware single-instance + upgrade handoff (plans/single-instance-upgrade-handoff.md): + a newer build launched over an older tray-resident one closes it and takes over instead of + being handed back the stale in-memory version. The coordinator owns the mutex + the exit + listener for the life of the owning process. */ + private const string ExitForUpgradeEventName = "PerformanceMonitorLite_ExitForUpgrade"; + private SingleInstanceCoordinator? _instanceCoordinator; /* Single-instance "surface the window" channel (#769, #1050). A second launch signals this named event and exits; the owning instance restores its window through WPF's own Show() path @@ -261,17 +265,25 @@ protected override void OnStartup(StartupEventArgs e) { SetCurrentProcessExplicitAppUserModelID("DarlingData.PerformanceMonitor.Lite"); - // Check for existing instance - _singleInstanceMutex = new Mutex(true, MutexName, out _ownsMutex); - - if (!_ownsMutex) + /* Single-instance with upgrade handoff. Runs synchronously, at the top of OnStartup before + base.OnStartup and any window/data init, so we only open the shared DuckDB / bind the MCP + port after any older instance has released them. A newer build closes an older tray-resident + one and takes over; a same/newer one just surfaces the existing instance (today's behavior); + an older-but-elevated one raises an actionable error. */ + _instanceCoordinator = new SingleInstanceCoordinator(new SingleInstanceOptions + { + MutexName = MutexName, + ProcessName = "PerformanceMonitorLite", + ExitEventName = ExitForUpgradeEventName, + SurfaceRunningInstance = () => SingleInstanceSignal.TrySignal(ShowWindowEventName), + GracefulSelfExit = () => Dispatcher.BeginInvoke(new Action(Shutdown)), + Prompts = new MessageBoxHandoffPrompts("Performance Monitor Lite"), + AutoConfirm = Array.Exists(e.Args, a => string.Equals(a, HandoffArgs.AutoConfirm, StringComparison.OrdinalIgnoreCase)), + Log = msg => { try { AppLogger.Info("SingleInstance", msg); } catch { /* logger not yet initialized */ } }, + }); + + if (!_instanceCoordinator.TryBecomeOwner()) { - /* Ask the running instance to surface its window, then exit (#769). We signal a named - event rather than poking its HWND with Win32 ShowWindow: the owning instance restores - through WPF's own Show() path, which is the only thing that un-blanks a tray-hidden - window (#1050). Best-effort — if the first instance is still mid-startup the channel - may not exist yet, but it's already coming up visible anyway. */ - SingleInstanceSignal.TrySignal(ShowWindowEventName); Shutdown(); return; } @@ -346,6 +358,13 @@ private void OnSurfaceWindowRequested() Dispatcher.BeginInvoke(new Action(() => _mainWindow?.RestoreFromTray())); } + /// + /// Opens the upgrade-handoff "exit" channel once startup is past its risky init (DuckDB ready). + /// Called by after initialization so a newer build won't signal/kill us + /// mid-init (#single-instance-upgrade-handoff). Safe to call more than once. + /// + public void EnableUpgradeHandoff() => _instanceCoordinator?.EnableUpgradeHandoff(); + protected override void OnExit(ExitEventArgs e) { AppLogger.Info("App", "Shutting down"); @@ -354,11 +373,8 @@ protected override void OnExit(ExitEventArgs e) AppLogger.Shutdown(); - if (_ownsMutex) - { - _singleInstanceMutex?.ReleaseMutex(); - } - _singleInstanceMutex?.Dispose(); + /* Releases the mutex + disposes the exit-for-upgrade listener. */ + _instanceCoordinator?.Dispose(); base.OnExit(e); } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index d9b7778f3..83b83f3dd 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -259,6 +259,10 @@ seeding here (not just before the explicit RefreshOverviewAsync later) keeps a await RefreshOverviewAsync(); StatusText.Text = "Ready - Collection active"; + /* Now past the risky DuckDB init — open the single-instance "exit for upgrade" channel so a + newer build can ask us to step aside cleanly (#single-instance-upgrade-handoff). */ + (Application.Current as App)?.EnableUpgradeHandoff(); + _ = CheckForUpdatesOnStartupAsync(); } catch (Exception ex) diff --git a/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs b/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs new file mode 100644 index 000000000..5df9eb8c4 --- /dev/null +++ b/PerformanceMonitor.Ui/MessageBoxHandoffPrompts.cs @@ -0,0 +1,52 @@ +/* + * 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.Windows; + +namespace PerformanceMonitor.Ui +{ + /// + /// Shared WPF -based so both apps show the + /// same upgrade-handoff dialogs (parity), parameterized by the app's display name. Runs early in + /// App.OnStartup (before the main window exists); an ownerless MessageBox is used because a + /// freshly-launched process's dialog comes up foreground, and an off-screen owner would mis-place + /// the box. (Guaranteed-topmost is a possible refinement, not correctness.) + /// + public sealed class MessageBoxHandoffPrompts : IHandoffPrompts + { + private readonly string _appName; + + public MessageBoxHandoffPrompts(string appName) => _appName = appName; + + public bool ConfirmCloseAndContinue(string oldVersion, string newVersion) => + MessageBox.Show( + $"A previous version ({oldVersion}) of {_appName} is still running.\n\n" + + $"Close it and continue with version {newVersion}?", + $"{_appName} — Update", + MessageBoxButton.YesNo, + MessageBoxImage.Question) == MessageBoxResult.Yes; + + public bool ConfirmRestartAsAdmin(string oldVersion) => + MessageBox.Show( + $"A previous version ({oldVersion}) of {_appName} is running with administrator " + + "privileges and can't be closed automatically.\n\n" + + "Restart as administrator to continue the update?", + $"{_appName} — Update", + MessageBoxButton.YesNo, + MessageBoxImage.Warning) == MessageBoxResult.Yes; + + public void ShowMustCloseElevatedManually(string oldVersion) => + MessageBox.Show( + $"A previous version ({oldVersion}) of {_appName} is running with administrator " + + "privileges and can't be closed automatically.\n\n" + + $"Close it from its system tray icon, then reopen {_appName}.", + $"{_appName} — Update", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } +} diff --git a/PerformanceMonitor.Ui/ProcessInspector.cs b/PerformanceMonitor.Ui/ProcessInspector.cs new file mode 100644 index 000000000..5244b6d3e --- /dev/null +++ b/PerformanceMonitor.Ui/ProcessInspector.cs @@ -0,0 +1,299 @@ +/* + * 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.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace PerformanceMonitor.Ui +{ + /// + /// Measures the facts the upgrade handoff needs about another running process — its release + /// version (read from the on-disk exe, which works cross-integrity for the same user) and its + /// mandatory-integrity level — plus our own integrity and whether we can elevate same-user. + /// Behind an interface so can be tested with a fake + /// and the brittle Win32 stays isolated. Every method fails CLOSED (returns null/false) so a + /// denied or unexpected call degrades to "surface", never a crash or a false elevation prompt. + /// + public interface IProcessInspector + { + /// Release version (ProductVersion of the on-disk exe) for the given pid, or null if unreadable. + Version? GetReleaseVersion(int pid); + + /// Mandatory-integrity RID (e.g. 0x3000 = High) for the given pid, or null if it couldn't be measured. + int? GetIntegrityLevel(int pid); + + /// This process's mandatory-integrity RID; falls back to Medium (0x2000) if it can't be read. + int CurrentIntegrityLevel(); + + /// True if the current user could elevate in place (already elevated, split-token admin, or a UAC-off / built-in admin). + bool CanElevateSameUser(); + } + + /// Real Win32-backed . + public sealed class Win32ProcessInspector : IProcessInspector + { + public const int IntegrityMedium = 0x2000; + public const int IntegrityHigh = 0x3000; + + public Version? GetReleaseVersion(int pid) + { + try + { + var path = QueryImagePath(pid); + if (string.IsNullOrEmpty(path)) + { + return null; + } + + /* Read the version from the on-disk file (world-readable), NOT process memory — + FileVersionInfo here, not Process.MainModule, so an elevated same-user instance + is still readable from a non-elevated launcher. */ + var info = FileVersionInfo.GetVersionInfo(path); + return SingleInstanceDecision.ParseProductVersion(info.ProductVersion) + ?? SingleInstanceDecision.ParseProductVersion(info.FileVersion); + } + catch + { + return null; + } + } + + public int? GetIntegrityLevel(int pid) + { + IntPtr process = IntPtr.Zero; + try + { + process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, (uint)pid); + if (process == IntPtr.Zero) + { + return null; + } + + return ReadIntegrityLevel(process); + } + catch + { + return null; + } + finally + { + if (process != IntPtr.Zero) + { + CloseHandle(process); + } + } + } + + public int CurrentIntegrityLevel() + { + try + { + var level = ReadIntegrityLevel(GetCurrentProcess()); + return level ?? IntegrityMedium; + } + catch + { + return IntegrityMedium; + } + } + + public bool CanElevateSameUser() + { + try + { + /* Already running elevated → we can act as admin. */ + using var identity = WindowsIdentity.GetCurrent(); + if (new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator)) + { + return true; + } + + /* Not elevated: a split-token admin can elevate in place; a true standard user cannot + (runas would require *different* admin credentials → a different user/profile). */ + var type = GetElevationType(); + return type == TokenElevationType.Limited; + } + catch + { + return false; + } + } + + private static string? QueryImagePath(int pid) + { + IntPtr process = IntPtr.Zero; + try + { + process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, (uint)pid); + if (process == IntPtr.Zero) + { + return null; + } + + var buffer = new char[1024]; + uint size = (uint)buffer.Length; + return QueryFullProcessImageName(process, 0, buffer, ref size) ? new string(buffer, 0, (int)size) : null; + } + finally + { + if (process != IntPtr.Zero) + { + CloseHandle(process); + } + } + } + + private static int? ReadIntegrityLevel(IntPtr process) + { + IntPtr token = IntPtr.Zero; + IntPtr info = IntPtr.Zero; + try + { + if (!OpenProcessToken(process, TOKEN_QUERY, out token)) + { + return null; + } + + GetTokenInformation(token, TokenInformationClass.TokenIntegrityLevel, IntPtr.Zero, 0, out uint needed); + if (needed == 0) + { + return null; + } + + info = Marshal.AllocHGlobal((int)needed); + if (!GetTokenInformation(token, TokenInformationClass.TokenIntegrityLevel, info, needed, out _)) + { + return null; + } + + var label = Marshal.PtrToStructure(info); + var sid = label.Label.Sid; + int subAuthorityCount = Marshal.ReadByte(GetSidSubAuthorityCount(sid)); + if (subAuthorityCount == 0) + { + return null; + } + + IntPtr ridPtr = GetSidSubAuthority(sid, (uint)(subAuthorityCount - 1)); + return Marshal.ReadInt32(ridPtr); + } + catch + { + return null; + } + finally + { + if (info != IntPtr.Zero) + { + Marshal.FreeHGlobal(info); + } + if (token != IntPtr.Zero) + { + CloseHandle(token); + } + } + } + + private static TokenElevationType GetElevationType() + { + IntPtr token = IntPtr.Zero; + try + { + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, out token)) + { + return TokenElevationType.Default; + } + + uint size = sizeof(int); + IntPtr buffer = Marshal.AllocHGlobal((int)size); + try + { + if (!GetTokenInformation(token, TokenInformationClass.TokenElevationType, buffer, size, out _)) + { + return TokenElevationType.Default; + } + return (TokenElevationType)Marshal.ReadInt32(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + catch + { + return TokenElevationType.Default; + } + finally + { + if (token != IntPtr.Zero) + { + CloseHandle(token); + } + } + } + + private enum TokenElevationType + { + Default = 1, + Full = 2, + Limited = 3, + } + + private enum TokenInformationClass + { + TokenElevationType = 18, + TokenIntegrityLevel = 25, + } + + [StructLayout(LayoutKind.Sequential)] + private struct SID_AND_ATTRIBUTES + { + public IntPtr Sid; + public uint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TOKEN_MANDATORY_LABEL + { + public SID_AND_ATTRIBUTES Label; + } + + private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + private const uint TOKEN_QUERY = 0x0008; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint desiredAccess, bool inheritHandle, uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool QueryFullProcessImageName(IntPtr hProcess, uint flags, char[] exeName, ref uint size); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation(IntPtr tokenHandle, TokenInformationClass tokenInformationClass, IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern IntPtr GetSidSubAuthorityCount(IntPtr sid); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern IntPtr GetSidSubAuthority(IntPtr sid, uint subAuthorityIndex); + } +} diff --git a/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs b/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs new file mode 100644 index 000000000..880aabd88 --- /dev/null +++ b/PerformanceMonitor.Ui/SingleInstanceCoordinator.cs @@ -0,0 +1,371 @@ +/* + * 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.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; + +namespace PerformanceMonitor.Ui +{ + /// Command-line flag the elevated relaunch carries so it skips the "close it?" prompt. + public static class HandoffArgs + { + public const string AutoConfirm = "--upgrade-takeover"; + } + + /// App-supplied dialogs for the handoff. Implemented per app (WPF MessageBox), kept out of + /// the coordinator so the orchestration stays testable and the UI stays in the app. + public interface IHandoffPrompts + { + /// "A previous version is still running. Close it and continue?" — true = proceed. + bool ConfirmCloseAndContinue(string oldVersion, string newVersion); + + /// "…is running as administrator. Restart as administrator to continue?" — true = elevate. + bool ConfirmRestartAsAdmin(string oldVersion); + + /// Info-only: an elevated previous version must be closed from its tray icon. + void ShowMustCloseElevatedManually(string oldVersion); + } + + /// Per-app configuration for . + public sealed class SingleInstanceOptions + { + /// Constant per-app mutex name (e.g. PerformanceMonitorLite_SingleInstance). + public string MutexName { get; set; } = string.Empty; + + /// Process name without extension (e.g. PerformanceMonitorLite). + public string ProcessName { get; set; } = string.Empty; + + /// Shared named event the owning instance listens on for an "exit for upgrade" request. + public string ExitEventName { get; set; } = string.Empty; + + /// Run in the NEW instance to tell the running instance to surface (Lite: signal the show + /// event; Dashboard: broadcast WM_SHOWMONITOR). + public Action SurfaceRunningInstance { get; set; } = static () => { }; + + /// Run in the OWNING instance when an exit-for-upgrade request arrives — the app's real + /// shutdown path (Lite: Application.Shutdown(); Dashboard: MainWindow.ExitApplication()). + public Action GracefulSelfExit { get; set; } = static () => { }; + + /// App dialogs. + public IHandoffPrompts Prompts { get; set; } = null!; + + /// True when launched by an elevated relaunch () — skip the close prompt. + public bool AutoConfirm { get; set; } + + /// Injectable for tests; defaults to . + public IProcessInspector? Inspector { get; set; } + + /// Optional diagnostic logging. + public Action? Log { get; set; } + } + + /// + /// Version-aware single-instance enforcement with upgrade handoff. Replaces the old inline + /// "mutex held → surface + exit" block: when a NEWER build launches over an OLDER running one + /// (the post-upgrade case), it closes the old one and takes over instead of being handed back + /// the stale in-memory version. See plans/single-instance-upgrade-handoff.md. + /// + /// Lifetime: the owning process keeps the instance (it holds the mutex + the exit listener) and + /// disposes it on exit. + /// + public sealed class SingleInstanceCoordinator : IDisposable + { + private const int GracefulWaitWithListenerMs = 20000; // old instance's own clean shutdown can take ~10s+ + private const int GracefulWaitNoListenerMs = 3000; // no listener yet → it may be mid-startup + private const int PostKillWaitMs = 3000; + private const int MutexAcquireTimeoutMs = 5000; + + private readonly SingleInstanceOptions _options; + private readonly IProcessInspector _inspector; + private Mutex? _mutex; + private bool _ownsMutex; + private SingleInstanceSignal? _exitSignal; + private bool _disposed; + + public SingleInstanceCoordinator(SingleInstanceOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrEmpty(options.MutexName) || string.IsNullOrEmpty(options.ProcessName) || string.IsNullOrEmpty(options.ExitEventName)) + throw new ArgumentException("MutexName, ProcessName, and ExitEventName are required.", nameof(options)); + if (options.Prompts is null) + throw new ArgumentException("Prompts is required.", nameof(options)); + _inspector = options.Inspector ?? new Win32ProcessInspector(); + } + + /// + /// True if this instance should proceed with startup (it now owns the single-instance slot). + /// False if the caller should Shutdown() immediately (it surfaced an existing instance, + /// raised an integrity error, declined, or relaunched elevated). + /// + public bool TryBecomeOwner() + { + try + { + _mutex = new Mutex(true, _options.MutexName, out var createdNew); + if (createdNew) + { + BecomeOwner(); + return true; + } + } + catch (UnauthorizedAccessException) + { + /* The named mutex was created by a higher-integrity (elevated) instance; a non-elevated + process is denied write access to it (mandatory NO_WRITE_UP). That IS the elevated-old + case — fall through to the handoff, which measures the integrity mismatch and raises + the actionable error (+ "Restart as administrator"). The handoff null-guards the + absent mutex; after elevation the equal-integrity relaunch acquires it normally. */ + _mutex = null; + } + catch (System.IO.IOException) + { + _mutex = null; + } + + /* Another instance holds the mutex — decide whether to surface it (today's behavior) or + take over (it's a stale older build from before an upgrade). */ + return Handoff(); + } + + private bool Handoff() + { + var others = FindOtherInstances(); + if (others.Count == 0) + { + /* Mutex held but no peer process found — a race or an abandoned mutex. Try to grab it. */ + return TryAcquireAfterRelease() || SurfaceAndExit(); + } + + try + { + var ourVersion = CurrentReleaseVersion(); + var ourIntegrity = _inspector.CurrentIntegrityLevel(); + var otherVersion = _inspector.GetReleaseVersion(others[0].Id); + var otherIntegrity = _inspector.GetIntegrityLevel(others[0].Id); + var canElevate = _inspector.CanElevateSameUser(); + + var action = SingleInstanceDecision.Decide(ourVersion, otherVersion, ourIntegrity, otherIntegrity, canElevate); + _options.Log?.Invoke($"Handoff: ours={ourVersion} other={otherVersion} ourIL={ourIntegrity} otherIL={otherIntegrity} canElevate={canElevate} -> {action}"); + + var oldText = otherVersion?.ToString() ?? "unknown"; + var newText = ourVersion?.ToString() ?? "unknown"; + + switch (action) + { + case HandoffAction.TakeOver: + if (!_options.AutoConfirm && !_options.Prompts.ConfirmCloseAndContinue(oldText, newText)) + return SurfaceAndExit(); + return TakeOverFrom(others) || SurfaceAndExit(); + + case HandoffAction.IntegrityErrorWithRunas: + if (_options.Prompts.ConfirmRestartAsAdmin(oldText)) + RelaunchElevated(); + return false; // either relaunched, or declined — exit without surfacing + + case HandoffAction.IntegrityErrorManualOnly: + _options.Prompts.ShowMustCloseElevatedManually(oldText); + return false; + + case HandoffAction.Surface: + default: + return SurfaceAndExit(); + } + } + finally + { + foreach (var proc in others) + { + proc.Dispose(); + } + } + } + + private bool TakeOverFrom(List others) + { + /* Ask the old instance(s) to shut down gracefully (it runs its real exit path: flush DuckDB, + dispose tray, release the mutex). The shared exit event broadcasts to whoever is listening. */ + var delivered = SingleInstanceSignal.TrySignal(_options.ExitEventName); + var budget = delivered ? GracefulWaitWithListenerMs : GracefulWaitNoListenerMs; + + var stillRunning = WaitForExit(others, budget); + if (stillRunning.Count > 0) + { + if (!delivered) + { + /* No listener existed → the old instance may be mid-startup (e.g. building the DuckDB + schema). Killing then is riskier than a stale alert; bail to surface instead. */ + return false; + } + + /* It had a listener (past startup) but didn't exit in time — force-kill as a last resort. */ + foreach (var proc in stillRunning) + { + TryKill(proc); + } + WaitForExit(stillRunning, PostKillWaitMs); + } + + return TryAcquireAfterRelease(); + } + + private bool TryAcquireAfterRelease() + { + if (_mutex is null) + return false; + try + { + if (_mutex.WaitOne(MutexAcquireTimeoutMs)) + { + BecomeOwner(); + return true; + } + } + catch (AbandonedMutexException) + { + /* Previous owner died without releasing — we now hold it. */ + BecomeOwner(); + return true; + } + return false; + } + + private void BecomeOwner() + { + _ownsMutex = true; + } + + /// + /// Opens the "exit for upgrade" channel — the app calls this once it is past its risky startup + /// (DuckDB initialized, MCP bound, etc.). Deferring it here (rather than in ) + /// preserves the safety rule that a newer build won't disturb an instance that is still + /// initializing: while no listener exists, a launching newer build sees TrySignal == false + /// and surfaces instead of signaling/killing us mid-init. No-op if we don't own the slot or the + /// channel is already open. Idempotent and thread-safe to call from the UI thread. + /// + public void EnableUpgradeHandoff() + { + if (!_ownsMutex || _exitSignal is not null || _disposed) + return; + /* Listen for a future newer build asking us to step aside. */ + _exitSignal = new SingleInstanceSignal(_options.ExitEventName, _options.GracefulSelfExit); + } + + private bool SurfaceAndExit() + { + try { _options.SurfaceRunningInstance(); } + catch (Exception ex) { _options.Log?.Invoke($"Surface failed: {ex.Message}"); } + return false; + } + + private void RelaunchElevated() + { + try + { + var exe = Environment.ProcessPath; + if (string.IsNullOrEmpty(exe)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = exe, + Arguments = HandoffArgs.AutoConfirm, + UseShellExecute = true, + Verb = "runas", + }); + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) // ERROR_CANCELLED — user dismissed UAC + { + _options.Log?.Invoke("Elevation cancelled by user."); + } + catch (Exception ex) + { + _options.Log?.Invoke($"Elevation failed: {ex.Message}"); + } + } + + private List FindOtherInstances() + { + var self = Environment.ProcessId; + var result = new List(); + try + { + foreach (var proc in Process.GetProcessesByName(_options.ProcessName)) + { + if (proc.Id == self) + { + proc.Dispose(); + continue; + } + result.Add(proc); + } + } + catch (Exception ex) + { + _options.Log?.Invoke($"Enumerate instances failed: {ex.Message}"); + } + return result; + } + + private static List WaitForExit(List procs, int totalBudgetMs) + { + var deadline = Environment.TickCount64 + totalBudgetMs; + var stillRunning = new List(); + foreach (var proc in procs) + { + var remaining = (int)Math.Max(0, deadline - Environment.TickCount64); + try + { + if (!proc.WaitForExit(remaining)) + stillRunning.Add(proc); + } + catch + { + /* Process already gone / inaccessible — treat as exited. */ + } + } + return stillRunning; + } + + private void TryKill(Process proc) + { + try { proc.Kill(); } + catch (Exception ex) { _options.Log?.Invoke($"Kill failed: {ex.Message}"); } + } + + private static Version? CurrentReleaseVersion() + { + var informational = Assembly.GetEntryAssembly() + ?.GetCustomAttribute() + ?.InformationalVersion; + return SingleInstanceDecision.ParseProductVersion(informational) + ?? Assembly.GetEntryAssembly()?.GetName().Version; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _exitSignal?.Dispose(); + if (_mutex is not null) + { + if (_ownsMutex) + { + try { _mutex.ReleaseMutex(); } catch { /* not held / abandoned */ } + } + _mutex.Dispose(); + } + } + } +} diff --git a/PerformanceMonitor.Ui/SingleInstanceDecision.cs b/PerformanceMonitor.Ui/SingleInstanceDecision.cs new file mode 100644 index 000000000..bc7b05f25 --- /dev/null +++ b/PerformanceMonitor.Ui/SingleInstanceDecision.cs @@ -0,0 +1,118 @@ +/* + * 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; + +namespace PerformanceMonitor.Ui +{ + /// + /// What a launching instance should do when it finds the single-instance mutex already held + /// (version-aware upgrade handoff). Pure decision over already-measured inputs — no Win32, no + /// processes — so it is fully unit-testable; the messy measurement lives in + /// and the orchestration in . + /// + public enum HandoffAction + { + /// The running instance is an older build at an integrity level we can reach — close it and take over. + TakeOver, + + /// Genuine "already running" (same/newer build), or we can't determine enough to safely act — surface the running instance and exit. + Surface, + + /// Running instance is older but at a higher integrity level we can't signal/kill, and we can elevate same-user — offer "Restart as administrator". + IntegrityErrorWithRunas, + + /// Running instance is older but at a higher integrity level we can't signal/kill, and we cannot elevate same-user — show a manual-close message. + IntegrityErrorManualOnly, + } + + /// + /// The version-aware single-instance handoff decision (#single-instance-upgrade-handoff plan). + /// + public static class SingleInstanceDecision + { + /// This (launching) build's release version, or null if it couldn't be resolved. + /// The running instance's release version read from its on-disk exe, or null if unreadable. + /// This process's mandatory-integrity RID (e.g. 0x3000 = High). + /// The running instance's integrity RID, or null if it couldn't be measured. + /// Whether the current user could elevate in place (split-token admin / already elevated). + public static HandoffAction Decide( + Version? ourVersion, + Version? otherVersion, + int ourIntegrityLevel, + int? otherIntegrityLevel, + bool canElevateSameUser) + { + /* Can't read either version → can't tell if this is an upgrade → behave as today (surface). */ + if (ourVersion is null || otherVersion is null) + { + return HandoffAction.Surface; + } + + /* Same or newer running instance → genuine "already running" (incl. a plain double-launch), + or an older build trying to evict a newer one. Never take over. */ + if (otherVersion.CompareTo(ourVersion) >= 0) + { + return HandoffAction.Surface; + } + + /* The running instance is OLDER — a real takeover is warranted. Can we reach it? + We need its integrity level to know whether our signal/kill can land. If we couldn't + measure it (e.g. token read denied), don't guess — fall back to surface. */ + if (otherIntegrityLevel is null) + { + return HandoffAction.Surface; + } + + /* Older and at an integrity level at or below ours → we can signal/kill it → take over. */ + if (otherIntegrityLevel.Value <= ourIntegrityLevel) + { + return HandoffAction.TakeOver; + } + + /* Older but higher integrity than us → our exit signal and Kill() are both blocked. + Surface a clear error; offer elevation only if it would stay same-user. */ + return canElevateSameUser + ? HandoffAction.IntegrityErrorWithRunas + : HandoffAction.IntegrityErrorManualOnly; + } + + /// + /// Parses a ProductVersion / InformationalVersion string to its numeric core for comparison. + /// Strips any SemVer pre-release (-rc1) or build (+sha) suffix and parses the + /// leading X.Y[.Z[.W]] with — deliberately dependency-free. + /// Consequence: two builds that share the same numeric <Version> (e.g. two nightlies, + /// or rc1→rc2) compare equal → "surface", which is also the right call for a normal double-launch. + /// Real release upgrades bump <Version>, so the upgrade case is covered. Returns null + /// if there is no parseable numeric core. + /// + public static Version? ParseProductVersion(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var core = raw.Trim(); + + var plus = core.IndexOf('+'); + if (plus >= 0) + { + core = core.Substring(0, plus); + } + + var dash = core.IndexOf('-'); + if (dash >= 0) + { + core = core.Substring(0, dash); + } + + return Version.TryParse(core, out var version) ? version : null; + } + } +} From a4945f4f3acb2b74e2e8f31026b0843d50578b50 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:49:26 -0400 Subject: [PATCH 013/145] Bump minor/patch NuGet dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Low-risk dependency refresh (Velopack 0.x→1.2.0 and DuckDB held as separate efforts): - Microsoft.Extensions.* (Configuration, Configuration.Json, Hosting, Logging, Logging.Abstractions): 10.0.8 -> 10.0.9 (tracks the .NET 10 servicing line) - ModelContextProtocol + ModelContextProtocol.AspNetCore: 1.3.0 -> 1.4.0 - Microsoft.NET.Test.Sdk: 18.5.1 -> 18.6.0 (test projects) Lock files regenerated (--force-evaluate) for --locked-mode CI restore. Build clean (0 new warnings); Lite 524 + Dashboard 487 + Installer.Tests (fast subset) 61 green. MCP 1.4.0 compiled with no source changes needed. ScottPlot.WPF, Microsoft.Data.SqlClient, Hardcodet, CredentialManagement, xunit are already at latest. WPF/.NET stays on .NET 10 (11 is preview). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/Dashboard.Tests.csproj | 2 +- Dashboard/Dashboard.csproj | 10 +- Dashboard/packages.lock.json | 330 ++++++++-------- Installer.Tests/Installer.Tests.csproj | 2 +- Installer.Tests/packages.lock.json | 24 +- Lite.Tests/Lite.Tests.csproj | 2 +- Lite.Tests/packages.lock.json | 352 +++++++++--------- Lite/PerformanceMonitorLite.csproj | 8 +- Lite/packages.lock.json | 328 ++++++++-------- .../PerformanceMonitor.Common.csproj | 2 +- .../PerformanceMonitor.Notifications.csproj | 2 +- 11 files changed, 531 insertions(+), 531 deletions(-) diff --git a/Dashboard.Tests/Dashboard.Tests.csproj b/Dashboard.Tests/Dashboard.Tests.csproj index 97e989265..ef80ea238 100644 --- a/Dashboard.Tests/Dashboard.Tests.csproj +++ b/Dashboard.Tests/Dashboard.Tests.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 3b8a7a570..8e2fa8746 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -44,12 +44,12 @@ - - + + - - - + + + diff --git a/Dashboard/packages.lock.json b/Dashboard/packages.lock.json index 4d7bea1dc..125c4e351 100644 --- a/Dashboard/packages.lock.json +++ b/Dashboard/packages.lock.json @@ -46,74 +46,74 @@ }, "Microsoft.Extensions.Configuration": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Logging.Console": "10.0.8", - "Microsoft.Extensions.Logging.Debug": "10.0.8", - "Microsoft.Extensions.Logging.EventLog": "10.0.8", - "Microsoft.Extensions.Logging.EventSource": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "HTgnvmK0ubesUFO16pLC+i9+RS8lEGd6TmDouuy75FsAgIFrSwUVhYCqG2IENzBJwgxGc/6Rsulfsvd9ZG/XkA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.9", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.9", + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Logging.Console": "10.0.9", + "Microsoft.Extensions.Logging.Debug": "10.0.9", + "Microsoft.Extensions.Logging.EventLog": "10.0.9", + "Microsoft.Extensions.Logging.EventSource": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "ModelContextProtocol": { "type": "Direct", - "requested": "[1.3.0, )", - "resolved": "1.3.0", - "contentHash": "WDaD6z9KkkCUHSo15xK7tYBERHy8uqP+cIUp8uIxhR0yrlpJLXTRcJcUUVqpXlBkV7MK9Eo3mTrAnctLnJuHDQ==", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "CAmnAMQIMax2t9naUgyDAkVBj329EhQCiHcbfirMFNgP6ShQ8hJdJb0OLoaBpeGjkunH3IOjRXLdwXGKKwMlLA==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "10.0.7", "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", - "ModelContextProtocol.Core": "1.3.0" + "ModelContextProtocol.Core": "1.4.0" } }, "ModelContextProtocol.AspNetCore": { "type": "Direct", - "requested": "[1.3.0, )", - "resolved": "1.3.0", - "contentHash": "bKQAVc9Npwbbxaa53PTY5NCszoxkSIZ1ZyCpoVFHMQqZtEmXaYNz+2QUXmf0ILWQduRMd2GYi9TL431mMnpbCA==", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "Q/xGhfhbCfZsoeEsEqBeiyzr6AJw2cBUTKN3Giz3QRmWyRzn62ISsgJCTnk2/VPMNj2/Spb2jYpPahnMZWvaPg==", "dependencies": { - "ModelContextProtocol": "1.3.0" + "ModelContextProtocol": "1.4.0" } }, "ScottPlot.WPF": { @@ -238,232 +238,232 @@ }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "resolved": "10.0.9", + "contentHash": "8D4HaqxWdm5M/nuhQffjPoR1ekhlpyKTXjFMAT5KlP0dvxkJe5JLAP6MAsuUEUxKWG09Bi5aAUaYMFKrMqWHqA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "resolved": "10.0.9", + "contentHash": "JhKySWIL8+N4yFt4HPm1rGKCHooze+MBdTdpXc0bd/PGm31TrSUi2m0Nek1y441Wlv/RE6VH0W/DCv2xnmy8FA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "resolved": "10.0.9", + "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "resolved": "10.0.9", + "contentHash": "ockJRreRW/HbGwoyHzYOxMucFBimvAZ8lKNwQLMHrS6mwkDUaCJMWzzeE+Rm9vgFlv2o/xqk8fm+FpqrDCnkTA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "resolved": "10.0.9", + "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + "resolved": "10.0.9", + "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "resolved": "10.0.9", + "contentHash": "WyZEG/O8jKqBOBF6/M6IJqiEyWFBUv6PDyzNoXDA0mBZwKtkuf7GiZ/0/8eU8OpLKKQL0O95oPOY1szrWIKofQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "resolved": "10.0.9", + "contentHash": "r/A0ahpXmZH/8ltPjrFFWp12BIizK9cCVJXPcHyOad8e4eIX7P/geW+uBYdczkeCAaMebT4jEU7snOm4GnmKfA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "resolved": "10.0.9", + "contentHash": "goAl30/WwmdnWDPRwATaDPIK0iuDBnQSMTH2XYGVB1SwReg7hglhvDNjjpNhT25US3GF4I5q6BhTNs6nFYzEfg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "resolved": "10.0.9", + "contentHash": "tHynPVHbTicuaDpS2JVTxX0qA5VTg15CXgVKTwWvvudb5BvW5aVew8MMyek6LrDGAom7UbON1jf1T5GhpTilFA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, "Microsoft.Identity.Client": { "type": "Transitive", @@ -535,8 +535,8 @@ }, "ModelContextProtocol.Core": { "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "OWmdxDSwA7K9pNNg4t98MXNIssHG/wOQEr/G8pG5B7synDdw4MnmZ/IIVeb3yUdeznPqnDHvd3FBCK0jRk4IZQ==", + "resolved": "1.4.0", + "contentHash": "6ZJFQTgYwdu0IacUUTNExAY2Z+JFuTd10CPeORalQtuGPY4hdUqq0BXxTpNEP0n7ajruNsGXyCKQLP0erIZmog==", "dependencies": { "Microsoft.Extensions.AI.Abstractions": "10.5.2", "Microsoft.Extensions.Logging.Abstractions": "10.0.7" @@ -744,13 +744,13 @@ "performancemonitor.common": { "type": "Project", "dependencies": { - "ModelContextProtocol": "[1.3.0, )" + "ModelContextProtocol": "[1.4.0, )" } }, "performancemonitor.notifications": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "PerformanceMonitor.Analysis": "[1.0.0, )" } }, diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj index a8bae346b..df2d23923 100644 --- a/Installer.Tests/Installer.Tests.csproj +++ b/Installer.Tests/Installer.Tests.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Installer.Tests/packages.lock.json b/Installer.Tests/packages.lock.json index b093398be..ff018a78c 100644 --- a/Installer.Tests/packages.lock.json +++ b/Installer.Tests/packages.lock.json @@ -22,12 +22,12 @@ }, "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[18.5.1, )", - "resolved": "18.5.1", - "contentHash": "SfqVaLiIqAbRWuPg5BP4QFwBIirQj/YIL8Dhxl6zntBKbXp0cQykoV480SmwG+yRMiWptxEI6NbHQuGSZ8b97w==", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", "dependencies": { - "Microsoft.CodeCoverage": "18.5.1", - "Microsoft.TestPlatform.TestHost": "18.5.1" + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" } }, "xunit.runner.visualstudio": { @@ -84,8 +84,8 @@ }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "vMFDR1ZjqzzgKmM0zrPie7Gv9Y+ZppjODB5Quzu9Eq0TlIusUfUCYFPEawO91zQuqwzvdFbJSU7WHNtjStffJQ==" + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" }, "Microsoft.Data.SqlClient.Extensions.Abstractions": { "type": "Transitive", @@ -303,15 +303,15 @@ }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "KNZd+M0S0rz5eNAln0pbZX+A/RbokYZCbGKx4fN4CkhtWhkz6nSJDO+9LGYjRE4d0WPVriJ2JnVubkjt3+PpMg==" + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "RM+3JNHEoHOCFXzVntUcIiYxzPjzBN0N8wto6HYXi76YyBTZ/3CeRL8U+Pk5zx3AUrOmHxDvKJwGUCdElU9bJg==", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.5.1", + "Microsoft.TestPlatform.ObjectModel": "18.6.0", "Newtonsoft.Json": "13.0.3" } }, diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 916fd06f5..173b25d6e 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.Tests/packages.lock.json b/Lite.Tests/packages.lock.json index 7f7b9690e..0274c2a0b 100644 --- a/Lite.Tests/packages.lock.json +++ b/Lite.Tests/packages.lock.json @@ -4,12 +4,12 @@ "net10.0-windows7.0": { "Microsoft.NET.Test.Sdk": { "type": "Direct", - "requested": "[18.5.1, )", - "resolved": "18.5.1", - "contentHash": "SfqVaLiIqAbRWuPg5BP4QFwBIirQj/YIL8Dhxl6zntBKbXp0cQykoV480SmwG+yRMiWptxEI6NbHQuGSZ8b97w==", + "requested": "[18.6.0, )", + "resolved": "18.6.0", + "contentHash": "kAIBt0MsYR0o2RULmlW5BhQ1ha50aGEgLKG4f1p0kePBGLJCprqs3S+NxRrYN8UH7mSQRPKpeiH9mwPMEKUObQ==", "dependencies": { - "Microsoft.CodeCoverage": "18.5.1", - "Microsoft.TestPlatform.TestHost": "18.5.1" + "Microsoft.CodeCoverage": "18.6.0", + "Microsoft.TestPlatform.TestHost": "18.6.0" } }, "xunit.runner.visualstudio": { @@ -118,8 +118,8 @@ }, "Microsoft.CodeCoverage": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "vMFDR1ZjqzzgKmM0zrPie7Gv9Y+ZppjODB5Quzu9Eq0TlIusUfUCYFPEawO91zQuqwzvdFbJSU7WHNtjStffJQ==" + "resolved": "18.6.0", + "contentHash": "bkmCXn/65Cd0LdO2zTb/ValGAJ1H8y/CgYOiBb3jsDyHI3Y1ljKx6RBvhvn3e5D/4R4I00RRwLf+Bd2Sn6bJjA==" }, "Microsoft.Data.SqlClient": { "type": "Transitive", @@ -194,281 +194,281 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "resolved": "10.0.9", + "contentHash": "8D4HaqxWdm5M/nuhQffjPoR1ekhlpyKTXjFMAT5KlP0dvxkJe5JLAP6MAsuUEUxKWG09Bi5aAUaYMFKrMqWHqA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "resolved": "10.0.9", + "contentHash": "JhKySWIL8+N4yFt4HPm1rGKCHooze+MBdTdpXc0bd/PGm31TrSUi2m0Nek1y441Wlv/RE6VH0W/DCv2xnmy8FA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "resolved": "10.0.9", + "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "resolved": "10.0.9", + "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "resolved": "10.0.9", + "contentHash": "ockJRreRW/HbGwoyHzYOxMucFBimvAZ8lKNwQLMHrS6mwkDUaCJMWzzeE+Rm9vgFlv2o/xqk8fm+FpqrDCnkTA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "resolved": "10.0.9", + "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + "resolved": "10.0.9", + "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" }, "Microsoft.Extensions.Hosting": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Logging.Console": "10.0.8", - "Microsoft.Extensions.Logging.Debug": "10.0.8", - "Microsoft.Extensions.Logging.EventLog": "10.0.8", - "Microsoft.Extensions.Logging.EventSource": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "resolved": "10.0.9", + "contentHash": "HTgnvmK0ubesUFO16pLC+i9+RS8lEGd6TmDouuy75FsAgIFrSwUVhYCqG2IENzBJwgxGc/6Rsulfsvd9ZG/XkA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.9", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.9", + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Logging.Console": "10.0.9", + "Microsoft.Extensions.Logging.Debug": "10.0.9", + "Microsoft.Extensions.Logging.EventLog": "10.0.9", + "Microsoft.Extensions.Logging.EventSource": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "resolved": "10.0.9", + "contentHash": "WyZEG/O8jKqBOBF6/M6IJqiEyWFBUv6PDyzNoXDA0mBZwKtkuf7GiZ/0/8eU8OpLKKQL0O95oPOY1szrWIKofQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "resolved": "10.0.9", + "contentHash": "r/A0ahpXmZH/8ltPjrFFWp12BIizK9cCVJXPcHyOad8e4eIX7P/geW+uBYdczkeCAaMebT4jEU7snOm4GnmKfA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "resolved": "10.0.9", + "contentHash": "goAl30/WwmdnWDPRwATaDPIK0iuDBnQSMTH2XYGVB1SwReg7hglhvDNjjpNhT25US3GF4I5q6BhTNs6nFYzEfg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "resolved": "10.0.9", + "contentHash": "tHynPVHbTicuaDpS2JVTxX0qA5VTg15CXgVKTwWvvudb5BvW5aVew8MMyek6LrDGAom7UbON1jf1T5GhpTilFA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, "Microsoft.Identity.Client": { "type": "Transitive", @@ -570,15 +570,15 @@ }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "KNZd+M0S0rz5eNAln0pbZX+A/RbokYZCbGKx4fN4CkhtWhkz6nSJDO+9LGYjRE4d0WPVriJ2JnVubkjt3+PpMg==" + "resolved": "18.6.0", + "contentHash": "gQTW4BIfM2ZLxixo9ITXoulLKjn20FiiHtqTsx9PENqTrX7368ZeJ5L0QZJyReXDWORPRV8jXwZR6Aar8JOyaA==" }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", - "resolved": "18.5.1", - "contentHash": "RM+3JNHEoHOCFXzVntUcIiYxzPjzBN0N8wto6HYXi76YyBTZ/3CeRL8U+Pk5zx3AUrOmHxDvKJwGUCdElU9bJg==", + "resolved": "18.6.0", + "contentHash": "em1eLz5Q46+hsCtAXdXggWAPd9gQyT4ngdsQ7k1eWvQgpsjtS/wAOJ/5TteieFdiAvrEq1iVn00LtusAxRaVmQ==", "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "18.5.1", + "Microsoft.TestPlatform.ObjectModel": "18.6.0", "Newtonsoft.Json": "13.0.3" } }, @@ -589,26 +589,26 @@ }, "ModelContextProtocol": { "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "WDaD6z9KkkCUHSo15xK7tYBERHy8uqP+cIUp8uIxhR0yrlpJLXTRcJcUUVqpXlBkV7MK9Eo3mTrAnctLnJuHDQ==", + "resolved": "1.4.0", + "contentHash": "CAmnAMQIMax2t9naUgyDAkVBj329EhQCiHcbfirMFNgP6ShQ8hJdJb0OLoaBpeGjkunH3IOjRXLdwXGKKwMlLA==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "10.0.7", "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", - "ModelContextProtocol.Core": "1.3.0" + "ModelContextProtocol.Core": "1.4.0" } }, "ModelContextProtocol.AspNetCore": { "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "bKQAVc9Npwbbxaa53PTY5NCszoxkSIZ1ZyCpoVFHMQqZtEmXaYNz+2QUXmf0ILWQduRMd2GYi9TL431mMnpbCA==", + "resolved": "1.4.0", + "contentHash": "Q/xGhfhbCfZsoeEsEqBeiyzr6AJw2cBUTKN3Giz3QRmWyRzn62ISsgJCTnk2/VPMNj2/Spb2jYpPahnMZWvaPg==", "dependencies": { - "ModelContextProtocol": "1.3.0" + "ModelContextProtocol": "1.4.0" } }, "ModelContextProtocol.Core": { "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "OWmdxDSwA7K9pNNg4t98MXNIssHG/wOQEr/G8pG5B7synDdw4MnmZ/IIVeb3yUdeznPqnDHvd3FBCK0jRk4IZQ==", + "resolved": "1.4.0", + "contentHash": "6ZJFQTgYwdu0IacUUTNExAY2Z+JFuTd10CPeORalQtuGPY4hdUqq0BXxTpNEP0n7ajruNsGXyCKQLP0erIZmog==", "dependencies": { "Microsoft.Extensions.AI.Abstractions": "10.5.2", "Microsoft.Extensions.Logging.Abstractions": "10.0.7" @@ -900,13 +900,13 @@ "performancemonitor.common": { "type": "Project", "dependencies": { - "ModelContextProtocol": "[1.3.0, )" + "ModelContextProtocol": "[1.4.0, )" } }, "performancemonitor.notifications": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "PerformanceMonitor.Analysis": "[1.0.0, )" } }, @@ -928,10 +928,10 @@ "Hardcodet.NotifyIcon.Wpf": "[2.0.1, )", "Microsoft.Data.SqlClient": "[7.0.1, )", "Microsoft.Data.SqlClient.Extensions.Azure": "[1.0.0, )", - "Microsoft.Extensions.Hosting": "[10.0.8, )", - "Microsoft.Extensions.Logging": "[10.0.8, )", - "ModelContextProtocol": "[1.3.0, )", - "ModelContextProtocol.AspNetCore": "[1.3.0, )", + "Microsoft.Extensions.Hosting": "[10.0.9, )", + "Microsoft.Extensions.Logging": "[10.0.9, )", + "ModelContextProtocol": "[1.4.0, )", + "ModelContextProtocol.AspNetCore": "[1.4.0, )", "PerformanceMonitor.Analysis": "[1.0.0, )", "PerformanceMonitor.Common": "[1.0.0, )", "PerformanceMonitor.Notifications": "[1.0.0, )", diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 5238b1eaf..7b2d40629 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -62,17 +62,17 @@ - + - + - - + + diff --git a/Lite/packages.lock.json b/Lite/packages.lock.json index ed1062ace..04356eafe 100644 --- a/Lite/packages.lock.json +++ b/Lite/packages.lock.json @@ -61,63 +61,63 @@ }, "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "VfEyM2BipThcSd0GG/FS2ZPCVCTiosVq2zLKEDsfeMIg78sOVZPEmS7CgWlb+dqTlgXvLSL4OG2q6sM4xRhHNg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.8", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.8", - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Logging.Console": "10.0.8", - "Microsoft.Extensions.Logging.Debug": "10.0.8", - "Microsoft.Extensions.Logging.EventLog": "10.0.8", - "Microsoft.Extensions.Logging.EventSource": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "HTgnvmK0ubesUFO16pLC+i9+RS8lEGd6TmDouuy75FsAgIFrSwUVhYCqG2IENzBJwgxGc/6Rsulfsvd9ZG/XkA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.9", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.9", + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Logging.Console": "10.0.9", + "Microsoft.Extensions.Logging.Debug": "10.0.9", + "Microsoft.Extensions.Logging.EventLog": "10.0.9", + "Microsoft.Extensions.Logging.EventSource": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging": { "type": "Direct", - "requested": "[10.0.8, )", - "resolved": "10.0.8", - "contentHash": "K60JhWC2hN/Gi7TP68tBxSzk5ACWOs7lkmPzsfA8Bcf/IXTajujt2ORMf9rSMk1bsng6Lv4Y3fuxp3bm1+15ug==", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "ModelContextProtocol": { "type": "Direct", - "requested": "[1.3.0, )", - "resolved": "1.3.0", - "contentHash": "WDaD6z9KkkCUHSo15xK7tYBERHy8uqP+cIUp8uIxhR0yrlpJLXTRcJcUUVqpXlBkV7MK9Eo3mTrAnctLnJuHDQ==", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "CAmnAMQIMax2t9naUgyDAkVBj329EhQCiHcbfirMFNgP6ShQ8hJdJb0OLoaBpeGjkunH3IOjRXLdwXGKKwMlLA==", "dependencies": { "Microsoft.Extensions.Caching.Abstractions": "10.0.7", "Microsoft.Extensions.Hosting.Abstractions": "10.0.7", - "ModelContextProtocol.Core": "1.3.0" + "ModelContextProtocol.Core": "1.4.0" } }, "ModelContextProtocol.AspNetCore": { "type": "Direct", - "requested": "[1.3.0, )", - "resolved": "1.3.0", - "contentHash": "bKQAVc9Npwbbxaa53PTY5NCszoxkSIZ1ZyCpoVFHMQqZtEmXaYNz+2QUXmf0ILWQduRMd2GYi9TL431mMnpbCA==", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "Q/xGhfhbCfZsoeEsEqBeiyzr6AJw2cBUTKN3Giz3QRmWyRzn62ISsgJCTnk2/VPMNj2/Spb2jYpPahnMZWvaPg==", "dependencies": { - "ModelContextProtocol": "1.3.0" + "ModelContextProtocol": "1.4.0" } }, "ScottPlot.WPF": { @@ -247,242 +247,242 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ehZcoPbjzWzS4XFvuz7R3V55SmpdkyMqFURLH3yXaN9NtXd9tR6CGB7pd49HYtCkenl+G7ctXSFLhNI08xLfRg==", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "I63esIFbL3h5pSt7gXpXOlmcwDmYBUoYNEglKfDPFUqtYvSV84f2l28hO2lfVXsV0wdlplgAM7IVz16matapSg==", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "R3NN1X+kVu14uoxLEW6sBSQyhogDSbaOQzILnCtuXxBN4hx22AgjWPwZX6v/suERFkEDgU1lk12AglHTrUxhlw==", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "nQXq1a4MiInYh+0VF9fguxAl06q2ftmOyYQ+5e933s4rk57xjgkbTjUdFUySzjrcrvDeWsSqlZB+TE8+TbM2HA==", + "resolved": "10.0.9", + "contentHash": "8D4HaqxWdm5M/nuhQffjPoR1ekhlpyKTXjFMAT5KlP0dvxkJe5JLAP6MAsuUEUxKWG09Bi5aAUaYMFKrMqWHqA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "bVGqctAfPGfTxJvNp8pMshtvpsUj6r6JkeiCNVIGVYO5gBxuxdN0Lbr25kEvE/zXdctkEc44g8HssnPgDnFGVA==", + "resolved": "10.0.9", + "contentHash": "JhKySWIL8+N4yFt4HPm1rGKCHooze+MBdTdpXc0bd/PGm31TrSUi2m0Nek1y441Wlv/RE6VH0W/DCv2xnmy8FA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "1g9mzuu8gIHkjYb0jLxOTQVl/QDG5nn0b0JzgT/gbgNKr6gXZzxOHRAsdYRc1eDApB7LdHR8uK5vQrNjIQdRrQ==", + "resolved": "10.0.9", + "contentHash": "NgLB9cYnIb0/djSDcnqo4GIGGWooxGmr/gCUe3/CRXcKqLizOFui8MyW4EVkTB/KNJL+oXdMXnD6ZRm3Y+qkrQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "KLtAZ6A38s1pIfCO2ns6aG14NNGMYNZ4PBYfFK4M+R4A+xuSc6oklhqDcpHZxvDpyBWeFtR5C8iQBw2ng8tUHQ==", + "resolved": "10.0.9", + "contentHash": "LiFKJgc9jZEW+7RhcSfsvCwoikt1lDdOqOn+whZC5zVHyg/gExftHl2QPtmfiHsEdDNg+Y+BDr6835tOfj8Y7A==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6XTfFOnf27WY8kEeZkTZ4YNn0t+imgvdQ0YaAdR4vgURKATo9bCaVJ1KB71IOJAQtJP7Elb53VHlTNXg2CtSsA==", + "resolved": "10.0.9", + "contentHash": "ockJRreRW/HbGwoyHzYOxMucFBimvAZ8lKNwQLMHrS6mwkDUaCJMWzzeE+Rm9vgFlv2o/xqk8fm+FpqrDCnkTA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Json": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Physical": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Json": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Physical": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "daf62xHIrq8pnE709hgaZZN9tSam9TGGepWe1+bE6V3GEuVwJiMs6ib+38lfMCyAJAHiX0vapxBhsuMSV7U+cg==", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "21nbDV60SRPWGIivsyl6lqBeEJNG1sginhhfWgRrr3Ais7aQ12To25OAHQxgoiJkjqy1aQ6RxpZBGYuTi7Ge6A==" + "resolved": "10.0.9", + "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "uduyw9d3Fi+sbredO5drA1S44AQS2FRNFyn72UmB2vmQIO1qaXprpp1U/2lYhYi8yFdVERfY9sy/pxw/qPOU9w==", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "+f4C5g78QCGNyxzUfrTYsB7qYx06Zca0e88s3qFlea9/lQhgPImYdNprlgzl1uHhRU3fVHLfmbijayU2sJEZ6w==", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "U+oquaPxFdY8lYeEIWO/AD7jDIl9sPW6aVWMQRHU/pZ/SWpLcOrAj2fcLe1HwXl4sYw1ONI56K/eELT3xr4RRQ==", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "GkPvQe6IdidLu6Q3Lw6+B8NJpW8feW8czZ5mBKt5rXM/x8MvZfEp5WvAsjznzDGd23chIDrW0b2mmt+ScnEgiw==", + "resolved": "10.0.9", + "contentHash": "zm8WVod4swgprGrkxkuSILlbXqdDRqF+3y6U0I7jlmj4PMyKN6d8pzXZHUn5lr/gZVULzk/+FeTYlTupt6akpg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "IUQet3SY51xIFcFZKtAB6a54/Zdxs7T3SQ84kJtOD6yeXfZgiOMksACWD5qtTmXGQGFH4QYGBOT0KIO8Uy/dJw==" + "resolved": "10.0.9", + "contentHash": "mvRf9qOH/LslWIee/h+lsElnoUyKotEwoPL31soqScmO/eoxObaTCLCdx2DdqPdRi9LnB+7qKZ49jfyrLZuc+w==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "MoOWFPT88/pDfmWpbU9PydKRX/rJFQkliowE/L9wbQcl94IicUphb5BFgepkWiDkYYxPnuEqjN4buzOGW4vJpQ==", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.8", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "fdVadZmsC8jRP0KvKy8mO8f6GV/HyBvElfcSxEhd+5FM5boAw/01iSaCto5G3G37ApJira4A3pNaVvBv8cUiLQ==", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "rxSLTO7xTbcC3DuEJHNEijBr8g14Jj62zQ+DeFu68bsoTYoU8jLcMhc1735PV21bESXsATlL5LsfaWH71FOWAg==", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.8", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.8" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "6cv53sHsPnFS56PJw8X4GbNcjeX1KGyFJRxJWvxOgK63cnqeSB1k1eRwjUdkse0tBhwlH6qc9EOYDlan+CYTuw==", + "resolved": "10.0.9", + "contentHash": "WyZEG/O8jKqBOBF6/M6IJqiEyWFBUv6PDyzNoXDA0mBZwKtkuf7GiZ/0/8eU8OpLKKQL0O95oPOY1szrWIKofQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging.Configuration": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "4HW3M1lGHHDwEYcDZHRNptBQ48LCI2yW+XV4vuxdfQUqafTpVT8j9RqAsez08krZKhIiaArWu8iQq5uRKZ9Ffg==", + "resolved": "10.0.9", + "contentHash": "r/A0ahpXmZH/8ltPjrFFWp12BIizK9cCVJXPcHyOad8e4eIX7P/geW+uBYdczkeCAaMebT4jEU7snOm4GnmKfA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "kK/C3SLIoGrcZvddYQw4eMm6YaROiSYBO7YgUR5Hdv5l+GIjBmbvQK5cST2FqjeubiAOPqFEimBT2N/8wVI+3A==", + "resolved": "10.0.9", + "contentHash": "goAl30/WwmdnWDPRwATaDPIK0iuDBnQSMTH2XYGVB1SwReg7hglhvDNjjpNhT25US3GF4I5q6BhTNs6nFYzEfg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "HX2M0MgzwQM8jpLe3AYAEMd0YsUfOP5RgGrDuk+Ki9n7HSuMbvLm9TEV3qRI3Pg9aqxc56GfgK/KdMRBhfWwKw==", + "resolved": "10.0.9", + "contentHash": "tHynPVHbTicuaDpS2JVTxX0qA5VTg15CXgVKTwWvvudb5BvW5aVew8MMyek6LrDGAom7UbON1jf1T5GhpTilFA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Logging": "10.0.8", - "Microsoft.Extensions.Logging.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VBD+131DpTNCNDfA4kIyKTiCySvJGNhwibdWBSdFRu7GMfXLXcXODkgA+KStKbbhzraLglZWUN4nXyHgW4JIRA==", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "VOapXeO3lhBH0zYoyAH7tjapuo4V5pTHlevPpiSHueEquAajqd5nF0mttm+h/uE/exwAEuM5s26SzOJtletE3w==", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.8", - "Microsoft.Extensions.Configuration.Binder": "10.0.8", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.8", - "Microsoft.Extensions.Options": "10.0.8", - "Microsoft.Extensions.Primitives": "10.0.8" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "OBPo4nYhMyIbtueoC10CBm6AGAbo/A9IV8QQ/6ryZS7VvmqpGT7hunazeHLxFawRzn3oLOq4jhqhpBX4tfswWQ==" + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, "Microsoft.Identity.Client": { "type": "Transitive", @@ -554,8 +554,8 @@ }, "ModelContextProtocol.Core": { "type": "Transitive", - "resolved": "1.3.0", - "contentHash": "OWmdxDSwA7K9pNNg4t98MXNIssHG/wOQEr/G8pG5B7synDdw4MnmZ/IIVeb3yUdeznPqnDHvd3FBCK0jRk4IZQ==", + "resolved": "1.4.0", + "contentHash": "6ZJFQTgYwdu0IacUUTNExAY2Z+JFuTd10CPeORalQtuGPY4hdUqq0BXxTpNEP0n7ajruNsGXyCKQLP0erIZmog==", "dependencies": { "Microsoft.Extensions.AI.Abstractions": "10.5.2", "Microsoft.Extensions.Logging.Abstractions": "10.0.7" @@ -756,13 +756,13 @@ "performancemonitor.common": { "type": "Project", "dependencies": { - "ModelContextProtocol": "[1.3.0, )" + "ModelContextProtocol": "[1.4.0, )" } }, "performancemonitor.notifications": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.8, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.9, )", "PerformanceMonitor.Analysis": "[1.0.0, )" } }, diff --git a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj index 45197feed..84cb691df 100644 --- a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj +++ b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj @@ -16,7 +16,7 @@ - + diff --git a/PerformanceMonitor.Notifications/PerformanceMonitor.Notifications.csproj b/PerformanceMonitor.Notifications/PerformanceMonitor.Notifications.csproj index b5df89780..18176bf1b 100644 --- a/PerformanceMonitor.Notifications/PerformanceMonitor.Notifications.csproj +++ b/PerformanceMonitor.Notifications/PerformanceMonitor.Notifications.csproj @@ -13,7 +13,7 @@ - + From 37cfb100dc9979db07dec009a0b2b2e8a21c0b84 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:57:59 -0400 Subject: [PATCH 014/145] Bump Velopack 0.0.1298 -> 1.2.0 and pin the vpk CLI to match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Velopack 1.x is the stable line; this also corrects a latent mismatch — build.yml ran `dotnet tool install -g vpk` unpinned, so releases were already packed with vpk 1.x while the app library trailed at 0.0.1298. This aligns the reader library with the packer (now pinned to vpk 1.2.0) without changing the feed format. - Dashboard + Lite: Velopack PackageReference 0.0.1298 -> 1.2.0 - build.yml: `dotnet tool install -g vpk --version 1.2.0` (was unpinned) - Lock files regenerated (--force-evaluate); they shrink because Velopack 1.x dropped the NuGet.Versioning transitive dep (custom SemanticVersion, 1.0.1). No source changes needed — VelopackApp.Build().Run(), UpdateManager, GithubSource, CheckForUpdatesAsync/DownloadUpdatesAsync/ApplyUpdatesAndRestart all unchanged. Build clean (0 new warnings); Lite 524 + Dashboard 487 green. Live cross-release auto-update to be validated at the next release (checklist 8b). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 4 +++- Dashboard/Dashboard.csproj | 2 +- Dashboard/packages.lock.json | 14 +++----------- Lite.Tests/packages.lock.json | 14 +++----------- Lite/PerformanceMonitorLite.csproj | 2 +- Lite/packages.lock.json | 14 +++----------- 6 files changed, 14 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a23a2e39..82ab912f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -230,7 +230,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.VERSION }} run: | - dotnet tool install -g vpk + # Pin vpk to the Velopack library version (keep in sync with the Velopack + # PackageReference in Dashboard.csproj / PerformanceMonitorLite.csproj). + dotnet tool install -g vpk --version 1.2.0 New-Item -ItemType Directory -Force -Path releases/velopack-dashboard New-Item -ItemType Directory -Force -Path releases/velopack-lite diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 8e2fa8746..96c27d2c2 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -50,7 +50,7 @@ - + diff --git a/Dashboard/packages.lock.json b/Dashboard/packages.lock.json index 125c4e351..40e267624 100644 --- a/Dashboard/packages.lock.json +++ b/Dashboard/packages.lock.json @@ -130,12 +130,9 @@ }, "Velopack": { "type": "Direct", - "requested": "[0.0.1298, )", - "resolved": "0.0.1298", - "contentHash": "PJ6Nm28qJ4ChsHYzgHUJ8g+DGyyHes2+bwxY709+znMhgi8fMp8M1FTF8x6pZMjnsPCWVwoMlxVEyq0NLeRZtA==", - "dependencies": { - "NuGet.Versioning": "6.14.0" - } + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw==" }, "Azure.Core": { "type": "Transitive", @@ -542,11 +539,6 @@ "Microsoft.Extensions.Logging.Abstractions": "10.0.7" } }, - "NuGet.Versioning": { - "type": "Transitive", - "resolved": "6.14.0", - "contentHash": "4v4blkhCv8mpKtfx+z0G/X0daVCzdIaHSC51GkUspugi5JIMn2Bo8xm5PdZYF0U68gOBfz/+aPWMnpRd85Jbow==" - }, "OpenTK": { "type": "Transitive", "resolved": "4.9.4", diff --git a/Lite.Tests/packages.lock.json b/Lite.Tests/packages.lock.json index 0274c2a0b..7dcef2a13 100644 --- a/Lite.Tests/packages.lock.json +++ b/Lite.Tests/packages.lock.json @@ -619,11 +619,6 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, - "NuGet.Versioning": { - "type": "Transitive", - "resolved": "6.14.0", - "contentHash": "4v4blkhCv8mpKtfx+z0G/X0daVCzdIaHSC51GkUspugi5JIMn2Bo8xm5PdZYF0U68gOBfz/+aPWMnpRd85Jbow==" - }, "OpenTK": { "type": "Transitive", "resolved": "4.9.4", @@ -821,11 +816,8 @@ }, "Velopack": { "type": "Transitive", - "resolved": "0.0.1298", - "contentHash": "PJ6Nm28qJ4ChsHYzgHUJ8g+DGyyHes2+bwxY709+znMhgi8fMp8M1FTF8x6pZMjnsPCWVwoMlxVEyq0NLeRZtA==", - "dependencies": { - "NuGet.Versioning": "6.14.0" - } + "resolved": "1.2.0", + "contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw==" }, "xunit.analyzers": { "type": "Transitive", @@ -938,7 +930,7 @@ "PerformanceMonitor.PlanAnalysis": "[1.0.0, )", "PerformanceMonitor.Ui": "[1.0.0, )", "ScottPlot.WPF": "[5.1.58, )", - "Velopack": "[0.0.1298, )" + "Velopack": "[1.2.0, )" } } } diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 7b2d40629..d807e43f8 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -73,7 +73,7 @@ - + diff --git a/Lite/packages.lock.json b/Lite/packages.lock.json index 04356eafe..0bb0e0330 100644 --- a/Lite/packages.lock.json +++ b/Lite/packages.lock.json @@ -134,12 +134,9 @@ }, "Velopack": { "type": "Direct", - "requested": "[0.0.1298, )", - "resolved": "0.0.1298", - "contentHash": "PJ6Nm28qJ4ChsHYzgHUJ8g+DGyyHes2+bwxY709+znMhgi8fMp8M1FTF8x6pZMjnsPCWVwoMlxVEyq0NLeRZtA==", - "dependencies": { - "NuGet.Versioning": "6.14.0" - } + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw==" }, "Azure.Core": { "type": "Transitive", @@ -561,11 +558,6 @@ "Microsoft.Extensions.Logging.Abstractions": "10.0.7" } }, - "NuGet.Versioning": { - "type": "Transitive", - "resolved": "6.14.0", - "contentHash": "4v4blkhCv8mpKtfx+z0G/X0daVCzdIaHSC51GkUspugi5JIMn2Bo8xm5PdZYF0U68gOBfz/+aPWMnpRd85Jbow==" - }, "OpenTK": { "type": "Transitive", "resolved": "4.9.4", From 91b7873a889f1e8f9828d2d27d0339612a74ea48 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:26:06 -0400 Subject: [PATCH 015/145] Remove dead FakeExecutor.UnforceFunc test seam (CS0649) The only build warning across the solution: UnforceFunc was read in UnforcePlanAsync but never assigned by any test, so it was always null (CS0649). Removed the field and its no-op invoke; UnforcePlanAsync returns the same default outcome as before. Solution now builds at 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/RemediationTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dashboard.Tests/RemediationTests.cs b/Dashboard.Tests/RemediationTests.cs index e80dfad17..9d498b04d 100644 --- a/Dashboard.Tests/RemediationTests.cs +++ b/Dashboard.Tests/RemediationTests.cs @@ -1943,7 +1943,6 @@ private sealed class FakeExecutor : IRemediationExecutor public bool AuditWriteResult = true; public Func? PreflightFunc; public Func? ForceFunc; - public Func? UnforceFunc; public int ForceCalls; public int UnforceCalls; @@ -1974,7 +1973,7 @@ public Task ForcePlanAsync(string database, long queryId, long public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) { UnforceCalls++; - return Task.FromResult(UnforceFunc?.Invoke(database, queryId, planId) ?? new ForcePlanOutcome + return Task.FromResult(new ForcePlanOutcome { Database = database, QueryId = queryId, PlanId = planId, Status = RemediationStatus.Success, Forced = true, ExecutingLogin = "sa", GateSpid = 55, ExecSpid = 55 From 56dd068b93d54971ef7ba5e62d5558b8403ee724 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:34:27 -0400 Subject: [PATCH 016/145] Harden .gitattributes: force LF for shell scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's .gitattributes already normalizes text (`* text=auto eol=crlf`) and the tree is already normalized (`git add --renormalize .` is a no-op). Gap: with the global eol=crlf rule, a future *.sh would be checked out CRLF and fail under Git Bash, which this repo uses heavily. Add `*.sh text eol=lf`. No tracked .sh files today, so this changes nothing now — it's a latent-footgun guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitattributes b/.gitattributes index e71a7d8dd..aa168a0ae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,8 @@ * text=auto eol=crlf +# Shell scripts must stay LF (this repo uses Git Bash) — CRLF breaks them. +*.sh text eol=lf + # Explicit binary markers (prevent text mis-detection / corruption): *.png binary *.jpg binary From 4aefa41950b3596eca5921bd5709c2e0ed609563 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:18:18 -0400 Subject: [PATCH 017/145] Fix two Dashboard analysis bugs that drifted from Lite Both fixes bring Dashboard in line with the already-correct Lite copies; surfaced by a Lite<->Dashboard code-sharing drift audit. 1. SqlServerBaselineProvider: the full (hour, day-of-week) bucket tier was assigned via a copy-paste ternary whose two arms both returned BaselineTier.Full, so sparse buckets (count < CollapseThreshold) were mislabeled HourOnly in baseline_tier. Every bucket on this path is Full; HourOnly/Flat are assigned only on the collapse/flat paths. Matches Lite. 2. SqlServerAnomalyDetector.DetectBlockingAnomalies: blocking/deadlock spike ratios compared a raw window count against a per-hour baseline mean, so the ratio scaled with window length (default 4h) and a steady event rate could trip the spike threshold. Normalize current counts to per-hour before the ratio, mirroring Lite. Dashboard build + 487 Dashboard.Tests pass. No Lite changes (already correct). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Analysis/SqlServerAnomalyDetector.cs | 22 ++++++++++++++----- .../Analysis/SqlServerBaselineProvider.cs | 8 ++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/Dashboard/Analysis/SqlServerAnomalyDetector.cs b/Dashboard/Analysis/SqlServerAnomalyDetector.cs index a95ebfdc0..452728811 100644 --- a/Dashboard/Analysis/SqlServerAnomalyDetector.cs +++ b/Dashboard/Analysis/SqlServerAnomalyDetector.cs @@ -572,17 +572,27 @@ private async Task DetectBlockingAnomalies(AnalysisContext context, List a var currentBlocking = Convert.ToInt64(reader.GetValue(0)); var currentDeadlocks = Convert.ToInt64(reader.GetValue(1)); + /* Baseline mean is events per hour-of-day/dow bucket (≈ events per hour at this time of + day). current_* are raw counts over the whole analysis window (hoursBack, default 4), + so normalize them to per-hour before the ratio — otherwise the ratio scales with the + window length, not the workload, and a steady event rate trips the spike threshold. */ + var windowHours = (context.TimeRangeEnd - context.TimeRangeStart).TotalHours; + if (windowHours <= 0) windowHours = 1; + var currentBlockingPerHour = currentBlocking / windowHours; + var currentDeadlocksPerHour = currentDeadlocks / windowHours; + + // Baseline mean = events per hour for this hour+dow bucket var baselineBlockingRate = blockingBaseline.SampleCount > 0 ? blockingBaseline.Mean : 0; var baselineDeadlockRate = deadlockBaseline.SampleCount > 0 ? deadlockBaseline.Mean : 0; - // Blocking spike: at least 5 events AND 3x baseline rate (or no baseline) - if (currentBlocking >= 5 && (baselineBlockingRate <= 0 || currentBlocking / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) + // Blocking spike: at least 5 events in the window AND per-hour rate >= 3x baseline (or no baseline) + if (currentBlocking >= 5 && (baselineBlockingRate <= 0 || currentBlockingPerHour / Math.Max(baselineBlockingRate, 1) >= DefaultEventRatioThreshold)) { var metadata = new Dictionary { ["current_count"] = currentBlocking, ["baseline_rate"] = baselineBlockingRate, - ["ratio"] = baselineBlockingRate > 0 ? currentBlocking / baselineBlockingRate : 100.0 + ["ratio"] = baselineBlockingRate > 0 ? currentBlockingPerHour / baselineBlockingRate : 100.0 }; AddBaselineContext(metadata, blockingBaseline); @@ -596,14 +606,14 @@ private async Task DetectBlockingAnomalies(AnalysisContext context, List a }); } - // Deadlock spike: at least 3 events AND 3x baseline rate (or no baseline) - if (currentDeadlocks >= 3 && (baselineDeadlockRate <= 0 || currentDeadlocks / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) + // Deadlock spike: at least 3 events in the window AND per-hour rate >= 3x baseline (or no baseline) + if (currentDeadlocks >= 3 && (baselineDeadlockRate <= 0 || currentDeadlocksPerHour / Math.Max(baselineDeadlockRate, 1) >= DefaultEventRatioThreshold)) { var metadata = new Dictionary { ["current_count"] = currentDeadlocks, ["baseline_rate"] = baselineDeadlockRate, - ["ratio"] = baselineDeadlockRate > 0 ? currentDeadlocks / baselineDeadlockRate : 100.0 + ["ratio"] = baselineDeadlockRate > 0 ? currentDeadlocksPerHour / baselineDeadlockRate : 100.0 }; AddBaselineContext(metadata, deadlockBaseline); diff --git a/Dashboard/Analysis/SqlServerBaselineProvider.cs b/Dashboard/Analysis/SqlServerBaselineProvider.cs index 6ef0fef3e..dcecdad35 100644 --- a/Dashboard/Analysis/SqlServerBaselineProvider.cs +++ b/Dashboard/Analysis/SqlServerBaselineProvider.cs @@ -169,9 +169,11 @@ public async Task GetBaselineAsync(string metricName, DateTime a Mean = mean, StdDev = stddev, SampleCount = count, - Tier = count >= RestoreThreshold ? BaselineTier.Full - : count >= CollapseThreshold ? BaselineTier.Full - : BaselineTier.HourOnly + // Every bucket here is a full (hour, day-of-week) bucket; the HourOnly/Flat + // tiers are assigned only on the collapse/flat paths below. A sparse full + // bucket is still Full, just low-sample. (Was a copy-paste of two identical + // Full branches that mislabeled sparse buckets HourOnly in baseline_tier.) + Tier = BaselineTier.Full }; } From 4f181ac78e9876257c4139d6092af950ae11911d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:23:08 -0400 Subject: [PATCH 018/145] Fix #1154: key the alert delivery cooldown on the #1140 incident fingerprint The email/webhook cooldown was keyed on (serverId, metricName), ignoring the #1140 per-incident dedup fingerprint, so a genuinely distinct deadlock/blocking/ query/job/disk incident arriving inside the EmailCooldownMinutes window was silently dropped from email/Teams/Slack (the tray still fired). Per-event mode also collapsed to one notification per cycle because each per-incident send shared the single metric key. Introduce a shared IncidentCooldown (PerformanceMonitor.Notifications) keyed per fingerprint: send if any incident in the alert is outside its window, stamp every candidate key on success, and fall back to the metric-level key when an alert carries no fingerprintable incident (CPU/memory/poison-wait/tempdb/failed-job -- behavior unchanged). The restart seed (#981 email, #1145 webhook) is now per-fingerprint, reconstructed from the persisted ContextJson via an anchored "DedupKey" match (null-guarded for the Dashboard scan's null-context rows); the webhook null-store no-seed path is preserved. The per-fingerprint dict is bounded by the 2x-window eviction idiom reused from AnalysisNotificationService. Both apps and both channels run the identical shared decision; only the seed query differs (Lite DuckDB LIKE vs Dashboard in-memory scan), both pinned to the real serializer output by tests. Tests: Lite 544/544, Dashboard 488/488. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../JsonAlertHistoryStoreDedupKeyTests.cs | 82 +++++++++ Dashboard/Services/JsonAlertHistoryStore.cs | 11 +- .../ContextJsonContainsDedupKeyTests.cs | 68 +++++++ Lite.Tests/IncidentCooldownTests.cs | 172 ++++++++++++++++++ Lite.Tests/StoreRoundTripTests.cs | 48 +++++ Lite.Tests/WebhookCooldownSeedTests.cs | 47 ++++- Lite/Services/DuckDbAlertHistoryStore.cs | 31 +++- .../AlertContext.cs | 18 ++ .../EmailSendCore.cs | 43 ++--- .../IAlertHistoryStore.cs | 15 +- .../IncidentCooldown.cs | 130 +++++++++++++ .../WebhookAlertService.cs | 50 ++--- 12 files changed, 654 insertions(+), 61 deletions(-) create mode 100644 Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs create mode 100644 Lite.Tests/ContextJsonContainsDedupKeyTests.cs create mode 100644 Lite.Tests/IncidentCooldownTests.cs create mode 100644 PerformanceMonitor.Notifications/IncidentCooldown.cs diff --git a/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs b/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs new file mode 100644 index 000000000..daa2be9fe --- /dev/null +++ b/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Interfaces; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// #1154: the Dashboard JSON store's per-fingerprint cooldown seed. Proves the in-memory scan +/// restricts to rows whose ContextJson carries the #1140 dedup key, isolates per-fingerprint, and +/// — critically — does NOT NRE on the many null-ContextJson rows (tray/muted) the scan also visits +/// (the round-1 MAJOR). A unique serverId keeps the test isolated from any on-disk alert_history.json +/// the store loads in its constructor. +/// +public class JsonAlertHistoryStoreDedupKeyTests +{ + /// Minimal IUserPreferencesService over one UserPreferences instance (mirrors the other Dashboard tests). + private sealed class FakePreferencesService : IUserPreferencesService + { + public UserPreferences Preferences { get; } = new(); + public UserPreferences GetPreferences() => Preferences; + public void SavePreferences(UserPreferences preferences) { } + public void UpdateWaitStatsRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateCpuRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateMemoryRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateFileIoRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateExpensiveQueriesRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateBlockingRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + public void UpdateCollectionHealthRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) { } + } + + private static string JsonWith(string dedupKey) + { + var ctx = new AlertContext + { + Incidents = new List { new(dedupKey, new[] { "db.dbo.T" }) } + }; + return AlertContextSerializer.Serialize(ctx); + } + + private static Task RecordAsync(JsonAlertHistoryStore store, string serverId, string metric, string type, string? contextJson) + => store.RecordAlertAsync(new AlertHistoryRecord( + serverId, "Srv", metric, "4", "1", null, null, + AlertSent: true, NotificationType: type, SendError: null, + Muted: false, DetailText: null, ContextJson: contextJson)); + + [Fact] + public async Task GetLastSentUtc_WithDedupKey_FiltersToFingerprint_AndIgnoresNullContextRows() + { + var store = new JsonAlertHistoryStore(new FakePreferencesService()); + var srv = "test-" + Guid.NewGuid().ToString("N"); // unique -> isolated from on-disk history + + // Email rows: AAA earlier, BBB later, then a null-context "email" row (must not NRE, must be + // excluded by a dedupKey filter, but still counts for the metric-level seed). + await RecordAsync(store, srv, "Deadlocks Detected", "email", JsonWith("aaaa1111")); + await Task.Delay(10, TestContext.Current.CancellationToken); + await RecordAsync(store, srv, "Deadlocks Detected", "email", JsonWith("bbbb2222")); + await Task.Delay(10, TestContext.Current.CancellationToken); + await RecordAsync(store, srv, "Deadlocks Detected", "email", null); + + var lastAaa = await store.GetLastEmailSentUtcAsync(srv, "Deadlocks Detected", "aaaa1111"); + var lastBbb = await store.GetLastEmailSentUtcAsync(srv, "Deadlocks Detected", "bbbb2222"); + var lastCcc = await store.GetLastEmailSentUtcAsync(srv, "Deadlocks Detected", "cccc3333"); + var lastMetric = await store.GetLastEmailSentUtcAsync(srv, "Deadlocks Detected"); // metric-level (null key) + + Assert.NotNull(lastAaa); + Assert.NotNull(lastBbb); + Assert.Null(lastCcc); // no such fingerprint + Assert.True(lastAaa!.Value < lastBbb!.Value); // the filter isolates per-fingerprint + Assert.NotNull(lastMetric); + Assert.True(lastMetric!.Value >= lastBbb.Value); // metric-level still sees the later null-context row + + // Webhook channel parity. + await RecordAsync(store, srv, "Blocking Detected", "webhook", JsonWith("dddd4444")); + Assert.NotNull(await store.GetLastWebhookSentUtcAsync(srv, "Blocking Detected", "dddd4444")); + Assert.Null(await store.GetLastWebhookSentUtcAsync(srv, "Blocking Detected", "eeee5555")); + } +} diff --git a/Dashboard/Services/JsonAlertHistoryStore.cs b/Dashboard/Services/JsonAlertHistoryStore.cs index 16b78e69c..451fa5869 100644 --- a/Dashboard/Services/JsonAlertHistoryStore.cs +++ b/Dashboard/Services/JsonAlertHistoryStore.cs @@ -106,8 +106,11 @@ public Task RecordAlertAsync(AlertHistoryRecord record) /// Dashboard records email and webhook deliveries as separate alert-log /// rows, so the filter is just NotificationType == "email" — Lite's /// combined "email+webhook" notification_type never appears here. + /// When is non-null (#1154), the scan is additionally + /// restricted to rows whose ContextJson carries that #1140 fingerprint (the helper + /// null-guards the many tray/muted rows whose ContextJson is null). /// - public Task GetLastEmailSentUtcAsync(string serverId, string metricName) + public Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) { lock (_alertLogLock) { @@ -118,6 +121,7 @@ public Task RecordAlertAsync(AlertHistoryRecord record) if (entry.MetricName != metricName) continue; if (entry.NotificationType != "email") continue; if (!string.IsNullOrEmpty(entry.SendError)) continue; + if (dedupKey is not null && !AlertContextSerializer.ContextJsonContainsDedupKey(entry.ContextJson, dedupKey)) continue; if (max == null || entry.AlertTime > max.Value) max = entry.AlertTime; } return Task.FromResult(max); @@ -136,8 +140,10 @@ public Task RecordAlertAsync(AlertHistoryRecord record) /// Dashboard records webhook deliveries as their own alert-log rows with /// NotificationType == "webhook" (written only on a successful post), so /// the type alone implies success — no SendError filter is needed. + /// When is non-null (#1154), the scan is additionally + /// restricted to rows whose ContextJson carries that #1140 fingerprint. /// - public Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) { lock (_alertLogLock) { @@ -147,6 +153,7 @@ public Task RecordAlertAsync(AlertHistoryRecord record) if (entry.ServerId != serverId) continue; if (entry.MetricName != metricName) continue; if (entry.NotificationType != "webhook") continue; + if (dedupKey is not null && !AlertContextSerializer.ContextJsonContainsDedupKey(entry.ContextJson, dedupKey)) continue; if (max == null || entry.AlertTime > max.Value) max = entry.AlertTime; } return Task.FromResult(max); diff --git a/Lite.Tests/ContextJsonContainsDedupKeyTests.cs b/Lite.Tests/ContextJsonContainsDedupKeyTests.cs new file mode 100644 index 000000000..e32a1984b --- /dev/null +++ b/Lite.Tests/ContextJsonContainsDedupKeyTests.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #1154: the per-fingerprint cooldown seed matches rows by the serialized #1140 dedup key. +/// These tests pin the match against the REAL serializer output (so a future naming-policy +/// change breaks here, not silently in production), and the null/blank guards that keep the +/// Dashboard scan from NRE-ing on its many null-ContextJson rows. +/// +public class ContextJsonContainsDedupKeyTests +{ + private static string SerializeWith(params string[] dedupKeys) + { + var ctx = new AlertContext + { + Incidents = new List() + }; + foreach (var k in dedupKeys) + ctx.Incidents.Add(new AlertIncident(k, new[] { "db.dbo.T" })); + return AlertContextSerializer.Serialize(ctx); + } + + [Fact] + public void Finds_EachDedupKey_InRealSerializedContext() + { + var json = SerializeWith("aaaa1111", "bbbb2222"); + + Assert.True(AlertContextSerializer.ContextJsonContainsDedupKey(json, "aaaa1111")); + Assert.True(AlertContextSerializer.ContextJsonContainsDedupKey(json, "bbbb2222")); + } + + [Fact] + public void Misses_AbsentDedupKey() + { + var json = SerializeWith("aaaa1111"); + Assert.False(AlertContextSerializer.ContextJsonContainsDedupKey(json, "cccc3333")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ReturnsFalse_OnNullOrBlankContextJson(string? contextJson) + { + // The Dashboard scan visits many rows whose ContextJson is null (tray/muted/server rows); + // an un-guarded match would NRE and unwind every fingerprinted send. + Assert.False(AlertContextSerializer.ContextJsonContainsDedupKey(contextJson, "aaaa1111")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ReturnsFalse_OnNullOrBlankDedupKey(string? dedupKey) + { + var json = SerializeWith("aaaa1111"); + Assert.False(AlertContextSerializer.ContextJsonContainsDedupKey(json, dedupKey)); + } + + [Fact] + public void IsAnchored_DoesNotMatchSameValueUnderADifferentProperty() + { + // The same string under a non-DedupKey property must NOT match — the anchor is "DedupKey":"…". + const string notADedupKey = "{\"QueryHash\":\"deadbeef\",\"Other\":\"deadbeef\"}"; + Assert.False(AlertContextSerializer.ContextJsonContainsDedupKey(notADedupKey, "deadbeef")); + } +} diff --git a/Lite.Tests/IncidentCooldownTests.cs b/Lite.Tests/IncidentCooldownTests.cs new file mode 100644 index 000000000..024b9c578 --- /dev/null +++ b/Lite.Tests/IncidentCooldownTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// #1154: the shared per-incident-fingerprint cooldown. Replaces the old per-(serverId, metricName) +/// throttle that silently dropped a genuinely distinct incident arriving inside the window. These +/// tests pin the send decision (send if ANY candidate key is fresh; stamp ALL on success), the +/// metric-level fallback for non-fingerprinted alerts, the per-fingerprint restart seed, and the +/// 2×-window eviction bound. Windows are large (minutes) so the real-clock math is deterministic +/// within a sub-second test run; the one eviction test uses a tiny window with a generous delay. +/// +public class IncidentCooldownTests +{ + private static readonly TimeSpan Window = TimeSpan.FromMinutes(15); + + private static AlertIncident Incident(string dedupKey) => + new(dedupKey, new[] { "db.dbo." + dedupKey }); + + private static IReadOnlyList Incidents(params string[] keys) => + keys.Select(Incident).ToList(); + + /// A cooldown with no restart seed (history returns null for every key). + private static IncidentCooldown NoSeed() => + new(keyPrefix: "", seedLastSentUtc: (_, _, _) => Task.FromResult(null)); + + [Fact] + public async Task DistinctFingerprints_BothFresh_SendsWithOneKeyPerFingerprint() + { + var cd = NoSeed(); + var d = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A", "B"), Window); + + Assert.True(d.ShouldSend); + Assert.Equal(2, d.Keys.Count); + Assert.Equal(2, d.Keys.Distinct().Count()); + } + + [Fact] + public async Task SameFingerprintRepeat_WithinWindow_Suppressed() + { + var cd = NoSeed(); + + var first = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + Assert.True(first.ShouldSend); + cd.Stamp(first); + + var second = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + Assert.False(second.ShouldSend); // #1091: a re-fire of the same fingerprint stays suppressed + } + + [Fact] + public async Task DistinctFingerprint_WithinWindowOfAnother_StillSends() + { + // gotqn's #1154 repro: A delivered, then a DIFFERENT incident B arrives in the window. + var cd = NoSeed(); + + var a = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + cd.Stamp(a); + + var b = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("B"), Window); + Assert.True(b.ShouldSend); // B is not throttled by A's unrelated cooldown + } + + [Fact] + public async Task SummaryWithMixedFreshness_SendsThenStampAllSuppressesEither() + { + var cd = NoSeed(); + + var a = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + cd.Stamp(a); + + // Summary {A (in cooldown), B (fresh)} -> send because B is fresh; stamp BOTH. + var summary = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A", "B"), Window); + Assert.True(summary.ShouldSend); + cd.Stamp(summary); + + // Now A and B are both freshly stamped -> either one alone is suppressed. + Assert.False((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window)).ShouldSend); + Assert.False((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("B"), Window)).ShouldSend); + } + + [Fact] + public async Task AllIncidentsInCooldown_Suppressed() + { + var cd = NoSeed(); + var first = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A", "B"), Window); + cd.Stamp(first); + + var again = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A", "B"), Window); + Assert.False(again.ShouldSend); + } + + [Fact] + public async Task NonFingerprinted_FallsBackToMetricKey_LikePre1154() + { + var cd = NoSeed(); + + // No incidents -> single metric-level key. + var cpu = await cd.EvaluateAsync("1", "High CPU", null, Window); + Assert.True(cpu.ShouldSend); + Assert.Single(cpu.Keys); + cd.Stamp(cpu); + + // Same metric within window -> suppressed (today's behavior). + Assert.False((await cd.EvaluateAsync("1", "High CPU", null, Window)).ShouldSend); + // A different metric is its own key -> fresh. + Assert.True((await cd.EvaluateAsync("1", "TempDB Space", new List(), Window)).ShouldSend); + // A different server is its own key -> fresh. + Assert.True((await cd.EvaluateAsync("2", "High CPU", null, Window)).ShouldSend); + } + + [Fact] + public async Task Seed_WithinWindow_Suppresses_OlderThanWindow_Sends() + { + // Seed returns "just now" for A and an old time for B. + var cd = new IncidentCooldown("", (_, _, dedupKey) => Task.FromResult( + dedupKey == "A" ? DateTime.UtcNow + : dedupKey == "B" ? DateTime.UtcNow - TimeSpan.FromMinutes(17) + : null)); + + Assert.False((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window)).ShouldSend); // seeded in-window + Assert.True((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("B"), Window)).ShouldSend); // seeded but stale + Assert.True((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("C"), Window)).ShouldSend); // never sent + } + + [Fact] + public async Task NullSeedDelegate_NeverSeeds_FirstTouchAlwaysFresh() + { + // Mirrors the webhook null-store path: no seeding, pure in-memory. + var cd = new IncidentCooldown("webhook:", seedLastSentUtc: null); + + var d = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + Assert.True(d.ShouldSend); + } + + [Fact] + public async Task FailedSend_NotStamped_DoesNotStartCooldown() + { + var cd = NoSeed(); + + // Evaluate but do NOT stamp (simulating a failed send). + var d = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window); + Assert.True(d.ShouldSend); + + // Next evaluation still sends — a failed send must not consume the cooldown. + Assert.True((await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), Window)).ShouldSend); + } + + [Fact] + public async Task Eviction_DropsKeysPastTwiceWindow_KeepsDictBounded() + { + var window = TimeSpan.FromMilliseconds(50); + var cd = NoSeed(); + + var a = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("A"), window); + cd.Stamp(a); + Assert.Equal(1, cd.TrackedKeyCount); + + await Task.Delay(250, TestContext.Current.CancellationToken); // > 2x window: A is now evictable + + var b = await cd.EvaluateAsync("1", "Deadlocks Detected", Incidents("B"), window); + cd.Stamp(b); + + // A was pruned at the top of the second Evaluate, so only B remains (not 2). + Assert.Equal(1, cd.TrackedKeyCount); + } +} diff --git a/Lite.Tests/StoreRoundTripTests.cs b/Lite.Tests/StoreRoundTripTests.cs index 38c5e8d0d..66c273184 100644 --- a/Lite.Tests/StoreRoundTripTests.cs +++ b/Lite.Tests/StoreRoundTripTests.cs @@ -156,6 +156,39 @@ send_error is the EMAIL failure (the webhook still delivered), because send_erro Assert.Null(await store.GetLastWebhookSentUtcAsync("5", "Missing")); } + [Fact] + public async Task GetLastSentUtc_WithDedupKey_FiltersToContextJsonFingerprint() + { + await _duckDb.InitializeAsync(); + var store = new DuckDbAlertHistoryStore(_duckDb); + + /* #1154: two distinct deadlock incidents recorded as successful email rows (AAA earlier, + BBB later) carrying real serialized #1140 context, plus a later null-context row that + any dedupKey filter must exclude (it would NRE/over-match a naive scan). */ + await RecordWithContextAsync(store, "9", "Deadlocks Detected", "email", JsonWith("aaaa1111")); + await Task.Delay(10, TestContext.Current.CancellationToken); + await RecordWithContextAsync(store, "9", "Deadlocks Detected", "email", JsonWith("bbbb2222")); + await Task.Delay(10, TestContext.Current.CancellationToken); + await RecordAsync(store, "9", "Deadlocks Detected", "email", null); // null context, latest row + + var lastAaa = await store.GetLastEmailSentUtcAsync("9", "Deadlocks Detected", "aaaa1111"); + var lastBbb = await store.GetLastEmailSentUtcAsync("9", "Deadlocks Detected", "bbbb2222"); + var lastCcc = await store.GetLastEmailSentUtcAsync("9", "Deadlocks Detected", "cccc3333"); + var lastMetric = await store.GetLastEmailSentUtcAsync("9", "Deadlocks Detected"); // metric-level (null key) + + Assert.NotNull(lastAaa); + Assert.NotNull(lastBbb); + Assert.Null(lastCcc); // no such fingerprint + Assert.True(lastAaa!.Value < lastBbb!.Value); // the filter isolates per-fingerprint (AAA is earlier) + Assert.NotNull(lastMetric); + Assert.True(lastMetric!.Value >= lastBbb.Value); // metric-level still sees the later null-context row + + /* Webhook channel uses the identical filter — prove it too. */ + await RecordWithContextAsync(store, "9", "Blocking Detected", "webhook", JsonWith("dddd4444")); + Assert.NotNull(await store.GetLastWebhookSentUtcAsync("9", "Blocking Detected", "dddd4444")); + Assert.Null(await store.GetLastWebhookSentUtcAsync("9", "Blocking Detected", "eeee5555")); + } + [Fact] public async Task EdgeTriggerWatermark_SaveLoad_RoundTripsAndUpserts() { @@ -250,6 +283,21 @@ private static Task RecordAsync(IAlertHistoryStore store, string serverId, strin serverId, "Srv", metric, "90", "80", 90, 80, true, type, error, false, null, null)); + private static Task RecordWithContextAsync(IAlertHistoryStore store, string serverId, string metric, string type, string? contextJson) + => store.RecordAlertAsync(new AlertHistoryRecord( + serverId, "Srv", metric, "90", "80", 90, 80, + true, type, null, false, null, contextJson)); + + /// Real serialized #1140 context carrying a single incident with the given dedup key. + private static string JsonWith(string dedupKey) + { + var ctx = new AlertContext + { + Incidents = new List { new(dedupKey, new[] { "db.dbo.T" }) } + }; + return AlertContextSerializer.Serialize(ctx); + } + private sealed record AlertRow( DateTime AlertTime, int ServerId, string ServerName, string MetricName, double CurrentValue, double ThresholdValue, bool AlertSent, diff --git a/Lite.Tests/WebhookCooldownSeedTests.cs b/Lite.Tests/WebhookCooldownSeedTests.cs index e144c0c4e..34aebf472 100644 --- a/Lite.Tests/WebhookCooldownSeedTests.cs +++ b/Lite.Tests/WebhookCooldownSeedTests.cs @@ -98,13 +98,56 @@ private sealed class FakeHistoryStore : IAlertHistoryStore public DateTime? LastWebhookSent { get; set; } public int GetLastWebhookSentCallCount { get; private set; } + /// When set (#1154), the seed applies ONLY to this dedup key; other keys seed null. + /// Null (default) returns for any call — the pre-#1154 shape. + public string? SeededDedupKey { get; set; } + public Task RecordAlertAsync(AlertHistoryRecord record) => Task.CompletedTask; - public Task GetLastEmailSentUtcAsync(string serverId, string metricName) => Task.FromResult(null); - public Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + public Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) => Task.FromResult(null); + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) { GetLastWebhookSentCallCount++; + if (SeededDedupKey is not null && dedupKey != SeededDedupKey) + return Task.FromResult(null); return Task.FromResult(LastWebhookSent); } public Task GetLastAlertTimeAsync(string serverId, string metricName) => Task.FromResult(null); } + + private static AlertContext ContextWith(string dedupKey) => new() + { + Incidents = new System.Collections.Generic.List + { + new(dedupKey, new[] { "db.dbo.T" }) + } + }; + + [Fact] + public async Task DistinctFingerprint_NotSuppressedByAnotherIncidentsCooldown() + { + // #1154: incident X was delivered "just now"; a DISTINCT incident Y arrives within the window. + // Y must be attempted (it fails against the dead URL) — not throttled by X's cooldown. + var history = new FakeHistoryStore { LastWebhookSent = DateTime.UtcNow, SeededDedupKey = "X" }; + var svc = MakeService(history, EnabledTeamsSettings()); + + var sent = await svc.TrySendWebhookAlertsAsync( + "Deadlocks Detected", "Srv", "4", "1", "1", ContextWith("Y")); + + Assert.False(sent); // dead URL -> attempted, failed + Assert.Equal(1, svc.GetTeamsHealth().ConsecutiveFailures); // ATTEMPTED, not suppressed + } + + [Fact] + public async Task SameFingerprint_SuppressedByItsOwnSeededCooldown() + { + // The same incident X, seeded "just now" -> suppressed (no network touch). + var history = new FakeHistoryStore { LastWebhookSent = DateTime.UtcNow, SeededDedupKey = "X" }; + var svc = MakeService(history, EnabledTeamsSettings()); + + var sent = await svc.TrySendWebhookAlertsAsync( + "Deadlocks Detected", "Srv", "4", "1", "1", ContextWith("X")); + + Assert.False(sent); // suppressed + Assert.Equal(0, svc.GetTeamsHealth().ConsecutiveFailures); // NOT attempted + } } diff --git a/Lite/Services/DuckDbAlertHistoryStore.cs b/Lite/Services/DuckDbAlertHistoryStore.cs index 8fd4e1881..9cb4fd64c 100644 --- a/Lite/Services/DuckDbAlertHistoryStore.cs +++ b/Lite/Services/DuckDbAlertHistoryStore.cs @@ -108,9 +108,11 @@ INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, c /// /// Returns the UTC time the most recent alert email was successfully sent /// for this server/metric, read from config_alert_log — or null if none. - /// Used to seed the in-memory cooldown after an app restart (#981). + /// Used to seed the in-memory cooldown after an app restart (#981). When + /// is non-null (#1154), the result is additionally + /// restricted to rows whose context_json carries that #1140 fingerprint. /// - public async Task GetLastEmailSentUtcAsync(string serverId, string metricName) + public async Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) { var sid = int.TryParse(serverId, out var s) ? s : 0; try @@ -131,16 +133,22 @@ INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, c using var command = connection.CreateCommand(); /* A successful email send is logged with a notification_type of 'email' / 'email+webhook' and a null send_error — that mirrors - exactly when _cooldowns is updated after SendEmailAsync. */ + exactly when the cooldown is stamped after SendEmailAsync. + #1154: when a dedupKey is supplied, push the per-fingerprint filter into + DuckDB via an anchored LIKE on the serialized "DedupKey":"" property + (the hex key has no LIKE wildcards; NULL context_json rows fail the match). */ command.CommandText = @" SELECT MAX(alert_time) FROM config_alert_log WHERE server_id = $1 AND metric_name = $2 AND notification_type IN ('email', 'email+webhook') -AND send_error IS NULL"; +AND send_error IS NULL" + + (dedupKey is null ? "" : "\nAND context_json LIKE $3"); command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = sid }); command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = metricName }); + if (dedupKey is not null) + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = "%\"DedupKey\":\"" + dedupKey + "\"%" }); var result = await command.ExecuteScalarAsync(); if (result == null || result == DBNull.Value) return null; @@ -161,9 +169,11 @@ is explicit (the cooldown subtraction is tick math regardless). */ /// for this server/metric, read from config_alert_log — or null if none. /// Seeds the webhook cooldown after restart so a Teams/Slack alert posted /// shortly before a restart is not re-posted afterward (#1145, mirroring the - /// email seed #981). + /// email seed #981). When is non-null (#1154), the + /// result is additionally restricted to rows whose context_json carries that + /// #1140 fingerprint. /// - public async Task GetLastWebhookSentUtcAsync(string serverId, string metricName) + public async Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) { var sid = int.TryParse(serverId, out var s) ? s : 0; try @@ -186,15 +196,20 @@ is explicit (the cooldown subtraction is tick math regardless). */ 'webhook' / 'email+webhook' — those types are only ever written when WebhookSent is true, so the type alone implies success. send_error tracks the EMAIL channel, so it is NOT filtered on: - an email-failed-but-webhook-sent row must still seed the cooldown. */ + an email-failed-but-webhook-sent row must still seed the cooldown. + #1154: when a dedupKey is supplied, push the per-fingerprint filter into + DuckDB via an anchored LIKE on the serialized "DedupKey":"" property. */ command.CommandText = @" SELECT MAX(alert_time) FROM config_alert_log WHERE server_id = $1 AND metric_name = $2 -AND notification_type IN ('webhook', 'email+webhook')"; +AND notification_type IN ('webhook', 'email+webhook')" + + (dedupKey is null ? "" : "\nAND context_json LIKE $3"); command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = sid }); command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = metricName }); + if (dedupKey is not null) + command.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = "%\"DedupKey\":\"" + dedupKey + "\"%" }); var result = await command.ExecuteScalarAsync(); if (result == null || result == DBNull.Value) return null; diff --git a/PerformanceMonitor.Notifications/AlertContext.cs b/PerformanceMonitor.Notifications/AlertContext.cs index 4ee9422e7..69fef4774 100644 --- a/PerformanceMonitor.Notifications/AlertContext.cs +++ b/PerformanceMonitor.Notifications/AlertContext.cs @@ -6,6 +6,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ +using System; using System.Collections.Generic; using System.Text.Json; using PerformanceMonitor.Analysis; @@ -259,6 +260,23 @@ public record MissingIndexTargetDto( /// public static class AlertContextSerializer { + /// + /// True when the persisted carries the given #1140 dedup fingerprint + /// (#1154 per-fingerprint cooldown seed). Anchored substring match on the serialized + /// "DedupKey":"<hex>" property — safe because the key is lowercase SHA-256 hex (no JSON + /// escaping, no collision with any other serialized field) and emits PascalCase + /// with default options. Returns false on null/blank input — the Dashboard scan visits many rows whose + /// ContextJson is null (tray/muted/server-reachability rows), and an un-guarded match would NRE. + /// Centralizes the JSON shape so the Dashboard store cannot drift; the Lite store re-states the same + /// anchor in its SQL LIKE for push-down and is guarded by a store round-trip test. + /// + public static bool ContextJsonContainsDedupKey(string? contextJson, string? dedupKey) + { + if (string.IsNullOrEmpty(contextJson) || string.IsNullOrEmpty(dedupKey)) + return false; + return contextJson.Contains("\"DedupKey\":\"" + dedupKey + "\"", StringComparison.Ordinal); + } + public static string Serialize(AlertContext context) { var dto = new AlertContextDto( diff --git a/PerformanceMonitor.Notifications/EmailSendCore.cs b/PerformanceMonitor.Notifications/EmailSendCore.cs index 65e501aa0..063635836 100644 --- a/PerformanceMonitor.Notifications/EmailSendCore.cs +++ b/PerformanceMonitor.Notifications/EmailSendCore.cs @@ -7,7 +7,6 @@ */ using System; -using System.Collections.Concurrent; using System.IO; using System.Net; using System.Net.Mail; @@ -30,11 +29,15 @@ namespace PerformanceMonitor.Notifications; public sealed class EmailSendCore { private readonly IAlertSettings _settings; - private readonly IAlertHistoryStore _historyStore; private readonly WebhookAlertService _webhookAlertService; private readonly AlertBranding _branding; private readonly ILogger _logger; - private readonly ConcurrentDictionary _cooldowns = new(); + + /* #1154: per-incident-fingerprint cooldown (was a per-(serverId, metricName) + ConcurrentDictionary). Keyed per #1140 dedup fingerprint so a distinct incident in + the window is delivered; falls back to the metric-level key when an alert carries no + fingerprintable incident. Seeds the email last-sent time from the alert log (#981). */ + private readonly IncidentCooldown _cooldown; /* Failure tracking for louder logging + the health getter (MIN-4: counters stay co-located with GetEmailHealth on the shared core). */ @@ -49,10 +52,13 @@ public EmailSendCore( ILogger logger) { _settings = settings; - _historyStore = historyStore; _webhookAlertService = webhookAlertService; _branding = branding; _logger = logger; + _cooldown = new IncidentCooldown( + keyPrefix: "", + seedLastSentUtc: (serverId, metricName, dedupKey) => + historyStore.GetLastEmailSentUtcAsync(serverId, metricName, dedupKey)); } /// @@ -84,24 +90,15 @@ public async Task TrySendAsync( !string.IsNullOrWhiteSpace(_settings.SmtpFromAddress) && !string.IsNullOrWhiteSpace(_settings.SmtpRecipients)) { - var cooldownKey = $"{serverId}:{metricName}"; - - /* Seed the in-memory cooldown from the alert log the first time this key is - seen, so an alert email sent shortly before an app restart is not immediately - re-sent afterward (#981). The in-memory dictionary is authoritative once seeded. */ - if (!_cooldowns.ContainsKey(cooldownKey)) - { - var lastPersistedSend = await _historyStore.GetLastEmailSentUtcAsync(serverId, metricName); - if (lastPersistedSend.HasValue) - { - _cooldowns.TryAdd(cooldownKey, lastPersistedSend.Value); - } - } - - var withinCooldown = _cooldowns.TryGetValue(cooldownKey, out var lastSent) && - DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(_settings.EmailCooldownMinutes); - - if (!withinCooldown) + /* #1154: per-fingerprint cooldown. Send if any incident in this alert is outside its + window (a distinct fingerprint is not throttled by an unrelated prior incident); + stamp every candidate key only after a successful send. Seeds from the alert log on + first touch per key (#981). No incidents -> the metric-level fallback key (today's behavior). */ + var decision = await _cooldown.EvaluateAsync( + serverId, metricName, context?.Incidents, + TimeSpan.FromMinutes(_settings.EmailCooldownMinutes)); + + if (decision.ShouldSend) { emailAttempted = true; @@ -113,7 +110,7 @@ public async Task TrySendAsync( { await SendEmailAsync(_settings, subject, htmlBody, plainTextBody, context); emailSent = true; - _cooldowns[cooldownKey] = DateTime.UtcNow; + _cooldown.Stamp(decision); if (_consecutiveFailures > 0) { diff --git a/PerformanceMonitor.Notifications/IAlertHistoryStore.cs b/PerformanceMonitor.Notifications/IAlertHistoryStore.cs index 76c988005..2fd6485e0 100644 --- a/PerformanceMonitor.Notifications/IAlertHistoryStore.cs +++ b/PerformanceMonitor.Notifications/IAlertHistoryStore.cs @@ -38,8 +38,14 @@ public interface IAlertHistoryStore /// cooldown across restart (#981). Lite: notification_type IN /// ('email','email+webhook') AND send_error IS NULL. Dash: NotificationType /// == "email" AND SendError empty. + /// + /// When is non-null (#1154 per-fingerprint cooldown), the result is + /// additionally restricted to rows whose persisted ContextJson carries that #1140 dedup + /// fingerprint, so the seed reconstructs the per-incident last-sent time. Null = the metric-level + /// seed (the pre-#1154 behavior, used by the non-fingerprinted fallback). + /// /// - Task GetLastEmailSentUtcAsync(string serverId, string metricName); + Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null); /// /// MAX(alert_time) filtered to a *successful webhook send* — seeds the webhook @@ -48,8 +54,13 @@ public interface IAlertHistoryStore /// notification_type already implies the webhook delivered (it's only written on a /// successful post), and send_error tracks the EMAIL channel, so it is NOT filtered on. /// Lite: notification_type IN ('webhook','email+webhook'). Dash: NotificationType == "webhook". + /// + /// When is non-null (#1154 per-fingerprint cooldown), the result is + /// additionally restricted to rows whose persisted ContextJson carries that #1140 dedup + /// fingerprint. Null = the metric-level seed (the pre-#1154 behavior). + /// /// - Task GetLastWebhookSentUtcAsync(string serverId, string metricName); + Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null); /// /// MAX(alert_time) UNFILTERED (any channel/result) — seeds the analysis diff --git a/PerformanceMonitor.Notifications/IncidentCooldown.cs b/PerformanceMonitor.Notifications/IncidentCooldown.cs new file mode 100644 index 000000000..096788ce8 --- /dev/null +++ b/PerformanceMonitor.Notifications/IncidentCooldown.cs @@ -0,0 +1,130 @@ +/* + * 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace PerformanceMonitor.Notifications; + +/// +/// Per-incident-fingerprint delivery cooldown shared by the email () and +/// webhook () send paths (#1154). Replaces the old per- +/// (serverId, metricName) cooldown that silently dropped a genuinely distinct incident arriving inside +/// the window: the cooldown is now keyed per #1140 dedup fingerprint, so a DISTINCT fingerprint is +/// delivered while a REPEAT of the same fingerprint stays suppressed (#1091). Alerts with no +/// fingerprintable incidents (Incidents null/empty — CPU/memory/poison-wait/tempdb/failed-job) fall back +/// to the metric-level key, byte-identical to the pre-#1154 behavior. +/// +public sealed class IncidentCooldown +{ + private readonly ConcurrentDictionary _cooldowns = new(); + private readonly string _keyPrefix; + private readonly Func>? _seedLastSentUtc; + + /// + /// Per-channel prefix so the two channels' keys never collide in shape: "" for email, + /// "webhook:" for the webhook path (matching the pre-#1154 key strings). + /// + /// + /// Lazy restart seed: (serverId, metricName, dedupKey) => the last successful send time for + /// that key, or null if none. dedupKey is null for the metric-level fallback. A + /// null delegate disables seeding entirely (the webhook no-store path — preserves + /// WebhookCooldownSeedTests.NullHistoryStore_NoSeed_AttemptsPost). + /// + public IncidentCooldown(string keyPrefix, Func>? seedLastSentUtc) + { + _keyPrefix = keyPrefix; + _seedLastSentUtc = seedLastSentUtc; + } + + /// + /// Decides whether to send, seeding unseen keys from history and evicting stale keys first. Does NOT + /// stamp — the caller stamps via only after a successful send, so a failed send + /// never starts the cooldown. Sends when AT LEAST ONE candidate key is fresh (outside the window); a + /// successful send then stamps EVERY candidate key, so an in-cooldown incident that rode along on a + /// fresh sibling has its timer refreshed and never re-alerts standalone next cycle. + /// + public async Task EvaluateAsync( + string serverId, string metricName, IReadOnlyList? incidents, TimeSpan window) + { + var now = DateTime.UtcNow; + Evict(now, window); + + var keys = BuildKeys(serverId, metricName, incidents); + + bool anyFresh = false; + foreach (var (key, dedupKey) in keys) + { + if (_seedLastSentUtc is not null && !_cooldowns.ContainsKey(key)) + { + var lastSent = await _seedLastSentUtc(serverId, metricName, dedupKey); + if (lastSent.HasValue) + _cooldowns.TryAdd(key, lastSent.Value); + } + + if (!_cooldowns.TryGetValue(key, out var last) || now - last >= window) + anyFresh = true; + } + + return new Decision(anyFresh, keys.Select(k => k.Key).ToList(), now); + } + + /// + /// Stamps every candidate key from a to its evaluation time. Call ONLY after a + /// successful send (a failed send must not start the cooldown). + /// + public void Stamp(Decision decision) + { + foreach (var key in decision.Keys) + _cooldowns[key] = decision.EvaluatedAtUtc; + } + + // Distinct non-blank fingerprints -> one "{prefix}{server}:{metric}:{dedupKey}" key each; no + // fingerprint -> the single "{prefix}{server}:{metric}" fallback key (pre-#1154 behavior). A blank + // DedupKey can't occur (AlertFingerprint returns null incidents, filtered upstream) but is excluded + // defensively so it never produces a "…::" key. + private List<(string Key, string? DedupKey)> BuildKeys( + string serverId, string metricName, IReadOnlyList? incidents) + { + var dedupKeys = incidents? + .Select(i => i.DedupKey) + .Where(k => !string.IsNullOrEmpty(k)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (dedupKeys is { Count: > 0 }) + return dedupKeys + .Select(d => ($"{_keyPrefix}{serverId}:{metricName}:{d}", (string?)d)) + .ToList(); + + return new List<(string, string?)> { ($"{_keyPrefix}{serverId}:{metricName}", (string?)null) }; + } + + /* Drop entries past 2x the window so the per-fingerprint dict stays bounded — any entry past 1x is + already re-fire-eligible, so doubling only adds clock-skew margin and can never evict a key that + could still suppress. A key dropped here that later recurs is re-seeded from history on next touch; + that's a wash, not a bug. Mirrors AnalysisNotificationService's prune idiom. */ + private void Evict(DateTime now, TimeSpan window) + { + var pruneBefore = now - TimeSpan.FromTicks(window.Ticks * 2); + foreach (var entry in _cooldowns) + { + if (entry.Value < pruneBefore) + _cooldowns.TryRemove(entry.Key, out _); + } + } + + /// Live key count, for tests asserting eviction keeps the dict bounded. + internal int TrackedKeyCount => _cooldowns.Count; + + /// The send decision plus the candidate keys to stamp on a successful send. + public sealed record Decision(bool ShouldSend, IReadOnlyList Keys, DateTime EvaluatedAtUtc); +} diff --git a/PerformanceMonitor.Notifications/WebhookAlertService.cs b/PerformanceMonitor.Notifications/WebhookAlertService.cs index 506b07425..5e74123f1 100644 --- a/PerformanceMonitor.Notifications/WebhookAlertService.cs +++ b/PerformanceMonitor.Notifications/WebhookAlertService.cs @@ -37,11 +37,14 @@ at the email / in-app dialog instead. */ private const string TsqlWebhookHint = "See email or in-app Alert Details for the copy-paste T-SQL."; private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null }; - private readonly ConcurrentDictionary _cooldowns = new(); + /* #1154: per-incident-fingerprint cooldown (was a per-(serverId, metricName) + ConcurrentDictionary). Keyed per #1140 dedup fingerprint so a distinct incident in the + window is delivered; falls back to the metric-level key when an alert carries no + fingerprintable incident. */ + private readonly IncidentCooldown _cooldown; private readonly IAlertSettings _settings; private readonly AlertBranding _branding; private readonly ILogger _logger; - private readonly IAlertHistoryStore? _historyStore; private int _consecutiveTeamsFailures; private string? _lastTeamsError; @@ -49,9 +52,9 @@ at the email / in-app dialog instead. */ private string? _lastSlackError; /// - /// Optional alert-history store used to seed the per-(serverId, metricName) webhook - /// cooldown across an app restart (#1145, mirroring the email seed #981). When null the - /// cooldown is purely in-memory (the pre-#1145 behavior) — the test call sites pass null. + /// Optional alert-history store used to seed the per-fingerprint webhook cooldown across an app + /// restart (#1145, mirroring the email seed #981). When null the cooldown is purely in-memory + /// (the pre-#1145 behavior, seeding disabled) — the test call sites pass null. /// public WebhookAlertService( IAlertSettings settings, @@ -62,7 +65,13 @@ public WebhookAlertService( _settings = settings; _branding = branding; _logger = logger; - _historyStore = historyStore; + _cooldown = new IncidentCooldown( + keyPrefix: "webhook:", + // Null store -> null seed delegate -> no restart seeding (preserves the pre-#1145 in-memory path). + seedLastSentUtc: historyStore is null + ? null + : (serverId, metricName, dedupKey) => + historyStore.GetLastWebhookSentUtcAsync(serverId, metricName, dedupKey)); } /// @@ -79,23 +88,16 @@ public async Task TrySendWebhookAlertsAsync( { try { - var cooldownKey = $"webhook:{serverId}:{metricName}"; - - /* Seed the in-memory cooldown from the alert log the first time this key is - seen, so a Teams/Slack alert posted shortly before an app restart is not - immediately re-posted afterward (#1145, mirroring the email seed #981). The - in-memory dictionary is authoritative once seeded. */ - if (_historyStore is not null && !_cooldowns.ContainsKey(cooldownKey)) - { - var lastPersistedSend = await _historyStore.GetLastWebhookSentUtcAsync(serverId, metricName); - if (lastPersistedSend.HasValue) - { - _cooldowns.TryAdd(cooldownKey, lastPersistedSend.Value); - } - } - - if (_cooldowns.TryGetValue(cooldownKey, out var lastSent) && - DateTime.UtcNow - lastSent < TimeSpan.FromMinutes(_settings.EmailCooldownMinutes)) + /* #1154: per-fingerprint cooldown. Post if any incident in this alert is outside its + window (a distinct fingerprint is not throttled by an unrelated prior incident); stamp + every candidate key only after a successful post. Seeds the webhook last-sent time from + the alert log on first touch per key (#1145), unless the store is null (no seeding). No + incidents -> the metric-level fallback key (today's behavior). */ + var decision = await _cooldown.EvaluateAsync( + serverId, metricName, context?.Incidents, + TimeSpan.FromMinutes(_settings.EmailCooldownMinutes)); + + if (!decision.ShouldSend) { return false; } @@ -114,7 +116,7 @@ public async Task TrySendWebhookAlertsAsync( if (sent) { - _cooldowns[cooldownKey] = DateTime.UtcNow; + _cooldown.Stamp(decision); } return sent; From 2911d57d97a6ec0c1073854e837f3180db417348 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:52:59 -0400 Subject: [PATCH 019/145] Fingerprint failed-job alerts so distinct failures don't coalesce (#1154 follow-up) BuildFailedJobContext (both apps) attached no #1140 incident, so two distinct failed jobs in the same window coalesced under the metric-key fallback of the #1154 per-fingerprint cooldown. Attach a Job fingerprint keyed by job name (scoped to the instance via serverName), mirroring the sibling BuildAnomalousJobContext. Both apps; serverName threaded to the builder. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/MainWindow.xaml.cs | 14 +++++++++++--- Lite/MainWindow.xaml.cs | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index ce828d9b5..ac2d94efa 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -2150,7 +2150,7 @@ await _emailAlertService.TrySendAlertEmailAsync( bool isMuted = _muteRuleService.IsAlertMuted(muteCtx); _lastFailedJobAlert[serverId] = now; _lastAlertedFailedJobTime[serverId] = newestFailure; - var jobContext = BuildFailedJobContext(health.RecentlyFailedJobs); + var jobContext = BuildFailedJobContext(serverName, health.RecentlyFailedJobs); var detailText = ContextToDetailText(jobContext); if (!isMuted) @@ -2498,12 +2498,13 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List exclu return context; } - private static AlertContext? BuildFailedJobContext(List jobs) + private static AlertContext? BuildFailedJobContext(string serverName, List jobs) { if (jobs.Count == 0) return null; var context = new AlertContext(); - foreach (var j in jobs.GetRange(0, Math.Min(5, jobs.Count))) + var shown = jobs.GetRange(0, Math.Min(5, jobs.Count)); + foreach (var j in shown) { var item = new AlertDetailItem { Heading = j.JobName, Fields = new() }; item.Fields.Add(("Job", j.JobName)); @@ -2512,6 +2513,13 @@ private static bool IsDeadlockExcluded(DeadlockItem deadlock, List exclu item.Fields.Add(("Message", Truncate(j.Message, 300))); context.Details.Add(item); } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName) — mirrors + BuildAnomalousJobContext so two distinct failed jobs are distinct incidents under the + #1154 per-fingerprint cooldown instead of coalescing on the metric key. */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 83b83f3dd..fb7082661 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -2255,7 +2255,7 @@ dedups so the same failure never re-fires. */ _muteRuleService); } - var failedJobContext = BuildFailedJobContext(failedJobs); + var failedJobContext = BuildFailedJobContext(summary.DisplayName, failedJobs); var detailText = ContextToDetailText(failedJobContext); await _emailAlertService.TrySendAlertEmailAsync( @@ -2664,12 +2664,13 @@ private static string FormatLowDiskThreshold() return context; } - private static AlertContext? BuildFailedJobContext(List jobs) + private static AlertContext? BuildFailedJobContext(string serverName, List jobs) { if (jobs.Count == 0) return null; var context = new AlertContext(); - foreach (var j in jobs.GetRange(0, Math.Min(5, jobs.Count))) + var shown = jobs.GetRange(0, Math.Min(5, jobs.Count)); + foreach (var j in shown) { var item = new AlertDetailItem { Heading = j.JobName, Fields = new() }; item.Fields.Add(("Job", j.JobName)); @@ -2678,6 +2679,13 @@ private static string FormatLowDiskThreshold() item.Fields.Add(("Message", TruncateText(j.Message, 300))); context.Details.Add(item); } + + /* #1140: dedup key per job (job name, scoped to the instance via serverName) — mirrors + BuildAnomalousJobContext so two distinct failed jobs are distinct incidents under the + #1154 per-fingerprint cooldown instead of coalescing on the metric key. */ + AlertIncidentRenderer.Apply(context, shown + .Select(j => AlertFingerprint.ForKey(serverName, AlertFingerprint.Job, j.JobName, new[] { j.JobName })) + .Where(i => i is not null).Select(i => i!).ToList()); return context; } From df9fe903fa74e8f41005289dc37ae2aa40ef1fec Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:14:52 -0400 Subject: [PATCH 020/145] Share ServerIdHelper + MfaAuthenticationHelper across Lite/Dashboard First wave of the Lite<->Dashboard code-sharing follow-up: two leaf utilities that were duplicated (the server-id audit's highest-confidence, lowest-risk items) now have a single implementation in PerformanceMonitor.Common. - ServerIdHelper.GetDeterministicHashCode (FNV-1a): the Dashboard copy and the Lite copy (RemoteCollectorService) were byte-identical, and Dashboard's own doc-comment warned they MUST stay in sync (a mismatch silently corrupts server-id matching for analysis findings/mutes). Moved the implementation to Common; Dashboard call sites repoint to it (added the Common using to AnalysisScheduler + RecommendationsContent), and Lite keeps RemoteCollectorService.GetDeterministicHashCode as a thin forwarder so its ~16 internal call sites are untouched. One implementation now. - MfaAuthenticationHelper.IsMfaCancelledException: byte-identical in both apps; moved to Common, both copies deleted. All 7 call sites already imported Common. Pure refactor, no behavior change. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controls/RecommendationsContent.xaml.cs | 1 + Dashboard/Helpers/MfaAuthenticationHelper.cs | 35 ------------------- Dashboard/Services/AnalysisScheduler.cs | 1 + Lite/Services/RemoteCollectorService.cs | 20 ++++------- .../Helpers/MfaAuthenticationHelper.cs | 6 ++-- .../Services/ServerIdHelper.cs | 18 +++++----- 6 files changed, 20 insertions(+), 61 deletions(-) delete mode 100644 Dashboard/Helpers/MfaAuthenticationHelper.cs rename {Lite => PerformanceMonitor.Common}/Helpers/MfaAuthenticationHelper.cs (90%) rename {Dashboard => PerformanceMonitor.Common}/Services/ServerIdHelper.cs (54%) diff --git a/Dashboard/Controls/RecommendationsContent.xaml.cs b/Dashboard/Controls/RecommendationsContent.xaml.cs index ca49f7c6f..8a06eea5c 100644 --- a/Dashboard/Controls/RecommendationsContent.xaml.cs +++ b/Dashboard/Controls/RecommendationsContent.xaml.cs @@ -13,6 +13,7 @@ using System.Windows; using System.Windows.Controls; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; using PerformanceMonitorDashboard.Analysis; using PerformanceMonitorDashboard.Helpers; using PerformanceMonitorDashboard.Interfaces; diff --git a/Dashboard/Helpers/MfaAuthenticationHelper.cs b/Dashboard/Helpers/MfaAuthenticationHelper.cs deleted file mode 100644 index 3f4b1efe3..000000000 --- a/Dashboard/Helpers/MfaAuthenticationHelper.cs +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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; - -namespace PerformanceMonitorDashboard.Helpers -{ - /// - /// Helper utilities for Microsoft Entra MFA authentication. - /// - public static class MfaAuthenticationHelper - { - /// - /// Checks if an exception indicates that the user cancelled MFA authentication. - /// - /// The exception to check. - /// True if the exception represents user cancellation, false otherwise. - public static bool IsMfaCancelledException(Exception ex) - { - var message = ex.Message?.ToLowerInvariant() ?? string.Empty; - - // Only treat explicit user cancellation messages as cancellation - // Do NOT treat authentication errors (wrong password, account selection, etc.) as cancellation - return message.Contains("user canceled") || - message.Contains("user cancelled") || - message.Contains("authentication was cancelled") || - message.Contains("authentication was canceled"); - } - } -} diff --git a/Dashboard/Services/AnalysisScheduler.cs b/Dashboard/Services/AnalysisScheduler.cs index b0d10558e..4e70e20af 100644 --- a/Dashboard/Services/AnalysisScheduler.cs +++ b/Dashboard/Services/AnalysisScheduler.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using System.Windows.Threading; using PerformanceMonitor.Analysis; +using PerformanceMonitor.Common; using PerformanceMonitor.Notifications; using PerformanceMonitorDashboard.Analysis; using PerformanceMonitorDashboard.Helpers; diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 306972857..015e3c2d9 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -1000,21 +1000,13 @@ protected static decimal SafeToDecimal(object value) } /// - /// Deterministic hash code for a string. .NET Core randomizes string.GetHashCode() - /// per process, so we use a simple FNV-1a hash to get a stable value across restarts. + /// Deterministic hash code for a string. Forwards to the shared + /// so Lite, + /// Dashboard, and the MCP paths all derive the same server id from a server name. Kept as a + /// thin internal wrapper to avoid churning the many existing call sites. /// - internal static int GetDeterministicHashCode(string value) - { - unchecked - { - var hash = (int)2166136261; - foreach (var c in value) - { - hash = (hash ^ c) * 16777619; - } - return hash; - } - } + internal static int GetDeterministicHashCode(string value) => + PerformanceMonitor.Common.ServerIdHelper.GetDeterministicHashCode(value); /// /// Checks if a collector is supported on the given SQL Server version and engine edition. diff --git a/Lite/Helpers/MfaAuthenticationHelper.cs b/PerformanceMonitor.Common/Helpers/MfaAuthenticationHelper.cs similarity index 90% rename from Lite/Helpers/MfaAuthenticationHelper.cs rename to PerformanceMonitor.Common/Helpers/MfaAuthenticationHelper.cs index dfedf948c..6a439da19 100644 --- a/Lite/Helpers/MfaAuthenticationHelper.cs +++ b/PerformanceMonitor.Common/Helpers/MfaAuthenticationHelper.cs @@ -1,14 +1,14 @@ /* * Copyright (c) 2026 Erik Darling, Darling Data LLC * - * This file is part of the SQL Server Performance Monitor Lite. + * 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; -namespace PerformanceMonitorLite.Helpers; +namespace PerformanceMonitor.Common; /// /// Helper utilities for Microsoft Entra MFA authentication. @@ -23,7 +23,7 @@ public static class MfaAuthenticationHelper public static bool IsMfaCancelledException(Exception ex) { var message = ex.Message?.ToLowerInvariant() ?? string.Empty; - + // Only treat explicit user cancellation messages as cancellation // Do NOT treat authentication errors (wrong password, account selection, etc.) as cancellation return message.Contains("user canceled") || diff --git a/Dashboard/Services/ServerIdHelper.cs b/PerformanceMonitor.Common/Services/ServerIdHelper.cs similarity index 54% rename from Dashboard/Services/ServerIdHelper.cs rename to PerformanceMonitor.Common/Services/ServerIdHelper.cs index 5c86bec70..5cce81825 100644 --- a/Dashboard/Services/ServerIdHelper.cs +++ b/PerformanceMonitor.Common/Services/ServerIdHelper.cs @@ -6,25 +6,25 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -namespace PerformanceMonitorDashboard.Services; +namespace PerformanceMonitor.Common; /// /// Deterministic int server-id derivation used by the analysis pipeline. /// /// /// string.GetHashCode() is randomized per process on .NET Core / .NET 10, -/// so persisted rows in config.analysis_findings and config.analysis_muted -/// would not match the next launch's value for the same server name. This helper -/// produces a stable FNV-1a hash so writes survive restart and are consistent across -/// the MCP entry points and any scheduled-analysis path. +/// so persisted rows in config.analysis_findings / config.analysis_muted +/// (Dashboard) and their DuckDB equivalents (Lite) would not match the next launch's +/// value for the same server name. This shared helper produces a stable FNV-1a hash so +/// writes survive restart and are consistent across Dashboard, Lite, the MCP entry +/// points, and any scheduled-analysis path. Both apps MUST use this one implementation +/// so they derive the same id for the same server name. /// /// -internal static class ServerIdHelper +public static class ServerIdHelper { /// - /// Process-independent FNV-1a hash of a string. Identical implementation to - /// Lite/Services/RemoteCollectorService.GetDeterministicHashCode so - /// Dashboard and Lite produce the same id for the same server name. + /// Process-independent FNV-1a hash of a string. /// public static int GetDeterministicHashCode(string value) { From c7e55416558b7ecaa20dc4731a867ae6d053b997 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:42:50 -0400 Subject: [PATCH 021/145] Share FactCollector pure helpers across Lite/Dashboard Second wave of the code-sharing follow-up. The Dashboard SqlServerFactCollector and Lite DuckDbFactCollector each carried byte-identical, data-source-agnostic fact-shaping helpers. Moved them to a single PerformanceMonitor.Analysis class (FactCollectorHelpers) so the two collectors can't drift: - EmitServerHealthFacts (WS5 IFI/LPIM/memory-dump advisories) + the LpimAdvisoryMinPhysicalMemoryMb RAM-floor const - GroupGeneralLockWaits / IsGeneralLockWait (LCK grouping) - GroupParallelismWaits (CX* grouping) These operate only on the already-collected Fact list + AnalysisContext, so no new project dependencies are needed (the helper lives in Analysis, where Fact and AnalysisContext already are). Both collectors now call FactCollectorHelpers.*. Pure refactor, no behavior change. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Analysis/SqlServerFactCollector.cs | 181 +--------------- Lite/Analysis/DuckDbFactCollector.cs | 181 +--------------- .../FactCollectorHelpers.cs | 201 ++++++++++++++++++ 3 files changed, 207 insertions(+), 356 deletions(-) create mode 100644 PerformanceMonitor.Analysis/FactCollectorHelpers.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs index 29cd1462d..fc92cf6d7 100644 --- a/Dashboard/Analysis/SqlServerFactCollector.cs +++ b/Dashboard/Analysis/SqlServerFactCollector.cs @@ -28,8 +28,8 @@ public async Task> CollectFactsAsync(AnalysisContext context) var facts = new List(); await CollectWaitStatsFactsAsync(context, facts); - GroupGeneralLockWaits(facts, context); - GroupParallelismWaits(facts, context); + FactCollectorHelpers.GroupGeneralLockWaits(facts, context); + FactCollectorHelpers.GroupParallelismWaits(facts, context); await CollectBlockingFactsAsync(context, facts); await CollectDeadlockFactsAsync(context, facts); await CollectServerConfigFactsAsync(context, facts); @@ -2094,7 +2094,7 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li // WS5 server-health advisories (advise-only). Gating lives here so a fact that would // score 0 is simply never emitted (noise control); the scorer then scores the emitted // fact's Value. Shared with Lite — keep the rules identical (see DuckDbFactCollector). - EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); } catch (Exception ex) { @@ -2102,71 +2102,6 @@ private async Task CollectServerPropertiesFactsAsync(AnalysisContext context, Li } } - // RAM floor below which LPIM-off is not worth flagging — on a small buffer pool the OS paging - // SQL out is not the practical risk it is on a large dedicated host. Shared rule with Lite. - private const long LpimAdvisoryMinPhysicalMemoryMb = 32 * 1024; - - /// - /// Emits the WS5 advise-only server-health facts (IFI off / LPIM off / memory dumps) from the - /// latest server_properties values, applying the noise-control gating both apps share: - /// • IFI: emit whenever the value is known (Value = enabled bit) — universally good advice. - /// • LPIM: emit only on non-Express editions with meaningful RAM (Value = enabled bit) — so a - /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). - /// • Dumps: emit whenever the count is known (Value = count) — the scorer flags count > 0. - /// - private static void EmitServerHealthFacts( - AnalysisContext context, List facts, string edition, long physicalMemMb, - bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount) - { - var isExpress = edition.Contains("Express", StringComparison.OrdinalIgnoreCase); - - if (instantFileInit.HasValue) - { - facts.Add(new Fact - { - Source = "config", - Key = "CONFIG_IFI_DISABLED", - Value = instantFileInit.Value ? 1 : 0, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["instant_file_initialization_enabled"] = instantFileInit.Value ? 1 : 0 - } - }); - } - - if (lockPagesInMemory.HasValue && !isExpress && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) - { - facts.Add(new Fact - { - Source = "config", - Key = "CONFIG_LPIM_DISABLED", - Value = lockPagesInMemory.Value ? 1 : 0, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["lock_pages_in_memory"] = lockPagesInMemory.Value ? 1 : 0, - ["physical_memory_mb"] = physicalMemMb - } - }); - } - - if (memoryDumpCount.HasValue) - { - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_MEMORY_DUMPS", - Value = memoryDumpCount.Value, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["memory_dump_count"] = memoryDumpCount.Value - } - }); - } - } - /// /// WS4: plan-XML advisories. Parses the already-collected query plans of the top queries by /// cost (no live fetch, no DMV) with the shared ShowPlanParser/PlanAnalyzer and emits two @@ -2317,114 +2252,4 @@ AND volume_total_mb > 0 } } - /// - /// Groups general lock waits (X, U, IX, SIX, BU, IU, UIX, etc.) into a single "LCK" fact. - /// Keeps individual facts for: - /// - LCK_M_S, LCK_M_IS (reader/writer blocking -- RCSI signal) - /// - LCK_M_RS_*, LCK_M_RIn_*, LCK_M_RX_* (serializable/repeatable read signal) - /// - SCH_M, SCH_S (schema locks -- DDL/index operations) - /// Individual constituent wait times are preserved in metadata as "{type}_ms" keys. - /// - private static void GroupGeneralLockWaits(List facts, AnalysisContext context) - { - var generalLocks = facts.Where(f => f.Source == "waits" && IsGeneralLockWait(f.Key)).ToList(); - if (generalLocks.Count == 0) return; - - var totalWaitTimeMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); - var totalWaitingTasks = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); - var totalSignalMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); - var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; - var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; - - var metadata = new Dictionary - { - ["wait_time_ms"] = totalWaitTimeMs, - ["waiting_tasks_count"] = totalWaitingTasks, - ["signal_wait_time_ms"] = totalSignalMs, - ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, - ["avg_ms_per_wait"] = avgMsPerWait, - ["period_duration_ms"] = context.PeriodDurationMs, - ["lock_type_count"] = generalLocks.Count - }; - - // Preserve individual constituent wait times for detailed analysis - foreach (var lck in generalLocks) - metadata[$"{lck.Key}_ms"] = lck.Metadata.GetValueOrDefault("wait_time_ms"); - - // Remove individual facts, add grouped fact - foreach (var lck in generalLocks) - facts.Remove(lck); - - facts.Add(new Fact - { - Source = "waits", - Key = "LCK", - Value = fractionOfPeriod, - ServerId = context.ServerId, - Metadata = metadata - }); - } - - /// - /// Groups all CX* parallelism waits (CXPACKET, CXCONSUMER, CXSYNC_PORT, CXSYNC_CONSUMER, etc.) - /// into a single "CXPACKET" fact. They all indicate the same thing: parallel queries are running. - /// Individual wait times are preserved in metadata for detailed analysis. - /// - private static void GroupParallelismWaits(List facts, AnalysisContext context) - { - var cxWaits = facts.Where(f => f.Source == "waits" && f.Key.StartsWith("CX", StringComparison.Ordinal)).ToList(); - if (cxWaits.Count <= 1) return; - - var totalWaitTimeMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); - var totalWaitingTasks = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); - var totalSignalMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); - var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; - var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; - - var metadata = new Dictionary - { - ["wait_time_ms"] = totalWaitTimeMs, - ["waiting_tasks_count"] = totalWaitingTasks, - ["signal_wait_time_ms"] = totalSignalMs, - ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, - ["avg_ms_per_wait"] = avgMsPerWait, - ["period_duration_ms"] = context.PeriodDurationMs - }; - - // Preserve individual constituent wait times for detailed analysis - foreach (var cx in cxWaits) - metadata[$"{cx.Key}_ms"] = cx.Metadata.GetValueOrDefault("wait_time_ms"); - - foreach (var cx in cxWaits) - facts.Remove(cx); - - facts.Add(new Fact - { - Source = "waits", - Key = "CXPACKET", - Value = fractionOfPeriod, - ServerId = cxWaits[0].ServerId, - Metadata = metadata - }); - } - - /// - /// Returns true for general lock waits that should be grouped into "LCK". - /// Excludes reader locks (S, IS), range locks (RS_*, RIn_*, RX_*), and schema locks. - /// - private static bool IsGeneralLockWait(string waitType) - { - if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false; - - // Keep individual: reader/writer locks - if (waitType is "LCK_M_S" or "LCK_M_IS") return false; - - // Keep individual: range locks (serializable/repeatable read) - if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) || - waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) || - waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false; - - // Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) -> group - return true; - } } diff --git a/Lite/Analysis/DuckDbFactCollector.cs b/Lite/Analysis/DuckDbFactCollector.cs index ca9a0bf72..6b7ed35a1 100644 --- a/Lite/Analysis/DuckDbFactCollector.cs +++ b/Lite/Analysis/DuckDbFactCollector.cs @@ -28,8 +28,8 @@ public async Task> CollectFactsAsync(AnalysisContext context) var facts = new List(); await CollectWaitStatsFactsAsync(context, facts); - GroupGeneralLockWaits(facts, context); - GroupParallelismWaits(facts, context); + FactCollectorHelpers.GroupGeneralLockWaits(facts, context); + FactCollectorHelpers.GroupParallelismWaits(facts, context); await CollectBlockingFactsAsync(context, facts); await CollectBlockingChainFactsAsync(context, facts); await CollectDeadlockFactsAsync(context, facts); @@ -1891,75 +1891,11 @@ ORDER BY collection_time DESC // WS5 server-health advisories (advise-only). Gating mirrors the Dashboard collector so // both apps agree on what is worth flagging; a fact that would score 0 is simply never // emitted (noise control). - EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); + FactCollectorHelpers.EmitServerHealthFacts(context, facts, edition, physicalMemMb, lpim, ifi, dumpCount); } catch { /* Table may not exist or have no data */ } } - // RAM floor below which LPIM-off is not worth flagging — shared rule with the Dashboard - // SqlServerFactCollector (small buffer pools do not suffer from OS paging the way large ones do). - private const long LpimAdvisoryMinPhysicalMemoryMb = 32 * 1024; - - /// - /// Emits the WS5 advise-only server-health facts (IFI off / LPIM off / memory dumps) from the - /// latest server_properties values, applying the same noise-control gating as the Dashboard: - /// • IFI: emit whenever the value is known (Value = enabled bit) — universally good advice. - /// • LPIM: emit only on non-Express editions with meaningful RAM (Value = enabled bit). - /// • Dumps: emit whenever the count is known (Value = count) — the scorer flags count > 0. - /// - private static void EmitServerHealthFacts( - AnalysisContext context, List facts, string edition, long physicalMemMb, - bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount) - { - var isExpress = edition.Contains("Express", StringComparison.OrdinalIgnoreCase); - - if (instantFileInit.HasValue) - { - facts.Add(new Fact - { - Source = "config", - Key = "CONFIG_IFI_DISABLED", - Value = instantFileInit.Value ? 1 : 0, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["instant_file_initialization_enabled"] = instantFileInit.Value ? 1 : 0 - } - }); - } - - if (lockPagesInMemory.HasValue && !isExpress && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) - { - facts.Add(new Fact - { - Source = "config", - Key = "CONFIG_LPIM_DISABLED", - Value = lockPagesInMemory.Value ? 1 : 0, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["lock_pages_in_memory"] = lockPagesInMemory.Value ? 1 : 0, - ["physical_memory_mb"] = physicalMemMb - } - }); - } - - if (memoryDumpCount.HasValue) - { - facts.Add(new Fact - { - Source = "config", - Key = "SERVER_MEMORY_DUMPS", - Value = memoryDumpCount.Value, - ServerId = context.ServerId, - Metadata = new Dictionary - { - ["memory_dump_count"] = memoryDumpCount.Value - } - }); - } - } - /// /// WS4: plan-XML advisories. Parses the already-collected query plans of the top queries by /// cost with the shared ShowPlanParser/PlanAnalyzer and emits two advise-only facts — @@ -2106,117 +2042,6 @@ AND volume_total_mb > 0 catch { /* Table may not exist or have no data */ } } - /// - /// Groups general lock waits (X, U, IX, SIX, BU, IU, UIX, etc.) into a single "LCK" fact. - /// Keeps individual facts for: - /// - LCK_M_S, LCK_M_IS (reader/writer blocking — RCSI signal) - /// - LCK_M_RS_*, LCK_M_RIn_*, LCK_M_RX_* (serializable/repeatable read signal) - /// - SCH_M, SCH_S (schema locks — DDL/index operations) - /// Individual constituent wait times are preserved in metadata as "{type}_ms" keys. - /// - private static void GroupGeneralLockWaits(List facts, AnalysisContext context) - { - var generalLocks = facts.Where(f => f.Source == "waits" && IsGeneralLockWait(f.Key)).ToList(); - if (generalLocks.Count == 0) return; - - var totalWaitTimeMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); - var totalWaitingTasks = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); - var totalSignalMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); - var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; - var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; - - var metadata = new Dictionary - { - ["wait_time_ms"] = totalWaitTimeMs, - ["waiting_tasks_count"] = totalWaitingTasks, - ["signal_wait_time_ms"] = totalSignalMs, - ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, - ["avg_ms_per_wait"] = avgMsPerWait, - ["period_duration_ms"] = context.PeriodDurationMs, - ["lock_type_count"] = generalLocks.Count - }; - - // Preserve individual constituent wait times for detailed analysis - foreach (var lck in generalLocks) - metadata[$"{lck.Key}_ms"] = lck.Metadata.GetValueOrDefault("wait_time_ms"); - - // Remove individual facts, add grouped fact - foreach (var lck in generalLocks) - facts.Remove(lck); - - facts.Add(new Fact - { - Source = "waits", - Key = "LCK", - Value = fractionOfPeriod, - ServerId = context.ServerId, - Metadata = metadata - }); - } - - /// - /// Groups all CX* parallelism waits (CXPACKET, CXCONSUMER, CXSYNC_PORT, CXSYNC_CONSUMER, etc.) - /// into a single "CXPACKET" fact. They all indicate the same thing: parallel queries are running. - /// Individual wait times are preserved in metadata for detailed analysis. - /// - private static void GroupParallelismWaits(List facts, AnalysisContext context) - { - var cxWaits = facts.Where(f => f.Source == "waits" && f.Key.StartsWith("CX", StringComparison.Ordinal)).ToList(); - if (cxWaits.Count <= 1) return; - - var totalWaitTimeMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); - var totalWaitingTasks = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); - var totalSignalMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); - var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; - var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; - - var metadata = new Dictionary - { - ["wait_time_ms"] = totalWaitTimeMs, - ["waiting_tasks_count"] = totalWaitingTasks, - ["signal_wait_time_ms"] = totalSignalMs, - ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, - ["avg_ms_per_wait"] = avgMsPerWait, - ["period_duration_ms"] = context.PeriodDurationMs - }; - - // Preserve individual constituent wait times for detailed analysis - foreach (var cx in cxWaits) - metadata[$"{cx.Key}_ms"] = cx.Metadata.GetValueOrDefault("wait_time_ms"); - - foreach (var cx in cxWaits) - facts.Remove(cx); - - facts.Add(new Fact - { - Source = "waits", - Key = "CXPACKET", - Value = fractionOfPeriod, - ServerId = cxWaits[0].ServerId, - Metadata = metadata - }); - } - - /// - /// Returns true for general lock waits that should be grouped into "LCK". - /// Excludes reader locks (S, IS), range locks (RS_*, RIn_*, RX_*), and schema locks. - /// - private static bool IsGeneralLockWait(string waitType) - { - if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false; - - // Keep individual: reader/writer locks - if (waitType is "LCK_M_S" or "LCK_M_IS") return false; - - // Keep individual: range locks (serializable/repeatable read) - if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) || - waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) || - waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false; - - // Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) → group - return true; - } - private static long ToInt64(object value) { if (value is BigInteger bi) diff --git a/PerformanceMonitor.Analysis/FactCollectorHelpers.cs b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs new file mode 100644 index 000000000..001b45758 --- /dev/null +++ b/PerformanceMonitor.Analysis/FactCollectorHelpers.cs @@ -0,0 +1,201 @@ +/* + * 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 PerformanceMonitor.Analysis; + +/// +/// Pure, data-source-agnostic fact-shaping helpers shared by the per-app fact collectors +/// (Dashboard's SqlServerFactCollector and Lite's DuckDbFactCollector). These operate only on the +/// already-collected list plus , so both apps emit +/// and group facts identically regardless of where the underlying data was read from. Keeping one +/// copy here prevents the two collectors from drifting apart. +/// +public static class FactCollectorHelpers +{ + /// + /// RAM floor below which LPIM-off is not worth flagging — on a small buffer pool the OS paging + /// SQL out is not the practical risk it is on a large dedicated host. + /// + public const long LpimAdvisoryMinPhysicalMemoryMb = 32 * 1024; + + /// + /// Emits the WS5 advise-only server-health facts (IFI off / LPIM off / memory dumps) from the + /// latest server_properties values, applying the noise-control gating both apps share: + /// • IFI: emit whenever the value is known (Value = enabled bit) — universally good advice. + /// • LPIM: emit only on non-Express editions with meaningful RAM (Value = enabled bit) — so a + /// tiny instance never flags. When LPIM is ON the emitted Value scores 0 (harmless). + /// • Dumps: emit whenever the count is known (Value = count) — the scorer flags count > 0. + /// + public static void EmitServerHealthFacts( + AnalysisContext context, List facts, string edition, long physicalMemMb, + bool? lockPagesInMemory, bool? instantFileInit, int? memoryDumpCount) + { + var isExpress = edition.Contains("Express", StringComparison.OrdinalIgnoreCase); + + if (instantFileInit.HasValue) + { + facts.Add(new Fact + { + Source = "config", + Key = "CONFIG_IFI_DISABLED", + Value = instantFileInit.Value ? 1 : 0, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["instant_file_initialization_enabled"] = instantFileInit.Value ? 1 : 0 + } + }); + } + + if (lockPagesInMemory.HasValue && !isExpress && physicalMemMb >= LpimAdvisoryMinPhysicalMemoryMb) + { + facts.Add(new Fact + { + Source = "config", + Key = "CONFIG_LPIM_DISABLED", + Value = lockPagesInMemory.Value ? 1 : 0, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["lock_pages_in_memory"] = lockPagesInMemory.Value ? 1 : 0, + ["physical_memory_mb"] = physicalMemMb + } + }); + } + + if (memoryDumpCount.HasValue) + { + facts.Add(new Fact + { + Source = "config", + Key = "SERVER_MEMORY_DUMPS", + Value = memoryDumpCount.Value, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["memory_dump_count"] = memoryDumpCount.Value + } + }); + } + } + + /// + /// Groups general lock waits (X, U, IX, SIX, BU, IU, UIX, etc.) into a single "LCK" fact. + /// Keeps individual facts for: + /// - LCK_M_S, LCK_M_IS (reader/writer blocking — RCSI signal) + /// - LCK_M_RS_*, LCK_M_RIn_*, LCK_M_RX_* (serializable/repeatable read signal) + /// - SCH_M, SCH_S (schema locks — DDL/index operations) + /// Individual constituent wait times are preserved in metadata as "{type}_ms" keys. + /// + public static void GroupGeneralLockWaits(List facts, AnalysisContext context) + { + var generalLocks = facts.Where(f => f.Source == "waits" && IsGeneralLockWait(f.Key)).ToList(); + if (generalLocks.Count == 0) return; + + var totalWaitTimeMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); + var totalWaitingTasks = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); + var totalSignalMs = generalLocks.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); + var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; + var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; + + var metadata = new Dictionary + { + ["wait_time_ms"] = totalWaitTimeMs, + ["waiting_tasks_count"] = totalWaitingTasks, + ["signal_wait_time_ms"] = totalSignalMs, + ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, + ["avg_ms_per_wait"] = avgMsPerWait, + ["period_duration_ms"] = context.PeriodDurationMs, + ["lock_type_count"] = generalLocks.Count + }; + + // Preserve individual constituent wait times for detailed analysis + foreach (var lck in generalLocks) + metadata[$"{lck.Key}_ms"] = lck.Metadata.GetValueOrDefault("wait_time_ms"); + + // Remove individual facts, add grouped fact + foreach (var lck in generalLocks) + facts.Remove(lck); + + facts.Add(new Fact + { + Source = "waits", + Key = "LCK", + Value = fractionOfPeriod, + ServerId = context.ServerId, + Metadata = metadata + }); + } + + /// + /// Groups all CX* parallelism waits (CXPACKET, CXCONSUMER, CXSYNC_PORT, CXSYNC_CONSUMER, etc.) + /// into a single "CXPACKET" fact. They all indicate the same thing: parallel queries are running. + /// Individual wait times are preserved in metadata for detailed analysis. + /// + public static void GroupParallelismWaits(List facts, AnalysisContext context) + { + var cxWaits = facts.Where(f => f.Source == "waits" && f.Key.StartsWith("CX", StringComparison.Ordinal)).ToList(); + if (cxWaits.Count <= 1) return; + + var totalWaitTimeMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("wait_time_ms")); + var totalWaitingTasks = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("waiting_tasks_count")); + var totalSignalMs = cxWaits.Sum(f => f.Metadata.GetValueOrDefault("signal_wait_time_ms")); + var avgMsPerWait = totalWaitingTasks > 0 ? totalWaitTimeMs / totalWaitingTasks : 0; + var fractionOfPeriod = totalWaitTimeMs / context.PeriodDurationMs; + + var metadata = new Dictionary + { + ["wait_time_ms"] = totalWaitTimeMs, + ["waiting_tasks_count"] = totalWaitingTasks, + ["signal_wait_time_ms"] = totalSignalMs, + ["resource_wait_time_ms"] = totalWaitTimeMs - totalSignalMs, + ["avg_ms_per_wait"] = avgMsPerWait, + ["period_duration_ms"] = context.PeriodDurationMs + }; + + // Preserve individual constituent wait times for detailed analysis + foreach (var cx in cxWaits) + metadata[$"{cx.Key}_ms"] = cx.Metadata.GetValueOrDefault("wait_time_ms"); + + foreach (var cx in cxWaits) + facts.Remove(cx); + + facts.Add(new Fact + { + Source = "waits", + Key = "CXPACKET", + Value = fractionOfPeriod, + ServerId = cxWaits[0].ServerId, + Metadata = metadata + }); + } + + /// + /// Returns true for general lock waits that should be grouped into "LCK". + /// Excludes reader locks (S, IS), range locks (RS_*, RIn_*, RX_*), and schema locks. + /// + private static bool IsGeneralLockWait(string waitType) + { + if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false; + + // Keep individual: reader/writer locks + if (waitType is "LCK_M_S" or "LCK_M_IS") return false; + + // Keep individual: range locks (serializable/repeatable read) + if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) || + waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) || + waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false; + + // Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) -> group + return true; + } +} From 602fa8c491db004a1e2910af15b3640d8204a7c5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:51:45 -0400 Subject: [PATCH 022/145] Stop integration tests from leaking orphaned Agent jobs on real instances The IdempotencyTests and AdversarialTests run the real install/*.sql against a PerformanceMonitor_Test database on the live SQL2022 instance (per TestDatabaseHelper) after RewriteForTestDatabase(). That rewrite repoints @database_name = N'PerformanceMonitor' -> N'PerformanceMonitor_Test' but does NOT rewrite the job names N'PerformanceMonitor - Collection' (no closing quote after the word), so 45_create_agent_jobs.sql created jobs with PRODUCTION names whose step pointed at the test DB -- and its drop-by-name logic clobbered the real install's jobs. Teardown drops only the _Test database, leaving the 3 Agent jobs (and the server-level XE sessions + sp_configure from 21_setup_blocked_process_xe.sql) orphaned and failing every minute with "Unable to connect to SQL Server '(local)'". This recurred after every release because release-checklist 4b runs these tests locally; CI excludes them so it never surfaced there. Exclude the two instance-scoped scripts (21_setup_blocked_process_xe.sql and 45_create_agent_jobs.sql) from GetFilteredInstallFiles in both test classes, mirroring the existing 00_/97_/99_ exclusion. Their errors were already swallowed by IsExpectedTestFailure, so this loses no real coverage while removing the destructive server-scoped side effect and the production-job collision. Verified: 19/19 integration tests pass against SQL2022, and after a full test run the real PerformanceMonitor jobs still point at PerformanceMonitor (previously they would have been clobbered to _Test and orphaned). Co-Authored-By: Claude Opus 4.8 (1M context) --- Installer.Tests/AdversarialTests.cs | 10 +++++++++- Installer.Tests/IdempotencyTests.cs | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Installer.Tests/AdversarialTests.cs b/Installer.Tests/AdversarialTests.cs index 2db7e8488..5d956836e 100644 --- a/Installer.Tests/AdversarialTests.cs +++ b/Installer.Tests/AdversarialTests.cs @@ -510,9 +510,17 @@ private static List GetFilteredInstallFiles(string installDir) { var name = Path.GetFileName(f); if (!pattern.IsMatch(name)) return false; + // Server-scoped scripts create instance-wide objects (SQL Agent jobs, + // server-level XE sessions, sp_configure) that can't be namespaced to the + // test database. Run against a real instance they leak orphaned jobs/XE + // sessions and clobber a production install's identically-named jobs. Their + // errors are already swallowed by IsExpectedTestFailure, so excluding them + // loses no real coverage. if (name.StartsWith("00_", StringComparison.Ordinal) || name.StartsWith("97_", StringComparison.Ordinal) || - name.StartsWith("99_", StringComparison.Ordinal)) + name.StartsWith("99_", StringComparison.Ordinal) || + name.Equals("21_setup_blocked_process_xe.sql", StringComparison.Ordinal) || + name.Equals("45_create_agent_jobs.sql", StringComparison.Ordinal)) return false; return true; }) diff --git a/Installer.Tests/IdempotencyTests.cs b/Installer.Tests/IdempotencyTests.cs index 84db0bdc7..a31ab3d82 100644 --- a/Installer.Tests/IdempotencyTests.cs +++ b/Installer.Tests/IdempotencyTests.cs @@ -71,9 +71,17 @@ private static List GetFilteredInstallFiles(string installDir) { var name = Path.GetFileName(f); if (!pattern.IsMatch(name)) return false; + // Server-scoped scripts create instance-wide objects (SQL Agent jobs, + // server-level XE sessions, sp_configure) that can't be namespaced to the + // test database. Run against a real instance they leak orphaned jobs/XE + // sessions and clobber a production install's identically-named jobs. Their + // errors are already swallowed by IsExpectedTestFailure, so excluding them + // loses no real coverage. if (name.StartsWith("00_", StringComparison.Ordinal) || name.StartsWith("97_", StringComparison.Ordinal) || - name.StartsWith("99_", StringComparison.Ordinal)) + name.StartsWith("99_", StringComparison.Ordinal) || + name.Equals("21_setup_blocked_process_xe.sql", StringComparison.Ordinal) || + name.Equals("45_create_agent_jobs.sql", StringComparison.Ordinal)) return false; return true; }) From f0cf256ee363ae44874708017ee95e8b18f5a9bb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:20:21 -0400 Subject: [PATCH 023/145] Share ServerConnectionStatus across Lite/Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third wave of the code-sharing follow-up. The Dashboard and Lite ServerConnectionStatus models were near-identical — all shared fields plus the four display getters (StatusText/StatusIcon/LastCheckedDisplay/ StatusDurationDisplay) were byte-identical; the only divergence was additive (Dashboard had InstalledMonitorVersion; Lite had SqlServerVersion/ SqlMajorVersion/HasMsdbAccess). Unioned them into one PerformanceMonitor.Common.ServerConnectionStatus; each app simply leaves the fields it doesn't populate at their defaults. Both app copies deleted; referencing files repoint to Common (added the using to IServerManager + ServerListItem; ServerManager/RemoteCollectorService already imported Common). Pure refactor, no behavior change. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Interfaces/IServerManager.cs | 1 + Dashboard/Models/ServerConnectionStatus.cs | 170 ------------------ Dashboard/Models/ServerListItem.cs | 1 + .../Models/ServerConnectionStatus.cs | 32 ++-- 4 files changed, 23 insertions(+), 181 deletions(-) delete mode 100644 Dashboard/Models/ServerConnectionStatus.cs rename {Lite => PerformanceMonitor.Common}/Models/ServerConnectionStatus.cs (79%) diff --git a/Dashboard/Interfaces/IServerManager.cs b/Dashboard/Interfaces/IServerManager.cs index d8d618308..a134a60a6 100644 --- a/Dashboard/Interfaces/IServerManager.cs +++ b/Dashboard/Interfaces/IServerManager.cs @@ -8,6 +8,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using PerformanceMonitor.Common; using PerformanceMonitorDashboard.Models; namespace PerformanceMonitorDashboard.Interfaces diff --git a/Dashboard/Models/ServerConnectionStatus.cs b/Dashboard/Models/ServerConnectionStatus.cs deleted file mode 100644 index 2bdca9acf..000000000 --- a/Dashboard/Models/ServerConnectionStatus.cs +++ /dev/null @@ -1,170 +0,0 @@ -/* - * 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; - -namespace PerformanceMonitorDashboard.Models -{ - /// - /// Represents the runtime connection status of a server. - /// This is transient state that is not persisted to disk. - /// - public class ServerConnectionStatus - { - /// - /// The server ID this status belongs to. - /// - public string ServerId { get; set; } = string.Empty; - - /// - /// Whether the server is currently reachable. - /// Null means status has not been checked yet. - /// - public bool? IsOnline { get; set; } - - /// - /// The last time connectivity was checked. - /// Null means status has never been checked. - /// - public DateTime? LastChecked { get; set; } - - /// - /// The time when the status last changed (online to offline or vice versa). - /// Used to show "Online since X" or "Offline for X". - /// - public DateTime? StatusChangedAt { get; set; } - - /// - /// The previous online status, used to detect status changes. - /// - public bool? PreviousIsOnline { get; set; } - - /// - /// Error message if the connection failed. - /// Null if online or not yet checked. - /// - public string? ErrorMessage { get; set; } - - /// - /// The SQL Server start time, queried from sys.dm_os_sys_info. - /// Only populated when server is online. - /// - public DateTime? ServerStartTime { get; set; } - - /// - /// SQL Server engine edition from SERVERPROPERTY('EngineEdition'). - /// 5=Azure SQL DB (unsupported), 8=Azure MI (supported). - /// - public int SqlEngineEdition { get; set; } - - /// - /// Whether this server is an AWS RDS instance (detected by presence of rdsadmin database). - /// Used for gating features that require msdb permissions unavailable on RDS. - /// - public bool IsAwsRds { get; set; } - - /// - /// Whether the user cancelled MFA authentication for this server. - /// When true, background connectivity checks are skipped to avoid repeated authentication popups. - /// - public bool UserCancelledMfa { get; set; } - - /// - /// The server's UTC offset in minutes, queried via DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()). - /// Used to convert UTC-stored collection_time values to server-local time for display. - /// - public int? UtcOffsetMinutes { get; set; } - - /// - /// The installed PerformanceMonitor version on the server (e.g., "2.5.0"). - /// Null if the PerformanceMonitor database is not installed. - /// - public string? InstalledMonitorVersion { get; set; } - - /// - /// Gets the status display text for the UI. - /// - public string StatusText - { - get - { - if (!LastChecked.HasValue) - return "Not checked"; - - if (IsOnline == true) - return "Online"; - - return "Offline"; - } - } - - /// - /// Gets the status icon for the UI (checkmark or X). - /// - public string StatusIcon - { - get - { - if (!LastChecked.HasValue) - return "?"; - - return IsOnline == true ? "\u2713" : "\u2717"; // ✓ or ✗ - } - } - - /// - /// Gets the formatted "last checked" time for display. - /// - public string LastCheckedDisplay - { - get - { - if (!LastChecked.HasValue) - return "Never checked"; - - var elapsed = DateTime.Now - LastChecked.Value; - - if (elapsed.TotalSeconds < 60) - return "Checked just now"; - - if (elapsed.TotalMinutes < 60) - return $"Checked {(int)elapsed.TotalMinutes}m ago"; - - if (elapsed.TotalHours < 24) - return $"Checked {(int)elapsed.TotalHours}h ago"; - - return $"Checked {LastChecked.Value.ToString("g")}"; - } - } - - /// - /// Gets the status duration display. - /// For online: "Online since [server start time]" - /// For offline: Just "Offline" - /// - public string StatusDurationDisplay - { - get - { - if (!IsOnline.HasValue) - return string.Empty; - - if (IsOnline == true) - { - if (ServerStartTime.HasValue) - { - return $"Online since {ServerStartTime.Value.ToString("g")}"; - } - return "Online"; - } - - return "Offline"; - } - } - } -} diff --git a/Dashboard/Models/ServerListItem.cs b/Dashboard/Models/ServerListItem.cs index 09bdf2524..23f73a9dc 100644 --- a/Dashboard/Models/ServerListItem.cs +++ b/Dashboard/Models/ServerListItem.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; +using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard.Models { diff --git a/Lite/Models/ServerConnectionStatus.cs b/PerformanceMonitor.Common/Models/ServerConnectionStatus.cs similarity index 79% rename from Lite/Models/ServerConnectionStatus.cs rename to PerformanceMonitor.Common/Models/ServerConnectionStatus.cs index b786f70af..3d2550a8d 100644 --- a/Lite/Models/ServerConnectionStatus.cs +++ b/PerformanceMonitor.Common/Models/ServerConnectionStatus.cs @@ -1,18 +1,23 @@ /* * Copyright (c) 2026 Erik Darling, Darling Data LLC * - * This file is part of the SQL Server Performance Monitor Lite. + * 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; -namespace PerformanceMonitorLite.Models; +namespace PerformanceMonitor.Common; /// /// Represents the runtime connection status of a server. /// This is transient state that is not persisted to disk. +/// +/// Shared by Lite and Dashboard. A few fields are only populated by one app (Dashboard sets +/// ; Lite sets , +/// , and ) — the other app simply leaves +/// them at their defaults. /// public class ServerConnectionStatus { @@ -57,13 +62,12 @@ public class ServerConnectionStatus public DateTime? ServerStartTime { get; set; } /// - /// The SQL Server version string. - /// Only populated when server is online. + /// The SQL Server version string. (Lite) Only populated when server is online. /// public string? SqlServerVersion { get; set; } /// - /// SQL Server major product version (e.g., 13 = 2016, 14 = 2017, 15 = 2019, 16 = 2022). + /// SQL Server major product version (e.g., 13 = 2016, 14 = 2017, 15 = 2019, 16 = 2022). (Lite) /// Used for version-gating collectors that require specific DMV columns. /// public int SqlMajorVersion { get; set; } @@ -77,25 +81,31 @@ public class ServerConnectionStatus /// /// Whether this server is an AWS RDS instance (detected by presence of rdsadmin database). - /// Used for gating collectors that require msdb permissions unavailable on RDS. + /// Used for gating features that require msdb permissions unavailable on RDS. /// public bool IsAwsRds { get; set; } /// - /// Whether the connected login has access to msdb. + /// Whether the connected login has access to msdb. (Lite) /// Used for gating collectors that query msdb system tables (e.g., running jobs). /// public bool HasMsdbAccess { get; set; } = true; + /// + /// The installed PerformanceMonitor version on the server (e.g., "2.5.0"). (Dashboard) + /// Null if the PerformanceMonitor database is not installed. + /// + public string? InstalledMonitorVersion { get; set; } + /// /// The server's UTC offset in minutes, queried via DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()). - /// Used to convert UTC collection_time values to server-local time for display. + /// Used to convert UTC-stored collection_time values to server-local time for display. /// public int? UtcOffsetMinutes { get; set; } /// - /// Indicates whether the user has cancelled MFA authentication for this server. - /// When true, MFA popups will not be shown until the user explicitly tries to connect again. + /// Whether the user cancelled MFA authentication for this server. + /// When true, background connectivity checks are skipped to avoid repeated authentication popups. /// public bool UserCancelledMfa { get; set; } @@ -126,7 +136,7 @@ public string StatusIcon if (!LastChecked.HasValue) return "?"; - return IsOnline == true ? "\u2713" : "\u2717"; // checkmark or X + return IsOnline == true ? "✓" : "✗"; // checkmark or X } } From a5c1f5eae71006b37083bf28316522131254bb4b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:37:16 -0400 Subject: [PATCH 024/145] Share DataGrid column-filter matcher across Lite/Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth wave of the code-sharing follow-up. The operator-based ColumnFilterState matcher (MatchesFilter + IsValueEmpty/CompareValues/CompareNumeric/ TryParseNumeric) was byte-identical in Lite's and Dashboard's DataGridFilterService. Extracted it to PerformanceMonitor.Common.ColumnFilterMatcher (pure reflection + value comparison, no WPF dependency). Both apps' DataGridFilterService.MatchesFilter(item, filter) is now a one-line forwarder to the shared matcher, so the ~28 Dashboard + Lite call sites are untouched. Dashboard keeps its app-specific extras (the string-overload MatchesFilter, ApplyFilter, NumericTypes/DateTypes, property cache); Lite's service collapses to the single forwarder. DataGridFilterManager is intentionally NOT touched here — it carries a behavioral diff (Lite preserves sort on filter, Dashboard doesn't), which is a reconciliation decision for a separate change. Pure refactor, no behavior change. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Services/DataGridFilterService.cs | 120 +--------------- Lite/Services/DataGridFilterService.cs | 109 +-------------- .../Services/ColumnFilterMatcher.cs | 131 ++++++++++++++++++ 3 files changed, 137 insertions(+), 223 deletions(-) create mode 100644 PerformanceMonitor.Common/Services/ColumnFilterMatcher.cs diff --git a/Dashboard/Services/DataGridFilterService.cs b/Dashboard/Services/DataGridFilterService.cs index 1a93849e2..c441f8a13 100644 --- a/Dashboard/Services/DataGridFilterService.cs +++ b/Dashboard/Services/DataGridFilterService.cs @@ -9,7 +9,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Reflection; using System.Windows.Controls; @@ -99,123 +98,8 @@ public static bool MatchesFilter(object item, string propertyName, string filter /// The data item to check /// The filter state containing operator and value /// True if the value matches the filter, false otherwise - public static bool MatchesFilter(object item, ColumnFilterState filter) - { - if (item == null || filter == null || !filter.IsActive) - return true; - - var property = item.GetType().GetProperty(filter.ColumnName); - if (property == null) - return true; - - var rawValue = property.GetValue(item); - - // Handle IsEmpty/IsNotEmpty operators first - if (filter.Operator == FilterOperator.IsEmpty) - return IsValueEmpty(rawValue); - if (filter.Operator == FilterOperator.IsNotEmpty) - return !IsValueEmpty(rawValue); - - var stringValue = rawValue?.ToString() ?? string.Empty; - var filterValue = filter.Value ?? string.Empty; - - // Split comma-separated values for text operators (e.g., "db1, db2") - var terms = filterValue.Split(',') - .Select(t => t.Trim()) - .Where(t => !string.IsNullOrEmpty(t)) - .ToArray(); - - if (terms.Length == 0) - return true; - - // Text operators match ANY term, NotEquals excludes ALL terms - return filter.Operator switch - { - FilterOperator.Contains => terms.Any(t => stringValue.Contains(t, StringComparison.OrdinalIgnoreCase)), - FilterOperator.Equals => terms.Any(t => CompareValues(rawValue, t, (a, b) => a == b)), - FilterOperator.NotEquals => terms.All(t => CompareValues(rawValue, t, (a, b) => a != b)), - FilterOperator.GreaterThan => CompareNumeric(rawValue, filterValue, (a, b) => a > b), - FilterOperator.GreaterThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a >= b), - FilterOperator.LessThan => CompareNumeric(rawValue, filterValue, (a, b) => a < b), - FilterOperator.LessThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a <= b), - FilterOperator.StartsWith => terms.Any(t => stringValue.StartsWith(t, StringComparison.OrdinalIgnoreCase)), - FilterOperator.EndsWith => terms.Any(t => stringValue.EndsWith(t, StringComparison.OrdinalIgnoreCase)), - _ => true - }; - } - - /// - /// Checks if a value is considered empty (null, empty string, or whitespace). - /// - private static bool IsValueEmpty(object? value) - { - if (value == null) - return true; - if (value is string str) - return string.IsNullOrWhiteSpace(str); - return false; - } - - /// - /// Compares values with case-insensitive string comparison for non-numeric types. - /// - private static bool CompareValues(object? rawValue, string filterValue, Func comparison) - { - if (rawValue == null) - return comparison(0, 1); // null is "less than" any value - - var stringValue = rawValue.ToString() ?? string.Empty; - - // Try numeric comparison first - if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) - { - return comparison(numericValue.CompareTo(filterNumeric), 0); - } - - // Fall back to string comparison - return comparison(string.Compare(stringValue, filterValue, StringComparison.OrdinalIgnoreCase), 0); - } - - /// - /// Compares values numerically. Returns true if the value can't be parsed (non-numeric values pass through). - /// - private static bool CompareNumeric(object? rawValue, string filterValue, Func comparison) - { - if (rawValue == null) - return false; - - var stringValue = rawValue.ToString() ?? string.Empty; - - // Try to parse both values as decimals - if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) - { - return comparison(numericValue, filterNumeric); - } - - // If we can't parse both as numbers, the filter doesn't match - return false; - } - - /// - /// Attempts to parse a string as a decimal, handling various formats. - /// - private static bool TryParseNumeric(string value, out decimal result) - { - if (string.IsNullOrWhiteSpace(value)) - { - result = 0; - return false; - } - - // Remove common formatting characters (commas, percent signs, currency symbols) - var cleanValue = value.Trim() - .Replace(",", "") - .Replace("%", "") - .Replace("$", "") - .Replace(" ", ""); - - return decimal.TryParse(cleanValue, NumberStyles.Any, CultureInfo.InvariantCulture, out result); - } + public static bool MatchesFilter(object item, ColumnFilterState filter) => + ColumnFilterMatcher.MatchesFilter(item, filter); /// /// Applies a column-based filter to a DataGrid. diff --git a/Lite/Services/DataGridFilterService.cs b/Lite/Services/DataGridFilterService.cs index c00168086..4bd9f5140 100644 --- a/Lite/Services/DataGridFilterService.cs +++ b/Lite/Services/DataGridFilterService.cs @@ -6,120 +6,19 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Globalization; -using System.Linq; -using PerformanceMonitorLite.Models; using PerformanceMonitor.Common; namespace PerformanceMonitorLite.Services; /// -/// Provides operator-based filtering for DataGrid column filters. +/// Provides operator-based filtering for DataGrid column filters. Forwards to the shared +/// so Lite and Dashboard share one matching implementation. /// public static class DataGridFilterService { /// /// Checks if an item matches a ColumnFilterState using operator-based filtering. /// - public static bool MatchesFilter(object item, ColumnFilterState filter) - { - if (item == null || filter == null || !filter.IsActive) - return true; - - var property = item.GetType().GetProperty(filter.ColumnName); - if (property == null) - return true; - - var rawValue = property.GetValue(item); - - /* Handle IsEmpty/IsNotEmpty operators first */ - if (filter.Operator == FilterOperator.IsEmpty) - return IsValueEmpty(rawValue); - if (filter.Operator == FilterOperator.IsNotEmpty) - return !IsValueEmpty(rawValue); - - var stringValue = rawValue?.ToString() ?? string.Empty; - var filterValue = filter.Value ?? string.Empty; - - /* Split comma-separated values for text operators (e.g., "db1, db2") */ - var terms = filterValue.Split(',') - .Select(t => t.Trim()) - .Where(t => !string.IsNullOrEmpty(t)) - .ToArray(); - - if (terms.Length == 0) - return true; - - /* Text operators match ANY term, NotEquals excludes ALL terms */ - return filter.Operator switch - { - FilterOperator.Contains => terms.Any(t => stringValue.Contains(t, StringComparison.OrdinalIgnoreCase)), - FilterOperator.Equals => terms.Any(t => CompareValues(rawValue, t, (a, b) => a == b)), - FilterOperator.NotEquals => terms.All(t => CompareValues(rawValue, t, (a, b) => a != b)), - FilterOperator.GreaterThan => CompareNumeric(rawValue, filterValue, (a, b) => a > b), - FilterOperator.GreaterThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a >= b), - FilterOperator.LessThan => CompareNumeric(rawValue, filterValue, (a, b) => a < b), - FilterOperator.LessThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a <= b), - FilterOperator.StartsWith => terms.Any(t => stringValue.StartsWith(t, StringComparison.OrdinalIgnoreCase)), - FilterOperator.EndsWith => terms.Any(t => stringValue.EndsWith(t, StringComparison.OrdinalIgnoreCase)), - _ => true - }; - } - - private static bool IsValueEmpty(object? value) - { - if (value == null) - return true; - if (value is string str) - return string.IsNullOrWhiteSpace(str); - return false; - } - - private static bool CompareValues(object? rawValue, string filterValue, Func comparison) - { - if (rawValue == null) - return comparison(0, 1); - - var stringValue = rawValue.ToString() ?? string.Empty; - - if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) - { - return comparison(numericValue.CompareTo(filterNumeric), 0); - } - - return comparison(string.Compare(stringValue, filterValue, StringComparison.OrdinalIgnoreCase), 0); - } - - private static bool CompareNumeric(object? rawValue, string filterValue, Func comparison) - { - if (rawValue == null) - return false; - - var stringValue = rawValue.ToString() ?? string.Empty; - - if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) - { - return comparison(numericValue, filterNumeric); - } - - return false; - } - - private static bool TryParseNumeric(string value, out decimal result) - { - if (string.IsNullOrWhiteSpace(value)) - { - result = 0; - return false; - } - - var cleanValue = value.Trim() - .Replace(",", "") - .Replace("%", "") - .Replace("$", "") - .Replace(" ", ""); - - return decimal.TryParse(cleanValue, NumberStyles.Any, CultureInfo.InvariantCulture, out result); - } + public static bool MatchesFilter(object item, ColumnFilterState filter) => + ColumnFilterMatcher.MatchesFilter(item, filter); } diff --git a/PerformanceMonitor.Common/Services/ColumnFilterMatcher.cs b/PerformanceMonitor.Common/Services/ColumnFilterMatcher.cs new file mode 100644 index 000000000..e608b49b9 --- /dev/null +++ b/PerformanceMonitor.Common/Services/ColumnFilterMatcher.cs @@ -0,0 +1,131 @@ +/* + * 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.Globalization; +using System.Linq; + +namespace PerformanceMonitor.Common; + +/// +/// Operator-based matching for popup column filters, shared by Lite and Dashboard. +/// Pure reflection + value comparison over / +/// — no WPF dependency — so both apps' DataGridFilterService +/// forward here instead of each carrying their own (previously byte-identical) copy. +/// +public static class ColumnFilterMatcher +{ + /// + /// Checks if an item matches a ColumnFilterState using operator-based filtering. + /// + public static bool MatchesFilter(object item, ColumnFilterState filter) + { + if (item == null || filter == null || !filter.IsActive) + return true; + + var property = item.GetType().GetProperty(filter.ColumnName); + if (property == null) + return true; + + var rawValue = property.GetValue(item); + + // Handle IsEmpty/IsNotEmpty operators first + if (filter.Operator == FilterOperator.IsEmpty) + return IsValueEmpty(rawValue); + if (filter.Operator == FilterOperator.IsNotEmpty) + return !IsValueEmpty(rawValue); + + var stringValue = rawValue?.ToString() ?? string.Empty; + var filterValue = filter.Value ?? string.Empty; + + // Split comma-separated values for text operators (e.g., "db1, db2") + var terms = filterValue.Split(',') + .Select(t => t.Trim()) + .Where(t => !string.IsNullOrEmpty(t)) + .ToArray(); + + if (terms.Length == 0) + return true; + + // Text operators match ANY term, NotEquals excludes ALL terms + return filter.Operator switch + { + FilterOperator.Contains => terms.Any(t => stringValue.Contains(t, StringComparison.OrdinalIgnoreCase)), + FilterOperator.Equals => terms.Any(t => CompareValues(rawValue, t, (a, b) => a == b)), + FilterOperator.NotEquals => terms.All(t => CompareValues(rawValue, t, (a, b) => a != b)), + FilterOperator.GreaterThan => CompareNumeric(rawValue, filterValue, (a, b) => a > b), + FilterOperator.GreaterThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a >= b), + FilterOperator.LessThan => CompareNumeric(rawValue, filterValue, (a, b) => a < b), + FilterOperator.LessThanOrEqual => CompareNumeric(rawValue, filterValue, (a, b) => a <= b), + FilterOperator.StartsWith => terms.Any(t => stringValue.StartsWith(t, StringComparison.OrdinalIgnoreCase)), + FilterOperator.EndsWith => terms.Any(t => stringValue.EndsWith(t, StringComparison.OrdinalIgnoreCase)), + _ => true + }; + } + + private static bool IsValueEmpty(object? value) + { + if (value == null) + return true; + if (value is string str) + return string.IsNullOrWhiteSpace(str); + return false; + } + + private static bool CompareValues(object? rawValue, string filterValue, Func comparison) + { + if (rawValue == null) + return comparison(0, 1); // null is "less than" any value + + var stringValue = rawValue.ToString() ?? string.Empty; + + // Try numeric comparison first + if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) + { + return comparison(numericValue.CompareTo(filterNumeric), 0); + } + + // Fall back to string comparison + return comparison(string.Compare(stringValue, filterValue, StringComparison.OrdinalIgnoreCase), 0); + } + + private static bool CompareNumeric(object? rawValue, string filterValue, Func comparison) + { + if (rawValue == null) + return false; + + var stringValue = rawValue.ToString() ?? string.Empty; + + // Try to parse both values as decimals + if (TryParseNumeric(stringValue, out var numericValue) && TryParseNumeric(filterValue, out var filterNumeric)) + { + return comparison(numericValue, filterNumeric); + } + + // If we can't parse both as numbers, the filter doesn't match + return false; + } + + private static bool TryParseNumeric(string value, out decimal result) + { + if (string.IsNullOrWhiteSpace(value)) + { + result = 0; + return false; + } + + // Remove common formatting characters (commas, percent signs, currency symbols) + var cleanValue = value.Trim() + .Replace(",", "") + .Replace("%", "") + .Replace("$", "") + .Replace(" ", ""); + + return decimal.TryParse(cleanValue, NumberStyles.Any, CultureInfo.InvariantCulture, out result); + } +} From e668634e5d1fe1b4a68ccb845e62de3910b47a54 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:52:40 -0400 Subject: [PATCH 025/145] Share MCP plan-analysis JSON formatter across Lite/Dashboard The byte-identical BuildAnalysisResult + CollectNodes (parse plan XML -> analyze -> serialize structured JSON) lived privately in both apps' McpPlanTools. Moved to PerformanceMonitor.PlanAnalysis.McpPlanAnalysisFormatter; both apps' five MCP tool wrappers (which fetch the plan XML from SQL Server vs DuckDB) now call the shared formatter. Required adding a PlanAnalysis -> Common project reference (the formatter uses McpHelpers.Truncate/JsonOptions) and granting Common's InternalsVisibleTo to PerformanceMonitor.PlanAnalysis. No dependency cycle: Common references no other project. Dropped now-unused usings from both McpPlanTools files. Pure refactor, no behavior change. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Mcp/McpPlanTools.cs | 135 +--------------- Dashboard/packages.lock.json | 5 +- Lite.Tests/packages.lock.json | 5 +- Lite/Mcp/McpPlanTools.cs | 133 +--------------- Lite/packages.lock.json | 5 +- .../PerformanceMonitor.Common.csproj | 1 + .../McpPlanAnalysisFormatter.cs | 149 ++++++++++++++++++ .../PerformanceMonitor.PlanAnalysis.csproj | 4 + 8 files changed, 174 insertions(+), 263 deletions(-) create mode 100644 PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs diff --git a/Dashboard/Mcp/McpPlanTools.cs b/Dashboard/Mcp/McpPlanTools.cs index bdc1b033c..35e04dac7 100644 --- a/Dashboard/Mcp/McpPlanTools.cs +++ b/Dashboard/Mcp/McpPlanTools.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; using System.ComponentModel; -using System.Linq; -using System.Text.Json; using System.Threading.Tasks; using ModelContextProtocol.Server; using PerformanceMonitor.PlanAnalysis; @@ -37,7 +34,7 @@ public static async Task AnalyzeQueryPlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); } catch (Exception ex) { @@ -65,7 +62,7 @@ public static async Task AnalyzeProcedurePlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for sql_handle '{sql_handle}'. The procedure may have been evicted from the plan cache since the last collection."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", sql_handle); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", sql_handle); } catch (Exception ex) { @@ -94,7 +91,7 @@ public static async Task AnalyzeQueryStorePlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for query_id {query_id} in database '{database_name}'. Query Store may not be enabled or the query may have been purged."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{query_id}"); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{query_id}"); } catch (Exception ex) { @@ -114,7 +111,7 @@ public static string AnalyzePlanXml( try { - return BuildAnalysisResult(plan_xml, null, "xml", null); + return McpPlanAnalysisFormatter.BuildAnalysisResult(plan_xml, null, "xml", null); } catch (Exception ex) { @@ -150,128 +147,4 @@ public static async Task GetPlanXml( } } - /// - /// Parses plan XML, runs the analyzer, and builds a structured JSON result. - /// - private static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) - { - var plan = ShowPlanParser.Parse(xml); - PlanAnalyzer.Analyze(plan); - - var statements = plan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .Select(s => - { - var allNodes = new List(); - CollectNodes(s.RootNode!, allNodes); - - var nodeWarnings = allNodes - .SelectMany(n => n.Warnings) - .ToList(); - var stmtWarnings = s.PlanWarnings; - var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); - - var hasActuals = allNodes.Any(n => n.HasActualStats); - var topOps = (hasActuals - ? allNodes.OrderByDescending(n => n.ActualElapsedMs) - : allNodes.OrderByDescending(n => n.CostPercent)) - .Take(10) - .Select(n => new - { - node_id = n.NodeId, - physical_op = n.PhysicalOp, - logical_op = n.LogicalOp, - cost_percent = n.CostPercent, - estimated_rows = n.EstimateRows, - actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, - actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, - actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, - logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, - object_name = n.ObjectName, - index_name = n.IndexName, - predicate = McpHelpers.Truncate(n.Predicate, 500), - seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), - warning_count = n.Warnings.Count - }); - - return new - { - statement_text = McpHelpers.Truncate(s.StatementText, 2000), - statement_type = s.StatementType, - estimated_cost = Math.Round(s.StatementSubTreeCost, 4), - dop = s.DegreeOfParallelism, - serial_reason = s.NonParallelPlanReason, - compile_cpu_ms = s.CompileCPUMs, - compile_memory_kb = s.CompileMemoryKB, - cardinality_model = s.CardinalityEstimationModelVersion, - query_hash = s.QueryHash, - query_plan_hash = s.QueryPlanHash, - has_actual_stats = hasActuals, - warnings = allWarnings.Select(w => new - { - severity = w.Severity.ToString(), - type = w.WarningType, - message = w.Message - }), - warning_count = allWarnings.Count, - critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), - missing_indexes = s.MissingIndexes.Select(idx => new - { - table = $"{idx.Schema}.{idx.Table}", - database = idx.Database, - impact = idx.Impact, - equality_columns = idx.EqualityColumns, - inequality_columns = idx.InequalityColumns, - include_columns = idx.IncludeColumns, - create_statement = idx.CreateStatement - }), - parameters = s.Parameters.Select(p => new - { - name = p.Name, - data_type = p.DataType, - compiled_value = p.CompiledValue, - runtime_value = p.RuntimeValue, - sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null - && p.CompiledValue != p.RuntimeValue - }), - memory_grant = s.MemoryGrant == null ? null : new - { - requested_kb = s.MemoryGrant.RequestedMemoryKB, - granted_kb = s.MemoryGrant.GrantedMemoryKB, - max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, - desired_kb = s.MemoryGrant.DesiredMemoryKB, - grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, - feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted - }, - top_operators = topOps - }; - }) - .ToList(); - - var totalWarnings = statements.Sum(s => s.warning_count); - var totalCritical = statements.Sum(s => s.critical_count); - var totalMissing = statements.Sum(s => s.missing_indexes.Count()); - - var result = new - { - server = serverName, - source, - identifier, - statement_count = statements.Count, - total_warnings = totalWarnings, - total_critical = totalCritical, - total_missing_indexes = totalMissing, - statements - }; - - return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); - } - - private static void CollectNodes(PlanNode node, List nodes) - { - nodes.Add(node); - foreach (var child in node.Children) - CollectNodes(child, nodes); - } } diff --git a/Dashboard/packages.lock.json b/Dashboard/packages.lock.json index 40e267624..a6df7d243 100644 --- a/Dashboard/packages.lock.json +++ b/Dashboard/packages.lock.json @@ -747,7 +747,10 @@ } }, "performancemonitor.plananalysis": { - "type": "Project" + "type": "Project", + "dependencies": { + "PerformanceMonitor.Common": "[1.0.0, )" + } }, "performancemonitor.ui": { "type": "Project", diff --git a/Lite.Tests/packages.lock.json b/Lite.Tests/packages.lock.json index 7dcef2a13..d8f922ce2 100644 --- a/Lite.Tests/packages.lock.json +++ b/Lite.Tests/packages.lock.json @@ -903,7 +903,10 @@ } }, "performancemonitor.plananalysis": { - "type": "Project" + "type": "Project", + "dependencies": { + "PerformanceMonitor.Common": "[1.0.0, )" + } }, "performancemonitor.ui": { "type": "Project", diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs index 531e6e1f5..2b4356aad 100644 --- a/Lite/Mcp/McpPlanTools.cs +++ b/Lite/Mcp/McpPlanTools.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Text.Json; using ModelContextProtocol.Server; using PerformanceMonitor.PlanAnalysis; using PerformanceMonitorLite.Models; @@ -33,7 +32,7 @@ public static async Task AnalyzeQueryPlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); } catch (Exception ex) { @@ -61,7 +60,7 @@ public static async Task AnalyzeProcedurePlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for plan_handle '{plan_handle}'. The procedure may have been evicted from the plan cache since the last collection."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", plan_handle); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", plan_handle); } catch (Exception ex) { @@ -101,7 +100,7 @@ public static async Task AnalyzeQueryStorePlan( if (string.IsNullOrEmpty(xml)) return $"No plan found for plan_id {plan_id} in database '{database_name}'. Query Store may not be enabled or the plan may have been purged."; - return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{plan_id}"); + return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{plan_id}"); } catch (Exception ex) { @@ -121,7 +120,7 @@ public static string AnalyzePlanXml( try { - return BuildAnalysisResult(plan_xml, null, "xml", null); + return McpPlanAnalysisFormatter.BuildAnalysisResult(plan_xml, null, "xml", null); } catch (Exception ex) { @@ -157,128 +156,4 @@ public static async Task GetPlanXml( } } - /// - /// Parses plan XML, runs the analyzer, and builds a structured JSON result. - /// - private static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) - { - var plan = ShowPlanParser.Parse(xml); - PlanAnalyzer.Analyze(plan); - - var statements = plan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .Select(s => - { - var allNodes = new List(); - CollectNodes(s.RootNode!, allNodes); - - var nodeWarnings = allNodes - .SelectMany(n => n.Warnings) - .ToList(); - var stmtWarnings = s.PlanWarnings; - var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); - - var hasActuals = allNodes.Any(n => n.HasActualStats); - var topOps = (hasActuals - ? allNodes.OrderByDescending(n => n.ActualElapsedMs) - : allNodes.OrderByDescending(n => n.CostPercent)) - .Take(10) - .Select(n => new - { - node_id = n.NodeId, - physical_op = n.PhysicalOp, - logical_op = n.LogicalOp, - cost_percent = n.CostPercent, - estimated_rows = n.EstimateRows, - actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, - actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, - actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, - logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, - object_name = n.ObjectName, - index_name = n.IndexName, - predicate = McpHelpers.Truncate(n.Predicate, 500), - seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), - warning_count = n.Warnings.Count - }); - - return new - { - statement_text = McpHelpers.Truncate(s.StatementText, 2000), - statement_type = s.StatementType, - estimated_cost = Math.Round(s.StatementSubTreeCost, 4), - dop = s.DegreeOfParallelism, - serial_reason = s.NonParallelPlanReason, - compile_cpu_ms = s.CompileCPUMs, - compile_memory_kb = s.CompileMemoryKB, - cardinality_model = s.CardinalityEstimationModelVersion, - query_hash = s.QueryHash, - query_plan_hash = s.QueryPlanHash, - has_actual_stats = hasActuals, - warnings = allWarnings.Select(w => new - { - severity = w.Severity.ToString(), - type = w.WarningType, - message = w.Message - }), - warning_count = allWarnings.Count, - critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), - missing_indexes = s.MissingIndexes.Select(idx => new - { - table = $"{idx.Schema}.{idx.Table}", - database = idx.Database, - impact = idx.Impact, - equality_columns = idx.EqualityColumns, - inequality_columns = idx.InequalityColumns, - include_columns = idx.IncludeColumns, - create_statement = idx.CreateStatement - }), - parameters = s.Parameters.Select(p => new - { - name = p.Name, - data_type = p.DataType, - compiled_value = p.CompiledValue, - runtime_value = p.RuntimeValue, - sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null - && p.CompiledValue != p.RuntimeValue - }), - memory_grant = s.MemoryGrant == null ? null : new - { - requested_kb = s.MemoryGrant.RequestedMemoryKB, - granted_kb = s.MemoryGrant.GrantedMemoryKB, - max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, - desired_kb = s.MemoryGrant.DesiredMemoryKB, - grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, - feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted - }, - top_operators = topOps - }; - }) - .ToList(); - - var totalWarnings = statements.Sum(s => s.warning_count); - var totalCritical = statements.Sum(s => s.critical_count); - var totalMissing = statements.Sum(s => s.missing_indexes.Count()); - - var result = new - { - server = serverName, - source, - identifier, - statement_count = statements.Count, - total_warnings = totalWarnings, - total_critical = totalCritical, - total_missing_indexes = totalMissing, - statements - }; - - return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); - } - - private static void CollectNodes(PlanNode node, List nodes) - { - nodes.Add(node); - foreach (var child in node.Children) - CollectNodes(child, nodes); - } } diff --git a/Lite/packages.lock.json b/Lite/packages.lock.json index 0bb0e0330..2e11d3684 100644 --- a/Lite/packages.lock.json +++ b/Lite/packages.lock.json @@ -759,7 +759,10 @@ } }, "performancemonitor.plananalysis": { - "type": "Project" + "type": "Project", + "dependencies": { + "PerformanceMonitor.Common": "[1.0.0, )" + } }, "performancemonitor.ui": { "type": "Project", diff --git a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj index 84cb691df..521473dd3 100644 --- a/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj +++ b/PerformanceMonitor.Common/PerformanceMonitor.Common.csproj @@ -20,6 +20,7 @@ + diff --git a/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs b/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs new file mode 100644 index 000000000..06638ab1e --- /dev/null +++ b/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using PerformanceMonitor.Common; + +namespace PerformanceMonitor.PlanAnalysis; + +/// +/// Parses a query plan, runs the analyzer, and serializes a structured JSON result for the MCP +/// plan-analysis tools. Shared by Lite and Dashboard — both apps' McpPlanTools call this so the +/// (previously byte-identical) projection stays in one place. The per-app tool wrappers that fetch +/// the plan XML (from SQL Server vs DuckDB) remain app-specific. +/// +public static class McpPlanAnalysisFormatter +{ + /// + /// Parses plan XML, runs the analyzer, and builds a structured JSON result. + /// + public static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) + { + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + + var statements = plan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .Select(s => + { + var allNodes = new List(); + CollectNodes(s.RootNode!, allNodes); + + var nodeWarnings = allNodes + .SelectMany(n => n.Warnings) + .ToList(); + var stmtWarnings = s.PlanWarnings; + var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); + + var hasActuals = allNodes.Any(n => n.HasActualStats); + var topOps = (hasActuals + ? allNodes.OrderByDescending(n => n.ActualElapsedMs) + : allNodes.OrderByDescending(n => n.CostPercent)) + .Take(10) + .Select(n => new + { + node_id = n.NodeId, + physical_op = n.PhysicalOp, + logical_op = n.LogicalOp, + cost_percent = n.CostPercent, + estimated_rows = n.EstimateRows, + actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, + actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, + actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, + logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, + object_name = n.ObjectName, + index_name = n.IndexName, + predicate = McpHelpers.Truncate(n.Predicate, 500), + seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), + warning_count = n.Warnings.Count + }); + + return new + { + statement_text = McpHelpers.Truncate(s.StatementText, 2000), + statement_type = s.StatementType, + estimated_cost = Math.Round(s.StatementSubTreeCost, 4), + dop = s.DegreeOfParallelism, + serial_reason = s.NonParallelPlanReason, + compile_cpu_ms = s.CompileCPUMs, + compile_memory_kb = s.CompileMemoryKB, + cardinality_model = s.CardinalityEstimationModelVersion, + query_hash = s.QueryHash, + query_plan_hash = s.QueryPlanHash, + has_actual_stats = hasActuals, + warnings = allWarnings.Select(w => new + { + severity = w.Severity.ToString(), + type = w.WarningType, + message = w.Message + }), + warning_count = allWarnings.Count, + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + missing_indexes = s.MissingIndexes.Select(idx => new + { + table = $"{idx.Schema}.{idx.Table}", + database = idx.Database, + impact = idx.Impact, + equality_columns = idx.EqualityColumns, + inequality_columns = idx.InequalityColumns, + include_columns = idx.IncludeColumns, + create_statement = idx.CreateStatement + }), + parameters = s.Parameters.Select(p => new + { + name = p.Name, + data_type = p.DataType, + compiled_value = p.CompiledValue, + runtime_value = p.RuntimeValue, + sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null + && p.CompiledValue != p.RuntimeValue + }), + memory_grant = s.MemoryGrant == null ? null : new + { + requested_kb = s.MemoryGrant.RequestedMemoryKB, + granted_kb = s.MemoryGrant.GrantedMemoryKB, + max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, + desired_kb = s.MemoryGrant.DesiredMemoryKB, + grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, + feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted + }, + top_operators = topOps + }; + }) + .ToList(); + + var totalWarnings = statements.Sum(s => s.warning_count); + var totalCritical = statements.Sum(s => s.critical_count); + var totalMissing = statements.Sum(s => s.missing_indexes.Count()); + + var result = new + { + server = serverName, + source, + identifier, + statement_count = statements.Count, + total_warnings = totalWarnings, + total_critical = totalCritical, + total_missing_indexes = totalMissing, + statements + }; + + return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + } + + private static void CollectNodes(PlanNode node, List nodes) + { + nodes.Add(node); + foreach (var child in node.Children) + CollectNodes(child, nodes); + } +} diff --git a/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj b/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj index a1f3f793e..0f210a10d 100644 --- a/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj +++ b/PerformanceMonitor.PlanAnalysis/PerformanceMonitor.PlanAnalysis.csproj @@ -13,6 +13,10 @@ CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806 + + + + From c8d174eeb6118710e6173a1619e825d7f1dbb34e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:03:36 -0400 Subject: [PATCH 026/145] =?UTF-8?q?Share=20DataGridFilterManager=20across?= =?UTF-8?q?=20Lite/Dashboard=20(=E2=86=92=20Ui)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic DataGridFilterManager + IDataGridFilterManager were identical in both apps except that Lite preserved the user's sort order across refresh/filter cycles (SetItemsSourcePreservingSort) while Dashboard reassigned ItemsSource directly. Moved one copy to PerformanceMonitor.Ui, adopting Lite's sort-preserving superset. BEHAVIOR CHANGE (intended, approved): Dashboard DataGrids now also preserve sort order when data refreshes or filters change — previously a refresh dropped the user's sort. Strict UX improvement; achieves Lite/Dashboard parity. The shared manager calls Common.ColumnFilterMatcher.MatchesFilter directly (instead of each app's DataGridFilterService), so it no longer depends on per-app code. Added a Ui → Common project reference for ColumnFilterState/ColumnFilterMatcher (no cycle: Common references no project). Both app copies deleted; the one caller missing the Ui import (ServerTab.Filters.cs) got it. Solution builds clean; Dashboard.Tests (488), Lite.Tests (544), Installer.Tests (80) all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Services/DataGridFilterManager.cs | 137 ------------------ Dashboard/packages.lock.json | 1 + Lite.Tests/packages.lock.json | 1 + Lite/Controls/ServerTab.Filters.cs | 1 + Lite/packages.lock.json | 1 + .../DataGridFilterManager.cs | 11 +- .../PerformanceMonitor.Ui.csproj | 4 + 7 files changed, 13 insertions(+), 143 deletions(-) delete mode 100644 Dashboard/Services/DataGridFilterManager.cs rename {Lite/Services => PerformanceMonitor.Ui}/DataGridFilterManager.cs (94%) diff --git a/Dashboard/Services/DataGridFilterManager.cs b/Dashboard/Services/DataGridFilterManager.cs deleted file mode 100644 index 01ccbbb1d..000000000 --- a/Dashboard/Services/DataGridFilterManager.cs +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using PerformanceMonitorDashboard.Models; -using PerformanceMonitor.Common; - -namespace PerformanceMonitorDashboard.Services; - -/// -/// Non-generic interface for looking up filter state from a shared dictionary. -/// -public interface IDataGridFilterManager -{ - Dictionary Filters { get; } - void SetFilter(ColumnFilterState filterState); - void UpdateFilterButtonStyles(); -} - -/// -/// Manages column filter state, unfiltered data capture, and filter application -/// for a single DataGrid. Eliminates per-grid boilerplate code. -/// -public class DataGridFilterManager : IDataGridFilterManager -{ - private readonly DataGrid _dataGrid; - private readonly Dictionary _filters = new(); - private List? _unfilteredData; - - public DataGridFilterManager(DataGrid dataGrid) - { - _dataGrid = dataGrid; - } - - public Dictionary Filters => _filters; - - /// - /// Called when new data arrives (refresh cycle). Captures unfiltered data, - /// then re-applies any active filters. - /// - public void UpdateData(List newData) - { - _unfilteredData = newData; - - if (!HasActiveFilters()) - { - _dataGrid.ItemsSource = newData; - return; - } - - ApplyFilters(); - } - - /// - /// Applies or removes a filter and re-filters the data. - /// - public void SetFilter(ColumnFilterState filterState) - { - if (filterState.IsActive) - _filters[filterState.ColumnName] = filterState; - else - _filters.Remove(filterState.ColumnName); - - ApplyFilters(); - UpdateFilterButtonStyles(); - } - - private bool HasActiveFilters() - { - return _filters.Count > 0 && _filters.Values.Any(f => f.IsActive); - } - - private void ApplyFilters() - { - if (_unfilteredData == null) return; - - if (!HasActiveFilters()) - { - _dataGrid.ItemsSource = _unfilteredData; - return; - } - - var filteredData = _unfilteredData.Where(item => - { - foreach (var filter in _filters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item!, filter)) - return false; - } - return true; - }).ToList(); - - _dataGrid.ItemsSource = filteredData; - } - - /// - /// Updates filter icon colors (gold when active, dim when inactive). - /// - public void UpdateFilterButtonStyles() - { - foreach (var column in _dataGrid.Columns) - { - if (column.Header is StackPanel headerPanel) - { - var filterButton = headerPanel.Children.OfType