diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e71a7d8d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto eol=crlf + +# Explicit binary markers (prevent text mis-detection / corruption): +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.dll binary +*.exe binary +*.pdb binary +*.snk binary +*.parquet binary +*.zip binary +*.duckdb binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2dd0a89b..4343fd4a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,111 +1,111 @@ -name: Bug Report -description: Report a problem with Performance Monitor -title: "[BUG] " -labels: ["bug"] - -body: - - type: dropdown - id: component - attributes: - label: Component - description: Which part of Performance Monitor is affected? - options: - - Full Dashboard - - Lite - - Installer (CLI) - - Installer (GUI) - - SQL collection scripts - validations: - required: true - - - type: input - id: version - attributes: - label: Performance Monitor Version - description: Check the About dialog or the release you downloaded. - placeholder: "e.g., 1.2.0" - validations: - required: true - - - type: input - id: sql-version - attributes: - label: SQL Server Version - description: "Run SELECT @@VERSION on the target server." - placeholder: "e.g., SQL Server 2019 CU25" - validations: - required: true - - - type: input - id: windows-version - attributes: - label: Windows Version - description: The OS where the Dashboard or Lite app is running. - placeholder: "e.g., Windows 11 23H2, Windows Server 2022" - validations: - required: true - - - type: textarea - id: description - attributes: - label: Describe the Bug - description: A clear description of what the bug is. - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to Reproduce - description: How can we reproduce this? - placeholder: | - 1. Open the Dashboard - 2. Click on... - 3. See error - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected Behavior - description: What you expected to happen. - validations: - required: true - - - type: textarea - id: actual - attributes: - label: Actual Behavior - description: What actually happened. - validations: - required: true - - - type: textarea - id: errors - attributes: - label: Error Messages / Log Output - description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\. - render: text - validations: - required: false - - - type: textarea - id: screenshots - attributes: - label: Screenshots - description: If applicable, add screenshots to help explain the problem. - validations: - required: false - - - type: textarea - id: context - attributes: - label: Additional Context - description: | - Anything else that might help: - - Did this work before? If so, what changed? - - Is SQL Server Agent running? (Full Edition only) - - Are you monitoring Azure SQL DB, Azure MI, or AWS RDS? - validations: - required: false +name: Bug Report +description: Report a problem with Performance Monitor +title: "[BUG] " +labels: ["bug"] + +body: + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Performance Monitor is affected? + options: + - Full Dashboard + - Lite + - Installer (CLI) + - Installer (GUI) + - SQL collection scripts + validations: + required: true + + - type: input + id: version + attributes: + label: Performance Monitor Version + description: Check the About dialog or the release you downloaded. + placeholder: "e.g., 1.2.0" + validations: + required: true + + - type: input + id: sql-version + attributes: + label: SQL Server Version + description: "Run SELECT @@VERSION on the target server." + placeholder: "e.g., SQL Server 2019 CU25" + validations: + required: true + + - type: input + id: windows-version + attributes: + label: Windows Version + description: The OS where the Dashboard or Lite app is running. + placeholder: "e.g., Windows 11 23H2, Windows Server 2022" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the Bug + description: A clear description of what the bug is. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: How can we reproduce this? + placeholder: | + 1. Open the Dashboard + 2. Click on... + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened. + validations: + required: true + + - type: textarea + id: errors + attributes: + label: Error Messages / Log Output + description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\. + render: text + validations: + required: false + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain the problem. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: | + Anything else that might help: + - Did this work before? If so, what changed? + - Is SQL Server Agent running? (Full Edition only) + - Are you monitoring Azure SQL DB, Azure MI, or AWS RDS? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1e486216..530cd13e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ -blank_issues_enabled: false -contact_links: - - name: Questions & Discussion - url: https://github.com/erikdarlingdata/PerformanceMonitor/discussions - about: Ask questions and discuss Performance Monitor with the community +blank_issues_enabled: false +contact_links: + - name: Questions & Discussion + url: https://github.com/erikdarlingdata/PerformanceMonitor/discussions + about: Ask questions and discuss Performance Monitor with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 10c4de58..b330d3df 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,62 +1,62 @@ -name: Feature Request -description: Suggest a new feature or enhancement -title: "[FEATURE] " -labels: ["enhancement"] - -body: - - type: checkboxes - id: component - attributes: - label: Which component(s) does this affect? - options: - - label: Full Dashboard - - label: Lite - - label: SQL collection scripts - - label: Installer - - label: Documentation - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem Statement - description: Describe the problem you're trying to solve or the limitation you're facing. - validations: - required: true - - - type: textarea - id: solution - attributes: - label: Proposed Solution - description: Describe your proposed feature or enhancement. - validations: - required: true - - - type: textarea - id: use-case - attributes: - label: Use Case - description: How would you use this feature? Provide a specific example. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives Considered - description: Have you considered any alternative solutions or workarounds? - validations: - required: false - - - type: textarea - id: context - attributes: - label: Additional Context - description: | - Anything else: - - Is this related to a specific SQL Server version? - - Would this require schema changes? - - How frequently would you use this feature? - validations: - required: false +name: Feature Request +description: Suggest a new feature or enhancement +title: "[FEATURE] " +labels: ["enhancement"] + +body: + - type: checkboxes + id: component + attributes: + label: Which component(s) does this affect? + options: + - label: Full Dashboard + - label: Lite + - label: SQL collection scripts + - label: Installer + - label: Documentation + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: Describe the problem you're trying to solve or the limitation you're facing. + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe your proposed feature or enhancement. + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case + description: How would you use this feature? Provide a specific example. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: | + Anything else: + - Is this related to a specific SQL Server version? + - Would this require schema changes? + - How frequently would you use this feature? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index dec7b93d..3c497310 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,59 +1,59 @@ -name: Question -description: I have a question about Performance Monitor -title: "[QUESTION] " -labels: ["question"] - -body: - - type: dropdown - id: component - attributes: - label: Which component is your question about? - options: - - Full Dashboard - - Lite - - SQL collection scripts - - Installer - - General / setup - validations: - required: true - - - type: input - id: version - attributes: - label: Performance Monitor Version - description: Check the About dialog or the release you downloaded. - placeholder: "e.g., 1.2.0" - validations: - required: true - - - type: dropdown - id: question-type - attributes: - label: Is your question about how it works, or the results? - description: Questions about results or SQL Server tuning are better suited for https://www.erikdarling.com/ or https://dba.stackexchange.com/ - options: - - How the tool works - - Setup / installation - - Results / data interpretation - - Contributing / development - validations: - required: true - - - type: textarea - id: question - attributes: - label: What's your question? - description: A clear description of what you'd like to know. - validations: - required: true - - - type: textarea - id: context - attributes: - label: Additional Context - description: | - Anything that might help: - - SQL Server version and edition - - Are you using Azure SQL DB, Azure MI, or AWS RDS? - validations: - required: false +name: Question +description: I have a question about Performance Monitor +title: "[QUESTION] " +labels: ["question"] + +body: + - type: dropdown + id: component + attributes: + label: Which component is your question about? + options: + - Full Dashboard + - Lite + - SQL collection scripts + - Installer + - General / setup + validations: + required: true + + - type: input + id: version + attributes: + label: Performance Monitor Version + description: Check the About dialog or the release you downloaded. + placeholder: "e.g., 1.2.0" + validations: + required: true + + - type: dropdown + id: question-type + attributes: + label: Is your question about how it works, or the results? + description: Questions about results or SQL Server tuning are better suited for https://www.erikdarling.com/ or https://dba.stackexchange.com/ + options: + - How the tool works + - Setup / installation + - Results / data interpretation + - Contributing / development + validations: + required: true + + - type: textarea + id: question + attributes: + label: What's your question? + description: A clear description of what you'd like to know. + validations: + required: true + + - type: textarea + id: context + attributes: + label: Additional Context + description: | + Anything that might help: + - SQL Server version and edition + - Are you using Azure SQL DB, Azure MI, or AWS RDS? + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b5936c08..6b13b4d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,26 +1,26 @@ -## What does this PR do? - -A clear description of the change and why it's being made. - -## Which component(s) does this affect? - -- [ ] Full Dashboard -- [ ] Lite Dashboard -- [ ] Lite Tests -- [ ] SQL collection scripts -- [ ] CLI Installer -- [ ] GUI Installer -- [ ] Documentation - -## How was this tested? - -Describe the testing you've done. Include: -- SQL Server version(s) tested against -- Steps to verify the change works - -## Checklist - -- [ ] I have read the [contributing guide](https://github.com/erikdarlingdata/PerformanceMonitor/blob/main/CONTRIBUTING.md) -- [ ] My code builds with zero warnings (`dotnet build -c Debug`) -- [ ] I have tested my changes against at least one SQL Server version -- [ ] I have not introduced any hardcoded credentials or server names +## What does this PR do? + +A clear description of the change and why it's being made. + +## Which component(s) does this affect? + +- [ ] Full Dashboard +- [ ] Lite Dashboard +- [ ] Lite Tests +- [ ] SQL collection scripts +- [ ] CLI Installer +- [ ] GUI Installer +- [ ] Documentation + +## How was this tested? + +Describe the testing you've done. Include: +- SQL Server version(s) tested against +- Steps to verify the change works + +## Checklist + +- [ ] I have read the [contributing guide](https://github.com/erikdarlingdata/PerformanceMonitor/blob/main/CONTRIBUTING.md) +- [ ] My code builds with zero warnings (`dotnet build -c Debug`) +- [ ] I have tested my changes against at least one SQL Server version +- [ ] I have not introduced any hardcoded credentials or server names diff --git a/.github/sql/ci_validate_installation.sql b/.github/sql/ci_validate_installation.sql index d3b6382f..ede7b80a 100644 --- a/.github/sql/ci_validate_installation.sql +++ b/.github/sql/ci_validate_installation.sql @@ -1,209 +1,209 @@ -/* -CI Validation: Verify all expected objects exist after installation. -Run with sqlcmd -b to fail on RAISERROR. -*/ - -SET NOCOUNT ON; - -USE PerformanceMonitor; -GO - -PRINT '========================================'; -PRINT 'CI Installation Validation'; -PRINT '========================================'; -PRINT ''; - -DECLARE - @missing int = 0, - @checked int = 0; - -/* -Schemas (4) -*/ -PRINT 'Checking schemas...'; - -IF SCHEMA_ID(N'collect') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema collect'; END; SET @checked += 1; -IF SCHEMA_ID(N'analyze') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema analyze'; END; SET @checked += 1; -IF SCHEMA_ID(N'config') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema config'; END; SET @checked += 1; -IF SCHEMA_ID(N'report') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema report'; END; SET @checked += 1; - -PRINT ''; - -/* -Procedures in collect schema (38) -*/ -PRINT 'Checking collect procedures...'; - -IF OBJECT_ID(N'collect.calculate_deltas', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.calculate_deltas'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.wait_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.wait_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.query_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.query_store_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_store_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.procedure_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.query_snapshots_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.query_snapshots_create_views', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_create_views'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.query_snapshots_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_retention'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.memory_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.memory_grant_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_grant_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.memory_clerks_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_clerks_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.cpu_scheduler_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_scheduler_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.cpu_utilization_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_utilization_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.perfmon_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.perfmon_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.file_io_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.file_io_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.blocked_process_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocked_process_xml_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.process_blocked_process_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_blocked_process_xml'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.deadlock_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.deadlock_xml_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.process_deadlock_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_deadlock_xml'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.blocking_deadlock_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocking_deadlock_analyzer'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.memory_pressure_events_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_pressure_events_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.system_health_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.system_health_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.default_trace_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.default_trace_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.trace_management_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_management_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.trace_analysis_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_analysis_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.latch_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.latch_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.spinlock_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.spinlock_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.tempdb_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.tempdb_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.plan_cache_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.plan_cache_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.session_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.waiting_tasks_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.waiting_tasks_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.server_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_configuration_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.database_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_configuration_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.configuration_issues_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.configuration_issues_analyzer'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.scheduled_master_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.scheduled_master_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.running_jobs_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.running_jobs_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.database_size_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_size_stats_collector'; END; SET @checked += 1; -IF OBJECT_ID(N'collect.server_properties_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_properties_collector'; END; SET @checked += 1; - -PRINT ''; - -/* -Procedures in config schema (8) -*/ -PRINT 'Checking config procedures...'; - -IF OBJECT_ID(N'config.ensure_config_tables', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_config_tables'; END; SET @checked += 1; -IF OBJECT_ID(N'config.ensure_collection_table', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_collection_table'; END; SET @checked += 1; -IF OBJECT_ID(N'config.update_collector_frequency', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.update_collector_frequency'; END; SET @checked += 1; -IF OBJECT_ID(N'config.set_collector_enabled', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.set_collector_enabled'; END; SET @checked += 1; -IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.apply_collection_preset'; END; SET @checked += 1; -IF OBJECT_ID(N'config.show_collection_schedule', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.show_collection_schedule'; END; SET @checked += 1; -IF OBJECT_ID(N'config.data_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.data_retention'; END; SET @checked += 1; -IF OBJECT_ID(N'config.check_hung_collector_job', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.check_hung_collector_job'; END; SET @checked += 1; - -PRINT ''; - -/* -Views in config schema (2) -*/ -PRINT 'Checking config views...'; - -IF OBJECT_ID(N'config.current_version', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.current_version'; END; SET @checked += 1; -IF OBJECT_ID(N'config.server_info', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.server_info'; END; SET @checked += 1; - -PRINT ''; - -/* -Views in report schema (41) -Note: report.query_snapshots and report.query_snapshots_blocking are created -dynamically by collect.query_snapshots_create_views, so they are not checked here. -*/ -PRINT 'Checking report views...'; - -IF OBJECT_ID(N'report.query_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_with_formatted_plans'; END; SET @checked += 1; -IF OBJECT_ID(N'report.procedure_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_with_formatted_plans'; END; SET @checked += 1; -IF OBJECT_ID(N'report.query_store_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_stats_with_formatted_plans'; END; SET @checked += 1; -IF OBJECT_ID(N'report.expensive_queries_today', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.expensive_queries_today'; END; SET @checked += 1; -IF OBJECT_ID(N'report.query_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.procedure_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.query_store_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.collection_health', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.collection_health'; END; SET @checked += 1; -IF OBJECT_ID(N'report.top_waits_last_hour', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_waits_last_hour'; END; SET @checked += 1; -IF OBJECT_ID(N'report.memory_pressure_events', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_events'; END; SET @checked += 1; -IF OBJECT_ID(N'report.cpu_spikes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_spikes'; END; SET @checked += 1; -IF OBJECT_ID(N'report.blocking_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.deadlock_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.deadlock_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.daily_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary'; END; SET @checked += 1; -IF OBJECT_ID(N'report.daily_summary_v2', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary_v2'; END; SET @checked += 1; -IF OBJECT_ID(N'report.server_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.server_configuration_changes'; END; SET @checked += 1; -IF OBJECT_ID(N'report.database_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.database_configuration_changes'; END; SET @checked += 1; -IF OBJECT_ID(N'report.trace_flag_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.trace_flag_changes'; END; SET @checked += 1; -IF OBJECT_ID(N'report.top_latch_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_latch_contention'; END; SET @checked += 1; -IF OBJECT_ID(N'report.top_spinlock_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_spinlock_contention'; END; SET @checked += 1; -IF OBJECT_ID(N'report.tempdb_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_pressure'; END; SET @checked += 1; -IF OBJECT_ID(N'report.plan_cache_bloat', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.plan_cache_bloat'; END; SET @checked += 1; -IF OBJECT_ID(N'report.top_memory_consumers', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_memory_consumers'; END; SET @checked += 1; -IF OBJECT_ID(N'report.memory_grant_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_grant_pressure'; END; SET @checked += 1; -IF OBJECT_ID(N'report.file_io_latency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_latency'; END; SET @checked += 1; -IF OBJECT_ID(N'report.cpu_scheduler_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_scheduler_pressure'; END; SET @checked += 1; -IF OBJECT_ID(N'report.long_running_query_patterns', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.long_running_query_patterns'; END; SET @checked += 1; -IF OBJECT_ID(N'report.memory_pressure_indicators', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_indicators'; END; SET @checked += 1; -IF OBJECT_ID(N'report.file_io_wait_correlation', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_wait_correlation'; END; SET @checked += 1; -IF OBJECT_ID(N'report.blocking_chain_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_chain_analysis'; END; SET @checked += 1; -IF OBJECT_ID(N'report.tempdb_contention_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_contention_analysis'; END; SET @checked += 1; -IF OBJECT_ID(N'report.parameter_sensitivity_detection', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.parameter_sensitivity_detection'; END; SET @checked += 1; -IF OBJECT_ID(N'report.scheduler_cpu_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.scheduler_cpu_analysis'; END; SET @checked += 1; -IF OBJECT_ID(N'report.critical_issues', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.critical_issues'; END; SET @checked += 1; -IF OBJECT_ID(N'report.memory_usage_trends', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_usage_trends'; END; SET @checked += 1; -IF OBJECT_ID(N'report.running_jobs', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.running_jobs'; END; SET @checked += 1; -IF OBJECT_ID(N'report.finops_database_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_database_resource_usage'; END; SET @checked += 1; -IF OBJECT_ID(N'report.finops_utilization_efficiency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_utilization_efficiency'; END; SET @checked += 1; -IF OBJECT_ID(N'report.finops_peak_utilization', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_peak_utilization'; END; SET @checked += 1; -IF OBJECT_ID(N'report.finops_application_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_application_resource_usage'; END; SET @checked += 1; - -PRINT ''; - -/* -Functions in report schema (1) -*/ -PRINT 'Checking report functions...'; - -IF OBJECT_ID(N'report.query_store_regressions', N'IF') IS NULL -AND OBJECT_ID(N'report.query_store_regressions', N'TF') IS NULL -AND OBJECT_ID(N'report.query_store_regressions', N'FN') IS NULL -BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_regressions'; END; -SET @checked += 1; - -PRINT ''; - -/* -Table count checks (minimum expected per schema) -*/ -PRINT 'Checking table counts...'; - -DECLARE - @collect_tables int, - @config_tables int; - -SELECT @collect_tables = COUNT_BIG(*) -FROM sys.tables AS t -WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'collect'; - -SELECT @config_tables = COUNT_BIG(*) -FROM sys.tables AS t -WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'config'; - -PRINT ' collect schema tables: ' + CONVERT(varchar(10), @collect_tables); -PRINT ' config schema tables: ' + CONVERT(varchar(10), @config_tables); - -IF @collect_tables < 21 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 21 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1; -IF @config_tables < 5 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 5 config tables, found ' + CONVERT(varchar(10), @config_tables); END; SET @checked += 1; - -PRINT ''; - -/* -Summary -*/ -PRINT '========================================'; -PRINT 'Checked ' + CONVERT(varchar(10), @checked) + ' objects'; - -IF @missing > 0 -BEGIN - PRINT 'FAILED: ' + CONVERT(varchar(10), @missing) + ' object(s) missing'; - RAISERROR('CI validation failed: %d object(s) missing', 16, 1, @missing); -END; -ELSE -BEGIN - PRINT 'PASSED: All objects present'; -END; - -PRINT '========================================'; -GO +/* +CI Validation: Verify all expected objects exist after installation. +Run with sqlcmd -b to fail on RAISERROR. +*/ + +SET NOCOUNT ON; + +USE PerformanceMonitor; +GO + +PRINT '========================================'; +PRINT 'CI Installation Validation'; +PRINT '========================================'; +PRINT ''; + +DECLARE + @missing int = 0, + @checked int = 0; + +/* +Schemas (4) +*/ +PRINT 'Checking schemas...'; + +IF SCHEMA_ID(N'collect') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema collect'; END; SET @checked += 1; +IF SCHEMA_ID(N'analyze') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema analyze'; END; SET @checked += 1; +IF SCHEMA_ID(N'config') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema config'; END; SET @checked += 1; +IF SCHEMA_ID(N'report') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema report'; END; SET @checked += 1; + +PRINT ''; + +/* +Procedures in collect schema (38) +*/ +PRINT 'Checking collect procedures...'; + +IF OBJECT_ID(N'collect.calculate_deltas', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.calculate_deltas'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.wait_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.wait_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.query_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.query_store_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_store_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.procedure_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.query_snapshots_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.query_snapshots_create_views', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_create_views'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.query_snapshots_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_retention'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.memory_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.memory_grant_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_grant_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.memory_clerks_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_clerks_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.cpu_scheduler_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_scheduler_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.cpu_utilization_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_utilization_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.perfmon_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.perfmon_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.file_io_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.file_io_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.blocked_process_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocked_process_xml_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.process_blocked_process_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_blocked_process_xml'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.deadlock_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.deadlock_xml_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.process_deadlock_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_deadlock_xml'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.blocking_deadlock_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocking_deadlock_analyzer'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.memory_pressure_events_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_pressure_events_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.system_health_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.system_health_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.default_trace_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.default_trace_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.trace_management_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_management_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.trace_analysis_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_analysis_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.latch_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.latch_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.spinlock_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.spinlock_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.tempdb_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.tempdb_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.plan_cache_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.plan_cache_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.session_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.waiting_tasks_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.waiting_tasks_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.server_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_configuration_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.database_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_configuration_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.configuration_issues_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.configuration_issues_analyzer'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.scheduled_master_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.scheduled_master_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.running_jobs_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.running_jobs_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.database_size_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_size_stats_collector'; END; SET @checked += 1; +IF OBJECT_ID(N'collect.server_properties_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_properties_collector'; END; SET @checked += 1; + +PRINT ''; + +/* +Procedures in config schema (8) +*/ +PRINT 'Checking config procedures...'; + +IF OBJECT_ID(N'config.ensure_config_tables', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_config_tables'; END; SET @checked += 1; +IF OBJECT_ID(N'config.ensure_collection_table', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_collection_table'; END; SET @checked += 1; +IF OBJECT_ID(N'config.update_collector_frequency', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.update_collector_frequency'; END; SET @checked += 1; +IF OBJECT_ID(N'config.set_collector_enabled', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.set_collector_enabled'; END; SET @checked += 1; +IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.apply_collection_preset'; END; SET @checked += 1; +IF OBJECT_ID(N'config.show_collection_schedule', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.show_collection_schedule'; END; SET @checked += 1; +IF OBJECT_ID(N'config.data_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.data_retention'; END; SET @checked += 1; +IF OBJECT_ID(N'config.check_hung_collector_job', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.check_hung_collector_job'; END; SET @checked += 1; + +PRINT ''; + +/* +Views in config schema (2) +*/ +PRINT 'Checking config views...'; + +IF OBJECT_ID(N'config.current_version', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.current_version'; END; SET @checked += 1; +IF OBJECT_ID(N'config.server_info', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.server_info'; END; SET @checked += 1; + +PRINT ''; + +/* +Views in report schema (41) +Note: report.query_snapshots and report.query_snapshots_blocking are created +dynamically by collect.query_snapshots_create_views, so they are not checked here. +*/ +PRINT 'Checking report views...'; + +IF OBJECT_ID(N'report.query_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_with_formatted_plans'; END; SET @checked += 1; +IF OBJECT_ID(N'report.procedure_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_with_formatted_plans'; END; SET @checked += 1; +IF OBJECT_ID(N'report.query_store_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_stats_with_formatted_plans'; END; SET @checked += 1; +IF OBJECT_ID(N'report.expensive_queries_today', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.expensive_queries_today'; END; SET @checked += 1; +IF OBJECT_ID(N'report.query_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.procedure_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.query_store_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.collection_health', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.collection_health'; END; SET @checked += 1; +IF OBJECT_ID(N'report.top_waits_last_hour', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_waits_last_hour'; END; SET @checked += 1; +IF OBJECT_ID(N'report.memory_pressure_events', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_events'; END; SET @checked += 1; +IF OBJECT_ID(N'report.cpu_spikes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_spikes'; END; SET @checked += 1; +IF OBJECT_ID(N'report.blocking_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.deadlock_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.deadlock_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.daily_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary'; END; SET @checked += 1; +IF OBJECT_ID(N'report.daily_summary_v2', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary_v2'; END; SET @checked += 1; +IF OBJECT_ID(N'report.server_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.server_configuration_changes'; END; SET @checked += 1; +IF OBJECT_ID(N'report.database_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.database_configuration_changes'; END; SET @checked += 1; +IF OBJECT_ID(N'report.trace_flag_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.trace_flag_changes'; END; SET @checked += 1; +IF OBJECT_ID(N'report.top_latch_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_latch_contention'; END; SET @checked += 1; +IF OBJECT_ID(N'report.top_spinlock_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_spinlock_contention'; END; SET @checked += 1; +IF OBJECT_ID(N'report.tempdb_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_pressure'; END; SET @checked += 1; +IF OBJECT_ID(N'report.plan_cache_bloat', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.plan_cache_bloat'; END; SET @checked += 1; +IF OBJECT_ID(N'report.top_memory_consumers', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_memory_consumers'; END; SET @checked += 1; +IF OBJECT_ID(N'report.memory_grant_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_grant_pressure'; END; SET @checked += 1; +IF OBJECT_ID(N'report.file_io_latency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_latency'; END; SET @checked += 1; +IF OBJECT_ID(N'report.cpu_scheduler_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_scheduler_pressure'; END; SET @checked += 1; +IF OBJECT_ID(N'report.long_running_query_patterns', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.long_running_query_patterns'; END; SET @checked += 1; +IF OBJECT_ID(N'report.memory_pressure_indicators', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_indicators'; END; SET @checked += 1; +IF OBJECT_ID(N'report.file_io_wait_correlation', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_wait_correlation'; END; SET @checked += 1; +IF OBJECT_ID(N'report.blocking_chain_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_chain_analysis'; END; SET @checked += 1; +IF OBJECT_ID(N'report.tempdb_contention_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_contention_analysis'; END; SET @checked += 1; +IF OBJECT_ID(N'report.parameter_sensitivity_detection', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.parameter_sensitivity_detection'; END; SET @checked += 1; +IF OBJECT_ID(N'report.scheduler_cpu_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.scheduler_cpu_analysis'; END; SET @checked += 1; +IF OBJECT_ID(N'report.critical_issues', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.critical_issues'; END; SET @checked += 1; +IF OBJECT_ID(N'report.memory_usage_trends', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_usage_trends'; END; SET @checked += 1; +IF OBJECT_ID(N'report.running_jobs', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.running_jobs'; END; SET @checked += 1; +IF OBJECT_ID(N'report.finops_database_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_database_resource_usage'; END; SET @checked += 1; +IF OBJECT_ID(N'report.finops_utilization_efficiency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_utilization_efficiency'; END; SET @checked += 1; +IF OBJECT_ID(N'report.finops_peak_utilization', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_peak_utilization'; END; SET @checked += 1; +IF OBJECT_ID(N'report.finops_application_resource_usage', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.finops_application_resource_usage'; END; SET @checked += 1; + +PRINT ''; + +/* +Functions in report schema (1) +*/ +PRINT 'Checking report functions...'; + +IF OBJECT_ID(N'report.query_store_regressions', N'IF') IS NULL +AND OBJECT_ID(N'report.query_store_regressions', N'TF') IS NULL +AND OBJECT_ID(N'report.query_store_regressions', N'FN') IS NULL +BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_regressions'; END; +SET @checked += 1; + +PRINT ''; + +/* +Table count checks (minimum expected per schema) +*/ +PRINT 'Checking table counts...'; + +DECLARE + @collect_tables int, + @config_tables int; + +SELECT @collect_tables = COUNT_BIG(*) +FROM sys.tables AS t +WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'collect'; + +SELECT @config_tables = COUNT_BIG(*) +FROM sys.tables AS t +WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'config'; + +PRINT ' collect schema tables: ' + CONVERT(varchar(10), @collect_tables); +PRINT ' config schema tables: ' + CONVERT(varchar(10), @config_tables); + +IF @collect_tables < 21 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 21 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1; +IF @config_tables < 5 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 5 config tables, found ' + CONVERT(varchar(10), @config_tables); END; SET @checked += 1; + +PRINT ''; + +/* +Summary +*/ +PRINT '========================================'; +PRINT 'Checked ' + CONVERT(varchar(10), @checked) + ' objects'; + +IF @missing > 0 +BEGIN + PRINT 'FAILED: ' + CONVERT(varchar(10), @missing) + ' object(s) missing'; + RAISERROR('CI validation failed: %d object(s) missing', 16, 1, @missing); +END; +ELSE +BEGIN + PRINT 'PASSED: All objects present'; +END; + +PRINT '========================================'; +GO diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9dae595..8a23a2e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,280 +1,280 @@ -name: Build - -on: - push: - branches: [main, dev] - pull_request: - branches: [main, dev] - release: - types: [published] - -permissions: - contents: write - id-token: write - actions: read - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v5 - - - name: Detect changed paths - id: filter - if: github.event_name != 'release' - uses: dorny/paths-filter@v3 - with: - # On push events, compare against the previous commit on this branch - # (github.event.before). Without this, the action defaults to comparing - # against the default branch on non-default branch pushes, which would - # match every accumulated change and defeat the filter. - base: ${{ github.event_name == 'push' && github.event.before || '' }} - filters: | - lite_analysis: - - 'Lite/Analysis/**' - - 'Lite/Services/**' - - 'Lite/DuckDb/**' - - 'Lite/Models/**' - - 'Lite.Tests/**' - - 'Lite/PerformanceMonitorLite.csproj' - installer: - - 'Installer/**' - - 'Installer.Core/**' - - 'Installer.Tests/**' - - 'install/**' - - 'upgrades/**' - # True when any non-documentation file changed. Documentation-only - # changes (e.g. a CHANGELOG or README edit) skip the build/test/ - # publish steps below — there is nothing to compile. The job still - # runs so the required 'build' check reports a result. - code: - - '**' - - '!**/*.md' - - - name: Setup .NET 10.0 - if: steps.filter.outputs.code != 'false' - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Restore dependencies - if: steps.filter.outputs.code != 'false' - run: | - dotnet restore Dashboard/Dashboard.csproj --locked-mode - dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode - dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - dotnet restore Installer.Tests/Installer.Tests.csproj --locked-mode - - - name: Build Lite.Tests - if: steps.filter.outputs.code != 'false' - run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - - name: Build Installer.Tests - if: steps.filter.outputs.code != 'false' - run: dotnet build Installer.Tests/Installer.Tests.csproj -c Release --no-restore - - - name: Run Lite fast tests - if: steps.filter.outputs.code != 'false' - run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~AnomalyDetectorTests&FullyQualifiedName!~FactCollectorTests&FullyQualifiedName!~FactCollectorMiseryTests&FullyQualifiedName!~BaselineProviderTests&FullyQualifiedName!~InferenceEngineTests&FullyQualifiedName!~ScenarioTests&FullyQualifiedName!~AnalysisServiceTests" - - - name: Run Lite analysis-heavy tests - if: steps.filter.outputs.lite_analysis == 'true' || github.event_name == 'release' - run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~AnomalyDetectorTests|FullyQualifiedName~FactCollectorTests|FullyQualifiedName~FactCollectorMiseryTests|FullyQualifiedName~BaselineProviderTests|FullyQualifiedName~InferenceEngineTests|FullyQualifiedName~ScenarioTests|FullyQualifiedName~AnalysisServiceTests" - - - name: Run Installer tests - if: steps.filter.outputs.installer == 'true' || github.event_name == 'release' - run: dotnet test Installer.Tests/Installer.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~VersionDetectionTests&FullyQualifiedName!~IdempotencyTests&FullyQualifiedName!~AdversarialTests" - - - name: Get version - if: steps.filter.outputs.code != 'false' - id: version - shell: pwsh - run: | - $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - - - name: Publish Dashboard - if: steps.filter.outputs.code != 'false' - run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - - - name: Publish Dashboard (self-contained for Velopack) - if: github.event_name == 'release' - run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack - - - name: Publish Lite - if: steps.filter.outputs.code != 'false' - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - - - name: Publish Lite (self-contained for Velopack) - if: github.event_name == 'release' - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack - - - name: Publish CLI Installer - if: steps.filter.outputs.code != 'false' - run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release - - - name: Package release artifacts - if: github.event_name == 'release' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - New-Item -ItemType Directory -Force -Path releases - - # Dashboard ZIP — portable artifact for advanced/air-gapped users. - # The README points end users at Setup.exe (Velopack) because it sets up Start Menu - # shortcuts and Apps & Features registration; this ZIP is the explicit fallback. - Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force - - # Lite ZIP — same rationale. - Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - - # Installer ZIP (CLI + SQL scripts) — still shipped for server-side install - $instDir = 'publish/Installer' - New-Item -ItemType Directory -Force -Path $instDir - New-Item -ItemType Directory -Force -Path "$instDir/install" - New-Item -ItemType Directory -Force -Path "$instDir/upgrades" - - Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir - Copy-Item 'install/*.sql' "$instDir/install/" - if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } - if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } - if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } - - Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force - - - name: Upload Dashboard for signing - if: github.event_name == 'release' - id: upload-dashboard - uses: actions/upload-artifact@v6 - with: - name: Dashboard-unsigned - path: publish/Dashboard/ - - - name: Upload Lite for signing - if: github.event_name == 'release' - id: upload-lite - uses: actions/upload-artifact@v6 - with: - name: Lite-unsigned - path: publish/Lite/ - - - name: Upload Installer for signing - if: github.event_name == 'release' - id: upload-installer - uses: actions/upload-artifact@v6 - with: - name: Installer-unsigned - path: publish/Installer/ - - - name: Sign Dashboard - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v2 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Dashboard' - github-artifact-id: '${{ steps.upload-dashboard.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Dashboard' - - - name: Sign Lite - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v2 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Lite' - github-artifact-id: '${{ steps.upload-lite.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Lite' - - - name: Sign Installer - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v2 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Installers' - github-artifact-id: '${{ steps.upload-installer.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Installer' - - - name: Replace with signed artifacts - if: github.event_name == 'release' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - # Re-zip signed files into release archives - Remove-Item "releases/PerformanceMonitorDashboard-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force - Remove-Item "releases/PerformanceMonitorLite-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force - - - name: Create Velopack release (Dashboard) - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - dotnet tool install -g vpk - New-Item -ItemType Directory -Force -Path releases/velopack-dashboard - New-Item -ItemType Directory -Force -Path releases/velopack-lite - - # Dashboard: download previous + pack - vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard - - # Lite: download previous + pack - vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite - - - name: Generate checksums - if: github.event_name == 'release' - shell: pwsh - run: | - $checksums = Get-ChildItem releases/*.zip | ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" - } - $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8 - Write-Host "Checksums:" - $checksums | ForEach-Object { Write-Host $_ } - - - name: Upload release assets - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber - - - name: Upload Dashboard Velopack artifacts - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN - - - name: Upload Lite Velopack artifacts - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN +name: Build + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + release: + types: [published] + +permissions: + contents: write + id-token: write + actions: read + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v5 + + - name: Detect changed paths + id: filter + if: github.event_name != 'release' + uses: dorny/paths-filter@v3 + with: + # On push events, compare against the previous commit on this branch + # (github.event.before). Without this, the action defaults to comparing + # against the default branch on non-default branch pushes, which would + # match every accumulated change and defeat the filter. + base: ${{ github.event_name == 'push' && github.event.before || '' }} + filters: | + lite_analysis: + - 'Lite/Analysis/**' + - 'Lite/Services/**' + - 'Lite/DuckDb/**' + - 'Lite/Models/**' + - 'Lite.Tests/**' + - 'Lite/PerformanceMonitorLite.csproj' + installer: + - 'Installer/**' + - 'Installer.Core/**' + - 'Installer.Tests/**' + - 'install/**' + - 'upgrades/**' + # True when any non-documentation file changed. Documentation-only + # changes (e.g. a CHANGELOG or README edit) skip the build/test/ + # publish steps below — there is nothing to compile. The job still + # runs so the required 'build' check reports a result. + code: + - '**' + - '!**/*.md' + + - name: Setup .NET 10.0 + if: steps.filter.outputs.code != 'false' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Restore dependencies + if: steps.filter.outputs.code != 'false' + run: | + dotnet restore Dashboard/Dashboard.csproj --locked-mode + dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode + dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode + dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + dotnet restore Installer.Tests/Installer.Tests.csproj --locked-mode + + - name: Build Lite.Tests + if: steps.filter.outputs.code != 'false' + run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore + + - name: Build Installer.Tests + if: steps.filter.outputs.code != 'false' + run: dotnet build Installer.Tests/Installer.Tests.csproj -c Release --no-restore + + - name: Run Lite fast tests + if: steps.filter.outputs.code != 'false' + run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~AnomalyDetectorTests&FullyQualifiedName!~FactCollectorTests&FullyQualifiedName!~FactCollectorMiseryTests&FullyQualifiedName!~BaselineProviderTests&FullyQualifiedName!~InferenceEngineTests&FullyQualifiedName!~ScenarioTests&FullyQualifiedName!~AnalysisServiceTests" + + - name: Run Lite analysis-heavy tests + if: steps.filter.outputs.lite_analysis == 'true' || github.event_name == 'release' + run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName~AnomalyDetectorTests|FullyQualifiedName~FactCollectorTests|FullyQualifiedName~FactCollectorMiseryTests|FullyQualifiedName~BaselineProviderTests|FullyQualifiedName~InferenceEngineTests|FullyQualifiedName~ScenarioTests|FullyQualifiedName~AnalysisServiceTests" + + - name: Run Installer tests + if: steps.filter.outputs.installer == 'true' || github.event_name == 'release' + run: dotnet test Installer.Tests/Installer.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~VersionDetectionTests&FullyQualifiedName!~IdempotencyTests&FullyQualifiedName!~AdversarialTests" + + - name: Get version + if: steps.filter.outputs.code != 'false' + id: version + shell: pwsh + run: | + $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + + - name: Publish Dashboard + if: steps.filter.outputs.code != 'false' + run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard + + - name: Publish Dashboard (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack + + - name: Publish Lite + if: steps.filter.outputs.code != 'false' + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite + + - name: Publish Lite (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack + + - name: Publish CLI Installer + if: steps.filter.outputs.code != 'false' + run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release + + - name: Package release artifacts + if: github.event_name == 'release' + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + New-Item -ItemType Directory -Force -Path releases + + # Dashboard ZIP — portable artifact for advanced/air-gapped users. + # The README points end users at Setup.exe (Velopack) because it sets up Start Menu + # shortcuts and Apps & Features registration; this ZIP is the explicit fallback. + Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force + + # Lite ZIP — same rationale. + Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force + + # Installer ZIP (CLI + SQL scripts) — still shipped for server-side install + $instDir = 'publish/Installer' + New-Item -ItemType Directory -Force -Path $instDir + New-Item -ItemType Directory -Force -Path "$instDir/install" + New-Item -ItemType Directory -Force -Path "$instDir/upgrades" + + Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir + Copy-Item 'install/*.sql' "$instDir/install/" + if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } + if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } + if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } + + Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + + - name: Upload Dashboard for signing + if: github.event_name == 'release' + id: upload-dashboard + uses: actions/upload-artifact@v6 + with: + name: Dashboard-unsigned + path: publish/Dashboard/ + + - name: Upload Lite for signing + if: github.event_name == 'release' + id: upload-lite + uses: actions/upload-artifact@v6 + with: + name: Lite-unsigned + path: publish/Lite/ + + - name: Upload Installer for signing + if: github.event_name == 'release' + id: upload-installer + uses: actions/upload-artifact@v6 + with: + name: Installer-unsigned + path: publish/Installer/ + + - name: Sign Dashboard + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Dashboard' + github-artifact-id: '${{ steps.upload-dashboard.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Dashboard' + + - name: Sign Lite + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Lite' + github-artifact-id: '${{ steps.upload-lite.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Lite' + + - name: Sign Installer + if: github.event_name == 'release' + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceMonitor' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Installers' + github-artifact-id: '${{ steps.upload-installer.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/Installer' + + - name: Replace with signed artifacts + if: github.event_name == 'release' + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + # Re-zip signed files into release archives + Remove-Item "releases/PerformanceMonitorDashboard-$version.zip" -ErrorAction SilentlyContinue + Compress-Archive -Path 'signed/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force + Remove-Item "releases/PerformanceMonitorLite-$version.zip" -ErrorAction SilentlyContinue + Compress-Archive -Path 'signed/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force + Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue + Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + + - name: Create Velopack release (Dashboard) + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + dotnet tool install -g vpk + New-Item -ItemType Directory -Force -Path releases/velopack-dashboard + New-Item -ItemType Directory -Force -Path releases/velopack-lite + + # Dashboard: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard + + # Lite: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite + + - name: Generate checksums + if: github.event_name == 'release' + shell: pwsh + run: | + $checksums = Get-ChildItem releases/*.zip | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" + } + $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8 + Write-Host "Checksums:" + $checksums | ForEach-Object { Write-Host $_ } + + - name: Upload release assets + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber + + - name: Upload Dashboard Velopack artifacts + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + + - name: Upload Lite Velopack artifacts + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN diff --git a/.github/workflows/check-pr-branch.yml b/.github/workflows/check-pr-branch.yml index 88fa6fae..1c14086f 100644 --- a/.github/workflows/check-pr-branch.yml +++ b/.github/workflows/check-pr-branch.yml @@ -1,21 +1,21 @@ -name: Check pull request target branch -on: - pull_request_target: - types: - - opened - - reopened - - synchronize - - edited -jobs: - check-branches: - runs-on: ubuntu-latest - steps: - - name: Check branches - env: - HEAD_REF: ${{ github.head_ref }} - BASE_REF: ${{ github.base_ref }} - run: | - if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then - echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead." - exit 1 - fi +name: Check pull request target branch +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited +jobs: + check-branches: + runs-on: ubuntu-latest + steps: + - name: Check branches + env: + HEAD_REF: ${{ github.head_ref }} + BASE_REF: ${{ github.base_ref }} + run: | + if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then + echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead." + exit 1 + fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index fd33e514..f886a01a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,145 +1,145 @@ -name: Nightly Build - -on: - schedule: - # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT) - - cron: '0 6 * * *' - workflow_dispatch: # manual trigger - -permissions: - contents: write - -jobs: - check: - runs-on: ubuntu-latest - outputs: - has_changes: ${{ steps.check.outputs.has_changes }} - steps: - - uses: actions/checkout@v5 - with: - ref: dev - fetch-depth: 0 - - - name: Check for new commits in last 24 hours - id: check - run: | - RECENT=$(git log --since="24 hours ago" --oneline | head -1) - if [ -n "$RECENT" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "New commits found — building nightly" - else - echo "has_changes=false" >> $GITHUB_OUTPUT - echo "No new commits — skipping nightly build" - fi - - build: - needs: check - if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' - runs-on: windows-latest - - steps: - - uses: actions/checkout@v5 - with: - ref: dev - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Set nightly version - id: version - shell: pwsh - run: | - $base = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - $date = Get-Date -Format "yyyyMMdd" - $nightly = "$base-nightly.$date" - echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT - echo "Nightly version: $nightly" - - - name: Restore dependencies - run: | - dotnet restore Dashboard/Dashboard.csproj --locked-mode - dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode - dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - - - name: Run tests - run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --verbosity normal - - - name: Publish Dashboard - run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - - - name: Publish Lite - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - - - name: Publish CLI Installer - run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release - - - name: Package artifacts - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - New-Item -ItemType Directory -Force -Path releases - - Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force - Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - - $instDir = 'publish/Installer' - New-Item -ItemType Directory -Force -Path $instDir - New-Item -ItemType Directory -Force -Path "$instDir/install" - New-Item -ItemType Directory -Force -Path "$instDir/upgrades" - - Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir - Copy-Item 'install/*.sql' "$instDir/install/" - if (Test-Path 'install/templates') { Copy-Item 'install/templates' "$instDir/install/templates" -Recurse -ErrorAction SilentlyContinue } - if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } - if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } - if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } - - Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force - - - name: Generate checksums - shell: pwsh - run: | - $checksums = Get-ChildItem releases/*.zip | ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" - } - $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8 - Write-Host "Checksums:" - $checksums | ForEach-Object { Write-Host $_ } - - - name: Delete previous nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0 - shell: pwsh - - - name: Create nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - $sha = git rev-parse --short HEAD - $body = @" - Automated nightly build from ``dev`` branch. - - **Version:** ``$version`` - **Commit:** ``$sha`` - **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC") - - > These builds include the latest changes and may be unstable. - > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest). - "@ - - gh release create nightly ` - --target dev ` - --title "Nightly Build ($version)" ` - --notes $body ` - --prerelease ` - releases/*.zip releases/SHA256SUMS.txt +name: Nightly Build + +on: + schedule: + # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT) + - cron: '0 6 * * *' + workflow_dispatch: # manual trigger + +permissions: + contents: write + +jobs: + check: + runs-on: ubuntu-latest + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + steps: + - uses: actions/checkout@v5 + with: + ref: dev + fetch-depth: 0 + + - name: Check for new commits in last 24 hours + id: check + run: | + RECENT=$(git log --since="24 hours ago" --oneline | head -1) + if [ -n "$RECENT" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "New commits found — building nightly" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No new commits — skipping nightly build" + fi + + build: + needs: check + if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' + runs-on: windows-latest + + steps: + - uses: actions/checkout@v5 + with: + ref: dev + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Set nightly version + id: version + shell: pwsh + run: | + $base = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $date = Get-Date -Format "yyyyMMdd" + $nightly = "$base-nightly.$date" + echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT + echo "Nightly version: $nightly" + + - name: Restore dependencies + run: | + dotnet restore Dashboard/Dashboard.csproj --locked-mode + dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode + dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode + dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + + - name: Run tests + run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --verbosity normal + + - name: Publish Dashboard + run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard + + - name: Publish Lite + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite + + - name: Publish CLI Installer + run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release + + - name: Package artifacts + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + New-Item -ItemType Directory -Force -Path releases + + Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force + Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force + + $instDir = 'publish/Installer' + New-Item -ItemType Directory -Force -Path $instDir + New-Item -ItemType Directory -Force -Path "$instDir/install" + New-Item -ItemType Directory -Force -Path "$instDir/upgrades" + + Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir + Copy-Item 'install/*.sql' "$instDir/install/" + if (Test-Path 'install/templates') { Copy-Item 'install/templates' "$instDir/install/templates" -Recurse -ErrorAction SilentlyContinue } + if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } + if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } + if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } + if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } + + Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + + - name: Generate checksums + shell: pwsh + run: | + $checksums = Get-ChildItem releases/*.zip | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" + } + $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8 + Write-Host "Checksums:" + $checksums | ForEach-Object { Write-Host $_ } + + - name: Delete previous nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0 + shell: pwsh + + - name: Create nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + $sha = git rev-parse --short HEAD + $body = @" + Automated nightly build from ``dev`` branch. + + **Version:** ``$version`` + **Commit:** ``$sha`` + **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC") + + > These builds include the latest changes and may be unstable. + > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest). + "@ + + gh release create nightly ` + --target dev ` + --title "Nightly Build ($version)" ` + --notes $body ` + --prerelease ` + releases/*.zip releases/SHA256SUMS.txt diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml index 84c817e5..330c8486 100644 --- a/.github/workflows/sql-validation.yml +++ b/.github/workflows/sql-validation.yml @@ -1,79 +1,79 @@ -name: SQL Validation - -on: - push: - branches: [dev] - paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] - pull_request: - branches: [dev] - paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] - -jobs: - validate-sql: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - version: '2017' - image: mcr.microsoft.com/mssql/server:2017-latest - - version: '2019' - image: mcr.microsoft.com/mssql/server:2019-latest - - version: '2022' - image: mcr.microsoft.com/mssql/server:2022-latest - - version: '2025' - image: mcr.microsoft.com/mssql/server:2025-latest - - name: SQL Server ${{ matrix.version }} - - services: - sqlserver: - image: ${{ matrix.image }} - env: - ACCEPT_EULA: Y - MSSQL_SA_PASSWORD: CI_Test#2026! - ports: - - 1433:1433 - options: >- - --health-cmd "grep -q 'SQL Server is now ready for client connections' /var/opt/mssql/log/errorlog || exit 1" - --health-interval 10s - --health-timeout 5s - --health-retries 15 - - steps: - - uses: actions/checkout@v5 - - - name: Install sqlcmd - run: | - # Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts - if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then - curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg - source /etc/os-release - echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list - fi - sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 - - - name: Run install scripts - env: - SA_PASSWORD: CI_Test#2026! - run: | - for script in $(ls install/[0-9]*.sql | sort); do - filename=$(basename "$script") - - # Skip scripts that require SQL Agent or are test/troubleshooting - case "$filename" in - 45_*) echo "Skipping $filename (requires SQL Agent)"; continue;; - 97_*|98_*|99_*) echo "Skipping $filename (test/troubleshooting)"; continue;; - esac - - echo "Running $filename..." - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "$script" - echo " OK" - done - - - name: Validate installation - env: - SA_PASSWORD: CI_Test#2026! - run: | - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql +name: SQL Validation + +on: + push: + branches: [dev] + paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] + pull_request: + branches: [dev] + paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] + +jobs: + validate-sql: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - version: '2017' + image: mcr.microsoft.com/mssql/server:2017-latest + - version: '2019' + image: mcr.microsoft.com/mssql/server:2019-latest + - version: '2022' + image: mcr.microsoft.com/mssql/server:2022-latest + - version: '2025' + image: mcr.microsoft.com/mssql/server:2025-latest + + name: SQL Server ${{ matrix.version }} + + services: + sqlserver: + image: ${{ matrix.image }} + env: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: CI_Test#2026! + ports: + - 1433:1433 + options: >- + --health-cmd "grep -q 'SQL Server is now ready for client connections' /var/opt/mssql/log/errorlog || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 15 + + steps: + - uses: actions/checkout@v5 + + - name: Install sqlcmd + run: | + # Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts + if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then + curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + source /etc/os-release + echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list + fi + sudo apt-get update + sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 + + - name: Run install scripts + env: + SA_PASSWORD: CI_Test#2026! + run: | + for script in $(ls install/[0-9]*.sql | sort); do + filename=$(basename "$script") + + # Skip scripts that require SQL Agent or are test/troubleshooting + case "$filename" in + 45_*) echo "Skipping $filename (requires SQL Agent)"; continue;; + 97_*|98_*|99_*) echo "Skipping $filename (test/troubleshooting)"; continue;; + esac + + echo "Running $filename..." + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "$script" + echo " OK" + done + + - name: Validate installation + env: + SA_PASSWORD: CI_Test#2026! + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql diff --git a/.gitignore b/.gitignore index 5af2ea72..1937a3d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,64 +1,64 @@ -################################################################################ -# This .gitignore file was automatically created by Microsoft(R) Visual Studio. -################################################################################ - -# Build output -bin/ -obj/ -publish/ -releases/ - -# Visual Studio -*.suo -*.user -*.vsidx -.vs/ - -# NuGet -packages/ -*.nupkg - -# SQLite databases -*.sqlite - -# Lock files -*.lock - -# Sensitive configuration (connection strings) -appsettings.json - -# Logs -*.log - -# OS files -Thumbs.db -.DS_Store - -# Temp and backup files -*.tmp -*.bak -*.temp -*.orig -*~ - -# Install logs (generated by installer) -PerformanceMonitor_Install_*.txt - -# Internal development files (not for public release) -.internal/ -.claude/ -CLAUDE.md -Dashboard/todo.md -Lite/TODO.md -nul - -# Lite runtime configuration (user-specific) -Lite/config/servers.json -Lite/servers.json -Lite/collection_schedule.json - -# Plans directory -plans/ - -# Community scripts (user-provided, not bundled) -community/*.sql +################################################################################ +# This .gitignore file was automatically created by Microsoft(R) Visual Studio. +################################################################################ + +# Build output +bin/ +obj/ +publish/ +releases/ + +# Visual Studio +*.suo +*.user +*.vsidx +.vs/ + +# NuGet +packages/ +*.nupkg + +# SQLite databases +*.sqlite + +# Lock files +*.lock + +# Sensitive configuration (connection strings) +appsettings.json + +# Logs +*.log + +# OS files +Thumbs.db +.DS_Store + +# Temp and backup files +*.tmp +*.bak +*.temp +*.orig +*~ + +# Install logs (generated by installer) +PerformanceMonitor_Install_*.txt + +# Internal development files (not for public release) +.internal/ +.claude/ +CLAUDE.md +Dashboard/todo.md +Lite/TODO.md +nul + +# Lite runtime configuration (user-specific) +Lite/config/servers.json +Lite/servers.json +Lite/collection_schedule.json + +# Plans directory +plans/ + +# Community scripts (user-provided, not bundled) +community/*.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 08689041..b28b938b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,978 +1,1055 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [2.11.0] - 2026-05-19 - -### Important - -- **.NET 10 upgrade** — Dashboard, Lite, Installer, and Installer.Core now target `net10.0` (Windows projects target `net10.0-windows`). Building from source now requires the .NET 10 SDK; CI is pinned to 10.0.204 via `global.json` for reproducible builds. End users running prebuilt Velopack installers do not need to install anything separately — runtime is bundled ([#958]) -- **Setup.exe is now the recommended install path** for Dashboard and Lite — the README steers users to the Velopack `Setup.exe`, which installs to `%LocalAppData%`, registers the apps under Apps & Features, creates Start Menu and Desktop shortcuts, and wires up auto-update. Portable ZIPs are still produced for both apps (CI release pipeline and local build scripts) as a fallback for advanced or air-gapped users. The Installer ZIP (CLI installer + SQL scripts) is unchanged -- **Shared `servers.json` location** — Dashboard and Lite now store `servers.json` under `%ProgramData%\PerformanceMonitor{Dashboard,Lite}\` so every Windows user on the same machine shares one server list. First run migrates an existing per-user `servers.json` to the new location and grants Authenticated Users Modify on the directory. SQL credentials remain per-user in Windows Credential Manager — each DBA re-enters SQL passwords on first connect; Windows Auth works with no re-entry - -### Added - -- **One-click snooze from the alert tray popup** in Lite — snooze an alert directly from the tray notification balloon without opening the main window ([#944]) -- **Snooze hint in email and Teams/Slack alert payloads** — alert messages now show the snooze duration / scheduled wake time when an alert is fired while a snooze is active ([#944]) -- **Process memory logging per collection cycle** in Lite — the collector now logs working set and private bytes at the end of each cycle, making it easier to track memory growth in long-running sessions - -### Changed - -- **Lite compaction memory tuning** ([#933]) — multiple changes to make parquet compaction robust on wide-row tables and large datasets: - - Cap the main collector connection's `memory_limit` and raise it transiently only for the `COPY` step - - Detect compaction `EXCLUDE` columns per merge step instead of once up front - - Raise the compaction `memory_limit` floor to 4 GB - - Set DuckDB `temp_directory` explicitly so spill files don't blow the OS temp drive - - Compact parquet in size-budgeted batches instead of one mega-batch -- **Trace collectors honor `config.collector_database_exclusions`** ([#887] follow-up) — the trace-file based collectors now filter against the exclusions table, matching the behavior of the eight DMV-based per-database collectors shipped in v2.9.0 -- **InstallerGui project directory removed** — the WPF InstallerGui was retired in v2.9.0 in favor of the Dashboard's integrated Add Server dialog. The project directory has now been deleted from the repo -- **Build warnings cleaned up** across Lite, Dashboard, and Installer ([#945]) -- **GitHub Actions runners bumped** to Node 24-compatible major versions to silence deprecation warnings - -### Fixed - -- **Re-run `installation_history` column widening** for servers that crossed v2.4.0 → v2.5.0 before PR #828's fix shipped in v2.7.0. Those servers ran the original widen script as a no-op against `master`, then advanced their installer_version past 2.5, so the now-fixed script never reapplied. Adds an idempotent ALTER under an `IF EXISTS` guard checking `max_length = 510` ([#828]) -- **Mute rules preserved across size-triggered DuckDB reset** in Lite — when the local DuckDB exceeded the configured size budget and was reset, mute rules were being lost. They now survive the reset ([#938]) -- **Chart tooltips break after tab switch** — root-cause fix for the popup-wedge issue first patched in v2.10.0. Both the Memory tab handlers and `CorrelatedCrosshairManager` are now resilient to tab churn ([#916], [#937]) -- **Stale `Monitor_LongQueries_*.trc` files cleaned up** by `config.data_retention` — the trace-file cleanup step previously left old `.trc` files behind on disk ([#951]) -- **Nullability guards** added to the remaining comparison overlay tasks that were producing CS86xx warnings - -[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 -[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887 -[#916]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/916 -[#933]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/933 -[#937]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/937 -[#938]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/938 -[#944]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/944 -[#945]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/945 -[#951]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/951 -[#958]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/958 - -## [2.10.0] - 2026-05-04 - -### Fixed - -- **Memory tab tooltip** stops working after switching away and returning to the tab. Both Dashboard and Lite Memory tab crosshair tooltip handlers now reattach correctly on tab re-entry; the same popup-wedge fix is also applied to `CorrelatedCrosshairManager` ([#916]) -- **FinOps memory recommendation** now bases sizing on a 7-day P95 of memory samples instead of a single snapshot, so recommendations no longer swing based on instantaneous workload state. Applied in both Dashboard and Lite ([#917]) - -### Changed - -- **Per-database grants for FinOps Index Analysis** documented in the README — sp_IndexCleanup-backed Index Analysis requires per-database `EXECUTE` grants on each user database you want to analyze ([#915]) - -[#915]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/915 -[#917]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/917 - -## [2.9.0] - 2026-04-29 - -### Important - -- **Breaking change to `config.data_retention`** — the `@truncate_all` parameter has been removed. Pass `@retention_days = 0` for the same behavior. `@retention_days = NULL` (default) respects per-collector retention from `config.collection_schedule` with a 30-day fallback for unscheduled tables; `@retention_days = N > 0` overrides every table to N days. Any existing Agent jobs or scripts calling `data_retention @truncate_all = 1` need to be updated ([#900]) -- **New `config.collector_database_exclusions` table** for per-database collector exclusions. Eight per-database collectors filter against this table; system databases remain hard-skipped by the collectors themselves. Existing installs get the table on the next upgrade — `install/01_install_database.sql` and `config.ensure_config_tables` both create it under an `IF OBJECT_ID … IS NULL` guard ([#887]) - -### Added - -- **Per-database collector exclusions** — exclude noisy or unimportant databases from per-database collectors. Dashboard side adds `config.collector_database_exclusions` and filters 8 collectors (`query_stats`, `query_store`, `procedure_stats`, `file_io_stats`, `waiting_tasks`, `database_configuration`, `database_size_stats`, `server_properties`). Lite side adds an `ExcludedDatabases` list per server in `servers.json` and filters 9 collectors ([#887]) -- **`Off` collection preset** — `EXECUTE config.apply_collection_preset @preset_name = N'Off'` disables every collector in one call. Pair with a second Agent job that applies a non-`Off` preset at the start of your active window for overnight / quiet-hours scoping. Non-`Off` presets now also set `enabled = 1` across the board so the switch from `Off → Balanced` reliably resumes collection ([#888]) -- **Purge Now action** in Manage Servers — confirm dialog with a mode picker (Use configured / 1 / 3 / 7 / Custom / All) drives `config.data_retention`; right-click menu on the Manage Servers grid mirrors every per-row action (Edit, Toggle Favorite, Check Server Version, Purge Now, Remove) ([#900]) -- **Total non-idle CPU on Lite Overview** — headline value shows total CPU with the SQL-only value alongside (e.g. `64% (SQL 60%)`); new `CpuAlertMode` dropdown in Settings → Alerts (Total / SqlOnly) drives both the alert evaluator and headline color; tray notifications and email alerts label the value as "Total CPU" or "SQL CPU" ([#899]) -- **Resume gap detection** — `query_stats`, `procedure_stats`, and `query_store` collectors skip the historical sweep on first run after an Off preset, Agent stoppage, or server reboot. When the last successful run is older than 5× the configured `frequency_minutes` (floored at 30 minutes), the cutoff clamps to `SYSDATETIME()` so only forward-going data is collected on resume — preventing the tempdb blowout that hit the original reporter ([#892]) -- **Right-click View Plan** on Dashboard Blocked Process Reports (View Blocked Plan + View Blocking Plan), Dashboard Deadlocks, and Lite Deadlocks grids. Plan lookup hits `sys.dm_exec_query_stats` + `sys.dm_exec_text_query_plan` on the monitored server, falling back to `executionStack/frame` entries when the process-level `sql_handle` is empty or evicted ([#880]) -- **Open Log Folder** sidebar button in Lite — opens `%LocalAppData%\PerformanceMonitorLite\logs\` in Explorer for grabbing historical logs to attach to bug reports. Sits below View Log, which retains its existing behavior of opening today's log file ([#873]) -- **Installed Version column** in the Manage Servers grid for both Dashboard and Lite. Dashboard shows the PerformanceMonitor database version on each server (probed in parallel via `GetInstalledVersionAsync`, with `Not installed` / `Unavailable` fallbacks). Lite shows the running app's own version on every row, mirroring Full's column header for consistency. -- **Lite-style server card indicators in Full** — back-ported the Ellipse-with-DataTriggers status dot (Online/Offline/Warning/Unknown) and the right-aligned favorite star from Lite to the Full Dashboard's server list, matching Lite's visual treatment. -- **Architecture overview** at `docs/how-collection-works.md` covering the minute loop, dispatcher, collector shape, `config.collection_schedule`, retention, and the Dashboard read path - -### Changed - -- **PlanIconMapper synced** with PerformanceStudio v1.9.0 improvements — columnstore storage type on scan/delete/insert/update/merge operators routes to `columnstore_index_*` icons (covers CCI and NCCI); `Parallelism` operator subtypes (Repartition Streams, Distribute Streams, Gather Streams) get their own icons -- **`Microsoft.Data.SqlClient` 6.1.4 → 7.0.1** — major-version bump. Azure/Entra dependencies were split out of the core package in 7.0; `Microsoft.Data.SqlClient.Extensions.Azure 1.0.0` added to Dashboard, Lite, and Installer.Core for `ActiveDirectoryInteractive` connections -- **`ModelContextProtocol` 0.7.0-preview.1 → 1.2.0** — off the preview tag and onto stable 1.x in Dashboard and Lite -- **`DuckDB.NET` 1.5.0 → 1.5.2** in Lite — fixes unbounded row group growth on indexed tables under repeated load+insert cycles, memory leaks and race conditions in prepared statements, WAL checkpoint marking, and Windows UTF-8/UTF-16 handling -- **`Microsoft.Extensions.*` 10.0.5 → 10.0.7**, **`System.Text.Json` 10.0.5 → 10.0.7**, **`ScottPlot.WPF` 5.1.57 → 5.1.58** — patch-level bumps with no expected behavioral change -- **Theme polish** on grids and plan viewer in Dashboard and Lite — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) ([#889]) - -### Fixed - -- **Install loop timeout** raised from 5 minutes to 1 hour. `install/98_validate_installation.sql` runs every enabled collector with `@debug = 1` in a single batch; on large databases (reporter had 7.2M rows in `collect.query_stats`, 4.4M in `collect.query_store_data`) this took ~9 minutes and was blowing the 5-minute timeout, failing the install or upgrade ([#884]) - -[#873]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/873 -[#880]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/880 -[#884]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/884 -[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887 -[#888]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/888 -[#889]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/889 -[#892]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/892 -[#899]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/899 -[#900]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/900 - -## [2.8.0] - 2026-04-22 - -### Important - -- **New nonclustered indexes** on `collect.query_stats`, `collect.procedure_stats`, and `collect.query_store_data` to eliminate Eager Index Spools in Dashboard grid queries. On large installations these indexes may take several minutes to build; the upgrade script uses `ONLINE = ON` on Enterprise/Developer/Azure editions and falls back to offline on Standard/Web ([#835]) - -### Added - -- **Memory Pressure Events in Lite** — the collector, chart, and `get_memory_pressure_events` MCP tool previously only in the Full Edition are now available in Lite ([#865]) -- **Grid auto-scrolling** in Lite and Dashboard ([#843]) — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) - -### Changed - -- **PlanAnalyzer and BenefitScorer** synced with PerformanceStudio's Apr 9–16 improvements -- **Query/Procedure/Query Store stats** refactored to a phased DECOMPRESS approach; removed unhelpful `WAITFOR DECOMPRESS` filters -- **Query/Procedure/Query Store grids** capped to TOP 500 to prevent UI freezes on large datasets -- **Server tabs lazy-load** — only the visible server tab loads on startup; remaining tabs load on first visit -- **Webhook URLs (Dashboard)** encrypted with DPAPI via Windows Credential Manager — Lite webhook URLs remain in plaintext settings for now -- **DuckDB queries hardened** — parameterized values, escaped paths, fixed `IsArchiving` race -- **Lite chart axes and sub-tab styling** polished, then ported to Dashboard - -### Fixed - -- **Memory Pressure Events chart filter** was dropping valid rows; added MCP interpretation guidance ([#865]) -- **FinOps recommendation severity sort order** in Lite and Dashboard ([#872]) -- **Overview crosshair** disappearing after tab switches or layout passes -- **Blocked process report plan lookup** returning the wrong plan ([#867]) -- **FinOps TDE recommendation** flagging Standard edition on SQL Server 2019+ where TDE is free ([#854]) -- **Azure SQL DB collector** falls back to single-database mode when `master` is inaccessible ([#857]) -- **Azure SQL DB query snapshots** scoped to the current database ([#857]) -- **Azure SQL DB query snapshot prefilter** — request set is narrowed into `#temp` before joining DMVs to avoid Azure-specific execution plan issues ([#857]) -- **Azure SQL DB live query plans** — now skipped gracefully instead of erroring ([#857]) -- **Azure SQL DB memory_stats collector** — dropped `sys.dm_os_schedulers` which is blocked on elastic-pool contained users regardless of DB-scoped grants ([#857]) -- **Non-transient permission denials** now stop collector retries instead of looping forever ([#857]) - -[#835]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/835 -[#843]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/843 -[#854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/854 -[#857]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/857 -[#865]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/865 -[#867]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/867 -[#872]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/872 - -## [2.7.0] - 2026-04-13 - -### Added - -- **Host OS column** in Server Inventory for both Dashboard and Lite ([#748], [#823]) -- **Offline community script support** via `community/` directory for user-contributed scripts ([#814], [#822]) -- **MultiSubnetFailover connection option** in Dashboard and Lite for Always On availability groups ([#813], [#821]) - -### Changed - -- **PlanAnalyzer and ShowPlanParser** synced from PerformanceStudio with latest improvements ([#816]) -- **MCP query tools** optimized for large databases ([#826]) -- **Add Server dialog UX** improved with inline connection status and full-height window -- **"CPUs" renamed to "Logical CPUs"** for clarity in Lite ([#825]) - -### Fixed - -- **Dashboard auto-refresh stalling under load** — replaced DispatcherTimer with async Task.Delay loop to prevent priority starvation during heavy chart rendering ([#833], [#834]) -- **Lite auto-refresh silently skipping** every tick ([#824]) -- **Deadlock count not resetting** between collections ([#803], [#820]) -- **Upgrade filter skipping patch versions** during version comparison ([#817], [#819]) -- **Upgrade script executing against master** instead of PerformanceMonitor database ([#828]) -- **Duplicate release builds** triggering on both created and published events - -[#748]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/748 -[#803]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/803 -[#813]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/813 -[#814]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/814 -[#816]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/816 -[#817]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/817 -[#819]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/819 -[#820]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/820 -[#821]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/821 -[#822]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/822 -[#823]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/823 -[#824]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/824 -[#825]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/825 -[#826]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/826 -[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 -[#833]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/833 -[#834]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/834 - -## [2.6.0] - 2026-04-08 - -### Added - -- **Correlated timeline lanes** on Lite Overview and Dashboard — synchronized CPU, memory, waits, and TempDB trend lanes for at-a-glance correlation ([#688]) -- **Dynamic baselines and anomaly detection** in Lite and Dashboard — automatic baseline calculation with anomaly highlighting on key metrics ([#692], [#693]) -- **Query grid comparison** — before/after comparison mode for query grids in Lite and Dashboard with global Compare dropdown ([#687]) -- **Nonclustered index count badge** on modification operators in plan viewer ([#788]) -- **Upgrade detection in Edit Server** dialog — see pending upgrades without adding a new server ([#772]) -- **CLI installer interactive mode** prompts for trust-cert and encryption settings ([#784]) -- **SignPath code signing** — release binaries are now digitally signed via the [SignPath FOSS](https://signpath.io) program - -### Changed - -- **PlanAnalyzer Rule 3 (Serial Plan)** comprehensively refined — severity demotion for TRIVIAL and 0ms plans, `CouldNotGenerateValidParallelPlan` treated as actionable, all 25 `NonParallelPlanReason` values now covered -- **PlanAnalyzer warning rules** ported from PerformanceStudio improvements -- **Text readability** — replaced all muted/dim text colors with full foreground colors for readability - -### Fixed - -- **Embedded resource upgrade discovery** broken — upgrades silently returned zero results for Dashboard installs ([#772]) -- **Archive compaction OOM** on large parquet groups -- **CLI installer argument parsing** treating flags as positional args ([#786]) -- **Lite long-running query alerts** firing on stale DuckDB snapshots -- **FinOps Enterprise feature detection** now queries all databases and filters to TDE only ([#780]) -- **Second launch error** — now brings existing window to foreground instead ([#769]) -- **Overview tab Memory Grant** showing 0 for all timestamps ([#776]) -- **Lite FinOps Enterprise features** query error on servers without `database_id` column ([#777]) -- **Collector health status** incorrect for on-load collectors -- **CSV and clipboard exports** writing `System.Windows.Controls.StackPanel` as column headers instead of actual header text ([#805]) - -[#687]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/687 -[#688]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/688 -[#692]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/692 -[#693]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/693 -[#769]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/769 -[#772]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/772 -[#776]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/776 -[#777]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/777 -[#780]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/780 -[#784]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/784 -[#786]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/786 -[#788]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/788 -[#805]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/805 - -## [2.5.0] - 2026-03-30 - -### Important - -- **InstallerGui retired**: The standalone GUI installer has been removed. Installation, upgrade, and uninstall are now handled directly from the Dashboard's Add Server dialog, powered by the new Installer.Core shared library. The CLI installer continues to work as before. ([#755]) - -### Added - -- **Dashboard integrated installer** — Add Server dialog now installs, upgrades, and uninstalls PerformanceMonitor directly, replacing the standalone InstallerGui ([#755]) -- **Installer.Core shared library** — shared installation logic used by both the CLI installer and Dashboard ([#755]) -- **Overview tab** for Lite with 2x2 resource chart grid (CPU, Memory, Wait Stats, TempDB) ([#689]) -- **Chart drill-down** on CPU, Memory, TempDB, Blocking, and Deadlock charts in both Dashboard and Lite — right-click any chart point to jump to Active Queries for that time window ([#682]) -- **Grid-to-slicer overlay** for Query Stats, Procedure Stats, and Query Store tabs — click a row to overlay its trend on the slicer chart ([#683]) -- **Query heatmap** tab in both Dashboard and Lite — visual heat map of query activity over time ([#739], [#743]) -- **Webhook notifications** for alerts — configurable webhook endpoint for alert delivery ([#725]) -- **Per-server collector schedule intervals** — customize collection frequency per server ([#703]) -- **Investigate button** in Critical Issues grid — jump directly to relevant tab from an alert ([#684]) -- **Dismiss Selected** context menu and View Log sidebar button for alert management ([#718], [#740]) -- **Alert archival awareness** — dismissed_archive_alerts sidecar table, source column for live vs archived alerts, stale-data indicator, structured telemetry ([#718]) -- **Dashboard read-only connection intent** — connections use `ApplicationIntent=ReadOnly` where supported ([#728]) -- FUNDING.yml for GitHub Sponsors ([#752]) - -### Changed - -- **Installer architecture** refactored: CLI installer is now a thin wrapper over Installer.Core ([#755]) -- **DuckDB memory capped** at 2 GB during parquet compaction to prevent out-of-memory on large archives ([#758]) -- **Text rendering** improved with `TextOptions.TextFormattingMode="Display"` for sharper text ([#710]) -- **installation_history version columns** widened from nvarchar(255) to nvarchar(512) to handle long @@VERSION strings ([#712]) - -### Fixed - -- **Memory leaks in Lite** — delta cache, event handlers, and chart helpers properly disposed ([#758]) -- **Doomed transaction errors** in delta framework and ensure_collection_table — ROLLBACK now occurs before error logging ([#756]) -- **XACT_STATE check** added after third-party stored procedure calls (sp_HumanEventsBlockViewer, sp_BlitzLock) to prevent doomed transaction errors ([#695]) -- **CREATE DATABASE failure** when model database has large default file sizes ([#676]) -- **CPU metrics mixed** for different Azure SQL databases on the same logical server ([#680]) -- **Azure SQL DB vCore** FinOps calculations incorrect for serverless/vCore tiers ([#736]) -- **Webhook alert recording** not persisting correctly ([#726]) -- **Drill-down timezone** misalignment between chart and detail view ([#747], [#750]) -- **Drill-down refresh** losing context on auto-refresh ([#744]) -- **Drill-down target** incorrectly routing Memory to Memory Grants instead of Active Queries ([#706]) -- **Heatmap colorbar stacking** when switching between servers ([#746]) -- **Display mode pickers** not reflecting current state on tab switch ([#751]) -- **Slicer custom range** handling and sub-hour display issues ([#704]) -- **Overlay selection** lost on Dashboard auto-refresh ([#683]) -- **Numeric values** in alert details treated as strings instead of numbers ([#732]) -- **FinOps VM right-sizing** query error — `PERCENTILE_CONT` missing required `OVER()` clause -- **FinOps Enterprise features** query error on AWS RDS — `database_id` column not present in `sys.dm_db_persisted_sku_features` on RDS -- **FinOps right-click copy** broken on all Dashboard FinOps grids — context menu walked to row instead of grid -- **FinOps recommendation error logs** now include server name for easier troubleshooting - -### Deprecated - -- **InstallerGui** — removed from the solution and build pipeline. Use the Dashboard or CLI installer instead. ([#755]) - -[#676]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/676 -[#680]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/680 -[#682]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/682 -[#683]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/683 -[#684]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/684 -[#689]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/689 -[#695]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/695 -[#703]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/703 -[#704]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/704 -[#706]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/706 -[#710]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/710 -[#712]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/712 -[#718]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/718 -[#725]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/725 -[#726]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/726 -[#728]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/728 -[#732]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/732 -[#736]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/736 -[#739]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/739 -[#740]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/740 -[#743]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/743 -[#744]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/744 -[#746]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/746 -[#747]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/747 -[#750]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/750 -[#751]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/751 -[#752]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/752 -[#755]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/755 -[#756]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/756 -[#758]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/758 - -## [2.4.0] - 2026-03-23 - -### Important - -- **Lite data directory moved**: Lite now stores all data (config, DuckDB, archives, logs) in `%LOCALAPPDATA%\PerformanceMonitorLite\` instead of alongside the executable. This enables auto-update support. Existing users upgrading from the zip should use **Import Settings** and **Import Data** to bring over their configuration and historical data from the old install folder. -- **Auto-update (Windows)**: Both Dashboard and Lite now include Velopack auto-update. Users who install via the new Setup.exe will receive update notifications and can download + apply updates from within the app. Existing zip distribution continues to work as before. - -### Added - -- **Velopack auto-update** for Dashboard and Lite — check on startup, download + apply from About window with confirmation dialog before restart ([#635]) -- **Per-tab time range slicers** on Dashboard and Lite query tabs — filter data directly on each tab without changing global time range ([#655], [#662]) -- **Time display picker** (Local/UTC/Server) in Dashboard and Lite toolbars ([#646]) -- **Import Settings** — renamed from "Import Connections", now also copies `settings.json`, `collection_schedule.json`, `ignored_wait_types.json`, and `alert_state.json` from a previous install -- **Alert muting improvements** — pre-fill context fields (database, query, wait type, job name) from alert detail text, configurable default expiration for new mute rules, tooltip on query text field ([#642]) -- **Missing date columns** on Query Stats and Procedure Stats tabs (`creation_time`, `last_execution_time`) ([#649], [#651], [#654]) -- **Trace pattern drill-down** now includes `CollectionTime` and `NtUserName` columns ([#663]) -- **DataGrid sort preservation** across auto-refresh — sort order no longer resets when data refreshes ([#659]) -- **CLI installer**: colored output (green/red/yellow) and version check on startup ([#639]) -- **GUI installer**: version check on startup -- **Growth rate and VLF count** columns in Database Sizes (from v2.3.0 nightly, now in upgrade path) ([#567]) -- `llms.txt` and `CITATION.cff` for project discoverability ([#630]) - -### Changed - -- **Lite data directory** moved to `%LOCALAPPDATA%\PerformanceMonitorLite\` for Velopack compatibility -- **Delta gap detection** added to all cumulative-counter collectors (file I/O, wait stats, query stats, procedure stats, memory grants) — prevents inflated spikes after app restart ([#633]) -- **File I/O NULL fallbacks** improved when `sys.master_files` is inaccessible — falls back to `DB_NAME()` and `File_{id}` instead of generic "Unknown" ([#633]) -- **Running jobs collector** skipped gracefully when login lacks msdb access ([#656]) -- NuGet packages updated to latest minor versions ([#653]) - -### Fixed - -- **Installer writing SUCCESS when files fail** — CLI tolerated 1 failure in automated mode, GUI had a similar workaround. Now any failure = not success. -- **Query stats collector causing SQL dumps** on passive mirror servers — removed `dm_exec_plan_attributes` CROSS APPLY, uses temp table of ONLINE database IDs instead ([#632]) -- **Trigger name extraction** fails when comment before `CREATE TRIGGER` contains " ON " ([#666]) -- **FinOps expensive queries** DuckDB error — query referenced `statement_start_offset` column that doesn't exist in schema -- **Imported parquet files** not recognized by archive compaction — added regex patterns for `imported_` prefix -- **Auto-refresh after Import Data** — views now refresh immediately after import completes - -[#630]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/630 -[#632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/632 -[#633]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/633 -[#635]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/635 -[#639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/639 -[#642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/642 -[#646]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/646 -[#649]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/649 -[#651]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/651 -[#653]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/653 -[#654]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/654 -[#655]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/655 -[#656]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/656 -[#659]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/659 -[#662]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/662 -[#663]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/663 -[#666]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/666 -[#567]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/567 - -## [2.3.0] - 2026-03-18 - -### Important - -- **Schema upgrade**: Six columns widened across three tables (`query_stats`, `cpu_scheduler_stats`, `waiting_tasks`, `database_size_stats`) to match DMV documentation types. These are in-place ALTER COLUMN operations — fast on any table size, no data migration. Upgrade scripts run automatically via the CLI/GUI installer. -- **SQL Server version check**: Both installers now reject SQL Server 2014 and earlier before running any scripts, with a clear error message. Azure MI (EngineEdition 8) is always accepted. ([#543]) -- **Installer adversarial tests**: 35 automated tests covering upgrade failures, data survival, idempotency, version detection fallback, file filtering, restricted permissions, and more. These run as part of pre-release validation. ([#543]) - -### Added - -- **ErikAI analysis engine** — rule-based inference engine for Lite that scores server health across wait stats, CPU, memory, I/O, blocking, tempdb, and query performance. Surfaces actionable findings with severity, detail, and recommended actions. Includes anomaly detection (baseline comparison for acute deviations), bad actor detection (per-query scoring for consistently terrible queries), and CPU spike detection for bursty workloads. ([#589], [#593]) -- **ErikAI Dashboard port** — full analysis engine ported to Dashboard with SQL Server backend ([#590]) -- **FinOps cost optimization recommendations** — Phase 1-4 checks: enterprise feature audit, CPU/memory right-sizing, compression savings estimator, unused index cost quantification, dormant database detection, dev/test workload detection, VM right-sizing, storage tier optimization, reserved capacity candidates ([#564]) -- **FinOps High Impact Queries** — 80/20 analysis showing which queries consume the most resources across all dimensions ([#564]) -- **FinOps dollar-denominated cost attribution** — per-server monthly cost setting with proportional database-level breakdown ([#564]) -- **On-demand plan fetch** for bad actor and analysis findings — click to retrieve execution plans for flagged queries ([#604]) -- **Plan analysis integration** — findings include execution plan analysis when plans are available ([#594]) -- **Server unreachable email alerts** — Dashboard sends email (not just tray notification) when a monitored server goes offline or comes back online ([#529]) -- **Column filters on all FinOps DataGrids** — filter funnel icons on every column header across all 7 FinOps grids in Lite and Dashboard ([#562]) -- **Column filters on Dashboard** IdleDatabases, TempDB, and Index Analysis grids -- **Lite data import** — "Import Data" button brings in monitoring history from a previous Lite install via parquet files, preserving trend data across version upgrades ([#566]) -- **Per-server Utility Database setting** — Lite can call community stored procedures (sp_IndexCleanup) from a database other than master ([#555]) -- **SQL Server version check** in both CLI and GUI installers — rejects 2014 and earlier with a clear message ([#543]) -- **Execution plan analysis MCP tools** for both Dashboard and Lite -- **Full MCP tool coverage** — Dashboard expanded from 28 to 57 tools, Lite from 32 to 51 tools ([#576], [#577]) -- **Self-sufficient analyze_server drill-down** — MCP tool returns complete analysis, not breadcrumb trail ([#578]) -- **NuGet package dependency licenses** in THIRD_PARTY_NOTICES.md - -### Changed - -- **Azure SQL DB FinOps** — all collectors (database sizes, query stats, file I/O) now connect to each database individually instead of only querying master. Server Inventory uses dynamic SQL to avoid `sys.master_files` dependency. ([#557]) -- **Index Analysis scroll fix** — both summary and detail grids now use proportional heights instead of Auto, so they scroll independently with large result sets ([#554]) -- **Dashboard Add Server dialog** — increased MaxHeight from 700 to 850px so buttons are visible when SQL auth fields are shown -- **GUI installer** — Uninstall button now correctly enables after a successful install -- **GUI installer** — fixed encryption mapping and history logging ([#612]) -- **Dashboard visible sub-tab only refresh** on auto-refresh ticks ([#528]) -- Analysis engine decouples data maturity check from analysis window - -### Fixed - -- **Installer dropping database on every upgrade** — `00_uninstall.sql` excluded from install file list, installer aborts on upgrade failure, version detection fallback returns "1.0.0" instead of null ([#538], [#539]) -- **SQL dumps on mirroring passive servers** from FinOps collectors ([#535]) -- **RetrievedFromCache** always showing False ([#536]) -- **Arithmetic overflow** in query_stats collector for dop/thread columns ([#547]) -- **Lite perfmon chart bugs** and Dashboard ScottPlot crash handling ([#544], [#545]) -- **PLE=0 scoring bug** — was scored as harmless, now correctly flagged ([#543]) -- **PercentRank >1.0** bug in HealthCalculator -- **6 verified Lite bugs** from code review ([#611]) -- **Enterprise feature audit text** — partitioning is not Enterprise-only -- **FinOps collector scheduling**, server switch, and utilization bugs -- **Dashboard drill-down** Unicode arrow in story path split -- **Empty DataGrid scrollbar artifacts** — hide grids when empty across all FinOps tabs -- **Query preview** — truncated in row, full text in tooltip - -[#529]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/529 -[#535]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/535 -[#536]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/536 -[#538]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/538 -[#539]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/539 -[#543]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/543 -[#544]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/544 -[#545]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/545 -[#547]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/547 -[#554]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/554 -[#555]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/555 -[#557]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/557 -[#562]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/562 -[#564]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/564 -[#566]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/566 -[#576]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/576 -[#577]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/577 -[#578]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/578 -[#528]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/528 -[#589]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/589 -[#590]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/590 -[#593]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/593 -[#594]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/594 -[#604]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/604 -[#611]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/611 -[#612]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/612 - -## [2.2.0] - 2026-03-11 - -**Contributors:** [@HannahVernon](https://github.com/HannahVernon), [@ClaudioESSilva](https://github.com/ClaudioESSilva), [@dphugo](https://github.com/dphugo), [@Orestes](https://github.com/Orestes) — thank you! - -### Important - -- **Schema upgrade**: Three large collection tables (`query_stats`, `procedure_stats`, `query_store_data`) are migrated to use `COMPRESS()` for query text and plan columns. The upgrade performs a table swap (create new → migrate data → rename) which may take several minutes on large tables. A `row_hash` column is added for deduplication. Three new tracking tables are also created. Volume stats columns are added to `database_size_stats`. Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. - - Compression results measured on a production instance: - - | Table | Compressed | Uncompressed | Ratio | - |---|---|---|---| - | query_stats | 18.0 MB | 339.0 MB | 18.8x | - | query_store_data | 13.5 MB | 258.0 MB | 19.1x | - | **Total** | **31.5 MB** | **597 MB** | **~19x** | - -### Added - -- **FinOps monitoring tab** — database size tracking, server properties, storage growth analysis (7d/30d), index analysis with unused/duplicate/compressible detection, utilization efficiency, idle database identification, and estate-level resource views ([#474]) -- **Named collection presets** — Aggressive, Balanced, and Low-Impact schedule profiles via `config.apply_collection_preset` ([#454]) -- **Entra ID interactive MFA authentication** in both CLI and GUI installers for Azure SQL MI connections ([#481]) -- **MCP port validation** — TCP port conflict detection, range validation (1024+), Auto port button, and auto-restart on settings change ([#453]) -- **Alert database exclusion filters** — filter blocking and deadlock alerts by database in both Dashboard and Lite ([#410], [#412]) -- **Configurable alert cooldown periods** for tray notifications and email alerts -- **Wait stats query drill-down** — click a wait type to see the queries causing it ([#372]) -- **Configurable long-running query settings** — max results, WAITFOR/backup/diagnostics exclusions ([#415]) -- **Uninstall option** in both CLI and GUI installers ([#431]) -- **Session stats collector** for active session tracking ([#474]) -- **LOB compression and deduplication** for query stats tables to reduce storage ([#419]) -- **Volume-level drive space** enrichment in database size stats via `dm_os_volume_stats` -- **GUI installer installation history** logging to `config.installation_history` ([#414]) -- **ReadOnlyIntent connection option** — Lite connections can set `ApplicationIntent=ReadOnly` for automatic read routing to Always On AG readable secondaries ([#515]) -- **Alert muting** — mute individual alerts or create pattern-based mute rules by server, metric, database, or application. Manage Mute Rules window with enable/disable toggle. Alert history detail view with double-click drill-down and context-sensitive detail text. Poison wait type documentation links. ([#512]) -- **SignPath code signing** — all release binaries (Dashboard, Lite, Installers) are digitally signed, eliminating Windows SmartScreen warnings ([#511]) -- CI version bump check on PRs to main -- Permissions section in README with least-privilege setup ([#421]) - -### Changed - -- **Utilization tab redesigned** — ported to Dashboard with aligned metrics between apps ([#478]) -- PlanAnalyzer rules synced from PerformanceStudio — Rule 5 message format, seek predicate parsing, spool labels, unmatched index detail ([#416], [#475], [#480]) -- Data retention now purges processed XE staging rows -- GeneratedRegex conversion for compile-time regex patterns ([#346], [#420]) -- Server health card width increased from 260 to 300 for less text truncation ([#489]) -- User's locale used for date/time formatting in WPF bindings ([#459]) -- XML processing instructions stripped from sql_command/sql_text display -- Parameterized queries in blocking/deadlock alert filtering -- **DuckDB 1.5.0 upgrade** — non-blocking checkpointing eliminates read stalls during WAL flushes, free block reuse stabilizes database file size without archive-and-reset cycles ([#516]) -- **Automatic parquet compaction** — archive files are merged into monthly files after each archive cycle, reducing file count from 2,600+ to ~75 and eliminating per-file metadata overhead on glob scans ([#516]) - - Combined with the UI responsiveness overhaul (#510), Lite's refresh cycle improved 13-26x: - - | Metric | Before | After | - |---|---|---| - | Lite `RefreshAllDataAsync` | 6-13s | < 500ms | - | Parquet files scanned per query | 233 | 19 | - | Archive-and-reset frequency | 21/day | ~0 | - | `v_wait_stats` query time | 1,700ms | 27ms | - -- **Monthly archive retention** — switched from 90-day file-age deletion to 3-month calendar-month rolling window, aligned with compacted monthly filenames ([#516]) -- **Lite status bar** shows used data size vs file size (e.g., "Database: 175.5 / 423.8 MB") via DuckDB `pragma_database_size()` ([#517]) -- **Query Store collector diagnostics** — reader/append/flush timing breakdown logged when collection exceeds 2 seconds, for identifying SQL Server DMV contention under heavy workloads ([#518]) -- SSMS-parity edge tooltips on plan viewer operator connections and ManyToMany indicator always shown for merge join operators ([#504]) -- **Lite UI responsiveness overhaul** — visible-tab-only refresh, sub-tab awareness, Query Store collector optimization (NULL plan XML + LOOP JOIN hint), and DuckDB write reduction ([#510]) - - Timer tick improvements measured under TPC-C load on SQL2022: - - | Scenario | Before | After | Improvement | - |---|---|---|---| - | Lite idle | 6-13s | 546-750ms | ~90% | - | Lite under TPC-C | 6-13s | ~3s | ~70% | - | Dashboard idle | 5.6s | 0.6-0.8s | 86% | - | Dashboard under TPC-C | 5.6s | 1.8-2.0s | 64% | - - Query Store collector specifically: - - | Metric | Before | After | - |---|---|---| - | query_store collector total | 6-18s | ~600ms | - | query_store SQL time | 374-1,104ms | ~300ms (LOOP JOIN hint) | - | query_store DuckDB write | 6-16s | ~75-230ms (NULL plan XML) | - -### Fixed - -- **UI hang** when opening Dashboard tab for offline server — replaced synchronous `.GetAwaiter().GetResult()` with proper `await` ([#477]) -- **First-collection spike** skewing PerfMon, wait stats, file I/O, memory grant, query stats, and procedure stats charts — first cumulative value now treated as baseline ([#482]) -- **Wait type filter TextBox** too small to read ([#488]) -- **Poison wait false positives** and alert log parsing ([#445], [#448]) -- **RID Lookup** analyzer rule matching new PhysicalOp label ([#429]) -- **procedure_stats** plan query using DECOMPRESS after compression migration -- **database_size_stats** InvalidCastException on compatibility_level -- **Deadlock filter** using wrong column reference in `GetFilteredDeadlockCountAsync` -- **RESTORING database** filter added to waiting_tasks collector ([#430]) -- Custom TrayToolTip crash — replaced with plain ToolTipText ([#422]) -- **Lite tab switch freeze** — added `_isRefreshing` guard to prevent tab switch handler from competing with timer ticks for DuckDB connection, eliminating "not responding" hangs ([#510]) -- DuckDB read lock acquisition resilience -- Formatted duration columns sorting alphabetically instead of numerically -- Settings window staying open on validation errors -- Deserialization clamping and validation abort issues -- **sp_IndexCleanup** summary grid column mapping off-by-one, expanded both grids to show all columns from both result sets ([#503]) -- **Rule 22 table variable** false positive on modification operators — INSERT/UPDATE/DELETE on table variables is expected ([#513]) -- **ComboBox focus steal** in plan viewer stealing keyboard focus from other controls ([#508]) -- **DOP 2 skew** false positive — parallel skew rule no longer fires at DOP 2 ([#508]) -- **ReadOnlyIntent connections** sharing server_id in DuckDB when the same server was added with and without ReadOnlyIntent ([#521]) - -[2.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v2.1.0...v2.2.0 - -## [2.1.0] - 2026-03-04 - -### Important - -- **Schema upgrade**: The `config.collection_schedule` table gains two new columns (`collect_query`, `collect_plan`) for optional query text and execution plan collection. Both default to enabled to preserve existing behavior. Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. - -### Added - -- **Light theme and "Cool Breeze" theme** — full light mode support for both Dashboard and Lite with live preview in settings ([#347]) -- **Standalone Plan Viewer** — open, paste (Ctrl+V), or drag & drop `.sqlplan` files independent of any server connection, with tabbed multi-plan support ([#359]) -- **Time display mode toggle** — show timestamps in Server Time, Local Time, or UTC with timezone labels across all grids and tooltips ([#17]) -- **30 PlanAnalyzer rules** — expanded from 12 to 30 rules covering implicit conversions, GetRangeThroughConvert, lazy spools, OR expansion, exchange spills, RID lookups, and more ([#327], [#349], [#356], [#379]) -- **Wait stats banner** in plan viewer showing top waits for the query ([#373]) -- **UDF runtime details** — CPU and elapsed time shown in Runtime Summary pane when UDFs are present ([#382]) -- **Sortable statement grid** and canvas panning in plan viewer ([#331]) -- **Comma-separated column filters** — enter multiple values separated by commas in text filters ([#348]) -- **Optional query text and plan collection** — per-collector flags in `config.collection_schedule` to disable query text or plan capture ([#337]) -- **`--preserve-jobs` installer flag** — keep existing SQL Agent job schedules during upgrade ([#326]) -- **Copy Query Text** context menu on Dashboard statements grid ([#367]) -- **Server list sorting** by display name in both Dashboard and Lite ([#30]) -- **Warning status icon** in server health indicators ([#355]) -- Reserved threads and 10 missing ShowPlan XML attributes in plan viewer ([#378]) -- Nightly build workflow for CI ([#332]) - -### Changed - -- PlanAnalyzer warning messages rewritten to be actionable with expert-guided per-rule advice ([#370], [#371]) -- PlanAnalyzer rule tuning: time-based spill analysis (Rule 7), lowered parallel skew thresholds (Rule 8), memory grant floor raised to 1GB/4GB (Rule 9), skip PROBE-only bitmap predicates (Rule 11) ([#341], [#342], [#343], [#358]) -- First-run collector lookback reduced from 3-7 days to 1 hour for faster initial data ([#335]) -- Plan canvas aligns top-left and resets scroll on statement switch ([#366]) -- Plan viewer polish: index suggestions, property panel improvements, muted brush audit ([#365]) -- Add Server dialog visual parity between Dashboard and Lite with theme-driven PasswordBox styling ([#289]) - -### Fixed - -- **OverflowException** on wait stats page with large decimal values — SQL Server `decimal(38,24)` exceeding .NET precision ([#395]) -- **SQL dumps** on mirroring passive servers with RESTORING databases ([#384]) -- **UI hang** when adding first server to Dashboard ([#387]) -- **UTC/local timezone mismatch** in blocked process XML processor ([#383]) -- **AG secondary filter** skipping all inaccessible databases in cross-database collectors ([#325]) -- DuckDB column aliases in long-running queries ([#391]) -- sp_server_diagnostics and WAITFOR excluded from long-running query alerts ([#362]) -- UDF timing units corrected: microseconds to milliseconds ([#338]) -- DuckDB migration ordering after archive-and-reset ([#314]) -- Int16 cast error in long-running query alerts ([#313]) -- Missing dark mode on 19 SystemEventsContent charts ([#321]) -- Missing tooltips on charts after theme changes ([#319]) -- Operator time per-thread calculation synced across all plan viewers ([#392]) -- Theme StaticResource/DynamicResource binding fix for runtime theme switching -- Memory grant MB display, missing index quality scoring, wildcard LIKE detection ([#393]) -- **Installer validation** reporting historical collection errors as current failures — now filters to current run only ([#400]) -- **query_snapshots schema mismatch** after sp_WhoIsActive upgrade — collector auto-recreates daily table when column order changes ([#401]) -- **Missing upgrade script** for `default_trace_events` columns (`duration_us`, `end_time`) on 2.0.0→2.1.0 upgrade path ([#400]) - -## [2.0.0] - 2026-02-25 - -### Important - -- **Schema upgrade**: The `collect.memory_grant_stats` table gains new delta columns and drops unused warning columns. The `collect.session_wait_stats` table, its collector procedure, reporting view, and schedule entry are removed (zero UI coverage). Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. - -### Added - -- **Graphical query plan viewer** — native ShowPlan rendering in both Dashboard and Lite with SSMS-parity operator icons, properties panel, tooltips, warning/parallelism badges, and tabbed plan display ([#220]) -- **Actual execution plan support** — execute queries with SET STATISTICS XML ON to capture actual plans, with loading indicator and confirmation dialog ([#233]) -- **PlanAnalyzer** — automated plan analysis with rules for missing indexes, eager spools, key lookups, implicit conversions, memory grants, and more -- **Current Active Queries live snapshot** — real-time view of running queries with estimated/live plan download ([#149]) -- **Memory clerks tab** in Lite with picker-driven chart ([#145]) -- **Current Waits charts** in Blocking tab for both Dashboard and Lite ([#280]) -- **File I/O throughput charts** — read/write throughput trends, file-level latency breakdown, queued I/O overlay ([#281]) -- **Memory grant stats charts** — standardized collection with delta framework integration and trend visualization ([#281]) -- **CPU scheduler pressure status** — real-time scheduler, worker, runnable task counts with color-coded pressure level below CPU chart -- **Collection log drill-down** and daily summary in Lite ([#138]) -- **Collector duration trends chart** in Dashboard Collection Health ([#138]) -- **Themed perfmon counter packs** — 14 new counters with organized themed groups ([#255]) -- **User-configurable connection timeout** setting ([#236]) -- **Per-collector retention** — uses per-collector retention from `config.collection_schedule` in data retention ([#237]) -- **Query identifiers** in drill-down windows — query hash, plan hash, SQL handle visible for identification ([#268]) -- **Trace pattern drill-down** with missing columns and query text tooltips ([#273]) -- **Query Store Regressions drill-down** with TVF rewrite for performance ([#274]) -- **CLI `--help` flag** for installer ([#111]) -- Sort arrows, right-aligned numerics, and initial sort indicators across all grids ([#110]) -- Copyable plan viewer properties ([#269]) -- Standardized chart save/export filenames between Dashboard and Lite ([#284]) -- Full Dashboard column parity for query_stats, procedure_stats, and query_store_stats -- Min/max extremes surfaced in both apps — physical reads, rows, grant KB, spills, CLR time, log bytes ([#281]) - -### Changed - -- Query Store detection uses `sys.database_query_store_options` instead of `sys.databases.is_query_store_on` for Azure SQL DB compatibility ([#287]) -- Config tab consolidation, DB drop on server remove, DuckDB-first plan lookups, procedure stats parity -- Collector health status now detects consecutive recent failures — 5+ consecutive errors = FAILING, 3+ = WARNING -- Plan buttons now show a MessageBox when no plan is available instead of silently doing nothing -- CSV export uses locale-appropriate separators for non-US locales ([#240]) -- Query Store Regressions and Query Trace Patterns migrated to popup grid filtering ([#260]) -- NuGet packages updated; xUnit v3 migration - -### Fixed - -- **DuckDB file corruption** during maintenance — ReaderWriterLockSlim coordination, archive-all-and-reset at 512MB replaces compaction ([#218]) -- Archive view column mismatch, wait_stats thread-safety, and percent_complete type cast ([#234]) -- Collector health status bar text color ([#234]) -- View Plan for Query Store and Query Store Regressions tabs ([#261]) -- Query Store drill-down time filter alignment with main view ([#263]) -- Execution count mismatches between main views and drill-downs -- Drill-down chart UX — sparse data markers, hover tooltips, window sizing ([#271]) -- Truncated status text in Add Server dialog ([#257]) -- Scrollbar visibility, self-filtering artifacts, missing columns, and context menus ([#245], [#246], [#247], [#248]) -- query_stats and procedure_stats collectors ignoring recent queries -- Blank tooltips on warning and parallel badge icons -- Missing chart context menu on File I/O Throughput charts in Lite - -### Removed - -- `collect.session_wait_stats` table, `collect.session_wait_stats_collector` procedure, `report.session_wait_analysis` view, and schedule entry — zero UI coverage, never surfaced in Dashboard or Lite ([#281]) - -## [1.3.0] - 2026-02-20 - -### Important - -- **Schema upgrade**: The `collect.memory_stats` table gains two new columns (`total_physical_memory_mb`, `committed_target_memory_mb`). The upgrade script runs automatically via the CLI/GUI installer and uses `IF NOT EXISTS` checks, so it is safe to re-run. On servers with very large `memory_stats` tables this ALTER may take a moment. - -### Added - -- Physical Memory, SQL Server Memory, and Target Memory columns in Memory Overview ([#140]) -- Current Configuration view (Server Config, Database Config, Trace Flags) in Dashboard Overview ([#143]) -- Popup column filters and right-click context menus in all drill-down history windows ([#206]) -- Consistent popup column filters across all Dashboard grids — replaced remaining TextBox-in-header filters and added filters to Trace Flags ([#200]) -- 7-day time filter option in drill-down queries ([#165]) -- Alert badge/count on sidebar Alerts button ([#109]) -- Missing poison wait defaults in wait stats picker ([#188]) - -### Changed - -- Default Trace tabs moved from Resource Metrics to Overview section ([#169]) -- Trends tab shown first in Locking section ([#171]) -- Wait stats cap raised from 20 to 30 (Dashboard) / 50 (Lite) so poison waits are never dropped ([#139]) -- Settings time range dropdown now matches dashboard button options ([#210]) -- "Total Executions" label in drill-down summaries renamed to clarify meaning ([#194]) -- WAITFOR sessions excluded from long-running query alerts ([#151]) - -### Fixed - -- Deadlock XML processor timezone mismatch — sp_BlitzLock returning 0 results because UTC dates were passed instead of local time -- Sidebar alert badge not updating when alerts dismissed from server sub-tabs ([#214]) -- Sidebar alert badge not clearing on acknowledge ([#186]) -- NOC deadlock/blocking showing "just now" for stale events instead of actual timestamp ([#187]) -- NOC deadlock severity using extended events timestamp ([#170]) -- Newly added servers not appearing on Overview until app restart ([#199]) -- Double-click on column header incorrectly triggering row drill-down ([#195]) -- Squished drill-down charts — now use proportional sizing ([#166]) -- Unreliable chart tooltips — now use X-axis proximity matching ([#167]) -- Query Trace Patterns showing empty despite data existing ([#168]) -- Drill-down windows: removed inline plan XML, added time range filtering, aggregated by collection_time ([#189]) -- Row clipping in Default Trace and Current Configuration grids ([#183], [#184]) -- Numeric filter negative range parsing ([#113]) -- MCP shutdown deadlock risk ([#112]) -- Lite DBNull cast error in database_config collector on SQL 2016 Express ([#192]) -- DuckDB concurrent file access IO errors ([#164]) - -## [1.2.0] - 2026-02-15 - -### Added - -- Alert types, alerts history view, column filtering, and dismiss/hide for alerts ([#52], [#56]) -- Average ms per wait chart toggle in both apps ([#22]) -- Collection Health tab in Lite UI ([#39]) -- Collector performance diagnostics in Lite UI ([#40]) -- Hover tooltips on all Dashboard charts ([#70]) -- Minimize-to-tray setting added to Lite ([#53]) -- Persist dismissed alerts across app restarts ([#44]) -- Locale-aware date/time formatting throughout UI ([#41]) -- 24-hour format in time range picker ([#41]) -- CI pipelines for build validation, SQL install testing, and DuckDB schema tests -- Expanded Lite database config collector to 28 sys.databases columns ([#142]) -- Parquet archive visibility and scheduled DuckDB database compaction ([#160], [#161]) -- DuckDB checkpoint optimization and collection timing accuracy -- Installer `--reset-schedule` flag to reset collection schedule on re-install - -### Fixed - -- Deadlock charts not populating data ([#73]) -- Chart X-axis double-converting custom range to server time ([#49]) -- query_cost overflow in memory grant collector ([#47]) -- XE ring buffer query timeouts on large buffers ([#37]) -- Dashboard sub-tab badge state and DuckDB migration for dismissed column -- Lite duplicate blocking/deadlock events from missing WHERE clause ([#61]) -- Procedure_stats_collector truncation on DDL triggers ([#69]) -- DataGrid row height increased from 25 to 28 to fix text clipping -- Skip offline servers during Lite collection and reduce connection timeout ([#90]) -- Mutex crash on Lite app exit ([#89]) -- Permission denied errors handled gracefully in collector health ([#150]) - -## [1.1.0] - 2026-02-13 - -### Added - -- Hover tooltips on all multi-series charts — Wait Stats, Sessions, Latch Stats, Spinlock Stats, File I/O, Perfmon, TempDB ([#21]) -- Microsoft Entra MFA authentication for Azure SQL DB connections in Lite ([#20]) -- Column-level filtering on all 11 Lite DataGrids ([#18]) -- Chart visual parity — Material Design 300 color palette, data point markers, consistent grid styling ([#16]) -- Smart Select All for wait types + expand from 12 to 20 wait types ([#12]) -- Trend chart legends always visible in Dashboard ([#11]) -- Per-server collector health in Lite status bar ([#5]) -- Server Online/Offline status in Lite overview ([#2]) -- Check for updates feature in both apps ([#1]) -- High DPI support for both Dashboard and Lite - -### Fixed - -- Query text off-by-one truncation ([#25]) -- Blocking/deadlock XML processors truncating parsed data every run ([#23]) -- WAITFOR queries appearing in top queries views ([#4]) -- Wait type Clear All not refreshing search filter in Dashboard - -## [1.0.0] - 2026-02-11 - -### Added - -- Full Edition: Dashboard + CLI/GUI Installer with 30+ automated SQL Agent collectors -- Lite Edition: Agentless monitoring with local DuckDB storage -- Support for SQL Server 2016-2025, Azure SQL DB, Azure SQL MI, AWS RDS -- Real-time charts and trend analysis for wait stats, CPU, memory, query performance, index usage, file I/O, blocking, deadlocks -- Email alerts for blocking, deadlocks, and high CPU -- MCP server integration for AI-assisted analysis -- System tray operation with background collection and alert notifications -- Data retention with configurable automatic cleanup -- Delta normalization for per-second rate calculations -- Dark theme UI - -[2.1.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v2.0.0...v2.1.0 -[2.0.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.3.0...v2.0.0 -[1.3.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.2.0...v1.3.0 -[1.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.1.0...v1.2.0 -[1.1.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/erikdarlingdata/PerformanceMonitor/releases/tag/v1.0.0 -[#1]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1 -[#2]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2 -[#4]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/4 -[#5]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/5 -[#11]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/11 -[#12]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/12 -[#16]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/16 -[#18]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/18 -[#20]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/20 -[#21]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/21 -[#22]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/22 -[#23]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/23 -[#25]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/25 -[#37]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/37 -[#39]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/39 -[#40]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/40 -[#41]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/41 -[#44]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/44 -[#47]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/47 -[#49]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/49 -[#52]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/52 -[#53]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/53 -[#56]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/56 -[#61]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/61 -[#69]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/69 -[#70]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/70 -[#73]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/73 -[#85]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/85 -[#86]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/86 -[#89]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/89 -[#90]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/90 -[#109]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/109 -[#112]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/112 -[#113]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/113 -[#139]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/139 -[#140]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/140 -[#142]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/142 -[#143]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/143 -[#150]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/150 -[#151]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/151 -[#160]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/160 -[#161]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/161 -[#164]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/164 -[#165]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/165 -[#166]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/166 -[#167]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/167 -[#168]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/168 -[#169]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/169 -[#170]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/170 -[#171]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/171 -[#183]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/183 -[#184]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/184 -[#186]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/186 -[#187]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/187 -[#188]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/188 -[#189]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/189 -[#192]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/192 -[#194]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/194 -[#195]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/195 -[#199]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/199 -[#200]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/200 -[#206]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/206 -[#210]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/210 -[#214]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/214 -[#218]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/218 -[#220]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/220 -[#233]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/233 -[#234]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/234 -[#236]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/236 -[#237]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/237 -[#240]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/240 -[#245]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/245 -[#246]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/246 -[#247]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/247 -[#248]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/248 -[#255]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/255 -[#257]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/257 -[#260]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/260 -[#261]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/261 -[#263]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/263 -[#268]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/268 -[#269]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/269 -[#271]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/271 -[#273]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/273 -[#274]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/274 -[#280]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/280 -[#281]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/281 -[#284]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/284 -[#287]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/287 -[#313]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/313 -[#314]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/314 -[#17]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/17 -[#30]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/30 -[#319]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/319 -[#321]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/321 -[#325]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/325 -[#326]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/326 -[#327]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/327 -[#331]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/331 -[#332]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/332 -[#335]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/335 -[#337]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/337 -[#338]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/338 -[#341]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/341 -[#342]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/342 -[#343]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/343 -[#347]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/347 -[#348]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/348 -[#349]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/349 -[#355]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/355 -[#356]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/356 -[#358]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/358 -[#359]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/359 -[#362]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/362 -[#365]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/365 -[#366]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/366 -[#367]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/367 -[#370]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/370 -[#371]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/371 -[#373]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/373 -[#378]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/378 -[#379]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/379 -[#382]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/382 -[#383]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/383 -[#384]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/384 -[#387]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/387 -[#391]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/391 -[#392]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/392 -[#393]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/393 -[#289]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/289 -[#395]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/395 -[#400]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/400 -[#401]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/401 -[#410]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/410 -[#412]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/412 -[#414]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/414 -[#415]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/415 -[#416]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/416 -[#419]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/419 -[#420]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/420 -[#421]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/421 -[#422]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/422 -[#429]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/429 -[#430]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/430 -[#431]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/431 -[#445]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/445 -[#448]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/448 -[#453]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/453 -[#454]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/454 -[#459]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/459 -[#474]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/474 -[#475]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/475 -[#477]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/477 -[#478]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/478 -[#480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/480 -[#481]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/481 -[#482]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/482 -[#488]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/488 -[#489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/489 -[#503]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/503 -[#504]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/504 -[#508]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/508 -[#510]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/510 -[#512]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/512 -[#511]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/511 -[#513]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/513 -[#515]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/515 -[#516]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/516 -[#517]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/517 -[#518]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/518 -[#521]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/521 +# Changelog + +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). + +## [3.0.0] - 2026-06-15 + +### Important + +- **Major release — 2.11.0 → 3.0.0, no breaking changes.** This version rolls up a codebase-wide correctness and security hardening pass (the `code-review-*` series spanning the SQL schema, collectors, and views, the installer, the Lite and Dashboard services, and the shared libraries); a major UI-responsiveness overhaul that moves the data path off the WPF dispatcher in both apps; new object- and index-level collection (per-table / per-index size, growth, usage, and locking/contention); the rebuilt Recommendations / Apply Fix engine (advise-and-act, with safe and destructive fixes appliable behind informed two-sided consent); and a batch of smaller fixes and features. Nothing here is a breaking change — existing installations upgrade in place via `upgrades/2.11.0-to-3.0.0/` (typed blocked-process columns, a nullable host-CPU column, the `TRANSACTION_MUTEX` ignored wait, and new server-health columns), and the Dashboard and Lite apps auto-update over the top + +### Fixed + +- **Lite and Dashboard: Azure SQL Database shows its real product name in FinOps → Server Inventory** — the Edition column displayed the legacy `SQL Azure` value that `SERVERPROPERTY('Edition')` returns for Azure SQL DB; it now reads `Azure SQL Database ()` (e.g. `Azure SQL Database (General Purpose)`), derived from `DATABASEPROPERTYEX(DB_NAME(), 'Edition')`, for any engine-edition-5 instance. Normalized at every edition display/storage site across **both** apps — the live inventory queries (Lite + Dashboard) and the SQL-side collectors (`install/42`, `install/53`) plus Lite's `server_properties` collector — so the value is consistent app-wide; on-prem editions are unchanged. (The licensing-recommendation queries are left raw and identical in both apps: they only do an `Enterprise` substring check and never display the edition for Azure.) +- **Dashboard: "Deadlocks Cleared" no longer flaps right after every deadlock** ([#1091]) — deadlock detection is edge-triggered off a delta against the cumulative perfmon counter, so the check immediately after a deadlock saw a zero delta and fired a *"Deadlocks Cleared"* notification ~one interval (≈60s) after every *"Deadlock Detected"*. The alert now stays active and clears only once a deadlock-quiet window (1 hour) has elapsed since the last new deadlock, so the detect/clear pair lines up with Lite, whose rolling 1-hour count drains about an hour after the last deadlock. Each new deadlock resets the window. The clear message is now *"No deadlocks in the last hour"* (was *"No deadlocks since last check"*). Covered by `DeadlockAlertClearPolicyTests` +- **Lite: blocking and deadlock alerts no longer re-fire for the same events every cooldown** ([#1091]) — the overview alert engine treated the blocking and deadlock counts as a level: each check compared the rolling **1-hour** count against the threshold, so a single deadlock (or blocked-process report) kept the count above the threshold for the whole hour it lingered in the window, and the alert re-fired every cooldown (the reporter saw the same "2 deadlocks in the last hour" notification every five minutes for an hour). The Dashboard already edge-triggers off a delta; Lite now does too. Both alerts are gated by a new `RollingCountAlertGate` that fires only when the rolling count climbs above the count recorded at the last fired alert — a genuinely new event. The watermark decays as old events age out of the window (so a later rise re-alerts), resets when the window empties, and advances only when an alert actually fires (so an event arriving during a cooldown is reported once the cooldown elapses rather than being swallowed). Covered by `RollingCountAlertGateTests` +- **Lite and Dashboard: low-disk (Volume Free Space) alert no longer re-fires every cooldown for a standing full volume** — a breached volume is a sustained condition, but the alert engine treated free space as a level and re-fired every `AlertCooldownMinutes` (default 5) for as long as the volume stayed below threshold. Besides the repeated tray/email, every cycle wrote a fresh Alert-History row, so dismissing the alert appeared not to work — the dismissed row was immediately replaced by an identical, newer one. The alert is now gated by a shared `LowDiskAlertGate` that notifies only on a **fresh** breach or one that has **worsened** by at least 1 percentage point of free space below the last-alerted level, and clears its watermark when the volume recovers — mirroring the failed-job watermark and the [#1091] rolling-count edge trigger. Fixed identically in both apps. Covered by `LowDiskAlertGateTests` +- **Lite and Dashboard: low-disk and failed-Agent-job conditions now light the server tab badge** ([#754]/[#749]) — the per-server tab badge was driven only by blocking, deadlocks, CPU, and memory, so a server whose only problem was a full volume or a failed Agent job showed **no tab indicator** — you couldn't tell which server was affected at a glance (the alert surfaced only as a one-shot tray toast and an Alert-History row). Both apps now fold the alert engine's active low-disk / failed-job state into the badge: it lights while the breach (or a failure within the lookback window) is active, auto-clears when the disk recovers or the failure ages out, and acknowledges/silences exactly like the other badges. This is the persistent-indicator complement to the low-disk re-fire fix above — alert once, then stay quietly flagged instead of re-nagging. Covered by `AlertBadgeConditionTests` +- **Lite: blocking/deadlock XE sessions now self-heal and failures are surfaced** ([#1086]) — the `PerformanceMonitor_BlockedProcess` and `PerformanceMonitor_Deadlock` Extended Events sessions were created only when a server tab was opened; the recurring background collection loop never created or retried them. A server monitored without an open tab (e.g. app minimized to tray after a restart), or a first attempt that failed (connection not ready, missing `ALTER ANY EVENT SESSION`), left blocking/deadlock capture permanently dead — while the collectors read the non-existent ring buffer, got zero rows, and reported **OK**. The session ensure now runs inside the collector itself on every cycle (cheap existence check once created), so both the tab-open path and the background loop create/start/retry it. A failed ensure can no longer be masked: it fails the collector run, shows in the status-bar collector health (including permission failures, which previously didn't count as "erroring"), and fires a one-time tray notification ("Capture Not Running") on the transition. The Azure SQL DB database-scoped sessions also gain `STARTUP_STATE = ON` so they restart automatically after a failover +- **Dashboard: blocking/deadlock XE sessions self-heal, Azure SQL DB sessions are actually created, and a missing session raises a Capture Down alert** — same silent-failure family as [#1086], worse on the Dashboard side. (1) The server-scoped sessions were created once at install and never re-ensured: if later stopped or dropped, `collect.blocked_process_xml_collector` and `collect.deadlock_xml_collector` swallowed the missing-session error and logged `SUCCESS` with zero rows forever. Both procs now ensure (create/start) the session at the top of every run. (2) On Azure SQL DB, the code comments claimed the database-scoped sessions were "auto-created by the collection procedures" — nothing anywhere created them, so blocking/deadlock capture was 100% non-functional on Azure SQL DB; the procs now create and start them (`database_xml_deadlock_report` for deadlocks — the Azure read also filtered on the wrong event name and would have returned nothing even with a session present). (3) Honest logging: when the session is genuinely absent and can't be created (typically missing `ALTER ANY EVENT SESSION` on-prem / `CREATE ANY DATABASE EVENT SESSION` on Azure SQL DB), the run logs `SESSION_MISSING` with the real error instead of `SUCCESS`. (4) The alert engine reads that status and raises a **Capture Down** alert through the standard pipeline — snoozable tray notification, email, webhook, alert history, cooldown, and mute — with a **Capture Restored** clear when the session comes back. Note: on Azure SQL DB the blocked-process *threshold* cannot be set via `sp_configure` and Microsoft documents no default, so the blocked-process session may exist yet capture nothing there; deadlock capture has no such dependency +- **Blocked-process and deadlock XML processors no longer loop on un-parseable events** — the second-phase parsers (`collect.process_blocked_process_xml` → `sp_HumanEventsBlockViewer`, and `collect.process_deadlock_xml` → `sp_BlitzLock`) only marked a captured event processed when the parse produced at least one row. Events that legitimately yield zero — a *self-block* or non-lock wait (e.g. a memory-grant `RESOURCE_SEMAPHORE` wait that tripped `blocked_process_threshold`, which SQL Server reports as a session blocked by itself), or a deadlock graph the parser can't reconstruct — were never marked, so every collection cycle re-ran the CPU-intensive parser over the same dead events and re-logged a perpetual `NO_RESULTS` while the staging table never drained. Both processors now mark events processed after any clean parse run and log `SUCCESS`; genuine parse failures still roll back and retry. Separately, the blocked-process processor's parse window was half-open (`event_time < @end_date`), so a batch of reports sharing one timestamp — the common case, since a blocked-process monitor loop emits every report at a single instant — fell outside `[MIN, MAX)` and was silently dropped; the upper bound is now inclusive (matching the deadlock processor). Covered by `tools/test_blocked_process_processor.sql` using real self-block and two-session samples + +- **Lite and Dashboard UI no longer goes blank or disappears after sleep/wake** ([#1050]) — closing a laptop lid (or locking the screen) and then resuming could leave the app running with no usable window: notifications kept firing but the window was gone from the desktop and taskbar, and relaunching showed an empty window until a full exit/restart. Two causes, both fixed. (1) WPF's GPU render thread can lose its rendering surface across a sleep/wake or RDP reconnect and never recover, leaving a live-but-blank window; both apps now use software rendering (`RenderOptions.ProcessRenderMode = SoftwareOnly`) to remove the GPU dependency — charts are unaffected because ScottPlot already renders via SkiaSharp. (2) When Windows turned the sleep-driven minimize into a hidden window, the minimize-to-tray logic left it hidden with no automatic way back; a new shared resume guard now restores the window from the tray on resume/unlock if it was visible beforehand (a window the user deliberately sent to the tray is left alone) +- **"Silence All Alerts" now suppresses email too** ([#1035]) — right-clicking a monitored instance and choosing *Silence All Alerts* hid tray notifications and Alerts-tab badges, but two email paths ignored the silenced state and kept sending: connection up/down emails (*Server Unreachable* / *Server Restored*) and analysis-finding emails (the narrative findings from the analysis engine, which include CPU/memory/blocking stories). Only the threshold-alert path (High CPU, blocking, deadlocks, etc.) honored silencing. Both gaps are closed — a silenced server now produces no tray, email, or alert-history row from any path. The analysis path was the likely source of the reporter's "High CPU" email, since the threshold-based High CPU alert was already suppressed. The shared `AnalysisNotificationService` (used by Lite too) gains an optional per-server silence predicate; Lite has no silencing feature and passes none +- **Dashboard time labels are now consistently 24-hour** ([#1012]) — the time-range header at the top of each tab (e.g. *"Original: May 28, 11:30 PM – May 29, 1:30 AM (PST)"*) and the Query Performance heatmap x-axis tick labels used `h:mm tt`, while every other timestamp in the app (footer "Last refresh", DataGrid columns, slicer, tooltips, logs) already used 24-hour `HH:mm`/`HH:mm:ss`. The AM/PM marker was also being truncated in the column shown by the reporter. Normalized the four outliers to `HH:mm` to match the rest of the app. The Lite heatmap had the same `h:mm tt` straggler — fixed alongside +- **Lite UI no longer freezes during archival** ([#979]) — archival held DuckDB's exclusive write lock across the entire export-to-Parquet step, blocking every UI query (tab switches showed the spinning wheel, worse with more monitored servers). Export-to-Parquet only reads the database, so it now runs under a shared read lock concurrently with the UI; only the brief `DELETE` takes the exclusive write lock +- **Lite FinOps no longer recommends an edition downgrade on an Availability Group secondary** ([#980]) — the licensing recommendations suggested "downgrade to Standard to save $X/mo" for any Enterprise instance, with no AG awareness. On a secondary replica that advice is misleading — every replica in an AG must run the same edition. FinOps now detects the AG replica role and, on a secondary, shows an informational note instead of the downgrade/savings estimate +- **Lite alert emails no longer re-fire after an app restart** ([#981]) — the per-metric email cooldown lived only in memory, so restarting Lite cleared it and an alert sent minutes earlier could be sent again immediately. The cooldown is now seeded from `config_alert_log` (the most recent successful send for that server/metric) the first time each alert is evaluated, so it survives restarts +- **Dashboard alert emails no longer re-fire after an app restart** — brings Dashboard `EmailAlertService` to parity with the Lite-side persistence introduced in [#981]. The cooldown is now seeded from the in-memory alert log (loaded from `alert_history.json` on startup) the first time each `{serverId}:{metricName}` key is evaluated +- **Analysis-finding notification cooldowns now persist across restarts on both Lite and Dashboard** — the per-finding re-notification cooldown in `AnalysisNotificationService` lived only in memory, so restarting either app cleared it and a finding that had just fired (and entered its `AnalysisNotifyCooldownMinutes` cooldown) could re-notify immediately. The cooldown now seeds lazily from the alert log (Lite: `config_alert_log`; Dashboard: `alert_history.json`) on first lookup per finding, mirroring the email-cooldown pattern from #981. Entries past 2× the cooldown window are pruned on each notify cycle so the dictionary stays bounded +- **Data Retention job no longer fails with `xp_delete_file` error 22049** ([#972]) — the trace-file cleanup added in v2.11.0 passed a wildcard path to `xp_delete_file`, raising an uncatchable `Msg 22049` that failed the entire `PerformanceMonitor - Data Retention` Agent job on every run once any `Monitor_LongQueries_*.trc` files existed. `xp_delete_file` also cannot delete `.trc` files at all — it only accepts SQL Server backup files and Maintenance Plan report files — so that cleanup step has been removed from `config.data_retention` +- **Codebase-wide correctness and security hardening pass** — a broad review (the `code-review-*` PR series, #1093–#1108) fixed defects across the stack without changing behavior users depend on: + - **Shared libraries** — defects in the extracted `PerformanceMonitor.Analysis` / `.PlanAnalysis` / `.Ui` / `.Common` code + - **Dashboard** — timezone and CPU-path defects + - **Lite** — services, analysis, and UI defects, plus `ArchiveService` data-loss / corruption fixes + - **Installer** — CLI version-detection and failure-handling + - **SQL** — high-impact collector defects, view / analyzer crashes (including a Linux CPU gap), and schema / job / validation defects +- **FinOps no longer recommends downgrading to Standard Edition on a server running Availability Groups** ([#1085]) — an Enterprise instance with no TDE was told to "review whether Standard Edition would meet workload requirements" even when it was running AGs, which Standard supports only in the limited Basic Availability Groups form. FinOps now counts advanced (non-basic) AGs via `sys.availability_groups.basic_features` and, when any are present, appends a caveat naming the AG count and Standard's Basic-AG limitations (two replicas, one database per group, no readable secondary), retitles the finding to "review Availability Group requirements before downgrading," and lowers its confidence — the savings estimate is retained. The Dashboard, which previously had no AG awareness at all, was brought to full parity and also gains the [#980] AG-secondary informational note it never received +- **Server-tab alert badge is now clearable** ([#1092]) — the red alert badge on a server tab could previously only be cleared through an undiscoverable right-click menu. Left-clicking the badge now acknowledges and clears it (hand cursor, *"Click to dismiss · Right-click for options"* tooltip), and Alert History **Dismiss All** clears the matching server badge(s) too. A follow-up ([#1122]) closed the last gap: **Dismiss Selected** now also clears the badge for every distinct server represented in the dismissed rows. On the Dashboard, which already had richer auto-resolving badges, this added the missing left-click affordance for parity +- **Long-running-query alert no longer constantly trips on CDC capture jobs** ([#1096]) — the Change Data Capture capture job runs as a continuous SQL Agent session (`sp_MScdc_capture_job` → `sp_cdc_scan`), so its elapsed time permanently exceeded the long-running-query threshold and the alert fired non-stop; none of the four existing `wait_type`-based exclusions caught it. Both apps gain an **Exclude CDC capture jobs** toggle (default on) that identifies the capture session server-side by decoding its Agent `program_name` to a `job_id` and matching `msdb.dbo.cdc_jobs` (`job_type = 'capture'`), falling back to a whole-text match when msdb is unreadable or `cdc_jobs` doesn't yet exist — so it stays CDC-specific and never hides unrelated Agent jobs. Dashboard filters the live DMV query inline; Lite computes a per-row `is_cdc_capture` flag in the collector (its snapshots store only statement-level text) and filters on read + +### Changed + +- **Plan parsing / analysis extracted to shared library `PerformanceMonitor.PlanAnalysis`** — the previously duplicated `ShowPlanParser`, `PlanAnalyzer`, `BenefitScorer`, `PlanLayoutEngine`, and `PlanModels` pairs across `Dashboard/Services` + `Dashboard/Models` and `Lite/Services` + `Lite/Models` are now one copy referenced by both apps via ``. The new library targets `net10.0` (no WPF) and has zero dependency on `PerformanceMonitor.Analysis` — the two shared libraries are independent. ~5,100 LOC of byte-equivalent duplication eliminated. The `planalyzer-sync-checker` agent is retired (no copies to sync). `ActualPlanExecutor` stays per-app this release because it calls `ReproScriptBuilder` (Class B, drifted between Lite and Dashboard); both will be extracted in a follow-up PR once `ReproScriptBuilder` is reconciled and a logging abstraction is designed +- **`PlanIconMapper` split to break a shared-library WPF dependency** — `ShowPlanParser` calls `PlanIconMapper.GetIconName` to populate `PlanNode.IconName` during parse, but the rest of `PlanIconMapper` is WPF-bound (`GetIcon` returns `BitmapImage`). The pure-data half (the `IconMap` dictionary + the `GetIconName` lookup) is now `IconNameMapper` inside `PerformanceMonitor.PlanAnalysis`. The per-app `PlanIconMapper.GetIcon(string iconName)` is unchanged; the per-app `GetIconName` forwarder is gone (`ShowPlanParser` calls `IconNameMapper.GetIconName` directly, and there were no other callers) +- **Analysis engine extracted to shared library `PerformanceMonitor.Analysis`** — the previously duplicated `FactScorer`, `RelationshipGraph`, `InferenceEngine`, `AnalysisModels`, `IFactCollector`, `IPlanFetcher`, and `BlockingChainReconstructor` pairs across `Dashboard/Analysis/` and `Lite/Analysis/` are now one copy referenced by both apps and both test projects via ``. The new library targets `net10.0` (no WPF) so it can be picked up by future non-WPF consumers without a multi-target rewrite. The `blocking-reconstructor-sync-checker` agent is retired (no copies to sync). `BlockingChainReconstructorTests` ported to `Dashboard.Tests` (10 tests) as part of the same change — Dashboard now exercises the same reconstruction coverage as Lite. `AnalysisService` and the DB-bound adapters (`*FactCollector`, `*DrillDownCollector`, `*FindingStore`, `*AnomalyDetector`, `*BaselineProvider`, `*PlanFetcher`) stay per-app because they bind to `DuckDBConnection` vs `SqlConnection`. `PlanAnalyzer` and its `planalyzer-sync-checker` are outside this extraction's scope and stay +- **Trace files are now bounded at the source** ([#972]) — `collect.trace_management_collector` creates the long-query trace with a rollover file-count cap (`@filecount`, via the new `@max_files` parameter, default 5), so SQL Server itself deletes the oldest `.trc` file as the trace rolls. The scheduled collector also now issues `START` instead of `RESTART`: it keeps one trace running rather than tearing it down and spawning a fresh timestamped trace — and a fresh batch of orphaned files — every cycle +- **Blocked-process reports expose blocker-side fields as typed columns** — `collect.blocking_BlockedProcessReport` now carries `blocking_spid`, `blocking_last_tran_started`, `blocking_status`, `blocked_sql_text`, and `blocking_sql_text` populated at insert time from `blocked_process_report_xml`. Existing rows are backfilled idempotently by the 2.11.0 → 3.0.0 upgrade script +- **Blocking-chain reconstruction now reads typed columns from `collect.blocking_BlockedProcessReport`** instead of re-parsing `blocked_process_report_xml` on every analysis cycle — eliminates up to 5000 `XElement.Parse` calls per `BLOCKING_CHAIN` fact collection. The Dashboard `BlockedProcessXmlParser` has been deleted; the Lite collection-time parser is unchanged (Lite has no SQL-side staging table and still parses once at collect time) +- **Analysis minimum-data threshold lowered to 24 hours** — `Lite/Analysis/AnalysisService.cs` and `Dashboard/Analysis/AnalysisService.cs` now require 24 hours of collected data before analysis runs, down from 72. Validated empirically as sufficient for fraction-of-period calculations, so a fresh install starts producing findings after one day instead of three +- **Major UI-responsiveness overhaul — the data path now runs off the WPF dispatcher in both apps** — DuckDB.NET is synchronous, so in Lite `await _dataService.X()` completed on the calling (UI) thread, and a single DuckDB connection open under load is ~750 ms; the result was multi-hundred-millisecond to multi-second UI freezes on the per-minute pipeline, refreshes, and alert checks. The fix moves the work onto pool threads (`Task.Run`) across the board: Lite's background collect/checkpoint/archive pipeline, the full-refresh fan-out, the 60-second sub-tab refreshes, picker charts, the overview sweep, timeline lanes, connect, and the FinOps and Recommendations reads; the Dashboard's `ServerTab` row materialization and its execution-plan parse/analyze; and — found later by wall-clock thread-time profiling under a HammerDB TPC-C load — the alert-check / overview-sweep DuckDB queries that were still on the dispatcher ([#1121], which cut the worst measured dispatcher stall from ~1.2 s to under 10 ms). Lite also skips the heavy refresh for non-selected (hidden) server tabs, the shared crosshair/hover hot path was made cheaper for both apps, and Dashboard timers gained re-entrancy guards. A cluster of long-session memory leaks that progressively degraded responsiveness was fixed alongside ([#1116]): an Alerts-tab `DispatcherTimer` that kept ticking after the tab closed, unbounded per-run alert-key dictionaries, a tray-service handler re-subscribed on every theme change, and plan-viewer controls leaked through a static theme event. (The related sleep/wake blank-window and software-rendering fix is tracked separately under [#1050] above.) Net effect: the UI stays responsive under heavy collection and query load + +### Added + +- **`tools/Remove-OrphanedTraceFiles.ps1`** ([#972]) — one-time cleanup script for `Monitor_LongQueries_*.trc` files left on disk by versions through 2.11.0. Run it on the SQL Server host; it skips files belonging to a running trace and files that are in use +- **`FactAdvice` and `FactRemediation` in `PerformanceMonitor.Analysis`** — new shared-library data layer that maps every scorable fact-key to a Headline / Investigation / Remediation advice block, plus a copy-paste-ready `sp_query_store_force_plan` T-SQL generator for `PLAN_REGRESSION` findings (gated to that single fact-key in v1; `PARAMETER_SENSITIVITY` deliberately does not generate plan-force T-SQL because forcing locks in the wrong plan for some parameter values). Drill-down collectors now also project `best_plan_id` (via `MAX(plan_id)` in the plan-dedup CTE) so the generated EXEC carries the integer ID `sp_query_store_force_plan` actually accepts, not just the hash. Lite's `BuildContext` now mirrors Dashboard's — both apps emit a Diagnosis card at `Details[0]` carrying Story / Severity / Notify threshold / Confidence / Facts / Database / Window before the drill-down items. The rendering surfaces that consume this data (email HTML, plain-text email, Teams + Slack webhook payloads, in-app Alert Details window) ship in a separate follow-up PR +- **Object- and index-level collection: sizes, growth, usage, and locking/contention** ([#1103]) — both apps gain a daily collector that snapshots per-table and per-index storage (`sys.dm_db_partition_stats`), index usage (`sys.dm_db_index_usage_stats` — seeks/scans/lookups/updates), and per-object locking/latch/escalation (`sys.dm_db_index_operational_stats` — row-lock waits, page-latch waits, lock-escalation attempts), all from stock DMVs verified stable from SQL Server 2016 through 2025 and on Azure SQL DB / Managed Instance. On-prem and MI iterate user databases (honoring the collector exclusion list); Azure SQL DB uses its single-database branch. Dashboard collects into `collect.index_object_stats` (`install/55_collect_index_object_stats.sql`, scheduled daily with 90-day retention picked up by dynamic retention); Lite collects into DuckDB with archival registered. Three new FinOps sub-tabs in each app — **Object Sizes & Growth** (per-table size plus 7-/30-day growth and daily rate), **Index Usage** (Unused / Write-only / Active classification), and **Locking & Contention** (top-contended indexes) — plus MCP read tools (`get_table_index_sizes`, `get_index_usage`, `get_object_locking`). Because the daily snapshots are cumulative, the new **object-growth** (`ANOMALY_OBJECT_GROWTH`, a table grew >100 MB and ≥20% day-over-day) and **lock-contention** (`ANOMALY_OBJECT_CONTENTION`, an index gained ≥60s of new row-lock wait) alerts are delta-based (the two most recent snapshots, reset-guarded) and flow through the existing anomaly → `AnalysisNotificationService` pipeline. Thresholds are fixed constants in this release; making them user-configurable is a follow-up +- **Recommendations / Apply Fix engine (advise-and-act rebuild)** — the analysis engine's advisory output is now a first-class **Recommendations** surface in both apps, alongside Critical Issues. Each finding renders as a card with a plain-language Headline / Investigation / Remediation block (from the `FactAdvice`/`FactRemediation` shared-library data layer) and routes the reader into the relevant in-app view or MCP tool instead of dumping raw DMV queries. Advise-only recommendations include server-config advisories (MAXDOP / cost threshold for parallelism / max server memory), per-database config (autogrowth, percent-growth on large files), server-health facts (Lock Pages in Memory, Instant File Initialization, recent memory dumps), and missing-index / plan-warning recommendations mined from collected plans — missing-index `CREATE` statements are surfaced as copy-paste text. A subset is **appliable in place behind informed, two-sided consent**: always-safe `ALTER DATABASE SET` config fixes, and the destructive **RCSI** (enable read-committed snapshot) and **clear cached plan** (`DBCC FREEPROCCACHE` / unforce) fixes, which gate behind an acknowledge-each-risk dialog that quantifies both the risk of changing and the risk of doing nothing from the finding's own monitoring data. The advice and remediation T-SQL also render across every notification surface — email (HTML and plain text), Teams and Slack webhook payloads, and the in-app Alert Details window — and through the `analyze_server` and `get_analysis_findings` MCP tools +- **Low volume free-space alert** ([#754]) in both apps — a new **Volume Free Space** alert (default on) fires when a monitored server's disk volume drops below a free-space percentage **or** a fixed GB amount (set either threshold to `0` to disable that dimension; if both are set, either breach fires). It reads the per-volume size/free data already collected by the database-size collector, evaluates every volume on the server, and fires one alert per server naming the worst (lowest-free) volume with up to five breaching volumes in the context — with the same cooldown, mute, alert-history, tray, and email plumbing as the existing tempdb-space alert. Defaults: 10% / 5 GB. Azure SQL DB has no volume data, so the alert never fires there +- **Failed SQL Agent job alert** ([#749]) in both apps — complements the existing job-duration alerts with a **Failed Agent Job** alert (default on) that issues a live `msdb.dbo.sysjobhistory` query at alert-check time for job-outcome rows (`step_id = 0`, `run_status = 0`) that failed within a configurable look-back window (default 60 minutes). The read degrades gracefully when the login lacks msdb / `SQLAgentReaderRole` access (returns empty, never faults the alert cycle) and is skipped entirely on Azure SQL DB, which has no SQL Agent +- **Installer: optional custom data/log file locations** ([#768]) — two optional CLI flags, `--data-path` and `--log-path` (both `--flag VALUE` and `--flag=VALUE` forms accepted), place the `PerformanceMonitor` database's `.mdf`/`.ldf` on specific server-side volumes at install time; an omitted flag falls back to the instance default path as before. The paths apply only on first creation (the create block is guarded by `IF DB_ID(N'PerformanceMonitor') IS NULL`), and Azure SQL Managed Instance ignores them. The path is validated and escaped (control characters and the dangerous filename characters are rejected; single quotes are doubled in both the C# injection layer and the dynamic `CREATE DATABASE`) because a data-file `FILENAME` literal cannot be parameterized + +[#749]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/749 +[#754]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/754 +[#768]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/768 +[#972]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/972 +[#979]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/979 +[#980]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/980 +[#981]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/981 +[#1012]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1012 +[#1035]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1035 +[#1050]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1050 +[#1085]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1085 +[#1086]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1086 +[#1091]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1091 +[#1092]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1092 +[#1096]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1096 +[#1103]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1103 +[#1116]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1116 +[#1121]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1121 +[#1122]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1122 + +## [2.11.0] - 2026-05-19 + +### Important + +- **.NET 10 upgrade** — Dashboard, Lite, Installer, and Installer.Core now target `net10.0` (Windows projects target `net10.0-windows`). Building from source now requires the .NET 10 SDK; CI is pinned to 10.0.204 via `global.json` for reproducible builds. End users running prebuilt Velopack installers do not need to install anything separately — runtime is bundled ([#958]) +- **Setup.exe is now the recommended install path** for Dashboard and Lite — the README steers users to the Velopack `Setup.exe`, which installs to `%LocalAppData%`, registers the apps under Apps & Features, creates Start Menu and Desktop shortcuts, and wires up auto-update. Portable ZIPs are still produced for both apps (CI release pipeline and local build scripts) as a fallback for advanced or air-gapped users. The Installer ZIP (CLI installer + SQL scripts) is unchanged +- **Shared `servers.json` location** — Dashboard and Lite now store `servers.json` under `%ProgramData%\PerformanceMonitor{Dashboard,Lite}\` so every Windows user on the same machine shares one server list. First run migrates an existing per-user `servers.json` to the new location and grants Authenticated Users Modify on the directory. SQL credentials remain per-user in Windows Credential Manager — each DBA re-enters SQL passwords on first connect; Windows Auth works with no re-entry + +### Added + +- **One-click snooze from the alert tray popup** in Lite — snooze an alert directly from the tray notification balloon without opening the main window ([#944]) +- **Snooze hint in email and Teams/Slack alert payloads** — alert messages now show the snooze duration / scheduled wake time when an alert is fired while a snooze is active ([#944]) +- **Process memory logging per collection cycle** in Lite — the collector now logs working set and private bytes at the end of each cycle, making it easier to track memory growth in long-running sessions + +### Changed + +- **Lite compaction memory tuning** ([#933]) — multiple changes to make parquet compaction robust on wide-row tables and large datasets: + - Cap the main collector connection's `memory_limit` and raise it transiently only for the `COPY` step + - Detect compaction `EXCLUDE` columns per merge step instead of once up front + - Raise the compaction `memory_limit` floor to 4 GB + - Set DuckDB `temp_directory` explicitly so spill files don't blow the OS temp drive + - Compact parquet in size-budgeted batches instead of one mega-batch +- **Trace collectors honor `config.collector_database_exclusions`** ([#887] follow-up) — the trace-file based collectors now filter against the exclusions table, matching the behavior of the eight DMV-based per-database collectors shipped in v2.9.0 +- **InstallerGui project directory removed** — the WPF InstallerGui was retired in v2.9.0 in favor of the Dashboard's integrated Add Server dialog. The project directory has now been deleted from the repo +- **Build warnings cleaned up** across Lite, Dashboard, and Installer ([#945]) +- **GitHub Actions runners bumped** to Node 24-compatible major versions to silence deprecation warnings + +### Fixed + +- **Re-run `installation_history` column widening** for servers that crossed v2.4.0 → v2.5.0 before PR #828's fix shipped in v2.7.0. Those servers ran the original widen script as a no-op against `master`, then advanced their installer_version past 2.5, so the now-fixed script never reapplied. Adds an idempotent ALTER under an `IF EXISTS` guard checking `max_length = 510` ([#828]) +- **Mute rules preserved across size-triggered DuckDB reset** in Lite — when the local DuckDB exceeded the configured size budget and was reset, mute rules were being lost. They now survive the reset ([#938]) +- **Chart tooltips break after tab switch** — root-cause fix for the popup-wedge issue first patched in v2.10.0. Both the Memory tab handlers and `CorrelatedCrosshairManager` are now resilient to tab churn ([#916], [#937]) +- **Stale `Monitor_LongQueries_*.trc` files cleaned up** by `config.data_retention` — the trace-file cleanup step previously left old `.trc` files behind on disk ([#951]) +- **Nullability guards** added to the remaining comparison overlay tasks that were producing CS86xx warnings + +[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 +[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887 +[#916]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/916 +[#933]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/933 +[#937]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/937 +[#938]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/938 +[#944]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/944 +[#945]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/945 +[#951]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/951 +[#958]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/958 + +## [2.10.0] - 2026-05-04 + +### Fixed + +- **Memory tab tooltip** stops working after switching away and returning to the tab. Both Dashboard and Lite Memory tab crosshair tooltip handlers now reattach correctly on tab re-entry; the same popup-wedge fix is also applied to `CorrelatedCrosshairManager` ([#916]) +- **FinOps memory recommendation** now bases sizing on a 7-day P95 of memory samples instead of a single snapshot, so recommendations no longer swing based on instantaneous workload state. Applied in both Dashboard and Lite ([#917]) + +### Changed + +- **Per-database grants for FinOps Index Analysis** documented in the README — sp_IndexCleanup-backed Index Analysis requires per-database `EXECUTE` grants on each user database you want to analyze ([#915]) + +[#915]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/915 +[#917]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/917 + +## [2.9.0] - 2026-04-29 + +### Important + +- **Breaking change to `config.data_retention`** — the `@truncate_all` parameter has been removed. Pass `@retention_days = 0` for the same behavior. `@retention_days = NULL` (default) respects per-collector retention from `config.collection_schedule` with a 30-day fallback for unscheduled tables; `@retention_days = N > 0` overrides every table to N days. Any existing Agent jobs or scripts calling `data_retention @truncate_all = 1` need to be updated ([#900]) +- **New `config.collector_database_exclusions` table** for per-database collector exclusions. Eight per-database collectors filter against this table; system databases remain hard-skipped by the collectors themselves. Existing installs get the table on the next upgrade — `install/01_install_database.sql` and `config.ensure_config_tables` both create it under an `IF OBJECT_ID … IS NULL` guard ([#887]) + +### Added + +- **Per-database collector exclusions** — exclude noisy or unimportant databases from per-database collectors. Dashboard side adds `config.collector_database_exclusions` and filters 8 collectors (`query_stats`, `query_store`, `procedure_stats`, `file_io_stats`, `waiting_tasks`, `database_configuration`, `database_size_stats`, `server_properties`). Lite side adds an `ExcludedDatabases` list per server in `servers.json` and filters 9 collectors ([#887]) +- **`Off` collection preset** — `EXECUTE config.apply_collection_preset @preset_name = N'Off'` disables every collector in one call. Pair with a second Agent job that applies a non-`Off` preset at the start of your active window for overnight / quiet-hours scoping. Non-`Off` presets now also set `enabled = 1` across the board so the switch from `Off → Balanced` reliably resumes collection ([#888]) +- **Purge Now action** in Manage Servers — confirm dialog with a mode picker (Use configured / 1 / 3 / 7 / Custom / All) drives `config.data_retention`; right-click menu on the Manage Servers grid mirrors every per-row action (Edit, Toggle Favorite, Check Server Version, Purge Now, Remove) ([#900]) +- **Total non-idle CPU on Lite Overview** — headline value shows total CPU with the SQL-only value alongside (e.g. `64% (SQL 60%)`); new `CpuAlertMode` dropdown in Settings → Alerts (Total / SqlOnly) drives both the alert evaluator and headline color; tray notifications and email alerts label the value as "Total CPU" or "SQL CPU" ([#899]) +- **Resume gap detection** — `query_stats`, `procedure_stats`, and `query_store` collectors skip the historical sweep on first run after an Off preset, Agent stoppage, or server reboot. When the last successful run is older than 5× the configured `frequency_minutes` (floored at 30 minutes), the cutoff clamps to `SYSDATETIME()` so only forward-going data is collected on resume — preventing the tempdb blowout that hit the original reporter ([#892]) +- **Right-click View Plan** on Dashboard Blocked Process Reports (View Blocked Plan + View Blocking Plan), Dashboard Deadlocks, and Lite Deadlocks grids. Plan lookup hits `sys.dm_exec_query_stats` + `sys.dm_exec_text_query_plan` on the monitored server, falling back to `executionStack/frame` entries when the process-level `sql_handle` is empty or evicted ([#880]) +- **Open Log Folder** sidebar button in Lite — opens `%LocalAppData%\PerformanceMonitorLite\logs\` in Explorer for grabbing historical logs to attach to bug reports. Sits below View Log, which retains its existing behavior of opening today's log file ([#873]) +- **Installed Version column** in the Manage Servers grid for both Dashboard and Lite. Dashboard shows the PerformanceMonitor database version on each server (probed in parallel via `GetInstalledVersionAsync`, with `Not installed` / `Unavailable` fallbacks). Lite shows the running app's own version on every row, mirroring Full's column header for consistency. +- **Lite-style server card indicators in Full** — back-ported the Ellipse-with-DataTriggers status dot (Online/Offline/Warning/Unknown) and the right-aligned favorite star from Lite to the Full Dashboard's server list, matching Lite's visual treatment. +- **Architecture overview** at `docs/how-collection-works.md` covering the minute loop, dispatcher, collector shape, `config.collection_schedule`, retention, and the Dashboard read path + +### Changed + +- **PlanIconMapper synced** with PerformanceStudio v1.9.0 improvements — columnstore storage type on scan/delete/insert/update/merge operators routes to `columnstore_index_*` icons (covers CCI and NCCI); `Parallelism` operator subtypes (Repartition Streams, Distribute Streams, Gather Streams) get their own icons +- **`Microsoft.Data.SqlClient` 6.1.4 → 7.0.1** — major-version bump. Azure/Entra dependencies were split out of the core package in 7.0; `Microsoft.Data.SqlClient.Extensions.Azure 1.0.0` added to Dashboard, Lite, and Installer.Core for `ActiveDirectoryInteractive` connections +- **`ModelContextProtocol` 0.7.0-preview.1 → 1.2.0** — off the preview tag and onto stable 1.x in Dashboard and Lite +- **`DuckDB.NET` 1.5.0 → 1.5.2** in Lite — fixes unbounded row group growth on indexed tables under repeated load+insert cycles, memory leaks and race conditions in prepared statements, WAL checkpoint marking, and Windows UTF-8/UTF-16 handling +- **`Microsoft.Extensions.*` 10.0.5 → 10.0.7**, **`System.Text.Json` 10.0.5 → 10.0.7**, **`ScottPlot.WPF` 5.1.57 → 5.1.58** — patch-level bumps with no expected behavioral change +- **Theme polish** on grids and plan viewer in Dashboard and Lite — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) ([#889]) + +### Fixed + +- **Install loop timeout** raised from 5 minutes to 1 hour. `install/98_validate_installation.sql` runs every enabled collector with `@debug = 1` in a single batch; on large databases (reporter had 7.2M rows in `collect.query_stats`, 4.4M in `collect.query_store_data`) this took ~9 minutes and was blowing the 5-minute timeout, failing the install or upgrade ([#884]) + +[#873]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/873 +[#880]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/880 +[#884]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/884 +[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887 +[#888]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/888 +[#889]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/889 +[#892]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/892 +[#899]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/899 +[#900]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/900 + +## [2.8.0] - 2026-04-22 + +### Important + +- **New nonclustered indexes** on `collect.query_stats`, `collect.procedure_stats`, and `collect.query_store_data` to eliminate Eager Index Spools in Dashboard grid queries. On large installations these indexes may take several minutes to build; the upgrade script uses `ONLINE = ON` on Enterprise/Developer/Azure editions and falls back to offline on Standard/Web ([#835]) + +### Added + +- **Memory Pressure Events in Lite** — the collector, chart, and `get_memory_pressure_events` MCP tool previously only in the Full Edition are now available in Lite ([#865]) +- **Grid auto-scrolling** in Lite and Dashboard ([#843]) — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) + +### Changed + +- **PlanAnalyzer and BenefitScorer** synced with PerformanceStudio's Apr 9–16 improvements +- **Query/Procedure/Query Store stats** refactored to a phased DECOMPRESS approach; removed unhelpful `WAITFOR DECOMPRESS` filters +- **Query/Procedure/Query Store grids** capped to TOP 500 to prevent UI freezes on large datasets +- **Server tabs lazy-load** — only the visible server tab loads on startup; remaining tabs load on first visit +- **Webhook URLs (Dashboard)** encrypted with DPAPI via Windows Credential Manager — Lite webhook URLs remain in plaintext settings for now +- **DuckDB queries hardened** — parameterized values, escaped paths, fixed `IsArchiving` race +- **Lite chart axes and sub-tab styling** polished, then ported to Dashboard + +### Fixed + +- **Memory Pressure Events chart filter** was dropping valid rows; added MCP interpretation guidance ([#865]) +- **FinOps recommendation severity sort order** in Lite and Dashboard ([#872]) +- **Overview crosshair** disappearing after tab switches or layout passes +- **Blocked process report plan lookup** returning the wrong plan ([#867]) +- **FinOps TDE recommendation** flagging Standard edition on SQL Server 2019+ where TDE is free ([#854]) +- **Azure SQL DB collector** falls back to single-database mode when `master` is inaccessible ([#857]) +- **Azure SQL DB query snapshots** scoped to the current database ([#857]) +- **Azure SQL DB query snapshot prefilter** — request set is narrowed into `#temp` before joining DMVs to avoid Azure-specific execution plan issues ([#857]) +- **Azure SQL DB live query plans** — now skipped gracefully instead of erroring ([#857]) +- **Azure SQL DB memory_stats collector** — dropped `sys.dm_os_schedulers` which is blocked on elastic-pool contained users regardless of DB-scoped grants ([#857]) +- **Non-transient permission denials** now stop collector retries instead of looping forever ([#857]) + +[#835]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/835 +[#843]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/843 +[#854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/854 +[#857]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/857 +[#865]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/865 +[#867]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/867 +[#872]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/872 + +## [2.7.0] - 2026-04-13 + +### Added + +- **Host OS column** in Server Inventory for both Dashboard and Lite ([#748], [#823]) +- **Offline community script support** via `community/` directory for user-contributed scripts ([#814], [#822]) +- **MultiSubnetFailover connection option** in Dashboard and Lite for Always On availability groups ([#813], [#821]) + +### Changed + +- **PlanAnalyzer and ShowPlanParser** synced from PerformanceStudio with latest improvements ([#816]) +- **MCP query tools** optimized for large databases ([#826]) +- **Add Server dialog UX** improved with inline connection status and full-height window +- **"CPUs" renamed to "Logical CPUs"** for clarity in Lite ([#825]) + +### Fixed + +- **Dashboard auto-refresh stalling under load** — replaced DispatcherTimer with async Task.Delay loop to prevent priority starvation during heavy chart rendering ([#833], [#834]) +- **Lite auto-refresh silently skipping** every tick ([#824]) +- **Deadlock count not resetting** between collections ([#803], [#820]) +- **Upgrade filter skipping patch versions** during version comparison ([#817], [#819]) +- **Upgrade script executing against master** instead of PerformanceMonitor database ([#828]) +- **Duplicate release builds** triggering on both created and published events + +[#748]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/748 +[#803]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/803 +[#813]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/813 +[#814]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/814 +[#816]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/816 +[#817]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/817 +[#819]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/819 +[#820]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/820 +[#821]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/821 +[#822]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/822 +[#823]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/823 +[#824]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/824 +[#825]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/825 +[#826]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/826 +[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 +[#833]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/833 +[#834]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/834 + +## [2.6.0] - 2026-04-08 + +### Added + +- **Correlated timeline lanes** on Lite Overview and Dashboard — synchronized CPU, memory, waits, and TempDB trend lanes for at-a-glance correlation ([#688]) +- **Dynamic baselines and anomaly detection** in Lite and Dashboard — automatic baseline calculation with anomaly highlighting on key metrics ([#692], [#693]) +- **Query grid comparison** — before/after comparison mode for query grids in Lite and Dashboard with global Compare dropdown ([#687]) +- **Nonclustered index count badge** on modification operators in plan viewer ([#788]) +- **Upgrade detection in Edit Server** dialog — see pending upgrades without adding a new server ([#772]) +- **CLI installer interactive mode** prompts for trust-cert and encryption settings ([#784]) +- **SignPath code signing** — release binaries are now digitally signed via the [SignPath FOSS](https://signpath.io) program + +### Changed + +- **PlanAnalyzer Rule 3 (Serial Plan)** comprehensively refined — severity demotion for TRIVIAL and 0ms plans, `CouldNotGenerateValidParallelPlan` treated as actionable, all 25 `NonParallelPlanReason` values now covered +- **PlanAnalyzer warning rules** ported from PerformanceStudio improvements +- **Text readability** — replaced all muted/dim text colors with full foreground colors for readability + +### Fixed + +- **Embedded resource upgrade discovery** broken — upgrades silently returned zero results for Dashboard installs ([#772]) +- **Archive compaction OOM** on large parquet groups +- **CLI installer argument parsing** treating flags as positional args ([#786]) +- **Lite long-running query alerts** firing on stale DuckDB snapshots +- **FinOps Enterprise feature detection** now queries all databases and filters to TDE only ([#780]) +- **Second launch error** — now brings existing window to foreground instead ([#769]) +- **Overview tab Memory Grant** showing 0 for all timestamps ([#776]) +- **Lite FinOps Enterprise features** query error on servers without `database_id` column ([#777]) +- **Collector health status** incorrect for on-load collectors +- **CSV and clipboard exports** writing `System.Windows.Controls.StackPanel` as column headers instead of actual header text ([#805]) + +[#687]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/687 +[#688]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/688 +[#692]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/692 +[#693]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/693 +[#769]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/769 +[#772]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/772 +[#776]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/776 +[#777]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/777 +[#780]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/780 +[#784]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/784 +[#786]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/786 +[#788]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/788 +[#805]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/805 + +## [2.5.0] - 2026-03-30 + +### Important + +- **InstallerGui retired**: The standalone GUI installer has been removed. Installation, upgrade, and uninstall are now handled directly from the Dashboard's Add Server dialog, powered by the new Installer.Core shared library. The CLI installer continues to work as before. ([#755]) + +### Added + +- **Dashboard integrated installer** — Add Server dialog now installs, upgrades, and uninstalls PerformanceMonitor directly, replacing the standalone InstallerGui ([#755]) +- **Installer.Core shared library** — shared installation logic used by both the CLI installer and Dashboard ([#755]) +- **Overview tab** for Lite with 2x2 resource chart grid (CPU, Memory, Wait Stats, TempDB) ([#689]) +- **Chart drill-down** on CPU, Memory, TempDB, Blocking, and Deadlock charts in both Dashboard and Lite — right-click any chart point to jump to Active Queries for that time window ([#682]) +- **Grid-to-slicer overlay** for Query Stats, Procedure Stats, and Query Store tabs — click a row to overlay its trend on the slicer chart ([#683]) +- **Query heatmap** tab in both Dashboard and Lite — visual heat map of query activity over time ([#739], [#743]) +- **Webhook notifications** for alerts — configurable webhook endpoint for alert delivery ([#725]) +- **Per-server collector schedule intervals** — customize collection frequency per server ([#703]) +- **Investigate button** in Critical Issues grid — jump directly to relevant tab from an alert ([#684]) +- **Dismiss Selected** context menu and View Log sidebar button for alert management ([#718], [#740]) +- **Alert archival awareness** — dismissed_archive_alerts sidecar table, source column for live vs archived alerts, stale-data indicator, structured telemetry ([#718]) +- **Dashboard read-only connection intent** — connections use `ApplicationIntent=ReadOnly` where supported ([#728]) +- FUNDING.yml for GitHub Sponsors ([#752]) + +### Changed + +- **Installer architecture** refactored: CLI installer is now a thin wrapper over Installer.Core ([#755]) +- **DuckDB memory capped** at 2 GB during parquet compaction to prevent out-of-memory on large archives ([#758]) +- **Text rendering** improved with `TextOptions.TextFormattingMode="Display"` for sharper text ([#710]) +- **installation_history version columns** widened from nvarchar(255) to nvarchar(512) to handle long @@VERSION strings ([#712]) + +### Fixed + +- **Memory leaks in Lite** — delta cache, event handlers, and chart helpers properly disposed ([#758]) +- **Doomed transaction errors** in delta framework and ensure_collection_table — ROLLBACK now occurs before error logging ([#756]) +- **XACT_STATE check** added after third-party stored procedure calls (sp_HumanEventsBlockViewer, sp_BlitzLock) to prevent doomed transaction errors ([#695]) +- **CREATE DATABASE failure** when model database has large default file sizes ([#676]) +- **CPU metrics mixed** for different Azure SQL databases on the same logical server ([#680]) +- **Azure SQL DB vCore** FinOps calculations incorrect for serverless/vCore tiers ([#736]) +- **Webhook alert recording** not persisting correctly ([#726]) +- **Drill-down timezone** misalignment between chart and detail view ([#747], [#750]) +- **Drill-down refresh** losing context on auto-refresh ([#744]) +- **Drill-down target** incorrectly routing Memory to Memory Grants instead of Active Queries ([#706]) +- **Heatmap colorbar stacking** when switching between servers ([#746]) +- **Display mode pickers** not reflecting current state on tab switch ([#751]) +- **Slicer custom range** handling and sub-hour display issues ([#704]) +- **Overlay selection** lost on Dashboard auto-refresh ([#683]) +- **Numeric values** in alert details treated as strings instead of numbers ([#732]) +- **FinOps VM right-sizing** query error — `PERCENTILE_CONT` missing required `OVER()` clause +- **FinOps Enterprise features** query error on AWS RDS — `database_id` column not present in `sys.dm_db_persisted_sku_features` on RDS +- **FinOps right-click copy** broken on all Dashboard FinOps grids — context menu walked to row instead of grid +- **FinOps recommendation error logs** now include server name for easier troubleshooting + +### Deprecated + +- **InstallerGui** — removed from the solution and build pipeline. Use the Dashboard or CLI installer instead. ([#755]) + +[#676]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/676 +[#680]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/680 +[#682]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/682 +[#683]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/683 +[#684]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/684 +[#689]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/689 +[#695]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/695 +[#703]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/703 +[#704]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/704 +[#706]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/706 +[#710]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/710 +[#712]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/712 +[#718]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/718 +[#725]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/725 +[#726]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/726 +[#728]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/728 +[#732]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/732 +[#736]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/736 +[#739]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/739 +[#740]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/740 +[#743]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/743 +[#744]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/744 +[#746]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/746 +[#747]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/747 +[#750]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/750 +[#751]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/751 +[#752]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/752 +[#755]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/755 +[#756]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/756 +[#758]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/758 + +## [2.4.0] - 2026-03-23 + +### Important + +- **Lite data directory moved**: Lite now stores all data (config, DuckDB, archives, logs) in `%LOCALAPPDATA%\PerformanceMonitorLite\` instead of alongside the executable. This enables auto-update support. Existing users upgrading from the zip should use **Import Settings** and **Import Data** to bring over their configuration and historical data from the old install folder. +- **Auto-update (Windows)**: Both Dashboard and Lite now include Velopack auto-update. Users who install via the new Setup.exe will receive update notifications and can download + apply updates from within the app. Existing zip distribution continues to work as before. + +### Added + +- **Velopack auto-update** for Dashboard and Lite — check on startup, download + apply from About window with confirmation dialog before restart ([#635]) +- **Per-tab time range slicers** on Dashboard and Lite query tabs — filter data directly on each tab without changing global time range ([#655], [#662]) +- **Time display picker** (Local/UTC/Server) in Dashboard and Lite toolbars ([#646]) +- **Import Settings** — renamed from "Import Connections", now also copies `settings.json`, `collection_schedule.json`, `ignored_wait_types.json`, and `alert_state.json` from a previous install +- **Alert muting improvements** — pre-fill context fields (database, query, wait type, job name) from alert detail text, configurable default expiration for new mute rules, tooltip on query text field ([#642]) +- **Missing date columns** on Query Stats and Procedure Stats tabs (`creation_time`, `last_execution_time`) ([#649], [#651], [#654]) +- **Trace pattern drill-down** now includes `CollectionTime` and `NtUserName` columns ([#663]) +- **DataGrid sort preservation** across auto-refresh — sort order no longer resets when data refreshes ([#659]) +- **CLI installer**: colored output (green/red/yellow) and version check on startup ([#639]) +- **GUI installer**: version check on startup +- **Growth rate and VLF count** columns in Database Sizes (from v2.3.0 nightly, now in upgrade path) ([#567]) +- `llms.txt` and `CITATION.cff` for project discoverability ([#630]) + +### Changed + +- **Lite data directory** moved to `%LOCALAPPDATA%\PerformanceMonitorLite\` for Velopack compatibility +- **Delta gap detection** added to all cumulative-counter collectors (file I/O, wait stats, query stats, procedure stats, memory grants) — prevents inflated spikes after app restart ([#633]) +- **File I/O NULL fallbacks** improved when `sys.master_files` is inaccessible — falls back to `DB_NAME()` and `File_{id}` instead of generic "Unknown" ([#633]) +- **Running jobs collector** skipped gracefully when login lacks msdb access ([#656]) +- NuGet packages updated to latest minor versions ([#653]) + +### Fixed + +- **Installer writing SUCCESS when files fail** — CLI tolerated 1 failure in automated mode, GUI had a similar workaround. Now any failure = not success. +- **Query stats collector causing SQL dumps** on passive mirror servers — removed `dm_exec_plan_attributes` CROSS APPLY, uses temp table of ONLINE database IDs instead ([#632]) +- **Trigger name extraction** fails when comment before `CREATE TRIGGER` contains " ON " ([#666]) +- **FinOps expensive queries** DuckDB error — query referenced `statement_start_offset` column that doesn't exist in schema +- **Imported parquet files** not recognized by archive compaction — added regex patterns for `imported_` prefix +- **Auto-refresh after Import Data** — views now refresh immediately after import completes + +[#630]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/630 +[#632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/632 +[#633]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/633 +[#635]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/635 +[#639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/639 +[#642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/642 +[#646]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/646 +[#649]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/649 +[#651]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/651 +[#653]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/653 +[#654]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/654 +[#655]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/655 +[#656]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/656 +[#659]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/659 +[#662]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/662 +[#663]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/663 +[#666]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/666 +[#567]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/567 + +## [2.3.0] - 2026-03-18 + +### Important + +- **Schema upgrade**: Six columns widened across three tables (`query_stats`, `cpu_scheduler_stats`, `waiting_tasks`, `database_size_stats`) to match DMV documentation types. These are in-place ALTER COLUMN operations — fast on any table size, no data migration. Upgrade scripts run automatically via the CLI/GUI installer. +- **SQL Server version check**: Both installers now reject SQL Server 2014 and earlier before running any scripts, with a clear error message. Azure MI (EngineEdition 8) is always accepted. ([#543]) +- **Installer adversarial tests**: 35 automated tests covering upgrade failures, data survival, idempotency, version detection fallback, file filtering, restricted permissions, and more. These run as part of pre-release validation. ([#543]) + +### Added + +- **ErikAI analysis engine** — rule-based inference engine for Lite that scores server health across wait stats, CPU, memory, I/O, blocking, tempdb, and query performance. Surfaces actionable findings with severity, detail, and recommended actions. Includes anomaly detection (baseline comparison for acute deviations), bad actor detection (per-query scoring for consistently terrible queries), and CPU spike detection for bursty workloads. ([#589], [#593]) +- **ErikAI Dashboard port** — full analysis engine ported to Dashboard with SQL Server backend ([#590]) +- **FinOps cost optimization recommendations** — Phase 1-4 checks: enterprise feature audit, CPU/memory right-sizing, compression savings estimator, unused index cost quantification, dormant database detection, dev/test workload detection, VM right-sizing, storage tier optimization, reserved capacity candidates ([#564]) +- **FinOps High Impact Queries** — 80/20 analysis showing which queries consume the most resources across all dimensions ([#564]) +- **FinOps dollar-denominated cost attribution** — per-server monthly cost setting with proportional database-level breakdown ([#564]) +- **On-demand plan fetch** for bad actor and analysis findings — click to retrieve execution plans for flagged queries ([#604]) +- **Plan analysis integration** — findings include execution plan analysis when plans are available ([#594]) +- **Server unreachable email alerts** — Dashboard sends email (not just tray notification) when a monitored server goes offline or comes back online ([#529]) +- **Column filters on all FinOps DataGrids** — filter funnel icons on every column header across all 7 FinOps grids in Lite and Dashboard ([#562]) +- **Column filters on Dashboard** IdleDatabases, TempDB, and Index Analysis grids +- **Lite data import** — "Import Data" button brings in monitoring history from a previous Lite install via parquet files, preserving trend data across version upgrades ([#566]) +- **Per-server Utility Database setting** — Lite can call community stored procedures (sp_IndexCleanup) from a database other than master ([#555]) +- **SQL Server version check** in both CLI and GUI installers — rejects 2014 and earlier with a clear message ([#543]) +- **Execution plan analysis MCP tools** for both Dashboard and Lite +- **Full MCP tool coverage** — Dashboard expanded from 28 to 57 tools, Lite from 32 to 51 tools ([#576], [#577]) +- **Self-sufficient analyze_server drill-down** — MCP tool returns complete analysis, not breadcrumb trail ([#578]) +- **NuGet package dependency licenses** in THIRD_PARTY_NOTICES.md + +### Changed + +- **Azure SQL DB FinOps** — all collectors (database sizes, query stats, file I/O) now connect to each database individually instead of only querying master. Server Inventory uses dynamic SQL to avoid `sys.master_files` dependency. ([#557]) +- **Index Analysis scroll fix** — both summary and detail grids now use proportional heights instead of Auto, so they scroll independently with large result sets ([#554]) +- **Dashboard Add Server dialog** — increased MaxHeight from 700 to 850px so buttons are visible when SQL auth fields are shown +- **GUI installer** — Uninstall button now correctly enables after a successful install +- **GUI installer** — fixed encryption mapping and history logging ([#612]) +- **Dashboard visible sub-tab only refresh** on auto-refresh ticks ([#528]) +- Analysis engine decouples data maturity check from analysis window + +### Fixed + +- **Installer dropping database on every upgrade** — `00_uninstall.sql` excluded from install file list, installer aborts on upgrade failure, version detection fallback returns "1.0.0" instead of null ([#538], [#539]) +- **SQL dumps on mirroring passive servers** from FinOps collectors ([#535]) +- **RetrievedFromCache** always showing False ([#536]) +- **Arithmetic overflow** in query_stats collector for dop/thread columns ([#547]) +- **Lite perfmon chart bugs** and Dashboard ScottPlot crash handling ([#544], [#545]) +- **PLE=0 scoring bug** — was scored as harmless, now correctly flagged ([#543]) +- **PercentRank >1.0** bug in HealthCalculator +- **6 verified Lite bugs** from code review ([#611]) +- **Enterprise feature audit text** — partitioning is not Enterprise-only +- **FinOps collector scheduling**, server switch, and utilization bugs +- **Dashboard drill-down** Unicode arrow in story path split +- **Empty DataGrid scrollbar artifacts** — hide grids when empty across all FinOps tabs +- **Query preview** — truncated in row, full text in tooltip + +[#529]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/529 +[#535]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/535 +[#536]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/536 +[#538]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/538 +[#539]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/539 +[#543]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/543 +[#544]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/544 +[#545]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/545 +[#547]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/547 +[#554]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/554 +[#555]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/555 +[#557]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/557 +[#562]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/562 +[#564]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/564 +[#566]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/566 +[#576]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/576 +[#577]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/577 +[#578]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/578 +[#528]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/528 +[#589]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/589 +[#590]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/590 +[#593]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/593 +[#594]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/594 +[#604]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/604 +[#611]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/611 +[#612]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/612 + +## [2.2.0] - 2026-03-11 + +**Contributors:** [@HannahVernon](https://github.com/HannahVernon), [@ClaudioESSilva](https://github.com/ClaudioESSilva), [@dphugo](https://github.com/dphugo), [@Orestes](https://github.com/Orestes) — thank you! + +### Important + +- **Schema upgrade**: Three large collection tables (`query_stats`, `procedure_stats`, `query_store_data`) are migrated to use `COMPRESS()` for query text and plan columns. The upgrade performs a table swap (create new → migrate data → rename) which may take several minutes on large tables. A `row_hash` column is added for deduplication. Three new tracking tables are also created. Volume stats columns are added to `database_size_stats`. Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. + + Compression results measured on a production instance: + + | Table | Compressed | Uncompressed | Ratio | + |---|---|---|---| + | query_stats | 18.0 MB | 339.0 MB | 18.8x | + | query_store_data | 13.5 MB | 258.0 MB | 19.1x | + | **Total** | **31.5 MB** | **597 MB** | **~19x** | + +### Added + +- **FinOps monitoring tab** — database size tracking, server properties, storage growth analysis (7d/30d), index analysis with unused/duplicate/compressible detection, utilization efficiency, idle database identification, and estate-level resource views ([#474]) +- **Named collection presets** — Aggressive, Balanced, and Low-Impact schedule profiles via `config.apply_collection_preset` ([#454]) +- **Entra ID interactive MFA authentication** in both CLI and GUI installers for Azure SQL MI connections ([#481]) +- **MCP port validation** — TCP port conflict detection, range validation (1024+), Auto port button, and auto-restart on settings change ([#453]) +- **Alert database exclusion filters** — filter blocking and deadlock alerts by database in both Dashboard and Lite ([#410], [#412]) +- **Configurable alert cooldown periods** for tray notifications and email alerts +- **Wait stats query drill-down** — click a wait type to see the queries causing it ([#372]) +- **Configurable long-running query settings** — max results, WAITFOR/backup/diagnostics exclusions ([#415]) +- **Uninstall option** in both CLI and GUI installers ([#431]) +- **Session stats collector** for active session tracking ([#474]) +- **LOB compression and deduplication** for query stats tables to reduce storage ([#419]) +- **Volume-level drive space** enrichment in database size stats via `dm_os_volume_stats` +- **GUI installer installation history** logging to `config.installation_history` ([#414]) +- **ReadOnlyIntent connection option** — Lite connections can set `ApplicationIntent=ReadOnly` for automatic read routing to Always On AG readable secondaries ([#515]) +- **Alert muting** — mute individual alerts or create pattern-based mute rules by server, metric, database, or application. Manage Mute Rules window with enable/disable toggle. Alert history detail view with double-click drill-down and context-sensitive detail text. Poison wait type documentation links. ([#512]) +- **SignPath code signing** — all release binaries (Dashboard, Lite, Installers) are digitally signed, eliminating Windows SmartScreen warnings ([#511]) +- CI version bump check on PRs to main +- Permissions section in README with least-privilege setup ([#421]) + +### Changed + +- **Utilization tab redesigned** — ported to Dashboard with aligned metrics between apps ([#478]) +- PlanAnalyzer rules synced from PerformanceStudio — Rule 5 message format, seek predicate parsing, spool labels, unmatched index detail ([#416], [#475], [#480]) +- Data retention now purges processed XE staging rows +- GeneratedRegex conversion for compile-time regex patterns ([#346], [#420]) +- Server health card width increased from 260 to 300 for less text truncation ([#489]) +- User's locale used for date/time formatting in WPF bindings ([#459]) +- XML processing instructions stripped from sql_command/sql_text display +- Parameterized queries in blocking/deadlock alert filtering +- **DuckDB 1.5.0 upgrade** — non-blocking checkpointing eliminates read stalls during WAL flushes, free block reuse stabilizes database file size without archive-and-reset cycles ([#516]) +- **Automatic parquet compaction** — archive files are merged into monthly files after each archive cycle, reducing file count from 2,600+ to ~75 and eliminating per-file metadata overhead on glob scans ([#516]) + + Combined with the UI responsiveness overhaul (#510), Lite's refresh cycle improved 13-26x: + + | Metric | Before | After | + |---|---|---| + | Lite `RefreshAllDataAsync` | 6-13s | < 500ms | + | Parquet files scanned per query | 233 | 19 | + | Archive-and-reset frequency | 21/day | ~0 | + | `v_wait_stats` query time | 1,700ms | 27ms | + +- **Monthly archive retention** — switched from 90-day file-age deletion to 3-month calendar-month rolling window, aligned with compacted monthly filenames ([#516]) +- **Lite status bar** shows used data size vs file size (e.g., "Database: 175.5 / 423.8 MB") via DuckDB `pragma_database_size()` ([#517]) +- **Query Store collector diagnostics** — reader/append/flush timing breakdown logged when collection exceeds 2 seconds, for identifying SQL Server DMV contention under heavy workloads ([#518]) +- SSMS-parity edge tooltips on plan viewer operator connections and ManyToMany indicator always shown for merge join operators ([#504]) +- **Lite UI responsiveness overhaul** — visible-tab-only refresh, sub-tab awareness, Query Store collector optimization (NULL plan XML + LOOP JOIN hint), and DuckDB write reduction ([#510]) + + Timer tick improvements measured under TPC-C load on SQL2022: + + | Scenario | Before | After | Improvement | + |---|---|---|---| + | Lite idle | 6-13s | 546-750ms | ~90% | + | Lite under TPC-C | 6-13s | ~3s | ~70% | + | Dashboard idle | 5.6s | 0.6-0.8s | 86% | + | Dashboard under TPC-C | 5.6s | 1.8-2.0s | 64% | + + Query Store collector specifically: + + | Metric | Before | After | + |---|---|---| + | query_store collector total | 6-18s | ~600ms | + | query_store SQL time | 374-1,104ms | ~300ms (LOOP JOIN hint) | + | query_store DuckDB write | 6-16s | ~75-230ms (NULL plan XML) | + +### Fixed + +- **UI hang** when opening Dashboard tab for offline server — replaced synchronous `.GetAwaiter().GetResult()` with proper `await` ([#477]) +- **First-collection spike** skewing PerfMon, wait stats, file I/O, memory grant, query stats, and procedure stats charts — first cumulative value now treated as baseline ([#482]) +- **Wait type filter TextBox** too small to read ([#488]) +- **Poison wait false positives** and alert log parsing ([#445], [#448]) +- **RID Lookup** analyzer rule matching new PhysicalOp label ([#429]) +- **procedure_stats** plan query using DECOMPRESS after compression migration +- **database_size_stats** InvalidCastException on compatibility_level +- **Deadlock filter** using wrong column reference in `GetFilteredDeadlockCountAsync` +- **RESTORING database** filter added to waiting_tasks collector ([#430]) +- Custom TrayToolTip crash — replaced with plain ToolTipText ([#422]) +- **Lite tab switch freeze** — added `_isRefreshing` guard to prevent tab switch handler from competing with timer ticks for DuckDB connection, eliminating "not responding" hangs ([#510]) +- DuckDB read lock acquisition resilience +- Formatted duration columns sorting alphabetically instead of numerically +- Settings window staying open on validation errors +- Deserialization clamping and validation abort issues +- **sp_IndexCleanup** summary grid column mapping off-by-one, expanded both grids to show all columns from both result sets ([#503]) +- **Rule 22 table variable** false positive on modification operators — INSERT/UPDATE/DELETE on table variables is expected ([#513]) +- **ComboBox focus steal** in plan viewer stealing keyboard focus from other controls ([#508]) +- **DOP 2 skew** false positive — parallel skew rule no longer fires at DOP 2 ([#508]) +- **ReadOnlyIntent connections** sharing server_id in DuckDB when the same server was added with and without ReadOnlyIntent ([#521]) + +[2.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v2.1.0...v2.2.0 + +## [2.1.0] - 2026-03-04 + +### Important + +- **Schema upgrade**: The `config.collection_schedule` table gains two new columns (`collect_query`, `collect_plan`) for optional query text and execution plan collection. Both default to enabled to preserve existing behavior. Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. + +### Added + +- **Light theme and "Cool Breeze" theme** — full light mode support for both Dashboard and Lite with live preview in settings ([#347]) +- **Standalone Plan Viewer** — open, paste (Ctrl+V), or drag & drop `.sqlplan` files independent of any server connection, with tabbed multi-plan support ([#359]) +- **Time display mode toggle** — show timestamps in Server Time, Local Time, or UTC with timezone labels across all grids and tooltips ([#17]) +- **30 PlanAnalyzer rules** — expanded from 12 to 30 rules covering implicit conversions, GetRangeThroughConvert, lazy spools, OR expansion, exchange spills, RID lookups, and more ([#327], [#349], [#356], [#379]) +- **Wait stats banner** in plan viewer showing top waits for the query ([#373]) +- **UDF runtime details** — CPU and elapsed time shown in Runtime Summary pane when UDFs are present ([#382]) +- **Sortable statement grid** and canvas panning in plan viewer ([#331]) +- **Comma-separated column filters** — enter multiple values separated by commas in text filters ([#348]) +- **Optional query text and plan collection** — per-collector flags in `config.collection_schedule` to disable query text or plan capture ([#337]) +- **`--preserve-jobs` installer flag** — keep existing SQL Agent job schedules during upgrade ([#326]) +- **Copy Query Text** context menu on Dashboard statements grid ([#367]) +- **Server list sorting** by display name in both Dashboard and Lite ([#30]) +- **Warning status icon** in server health indicators ([#355]) +- Reserved threads and 10 missing ShowPlan XML attributes in plan viewer ([#378]) +- Nightly build workflow for CI ([#332]) + +### Changed + +- PlanAnalyzer warning messages rewritten to be actionable with expert-guided per-rule advice ([#370], [#371]) +- PlanAnalyzer rule tuning: time-based spill analysis (Rule 7), lowered parallel skew thresholds (Rule 8), memory grant floor raised to 1GB/4GB (Rule 9), skip PROBE-only bitmap predicates (Rule 11) ([#341], [#342], [#343], [#358]) +- First-run collector lookback reduced from 3-7 days to 1 hour for faster initial data ([#335]) +- Plan canvas aligns top-left and resets scroll on statement switch ([#366]) +- Plan viewer polish: index suggestions, property panel improvements, muted brush audit ([#365]) +- Add Server dialog visual parity between Dashboard and Lite with theme-driven PasswordBox styling ([#289]) + +### Fixed + +- **OverflowException** on wait stats page with large decimal values — SQL Server `decimal(38,24)` exceeding .NET precision ([#395]) +- **SQL dumps** on mirroring passive servers with RESTORING databases ([#384]) +- **UI hang** when adding first server to Dashboard ([#387]) +- **UTC/local timezone mismatch** in blocked process XML processor ([#383]) +- **AG secondary filter** skipping all inaccessible databases in cross-database collectors ([#325]) +- DuckDB column aliases in long-running queries ([#391]) +- sp_server_diagnostics and WAITFOR excluded from long-running query alerts ([#362]) +- UDF timing units corrected: microseconds to milliseconds ([#338]) +- DuckDB migration ordering after archive-and-reset ([#314]) +- Int16 cast error in long-running query alerts ([#313]) +- Missing dark mode on 19 SystemEventsContent charts ([#321]) +- Missing tooltips on charts after theme changes ([#319]) +- Operator time per-thread calculation synced across all plan viewers ([#392]) +- Theme StaticResource/DynamicResource binding fix for runtime theme switching +- Memory grant MB display, missing index quality scoring, wildcard LIKE detection ([#393]) +- **Installer validation** reporting historical collection errors as current failures — now filters to current run only ([#400]) +- **query_snapshots schema mismatch** after sp_WhoIsActive upgrade — collector auto-recreates daily table when column order changes ([#401]) +- **Missing upgrade script** for `default_trace_events` columns (`duration_us`, `end_time`) on 2.0.0→2.1.0 upgrade path ([#400]) + +## [2.0.0] - 2026-02-25 + +### Important + +- **Schema upgrade**: The `collect.memory_grant_stats` table gains new delta columns and drops unused warning columns. The `collect.session_wait_stats` table, its collector procedure, reporting view, and schedule entry are removed (zero UI coverage). Upgrade scripts run automatically via the CLI/GUI installer and use idempotent checks. + +### Added + +- **Graphical query plan viewer** — native ShowPlan rendering in both Dashboard and Lite with SSMS-parity operator icons, properties panel, tooltips, warning/parallelism badges, and tabbed plan display ([#220]) +- **Actual execution plan support** — execute queries with SET STATISTICS XML ON to capture actual plans, with loading indicator and confirmation dialog ([#233]) +- **PlanAnalyzer** — automated plan analysis with rules for missing indexes, eager spools, key lookups, implicit conversions, memory grants, and more +- **Current Active Queries live snapshot** — real-time view of running queries with estimated/live plan download ([#149]) +- **Memory clerks tab** in Lite with picker-driven chart ([#145]) +- **Current Waits charts** in Blocking tab for both Dashboard and Lite ([#280]) +- **File I/O throughput charts** — read/write throughput trends, file-level latency breakdown, queued I/O overlay ([#281]) +- **Memory grant stats charts** — standardized collection with delta framework integration and trend visualization ([#281]) +- **CPU scheduler pressure status** — real-time scheduler, worker, runnable task counts with color-coded pressure level below CPU chart +- **Collection log drill-down** and daily summary in Lite ([#138]) +- **Collector duration trends chart** in Dashboard Collection Health ([#138]) +- **Themed perfmon counter packs** — 14 new counters with organized themed groups ([#255]) +- **User-configurable connection timeout** setting ([#236]) +- **Per-collector retention** — uses per-collector retention from `config.collection_schedule` in data retention ([#237]) +- **Query identifiers** in drill-down windows — query hash, plan hash, SQL handle visible for identification ([#268]) +- **Trace pattern drill-down** with missing columns and query text tooltips ([#273]) +- **Query Store Regressions drill-down** with TVF rewrite for performance ([#274]) +- **CLI `--help` flag** for installer ([#111]) +- Sort arrows, right-aligned numerics, and initial sort indicators across all grids ([#110]) +- Copyable plan viewer properties ([#269]) +- Standardized chart save/export filenames between Dashboard and Lite ([#284]) +- Full Dashboard column parity for query_stats, procedure_stats, and query_store_stats +- Min/max extremes surfaced in both apps — physical reads, rows, grant KB, spills, CLR time, log bytes ([#281]) + +### Changed + +- Query Store detection uses `sys.database_query_store_options` instead of `sys.databases.is_query_store_on` for Azure SQL DB compatibility ([#287]) +- Config tab consolidation, DB drop on server remove, DuckDB-first plan lookups, procedure stats parity +- Collector health status now detects consecutive recent failures — 5+ consecutive errors = FAILING, 3+ = WARNING +- Plan buttons now show a MessageBox when no plan is available instead of silently doing nothing +- CSV export uses locale-appropriate separators for non-US locales ([#240]) +- Query Store Regressions and Query Trace Patterns migrated to popup grid filtering ([#260]) +- NuGet packages updated; xUnit v3 migration + +### Fixed + +- **DuckDB file corruption** during maintenance — ReaderWriterLockSlim coordination, archive-all-and-reset at 512MB replaces compaction ([#218]) +- Archive view column mismatch, wait_stats thread-safety, and percent_complete type cast ([#234]) +- Collector health status bar text color ([#234]) +- View Plan for Query Store and Query Store Regressions tabs ([#261]) +- Query Store drill-down time filter alignment with main view ([#263]) +- Execution count mismatches between main views and drill-downs +- Drill-down chart UX — sparse data markers, hover tooltips, window sizing ([#271]) +- Truncated status text in Add Server dialog ([#257]) +- Scrollbar visibility, self-filtering artifacts, missing columns, and context menus ([#245], [#246], [#247], [#248]) +- query_stats and procedure_stats collectors ignoring recent queries +- Blank tooltips on warning and parallel badge icons +- Missing chart context menu on File I/O Throughput charts in Lite + +### Removed + +- `collect.session_wait_stats` table, `collect.session_wait_stats_collector` procedure, `report.session_wait_analysis` view, and schedule entry — zero UI coverage, never surfaced in Dashboard or Lite ([#281]) + +## [1.3.0] - 2026-02-20 + +### Important + +- **Schema upgrade**: The `collect.memory_stats` table gains two new columns (`total_physical_memory_mb`, `committed_target_memory_mb`). The upgrade script runs automatically via the CLI/GUI installer and uses `IF NOT EXISTS` checks, so it is safe to re-run. On servers with very large `memory_stats` tables this ALTER may take a moment. + +### Added + +- Physical Memory, SQL Server Memory, and Target Memory columns in Memory Overview ([#140]) +- Current Configuration view (Server Config, Database Config, Trace Flags) in Dashboard Overview ([#143]) +- Popup column filters and right-click context menus in all drill-down history windows ([#206]) +- Consistent popup column filters across all Dashboard grids — replaced remaining TextBox-in-header filters and added filters to Trace Flags ([#200]) +- 7-day time filter option in drill-down queries ([#165]) +- Alert badge/count on sidebar Alerts button ([#109]) +- Missing poison wait defaults in wait stats picker ([#188]) + +### Changed + +- Default Trace tabs moved from Resource Metrics to Overview section ([#169]) +- Trends tab shown first in Locking section ([#171]) +- Wait stats cap raised from 20 to 30 (Dashboard) / 50 (Lite) so poison waits are never dropped ([#139]) +- Settings time range dropdown now matches dashboard button options ([#210]) +- "Total Executions" label in drill-down summaries renamed to clarify meaning ([#194]) +- WAITFOR sessions excluded from long-running query alerts ([#151]) + +### Fixed + +- Deadlock XML processor timezone mismatch — sp_BlitzLock returning 0 results because UTC dates were passed instead of local time +- Sidebar alert badge not updating when alerts dismissed from server sub-tabs ([#214]) +- Sidebar alert badge not clearing on acknowledge ([#186]) +- NOC deadlock/blocking showing "just now" for stale events instead of actual timestamp ([#187]) +- NOC deadlock severity using extended events timestamp ([#170]) +- Newly added servers not appearing on Overview until app restart ([#199]) +- Double-click on column header incorrectly triggering row drill-down ([#195]) +- Squished drill-down charts — now use proportional sizing ([#166]) +- Unreliable chart tooltips — now use X-axis proximity matching ([#167]) +- Query Trace Patterns showing empty despite data existing ([#168]) +- Drill-down windows: removed inline plan XML, added time range filtering, aggregated by collection_time ([#189]) +- Row clipping in Default Trace and Current Configuration grids ([#183], [#184]) +- Numeric filter negative range parsing ([#113]) +- MCP shutdown deadlock risk ([#112]) +- Lite DBNull cast error in database_config collector on SQL 2016 Express ([#192]) +- DuckDB concurrent file access IO errors ([#164]) + +## [1.2.0] - 2026-02-15 + +### Added + +- Alert types, alerts history view, column filtering, and dismiss/hide for alerts ([#52], [#56]) +- Average ms per wait chart toggle in both apps ([#22]) +- Collection Health tab in Lite UI ([#39]) +- Collector performance diagnostics in Lite UI ([#40]) +- Hover tooltips on all Dashboard charts ([#70]) +- Minimize-to-tray setting added to Lite ([#53]) +- Persist dismissed alerts across app restarts ([#44]) +- Locale-aware date/time formatting throughout UI ([#41]) +- 24-hour format in time range picker ([#41]) +- CI pipelines for build validation, SQL install testing, and DuckDB schema tests +- Expanded Lite database config collector to 28 sys.databases columns ([#142]) +- Parquet archive visibility and scheduled DuckDB database compaction ([#160], [#161]) +- DuckDB checkpoint optimization and collection timing accuracy +- Installer `--reset-schedule` flag to reset collection schedule on re-install + +### Fixed + +- Deadlock charts not populating data ([#73]) +- Chart X-axis double-converting custom range to server time ([#49]) +- query_cost overflow in memory grant collector ([#47]) +- XE ring buffer query timeouts on large buffers ([#37]) +- Dashboard sub-tab badge state and DuckDB migration for dismissed column +- Lite duplicate blocking/deadlock events from missing WHERE clause ([#61]) +- Procedure_stats_collector truncation on DDL triggers ([#69]) +- DataGrid row height increased from 25 to 28 to fix text clipping +- Skip offline servers during Lite collection and reduce connection timeout ([#90]) +- Mutex crash on Lite app exit ([#89]) +- Permission denied errors handled gracefully in collector health ([#150]) + +## [1.1.0] - 2026-02-13 + +### Added + +- Hover tooltips on all multi-series charts — Wait Stats, Sessions, Latch Stats, Spinlock Stats, File I/O, Perfmon, TempDB ([#21]) +- Microsoft Entra MFA authentication for Azure SQL DB connections in Lite ([#20]) +- Column-level filtering on all 11 Lite DataGrids ([#18]) +- Chart visual parity — Material Design 300 color palette, data point markers, consistent grid styling ([#16]) +- Smart Select All for wait types + expand from 12 to 20 wait types ([#12]) +- Trend chart legends always visible in Dashboard ([#11]) +- Per-server collector health in Lite status bar ([#5]) +- Server Online/Offline status in Lite overview ([#2]) +- Check for updates feature in both apps ([#1]) +- High DPI support for both Dashboard and Lite + +### Fixed + +- Query text off-by-one truncation ([#25]) +- Blocking/deadlock XML processors truncating parsed data every run ([#23]) +- WAITFOR queries appearing in top queries views ([#4]) +- Wait type Clear All not refreshing search filter in Dashboard + +## [1.0.0] - 2026-02-11 + +### Added + +- Full Edition: Dashboard + CLI/GUI Installer with 30+ automated SQL Agent collectors +- Lite Edition: Agentless monitoring with local DuckDB storage +- Support for SQL Server 2016-2025, Azure SQL DB, Azure SQL MI, AWS RDS +- Real-time charts and trend analysis for wait stats, CPU, memory, query performance, index usage, file I/O, blocking, deadlocks +- Email alerts for blocking, deadlocks, and high CPU +- MCP server integration for AI-assisted analysis +- System tray operation with background collection and alert notifications +- Data retention with configurable automatic cleanup +- Delta normalization for per-second rate calculations +- Dark theme UI + +[2.1.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v2.0.0...v2.1.0 +[2.0.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.3.0...v2.0.0 +[1.3.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/erikdarlingdata/PerformanceMonitor/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/erikdarlingdata/PerformanceMonitor/releases/tag/v1.0.0 +[#1]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1 +[#2]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2 +[#4]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/4 +[#5]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/5 +[#11]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/11 +[#12]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/12 +[#16]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/16 +[#18]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/18 +[#20]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/20 +[#21]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/21 +[#22]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/22 +[#23]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/23 +[#25]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/25 +[#37]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/37 +[#39]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/39 +[#40]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/40 +[#41]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/41 +[#44]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/44 +[#47]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/47 +[#49]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/49 +[#52]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/52 +[#53]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/53 +[#56]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/56 +[#61]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/61 +[#69]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/69 +[#70]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/70 +[#73]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/73 +[#85]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/85 +[#86]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/86 +[#89]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/89 +[#90]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/90 +[#109]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/109 +[#112]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/112 +[#113]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/113 +[#139]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/139 +[#140]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/140 +[#142]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/142 +[#143]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/143 +[#150]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/150 +[#151]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/151 +[#160]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/160 +[#161]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/161 +[#164]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/164 +[#165]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/165 +[#166]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/166 +[#167]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/167 +[#168]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/168 +[#169]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/169 +[#170]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/170 +[#171]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/171 +[#183]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/183 +[#184]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/184 +[#186]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/186 +[#187]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/187 +[#188]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/188 +[#189]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/189 +[#192]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/192 +[#194]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/194 +[#195]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/195 +[#199]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/199 +[#200]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/200 +[#206]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/206 +[#210]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/210 +[#214]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/214 +[#218]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/218 +[#220]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/220 +[#233]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/233 +[#234]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/234 +[#236]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/236 +[#237]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/237 +[#240]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/240 +[#245]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/245 +[#246]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/246 +[#247]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/247 +[#248]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/248 +[#255]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/255 +[#257]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/257 +[#260]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/260 +[#261]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/261 +[#263]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/263 +[#268]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/268 +[#269]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/269 +[#271]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/271 +[#273]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/273 +[#274]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/274 +[#280]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/280 +[#281]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/281 +[#284]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/284 +[#287]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/287 +[#313]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/313 +[#314]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/314 +[#17]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/17 +[#30]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/30 +[#319]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/319 +[#321]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/321 +[#325]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/325 +[#326]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/326 +[#327]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/327 +[#331]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/331 +[#332]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/332 +[#335]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/335 +[#337]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/337 +[#338]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/338 +[#341]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/341 +[#342]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/342 +[#343]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/343 +[#347]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/347 +[#348]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/348 +[#349]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/349 +[#355]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/355 +[#356]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/356 +[#358]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/358 +[#359]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/359 +[#362]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/362 +[#365]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/365 +[#366]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/366 +[#367]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/367 +[#370]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/370 +[#371]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/371 +[#373]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/373 +[#378]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/378 +[#379]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/379 +[#382]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/382 +[#383]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/383 +[#384]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/384 +[#387]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/387 +[#391]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/391 +[#392]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/392 +[#393]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/393 +[#289]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/289 +[#395]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/395 +[#400]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/400 +[#401]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/401 +[#410]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/410 +[#412]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/412 +[#414]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/414 +[#415]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/415 +[#416]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/416 +[#419]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/419 +[#420]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/420 +[#421]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/421 +[#422]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/422 +[#429]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/429 +[#430]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/430 +[#431]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/431 +[#445]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/445 +[#448]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/448 +[#453]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/453 +[#454]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/454 +[#459]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/459 +[#474]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/474 +[#475]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/475 +[#477]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/477 +[#478]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/478 +[#480]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/480 +[#481]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/481 +[#482]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/482 +[#488]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/488 +[#489]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/489 +[#503]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/503 +[#504]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/504 +[#508]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/508 +[#510]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/510 +[#512]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/512 +[#511]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/511 +[#513]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/513 +[#515]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/515 +[#516]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/516 +[#517]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/517 +[#518]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/518 +[#521]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/521 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fdee23e3..0be3d4f7 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,44 +1,44 @@ -# Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment: - -* Being respectful of differing opinions and experiences -* Giving and gracefully accepting constructive feedback -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior: - -* Trolling, insulting or derogatory comments, and personal attacks -* Public or private harassment -* Publishing others' private information without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the project maintainer at **erik@erikdarling.com**. - -All complaints will be reviewed and investigated and will result in a response -that is deemed necessary and appropriate to the circumstances. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), -version 2.1, available at -https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +* Being respectful of differing opinions and experiences +* Giving and gracefully accepting constructive feedback +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior: + +* Trolling, insulting or derogatory comments, and personal attacks +* Public or private harassment +* Publishing others' private information without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer at **erik@erikdarling.com**. + +All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/Dashboard.Tests/AlertBadgeConditionTests.cs b/Dashboard.Tests/AlertBadgeConditionTests.cs new file mode 100644 index 00000000..ab25879f --- /dev/null +++ b/Dashboard.Tests/AlertBadgeConditionTests.cs @@ -0,0 +1,98 @@ +using System; +using PerformanceMonitorDashboard.Interfaces; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Guards #754/#749: the server-level (Overview) tab badge must light up for an active low-disk +/// breach or a recent failed Agent job — not only for blocking/deadlock/CPU/memory — and the +/// acknowledge baseline must keep it hidden until a NEW such condition appears (false -> true). +/// The Lite badge path shares the same condition shape (UpdateAlertCounts OR-ing the two flags), +/// so this covers both apps' intent; the Lite-specific UI plumbing is verified live. +/// +public class AlertBadgeConditionTests +{ + /// Minimal IUserPreferencesService over one mutable UserPreferences instance. + 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 ServerHealthStatus HealthyStatus() => + new(new ServerConnection { Id = "srv-1", ServerName = "SQL1", DisplayName = "SQL1" }) { IsOnline = true }; + + [Fact] + public void HasAnyAlertCondition_LowDiskOnly_IsTrue() + { + var svc = new AlertStateService(new FakePreferencesService()); + var status = HealthyStatus(); + status.HasLowDiskAlert = true; + Assert.True(svc.HasAnyAlertCondition(status)); + } + + [Fact] + public void HasAnyAlertCondition_FailedJobOnly_IsTrue() + { + var svc = new AlertStateService(new FakePreferencesService()); + var status = HealthyStatus(); + status.HasFailedJobAlert = true; + Assert.True(svc.HasAnyAlertCondition(status)); + } + + [Fact] + public void HasAnyAlertCondition_NoConditions_IsFalse() + { + var svc = new AlertStateService(new FakePreferencesService()); + Assert.False(svc.HasAnyAlertCondition(HealthyStatus())); + } + + [Fact] + public void ShouldShowBadge_LowDisk_ShowsThenSuppressedByAcknowledge_ReturnsOnNewCondition() + { + var svc = new AlertStateService(new FakePreferencesService()); + var status = HealthyStatus(); + status.HasLowDiskAlert = true; + + // The standing low-disk breach lights the Overview badge. + Assert.True(svc.ShouldShowBadge("srv-1", "Overview", status)); + + // Acknowledging snapshots the baseline; the same unchanged breach is now suppressed. + svc.AcknowledgeAlert("srv-1", "Overview", status); + Assert.False(svc.ShouldShowBadge("srv-1", "Overview", status)); + + // A newly-appearing failed-job condition is worse than the baseline -> badge returns. + status.HasFailedJobAlert = true; + Assert.True(svc.ShouldShowBadge("srv-1", "Overview", status)); + } + + [Fact] + public void ShouldShowBadge_LowDisk_AutoClearsWhenResolved() + { + var svc = new AlertStateService(new FakePreferencesService()); + var status = HealthyStatus(); + status.HasLowDiskAlert = true; + + svc.AcknowledgeAlert("srv-1", "Overview", status); + Assert.False(svc.ShouldShowBadge("srv-1", "Overview", status)); + + // Disk recovers: condition gone, baseline auto-clears, badge stays hidden. + status.HasLowDiskAlert = false; + Assert.False(svc.ShouldShowBadge("srv-1", "Overview", status)); + + // A fresh breach after the auto-clear shows the badge again (no stale ack). + status.HasLowDiskAlert = true; + Assert.True(svc.ShouldShowBadge("srv-1", "Overview", status)); + } +} diff --git a/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs b/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs new file mode 100644 index 00000000..00adeef3 --- /dev/null +++ b/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs @@ -0,0 +1,92 @@ +/* + * Performance Monitor Dashboard + * Copyright (c) 2026 Darling Data, LLC + * Licensed under the MIT License - see LICENSE file for details + */ + +using System.Text.Json; +using PerformanceMonitorDashboard.Models; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Recommendations rebuild PR-1 (D0): the new +/// setting gates analysis *production* and is independent of the notification *delivery* +/// gate (). These tests pin the +/// defaults and the JSON persistence contract that UserPreferencesService relies on +/// (it serializes/deserializes the whole UserPreferences object with the same options). +/// +public class AnalysisEnabledPreferenceTests +{ + /// Serializer options matching UserPreferencesService (WriteIndented = true). + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + [Fact] + public void AnalysisEnabled_DefaultsToTrue() + { + var prefs = new UserPreferences(); + Assert.True(prefs.AnalysisEnabled); + } + + [Fact] + public void AnalysisNotificationsEnabled_StillDefaultsToFalse() + { + // D0 keeps delivery opt-in: production is on by default, notifications are not. + var prefs = new UserPreferences(); + Assert.False(prefs.AnalysisNotificationsEnabled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AnalysisEnabled_RoundTripsThroughJson(bool value) + { + // This is exactly what UserPreferencesService.SavePreferences/LoadPreferences do: + // JsonSerializer.Serialize(prefs) -> file -> JsonSerializer.Deserialize. + var prefs = new UserPreferences { AnalysisEnabled = value }; + + var json = JsonSerializer.Serialize(prefs, s_jsonOptions); + var loaded = JsonSerializer.Deserialize(json); + + Assert.NotNull(loaded); + Assert.Equal(value, loaded!.AnalysisEnabled); + } + + [Fact] + public void AnalysisEnabled_AbsentFromExistingJson_DeserializesToTrueDefault() + { + // An existing preferences.json written before this PR has no "AnalysisEnabled" key. + // The default initializer (= true) must apply so upgraded installs get analysis on. + const string legacyJson = """ + { + "AnalysisNotificationsEnabled": false, + "AnalysisIntervalMinutes": 30 + } + """; + + var loaded = JsonSerializer.Deserialize(legacyJson); + + Assert.NotNull(loaded); + Assert.True(loaded!.AnalysisEnabled); + Assert.False(loaded.AnalysisNotificationsEnabled); + } + + [Fact] + public void AnalysisEnabled_AndNotifications_AreIndependentInJson() + { + // Production on, delivery off — the canonical D0 default-on/notify-off state. + var prefs = new UserPreferences + { + AnalysisEnabled = true, + AnalysisNotificationsEnabled = false + }; + + var json = JsonSerializer.Serialize(prefs, s_jsonOptions); + var loaded = JsonSerializer.Deserialize(json); + + Assert.NotNull(loaded); + Assert.True(loaded!.AnalysisEnabled); + Assert.False(loaded.AnalysisNotificationsEnabled); + } +} diff --git a/Dashboard.Tests/AnalysisNotificationTests.cs b/Dashboard.Tests/AnalysisNotificationTests.cs new file mode 100644 index 00000000..ef641453 --- /dev/null +++ b/Dashboard.Tests/AnalysisNotificationTests.cs @@ -0,0 +1,695 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Dashboard's first notification-formatter tests. Mirrors the Lite suite's +/// FindingMessageFormatter.BuildContext ordering/omission cases plus the +/// AlertContext serialization round-trip (triage v1 PR (b)). Dashboard threads +/// the notify threshold as a method parameter, so BuildContext takes (finding, threshold). +/// +public class AnalysisNotificationTests +{ + private static AnalysisFinding MakeFinding( + string hash, + double severity = 1.8, + string category = "cpu_pressure", + int serverId = 1, + double? rootValue = 92.5, + Dictionary? metadata = null, + Dictionary? drillDown = null, + string rootFactKey = "CPU_SPIKE") + { + return new AnalysisFinding + { + ServerId = serverId, + ServerName = "TestServer", + Category = category, + StoryPath = "CPU_SPIKE → PLAN_REGRESSION", + StoryPathHash = hash, + Severity = severity, + Confidence = 0.67, + FactCount = 2, + RootFactKey = rootFactKey, + RootFactValue = rootValue, + TimeRangeStart = DateTime.UtcNow.AddHours(-4), + TimeRangeEnd = DateTime.UtcNow, + RootFactMetadata = metadata, + DrillDown = drillDown + }; + } + + private static Dictionary RegressedQueriesDrillDown() => new() + { + ["regressed_queries"] = new List + { + new + { + database = "AdventureWorks", + query_id = 4242L, + best_plan_id = 17L, + latest_plan_hash = "0xDEAD", + best_plan_hash = "0xBEEF", + latest_cpu_per_exec_us = 9000.0, + best_cpu_per_exec_us = 1200.0, + regression_factor = 7.5 + } + } + }; + + [Fact] + public void BuildContext_RenderingOrder_DiagnosisAdviceTsqlDrillDown() + { + var finding = MakeFinding("planreg000000001", rootFactKey: "PLAN_REGRESSION", + drillDown: RegressedQueriesDrillDown()); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + // [0] Diagnosis, [1] Advice prose, [2] Remediation T-SQL, [3] regressed_queries drill-down. + Assert.Equal(4, context.Details.Count); + Assert.Equal("Diagnosis", context.Details[0].Heading); + + Assert.NotNull(context.Details[1].Body); + Assert.False(context.Details[1].IsCodeBlock); + Assert.Contains("Investigation:", context.Details[1].Body); + Assert.Contains("Remediation:", context.Details[1].Body); + + Assert.Equal("Remediation T-SQL", context.Details[2].Heading); + Assert.True(context.Details[2].IsCodeBlock); + Assert.NotNull(context.Details[2].Body); + Assert.Contains("sp_query_store_force_plan", context.Details[2].Body); + + Assert.Equal("Regressed Queries", context.Details[3].Heading); + } + + [Fact] + public void BuildContext_UnknownFactKey_OmitsAdviceAndTsql() + { + var finding = MakeFinding("unknown000000001", rootFactKey: "ZZZ_TEST"); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + // Diagnosis only — no advice block for an unknown fact key, and no T-SQL. + var only = Assert.Single(context.Details); + Assert.Equal("Diagnosis", only.Heading); + Assert.DoesNotContain(context.Details, d => d.Heading == "Remediation T-SQL"); + } + + [Fact] + public void BuildContext_NonPlanRegression_OmitsTsqlOnly() + { + // CPU_SPIKE has advice but is not PLAN_REGRESSION, so advice renders but no T-SQL. + var context = FindingMessageFormatter.BuildContext(MakeFinding("cpuonly000000001"), notifyThreshold: 1.5); + + Assert.Contains(context.Details, d => d.Body is not null && !d.IsCodeBlock); + Assert.DoesNotContain(context.Details, d => d.IsCodeBlock); + Assert.DoesNotContain(context.Details, d => d.Heading == "Remediation T-SQL"); + } + + [Fact] + public void AlertContext_SerializesAndDeserializes_PreservingFieldsBodyAndCodeBlock() + { + // MINOR-3: round-trip the real BuildContext output for a PLAN_REGRESSION finding (generated + // T-SQL with newlines/brackets/semicolons/-- comments + drill-down Fields), not a hand-built + // context, through the production AlertContextSerializer / DTO. + var finding = MakeFinding("roundtrip0000001", rootFactKey: "PLAN_REGRESSION", + drillDown: RegressedQueriesDrillDown()); + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + Assert.Equal(context.Details.Count, restored.Details.Count); + for (int i = 0; i < context.Details.Count; i++) + { + var expected = context.Details[i]; + var actual = restored.Details[i]; + + Assert.Equal(expected.Heading, actual.Heading); + Assert.Equal(expected.Body, actual.Body); + Assert.Equal(expected.IsCodeBlock, actual.IsCodeBlock); + Assert.Equal(expected.Fields.Count, actual.Fields.Count); + for (int j = 0; j < expected.Fields.Count; j++) + { + Assert.Equal(expected.Fields[j].Label, actual.Fields[j].Label); + Assert.Equal(expected.Fields[j].Value, actual.Fields[j].Value); + } + } + + // The T-SQL code block in particular must survive intact. + var tsql = restored.Details.Single(d => d.IsCodeBlock); + Assert.Contains("sp_query_store_force_plan", tsql.Body); + } + + [Fact] + public void BuildContext_PlanRegression_AttachesStructuredRemediationToCodeBlock() + { + var finding = MakeFinding("remstruct0000001", rootFactKey: "PLAN_REGRESSION", + drillDown: RegressedQueriesDrillDown()); + + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + var tsql = context.Details.Single(d => d.IsCodeBlock); + Assert.NotNull(tsql.Remediation); + Assert.Equal("PLAN_REGRESSION", tsql.Remediation!.FactKey); + Assert.Equal("force", tsql.Remediation.Action); + var target = Assert.Single(tsql.Remediation.Targets); + Assert.Equal("AdventureWorks", target.Database); + Assert.Equal(4242, target.QueryId); + Assert.Equal(17, target.PlanId); + } + + [Fact] + public void AlertContext_RemediationAction_SurvivesRoundTrip() + { + var finding = MakeFinding("remround00000001", rootFactKey: "PLAN_REGRESSION", + drillDown: RegressedQueriesDrillDown()); + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + var rem = restored.Details.Single(d => d.IsCodeBlock).Remediation; + Assert.NotNull(rem); + Assert.Equal("PLAN_REGRESSION", rem!.FactKey); + Assert.Equal("force", rem.Action); + var t = Assert.Single(rem.Targets); + Assert.Equal("AdventureWorks", t.Database); + Assert.Equal(4242, t.QueryId); + Assert.Equal(17, t.PlanId); + Assert.Equal("0xBEEF", t.BestPlanHash); + Assert.Equal(7.5, t.RegressionFactor); + } + + [Fact] + public void AlertContext_DbConfigAction_DbConfigTargetsSurviveRoundTrip() + { + // m-A: a DB_CONFIG action must come back with its DbConfigTargets intact after + // a full serialize -> deserialize. A 3-arg FromDto ctor call would silently + // drop them (action survives but un-applyable); this pins they survive. + var action = new RemediationAction("DB_CONFIG", "set", + Array.Empty(), + new List + { + new("Foo", DbConfigSetting.AutoShrinkOff, "ON"), + new("Foo", DbConfigSetting.PageVerifyChecksum, "TORN_PAGE_DETECTION") + }); + + var context = new AlertContext(); + context.Details.Add(new AlertDetailItem + { + Heading = "Remediation T-SQL", + IsCodeBlock = true, + Body = "ALTER DATABASE [Foo] SET AUTO_SHRINK OFF;", + Remediation = action + }); + + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + var rem = restored.Details.Single(d => d.IsCodeBlock).Remediation; + Assert.NotNull(rem); + Assert.Equal("DB_CONFIG", rem!.FactKey); + Assert.Equal("set", rem.Action); + Assert.Empty(rem.Targets); // force-plan list empty + Assert.NotNull(rem.DbConfigTargets); + Assert.Equal(2, rem.DbConfigTargets!.Count); + Assert.Equal("Foo", rem.DbConfigTargets[0].Database); + Assert.Equal(DbConfigSetting.AutoShrinkOff, rem.DbConfigTargets[0].Setting); + Assert.Equal("ON", rem.DbConfigTargets[0].CurrentValue); + Assert.Equal(DbConfigSetting.PageVerifyChecksum, rem.DbConfigTargets[1].Setting); + Assert.Equal("TORN_PAGE_DETECTION", rem.DbConfigTargets[1].CurrentValue); + } + + [Fact] + public void AlertContext_ForcePlanAction_DbConfigTargetsNull_AfterRoundTrip() + { + // A force-plan action has no DbConfigTargets; it must round-trip to null + // (not an empty list that would imply a DB_CONFIG action). + var finding = MakeFinding("remround00000002", rootFactKey: "PLAN_REGRESSION", + drillDown: RegressedQueriesDrillDown()); + var context = FindingMessageFormatter.BuildContext(finding, notifyThreshold: 1.5); + + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + var rem = restored.Details.Single(d => d.IsCodeBlock).Remediation; + Assert.NotNull(rem); + Assert.Null(rem!.DbConfigTargets); + } + + // ── B3 Phase 3 (PR-B): second "Enable RCSI (advanced)" detail item + cross-surface ── + + private static AnalysisFinding DbConfigRcsiFinding( + bool rcsiOff = true, bool enrichment = true, bool alsoAutoShrink = false, + int blocking = 12, int deadlocks = 3, int? rwPct = 80, string hash = "dbconfig00000099") + { + var row = new Dictionary + { + ["database"] = "Foo", + ["recovery_model"] = "FULL", + ["rcsi"] = !rcsiOff, // rcsi == true means RCSI is ON + ["query_store"] = true, + ["issues"] = rcsiOff ? new[] { "RCSI OFF" } : System.Array.Empty(), + ["auto_shrink"] = alsoAutoShrink, + ["auto_close"] = false, + ["page_verify"] = "CHECKSUM", + }; + if (enrichment) + { + row["rcsi_blocking_events"] = blocking; + row["rcsi_deadlocks"] = deadlocks; + row["rcsi_reader_writer_pct"] = rwPct; + } + return MakeFinding(hash, rootFactKey: "DB_CONFIG", category: "config_issues", + drillDown: new Dictionary { ["config_issues"] = new List { row } }); + } + + [Fact] + public void BuildContext_RcsiOffWithEnrichment_EmitsSecondEnableRcsiItem() + { + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(), notifyThreshold: 1.5); + + var rcsiItem = Assert.Single(context.Details, d => d.Heading == "Enable RCSI (advanced)"); + Assert.True(rcsiItem.IsCodeBlock); + Assert.NotNull(rcsiItem.Remediation); + Assert.Equal("RCSI", rcsiItem.Remediation!.FactKey); + Assert.Contains("SET READ_COMMITTED_SNAPSHOT ON", rcsiItem.Body); + // The figures rode the action (captured while the finding was in hand). + Assert.NotNull(rcsiItem.Remediation.RcsiFigures); + Assert.Equal(12, rcsiItem.Remediation.RcsiFigures!.BlockingEvents); + + // The cross-surface disclosure item is also present (read-only, both email bodies + webhook). + var disclosure = Assert.Single(context.Details, d => d.Heading == "RCSI — risks of changing / not changing"); + Assert.False(disclosure.IsCodeBlock); + Assert.Contains("Risks of CHANGING", disclosure.Body); + Assert.Contains("Risks of NOT changing", disclosure.Body); + Assert.Contains("RCSI eliminates", disclosure.Body); // 80% reader/writer + } + + [Fact] + public void BuildContext_RcsiOn_EmitsNoEnableRcsiItem() + { + // RCSI already ON -> the affordance must NOT appear (M2-1 polarity). + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(rcsiOff: false), notifyThreshold: 1.5); + Assert.DoesNotContain(context.Details, d => d.Heading == "Enable RCSI (advanced)"); + Assert.DoesNotContain(context.Details, d => d.Heading == "RCSI — risks of changing / not changing"); + } + + [Fact] + public void BuildContext_RcsiOffNoEnrichment_EmitsNoEnableRcsiItem() + { + // Legacy alert: RCSI off but no §3.3 enrichment -> no affordance. + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(enrichment: false), notifyThreshold: 1.5); + Assert.DoesNotContain(context.Details, d => d.Heading == "Enable RCSI (advanced)"); + } + + [Fact] + public void BuildContext_RcsiItem_NotGatedBehindAlwaysSafeRemediationTsql() + { + // The residual round-2 note: emit the RCSI item on ANY config_issues-bearing + // finding, NOT only when the always-safe block emitted a Remediation T-SQL item. + // Here ONLY RCSI is wrong (no auto_shrink/auto_close/page_verify issue), so the + // always-safe BuildAction yields null and NO "Remediation T-SQL" item is emitted — + // yet the RCSI item must still appear. + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(alsoAutoShrink: false), notifyThreshold: 1.5); + + Assert.DoesNotContain(context.Details, d => d.Heading == "Remediation T-SQL"); + Assert.Contains(context.Details, d => d.Heading == "Enable RCSI (advanced)"); + } + + [Fact] + public void BuildContext_BothItems_WhenAlsoAlwaysSafeIssue() + { + // RCSI off + an always-safe issue (auto_shrink ON): BOTH the always-safe + // "Remediation T-SQL" item AND the separate "Enable RCSI (advanced)" item appear, + // each with its own singular Remediation. The always-safe one never carries RCSI. + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(alsoAutoShrink: true), notifyThreshold: 1.5); + + var safe = Assert.Single(context.Details, d => d.Heading == "Remediation T-SQL"); + Assert.Equal("DB_CONFIG", safe.Remediation!.FactKey); + Assert.All(safe.Remediation.DbConfigTargets!, t => Assert.NotEqual(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting)); + + var rcsi = Assert.Single(context.Details, d => d.Heading == "Enable RCSI (advanced)"); + Assert.Equal("RCSI", rcsi.Remediation!.FactKey); + } + + [Fact] + public void BuildContext_RcsiItem_RemediationActionAndFigures_SurviveRoundTrip() + { + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(), notifyThreshold: 1.5); + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + var rcsi = Assert.Single(restored.Details, d => d.Heading == "Enable RCSI (advanced)"); + Assert.NotNull(rcsi.Remediation); + Assert.Equal("RCSI", rcsi.Remediation!.FactKey); + var t = Assert.Single(rcsi.Remediation.DbConfigTargets!); + Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting); + // The real figures survive the persistence round-trip. + Assert.NotNull(rcsi.Remediation.RcsiFigures); + Assert.Equal(12, rcsi.Remediation.RcsiFigures!.BlockingEvents); + Assert.Equal(3, rcsi.Remediation.RcsiFigures.Deadlocks); + Assert.Equal(80, rcsi.Remediation.RcsiFigures.ReaderWriterPct); + } + + [Fact] + public void CrossSurface_RiskDisclosure_RendersInBothEmailBodiesAndWebhook() + { + var context = FindingMessageFormatter.BuildContext(DbConfigRcsiFinding(), notifyThreshold: 1.5); + var branding = EmailAlertService.Branding; + + // Both email bodies flow from context.Details — the disclosure must render in EACH + // (feedback_emailtemplatebuilder_two_bodies: HTML + plain-text are separate bodies). + var (html, plain) = EmailTemplateBuilder.BuildAlertEmail( + "High CPU", "TestServer", "n/a", "n/a", 15, branding, context); + + foreach (var body in new[] { html, plain }) + { + Assert.Contains("RCSI — risks of changing / not changing", body); + Assert.Contains("Risks of CHANGING", body); + Assert.Contains("Risks of NOT changing", body); + Assert.Contains("RCSI eliminates", body); + } + + // Webhook payloads (Teams + Slack) also flow from context.Details. + var teams = WebhookAlertService.BuildTeamsPayload( + "High CPU", "TestServer", "n/a", "n/a", branding, context: context); + var slack = WebhookAlertService.BuildSlackPayload( + "High CPU", "TestServer", "n/a", "n/a", branding, context: context); + + foreach (var payload in new[] { teams, slack }) + { + Assert.Contains("Risks of NOT changing", payload); + Assert.Contains("RCSI eliminates", payload); + } + } + + // ── Clear-cached-plan (PR-B): "Clear cached plan (advanced)" item + cross-surface ── + + private static AnalysisFinding CpuFindingWithAbnormalPlans( + bool withRow = true, string rootFactKey = "CPU_SQL_PERCENT", + int cpuPercent = 62, bool planRegressionCoFired = false, bool parameterSensitivityCoFired = false) + { + var rows = new List(); + if (withRow) + { + rows.Add(new + { + query_hash = "0xABCDEF0123456789", + database = "AdventureWorks", + current_cpu_per_exec_ms = 45.0, + baseline_cpu_per_exec_ms = 9.0, + anomaly_ratio = 5.0, + execution_count = 1200L, + total_cpu_ms = 54000.0, + latest_plan_handle = "0x06000100ABCD", + query_text = "SELECT * FROM dbo.BigTable WHERE x = @p", + cpu_percent = cpuPercent, + plan_regression_cofired = planRegressionCoFired, + parameter_sensitivity_cofired = parameterSensitivityCoFired + }); + } + return MakeFinding("cpuplan000000001", rootFactKey: rootFactKey, category: "cpu", + drillDown: new Dictionary { ["abnormal_cpu_plans"] = rows }); + } + + [Fact] + public void BuildContext_CpuWithAbnormalPlans_EmitsClearCachedPlanItem() + { + var context = FindingMessageFormatter.BuildContext(CpuFindingWithAbnormalPlans(), notifyThreshold: 1.5); + + var item = Assert.Single(context.Details, d => d.Heading == "Clear cached plan (advanced)"); + Assert.True(item.IsCodeBlock); + Assert.NotNull(item.Remediation); + Assert.Equal("CLEAR_PLAN", item.Remediation!.FactKey); + var t = Assert.Single(item.Remediation.ClearPlanTargets!); + Assert.Equal("0xABCDEF0123456789", t.QueryHash); + // The figures rode the action (captured while the finding was in hand) incl. the REAL cpu%. + Assert.NotNull(item.Remediation.ClearPlanFigures); + Assert.Equal(62, item.Remediation.ClearPlanFigures!.CpuPercent); + + // The cross-surface disclosure item is also present (read-only, both email bodies + webhook). + var disclosure = Assert.Single(context.Details, d => d.Heading == "Clear cached plan — risks of changing / not changing"); + Assert.False(disclosure.IsCodeBlock); + Assert.Contains("Risks of CHANGING", disclosure.Body); + Assert.Contains("Risks of NOT changing", disclosure.Body); + Assert.Contains("62%", disclosure.Body); // the REAL cpu% (LOW-1 fix), not 0% + Assert.Contains("forces a recompile", disclosure.Body); + } + + [Fact] + public void BuildContext_CpuSpikeRootKey_AlsoEmitsClearCachedPlanItem() + { + var context = FindingMessageFormatter.BuildContext( + CpuFindingWithAbnormalPlans(rootFactKey: "CPU_SPIKE"), notifyThreshold: 1.5); + Assert.Contains(context.Details, d => d.Heading == "Clear cached plan (advanced)"); + } + + [Fact] + public void BuildContext_CpuNoQualifyingRow_EmitsNoClearCachedPlanItem() + { + // No qualifying abnormal_cpu_plans row -> no affordance, no disclosure. + var context = FindingMessageFormatter.BuildContext( + CpuFindingWithAbnormalPlans(withRow: false), notifyThreshold: 1.5); + Assert.DoesNotContain(context.Details, d => d.Heading == "Clear cached plan (advanced)"); + Assert.DoesNotContain(context.Details, d => d.Heading == "Clear cached plan — risks of changing / not changing"); + } + + [Fact] + public void BuildContext_ClearPlanItem_ActionAndFigures_SurviveRoundTrip() + { + var context = FindingMessageFormatter.BuildContext(CpuFindingWithAbnormalPlans(), notifyThreshold: 1.5); + var json = AlertContextSerializer.Serialize(context); + Assert.True(AlertContextSerializer.TryDeserialize(json, out var restored)); + + var item = Assert.Single(restored.Details, d => d.Heading == "Clear cached plan (advanced)"); + Assert.NotNull(item.Remediation); + Assert.Equal("CLEAR_PLAN", item.Remediation!.FactKey); + var t = Assert.Single(item.Remediation.ClearPlanTargets!); + Assert.Equal("0xABCDEF0123456789", t.QueryHash); + // The real figures (incl. the cpu% fix) survive the persistence round-trip. + Assert.NotNull(item.Remediation.ClearPlanFigures); + Assert.Equal(62, item.Remediation.ClearPlanFigures!.CpuPercent); + Assert.Equal(5.0, item.Remediation.ClearPlanFigures.AnomalyRatio); + } + + [Fact] + public void CrossSurface_ClearPlan_RiskDisclosure_RendersInBothEmailBodiesAndWebhook() + { + var context = FindingMessageFormatter.BuildContext(CpuFindingWithAbnormalPlans(), notifyThreshold: 1.5); + var branding = EmailAlertService.Branding; + + // Both email bodies flow from context.Details — the disclosure must render in EACH + // (feedback_emailtemplatebuilder_two_bodies: HTML + plain-text are separate bodies). + var (html, plain) = EmailTemplateBuilder.BuildAlertEmail( + "High CPU", "TestServer", "n/a", "n/a", 15, branding, context); + + foreach (var body in new[] { html, plain }) + { + Assert.Contains("Clear cached plan — risks of changing / not changing", body); + Assert.Contains("Risks of CHANGING", body); + Assert.Contains("Risks of NOT changing", body); + Assert.Contains("62%", body); // the REAL cpu% in both bodies + } + + // Webhook payloads (Teams + Slack) also flow from context.Details. + var teams = WebhookAlertService.BuildTeamsPayload( + "High CPU", "TestServer", "n/a", "n/a", branding, context: context); + var slack = WebhookAlertService.BuildSlackPayload( + "High CPU", "TestServer", "n/a", "n/a", branding, context: context); + + foreach (var payload in new[] { teams, slack }) + { + Assert.Contains("Risks of NOT changing", payload); + Assert.Contains("forces a recompile", payload); + } + } + + [Fact] + public void CrossSurface_ClearPlan_McpDisclosure_FromGetForFinding() + { + // The MCP analyze_server output reads advice.Risks from FactAdvice.GetForFinding — + // the SAME seam email/webhook use. A CPU finding with abnormal_cpu_plans must carry + // the two-sided CLEAR_PLAN disclosure there too. + var advice = FactAdvice.GetForFinding(CpuFindingWithAbnormalPlans()); + Assert.NotNull(advice); + Assert.NotNull(advice!.Risks); + Assert.NotEmpty(advice.Risks!.RisksOfChanging); + Assert.NotEmpty(advice.Risks.RisksOfNotChanging); + Assert.Contains(advice.Risks.RisksOfChanging, r => r.Text.Contains("forces a recompile")); + Assert.Contains(advice.Risks.RisksOfNotChanging, r => r.Text.Contains("62%")); + } + + [Fact] + public void AlertContext_LegacyJsonWithoutRemediation_DeserializesToNull() + { + // A persisted context written before the Remediation field existed: a code + // block with no "Remediation" property. It must deserialize cleanly with + // Remediation == null (no Apply button, no crash) — backward compatibility. + const string legacyJson = + "{\"Details\":[{\"Heading\":\"Remediation T-SQL\",\"Fields\":[]," + + "\"Body\":\"EXEC sys.sp_query_store_force_plan @query_id = 1, @plan_id = 2;\",\"IsCodeBlock\":true}]}"; + + Assert.True(AlertContextSerializer.TryDeserialize(legacyJson, out var restored)); + var item = Assert.Single(restored.Details); + Assert.True(item.IsCodeBlock); + Assert.Null(item.Remediation); + Assert.Contains("sp_query_store_force_plan", item.Body); + } + + // ── Recommendations rebuild D2: persist the BUILT RemediationAction on a finding ── + // SerializeAction/DeserializeAction wrap the SAME private ToDto/FromDto the alert + // path uses, so a finding's persisted action round-trips byte-identically. These + // mirror the RCSI/clear-plan ContextJson round-trips above, but exercise the new + // single-action seam the Recommendations reader uses. + + [Fact] + public void SerializeAction_RcsiAction_RoundTripsFiguresAndTargets() + { + // Build a real RCSI action from a finding whose config_issues row is rcsi:false + // with the inaction enrichment, then round-trip ONLY the action (the finding seam). + var action = FactRemediation.BuildRcsiAction(DbConfigRcsiFinding()); + Assert.NotNull(action); + Assert.Equal("RCSI", action!.FactKey); + + var json = AlertContextSerializer.SerializeAction(action); + Assert.NotNull(json); + var restored = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(restored); + Assert.Equal("RCSI", restored!.FactKey); + var t = Assert.Single(restored.DbConfigTargets!); + Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting); + // The RcsiInactionFigures survive the persist round-trip. + Assert.NotNull(restored.RcsiFigures); + Assert.Equal(12, restored.RcsiFigures!.BlockingEvents); + Assert.Equal(3, restored.RcsiFigures.Deadlocks); + Assert.Equal(80, restored.RcsiFigures.ReaderWriterPct); + } + + [Fact] + public void SerializeAction_ClearPlanAction_RoundTripsFiguresAndTargets() + { + var action = FactRemediation.BuildClearPlanAction(CpuFindingWithAbnormalPlans()); + Assert.NotNull(action); + Assert.Equal("CLEAR_PLAN", action!.FactKey); + + var json = AlertContextSerializer.SerializeAction(action); + Assert.NotNull(json); + var restored = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(restored); + Assert.Equal("CLEAR_PLAN", restored!.FactKey); + var t = Assert.Single(restored.ClearPlanTargets!); + Assert.Equal("0xABCDEF0123456789", t.QueryHash); + // The ClearPlanFigures survive the persist round-trip. + Assert.NotNull(restored.ClearPlanFigures); + Assert.Equal(62, restored.ClearPlanFigures!.CpuPercent); + Assert.Equal(5.0, restored.ClearPlanFigures.AnomalyRatio); + } + + [Fact] + public void SerializeAction_NullAction_SerializesAndDeserializesToNull() + { + // A finding with no execution shape persists a NULL column → no Apply affordance. + Assert.Null(AlertContextSerializer.SerializeAction(null)); + Assert.Null(AlertContextSerializer.DeserializeAction(null)); + Assert.Null(AlertContextSerializer.DeserializeAction("")); + // Garbage JSON degrades to null (try-catch), never throws. + Assert.Null(AlertContextSerializer.DeserializeAction("{ not json")); + } + + [Fact] + public void PersistedRcsiAction_RendersTwoSidedConsentGate_WithFindingNull() + { + // THE C1 GATING TEST. The Recommendations Apply surface reconstructs the action from + // the persisted remediation_action_json and has NO finding in hand, then asks + // FactRiskDisclosure.GetForAction(action, finding: null) for the consent gate. This + // proves the gate renders the REAL inaction figures from the PERSISTED ACTION ALONE. + var built = FactRemediation.BuildRcsiAction(DbConfigRcsiFinding(blocking: 12, deadlocks: 3, rwPct: 80)); + Assert.NotNull(built); + + // Round-trip through the exact persistence seam the store row uses. + var json = AlertContextSerializer.SerializeAction(built); + var action = AlertContextSerializer.DeserializeAction(json); + Assert.NotNull(action); + + // finding: null — exactly how the real Apply call site invokes it. + var disclosure = FactRiskDisclosure.GetForAction(action!, finding: null); + + Assert.NotNull(disclosure); + Assert.NotEmpty(disclosure!.RisksOfChanging); // two-sided: risk OF changing + Assert.NotEmpty(disclosure.RisksOfNotChanging); // two-sided: risk of NOT changing + + // The original inaction figures (carried on the action) are present in the rendered + // inaction side — proving they survived persistence and drive the gate with no finding. + var inaction = string.Join(" ", disclosure.RisksOfNotChanging.Select(r => r.Text)); + Assert.Contains("12 blocked-process events", inaction); + Assert.Contains("3 deadlocks", inaction); + Assert.Contains("80%", inaction); // 80% reader/writer + Assert.Contains("RCSI eliminates", inaction); // the >=50% reader/writer arm + } + + // ── WS3: percent-autogrowth action persists + round-trips ────────── + + // The FILE_AUTOGROWTH_PERCENT action is built from the drill-down and carries its per-file + // targets through the SAME SerializeAction/DeserializeAction round-trip the store uses for + // remediation_action_json — so the Recommendations reader can rebuild the copy-paste on read + // AND the FileAutogrowthHandler can re-derive the MODIFY FILE targets at apply (the drill-down + // itself is ephemeral). The action is Apply-able (handler registered) and copy-paste. + [Fact] + public void FileAutogrowthAction_BuildsFromDrillDown_AndRoundTrips() + { + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + Category = "config", + StoryPath = "FILE_AUTOGROWTH_PERCENT", + StoryPathHash = "fa9001", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + DrillDown = new Dictionary + { + ["autogrowth_percent_files"] = new List + { + new { database = "AppDb", logical_file_name = "AppDb_log", file_type = "LOG", + total_size_mb = 250000.0, growth_pct = 10, + issue = "10% autogrowth on 244.1 GB LOG file", + alter_statement = "ignored-on-read" } + } + } + }; + + var action = FactRemediation.BuildFileAutogrowthAction(finding); + Assert.NotNull(action); + Assert.Equal("FILE_AUTOGROWTH_PERCENT", action!.FactKey); + Assert.NotNull(action.FileGrowthTargets); + Assert.Single(action.FileGrowthTargets!); + // LOG file -> 64 MB flat (data files -> 1024 MB). + Assert.Equal(64, action.FileGrowthTargets![0].RecommendedGrowthMb); + + var json = AlertContextSerializer.SerializeAction(action); + Assert.NotNull(json); + var restored = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(restored); + Assert.Equal("FILE_AUTOGROWTH_PERCENT", restored!.FactKey); + Assert.NotNull(restored.FileGrowthTargets); + var t = Assert.Single(restored.FileGrowthTargets!); + Assert.Equal("AppDb", t.Database); + Assert.Equal("AppDb_log", t.LogicalFileName); + Assert.Equal(10, t.CurrentGrowthPercent); + Assert.Equal(64, t.RecommendedGrowthMb); + // The shared renderer rebuilds the exact copy-paste from the restored target. + Assert.Equal( + "ALTER DATABASE [AppDb] MODIFY FILE (NAME = [AppDb_log], FILEGROWTH = 64MB);", + FactRemediation.BuildModifyFileStatement(t.Database, t.LogicalFileName, t.RecommendedGrowthMb)); + } +} diff --git a/Dashboard.Tests/BaselineBucketTests.cs b/Dashboard.Tests/BaselineBucketTests.cs new file mode 100644 index 00000000..94ed27c4 --- /dev/null +++ b/Dashboard.Tests/BaselineBucketTests.cs @@ -0,0 +1,56 @@ +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Tests for BaselineBucket.EffectiveStdDev: proportional floor and division-by-zero handling. +/// +public class BaselineBucketTests +{ + // ── Division by zero: proportional floor ── + + [Fact] + public void EffectiveStdDev_ZeroStdDev_UsesProportionalFloor() + { + // All identical values → stddev = 0, mean = 50 + var bucket = new BaselineBucket + { + HourOfDay = 14, DayOfWeek = 3, + Mean = 50.0, StdDev = 0.0, SampleCount = 20, + Tier = BaselineTier.Full + }; + + // Should be max(0, 50 * 0.01) = 0.5 + Assert.Equal(0.5, bucket.EffectiveStdDev); + } + + [Fact] + public void EffectiveStdDev_ZeroMeanAndZeroStdDev_ReturnsZero() + { + // Zero activity → skip scoring + var bucket = new BaselineBucket + { + HourOfDay = 14, DayOfWeek = 3, + Mean = 0.0, StdDev = 0.0, SampleCount = 20, + Tier = BaselineTier.Full + }; + + Assert.Equal(0.0, bucket.EffectiveStdDev); + } + + [Fact] + public void EffectiveStdDev_NormalStdDev_ReturnsActual() + { + var bucket = new BaselineBucket + { + HourOfDay = 14, DayOfWeek = 3, + Mean = 50.0, StdDev = 5.0, SampleCount = 20, + Tier = BaselineTier.Full + }; + + // StdDev (5.0) > Mean * 0.01 (0.5), so return actual + Assert.Equal(5.0, bucket.EffectiveStdDev); + } +} diff --git a/Dashboard.Tests/BlockingChainReconstructorTests.cs b/Dashboard.Tests/BlockingChainReconstructorTests.cs new file mode 100644 index 00000000..7fd0fb49 --- /dev/null +++ b/Dashboard.Tests/BlockingChainReconstructorTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Pure unit tests for BlockingChainReconstructor — apex/depth/victim reconstruction, +/// the composite session identity that defeats SPID reuse, the 1900-01-01 sentinel, +/// cycle handling, and the traversal caps. No database. +/// +public class BlockingChainReconstructorTests +{ + private const int MaxDepth = 50; + private const int MaxPairs = 5000; + private const int StepBudget = 100_000; + + private static DateTime TranFor(int spid) => new DateTime(2026, 5, 22, 9, 0, 0).AddSeconds(spid); + + private static BlockingPairRow Pair( + int blockedSpid, int blockingSpid, + DateTime? blockedTran = null, DateTime? blockingTran = null, + long waitMs = 1000, string blockingStatus = "running") + { + return new BlockingPairRow + { + EventTime = new DateTime(2026, 5, 22, 10, 0, 0), + DatabaseName = "TestDb", + BlockedSpid = blockedSpid, + BlockedTranStarted = blockedTran ?? TranFor(blockedSpid), + BlockingSpid = blockingSpid, + BlockingTranStarted = blockingTran ?? TranFor(blockingSpid), + WaitTimeMs = waitMs, + LockMode = "X", + BlockingStatus = blockingStatus, + BlockedSqlText = "blocked sql", + BlockingSqlText = "blocking sql" + }; + } + + private static BlockingReconstruction Run(IEnumerable rows) => + BlockingChainReconstructor.Reconstruct(rows, MaxDepth, MaxPairs, StepBudget); + + [Fact] + public void Empty_ProducesNoChains() + { + var result = Run(Array.Empty()); + Assert.Empty(result.Chains); + } + + [Fact] + public void DepthFourLine_ReportsApexDepthAndVictims() + { + // 200 → 201 → 202 → 203 → 204 + var result = Run(new[] + { + Pair(201, 200), Pair(202, 201), Pair(203, 202), Pair(204, 203) + }); + + var chain = Assert.Single(result.Chains); + Assert.Equal(200, chain.ApexSpid); + Assert.Equal(4, chain.Depth); + Assert.Equal(4, chain.VictimCount); + Assert.False(result.CycleDetected); + } + + [Fact] + public void FanOut_IsDepthOneWithAllVictims() + { + // 300 blocks 301..305 directly + var result = Run(Enumerable.Range(301, 5).Select(v => Pair(v, 300))); + + var chain = Assert.Single(result.Chains); + Assert.Equal(300, chain.ApexSpid); + Assert.Equal(1, chain.Depth); + Assert.Equal(5, chain.VictimCount); + } + + [Fact] + public void SpidReuse_DifferentTransactionStart_DoesNotSplice() + { + // Real chain 200 → 201 → 202, plus SPID 201 reused (different tran) blocking 203. + var reusedTran = TranFor(201).AddHours(3); + var result = Run(new[] + { + Pair(201, 200), + Pair(202, 201), + Pair(203, 201, blockingTran: reusedTran) // reused 201 — a distinct session + }); + + // Two distinct chains: apex 200 depth 2, and the reused-201 apex depth 1. + Assert.Equal(2, result.Chains.Count); + Assert.Contains(result.Chains, c => c.ApexSpid == 200 && c.Depth == 2); + Assert.Contains(result.Chains, c => c.ApexSpid == 201 && c.Depth == 1); + } + + [Fact] + public void Sentinel_TransactionStart_NormalizesToNull() + { + // SQL Server's 1900-01-01 "no transaction" sentinel must key the same as NULL. + Assert.Equal( + BlockingChainReconstructor.MakeKey(100, null), + BlockingChainReconstructor.MakeKey(100, new DateTime(1900, 1, 1))); + + // And a real transaction start must NOT collapse to the sentinel key. + Assert.NotEqual( + BlockingChainReconstructor.MakeKey(100, null), + BlockingChainReconstructor.MakeKey(100, TranFor(100))); + } + + [Fact] + public void PureCycle_IsDetectedAndGetsFallbackRoot() + { + // A blocks B, B blocks A — every node is blocked, so there is no apex. + var result = Run(new[] + { + Pair(blockedSpid: 401, blockingSpid: 400), + Pair(blockedSpid: 400, blockingSpid: 401) + }); + + Assert.True(result.CycleDetected); + Assert.NotEmpty(result.Chains); // fallback root — the cycle is not silently dropped + } + + [Fact] + public void DepthCap_IsFlaggedAndBounded() + { + // A 12-edge line, reconstructed with a maxDepth of 4. + var rows = Enumerable.Range(0, 12).Select(i => Pair(501 + i, 500 + i)).ToList(); + var result = BlockingChainReconstructor.Reconstruct(rows, maxDepth: 4, MaxPairs, StepBudget); + + Assert.True(result.DepthCapped); + var chain = Assert.Single(result.Chains); + Assert.True(chain.Depth <= 4, $"depth {chain.Depth} should be capped at 4"); + } + + [Fact] + public void EdgeDedup_KeepsTheLargestWaitTime() + { + // Same blocked/blocker pair re-fires with a growing wait time. + var result = Run(new[] + { + Pair(601, 600, waitMs: 1_000), + Pair(601, 600, waitMs: 9_000), + Pair(601, 600, waitMs: 4_000) + }); + + var chain = Assert.Single(result.Chains); + Assert.Equal(9_000, chain.MaxWaitMs); + } + + [Fact] + public void SleepingApex_IsReported() + { + var result = Run(new[] + { + Pair(701, 700, blockingStatus: "sleeping"), + Pair(702, 701) + }); + + var chain = Assert.Single(result.Chains); + Assert.Equal(700, chain.ApexSpid); + Assert.True(chain.ApexSleeping); + } + + [Fact] + public void Ranking_PutsTheHigherMagnitudeChainFirst() + { + // Chain A: depth 1, 8 victims (wide). Chain B: depth 2, 2 victims (shallow). + var rows = new List(); + rows.AddRange(Enumerable.Range(801, 8).Select(v => Pair(v, 800))); // wide + rows.Add(Pair(901, 900)); // narrow + rows.Add(Pair(902, 901)); + + var result = Run(rows); + + Assert.Equal(2, result.Chains.Count); + // The 8-victim fan-out out-scores the depth-2 / 2-victim chain. + Assert.Equal(800, result.Chains[0].ApexSpid); + } +} diff --git a/Dashboard.Tests/Dashboard.Tests.csproj b/Dashboard.Tests/Dashboard.Tests.csproj new file mode 100644 index 00000000..97e98926 --- /dev/null +++ b/Dashboard.Tests/Dashboard.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0-windows + enable + true + false + true + CA1849;CA2007;CA1508;CA1822;CA1805;CA1510;CA1816;CA1861;CA1845;CA2201;CS4014;NU1701;CA1001;CA1848;CA1852;CA1305;CA1860;CA1707;CA1507;CA1806 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Dashboard.Tests/DashboardAlertSettingsTests.cs b/Dashboard.Tests/DashboardAlertSettingsTests.cs new file mode 100644 index 00000000..dd2c3556 --- /dev/null +++ b/Dashboard.Tests/DashboardAlertSettingsTests.cs @@ -0,0 +1,136 @@ +using System; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Interfaces; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Plan E Stage E1 — verifies the Dashboard adapter mirrors +/// Plan D's invariance discipline: it reads live from +/// (no caching), it clamps the analysis-notify bounds at the settings boundary (the inline +/// clamp was removed from AnalysisNotificationService), and the credential-backed getters +/// route to the service statics without throwing when no credential is present. +/// +public class DashboardAlertSettingsTests +{ + /// Minimal IUserPreferencesService over one mutable UserPreferences instance. + 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) { } + } + + [Fact] + public void ReadsLiveFromPreferences_NoCaching() + { + var fake = new FakePreferencesService(); + var settings = new DashboardAlertSettings(fake); + + // Plain string/bool/int pass-throughs reflect the current value on every access. + fake.Preferences.SmtpServer = "smtp.example.com"; + Assert.Equal("smtp.example.com", settings.SmtpServer); + + fake.Preferences.SmtpServer = "smtp.changed.com"; + Assert.Equal("smtp.changed.com", settings.SmtpServer); + + fake.Preferences.SmtpEnabled = true; + Assert.True(settings.SmtpEnabled); + + fake.Preferences.EmailCooldownMinutes = 42; + Assert.Equal(42, settings.EmailCooldownMinutes); + + fake.Preferences.TeamsWebhookEnabled = true; + Assert.True(settings.TeamsWebhookEnabled); + + fake.Preferences.TeamsProxyAddress = "http://proxy:8080"; + Assert.Equal("http://proxy:8080", settings.TeamsProxyAddress); + } + + [Fact] + public void AnalysisNotifySeverity_ClampsToZeroTwoRange() + { + var fake = new FakePreferencesService(); + var settings = new DashboardAlertSettings(fake); + + fake.Preferences.AnalysisNotifySeverity = -3.0; + Assert.Equal(0.0, settings.AnalysisNotifySeverity); + + fake.Preferences.AnalysisNotifySeverity = 9.9; + Assert.Equal(2.0, settings.AnalysisNotifySeverity); + + fake.Preferences.AnalysisNotifySeverity = 1.25; + Assert.Equal(1.25, settings.AnalysisNotifySeverity); + } + + [Fact] + public void AnalysisNotifyCooldownMinutes_ClampsTo30To10080Range() + { + var fake = new FakePreferencesService(); + var settings = new DashboardAlertSettings(fake); + + fake.Preferences.AnalysisNotifyCooldownMinutes = 5; + Assert.Equal(30, settings.AnalysisNotifyCooldownMinutes); + + fake.Preferences.AnalysisNotifyCooldownMinutes = 99999; + Assert.Equal(10080, settings.AnalysisNotifyCooldownMinutes); + + fake.Preferences.AnalysisNotifyCooldownMinutes = 360; + Assert.Equal(360, settings.AnalysisNotifyCooldownMinutes); + } + + [Fact] + public void CredentialBackedGetters_RouteToStatics_WithoutThrowing() + { + var fake = new FakePreferencesService(); + var settings = new DashboardAlertSettings(fake); + + // Structural/smoke check: these route to EmailAlertService.GetSmtpPassword() and + // WebhookAlertService.Get{Teams,Slack}WebhookUrl(). They must not throw; with no + // credential present the password getter returns null and the URL getters "". + var ex = Record.Exception(() => + { + _ = settings.GetSmtpPassword(); + _ = settings.TeamsWebhookUrl; + _ = settings.SlackWebhookUrl; + }); + Assert.Null(ex); + Assert.NotNull(settings.TeamsWebhookUrl); + Assert.NotNull(settings.SlackWebhookUrl); + } + + [Fact] + public void TransientAdapter_ReadsFromGivenPreferences_NotLiveService() + { + // The test-send adapter (MOD-1) reads the UserPreferences it was handed — the form + // values the user just typed — never the saved IUserPreferencesService. + var formValues = new UserPreferences + { + SmtpEnabled = true, + SmtpServer = "typed.example.com", + SmtpPort = 2525, + SmtpFromAddress = "from@example.com", + SmtpRecipients = "to@example.com", + EmailCooldownMinutes = 7 + }; + var settings = new UserPreferencesAlertSettings(formValues); + + Assert.Equal("typed.example.com", settings.SmtpServer); + Assert.Equal(2525, settings.SmtpPort); + Assert.Equal("from@example.com", settings.SmtpFromAddress); + Assert.Equal("to@example.com", settings.SmtpRecipients); + Assert.Equal(7, settings.EmailCooldownMinutes); + Assert.True(settings.SmtpEnabled); + } +} diff --git a/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs b/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs new file mode 100644 index 00000000..7d3d01cd --- /dev/null +++ b/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs @@ -0,0 +1,70 @@ +/* + * 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 PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests +{ + /// + /// Truth-table coverage for (#1091). Deadlock + /// detection is edge-triggered, so the check right after a deadlock has a zero delta and would + /// flap straight to "Deadlocks Cleared". The policy holds the alert active until a deadlock-quiet + /// window has elapsed since the last new deadlock, matching Lite's rolling-window semantics. + /// + public class DeadlockAlertClearPolicyTests + { + private static readonly TimeSpan QuietWindow = TimeSpan.FromHours(1); + private static readonly DateTime Now = new DateTime(2026, 6, 11, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void NotActive_NeverClears() + { + Assert.False(DeadlockAlertClearPolicy.ShouldClear( + wasActive: false, lastDeadlockActivity: Now - TimeSpan.FromHours(2), Now, QuietWindow)); + } + + [Fact] + public void Active_WithinQuietWindow_DoesNotFlap() + { + // A deadlock one minute ago: the next check must NOT clear (the #1091 flap). + Assert.False(DeadlockAlertClearPolicy.ShouldClear( + wasActive: true, lastDeadlockActivity: Now - TimeSpan.FromMinutes(1), Now, QuietWindow)); + } + + [Fact] + public void Active_JustBeforeWindow_StillHeld() + { + Assert.False(DeadlockAlertClearPolicy.ShouldClear( + wasActive: true, lastDeadlockActivity: Now - TimeSpan.FromMinutes(59), Now, QuietWindow)); + } + + [Fact] + public void Active_AtQuietWindow_Clears() + { + Assert.True(DeadlockAlertClearPolicy.ShouldClear( + wasActive: true, lastDeadlockActivity: Now - QuietWindow, Now, QuietWindow)); + } + + [Fact] + public void Active_PastQuietWindow_Clears() + { + Assert.True(DeadlockAlertClearPolicy.ShouldClear( + wasActive: true, lastDeadlockActivity: Now - TimeSpan.FromHours(2), Now, QuietWindow)); + } + + [Fact] + public void Active_WithoutRecordedActivity_Clears() + { + // Active but no timestamp tracked: nothing holds it open, so clearing is safe. + Assert.True(DeadlockAlertClearPolicy.ShouldClear( + wasActive: true, lastDeadlockActivity: null, Now, QuietWindow)); + } + } +} diff --git a/Dashboard.Tests/EmailTemplateBrandingTests.cs b/Dashboard.Tests/EmailTemplateBrandingTests.cs new file mode 100644 index 00000000..2b5707f6 --- /dev/null +++ b/Dashboard.Tests/EmailTemplateBrandingTests.cs @@ -0,0 +1,34 @@ +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Render invariant for Plan E Stage E3a: the shared EmailTemplateBuilder, fed +/// Dashboard's wired AlertBranding (via EmailAlertService.Branding), must reproduce +/// Dashboard's pre-E3a alert email exactly — edition string "Performance Monitor +/// Dashboard" and NO snooze hint (Dashboard's branding carries a null hint, so the +/// snooze section is omitted from both the HTML and plain-text bodies). +/// +public class EmailTemplateBrandingTests +{ + [Fact] + public void BuildAlertEmail_DashboardBranding_IncludesEditionAndOmitsSnoozeFromBothBodies() + { + var branding = EmailAlertService.Branding; + Assert.Equal("Performance Monitor Dashboard", branding.EditionName); + Assert.Null(branding.SnoozeHint); + + var (html, plain) = EmailTemplateBuilder.BuildAlertEmail( + "High CPU", "TestServer", "95%", "90%", 15, branding); + + // Edition string present, Lite's absent. + Assert.Contains("Performance Monitor Dashboard", html); + Assert.DoesNotContain("Performance Monitor Lite", html); + + // No snooze hint in either body. + Assert.DoesNotContain("To silence this alert", html); + Assert.DoesNotContain("To silence this alert", plain); + } +} diff --git a/Dashboard.Tests/FactScorerTests.cs b/Dashboard.Tests/FactScorerTests.cs new file mode 100644 index 00000000..657a8a5f --- /dev/null +++ b/Dashboard.Tests/FactScorerTests.cs @@ -0,0 +1,363 @@ +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Tests FactScorer Layer 1 (base severity) and Layer 2 (amplifiers). +/// Validates threshold formulas, amplifier firing, and severity capping. +/// +public class FactScorerTests +{ + /* ── Threshold formula unit tests ── */ + + [Theory] + [InlineData(0.0, 0.25, null, 0.0)] // Zero → 0.0 + [InlineData(0.125, 0.25, null, 0.5)] // Half of concerning → 0.5 + [InlineData(0.25, 0.25, null, 1.0)] // At concerning (no critical) → 1.0 + [InlineData(0.50, 0.25, null, 1.0)] // Above concerning (no critical) → capped at 1.0 + [InlineData(0.0, 0.25, 0.75, 0.0)] // Zero → 0.0 + [InlineData(0.125, 0.25, 0.75, 0.25)] // Half of concerning → 0.25 + [InlineData(0.25, 0.25, 0.75, 0.5)] // At concerning → 0.5 + [InlineData(0.50, 0.25, 0.75, 0.75)] // Midway → 0.75 + [InlineData(0.75, 0.25, 0.75, 1.0)] // At critical → 1.0 + [InlineData(1.00, 0.25, 0.75, 1.0)] // Above critical → 1.0 + public void ApplyThresholdFormula_ReturnsExpected( + double value, double concerning, double? critical, double expected) + { + var result = FactScorer.ApplyThresholdFormula(value, concerning, critical); + Assert.Equal(expected, result, precision: 4); + } + + /* ── Unknown wait types ── */ + + [Fact] + public void Score_UnknownWaitType_GetsSeverityZero() + { + var facts = new List + { + new() { Source = "waits", Key = "UNKNOWN_WAIT_XYZ", Value = 0.50 } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.0, facts[0].BaseSeverity); + } + + /* ── Layer 2: Amplifier tests ── */ + + [Fact] + public void Amplifier_SeverityCappedAt2() + { + // Synthetic: create a fact set where amplifiers would push past 2.0 + var facts = new List + { + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, // base = 1.0 + new() { Source = "waits", Key = "SOS_SCHEDULER_YIELD", Value = 0.50 }, // > 25% threshold + new() { Source = "waits", Key = "THREADPOOL", Value = 0.05, // real thread exhaustion + Metadata = new() { ["wait_time_ms"] = 7_200_000, ["avg_ms_per_wait"] = 3_600 } }, // 2h total, 3.6s avg + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // bad CTFP + new() { Source = "config", Key = "CONFIG_MAXDOP", Value = 0 }, // bad MAXDOP + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + var cx = facts.First(f => f.Key == "CXPACKET"); + + // base 1.0 * (1.0 + 0.3 SOS + 0.4 THREADPOOL + 0.3 CTFP + 0.2 MAXDOP) = 2.2 → capped at 2.0 + Assert.True(cx.Severity <= 2.0, "Severity should never exceed 2.0"); + Assert.Equal(2.0, cx.Severity); + } + + /* ── Regression: duplicate fact keys must not crash scoring ── */ + + // A duplicated fact key (e.g. a config collector returning two rows for the same setting) + // once aborted the ENTIRE analysis for a server: ScoreAll built its amplifier lookup with a + // raw ToDictionary(f => f.Key), which throws on a duplicate. The exception was swallowed + // upstream, silently blanking every finding. ScoreAll must now dedupe and survive. + [Fact] + public void ScoreAll_WithDuplicateFactKeys_DoesNotThrow() + { + var facts = new List + { + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, // duplicate key + new() { Source = "waits", Key = "CXPACKET", Value = 0.80 }, + }; + + var scorer = new FactScorer(); + var ex = Record.Exception(() => scorer.ScoreAll(facts)); + + Assert.Null(ex); + } + + // The backstop helper keeps the first fact per key and never throws on duplicates. + [Fact] + public void ToFactLookup_DedupesByKey_KeepsFirst() + { + var facts = new List + { + new() { Source = "config", Key = "CONFIG_CTFP", Value = 5 }, + new() { Source = "config", Key = "CONFIG_CTFP", Value = 99 }, + }; + + var lookup = facts.ToFactLookup(); + + Assert.Single(lookup); + Assert.Equal(5, lookup["CONFIG_CTFP"].Value); // first wins + } + + /* ── WS3: percent-autogrowth-on-large-files config fact ── */ + + // A FILE_AUTOGROWTH_PERCENT fact scores the 0.3 advisory base when at least one large + // percent-growth file was found (file_count > 0) — mirroring DB_CONFIG's single-misconfig + // base. It is deliberately below the 0.5 incident threshold; it surfaces only because it + // is a config-advisory root key (see the InferenceEngine rooting test). + [Fact] + public void FileAutogrowthPercent_ScoresAdvisoryBase_WhenFilesPresent() + { + var facts = new List + { + new() { Source = "config", Key = "FILE_AUTOGROWTH_PERCENT", Value = 2, + Metadata = new() { ["file_count"] = 2, ["database_count"] = 1 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.3, facts[0].BaseSeverity, precision: 4); + } + + // No offending files (file_count == 0) → no severity. Defends the collector contract that + // the fact is emitted only when file_count > 0, and the scorer's own guard. + [Fact] + public void FileAutogrowthPercent_ScoresZero_WhenNoFiles() + { + var facts = new List + { + new() { Source = "config", Key = "FILE_AUTOGROWTH_PERCENT", Value = 0, + Metadata = new() { ["file_count"] = 0, ["database_count"] = 0 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.0, facts[0].BaseSeverity, precision: 4); + } + + // Advice exists for the fact key (a missing AdviceBlock is the P1 dead-fact bug class — + // a fact that roots but renders no advice). Headline must be the authored copy. + [Fact] + public void FileAutogrowthPercent_HasAdviceBlock() + { + var advice = FactAdvice.GetForFactKey("FILE_AUTOGROWTH_PERCENT"); + + Assert.NotNull(advice); + Assert.Equal("Large file(s) growing in percentage steps", advice!.Headline); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + } + + /* ── WS3: server-level config facts (MAXDOP / CTFP / max & min memory) ── */ + + // Each per-setting CONFIG_* fact scores the 0.4 advisory base ONLY when the value is bad, and + // 0 otherwise — so audit_config still sees every CONFIG_* fact (it reads the raw value) but + // only a BAD one roots a recommendation card. + [Theory] + // MAXDOP: 0 is bad (unlimited); any other value is operator-chosen. + [InlineData("CONFIG_MAXDOP", 0, 0.4)] + [InlineData("CONFIG_MAXDOP", 4, 0.0)] + [InlineData("CONFIG_MAXDOP", 8, 0.0)] + // CTFP: <= 5 is bad (default-and-below); above is fine. + [InlineData("CONFIG_CTFP", 5, 0.4)] + [InlineData("CONFIG_CTFP", 1, 0.4)] + [InlineData("CONFIG_CTFP", 50, 0.0)] + [InlineData("CONFIG_CTFP", 6, 0.0)] + // max server memory: the 2 PB default (2147483647) is bad; a real cap is fine. + [InlineData("CONFIG_MAX_MEMORY_MB", 2147483647, 0.4)] + [InlineData("CONFIG_MAX_MEMORY_MB", 28672, 0.0)] + public void ServerConfigFact_ScoresBadValueOnly(string key, long value, double expected) + { + var facts = new List + { + new() { Source = "config", Key = key, Value = value, + Metadata = new() { ["value_in_use"] = value } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(expected, facts[0].BaseSeverity, precision: 4); + } + + // CONFIG_MIN_MAX_MEMORY_NARROW is emitted by the collector ONLY when bad, so any presence of + // the fact scores 0.4 (the fact's existence is the flag). + [Fact] + public void NarrowMemoryFact_AlwaysScoresAdvisoryBase() + { + var facts = new List + { + new() { Source = "config", Key = "CONFIG_MIN_MAX_MEMORY_NARROW", Value = 24000, + Metadata = new() { ["min_memory_mb"] = 24000, ["max_memory_mb"] = 28672 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.4, facts[0].BaseSeverity, precision: 4); + } + + // CONFIG_MIN_MEMORY_MB and CONFIG_MAX_WORKER_THREADS are leaf/context facts — never scored + // (they feed audit_config / the narrow-memory derivation, not a card of their own). + [Theory] + [InlineData("CONFIG_MIN_MEMORY_MB")] + [InlineData("CONFIG_MAX_WORKER_THREADS")] + public void ServerConfigLeafFacts_ScoreZero(string key) + { + var facts = new List + { + new() { Source = "config", Key = key, Value = 1024, Metadata = new() { ["value_in_use"] = 1024 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.0, facts[0].BaseSeverity, precision: 4); + } + + // Advice exists for each of the four server-config root keys (dead-fact guard). + [Theory] + [InlineData("CONFIG_MAXDOP")] + [InlineData("CONFIG_CTFP")] + [InlineData("CONFIG_MAX_MEMORY_MB")] + [InlineData("CONFIG_MIN_MAX_MEMORY_NARROW")] + public void ServerConfigKeys_HaveAdviceBlocks(string key) + { + var advice = FactAdvice.GetForFactKey(key); + + Assert.NotNull(advice); + Assert.False(string.IsNullOrWhiteSpace(advice!.Headline)); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + } + + // The shared narrow-memory builder: emitted only when max is CONFIGURED and min >= 80% of max. + [Theory] + [InlineData(28672, 24000, true)] // min 83.7% of max -> narrow + [InlineData(28672, 22938, true)] // just over 80% (threshold 22937.6) -> narrow + [InlineData(28672, 22000, false)] // ~76.7% -> just under 80%, not narrow + [InlineData(28672, 14000, false)] // min ~49% -> not narrow + [InlineData(2147483647, 2000000000, false)] // max unconfigured -> never narrow + [InlineData(0, 0, false)] // max 0 -> guard + public void BuildNarrowMemoryFact_FollowsThreshold(long maxMb, long minMb, bool emitted) + { + var fact = FactRemediation.BuildNarrowMemoryFact(1, maxMb, minMb); + + if (!emitted) + { + Assert.Null(fact); + return; + } + + Assert.NotNull(fact); + Assert.Equal("CONFIG_MIN_MAX_MEMORY_NARROW", fact!.Key); + Assert.Equal(minMb, fact.Value); + Assert.Equal(minMb, fact.Metadata["min_memory_mb"]); + Assert.Equal(maxMb, fact.Metadata["max_memory_mb"]); + } + + [Fact] + public void BuildNarrowMemoryFact_NullInputs_ReturnsNull() + { + Assert.Null(FactRemediation.BuildNarrowMemoryFact(1, null, 24000)); + Assert.Null(FactRemediation.BuildNarrowMemoryFact(1, 28672, null)); + } + + /* ── WS5: server-health advisory facts (LPIM / IFI / memory dumps) ── */ + + // Each WS5 server-health fact scores its 0.4 advisory base ONLY when the value is bad, and 0 + // otherwise — mirroring the WS3 server-config keys. The collectors gate emission (Express / + // small-RAM / dumps>0) so a fact that would score 0 is normally never emitted, but the scorer + // is still independently bad-only so it can be unit-tested in isolation. + [Theory] + // IFI: disabled (Value 0) is bad; enabled (Value 1) is fine. + [InlineData("CONFIG_IFI_DISABLED", 0, 0.4)] + [InlineData("CONFIG_IFI_DISABLED", 1, 0.0)] + // LPIM: disabled (Value 0) is bad; enabled (Value 1) is fine. + [InlineData("CONFIG_LPIM_DISABLED", 0, 0.4)] + [InlineData("CONFIG_LPIM_DISABLED", 1, 0.0)] + // Memory dumps: any count > 0 is bad; 0 is fine. + [InlineData("SERVER_MEMORY_DUMPS", 1, 0.4)] + [InlineData("SERVER_MEMORY_DUMPS", 7, 0.4)] + [InlineData("SERVER_MEMORY_DUMPS", 0, 0.0)] + public void ServerHealthFact_ScoresBadValueOnly(string key, long value, double expected) + { + var facts = new List + { + new() { Source = "config", Key = key, Value = value } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(expected, facts[0].BaseSeverity, precision: 4); + } + + // Advice exists for each WS5 server-health root key (dead-fact guard — a fact that roots but + // renders no advice is the P1 dead-fact bug class). + [Theory] + [InlineData("CONFIG_IFI_DISABLED")] + [InlineData("CONFIG_LPIM_DISABLED")] + [InlineData("SERVER_MEMORY_DUMPS")] + public void ServerHealthKeys_HaveAdviceBlocks(string key) + { + var advice = FactAdvice.GetForFactKey(key); + + Assert.NotNull(advice); + Assert.False(string.IsNullOrWhiteSpace(advice!.Headline)); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + // Advise-only: no generated Apply T-SQL is attached to the bare advice block. + Assert.Null(advice.RemediationTsql); + } + + // WS4: plan-XML advisories. MISSING_INDEX / PLAN_WARNING (Source "queries", Value = count) + // score the 0.4 advisory base only when the count is > 0, and 0 otherwise. Advise-only. + [Theory] + [InlineData("MISSING_INDEX", 1, 0.4)] + [InlineData("MISSING_INDEX", 5, 0.4)] + [InlineData("MISSING_INDEX", 0, 0.0)] + [InlineData("PLAN_WARNING", 1, 0.4)] + [InlineData("PLAN_WARNING", 0, 0.0)] + public void PlanAdvisoryFact_ScoresWhenPresentOnly(string key, long value, double expected) + { + var facts = new List + { + new() { Source = "queries", Key = key, Value = value } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(expected, facts[0].BaseSeverity, precision: 4); + } + + // Advice exists for each WS4 plan-advisory root key (dead-fact guard); advise-only (no Apply T-SQL). + [Theory] + [InlineData("MISSING_INDEX")] + [InlineData("PLAN_WARNING")] + public void PlanAdvisoryKeys_HaveAdviceBlocks(string key) + { + var advice = FactAdvice.GetForFactKey(key); + + Assert.NotNull(advice); + Assert.False(string.IsNullOrWhiteSpace(advice!.Headline)); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + Assert.Null(advice.RemediationTsql); + } +} diff --git a/Dashboard.Tests/FileAutogrowthHandlerTests.cs b/Dashboard.Tests/FileAutogrowthHandlerTests.cs new file mode 100644 index 00000000..bd71c3f6 --- /dev/null +++ b/Dashboard.Tests/FileAutogrowthHandlerTests.cs @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Coverage for the always-safe percent-autogrowth handler (FILE_AUTOGROWTH_PERCENT): +/// the self-gating against a faked executor — +/// audit-table hard block (no mutation), the happy path passing each target's +/// already-computed through to +/// SetFileGrowthAsync + writing one audit row per attempt, per-target +/// independence, the preflight dispositions, and the apply-only un-apply restriction. +/// Mirrors the DB-config handler tests exactly; the single-connection (R2-MOD-1) +/// guarantee is an executor/real-server concern (SPID equality) not provable here. +/// Reuses the shared FakeExecutor from via a +/// thin local fake of the same shape (the nested one is private to that class). +/// +public class FileAutogrowthHandlerTests +{ + private static readonly RemediationIdentity Identity = + new("TESTDOMAIN\\tester", "Analysis: file_autogrowth [abcd1234]"); + + private static RemediationAction FileAction(params FileGrowthTarget[] targets) => + new("FILE_AUTOGROWTH_PERCENT", "set", Array.Empty(), + FileGrowthTargets: targets.ToList()); + + private static FileGrowthTarget DataTarget(string db = "BigDb", string logical = "BigDb_data") => + new(db, logical, CurrentSizeMb: 51200, CurrentGrowthPercent: 10, RecommendedGrowthMb: 1024); + + private static FileGrowthTarget LogTarget(string db = "BigDb", string logical = "BigDb_log") => + new(db, logical, CurrentSizeMb: 250000, CurrentGrowthPercent: 25, RecommendedGrowthMb: 64); + + // ── Handler contract ──────────────────────────────────────────────────────── + + [Fact] + public void FactKey_IsFileAutogrowthPercent() + { + Assert.Equal("FILE_AUTOGROWTH_PERCENT", new FileAutogrowthHandler().FactKey); + } + + [Fact] + public void NotDestructive_AndApplyOnly() + { + var handler = new FileAutogrowthHandler(); + Assert.False(handler.IsDestructive); // metadata-only, online — same class as AUTO_SHRINK OFF + Assert.False(handler.SupportsUnapply); // no sensible reverse for FILEGROWTH + } + + [Fact] + public async Task UnapplyAsync_Throws() + { + await Assert.ThrowsAsync(() => + new FileAutogrowthHandler().UnapplyAsync( + FileAction(DataTarget()), new FakeFileExecutor(), Identity, CancellationToken.None)); + } + + [Fact] + public void Registry_ResolvesFileAutogrowthHandler() + { + var registry = new RemediationHandlerRegistry( + new IRemediationHandler[] { new ForcePlanHandler(), new DbConfigHandler(), new FileAutogrowthHandler() }); + Assert.IsType(registry.TryGet("FILE_AUTOGROWTH_PERCENT")); + } + + // ── Apply: audit-absent hard block, happy path, per-target independence ─────── + + [Fact] + public async Task AuditTableAbsent_HardBlocks_NoMutation_NoAudit() + { + var exec = new FakeFileExecutor { AuditTableExists = false }; + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction(DataTarget("BigDb", "BigDb_data"), LogTarget("BigDb", "BigDb_log")), + exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.Contains("3.0.0", o.Message); + }); + Assert.Equal(0, exec.SetFileCalls); // NOT mutated + Assert.Empty(exec.AuditRecords); // nowhere to write + } + + [Fact] + public async Task HappyPath_AppliesEachTarget_WithRecommendedGrowth_AndAudits() + { + var exec = new FakeFileExecutor(); // default success outcome + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction(DataTarget("BigDb", "BigDb_data"), LogTarget("BigDb", "BigDb_log")), + exec, Identity, CancellationToken.None); + + // One SetFileGrowthAsync call per target, EACH with the target's own already-computed + // RecommendedGrowthMb (1024 data / 64 log) — the handler never recomputes it. + Assert.Equal(2, exec.SetFileCalls); + Assert.Contains(("BigDb", "BigDb_data", 1024), exec.SetFileArgs); + Assert.Contains(("BigDb", "BigDb_log", 64), exec.SetFileArgs); + + // Two success outcomes, both audited. + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.True(o.AuditWritten); + Assert.False(o.AppliedButUnlogged); + }); + + // Audit rows: one per attempt, the precise taxonomy + per-target database, non-consent. + Assert.Equal(2, exec.AuditRecords.Count); + Assert.All(exec.AuditRecords, r => + { + Assert.Equal("FILE_AUTOGROWTH_PERCENT", r.FactKey); + Assert.Equal("set_file_growth", r.Action); + Assert.Equal("success", r.Result); + Assert.Equal("BigDb", r.TargetDatabase); + Assert.Null(r.QueryId); // no query_id/plan_id for file rows + Assert.Null(r.PlanId); + Assert.False(r.ConsentAcknowledged); // not destructive — explicit false + Assert.Contains("MODIFY FILE", r.GeneratedSql); + }); + } + + [Fact] + public async Task AppliesButAuditFails_FlagsAppliedButUnlogged() + { + var exec = new FakeFileExecutor { AuditWriteResult = false }; + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction(DataTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.False(o.AuditWritten); + Assert.True(o.AppliedButUnlogged); + } + + [Fact] + public async Task PermissionDenied_FailsClosed_AuditSkipped() + { + var exec = new FakeFileExecutor + { + SetFileFunc = (db, logical, mb) => new FileGrowthOutcome + { + Database = db, LogicalFileName = logical, Status = RemediationStatus.PermissionDenied, + Applied = false, Message = "lacks ALTER", PriorValue = "percent" + } + }; + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction(DataTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.PermissionDenied, o.Status); + Assert.False(o.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + Assert.Equal(1, exec.SetFileCalls); + } + + [Fact] + public async Task AlreadyDesired_Skips_AuditSkipped() + { + var exec = new FakeFileExecutor + { + SetFileFunc = (db, logical, mb) => new FileGrowthOutcome + { + Database = db, LogicalFileName = logical, Status = RemediationStatus.Skipped, + Applied = false, Message = "already at desired growth", PriorValue = "1024 MB" + } + }; + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction(DataTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Skipped, o.Status); + Assert.False(o.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + } + + [Fact] + public async Task OneTargetThrows_OthersStillRun() + { + var exec = new FakeFileExecutor + { + SetFileFunc = (db, logical, mb) => + { + if (logical == "A") throw new InvalidOperationException("boom"); + return new FileGrowthOutcome { Database = db, LogicalFileName = logical, Status = RemediationStatus.Success, Applied = true, PriorValue = "percent" }; + } + }; + + var result = await new FileAutogrowthHandler().ApplyAsync( + FileAction( + new FileGrowthTarget("BigDb", "A", 51200, 10, 1024), + new FileGrowthTarget("BigDb", "B", 51200, 10, 1024)), + exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.Equal(RemediationStatus.Error, result.Outcomes[0].Status); + Assert.Equal(RemediationStatus.Success, result.Outcomes[1].Status); + // Error target is still audited (aborted/error result), per-target independence. + Assert.Equal(2, exec.AuditRecords.Count); + } + + // ── Preflight dispositions ──────────────────────────────────────────────────── + + [Theory] + [InlineData(false, true, true, false, RemediationDisposition.BlockDatabaseNotFound)] // db missing + [InlineData(true, false, true, false, RemediationDisposition.BlockDatabaseNotFound)] // file missing + [InlineData(true, true, false, false, RemediationDisposition.BlockNoAlter)] // no ALTER + [InlineData(true, true, true, true, RemediationDisposition.AlreadyInDesiredState)] // already fixed-MB + [InlineData(true, true, true, false, RemediationDisposition.Ok)] // ready + public async Task Preflight_ClassifiesDisposition(bool dbExists, bool fileExists, bool hasAlter, bool alreadyDesired, RemediationDisposition expected) + { + var exec = new FakeFileExecutor + { + FilePreflightFunc = (db, logical, mb) => new FileGrowthPreflight + { + Database = db, LogicalFileName = logical, RecommendedGrowthMb = mb, + DatabaseExists = dbExists, FileExists = fileExists, HasAlter = hasAlter, + AlreadyInDesiredState = alreadyDesired, CurrentValue = "percent" + } + }; + + var pre = await new FileAutogrowthHandler().PreflightAsync( + FileAction(DataTarget()), exec, CancellationToken.None); + Assert.Equal(expected, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task Preflight_AuditTableAbsent_OverridesDisposition() + { + var exec = new FakeFileExecutor { AuditTableExists = false }; + + var pre = await new FileAutogrowthHandler().PreflightAsync( + FileAction(DataTarget()), exec, CancellationToken.None); + + Assert.False(pre.AuditTableExists); + Assert.Equal(RemediationDisposition.BlockAuditTableAbsent, pre.Targets.Single().Disposition); + } + + /// + /// A standalone fake of shaped like the one nested in + /// (which is private to that class). Only the audit + the + /// file-growth seam are exercised here; the force-plan / DB-config / clear-plan members + /// return harmless defaults. + /// + private sealed class FakeFileExecutor : IRemediationExecutor + { + public bool AuditTableExists = true; + public bool AuditWriteResult = true; + + public Func? FilePreflightFunc; + public Func? SetFileFunc; + public int SetFileCalls; + public readonly List<(string Database, string Logical, int GrowthMb)> SetFileArgs = new(); + public readonly List AuditRecords = new(); + + public Task AuditTableExistsAsync(CancellationToken ct) => Task.FromResult(AuditTableExists); + + public Task PreflightFileGrowthAsync(string database, string logicalFileName, int growthMb, CancellationToken ct) + => Task.FromResult(FilePreflightFunc?.Invoke(database, logicalFileName, growthMb) ?? new FileGrowthPreflight + { + Database = database, LogicalFileName = logicalFileName, RecommendedGrowthMb = growthMb, + DatabaseExists = true, FileExists = true, HasAlter = true, AlreadyInDesiredState = false, + ExecutingLogin = "sa", CurrentValue = "percent" + }); + + public Task SetFileGrowthAsync(string database, string logicalFileName, int growthMb, RemediationIdentity identity, CancellationToken ct) + { + SetFileCalls++; + SetFileArgs.Add((database, logicalFileName, growthMb)); + return Task.FromResult(SetFileFunc?.Invoke(database, logicalFileName, growthMb) ?? new FileGrowthOutcome + { + Database = database, LogicalFileName = logicalFileName, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "sa", PriorValue = "percent", + GeneratedSql = $"ALTER DATABASE [{database}] MODIFY FILE (NAME = [{logicalFileName}], FILEGROWTH = {growthMb}MB);", + GateSpid = 55, ExecSpid = 55 + }); + } + + public Task WriteAuditAsync(RemediationAuditRecord record, CancellationToken ct) + { + AuditRecords.Add(record); + return Task.FromResult(AuditWriteResult); + } + + // ── Unused seams (return harmless defaults) ────────────────────────────── + public Task PreflightForcePlanAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(new TargetPreflight { Database = database, QueryId = queryId, PlanId = planId }); + + public Task HasPriorForceAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(false); + + public Task ForcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database, QueryId = queryId, PlanId = planId, Status = RemediationStatus.Success }); + + public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database, QueryId = queryId, PlanId = planId, Status = RemediationStatus.Success }); + + public Task PreflightDbConfigAsync(string database, DbConfigSetting setting, CancellationToken ct) + => Task.FromResult(new DbConfigPreflight { Database = database, Setting = setting }); + + public Task SetDatabaseOptionAsync(string database, DbConfigSetting setting, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new DbConfigOutcome { Database = database, Setting = setting, Status = RemediationStatus.Success }); + + public Task ClearProcCacheAsync(string queryHash, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ClearPlanOutcome { QueryHash = queryHash, Status = RemediationStatus.Success }); + + public Task PreflightServerConfigAsync(ServerConfigSetting setting, long recommendedValue, CancellationToken ct) + => Task.FromResult(new ServerConfigPreflight { Setting = setting, RecommendedValue = recommendedValue, HasPermission = true }); + + public Task SetServerConfigAsync(ServerConfigSetting setting, long value, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ServerConfigOutcome { Setting = setting, Status = RemediationStatus.Success }); + } +} diff --git a/Dashboard.Tests/InferenceEngineTests.cs b/Dashboard.Tests/InferenceEngineTests.cs new file mode 100644 index 00000000..134bfa50 --- /dev/null +++ b/Dashboard.Tests/InferenceEngineTests.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using PerformanceMonitor.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Tests the InferenceEngine and RelationshipGraph against seeded scenarios. +/// Validates that stories are built with correct paths and severity ordering. +/// +public class InferenceEngineTests +{ + /* ── Unit tests: graph edge evaluation ── */ + + [Fact] + public void Graph_NoEdgesForUnknownFact() + { + var graph = new RelationshipGraph(); + var facts = new Dictionary(); + + var edges = graph.GetActiveEdges("UNKNOWN_THING", facts); + Assert.Empty(edges); + } + + [Fact] + public void Graph_CxPacketEdgeFires_WhenSosIsHigh() + { + var graph = new RelationshipGraph(); + var facts = new Dictionary + { + ["SOS_SCHEDULER_YIELD"] = new() { Key = "SOS_SCHEDULER_YIELD", Value = 0.50, Severity = 0.67 } + }; + + var edges = graph.GetActiveEdges("CXPACKET", facts); + Assert.Contains(edges, e => e.Destination == "SOS_SCHEDULER_YIELD"); + } + + [Fact] + public void Graph_CxPacketEdgeDoesNotFire_WhenSosIsLow() + { + var graph = new RelationshipGraph(); + var facts = new Dictionary + { + ["SOS_SCHEDULER_YIELD"] = new() { Key = "SOS_SCHEDULER_YIELD", Value = 0.10, Severity = 0.13 } + }; + + var edges = graph.GetActiveEdges("CXPACKET", facts); + Assert.DoesNotContain(edges, e => e.Destination == "SOS_SCHEDULER_YIELD"); + } + + // WS3: a config-advisory fact (DB_CONFIG/SERVER_CONFIG) roots a standalone recommendation + // at its base severity (e.g. RCSI-off = 0.3), below the 0.5 incident threshold — so a + // standing misconfig surfaces on a quiet, healthy server. An incident fact at the same + // severity does NOT root. + [Fact] + public void ConfigFact_RootsStandalone_BelowMinimumSeverity() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "DB_CONFIG", Source = "config", Value = 1, Severity = 0.3, + Metadata = new Dictionary { ["rcsi_off_count"] = 9 } } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == "DB_CONFIG"); + } + + [Fact] + public void IncidentFact_BelowMinimumSeverity_DoesNotRoot() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "CPU_SQL_PERCENT", Source = "cpu", Value = 60, Severity = 0.3 } + }; + + var stories = engine.BuildStories(facts); + + Assert.DoesNotContain(stories, s => s.RootFactKey == "CPU_SQL_PERCENT"); + } + + // WS3: a FILE_AUTOGROWTH_PERCENT fact at its 0.3 advisory base roots a standalone + // recommendation, below the 0.5 incident threshold — because it is a config-advisory root + // key. Mirrors ConfigFact_RootsStandalone_BelowMinimumSeverity for the new key. + [Fact] + public void FileAutogrowthPercentFact_RootsStandalone_BelowMinimumSeverity() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "FILE_AUTOGROWTH_PERCENT", Source = "config", Value = 2, Severity = 0.3, + Metadata = new Dictionary { ["file_count"] = 2 } } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == "FILE_AUTOGROWTH_PERCENT"); + } + + // WS3: each bad server-level config fact at its 0.4 advisory base roots a standalone + // recommendation, below the 0.5 incident threshold — because each is a config-advisory root + // key. One finding per CONFIG_* fact (they are leaves with no edges between them). + [Theory] + [InlineData("CONFIG_MAXDOP")] + [InlineData("CONFIG_CTFP")] + [InlineData("CONFIG_MAX_MEMORY_MB")] + [InlineData("CONFIG_MIN_MAX_MEMORY_NARROW")] + public void ServerConfigFact_RootsStandalone_BelowMinimumSeverity(string key) + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = key, Source = "config", Value = 0, Severity = 0.4 } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == key); + } + + // WS4: each plan-XML advisory (MISSING_INDEX / PLAN_WARNING) roots a standalone recommendation + // below the 0.5 incident threshold — they are config-advisory root keys, like the server facts. + [Theory] + [InlineData("MISSING_INDEX")] + [InlineData("PLAN_WARNING")] + public void PlanAdvisoryFact_RootsStandalone_BelowMinimumSeverity(string key) + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = key, Source = "queries", Value = 3, Severity = 0.4 } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == key); + } + + // All four bad server-config facts together root four distinct standalone findings (no edges + // between them, so none consumes another). + [Fact] + public void ServerConfigFacts_AllFour_RootFourDistinctFindings() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "CONFIG_MAXDOP", Source = "config", Value = 0, Severity = 0.4 }, + new() { Key = "CONFIG_CTFP", Source = "config", Value = 5, Severity = 0.4 }, + new() { Key = "CONFIG_MAX_MEMORY_MB", Source = "config", Value = 2147483647, Severity = 0.4 }, + new() { Key = "CONFIG_MIN_MAX_MEMORY_NARROW", Source = "config", Value = 24000, Severity = 0.4 }, + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_MAXDOP"); + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_CTFP"); + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_MAX_MEMORY_MB"); + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_MIN_MAX_MEMORY_NARROW"); + } + + // WS5: each server-health advisory fact at its 0.4 advisory base roots a standalone + // recommendation, below the 0.5 incident threshold — because each is a config-advisory root + // key. One finding per fact (they are leaves with no edges between them). Mirrors the WS3 + // ServerConfigFact_RootsStandalone_BelowMinimumSeverity test. + [Theory] + [InlineData("CONFIG_IFI_DISABLED")] + [InlineData("CONFIG_LPIM_DISABLED")] + [InlineData("SERVER_MEMORY_DUMPS")] + public void ServerHealthFact_RootsStandalone_BelowMinimumSeverity(string key) + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = key, Source = "config", Value = 0, Severity = 0.4 } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == key); + } + + // All three server-health facts together root three distinct standalone findings (no edges + // between them, so none consumes another). + [Fact] + public void ServerHealthFacts_AllThree_RootThreeDistinctFindings() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "CONFIG_IFI_DISABLED", Source = "config", Value = 0, Severity = 0.4 }, + new() { Key = "CONFIG_LPIM_DISABLED", Source = "config", Value = 0, Severity = 0.4 }, + new() { Key = "SERVER_MEMORY_DUMPS", Source = "config", Value = 3, Severity = 0.4 }, + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_IFI_DISABLED"); + Assert.Contains(stories, s => s.RootFactKey == "CONFIG_LPIM_DISABLED"); + Assert.Contains(stories, s => s.RootFactKey == "SERVER_MEMORY_DUMPS"); + } + + // Regression: a duplicated fact key (e.g. a collector returning two rows for the same + // setting) once aborted the entire analysis — BuildStories built its lookup with a raw + // ToDictionary(f => f.Key), which throws on a duplicate. It must now dedupe and survive. + [Fact] + public void BuildStories_WithDuplicateFactKeys_DoesNotThrow() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "CONFIG_CTFP", Source = "config", Value = 5, Severity = 0.4 }, + new() { Key = "CONFIG_CTFP", Source = "config", Value = 5, Severity = 0.4 }, // duplicate key + }; + + var ex = Record.Exception(() => engine.BuildStories(facts)); + + Assert.Null(ex); + } +} diff --git a/Dashboard.Tests/McpSchemaCompatTests.cs b/Dashboard.Tests/McpSchemaCompatTests.cs new file mode 100644 index 00000000..75a23b20 --- /dev/null +++ b/Dashboard.Tests/McpSchemaCompatTests.cs @@ -0,0 +1,159 @@ +/* + * 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.Reflection; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Golden-sample tests for the Gemini-compatible MCP tool schema transform (issue #1074), covering the +/// Dashboard tool surface. Mirrors Lite.Tests/McpSchemaCompatTests.cs against Dashboard's tool classes, +/// which differ from Lite's. See that file for the full rationale. +/// +public class McpSchemaCompatTests +{ + /// All Dashboard MCP tool classes, discovered by their [McpServerToolType] attribute. + private static List DashboardToolTypes() => + typeof(PerformanceMonitorDashboard.Mcp.McpAlertTools).Assembly + .GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .OrderBy(t => t.FullName, StringComparer.Ordinal) + .ToList(); + + private static bool IsServiceParameter(Type t) => + !t.IsPrimitive && t != typeof(string) && !t.IsEnum && t != typeof(decimal) && + t != typeof(DateTime) && t != typeof(DateTimeOffset) && t != typeof(Guid) && t != typeof(TimeSpan); + + private static readonly MethodInfo GeminiCompatibleToolsMethod = + typeof(McpSchemaCompat).GetMethod( + nameof(McpSchemaCompat.WithGeminiCompatibleTools), + BindingFlags.Public | BindingFlags.Static)!; + + private static readonly MethodInfo StockWithToolsMethod = + typeof(McpServerBuilderExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == nameof(McpServerBuilderExtensions.WithTools) + && m.IsGenericMethodDefinition + && m.GetParameters() is { Length: 2 } p + && p[1].ParameterType == typeof(JsonSerializerOptions)); + + private static List BuildProtocolTools(MethodInfo openGenericRegister, bool stock) + { + var toolTypes = DashboardToolTypes(); + var services = new ServiceCollection(); + + var serviceParamTypes = toolTypes + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + .Where(m => m.GetCustomAttribute() is not null) + .SelectMany(m => m.GetParameters()) + .Select(p => p.ParameterType) + .Where(IsServiceParameter) + .Distinct(); + + foreach (var serviceType in serviceParamTypes) + { + services.AddSingleton(serviceType, _ => null!); + } + + var builder = services.AddMcpServer(); + + foreach (var toolType in toolTypes) + { + var closed = openGenericRegister.MakeGenericMethod(toolType); + var args = stock ? new object?[] { builder, null } : new object?[] { builder }; + closed.Invoke(null, args); + } + + using var provider = services.BuildServiceProvider(); + return provider.GetServices().Select(t => t.ProtocolTool).ToList(); + } + + private static List FindViolations(string toolName, JsonElement schema) + { + var violations = new List(); + Walk(schema, toolName); + return violations; + + void Walk(JsonElement element, string path) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + if (property.NameEquals("type") && property.Value.ValueKind == JsonValueKind.Array) + { + violations.Add($"{path}: union type {property.Value.GetRawText()}"); + } + + if (property.NameEquals("default")) + { + violations.Add($"{path}: default = {property.Value.GetRawText()}"); + } + + Walk(property.Value, $"{path}.{property.Name}"); + } + break; + + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + Walk(item, $"{path}[{index++}]"); + } + break; + } + } + } + + [Fact] + public void GeminiCompatibleTools_EmitNoUnionTypesOrDefaultKeywords() + { + var tools = BuildProtocolTools(GeminiCompatibleToolsMethod, stock: false); + + Assert.NotEmpty(tools); + + var allViolations = tools + .SelectMany(t => FindViolations(t.Name, t.InputSchema)) + .ToList(); + + Assert.True( + allViolations.Count == 0, + "Gemini-incompatible schema keywords leaked into tools/list:\n" + string.Join("\n", allViolations)); + } + + [Fact] + public void StockWithTools_WouldEmitUnionTypesOrDefaults_ProvingTransformIsNecessary() + { + var tools = BuildProtocolTools(StockWithToolsMethod, stock: true); + + var violationCount = tools.Sum(t => FindViolations(t.Name, t.InputSchema).Count); + + Assert.True( + violationCount > 0, + "Expected the stock SDK registration to emit union types / default keywords; it did not. " + + "If the SDK changed, McpSchemaCompat may no longer be needed."); + } + + [Fact] + public void GeminiCompatibleTools_ExposeSameToolCountAsStock() + { + var gemini = BuildProtocolTools(GeminiCompatibleToolsMethod, stock: false); + var stock = BuildProtocolTools(StockWithToolsMethod, stock: true); + + Assert.Equal( + stock.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), + gemini.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)); + } +} diff --git a/Dashboard.Tests/MissingIndexAdvisoryTests.cs b/Dashboard.Tests/MissingIndexAdvisoryTests.cs new file mode 100644 index 00000000..19c4c385 --- /dev/null +++ b/Dashboard.Tests/MissingIndexAdvisoryTests.cs @@ -0,0 +1,166 @@ +/* + * 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 PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Controls; +using PerformanceMonitorDashboard.Services.Recommendations; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS4: missing-index advisory — the COPY-PASTE-ONLY recommendation. A MISSING_INDEX finding's +/// drill-down carries the SQL Server-suggested CREATE INDEX statement(s); the drill-down is +/// ephemeral (not persisted / not read back), so the statements ride the persisted +/// (FactKey "MISSING_INDEX") through to the reader — exactly like the +/// autogrowth advisory, but with NO handler so it never offers Apply. These tests cover the builder, +/// the persisted-action round-trip, the reader mapping (copy-paste set, Remediation null), and the +/// card affordances (Copy fix shown, Apply hidden, incident affordances preserved). +/// +public class MissingIndexAdvisoryTests +{ + private const string CreateStmt = + "CREATE NONCLUSTERED INDEX [ix_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]) INCLUDE ([OrderDate]);"; + + private static AnalysisFinding MissingIndexFinding(List? rows = null) => new() + { + ServerId = 1, + ServerName = "TestServer", + Category = "missing_index", + StoryPath = "MISSING_INDEX", + StoryPathHash = "missingidx000001", + RootFactKey = "MISSING_INDEX", + Severity = 0.4, + DrillDown = new Dictionary + { + // SqlServerDrillDownCollector "missing_indexes": { table, impact, create_statement }. + ["missing_indexes"] = rows ?? new List + { + new { table = "dbo.Orders", impact = 87.3, create_statement = CreateStmt } + } + } + }; + + // ── Builder + extractor ─────────────────────────────────────────────────────── + + [Fact] + public void BuildMissingIndexAction_ProducesAdviseActionWithTargets() + { + var action = FactRemediation.BuildMissingIndexAction(MissingIndexFinding()); + + Assert.NotNull(action); + Assert.Equal("MISSING_INDEX", action!.FactKey); + Assert.Equal("advise", action.Action); + Assert.Empty(action.Targets); // not a force-plan action + Assert.NotNull(action.MissingIndexTargets); + var t = Assert.Single(action.MissingIndexTargets!); + Assert.Equal("dbo.Orders", t.Table); + Assert.Equal(87.3, t.Impact); + Assert.Equal(CreateStmt, t.CreateStatement); + } + + [Fact] + public void BuildMissingIndexAction_WrongRootFactKey_ReturnsNull() + { + var finding = MissingIndexFinding(); + finding.RootFactKey = "PLAN_WARNING"; // a sibling advisory, not missing-index + Assert.Null(FactRemediation.BuildMissingIndexAction(finding)); + } + + [Fact] + public void BuildMissingIndexAction_NoDrillDownOrEmpty_ReturnsNull() + { + Assert.Null(FactRemediation.BuildMissingIndexAction(new AnalysisFinding { RootFactKey = "MISSING_INDEX" })); + Assert.Null(FactRemediation.BuildMissingIndexAction(MissingIndexFinding(new List()))); + } + + [Fact] + public void ExtractMissingIndexTargets_SkipsRowsWithoutCreateStatement_AndCapsAtFive() + { + // A row with no create_statement has nothing to copy → skipped. + var noStmt = FactRemediation.ExtractMissingIndexTargets(MissingIndexFinding(new List + { + new { table = "dbo.A", impact = 10.0, create_statement = "" } + })); + Assert.Empty(noStmt); + + var many = Enumerable.Range(1, 8) + .Select(i => (object)new { table = $"dbo.T{i}", impact = (double)i, create_statement = $"CREATE INDEX ix{i} ON dbo.T{i}(c);" }) + .ToList(); + Assert.Equal(5, FactRemediation.ExtractMissingIndexTargets(MissingIndexFinding(many)).Count); + } + + // ── Persisted-action round-trip (the drill-down is ephemeral; the action must survive) ── + + [Fact] + public void SerializeAction_RoundTrips_MissingIndexTargets() + { + var action = FactRemediation.BuildMissingIndexAction(MissingIndexFinding()); + var json = AlertContextSerializer.SerializeAction(action); + var back = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(back); + Assert.Equal("MISSING_INDEX", back!.FactKey); + var t = Assert.Single(back.MissingIndexTargets!); + Assert.Equal("dbo.Orders", t.Table); + Assert.Equal(87.3, t.Impact); + Assert.Equal(CreateStmt, t.CreateStatement); + } + + // ── Reader: copy-paste set, Remediation deliberately null (no Apply) ──────────── + + [Fact] + public void MapEngineFinding_MissingIndex_IsCopyOnly() + { + var finding = MissingIndexFinding(); + // Mirror AnalysisService: the built action is attached before persistence/read. + finding.Remediation = FactRemediation.BuildMissingIndexAction(finding); + + var item = RecommendationsReader.MapEngineFinding(finding); + + Assert.Contains(CreateStmt, item.CopyPasteSql); // the CREATE is the copy-paste payload + Assert.Null(item.Remediation); // copy-only — never Apply + Assert.True(item.IsMissingIndexAdvisory); + Assert.Equal(RecommendationSetting.None, item.Setting); // stays a non-de-duping incident + } + + [Fact] + public void MapEngineFinding_MissingIndex_JoinsMultipleCreateStatements() + { + var finding = MissingIndexFinding(new List + { + new { table = "dbo.A", impact = 50.0, create_statement = "CREATE INDEX ixa ON dbo.A(c);" }, + new { table = "dbo.B", impact = 40.0, create_statement = "CREATE INDEX ixb ON dbo.B(c);" } + }); + finding.Remediation = FactRemediation.BuildMissingIndexAction(finding); + + var item = RecommendationsReader.MapEngineFinding(finding); + Assert.Contains("CREATE INDEX ixa ON dbo.A(c);", item.CopyPasteSql); + Assert.Contains("CREATE INDEX ixb ON dbo.B(c);", item.CopyPasteSql); + } + + // ── Card affordances: Copy fix shown, Apply hidden, incident affordances preserved ── + + [Fact] + public void CardViewModel_MissingIndex_ShowsCopyFix_NotApply_StillIncident() + { + var finding = MissingIndexFinding(); + finding.Remediation = FactRemediation.BuildMissingIndexAction(finding); + var vm = new RecommendationCardViewModel(RecommendationsReader.MapEngineFinding(finding)); + + Assert.True(vm.ShowCopyFix); // the CREATE INDEX is copyable + Assert.False(vm.ShowApply); // copy-only — no handler, no Apply button + Assert.True(vm.IsIncident); // stays an incident... + Assert.True(vm.ShowAskAi); // ...so Ask-AI... + Assert.True(vm.ShowOpenInActiveQueries); // ...and Open-in-Active-Queries remain + } +} diff --git a/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs b/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs new file mode 100644 index 00000000..a0139e62 --- /dev/null +++ b/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using PerformanceMonitor.PlanAnalysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS4: the shared PlanAdvisoryAggregator parses collected plan XML into missing-index / warning +/// aggregates that drive the MISSING_INDEX / PLAN_WARNING facts and drill-down. These cover its +/// resilience contract — a malformed, empty, or null plan never throws and is simply skipped, so +/// one bad plan can't abort the fact-collection batch. +/// +/// The real parse→emit over production SHOWPLAN XML is exercised by the live analysis path (the +/// collectors run the same ShowPlanParser the Plan Viewer/drill-down already use), not a hand-built +/// fixture: there are no plan-XML fixtures in the repo and the parser's structural/namespace +/// expectations make a faithful minimal one fragile and misleading. +/// +public class PlanAdvisoryAggregatorTests +{ + [Fact] + public void Summarize_EmptyInput_ReturnsZeros() + { + var summary = PlanAdvisoryAggregator.Summarize(new List()); + + Assert.Equal(0, summary.MissingIndexCount); + Assert.Equal(0, summary.WarningCount); + Assert.Equal(0, summary.CriticalCount); + Assert.Equal(0.0, summary.MaxImpact); + } + + [Fact] + public void Extract_MalformedOrNullPlans_AreSkipped_NoThrow() + { + var plans = new List + { + null!, + "", + " ", + "", + "truncated...", + "{\"json\":true}" + }; + + var ex = Record.Exception(() => + { + var details = PlanAdvisoryAggregator.Extract(plans); + Assert.Empty(details.MissingIndexes); + Assert.Empty(details.Warnings); + }); + + Assert.Null(ex); + } +} diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/Dashboard.Tests/RecommendationDeduperTests.cs new file mode 100644 index 00000000..c7b9441e --- /dev/null +++ b/Dashboard.Tests/RecommendationDeduperTests.cs @@ -0,0 +1,780 @@ +/* + * 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 PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services.Recommendations; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS1a coverage for the PURE Recommendations data layer (no DB): the cross-store de-dupe +/// (C3) and canonical severity mapping. Every test synthesizes +/// lists (or scalars) and calls the static / the +/// mappers directly. The actual two-store read is NOT +/// unit-tested here (it needs a live DB — see the PR's integration list). +/// +public class RecommendationDeduperTests +{ + // ---- builders --------------------------------------------------------- + + private static RecommendationItem EngineItem( + RecommendationSetting setting, + string? db, + double rawSeverity = 0.3, + string title = "engine") + { + var band = RecommendationDeduper.FromEngineSeverity(rawSeverity); + return new RecommendationItem + { + Source = RecommendationSource.Engine, + CanonicalSeverity = band, + RawSeverity = rawSeverity, + Database = db, + Title = title, + ProblemArea = "db_config", + Setting = setting, + StoryPathHash = "hash-" + title + }; + } + + private static RecommendationItem LegacyItem( + RecommendationSetting setting, + string? db, + CanonicalSeverity band = CanonicalSeverity.Warning, + string title = "legacy") + { + return new RecommendationItem + { + Source = RecommendationSource.Legacy, + CanonicalSeverity = band, + RawSeverity = RecommendationDeduper.LegacyRawSeverity(band), + Database = db, + Title = title, + ProblemArea = "Database Configuration", + Setting = setting + }; + } + + // ---- de-dupe: collisions --------------------------------------------- + + [Fact] + public void Merge_AutoShrink_SameDb_BothStores_KeepsEngineOnly() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.AutoShrink, row.Setting); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + [Fact] + public void Merge_AutoClose_SameDb_BothStores_KeepsEngineOnly() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoClose, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoClose, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.AutoClose, row.Setting); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + [Fact] + public void Merge_QueryStore_SameDb_BothStores_KeepsLegacyOnly() + { + // Synthesize an engine QS row even though prod does not emit one today — the de-dupe + // must still prefer the legacy row for QueryStore (the engine has no QS Apply). + var engine = new[] { EngineItem(RecommendationSetting.QueryStore, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.QueryStore, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.QueryStore, row.Setting); + Assert.Equal(RecommendationSource.Legacy, row.Source); + } + + [Fact] + public void Merge_SameSetting_DifferentDatabases_KeepsBoth() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "DbOne") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "DbTwo") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Source == RecommendationSource.Engine && r.Database == "DbOne"); + Assert.Contains(result, r => r.Source == RecommendationSource.Legacy && r.Database == "DbTwo"); + } + + [Fact] + public void Merge_DatabaseNameMatch_IsCaseAndWhitespaceInsensitive() + { + // " MyDb " (engine) vs "mydb" (legacy) must be treated as the SAME database. + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, " MyDb ") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "mydb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + // ---- de-dupe: pass-through (Setting=None) ---------------------------- + + [Fact] + public void Merge_NoneSettingRows_NeverDedupe() + { + // A memory-pressure legacy row (None) + an engine CPU finding (None) both pass through. + var legacyMemory = LegacyItem(RecommendationSetting.None, "MyDb", + band: CanonicalSeverity.Critical, title: "Memory Pressure"); + var engineCpu = EngineItem(RecommendationSetting.None, "MyDb", + rawSeverity: 1.6, title: "High CPU"); + + var result = RecommendationDeduper.Merge(new[] { engineCpu }, new[] { legacyMemory }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Title == "Memory Pressure" && r.Source == RecommendationSource.Legacy); + Assert.Contains(result, r => r.Title == "High CPU" && r.Source == RecommendationSource.Engine); + } + + [Fact] + public void Merge_TwoNoneRows_SameDb_AreNotCollapsed() + { + // Even with identical (db) the None setting must never key together. + var a = LegacyItem(RecommendationSetting.None, "MyDb", title: "A"); + var b = LegacyItem(RecommendationSetting.None, "MyDb", title: "B"); + + var result = RecommendationDeduper.Merge(Array.Empty(), new[] { a, b }); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void Merge_NonOverlappingSettings_AllKept() + { + var engine = new[] + { + EngineItem(RecommendationSetting.Rcsi, "MyDb"), + EngineItem(RecommendationSetting.PageVerify, "MyDb"), + EngineItem(RecommendationSetting.AutoShrink, "MyDb") + }; + var legacy = new[] + { + LegacyItem(RecommendationSetting.QueryStore, "MyDb") + }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + Assert.Equal(4, result.Count); + Assert.Contains(result, r => r.Setting == RecommendationSetting.Rcsi); + Assert.Contains(result, r => r.Setting == RecommendationSetting.PageVerify); + Assert.Contains(result, r => r.Setting == RecommendationSetting.AutoShrink && r.Source == RecommendationSource.Engine); + Assert.Contains(result, r => r.Setting == RecommendationSetting.QueryStore && r.Source == RecommendationSource.Legacy); + } + + // ---- de-dupe: ordering ---------------------------------------------- + + [Fact] + public void Merge_SortsBySeverityDescending() + { + var info = EngineItem(RecommendationSetting.None, "DbA", rawSeverity: 0.3, title: "info"); + var critical = EngineItem(RecommendationSetting.None, "DbB", rawSeverity: 1.8, title: "critical"); + var warning = LegacyItem(RecommendationSetting.None, "DbC", + band: CanonicalSeverity.Warning, title: "warning"); + + var result = RecommendationDeduper.Merge(new[] { info, critical }, new[] { warning }); + + Assert.Equal(CanonicalSeverity.Critical, result[0].CanonicalSeverity); + Assert.Equal(CanonicalSeverity.Warning, result[1].CanonicalSeverity); + Assert.Equal(CanonicalSeverity.Info, result[2].CanonicalSeverity); + } + + [Fact] + public void Merge_OrderIndependent_SameWinnerRegardlessOfInputOrder() + { + var engine = EngineItem(RecommendationSetting.AutoShrink, "MyDb"); + var legacy = LegacyItem(RecommendationSetting.AutoShrink, "MyDb"); + + var forward = RecommendationDeduper.Merge(new[] { engine }, new[] { legacy }); + var legacyOnly = RecommendationDeduper.Merge(Array.Empty(), + new[] { legacy }); // legacy alone -> legacy survives (no engine to prefer) + + Assert.Equal(RecommendationSource.Engine, Assert.Single(forward).Source); + // With only the legacy row present, it must still surface (collision resolver falls back). + Assert.Equal(RecommendationSource.Legacy, Assert.Single(legacyOnly).Source); + } + + [Fact] + public void Merge_EmptyInputs_ReturnsEmpty() + { + var result = RecommendationDeduper.Merge( + Array.Empty(), Array.Empty()); + Assert.Empty(result); + } + + // ---- canonical severity: engine boundaries --------------------------- + + [Theory] + [InlineData(0.0, CanonicalSeverity.Info)] + [InlineData(0.74, CanonicalSeverity.Info)] + [InlineData(0.75, CanonicalSeverity.Warning)] + [InlineData(1.49, CanonicalSeverity.Warning)] + [InlineData(1.5, CanonicalSeverity.Critical)] + [InlineData(2.0, CanonicalSeverity.Critical)] + public void FromEngineSeverity_BoundaryCases(double severity, CanonicalSeverity expected) + { + Assert.Equal(expected, RecommendationDeduper.FromEngineSeverity(severity)); + } + + // ---- RCSI card severity scales with the contention it would relieve ---- + + [Theory] + [InlineData(0, 0, CanonicalSeverity.Info)] // gated in (rw>=50) but minimal magnitude + [InlineData(9, 0, CanonicalSeverity.Info)] // just under the Warning blocking threshold + [InlineData(10, 0, CanonicalSeverity.Warning)] // notable reader/writer blocking + [InlineData(0, 1, CanonicalSeverity.Warning)] // a deadlock escalates off Info + [InlineData(99, 9, CanonicalSeverity.Warning)] // just under Critical on both axes + [InlineData(100, 0, CanonicalSeverity.Critical)] // extreme blocking + [InlineData(0, 10, CanonicalSeverity.Critical)] // many deadlocks + public void RcsiSeverityBand_ScalesWithContention(int blocking, int deadlocks, CanonicalSeverity expected) + { + // rwPct doesn't affect the band (the >=50% reader/writer gate already decided the card + // exists); the band reflects magnitude. 80 here is just a representative gated value. + var band = RecommendationsReader.RcsiSeverityBand(new RcsiInactionFigures(blocking, deadlocks, 80)); + Assert.Equal(expected, band); + } + + // ---- canonical severity: legacy text --------------------------------- + + [Theory] + [InlineData("CRITICAL", CanonicalSeverity.Critical)] + [InlineData("critical", CanonicalSeverity.Critical)] + [InlineData(" WARNING ", CanonicalSeverity.Warning)] + [InlineData("INFO", CanonicalSeverity.Info)] + [InlineData("", CanonicalSeverity.Info)] + [InlineData(null, CanonicalSeverity.Info)] + [InlineData("something-unknown", CanonicalSeverity.Info)] + public void FromLegacySeverity_TextMapping(string? severity, CanonicalSeverity expected) + { + Assert.Equal(expected, RecommendationDeduper.FromLegacySeverity(severity)); + } + + // ---- mapper-side setting derivation (feeds the de-dupe) -------------- + + [Fact] + public void SettingFromAction_DbConfigAutoShrink_MapsAutoShrink() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + + Assert.Equal(RecommendationSetting.AutoShrink, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_DbConfigAutoClose_MapsAutoClose() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoCloseOff, "ON") }); + + Assert.Equal(RecommendationSetting.AutoClose, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_RcsiFactKey_MapsRcsi() + { + var action = new RemediationAction( + "RCSI", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }); + + Assert.Equal(RecommendationSetting.Rcsi, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_ForcePlan_MapsNone() + { + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] { new ForcePlanTarget("MyDb", 1, 2) }); + + Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_Null_MapsNone() + { + Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(null)); + } + + [Theory] + [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", RecommendationSetting.AutoShrink)] + [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_CLOSE OFF;", RecommendationSetting.AutoClose)] + [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE = ON;", RecommendationSetting.QueryStore)] + [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE (OPERATION_MODE = READ_WRITE);", RecommendationSetting.QueryStore)] + public void SettingFromLegacy_ConfigArea_ParsesAlterKeyword( + string problemArea, string investigateQuery, RecommendationSetting expected) + { + Assert.Equal(expected, RecommendationsReader.SettingFromLegacy(problemArea, investigateQuery)); + } + + [Fact] + public void SettingFromLegacy_NonConfigArea_MapsNone() + { + // A memory-pressure row whose SQL incidentally mentions a keyword must NOT de-dupe: + // only the two config problem-areas are eligible. + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Memory", "SELECT 'AUTO_SHRINK';")); + } + + [Fact] + public void SettingFromLegacy_ConfigArea_NoKeyword_MapsNone() + { + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Database Configuration", "SELECT 1;")); + } + + [Fact] + public void SettingFromLegacy_ConfigArea_NullSql_MapsNone() + { + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Database Configuration", null)); + } + + // ---- mapper-side copy-paste + full engine mapping -------------------- + + [Fact] + public void BuildCopyPasteFromAction_DbConfig_RendersAlterStatements() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] + { + new DbConfigTarget("My]Db", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("My]Db", DbConfigSetting.AutoCloseOff, "ON") + }); + + var sql = RecommendationsReader.BuildCopyPasteFromAction(action); + + Assert.NotNull(sql); + // Identifier bracket-doubling (QUOTENAME-equivalent) is applied. + Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_SHRINK OFF;", sql); + Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_CLOSE OFF;", sql); + } + + [Fact] + public void BuildCopyPasteFromAction_ForcePlan_ReturnsNull() + { + var action = new RemediationAction( + "PLAN_REGRESSION", "force", new[] { new ForcePlanTarget("MyDb", 1, 2) }); + + Assert.Null(RecommendationsReader.BuildCopyPasteFromAction(action)); + } + + [Fact] + public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = "MyDb", + Severity = 0.3, + Category = "db_config", + StoryText = "AUTO_SHRINK is on", + RootFactKey = "DB_CONFIG", + StoryPathHash = "abc123", + StoryPath = "config>db_config>MyDb", + Remediation = action + }; + + var item = RecommendationsReader.MapEngineFinding(finding); + + Assert.Equal(RecommendationSource.Engine, item.Source); + Assert.Equal(RecommendationSetting.AutoShrink, item.Setting); + Assert.Equal(CanonicalSeverity.Info, item.CanonicalSeverity); // 0.3 -> Info + Assert.Same(action, item.Remediation); + Assert.Equal("abc123", item.StoryPathHash); + Assert.Equal("config>db_config>MyDb", item.StoryPath); // carried for the mute record + Assert.NotNull(item.CopyPasteSql); + Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql); + } + + // ---- per-(db,setting) fan-out of DB_CONFIG findings ------------------- + + [Fact] + public void MapEngineFindings_DbConfig_SingleSafeTarget_FansToPerDbCardWithSlicedApply() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, // DB_CONFIG is SERVER-scoped + Severity = 0.3, + Category = "database_config", + RootFactKey = "DB_CONFIG", + StoryPathHash = "abc123", + StoryPath = "config>db_config", + Remediation = action + }; + + var item = Assert.Single(RecommendationsReader.MapEngineFindings(finding)); + + Assert.Equal(RecommendationSource.Engine, item.Source); + Assert.Equal("MyDb", item.Database); // from the target, NOT the null finding db + Assert.Equal(RecommendationSetting.AutoShrink, item.Setting); + Assert.Contains("AUTO_SHRINK", item.Title); + Assert.Contains("MyDb", item.Title); + Assert.NotNull(item.CopyPasteSql); + Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql); + // The Apply action is sliced to exactly this one (db, setting). + Assert.NotNull(item.Remediation); + var target = Assert.Single(item.Remediation!.DbConfigTargets!); + Assert.Equal("MyDb", target.Database); + Assert.Equal(DbConfigSetting.AutoShrinkOff, target.Setting); + Assert.Equal("abc123", item.StoryPathHash); // mute key carried from the finding + } + + [Fact] + public void MapEngineFindings_DbConfig_MultipleTargets_FansOutOnePerTarget() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] + { + new DbConfigTarget("Db1", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("Db2", DbConfigSetting.AutoCloseOff, "ON"), + new DbConfigTarget("Db3", DbConfigSetting.PageVerifyChecksum, "NONE") + }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, + Severity = 0.3, + Category = "database_config", + RootFactKey = "DB_CONFIG", + StoryPathHash = "h", + StoryPath = "p", + Remediation = action + }; + + var items = RecommendationsReader.MapEngineFindings(finding); + + Assert.Equal(3, items.Count); + // Each card is per-(db, setting); each Apply action is sliced to one target. + Assert.All(items, i => Assert.Single(i.Remediation!.DbConfigTargets!)); + Assert.Contains(items, i => i.Database == "Db1" && i.Setting == RecommendationSetting.AutoShrink); + Assert.Contains(items, i => i.Database == "Db2" && i.Setting == RecommendationSetting.AutoClose); + Assert.Contains(items, i => i.Database == "Db3" && i.Setting == RecommendationSetting.PageVerify); + } + + [Fact] + public void MapEngineFindings_DbConfig_NoSafeTargets_FallsBackToSingleGenericCard() + { + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, + Severity = 0.3, + Category = "database_config", + StoryText = "config issues", + RootFactKey = "DB_CONFIG", + StoryPathHash = "h", + StoryPath = "p", + Remediation = null // e.g. only RCSI-off (not a safe target) — no safe action persisted + }; + + var item = Assert.Single(RecommendationsReader.MapEngineFindings(finding)); + Assert.Equal(RecommendationSetting.None, item.Setting); // generic advise card, never de-dupes + Assert.Null(item.Remediation); + } + + [Fact] + public void MapEngineFindings_NonDbConfig_ReturnsSingleItemUnchanged() + { + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", Array.Empty(), + FileGrowthTargets: new[] { new FileGrowthTarget("MyDb", "MyDb_data", 120000, 10, 512) }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = "MyDb", + Severity = 0.3, + Category = "config", + StoryText = "Large file(s) growing in percentage steps", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + StoryPathHash = "fa", + StoryPath = "fa", + Remediation = action + }; + + var item = Assert.Single(RecommendationsReader.MapEngineFindings(finding)); + Assert.Equal(RecommendationSetting.None, item.Setting); + Assert.Same(action, item.Remediation); // same single action, NOT sliced + } + + // ---- per-db RCSI fan-out of DB_CONFIG findings (destructive, consent-gated) ---- + + [Fact] + public void MapEngineFindings_DbConfig_RcsiTargets_FanToPerDbRcsiCardsWithRcsiAction() + { + // A DB_CONFIG finding whose action carries TWO per-db RCSI targets fans to TWO RCSI + // cards — each a distinct FactKey="RCSI" action (dispatches to RcsiHandler + the two- + // sided consent gate) carrying THAT db's real inaction figures. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: Array.Empty(), + RcsiTargets: new[] + { + new RcsiTarget("Sales", new RcsiInactionFigures(40, 2, 85)), + new RcsiTarget("Orders", new RcsiInactionFigures(12, 0, 30)) + }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, // DB_CONFIG is SERVER-scoped + Severity = 0.3, + Category = "database_config", + RootFactKey = "DB_CONFIG", + StoryPathHash = "h", + StoryPath = "p", + Remediation = action + }; + + var items = RecommendationsReader.MapEngineFindings(finding); + + Assert.Equal(2, items.Count); + Assert.All(items, i => + { + Assert.Equal(RecommendationSource.Engine, i.Source); + Assert.Equal(RecommendationSetting.Rcsi, i.Setting); + Assert.NotNull(i.Remediation); + Assert.Equal("RCSI", i.Remediation!.FactKey); // dispatches to RcsiHandler + Assert.NotNull(i.Remediation.RcsiFigures); // real figures for the consent dialog + Assert.Contains("READ_COMMITTED_SNAPSHOT ON", i.CopyPasteSql); + Assert.Contains(i.Database!, i.Title); // Title names the db + // The reconstructed action's single target is THIS db with the RCSI setting. + var target = Assert.Single(i.Remediation.DbConfigTargets!); + Assert.Equal(i.Database, target.Database); + Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, target.Setting); + Assert.Equal("h", i.StoryPathHash); + }); + + var sales = Assert.Single(items, i => i.Database == "Sales"); + Assert.Equal(40, sales.Remediation!.RcsiFigures!.BlockingEvents); + Assert.Equal(2, sales.Remediation.RcsiFigures.Deadlocks); + Assert.Equal(85, sales.Remediation.RcsiFigures.ReaderWriterPct); + + var orders = Assert.Single(items, i => i.Database == "Orders"); + Assert.Equal(12, orders.Remediation!.RcsiFigures!.BlockingEvents); + Assert.Equal(0, orders.Remediation.RcsiFigures.Deadlocks); + Assert.Equal(30, orders.Remediation.RcsiFigures.ReaderWriterPct); + } + + [Fact] + public void MapEngineFindings_DbConfig_SafeAndRcsiTargets_ProduceBothSetsOfCards() + { + // One DB_CONFIG finding carrying BOTH a safe AUTO_SHRINK target and an RCSI target must + // produce a safe-setting card AND an RCSI card (the two fan-outs are independent). + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget("Sales", DbConfigSetting.AutoShrinkOff, "ON") }, + RcsiTargets: new[] { new RcsiTarget("Orders", new RcsiInactionFigures(12, 3, 80)) }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, + Severity = 0.3, + Category = "database_config", + RootFactKey = "DB_CONFIG", + StoryPathHash = "h", + StoryPath = "p", + Remediation = action + }; + + var items = RecommendationsReader.MapEngineFindings(finding); + + Assert.Equal(2, items.Count); + // The safe AUTO_SHRINK card: DB_CONFIG action, AutoShrink setting. + var safe = Assert.Single(items, i => i.Setting == RecommendationSetting.AutoShrink); + Assert.Equal("Sales", safe.Database); + Assert.Equal("DB_CONFIG", safe.Remediation!.FactKey); + Assert.Contains("AUTO_SHRINK OFF", safe.CopyPasteSql); + // The destructive RCSI card: distinct FactKey="RCSI" action with figures. + var rcsi = Assert.Single(items, i => i.Setting == RecommendationSetting.Rcsi); + Assert.Equal("Orders", rcsi.Database); + Assert.Equal("RCSI", rcsi.Remediation!.FactKey); + Assert.Equal(12, rcsi.Remediation.RcsiFigures!.BlockingEvents); + Assert.Contains("READ_COMMITTED_SNAPSHOT ON", rcsi.CopyPasteSql); + } + + [Fact] + public void EngineDbConfigFanOut_DeDupesWithLegacyPerDbRow_EngineWinsWithApply() + { + // Engine: server-scoped DB_CONFIG finding carrying an AUTO_SHRINK target for MyDb. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = null, + Severity = 0.3, + Category = "database_config", + RootFactKey = "DB_CONFIG", + StoryPathHash = "h", + StoryPath = "p", + Remediation = action + }; + var engine = RecommendationsReader.MapEngineFindings(finding); + + // Legacy: the per-db AUTO_SHRINK row the frozen proc emits for the same database. + var legacy = RecommendationsReader.MapLegacyIssue(new CriticalIssueItem + { + Severity = "WARNING", + ProblemArea = "Database Configuration", + AffectedDatabase = "MyDb", + Message = "AUTO_SHRINK is enabled", + InvestigateQuery = "ALTER DATABASE [MyDb] SET AUTO_SHRINK OFF;" + }); + + var merged = RecommendationDeduper.Merge(engine, new[] { legacy }); + + // Before this fix the engine card had Database=null → keys differed → TWO cards (the + // legacy copy-only one is what the user saw). Now they collapse to ONE engine card + // that carries the Apply action. + var item = Assert.Single(merged); + Assert.Equal(RecommendationSource.Engine, item.Source); + Assert.NotNull(item.Remediation); + } + + // ---- WS3: percent-autogrowth advisory copy-paste flows through the reader ---- + + [Fact] + public void BuildCopyPasteFromAction_FileAutogrowth_RendersModifyFileStatements() + { + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", System.Array.Empty(), + FileGrowthTargets: new[] + { + // Bracket-doubling is exercised on both the db and the logical file name. + new FileGrowthTarget("My]Db", "data]1", 60000, 10, 512), + new FileGrowthTarget("My]Db", "log]1", 250000, 25, 1024) + }); + + var sql = RecommendationsReader.BuildCopyPasteFromAction(action); + + Assert.NotNull(sql); + Assert.Contains("ALTER DATABASE [My]]Db] MODIFY FILE (NAME = [data]]1], FILEGROWTH = 512MB);", sql); + Assert.Contains("ALTER DATABASE [My]]Db] MODIFY FILE (NAME = [log]]1], FILEGROWTH = 1024MB);", sql); + } + + [Fact] + public void MapEngineFinding_FileAutogrowth_CarriesCopyPasteAndNoDeDupeSetting() + { + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", System.Array.Empty(), + FileGrowthTargets: new[] { new FileGrowthTarget("MyDb", "MyDb_data", 120000, 10, 512) }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = "MyDb", + Severity = 0.3, + Category = "config", + StoryText = "Large file(s) growing in percentage steps", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + StoryPathHash = "fa9000", + StoryPath = "FILE_AUTOGROWTH_PERCENT", + Remediation = action + }; + + var item = RecommendationsReader.MapEngineFinding(finding); + + // Fix + copy-paste: the copy-paste flows, but the row never de-dupes (file-level, not a + // per-DB config-setting collision) and the action stays attached. + Assert.Equal(RecommendationSetting.None, item.Setting); + Assert.Same(action, item.Remediation); + Assert.NotNull(item.CopyPasteSql); + Assert.Contains("MODIFY FILE (NAME = [MyDb_data], FILEGROWTH = 512MB);", item.CopyPasteSql); + // Headline from the authored advice block. + Assert.Equal("Large file(s) growing in percentage steps", item.Title); + } + + [Fact] + public void MapLegacyIssue_MemoryPressure_IsNoneAndAdviseOnly() + { + var issue = new CriticalIssueItem + { + Severity = "CRITICAL", + ProblemArea = "Memory", + AffectedDatabase = string.Empty, + Message = "Memory pressure detected", + InvestigateQuery = "SELECT * FROM sys.dm_os_memory_clerks;" + }; + + var item = RecommendationsReader.MapLegacyIssue(issue); + + Assert.Equal(RecommendationSource.Legacy, item.Source); + Assert.Equal(RecommendationSetting.None, item.Setting); + Assert.Equal(CanonicalSeverity.Critical, item.CanonicalSeverity); + Assert.Null(item.Remediation); + Assert.Null(item.StoryPathHash); + Assert.Null(item.StoryPath); // legacy rows have no mute concept + Assert.Null(item.Database); // empty AffectedDatabase -> null + Assert.Equal("Memory pressure detected", item.AdviceText); + Assert.Equal("SELECT * FROM sys.dm_os_memory_clerks;", item.CopyPasteSql); + } + + [Fact] + public void MapLegacyIssue_DatabaseConfiguration_AutoShrink_DerivesSetting() + { + var issue = new CriticalIssueItem + { + Severity = "WARNING", + ProblemArea = "Database Configuration", + AffectedDatabase = "MyDb", + Message = "Auto shrink is enabled", + InvestigateQuery = "ALTER DATABASE [MyDb] SET AUTO_SHRINK OFF;" + }; + + var item = RecommendationsReader.MapLegacyIssue(issue); + + Assert.Equal(RecommendationSetting.AutoShrink, item.Setting); + Assert.Equal("MyDb", item.Database); + } +} diff --git a/Dashboard.Tests/RecommendationsViewModelTests.cs b/Dashboard.Tests/RecommendationsViewModelTests.cs new file mode 100644 index 00000000..09dee1c8 --- /dev/null +++ b/Dashboard.Tests/RecommendationsViewModelTests.cs @@ -0,0 +1,508 @@ +/* + * 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 PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Controls; +using PerformanceMonitorDashboard.Services.Recommendations; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS1b-1 coverage for the PURE, WPF-free Recommendations view-model: grouping a flat +/// (already de-duped + severity-sorted) list into the +/// Critical / Warning / Info collapsible sections; selecting the single top-level +/// (loading / insufficient-data / empty / loaded); the +/// incident-vs-config affordance predicates (Open-in-Active-Queries + Ask-AI for incidents, +/// Copy-fix for config rows; Apply/Mute visibility); and the Ask-AI prompt interpolation. The +/// WPF navigation + clipboard themselves are not unit-testable (they need a running Dashboard — +/// see the PR's visual-verification note). +/// +public class RecommendationsViewModelTests +{ + // ---- builders --------------------------------------------------------- + + private static RemediationAction DbConfigAction(DbConfigSetting setting, string db = "db1") => + new( + FactKey: "DB_CONFIG", + Action: "set", + Targets: Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget(db, setting) }); + + private static RecommendationItem Item( + CanonicalSeverity severity, + RecommendationSource source = RecommendationSource.Engine, + RecommendationSetting setting = RecommendationSetting.None, + RemediationAction? remediation = null, + string? sql = null, + string title = "rec", + string? db = null, + double rawSeverity = 0.3, + string? advice = null, + DateTime? windowStartUtc = null, + DateTime? windowEndUtc = null, + string? storyHash = "hash", + string? storyPath = "root>leaf") + { + return new RecommendationItem + { + CanonicalSeverity = severity, + RawSeverity = rawSeverity, + Source = source, + Setting = setting, + Remediation = remediation, + CopyPasteSql = sql, + Title = title, + Database = db, + AdviceText = advice, + WindowStartUtc = windowStartUtc, + WindowEndUtc = windowEndUtc, + StoryPathHash = source == RecommendationSource.Engine ? storyHash : null, + StoryPath = source == RecommendationSource.Engine ? storyPath : null + }; + } + + private static RecommendationCardViewModel Card( + RecommendationItem item, string serverName = "SRV", int utcOffsetMinutes = 0) => + new(item, serverName, utcOffsetMinutes); + + // ---- state selection -------------------------------------------------- + + [Fact] + public void Loading_SelectsLoadingState() + { + var vm = RecommendationsViewModel.Loading(); + + Assert.Equal(RecommendationsState.Loading, vm.State); + Assert.Empty(vm.Sections); + } + + [Fact] + public void FromItems_EmptyList_SelectsEmptyState() + { + var vm = RecommendationsViewModel.FromItems(Array.Empty()); + + Assert.Equal(RecommendationsState.Empty, vm.State); + Assert.Empty(vm.Sections); + Assert.Equal(0, vm.TotalCount); + } + + [Fact] + public void FromItems_NonEmptyList_SelectsLoadedState() + { + var vm = RecommendationsViewModel.FromItems(new[] { Item(CanonicalSeverity.Warning) }); + + Assert.Equal(RecommendationsState.Loaded, vm.State); + Assert.Equal(1, vm.TotalCount); + } + + [Fact] + public void InsufficientData_WithEngineMessage_UsesIt() + { + var vm = RecommendationsViewModel.InsufficientData("need 1.0 days, have 0.5 days"); + + Assert.Equal(RecommendationsState.InsufficientData, vm.State); + Assert.Equal("need 1.0 days, have 0.5 days", vm.InsufficientDataMessage); + Assert.Empty(vm.Sections); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void InsufficientData_WithBlankMessage_FallsBackToDefault(string? engineMessage) + { + var vm = RecommendationsViewModel.InsufficientData(engineMessage); + + Assert.Equal(RecommendationsState.InsufficientData, vm.State); + Assert.Equal(RecommendationsViewModel.DefaultInsufficientDataMessage, vm.InsufficientDataMessage); + } + + // ---- grouping --------------------------------------------------------- + + [Fact] + public void FromItems_GroupsBySeverity_IntoThreeSectionsInFixedOrder() + { + var items = new[] + { + Item(CanonicalSeverity.Info, title: "i1"), + Item(CanonicalSeverity.Critical, title: "c1"), + Item(CanonicalSeverity.Warning, title: "w1"), + Item(CanonicalSeverity.Critical, title: "c2"), + }; + + var vm = RecommendationsViewModel.FromItems(items); + + // Fixed display order Critical -> Warning -> Info, regardless of input order. + Assert.Equal(3, vm.Sections.Count); + Assert.Equal(CanonicalSeverity.Critical, vm.Sections[0].Severity); + Assert.Equal(CanonicalSeverity.Warning, vm.Sections[1].Severity); + Assert.Equal(CanonicalSeverity.Info, vm.Sections[2].Severity); + + Assert.Equal(2, vm.Sections[0].Count); + Assert.Equal(1, vm.Sections[1].Count); + Assert.Equal(1, vm.Sections[2].Count); + Assert.Equal(4, vm.TotalCount); + } + + [Fact] + public void FromItems_OmitsEmptySeveritySections() + { + var items = new[] + { + Item(CanonicalSeverity.Warning, title: "w1"), + Item(CanonicalSeverity.Warning, title: "w2"), + }; + + var vm = RecommendationsViewModel.FromItems(items); + + Assert.Single(vm.Sections); + Assert.Equal(CanonicalSeverity.Warning, vm.Sections[0].Severity); + Assert.Equal(2, vm.Sections[0].Count); + } + + [Fact] + public void FromItems_PreservesReaderOrderWithinASection() + { + // The reader returns severity-desc; within a band the order must be preserved as-is. + var items = new[] + { + Item(CanonicalSeverity.Critical, title: "first", rawSeverity: 1.9), + Item(CanonicalSeverity.Critical, title: "second", rawSeverity: 1.6), + Item(CanonicalSeverity.Critical, title: "third", rawSeverity: 1.51), + }; + + var vm = RecommendationsViewModel.FromItems(items); + + var titles = vm.Sections[0].Cards.Select(c => c.Title).ToArray(); + Assert.Equal(new[] { "first", "second", "third" }, titles); + } + + [Fact] + public void FromItems_CriticalAndWarningExpanded_InfoCollapsed_ByDefault() + { + var items = new[] + { + Item(CanonicalSeverity.Critical, title: "c"), + Item(CanonicalSeverity.Warning, title: "w"), + Item(CanonicalSeverity.Info, title: "i"), + }; + + var vm = RecommendationsViewModel.FromItems(items); + + Assert.True(vm.Sections.Single(s => s.Severity == CanonicalSeverity.Critical).IsExpanded); + Assert.True(vm.Sections.Single(s => s.Severity == CanonicalSeverity.Warning).IsExpanded); + Assert.False(vm.Sections.Single(s => s.Severity == CanonicalSeverity.Info).IsExpanded); + } + + [Fact] + public void SectionHeader_IncludesCount() + { + var items = new[] + { + Item(CanonicalSeverity.Critical, title: "c1"), + Item(CanonicalSeverity.Critical, title: "c2"), + Item(CanonicalSeverity.Critical, title: "c3"), + }; + + var vm = RecommendationsViewModel.FromItems(items); + + Assert.Equal("Critical (3)", vm.Sections[0].Header); + } + + [Fact] + public void FromItems_FlowsServerNameAndOffsetOntoCards() + { + var item = Item( + CanonicalSeverity.Critical, + windowStartUtc: new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc), + windowEndUtc: new DateTime(2026, 6, 1, 10, 30, 0, DateTimeKind.Utc)); + + var vm = RecommendationsViewModel.FromItems(new[] { item }, "PROD-SQL-01", utcOffsetMinutes: 60); + var card = vm.Sections[0].Cards[0]; + + Assert.Equal("PROD-SQL-01", card.ServerName); + // offset 60 → window shifts +1h into server-local in the Ask-AI prompt. + Assert.Contains("11:00", card.AskAiPrompt); + Assert.Contains("11:30", card.AskAiPrompt); + } + + // ---- incident vs config affordances ----------------------------------- + + [Fact] + public void IncidentRow_ShowsOpenInActiveQueriesAndAskAi_NotCopyFix() + { + // Setting == None => incident (CPU/memory/blocking/waits/plan-regression). + var card = Card(Item(CanonicalSeverity.Critical, setting: RecommendationSetting.None)); + + Assert.True(card.IsIncident); + Assert.False(card.IsConfigFix); + Assert.True(card.ShowOpenInActiveQueries); + Assert.True(card.ShowAskAi); + Assert.False(card.ShowCopyFix); + } + + [Theory] + [InlineData(RecommendationSetting.AutoShrink)] + [InlineData(RecommendationSetting.AutoClose)] + [InlineData(RecommendationSetting.QueryStore)] + [InlineData(RecommendationSetting.Rcsi)] + [InlineData(RecommendationSetting.PageVerify)] + public void ConfigFixRow_ShowsCopyFix_NotOpenInActiveQueriesOrAskAi(RecommendationSetting setting) + { + // Setting != None => standing config-fix; carries an ALTER → Copy fix. + var card = Card(Item( + CanonicalSeverity.Warning, + setting: setting, + sql: "ALTER DATABASE [db1] SET AUTO_SHRINK OFF;")); + + Assert.False(card.IsIncident); + Assert.True(card.IsConfigFix); + Assert.False(card.ShowOpenInActiveQueries); + Assert.False(card.ShowAskAi); + Assert.True(card.ShowCopyFix); + } + + [Fact] + public void ConfigFixRow_WithoutSql_DoesNotShowCopyFix() + { + var card = Card(Item(CanonicalSeverity.Warning, setting: RecommendationSetting.Rcsi, sql: null)); + + Assert.True(card.IsConfigFix); + Assert.False(card.ShowCopyFix); + } + + [Fact] + public void IncidentRow_DoesNotShowCopyFix_EvenIfSqlSomehowPresent() + { + // Defensive: an incident never offers Copy fix regardless of CopyPasteSql. + var card = Card(Item(CanonicalSeverity.Critical, setting: RecommendationSetting.None, sql: "SELECT 1;")); + + Assert.True(card.IsIncident); + Assert.False(card.ShowCopyFix); + } + + // ---- Apply visibility (Remediation != null) --------------------------- + + [Fact] + public void ShowApply_True_OnlyWhenRemediationPresent() + { + var withAction = Card(Item( + CanonicalSeverity.Warning, + setting: RecommendationSetting.AutoShrink, + remediation: DbConfigAction(DbConfigSetting.AutoShrinkOff))); + var withoutAction = Card(Item(CanonicalSeverity.Warning, remediation: null)); + + Assert.True(withAction.ShowApply); + Assert.False(withoutAction.ShowApply); + } + + [Fact] + public void ShowApply_True_ForIncidentWithRemediation() + { + // e.g. plan-regression / clear-plan: an incident (Setting==None) that still has an action. + var card = Card(Item( + CanonicalSeverity.Critical, + setting: RecommendationSetting.None, + remediation: new RemediationAction("CLEAR_PLAN", "clear", Array.Empty()))); + + Assert.True(card.IsIncident); + Assert.True(card.ShowApply); + } + + [Fact] + public void ShowApply_False_ForLegacyRow() + { + // Legacy rows never carry a built action, so Apply is never shown. + var legacy = Card(Item(CanonicalSeverity.Critical, source: RecommendationSource.Legacy, remediation: null)); + + Assert.False(legacy.ShowApply); + } + + [Fact] + public void AutogrowthFix_ShowsCopyFixAndApply_NotIncident() + { + // FILE_AUTOGROWTH_PERCENT: Setting==None, but the action carries typed FileGrowthTargets — + // a structured standing fix. It must read as a config fix (NOT a time-bound incident) that + // offers BOTH "Copy fix" AND Apply (the MODIFY FILE FILEGROWTH change is metadata-only, + // online, non-destructive — same class as AUTO_SHRINK OFF, now handled by + // FileAutogrowthHandler). It must never get the incident affordances: a large file growing + // by a percent should offer Copy fix / Apply, never "Open in Active Queries". + var card = Card(Item( + CanonicalSeverity.Info, + setting: RecommendationSetting.None, + remediation: new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "set", Array.Empty(), + FileGrowthTargets: new[] { new FileGrowthTarget("BigDb", "BigDb_data", 51200, 10, 1024) }), + sql: "ALTER DATABASE [BigDb] MODIFY FILE (NAME = [BigDb_data], FILEGROWTH = 1024MB);")); + + Assert.False(card.IsIncident); + Assert.False(card.ShowOpenInActiveQueries); // <-- the user's exact complaint + Assert.False(card.ShowAskAi); + Assert.True(card.ShowCopyFix); // the copy-paste MODIFY FILE fix + Assert.True(card.ShowApply); // autogrowth IS Apply-able (FileAutogrowthHandler) + } + + [Fact] + public void RcsiCard_ShowsApplyAndCopyFix_NotIncident() + { + // A per-db RCSI recommendation: Setting=Rcsi (config-fix, NOT a time-bound incident) with + // a distinct FactKey="RCSI" action. It must offer Apply (-> RcsiHandler + the two-sided + // consent gate) and Copy fix (the ALTER), and never the incident affordances. + var card = Card(Item( + CanonicalSeverity.Warning, + setting: RecommendationSetting.Rcsi, + remediation: new RemediationAction( + "RCSI", "set", Array.Empty(), + new[] { new DbConfigTarget("Sales", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }, + RcsiFigures: new RcsiInactionFigures(40, 2, 85)), + sql: "ALTER DATABASE [Sales] SET READ_COMMITTED_SNAPSHOT ON;", + db: "Sales")); + + Assert.False(card.IsIncident); + Assert.True(card.IsConfigFix); + Assert.True(card.ShowApply); + Assert.True(card.ShowCopyFix); + Assert.False(card.ShowOpenInActiveQueries); + Assert.False(card.ShowAskAi); + } + + // ---- Mute visibility (Source == Engine) ------------------------------- + + [Fact] + public void ShowMute_True_ForEngineRow() + { + var engine = Card(Item(CanonicalSeverity.Warning, source: RecommendationSource.Engine)); + + Assert.True(engine.ShowMute); + } + + [Fact] + public void ShowMute_False_ForLegacyRow() + { + var legacy = Card(Item(CanonicalSeverity.Warning, source: RecommendationSource.Legacy)); + + Assert.False(legacy.ShowMute); + } + + // ---- Ask-AI prompt generation ----------------------------------------- + + [Fact] + public void BuildAskAiPrompt_InterpolatesServerTitleAndWindow() + { + var from = new DateTime(2026, 6, 1, 14, 5, 0); + var to = new DateTime(2026, 6, 1, 15, 30, 0); + + var prompt = RecommendationsViewModel.BuildAskAiPrompt("PROD\\SQL2022", "High CXPACKET waits", from, to); + + Assert.Contains("server \"PROD\\SQL2022\"", prompt); + Assert.Contains("\"High CXPACKET waits\"", prompt); + Assert.Contains("2026-06-01 14:05", prompt); + Assert.Contains("15:30", prompt); + Assert.Contains("PerformanceMonitor MCP tools", prompt); + Assert.Contains("analyze_server", prompt); + Assert.Contains("get_analysis_findings", prompt); + } + + [Fact] + public void Card_AskAiPrompt_UsesItemTitleAndServerName() + { + var item = Item( + CanonicalSeverity.Critical, + title: "Blocking storm", + windowStartUtc: new DateTime(2026, 6, 2, 9, 0, 0, DateTimeKind.Utc), + windowEndUtc: new DateTime(2026, 6, 2, 9, 15, 0, DateTimeKind.Utc)); + + var card = Card(item, serverName: "SQLBOX", utcOffsetMinutes: 0); + + Assert.Contains("server \"SQLBOX\"", card.AskAiPrompt); + Assert.Contains("\"Blocking storm\"", card.AskAiPrompt); + Assert.Contains("2026-06-02 09:00", card.AskAiPrompt); + Assert.Contains("09:15", card.AskAiPrompt); + } + + [Fact] + public void Card_AskAiPrompt_WithNoWindow_UsesNowAnchoredFallback() + { + // No producer window → a 2h band ending "now" (server-local). Just assert it renders a + // well-formed prompt (no crash, contains the fixed scaffolding + the title). + var card = Card(Item(CanonicalSeverity.Critical, title: "Memory pressure"), serverName: "S1"); + + Assert.Contains("server \"S1\"", card.AskAiPrompt); + Assert.Contains("\"Memory pressure\"", card.AskAiPrompt); + Assert.Contains("PerformanceMonitor MCP tools", card.AskAiPrompt); + } + + // ---- deep-link window passthrough ------------------------------------- + + [Fact] + public void Card_ExposesRawUtcWindow_ForDeepLink() + { + var su = new DateTime(2026, 6, 1, 10, 0, 0, DateTimeKind.Utc); + var eu = new DateTime(2026, 6, 1, 10, 30, 0, DateTimeKind.Utc); + var card = Card(Item(CanonicalSeverity.Critical, windowStartUtc: su, windowEndUtc: eu)); + + Assert.Equal(su, card.WindowStartUtc); + Assert.Equal(eu, card.WindowEndUtc); + } + + // ---- card display flags ---------------------------------------------- + + [Fact] + public void Card_DatabaseBracketed_WrapsOrCollapses() + { + var withDb = Card(Item(CanonicalSeverity.Warning, db: "Sales")); + var serverScoped = Card(Item(CanonicalSeverity.Warning, db: null)); + + Assert.True(withDb.HasDatabase); + Assert.Equal("[Sales]", withDb.DatabaseBracketed); + + Assert.False(serverScoped.HasDatabase); + Assert.Equal(string.Empty, serverScoped.DatabaseBracketed); + } + + [Theory] + [InlineData(CanonicalSeverity.Critical, "CRITICAL")] + [InlineData(CanonicalSeverity.Warning, "WARNING")] + [InlineData(CanonicalSeverity.Info, "INFO")] + public void Card_SeverityLabel_MatchesBand(CanonicalSeverity severity, string expected) + { + var card = Card(Item(severity)); + Assert.Equal(expected, card.SeverityLabel); + } + + [Fact] + public void EngineCard_ExposesMuteKeyInputs_HashAndPath() + { + // The Mute handler keys on the underlying item's StoryPathHash (the mute key) and carries + // StoryPath (the operator-facing label). Both must round-trip through the card for engine rows. + var card = Card(Item( + CanonicalSeverity.Warning, + source: RecommendationSource.Engine, + storyHash: "h-42", + storyPath: "blocking>rcsi>Sales")); + + Assert.True(card.ShowMute); + Assert.Equal("h-42", card.Item.StoryPathHash); + Assert.Equal("blocking>rcsi>Sales", card.Item.StoryPath); + } + + [Fact] + public void LegacyCard_HasNoMuteKeyInputs() + { + // Legacy rows never mute: no hash, no path, no Mute button. + var card = Card(Item(CanonicalSeverity.Warning, source: RecommendationSource.Legacy)); + + Assert.False(card.ShowMute); + Assert.Null(card.Item.StoryPathHash); + Assert.Null(card.Item.StoryPath); + } +} diff --git a/Dashboard.Tests/RemediationApplyServiceTests.cs b/Dashboard.Tests/RemediationApplyServiceTests.cs new file mode 100644 index 00000000..a3b20d49 --- /dev/null +++ b/Dashboard.Tests/RemediationApplyServiceTests.cs @@ -0,0 +1,865 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// PR-B coverage for the gated Apply Fix orchestrator: the confirm gate (the +/// privileged handler is unreachable unless confirm() returns true), M3 fail-closed +/// server resolution, applied-but-unlogged permanent-vs-transient surfacing (LOW-2), +/// and the AlertDetailWindow view-model gating (CanApply). +/// +public class RemediationApplyServiceTests +{ + private static readonly ServerConnection Server = + new() { Id = "11111111-1111-1111-1111-111111111111", ServerName = "SQL2022", DisplayName = "Prod SQL2022" }; + + private static RemediationAction ForceAction(double regression = 7.5) => + new("PLAN_REGRESSION", "force", new List { new("AdventureWorks", 4242, 17, RegressionFactor: regression) }); + + private static RemediationApplyService BuildService( + FakeExecutor exec, + IRemediationHandler? handler = null, + Func>? classifier = null) + { + var handlers = handler is null ? new[] { (IRemediationHandler)new ForcePlanHandler() } : new[] { handler }; + var registry = new RemediationHandlerRegistry(handlers); + return new RemediationApplyService(serverManager: null!, registry, _ => exec, classifier); + } + + private static RemediationApplyService BuildServiceNoHandler(FakeExecutor exec) => + new(serverManager: null!, new RemediationHandlerRegistry(Array.Empty()), _ => exec, null); + + // ── Confirm gate ──────────────────────────────────────────────────────────── + + [Fact] + public async Task Gate_ConfirmFalse_NeverReachesHandler() + { + var exec = new FakeExecutor(); + var service = BuildService(exec); + + var report = await service.ApplyAsync( + ForceAction(), Server, previewSql: "preview", operatorIdentity: "DOM\\op", sourceAlertRef: "ref", + confirm: _ => Task.FromResult(false), CancellationToken.None); + + Assert.Equal(RemediationRunStatus.NotConfirmed, report.Status); + Assert.Empty(report.Targets); + Assert.Equal(0, exec.ForceCalls); // the privileged path was never entered + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task Gate_ConfirmTrue_ReachesHandlerExactlyOnce() + { + var exec = new FakeExecutor(); + var confirmCalls = 0; + var service = BuildService(exec); + + var report = await service.ApplyAsync( + ForceAction(), Server, "preview", "DOM\\op", "ref", + confirm: req => { confirmCalls++; return Task.FromResult(true); }, CancellationToken.None); + + Assert.Equal(1, confirmCalls); + Assert.Equal(RemediationRunStatus.Ran, report.Status); + Assert.Equal(1, exec.ForceCalls); + Assert.Equal(RemediationStatus.Success, Assert.Single(report.Targets).Status); + } + + [Fact] + public async Task Gate_ConfirmRequest_CarriesServerRegressionAndCaveat() + { + var exec = new FakeExecutor(); + var service = BuildService(exec); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(ForceAction(regression: 9.0), Server, "preview", "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal("Prod SQL2022", captured!.ServerDisplayName); + Assert.Equal("preview", captured.PreviewSql); + Assert.Equal(9.0, Assert.Single(captured.Targets).RegressionFactor); + Assert.Contains("still the better choice", RemediationConfirmRequest.StillBetterCaveat); + } + + [Fact] + public async Task Apply_NoHandler_ReturnsNoHandler_NeverConfirms() + { + var exec = new FakeExecutor(); + var service = BuildServiceNoHandler(exec); + var confirmed = false; + + var report = await service.ApplyAsync(ForceAction(), Server, "preview", "DOM\\op", "ref", + confirm: _ => { confirmed = true; return Task.FromResult(true); }, CancellationToken.None); + + Assert.Equal(RemediationRunStatus.NoHandler, report.Status); + Assert.False(confirmed); + Assert.Equal(0, exec.ForceCalls); + } + + [Fact] + public async Task Unapply_ConfirmTrue_ReachesUnforce() + { + var exec = new FakeExecutor { PriorForce = true }; + var service = BuildService(exec); + + var report = await service.UnapplyAsync(ForceAction(), Server, "DOM\\op", "ref", + confirm: _ => Task.FromResult(true), CancellationToken.None); + + Assert.Equal(RemediationRunStatus.Ran, report.Status); + Assert.True(report.IsUnapply); + Assert.Equal(1, exec.UnforceCalls); + } + + // ── Apply-only enforcement (m-1 / m-C): un-apply for SupportsUnapply==false ── + + [Fact] + public async Task Unapply_HandlerDoesNotSupport_ShortCircuits_NeverConfirms_NeverReachesHandler() + { + var exec = new FakeExecutor(); + var handler = new DbConfigHandler(); // SupportsUnapply == false + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { handler }), _ => exec, null); + + var confirmed = false; + var dbConfigAction = new RemediationAction("DB_CONFIG", "set", + Array.Empty(), + new List { new("Foo", DbConfigSetting.AutoShrinkOff, "ON") }); + + // Even if a future mis-wired caller invokes UnapplyAsync, it must fail SAFE + // (clean report, no NotSupportedException) and never confirm or mutate. + var report = await service.UnapplyAsync(dbConfigAction, Server, "DOM\\op", "ref", + confirm: _ => { confirmed = true; return Task.FromResult(true); }, CancellationToken.None); + + Assert.Equal(RemediationRunStatus.UnapplyNotSupported, report.Status); + Assert.True(report.IsUnapply); + Assert.False(confirmed); // the gate was never even shown + Assert.Equal(0, exec.SetDbCalls); + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task Apply_DbConfig_ConfirmRequest_RendersDbConfigRows_NoQueryId() + { + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new DbConfigHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + var action = new RemediationAction("DB_CONFIG", "set", Array.Empty(), + new List { new("Foo", DbConfigSetting.AutoShrinkOff, "ON") }); + + await service.ApplyAsync(action, Server, previewSql: null, "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal("DB_CONFIG", captured!.FactKey); + var row = Assert.Single(captured.Targets); + Assert.Contains("AUTO_SHRINK OFF", row.StatusTitle); + Assert.True(captured.AnyActionable); // a DB_CONFIG Ok is actionable + // The fallback preview renders the ALTER statement, not sp_query_store_*. + Assert.Contains("ALTER DATABASE [Foo] SET AUTO_SHRINK OFF;", captured.PreviewSql); + Assert.DoesNotContain("sp_query_store", captured.PreviewSql); + } + + [Fact] + public async Task Apply_DbConfig_AllAlreadyDesired_NotActionable() + { + // Preflight all-AlreadyInDesiredState => AnyActionable false => Apply disabled. + var exec = new FakeExecutor(); // PreflightDbConfigAsync returns Ok by default; + // override via a handler driven by a preflight that's already-desired: + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new DbConfigHandler() }), + _ => new AlreadyDesiredExecutor(), null); + RemediationConfirmRequest? captured = null; + + var action = new RemediationAction("DB_CONFIG", "set", Array.Empty(), + new List { new("Foo", DbConfigSetting.AutoShrinkOff, "OFF") }); + + await service.ApplyAsync(action, Server, previewSql: null, "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, CancellationToken.None); + + Assert.NotNull(captured); + Assert.False(captured!.AnyActionable); + Assert.Equal(RemediationDisposition.AlreadyInDesiredState, Assert.Single(captured.Targets).Disposition); + } + + // ── LOW-2: applied-but-unlogged permanent vs transient ─────────────────────── + + [Fact] + public async Task Apply_AppliedButUnlogged_PermanentClassification_Surfaced() + { + var exec = new FakeExecutor { AuditWriteResult = false }; // force succeeds, audit INSERT fails + var classifierCalls = 0; + var service = BuildService(exec, classifier: (_, _) => { classifierCalls++; return Task.FromResult(AuditWriteFailureKind.Permanent); }); + + var report = await service.ApplyAsync(ForceAction(), Server, "preview", "DOM\\op", "ref", + confirm: _ => Task.FromResult(true), CancellationToken.None); + + var t = Assert.Single(report.Targets); + Assert.True(t.AppliedButUnlogged); + Assert.Equal(AuditWriteFailureKind.Permanent, t.AuditFailureKind); + Assert.Equal(1, classifierCalls); + } + + [Fact] + public async Task Apply_AppliedButUnlogged_TransientClassification_Surfaced() + { + var exec = new FakeExecutor { AuditWriteResult = false }; + var service = BuildService(exec, classifier: (_, _) => Task.FromResult(AuditWriteFailureKind.Transient)); + + var report = await service.ApplyAsync(ForceAction(), Server, "preview", "DOM\\op", "ref", + confirm: _ => Task.FromResult(true), CancellationToken.None); + + var t = Assert.Single(report.Targets); + Assert.True(t.AppliedButUnlogged); + Assert.Equal(AuditWriteFailureKind.Transient, t.AuditFailureKind); + } + + [Fact] + public async Task Apply_Success_AuditWritten_DoesNotClassify() + { + var exec = new FakeExecutor { AuditWriteResult = true }; + var classifierCalls = 0; + var service = BuildService(exec, classifier: (_, _) => { classifierCalls++; return Task.FromResult(AuditWriteFailureKind.Permanent); }); + + var report = await service.ApplyAsync(ForceAction(), Server, "preview", "DOM\\op", "ref", + confirm: _ => Task.FromResult(true), CancellationToken.None); + + var t = Assert.Single(report.Targets); + Assert.False(t.AppliedButUnlogged); + Assert.Equal(AuditWriteFailureKind.None, t.AuditFailureKind); + Assert.Equal(0, classifierCalls); // classifier only runs for an unlogged target + } + + // ── M3 fail-closed server resolution ───────────────────────────────────────── + + private static List Servers(params (string id, string name)[] defs) => + defs.Select(d => new ServerConnection { Id = d.id, ServerName = d.name, DisplayName = d.name }).ToList(); + + [Fact] + public void Resolve_GuidMatch_Resolves() + { + var servers = Servers(("guid-a", "SQL2022"), ("guid-b", "SQL2019")); + var r = RemediationApplyService.ResolveServer("guid-a", "SQL2022", servers); + + Assert.True(r.IsResolved); + Assert.False(r.ResolvedByName); + Assert.Equal("guid-a", r.Server!.Id); + } + + [Fact] + public void Resolve_IntIdFallback_GuidMiss_ResolvesByUniqueName() + { + // The notify-time resolver wrote the finding's int id ("3"); GetServerById misses. + var servers = Servers(("guid-a", "SQL2022"), ("guid-b", "SQL2019")); + var r = RemediationApplyService.ResolveServer("3", "SQL2022", servers); + + Assert.True(r.IsResolved); + Assert.True(r.ResolvedByName); + Assert.Equal("guid-a", r.Server!.Id); + } + + [Fact] + public void Resolve_EmptyServerId_ResolvesByUniqueName() + { + var servers = Servers(("guid-a", "SQL2022")); + var r = RemediationApplyService.ResolveServer("", "SQL2022", servers); + + Assert.True(r.IsResolved); + Assert.True(r.ResolvedByName); + } + + [Fact] + public void Resolve_AmbiguousByName_FailsClosed() + { + var servers = Servers(("guid-a", "SQL2022"), ("guid-b", "SQL2022")); + var r = RemediationApplyService.ResolveServer("3", "SQL2022", servers); + + Assert.False(r.IsResolved); + Assert.Null(r.Server); + Assert.Contains("unambiguously", r.Reason); + } + + [Fact] + public void Resolve_Unresolved_FailsClosed() + { + var servers = Servers(("guid-a", "SQL2022")); + var r = RemediationApplyService.ResolveServer("3", "GhostServer", servers); + + Assert.False(r.IsResolved); + Assert.Null(r.Server); + Assert.False(string.IsNullOrEmpty(r.Reason)); + } + + [Fact] + public void HasHandlerFor_KnownAndUnknown() + { + var service = BuildService(new FakeExecutor()); + Assert.True(service.HasHandlerFor("PLAN_REGRESSION")); + Assert.False(service.HasHandlerFor("PARAMETER_SENSITIVITY")); + Assert.False(service.HasHandlerFor(null)); + } + + // ── AlertDetailWindow view-model gating (CanApply) ─────────────────────────── + + [Fact] + public void CanApply_RequiresKnownFix_ResolvedServer_AndNotBusy() + { + // Known fix + resolved server -> enabled. + var enabled = new AlertDetailWindow.DetailItemView { ShowApply = true }; + enabled.SetServerResolved(true); + Assert.True(enabled.CanApply); + + // Resolved-but-unknown-fix (ShowApply false) -> never enabled. + var noFix = new AlertDetailWindow.DetailItemView { ShowApply = false }; + noFix.SetServerResolved(true); + Assert.False(noFix.CanApply); + + // Known fix but server unresolved (M3) -> hard-disabled. + var unresolved = new AlertDetailWindow.DetailItemView { ShowApply = true }; + unresolved.SetServerResolved(false); + Assert.False(unresolved.CanApply); + + // Mid-run -> disabled. + var busy = new AlertDetailWindow.DetailItemView { ShowApply = true }; + busy.SetServerResolved(true); + busy.BeginRun("Applying…"); + Assert.False(busy.CanApply); + } + + // ── B3 Phase 3: informed-consent request threading (B-1) ───────────────────── + // + // The confirm dialog IS the trust boundary; the service trusts the callback. PR-A + // populates the request: RequiresInformedConsent = handler.IsDestructive and, when + // destructive, the two-sided Risks (FactRiskDisclosure). The XAML acknowledge-each- + // risk RENDERING/gating is PR-B. These tests verify the request is populated + // correctly so the PR-B dialog has what it needs. + + private static RemediationAction RcsiAction() => + new("RCSI", "set", Array.Empty(), + new List { new("Foo", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }); + + private static AnalysisFinding RcsiFinding(int? rwPct = 80) => new() + { + ServerId = 1, ServerName = "SQL2022", Category = "config_issues", + StoryPath = "DB_CONFIG", StoryPathHash = "dbconfig00000099", RootFactKey = "DB_CONFIG", + DrillDown = new Dictionary + { + ["config_issues"] = new List + { + new { database = "Foo", rcsi = false, query_store = true, auto_shrink = false, + auto_close = false, page_verify = "CHECKSUM", issues = new[] { "RCSI OFF" }, + rcsi_blocking_events = 12, rcsi_deadlocks = 3, rcsi_reader_writer_pct = rwPct } + } + } + }; + + [Fact] + public async Task Apply_Destructive_Request_RequiresInformedConsent_CarriesRisks() + { + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new RcsiHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(RcsiAction(), Server, previewSql: "ALTER DATABASE [Foo] SET READ_COMMITTED_SNAPSHOT ON;", + "DOM\\op", "ref", confirm: req => { captured = req; return Task.FromResult(false); }, + CancellationToken.None, finding: RcsiFinding()); + + Assert.NotNull(captured); + Assert.Equal("RCSI", captured!.FactKey); + Assert.True(captured.RequiresInformedConsent); + Assert.NotNull(captured.Risks); + Assert.NotEmpty(captured.Risks!.RisksOfChanging); + Assert.NotEmpty(captured.Risks.RisksOfNotChanging); + // The RCSI confirm row title is the friendly one (m-2), not the raw enum name. + Assert.Contains("Read Committed Snapshot Isolation", Assert.Single(captured.Targets).StatusTitle); + } + + [Fact] + public async Task Apply_NonDestructive_Request_NoConsent_NoRisks() + { + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new DbConfigHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + var action = new RemediationAction("DB_CONFIG", "set", Array.Empty(), + new List { new("Foo", DbConfigSetting.AutoShrinkOff, "ON") }); + + await service.ApplyAsync(action, Server, previewSql: null, "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, CancellationToken.None); + + Assert.NotNull(captured); + Assert.False(captured!.RequiresInformedConsent); + Assert.Null(captured.Risks); + } + + [Fact] + public async Task Apply_Destructive_WriterWriter_Risks_SayRcsiWontResolve() + { + // The honest-both-directions property survives the service threading: a low + // reader/writer pct yields the "RCSI does NOT resolve this" inaction line. + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new RcsiHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(RcsiAction(), Server, previewSql: "preview", "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, + CancellationToken.None, finding: RcsiFinding(rwPct: 8)); + + Assert.Contains(captured!.Risks!.RisksOfNotChanging, r => r.Text.Contains("RCSI does NOT resolve")); + } + + // ── Clear-cached-plan: destructive request threading + reachability (PR-B) ────── + + private static RemediationAction ClearPlanAction() => + new("CLEAR_PLAN", "clear", Array.Empty(), + ClearPlanTargets: new[] { new ClearPlanTarget("AdventureWorks", "0xABCDEF0123456789", 45.0, 9.0, 5.0, "0x06") }, + ClearPlanFigures: new ClearPlanFigures(45.0, 9.0, 5.0, 62, false, false)); + + [Fact] + public async Task Apply_ClearPlan_RequiresInformedConsent_CarriesRisks_AndPerQueryTarget() + { + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new ClearPlanHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(ClearPlanAction(), Server, previewSql: "DBCC FREEPROCCACHE();", + "DOM\\op", "ref", confirm: req => { captured = req; return Task.FromResult(false); }, + CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal("CLEAR_PLAN", captured!.FactKey); + Assert.True(captured.RequiresInformedConsent); + Assert.NotNull(captured.Risks); + Assert.NotEmpty(captured.Risks!.RisksOfChanging); + Assert.NotEmpty(captured.Risks.RisksOfNotChanging); + // The confirm preview carries the per-query target (the per-HANDLE list is resolved + // live at apply); it shows the query hash + the anomaly figures. + var target = Assert.Single(captured.Targets); + Assert.Contains("0xABCDEF0123456789", target.StatusTitle); + Assert.Contains("5.0x", target.StatusTitle); + // Gate refused (confirm == false): the privileged DBCC path was never entered. + Assert.Equal(0, exec.ClearPlanCalls); + } + + [Fact] + public async Task Apply_ClearPlan_ConfirmTrue_ReachesClearProcCache_NotForceOrSetDb() + { + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new ClearPlanHandler() }), _ => exec, null); + + var report = await service.ApplyAsync(ClearPlanAction(), Server, previewSql: "preview", + "DOM\\op", "ref", confirm: _ => Task.FromResult(true), CancellationToken.None); + + Assert.Equal(RemediationRunStatus.Ran, report.Status); + Assert.Equal(1, exec.ClearPlanCalls); // routed to the DBCC executor method + Assert.Equal(0, exec.ForceCalls); // NOT the force-plan path + Assert.Equal(0, exec.SetDbCalls); // NOT the always-safe DB-config path + } + + [Fact] + public async Task Apply_AlwaysSafe_DbConfig_CannotExecute_ClearPlanTarget() + { + // Cross-routing guard: an action that (illegitimately) carries CLEAR_PLAN targets but + // is keyed to the always-safe DB_CONFIG handler must NOT reach the DBCC executor. + // DbConfigHandler iterates ONLY DbConfigTargets — a CLEAR_PLAN payload is inert to it, + // and the registry would never hand a CLEAR_PLAN fact key to DbConfigHandler anyway. + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new DbConfigHandler() }), _ => exec, null); + + var crossAction = new RemediationAction("DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: null, + RcsiFigures: null, + ClearPlanTargets: new[] { new ClearPlanTarget("AdventureWorks", "0xABCDEF0123456789") }); + + await service.ApplyAsync(crossAction, Server, previewSql: "preview", "DOM\\op", "ref", + confirm: _ => Task.FromResult(true), CancellationToken.None); + + Assert.Equal(0, exec.ClearPlanCalls); // the DBCC path is unreachable via DB_CONFIG + } + + // ── HARD gate-enforcement (B-1, MANDATORY): the acknowledge-each-risk predicate ── + // + // The dialog IS the trust boundary; the confirm callback returns true ONLY when the + // gate is satisfied. The dialog's exact enablement predicate is the pure, testable + // RemediationConfirmWindow.ComputeConfirmEnabled. These prove: a destructive request + // keeps Apply DISABLED until ALL risk checkboxes are checked; a subset leaves it + // disabled; un-checking any one re-disables; and the by-name ack combines (BOTH + // required for a destructive by-name target). + + [Fact] + public void Gate_Destructive_Disabled_UntilAllRiskBoxesChecked() + { + // 5 risk boxes (e.g. 3 changing + 2 not-changing). Disabled until ALL are ticked. + for (var checkedCount = 0; checkedCount < 5; checkedCount++) + { + var allChecked = checkedCount == 5; // never true in this loop (0..4) + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: allChecked, + resolvedByName: false, byNameAck: false, riskBoxCount: 5), + $"Apply must stay DISABLED with a subset ({checkedCount}/5) of risk boxes checked."); + } + + // All boxes checked -> enabled (no by-name complication). + Assert.True(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: false, byNameAck: false, riskBoxCount: 5)); + } + + [Fact] + public void Gate_Destructive_UncheckingAnyBox_ReDisables() + { + // Enabled with all checked... + Assert.True(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: false, byNameAck: false, riskBoxCount: 4)); + // ...then un-checking ANY box (allRiskBoxesChecked flips false) re-disables. + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: false, + resolvedByName: false, byNameAck: false, riskBoxCount: 4)); + } + + [Fact] + public void Gate_Destructive_ByName_RequiresBoth_RiskBoxesAndByNameAck() + { + // Risk boxes all checked but by-name NOT acked -> still disabled. + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: true, byNameAck: false, riskBoxCount: 4)); + // By-name acked but a risk box still unchecked -> still disabled. + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: false, + resolvedByName: true, byNameAck: true, riskBoxCount: 4)); + // BOTH satisfied -> enabled. + Assert.True(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: true, byNameAck: true, riskBoxCount: 4)); + } + + [Fact] + public void Gate_NotActionable_NeverEnabled_EvenWithAllConsent() + { + // Audit-absent / nothing-applyable: baseActionable false hard-blocks regardless + // of consent (the consent gate is ADDITIVE to AnyActionable, never a replacement). + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: false, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: false, byNameAck: false, riskBoxCount: 4)); + } + + [Fact] + public void Gate_Destructive_ZeroRiskBoxes_FailsClosed() + { + // FAIL CLOSED (LOW-1): a destructive (requiresConsent) request with NO rendered + // risk boxes must keep Apply DISABLED, even though allRiskBoxesChecked is vacuously + // true (List.TrueForAll on an empty list) and the base apply-ability holds. A + // future destructive handler whose disclosure is empty/null can never enable Apply + // with zero acknowledged checkboxes. + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: false, byNameAck: false, riskBoxCount: 0)); + // Still fails closed even if the (irrelevant) by-name ack is satisfied. + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: true, byNameAck: true, riskBoxCount: 0)); + // One real risk box, all checked -> enabled (the guard only blocks the empty case). + Assert.True(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: true, allRiskBoxesChecked: true, + resolvedByName: false, byNameAck: false, riskBoxCount: 1)); + } + + [Fact] + public void Gate_NonDestructive_Unaffected_ByConsentArm() + { + // A non-destructive (requiresConsent false) request ignores the risk-box arm: + // base actionable + (by-name ack if resolved-by-name) is the whole predicate, + // exactly as before Phase 3 — no regression for force-plan / always-safe. + Assert.True(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: false, allRiskBoxesChecked: false, + resolvedByName: false, byNameAck: false, riskBoxCount: 0)); + Assert.False(RemediationConfirmWindow.ComputeConfirmEnabled( + baseActionable: true, requiresConsent: false, allRiskBoxesChecked: false, + resolvedByName: true, byNameAck: false, riskBoxCount: 0)); + } + + // ── Real figures survive persistence to apply time (CRITICAL correctness) ───── + // + // The UI apply call site passes NO finding; only the persisted RemediationAction + // survives. The RCSI figures must therefore ride the action through the AlertContext + // serialize -> deserialize round-trip, so the dialog shows the REAL blocking numbers, + // not the weak-case baseline. + + [Fact] + public async Task Apply_Destructive_RealFigures_SurvivePersistence_NoFindingAtApplyTime() + { + // 1. Build the action the way AnalysisNotificationService does (finding in hand). + var finding = RcsiFinding(rwPct: 80); + var builtAction = FactRemediation.BuildRcsiAction(finding); + Assert.NotNull(builtAction); + Assert.NotNull(builtAction!.RcsiFigures); + Assert.Equal(12, builtAction.RcsiFigures!.BlockingEvents); + + // 2. Round-trip it through the persisted AlertContext (the only thing that + // survives to the UI apply call site). + var ctx = new AlertContext(); + ctx.Details.Add(new AlertDetailItem { Heading = "Enable RCSI (advanced)", IsCodeBlock = true, Remediation = builtAction }); + Assert.True(AlertContextSerializer.TryDeserialize(AlertContextSerializer.Serialize(ctx), out var round)); + var persistedAction = round.Details[0].Remediation!; + Assert.NotNull(persistedAction.RcsiFigures); + Assert.Equal(12, persistedAction.RcsiFigures!.BlockingEvents); + Assert.Equal(3, persistedAction.RcsiFigures.Deadlocks); + Assert.Equal(80, persistedAction.RcsiFigures.ReaderWriterPct); + + // 3. Apply with the PERSISTED action and NO finding (exactly the UI call site). + // The confirm request's Risks must show the REAL figures, not weak-case. + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new RcsiHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(persistedAction, Server, previewSql: "preview", "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, + CancellationToken.None /* finding defaults to null — the UI apply call site */); + + Assert.NotNull(captured!.Risks); + Assert.Contains(captured.Risks!.RisksOfNotChanging, + r => r.Text.Contains("12") && r.Text.Contains("blocked-process events") && r.Text.Contains("3 deadlocks")); + Assert.Contains(captured.Risks.RisksOfNotChanging, r => r.Text.Contains("80%") && r.Text.Contains("RCSI eliminates")); + // NOT the weak-case baseline (proves the real figures reached the dialog). + Assert.DoesNotContain(captured.Risks.RisksOfNotChanging, r => r.Text.Contains("Little or no reader/writer blocking")); + } + + [Fact] + public void RcsiTargets_OnDbConfigAction_SurviveAlertContextRoundTrip_WithFigures() + { + // The per-db RCSI targets carried on a DB_CONFIG action (for the read-time card fan-out) + // must survive the AlertContext serialize -> deserialize round-trip with their figures + // intact — otherwise the Recommendations reader fans no RCSI cards after persistence. + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + DbConfigTargets: new[] { new DbConfigTarget("Sales", DbConfigSetting.AutoShrinkOff, "ON") }, + RcsiTargets: new[] + { + new RcsiTarget("Sales", new RcsiInactionFigures(40, 2, 85)), + new RcsiTarget("Orders", new RcsiInactionFigures(12, 0, null)) + }); + + var ctx = new AlertContext(); + ctx.Details.Add(new AlertDetailItem { Heading = "DB config", IsCodeBlock = true, Remediation = action }); + Assert.True(AlertContextSerializer.TryDeserialize(AlertContextSerializer.Serialize(ctx), out var round)); + var persisted = round.Details[0].Remediation!; + + // Safe target preserved... + Assert.Single(persisted.DbConfigTargets!); + // ...and the two RCSI targets with their figures. + Assert.Equal(2, persisted.RcsiTargets!.Count); + var sales = Assert.Single(persisted.RcsiTargets, t => t.Database == "Sales"); + Assert.Equal(40, sales.Figures.BlockingEvents); + Assert.Equal(2, sales.Figures.Deadlocks); + Assert.Equal(85, sales.Figures.ReaderWriterPct); + var orders = Assert.Single(persisted.RcsiTargets, t => t.Database == "Orders"); + Assert.Equal(12, orders.Figures.BlockingEvents); + Assert.Null(orders.Figures.ReaderWriterPct); // nullable pct round-trips as null + } + + [Fact] + public async Task Apply_Destructive_NoFiguresNoFinding_ShowsWeakCaseBaseline() + { + // Genuinely no data: an action WITHOUT figures + no finding -> weak-case baseline. + var bareAction = RcsiAction(); // built without RcsiFigures + Assert.Null(bareAction.RcsiFigures); + var exec = new FakeExecutor(); + var service = new RemediationApplyService(serverManager: null!, + new RemediationHandlerRegistry(new IRemediationHandler[] { new RcsiHandler() }), _ => exec, null); + RemediationConfirmRequest? captured = null; + + await service.ApplyAsync(bareAction, Server, previewSql: "preview", "DOM\\op", "ref", + confirm: req => { captured = req; return Task.FromResult(false); }, CancellationToken.None); + + Assert.Contains(captured!.Risks!.RisksOfNotChanging, r => r.Text.Contains("Little or no reader/writer blocking")); + } + + // ── Fake executor (PR-B-local) ─────────────────────────────────────────────── + + private sealed class FakeExecutor : IRemediationExecutor + { + public bool AuditTableExists = true; + public bool PriorForce = true; + public bool AuditWriteResult = true; + + public int ForceCalls; + public int UnforceCalls; + public readonly List AuditRecords = new(); + + public Task PreflightForcePlanAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(new TargetPreflight + { + Database = database, QueryId = queryId, PlanId = planId, + CurrentDatabase = database, HasAlter = true, QueryStoreState = "READ_WRITE", + PlanPresent = true, ExecutingLogin = "PerfMonLogin" + }); + + public Task AuditTableExistsAsync(CancellationToken ct) => Task.FromResult(AuditTableExists); + public Task HasPriorForceAsync(string database, long queryId, long planId, CancellationToken ct) => Task.FromResult(PriorForce); + + public Task ForcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + { + ForceCalls++; + return Task.FromResult(new ForcePlanOutcome + { + Database = database, QueryId = queryId, PlanId = planId, + Status = RemediationStatus.Success, Forced = true, ExecutingLogin = "PerfMonLogin", GateSpid = 55, ExecSpid = 55 + }); + } + + public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + { + UnforceCalls++; + return Task.FromResult(new ForcePlanOutcome + { + Database = database, QueryId = queryId, PlanId = planId, + Status = RemediationStatus.Success, Forced = true, ExecutingLogin = "PerfMonLogin", GateSpid = 55, ExecSpid = 55 + }); + } + + public int SetDbCalls; + + public Task PreflightDbConfigAsync(string database, DbConfigSetting setting, CancellationToken ct) + => Task.FromResult(new DbConfigPreflight + { + Database = database, Setting = setting, DatabaseExists = true, HasAlter = true, + AlreadyInDesiredState = false, ExecutingLogin = "PerfMonLogin", CurrentValue = "ON" + }); + + public Task SetDatabaseOptionAsync(string database, DbConfigSetting setting, RemediationIdentity identity, CancellationToken ct) + { + SetDbCalls++; + return Task.FromResult(new DbConfigOutcome + { + Database = database, Setting = setting, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "PerfMonLogin", PriorValue = "ON", GeneratedSql = "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", + GateSpid = 55, ExecSpid = 55 + }); + } + + public int ClearPlanCalls; + + public Task ClearProcCacheAsync(string queryHash, RemediationIdentity identity, CancellationToken ct) + { + ClearPlanCalls++; + return Task.FromResult(new ClearPlanOutcome + { + QueryHash = queryHash, Status = RemediationStatus.Success, Cleared = true, HandlesCleared = 1, + ExecutingLogin = "PerfMonLogin", Message = "Cleared 1 cached plan(s).", + GeneratedSql = "DBCC FREEPROCCACHE(0xDEADBEEF);", PriorValue = "1 plan(s) cached for this query hash", + GateSpid = 55, ExecSpid = 55 + }); + } + + public int SetFileCalls; + + public Task PreflightFileGrowthAsync(string database, string logicalFileName, int growthMb, CancellationToken ct) + => Task.FromResult(new FileGrowthPreflight + { + Database = database, LogicalFileName = logicalFileName, RecommendedGrowthMb = growthMb, + DatabaseExists = true, FileExists = true, HasAlter = true, AlreadyInDesiredState = false, + ExecutingLogin = "PerfMonLogin", CurrentValue = "percent" + }); + + public Task SetFileGrowthAsync(string database, string logicalFileName, int growthMb, RemediationIdentity identity, CancellationToken ct) + { + SetFileCalls++; + return Task.FromResult(new FileGrowthOutcome + { + Database = database, LogicalFileName = logicalFileName, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "PerfMonLogin", PriorValue = "percent", + GeneratedSql = $"ALTER DATABASE [{database}] MODIFY FILE (NAME = [{logicalFileName}], FILEGROWTH = {growthMb}MB);", + GateSpid = 55, ExecSpid = 55 + }); + } + + public int SetServerConfigCalls; + + public Task PreflightServerConfigAsync(ServerConfigSetting setting, long recommendedValue, CancellationToken ct) + => Task.FromResult(new ServerConfigPreflight + { + Setting = setting, RecommendedValue = recommendedValue, + Executable = setting is ServerConfigSetting.Maxdop or ServerConfigSetting.CostThreshold, + HasPermission = true, AlreadyInDesiredState = false, ExecutingLogin = "PerfMonLogin", CurrentValue = 0 + }); + + public Task SetServerConfigAsync(ServerConfigSetting setting, long value, RemediationIdentity identity, CancellationToken ct) + { + SetServerConfigCalls++; + return Task.FromResult(new ServerConfigOutcome + { + Setting = setting, Status = RemediationStatus.Success, Applied = true, ExecutingLogin = "PerfMonLogin", + PriorValue = 0, GeneratedSql = "EXEC sys.sp_configure N'show advanced options', 1; RECONFIGURE; EXEC sys.sp_configure N'max degree of parallelism', @value; RECONFIGURE;", + GateSpid = 55, ExecSpid = 55 + }); + } + + public Task WriteAuditAsync(RemediationAuditRecord record, CancellationToken ct) + { + AuditRecords.Add(record); + return Task.FromResult(AuditWriteResult); + } + } + + /// Executor whose DB-config preflight always reports already-in-desired-state. + private sealed class AlreadyDesiredExecutor : IRemediationExecutor + { + public Task PreflightForcePlanAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(new TargetPreflight { Database = database }); + public Task AuditTableExistsAsync(CancellationToken ct) => Task.FromResult(true); + public Task HasPriorForceAsync(string database, long queryId, long planId, CancellationToken ct) => Task.FromResult(false); + public Task ForcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database }); + public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database }); + public Task PreflightDbConfigAsync(string database, DbConfigSetting setting, CancellationToken ct) + => Task.FromResult(new DbConfigPreflight + { + Database = database, Setting = setting, DatabaseExists = true, HasAlter = true, + AlreadyInDesiredState = true, ExecutingLogin = "PerfMonLogin", CurrentValue = "OFF" + }); + public Task SetDatabaseOptionAsync(string database, DbConfigSetting setting, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new DbConfigOutcome { Database = database, Setting = setting, Status = RemediationStatus.Skipped }); + public Task ClearProcCacheAsync(string queryHash, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ClearPlanOutcome { QueryHash = queryHash, Status = RemediationStatus.Skipped }); + public Task PreflightFileGrowthAsync(string database, string logicalFileName, int growthMb, CancellationToken ct) + => Task.FromResult(new FileGrowthPreflight + { + Database = database, LogicalFileName = logicalFileName, RecommendedGrowthMb = growthMb, + DatabaseExists = true, FileExists = true, HasAlter = true, AlreadyInDesiredState = true, + ExecutingLogin = "PerfMonLogin", CurrentValue = "percent" + }); + public Task SetFileGrowthAsync(string database, string logicalFileName, int growthMb, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new FileGrowthOutcome { Database = database, LogicalFileName = logicalFileName, Status = RemediationStatus.Skipped }); + public Task PreflightServerConfigAsync(ServerConfigSetting setting, long recommendedValue, CancellationToken ct) + => Task.FromResult(new ServerConfigPreflight + { + Setting = setting, RecommendedValue = recommendedValue, + Executable = setting is ServerConfigSetting.Maxdop or ServerConfigSetting.CostThreshold, + HasPermission = true, AlreadyInDesiredState = true, ExecutingLogin = "PerfMonLogin", CurrentValue = recommendedValue + }); + public Task SetServerConfigAsync(ServerConfigSetting setting, long value, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ServerConfigOutcome { Setting = setting, Status = RemediationStatus.Skipped }); + public Task WriteAuditAsync(RemediationAuditRecord record, CancellationToken ct) => Task.FromResult(true); + } +} diff --git a/Dashboard.Tests/RemediationContractTests.cs b/Dashboard.Tests/RemediationContractTests.cs new file mode 100644 index 00000000..05071cb2 --- /dev/null +++ b/Dashboard.Tests/RemediationContractTests.cs @@ -0,0 +1,71 @@ +using System.Linq; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Contract between the remediation BUILDERS (FactRemediation.Build*Action and the recommendations +/// reader) and the registered HANDLERS. Every Apply-able RemediationAction carries a FactKey, and +/// RemediationApplyService dispatches on it; a FactKey with no registered handler makes Apply +/// silently no-op — the "shipped half-built" failure class. These tests fail loudly if the handler +/// set drifts from the agreed Apply-able key set, forcing a conscious update whenever a builder or +/// handler is added or removed. +/// +/// Scope note: this locks the HANDLER side (registry integrity + the documented Apply-able set). +/// The complementary dynamic check — feeding real detector drill-down output through each builder +/// and asserting it still yields a non-null, dispatchable action (the autogrowth-CTE-drift class) — +/// lives in (the captured-fixture golden-sample). +/// +public class RemediationContractTests +{ + // The agreed Apply-able fact keys. Each is produced by a FactRemediation builder or the + // recommendations reader: PLAN_REGRESSION, DB_CONFIG, FILE_AUTOGROWTH_PERCENT and SERVER_CONFIG + // are always-safe; RCSI and CLEAR_PLAN are destructive (behind the informed-consent gate). + // Adding or removing a builder/handler? Update this list deliberately — that is the contract. + private static readonly string[] ApplyableFactKeys = + { + "PLAN_REGRESSION", + "DB_CONFIG", + "FILE_AUTOGROWTH_PERCENT", + "SERVER_CONFIG", + "RCSI", + "CLEAR_PLAN", + }; + + [Fact] + public void DefaultHandlers_HaveDistinctNonEmptyFactKeys() + { + var keys = RemediationApplyService.CreateDefaultHandlers().Select(h => h.FactKey).ToList(); + + Assert.DoesNotContain(keys, string.IsNullOrWhiteSpace); + Assert.Equal(keys.Count, keys.Distinct().Count()); // no two handlers claim the same key + } + + [Fact] + public void HandlerFactKeys_ExactlyMatch_ApplyableContract() + { + var handlerKeys = RemediationApplyService.CreateDefaultHandlers() + .Select(h => h.FactKey) + .ToHashSet(); + + var orphanBuilderKeys = ApplyableFactKeys.Except(handlerKeys).ToList(); // builder key, no handler -> silent no-op + var deadHandlerKeys = handlerKeys.Except(ApplyableFactKeys).ToList(); // handler, no producing builder -> dead + + Assert.True(orphanBuilderKeys.Count == 0, + $"Apply-able fact key(s) with NO registered handler (Apply would silently no-op): {string.Join(", ", orphanBuilderKeys)}"); + Assert.True(deadHandlerKeys.Count == 0, + $"Registered handler(s) with no Apply-able builder key (dead handler / contract drift): {string.Join(", ", deadHandlerKeys)}"); + } + + [Fact] + public void EveryApplyableFactKey_ResolvesThroughTheRegistry() + { + // The registry is the actual dispatch point used by RunAsync; prove each contract key + // resolves through it exactly as production constructs it. + var registry = new RemediationHandlerRegistry(RemediationApplyService.CreateDefaultHandlers()); + + foreach (var key in ApplyableFactKeys) + Assert.True(registry.TryGet(key) is not null, $"No handler resolves for Apply-able fact key '{key}'."); + } +} diff --git a/Dashboard.Tests/RemediationGoldenSampleTests.cs b/Dashboard.Tests/RemediationGoldenSampleTests.cs new file mode 100644 index 00000000..c8c7bc18 --- /dev/null +++ b/Dashboard.Tests/RemediationGoldenSampleTests.cs @@ -0,0 +1,320 @@ +/* + * 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; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// The DYNAMIC golden-sample half of the remediation contract — the follow-up named in +/// 's scope note. It feeds a representative detector +/// drill-down — shaped to match what the drill-down COLLECTORS actually emit +/// (SqlServerDrillDownCollector on Dashboard / DrillDownCollector on Lite, with the +/// shared extractors reading it) — through each builder in the EXACT +/// order AnalysisService.AnalyzeAsync applies them, and asserts the builder yields a +/// non-null action, carrying its target list, whose FactKey resolves through the production +/// handler registry. +/// +/// +/// This is the "autogrowth-CTE-drift" guard at the dynamic level: if a builder stops reading a +/// drill-down key the detector emits (a rename, a typo, an extractor refactor), the action comes +/// back null or target-less and the matching test fails loudly — instead of Apply silently +/// no-opping in production. locks the STATIC +/// handler<->key set; this locks that real drill-down still flows through to a DISPATCHABLE +/// action, and that every registered Apply-able key has a fixture proving it. +/// +/// +/// +/// Residual limitation (documented, not silently accepted): the fixtures are captured from the +/// collector emit at authoring time (each cites its emit site). They catch BUILDER-side drift +/// immediately; a future COLLECTOR-side key rename is caught only when these fixtures are +/// regenerated against the new emit — the fixtures ARE the contract, so keep them in sync with the +/// cited emit sites. (A drill-down key-name constant set shared by collector and builder would make +/// this automatic; that is a larger refactor, out of scope here.) +/// +/// +public class RemediationGoldenSampleTests +{ + // The production dispatch point (RemediationApplyService builds the v1 registry from this). + private static readonly RemediationHandlerRegistry Registry = + new(RemediationApplyService.CreateDefaultHandlers()); + + /// + /// A built action is "dispatchable" when it is non-null, carries the expected fact key, and + /// that key resolves through the production handler registry — i.e. Apply will route to a real + /// handler instead of silently no-opping (RemediationRunStatus.NoHandler). + /// + private static void AssertDispatchable(RemediationAction? action, string expectedFactKey) + { + Assert.NotNull(action); + Assert.Equal(expectedFactKey, action!.FactKey); + Assert.NotNull(Registry.TryGet(action.FactKey)); + } + + // ── PLAN_REGRESSION → BuildAction → FactKey "PLAN_REGRESSION" (ForcePlanHandler) ────────────── + // Drill-down: SqlServerDrillDownCollector "regressed_queries"; read by ExtractPlanRegressionTargets + // (database / query_id / best_plan_id / *_plan_hash / *_cpu_per_exec_us / regression_factor). + private static AnalysisFinding PlanRegression() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "plan_regression", + StoryPath = "PLAN_REGRESSION", StoryPathHash = "golden_planreg01", + RootFactKey = "PLAN_REGRESSION", + DrillDown = new Dictionary + { + ["regressed_queries"] = new List + { + new + { + database = "AdventureWorks", query_id = 4242L, best_plan_id = 17L, + latest_plan_hash = "0xDEAD", best_plan_hash = "0xBEEF", + latest_cpu_per_exec_us = 9000.0, best_cpu_per_exec_us = 1200.0, + regression_factor = 7.5 + } + } + } + }; + + [Fact] + public void PlanRegression_DrillDown_BuildsDispatchableForceAction() + { + var action = FactRemediation.BuildAction(PlanRegression()); + AssertDispatchable(action, "PLAN_REGRESSION"); + Assert.NotEmpty(action!.Targets); + } + + // ── DB_CONFIG → BuildAction → FactKey "DB_CONFIG" (DbConfigHandler) ──────────────────────────── + // Drill-down: SqlServerDrillDownCollector "config_issues"; read by ExtractDbConfigTargets + // (database / auto_shrink / auto_close / page_verify — the structured typed fields, never the + // human "issues" strings). + private static AnalysisFinding DbConfig() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "config_issues", + StoryPath = "DB_CONFIG", StoryPathHash = "golden_dbconfig1", + RootFactKey = "DB_CONFIG", + DrillDown = new Dictionary + { + ["config_issues"] = new List + { + new + { + database = "Foo", recovery_model = "FULL", rcsi = true, query_store = true, + issues = new[] { "auto_shrink ON" }, + auto_shrink = true, auto_close = false, page_verify = "CHECKSUM" + } + } + } + }; + + [Fact] + public void DbConfig_DrillDown_BuildsDispatchableSetAction() + { + var action = FactRemediation.BuildAction(DbConfig()); + AssertDispatchable(action, "DB_CONFIG"); + Assert.NotNull(action!.DbConfigTargets); + Assert.NotEmpty(action.DbConfigTargets!); + } + + // ── RCSI → BuildRcsiAction → FactKey "RCSI" (RcsiHandler, destructive) ───────────────────────── + // Drill-down: SqlServerDrillDownCollector "config_issues" with rcsi == false + the §3.3 + // enrichment (rcsi_blocking_events / rcsi_deadlocks / rcsi_reader_writer_pct) and a + // reader/writer share above FactRiskDisclosure.ReaderWriterMeaningfulPct; read by CollectRcsiTargets. + private static AnalysisFinding RcsiOff() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "config_issues", + StoryPath = "DB_CONFIG", StoryPathHash = "golden_rcsioff01", + RootFactKey = "DB_CONFIG", + DrillDown = new Dictionary + { + ["config_issues"] = new List + { + new + { + database = "Foo", recovery_model = "FULL", rcsi = false, query_store = true, + issues = new[] { "RCSI OFF" }, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + rcsi_blocking_events = 12, rcsi_deadlocks = 3, rcsi_reader_writer_pct = 80 + } + } + } + }; + + [Fact] + public void Rcsi_DrillDown_BuildsDispatchableRcsiAction() + { + var action = FactRemediation.BuildRcsiAction(RcsiOff()); + AssertDispatchable(action, "RCSI"); + Assert.NotNull(action!.DbConfigTargets); + Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, action.DbConfigTargets!.Single().Setting); + } + + // ── CLEAR_PLAN → BuildClearPlanAction → FactKey "CLEAR_PLAN" (ClearPlanHandler, destructive) ──── + // Drill-down: SqlServerDrillDownCollector "abnormal_cpu_plans" on a CPU finding; read by + // BuildClearPlanAction (query_hash 0x… / database / current+baseline_cpu_per_exec_ms / + // anomaly_ratio / cpu_percent / *_cofired / latest_plan_handle). + private static AnalysisFinding ClearPlan() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "cpu", + StoryPath = "CPU_SQL_PERCENT", StoryPathHash = "golden_clearpln1", + RootFactKey = "CPU_SQL_PERCENT", + DrillDown = new Dictionary + { + ["abnormal_cpu_plans"] = new List + { + new + { + query_hash = "0x1A2B3C4D5E6F7080", database = "Foo", + current_cpu_per_exec_ms = 500.0, baseline_cpu_per_exec_ms = 50.0, + anomaly_ratio = 10.0, cpu_percent = 40, + latest_plan_handle = "0x06000100ABCDEF", + plan_regression_cofired = false, parameter_sensitivity_cofired = false + } + } + } + }; + + [Fact] + public void ClearPlan_DrillDown_BuildsDispatchableClearAction() + { + var action = FactRemediation.BuildClearPlanAction(ClearPlan()); + AssertDispatchable(action, "CLEAR_PLAN"); + Assert.NotNull(action!.ClearPlanTargets); + Assert.NotEmpty(action.ClearPlanTargets!); + } + + // ── FILE_AUTOGROWTH_PERCENT → BuildFileAutogrowthAction → FactKey "FILE_AUTOGROWTH_PERCENT" + // (FileAutogrowthHandler) ───────────────────────────────────────────────────────────────── + // Drill-down: SqlServerDrillDownCollector "autogrowth_percent_files"; read by + // ExtractFileGrowthTargets (database / logical_file_name / total_size_mb / growth_pct / file_type). + // This is the canonical "CTE-drift" sentinel: if the collector's CTE column → drill-down key + // mapping drifts from these names, the builder yields no targets and this fails. + private static AnalysisFinding FileAutogrowth() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "config_issues", + StoryPath = "FILE_AUTOGROWTH_PERCENT", StoryPathHash = "golden_autogro01", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + DrillDown = new Dictionary + { + ["autogrowth_percent_files"] = new List + { + new + { + database = "Foo", logical_file_name = "Foo_data", file_type = "ROWS", + total_size_mb = 51200.0, growth_pct = 10 + } + } + } + }; + + [Fact] + public void FileAutogrowth_DrillDown_BuildsDispatchableSetAction() + { + var action = FactRemediation.BuildFileAutogrowthAction(FileAutogrowth()); + AssertDispatchable(action, "FILE_AUTOGROWTH_PERCENT"); + Assert.NotNull(action!.FileGrowthTargets); + Assert.NotEmpty(action.FileGrowthTargets!); + } + + // ── SERVER_CONFIG → BuildServerConfigAction → FactKey "SERVER_CONFIG" (ServerConfigHandler) ───── + // Drill-down: SqlServerDrillDownCollector "server_config" on a CONFIG_* finding; read by + // ExtractServerConfigTargets (setting maxdop/ctfp/max_memory/min_memory / current_value / + // edition / cores_per_socket). + private static AnalysisFinding ServerConfig() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "config", + StoryPath = "SERVER_CONFIG → CONFIG_MAXDOP", StoryPathHash = "golden_srvconf01", + RootFactKey = "CONFIG_MAXDOP", + DrillDown = new Dictionary + { + ["server_config"] = new List + { + new { setting = "maxdop", current_value = 0L, edition = 3, cores_per_socket = 16 } + } + } + }; + + [Fact] + public void ServerConfig_DrillDown_BuildsDispatchableSetAction() + { + var action = FactRemediation.BuildServerConfigAction(ServerConfig()); + AssertDispatchable(action, "SERVER_CONFIG"); + Assert.NotNull(action!.ServerConfigTargets); + Assert.Equal(ServerConfigSetting.Maxdop, action.ServerConfigTargets!.Single().Setting); + } + + // ── MISSING_INDEX → BuildMissingIndexAction → FactKey "MISSING_INDEX" (WS4; COPY-ONLY) ───────── + // The drill-down → builder drift guard for the one COPY-PASTE-ONLY advisory: a non-null action + // carrying its CREATE-statement targets, but deliberately NOT registry-dispatchable — creating an + // index is a judgement call, so there is no handler and no Apply (the reader renders the CREATE as + // copy-paste and leaves the card's Remediation null). Drill-down: SqlServerDrillDownCollector / + // Lite DrillDownCollector "missing_indexes" ({ table, impact, create_statement }). + private static AnalysisFinding MissingIndex() => new() + { + ServerId = 1, ServerName = "GoldenServer", Category = "missing_index", + StoryPath = "MISSING_INDEX", StoryPathHash = "golden_missidx01", + RootFactKey = "MISSING_INDEX", + DrillDown = new Dictionary + { + ["missing_indexes"] = new List + { + new + { + table = "dbo.Orders", impact = 87.3, + create_statement = "CREATE NONCLUSTERED INDEX [ix_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]);" + } + } + } + }; + + [Fact] + public void MissingIndex_DrillDown_BuildsCopyOnlyAdvisoryAction_NotDispatchable() + { + var action = FactRemediation.BuildMissingIndexAction(MissingIndex()); + Assert.NotNull(action); + Assert.Equal("MISSING_INDEX", action!.FactKey); + Assert.NotNull(action.MissingIndexTargets); + Assert.NotEmpty(action.MissingIndexTargets!); + // Copy-only: NO handler resolves it — the reader surfaces copy-paste and shows no Apply. + Assert.Null(Registry.TryGet(action.FactKey)); + } + + // ── Completeness: every registered Apply-able key has a golden fixture ───────────────────────── + [Fact] + public void EveryRegisteredHandlerKey_IsExercisedByAGoldenFixture() + { + // Every key the production registry dispatches must have a golden fixture proving real + // drill-down still flows through to a dispatchable action. A new Apply-able handler with no + // golden fixture here fails loudly — the dynamic complement to RemediationContractTests' + // static handler<->key check, which forces a fixture whenever the Apply-able surface grows. + var handlerKeys = RemediationApplyService.CreateDefaultHandlers() + .Select(h => h.FactKey) + .ToHashSet(); + + // Each builder is invoked exactly as AnalysisService.AnalyzeAsync orders the chain. + var produced = new[] + { + FactRemediation.BuildAction(PlanRegression()), + FactRemediation.BuildAction(DbConfig()), + FactRemediation.BuildRcsiAction(RcsiOff()), + FactRemediation.BuildClearPlanAction(ClearPlan()), + FactRemediation.BuildFileAutogrowthAction(FileAutogrowth()), + FactRemediation.BuildServerConfigAction(ServerConfig()), + } + .Where(a => a is not null) + .Select(a => a!.FactKey) + .ToHashSet(); + + var uncovered = handlerKeys.Except(produced).ToList(); + Assert.True(uncovered.Count == 0, + "Registered Apply-able handler key(s) with NO golden-sample fixture producing a " + + $"dispatchable action: {string.Join(", ", uncovered)}"); + } +} diff --git a/Dashboard.Tests/RemediationTests.cs b/Dashboard.Tests/RemediationTests.cs new file mode 100644 index 00000000..e80dfad1 --- /dev/null +++ b/Dashboard.Tests/RemediationTests.cs @@ -0,0 +1,2070 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// PR-A coverage for the privileged "Apply Fix" core (no UI): the structured +/// extractor + render-stability, the self-gating handler against a faked executor +/// (fail-closed permission, audit-table hard block, freshness/skip dispositions, +/// per-target independence, gate re-derivation, applied-but-unlogged), un-apply +/// restriction, the registry, and the "no caller reaches the executor" guard. +/// The single-connection (R2-MOD-1) guarantee is an executor-level/real-server +/// concern (SPID equality) and cannot be proven against a faked executor. +/// +public class RemediationTests +{ + private static AnalysisFinding PlanRegressionFinding(List? rows = null) => new() + { + ServerId = 1, + ServerName = "TestServer", + Category = "plan_regression", + StoryPath = "PLAN_REGRESSION", + StoryPathHash = "planreg000000001", + RootFactKey = "PLAN_REGRESSION", + DrillDown = new Dictionary + { + ["regressed_queries"] = rows ?? new List + { + new + { + database = "AdventureWorks", + query_id = 4242L, + best_plan_id = 17L, + latest_plan_hash = "0xDEAD", + best_plan_hash = "0xBEEF", + latest_cpu_per_exec_us = 9000.0, + best_cpu_per_exec_us = 1200.0, + regression_factor = 7.5 + } + } + } + }; + + private static RemediationAction ForceAction(params ForcePlanTarget[] targets) => + new("PLAN_REGRESSION", "force", targets.ToList()); + + private static ForcePlanTarget Target(string db = "AdventureWorks", long q = 4242, long p = 17) => + new(db, q, p); + + private static readonly RemediationIdentity Identity = + new("TESTDOMAIN\\tester", "Analysis: plan_regression [abcd1234]"); + + [Fact] + public void Extract_SkipsRowsFailingGuards() + { + var finding = PlanRegressionFinding(new List + { + new { database = "", query_id = 1L, best_plan_id = 1L }, + new { database = "Db", query_id = 0L, best_plan_id = 1L }, + new { database = "Db", query_id = 1L, best_plan_id = 0L }, + new { database = "Good", query_id = 5L, best_plan_id = 9L } + }); + + var targets = FactRemediation.ExtractPlanRegressionTargets(finding); + + var only = Assert.Single(targets); + Assert.Equal("Good", only.Database); + Assert.Equal(5, only.QueryId); + Assert.Equal(9, only.PlanId); + } + + [Fact] + public void Extract_CapsAtFiveTargets() + { + var rows = Enumerable.Range(1, 8) + .Select(i => (object)new { database = $"Db{i}", query_id = (long)i, best_plan_id = (long)(i + 100) }) + .ToList(); + + var targets = FactRemediation.ExtractPlanRegressionTargets(PlanRegressionFinding(rows)); + + Assert.Equal(5, targets.Count); + Assert.Equal("Db1", targets[0].Database); + Assert.Equal("Db5", targets[4].Database); + } + + [Fact] + public void BuildAction_PlanRegression_ProducesForceActionWithTargets() + { + var action = FactRemediation.BuildAction(PlanRegressionFinding()); + + Assert.NotNull(action); + Assert.Equal("PLAN_REGRESSION", action!.FactKey); + Assert.Equal("force", action.Action); + var t = Assert.Single(action.Targets); + Assert.Equal("AdventureWorks", t.Database); + Assert.Equal(4242, t.QueryId); + Assert.Equal(17, t.PlanId); + Assert.Equal(7.5, t.RegressionFactor); + } + + [Fact] + public void BuildAction_ParameterSensitivity_ReturnsNull() + { + var finding = PlanRegressionFinding(); + finding.RootFactKey = "PARAMETER_SENSITIVITY"; + Assert.Null(FactRemediation.BuildAction(finding)); + } + + [Fact] + public void BuildAction_UnknownKeyOrNoTargets_ReturnsNull() + { + var unknown = PlanRegressionFinding(); + unknown.RootFactKey = "ZZZ_TEST"; + Assert.Null(FactRemediation.BuildAction(unknown)); + + Assert.Null(FactRemediation.BuildAction(PlanRegressionFinding(new List()))); + } + + [Fact] + public void GenerateForFinding_RenderStability_ByteForByte() + { + const string expected = + "-- Database: AdventureWorks\n" + + "-- query_id = 4242, forcing plan_id = 17\n" + + "-- latest plan hash: 0xDEAD (cpu/exec 9000 us)\n" + + "-- best plan hash: 0xBEEF (cpu/exec 1200 us)\n" + + "-- regression factor: 7.5x\n" + + "USE [AdventureWorks];\n" + + "EXEC sys.sp_query_store_force_plan @query_id = 4242, @plan_id = 17;\n" + + "\n" + + "-- To back out:\n" + + "-- EXEC sys.sp_query_store_unforce_plan @query_id = 4242, @plan_id = 17;"; + + var actual = FactRemediation.GenerateForFinding(PlanRegressionFinding()); + + Assert.NotNull(actual); + Assert.Equal(expected, actual!.Replace("\r\n", "\n")); + } + + [Fact] + public void Registry_ResolvesForcePlanHandler_AndMissesUnknown() + { + var registry = new RemediationHandlerRegistry(new IRemediationHandler[] { new ForcePlanHandler() }); + + Assert.IsType(registry.TryGet("PLAN_REGRESSION")); + Assert.Null(registry.TryGet("PARAMETER_SENSITIVITY")); + Assert.Null(registry.TryGet(null)); + } + + [Fact] + public async Task Apply_Success_ForcesAndWritesSuccessAudit() + { + var exec = new FakeExecutor(); + var result = await new ForcePlanHandler().ApplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + var outcome = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, outcome.Status); + Assert.True(outcome.AuditWritten); + Assert.False(outcome.AppliedButUnlogged); + Assert.Equal(1, exec.ForceCalls); + + var row = Assert.Single(exec.AuditRecords); + Assert.Equal("force", row.Action); + Assert.Equal("success", row.Result); + Assert.Equal("TESTDOMAIN\\tester", row.OperatorIdentity); + Assert.Contains("sp_query_store_force_plan", row.GeneratedSql); + } + + [Fact] + public async Task Apply_HasAlterZero_FailsClosed_NoElevation_AuditSkipped() + { + var exec = new FakeExecutor + { + ForceFunc = (db, q, p) => new ForcePlanOutcome + { + Database = db, QueryId = q, PlanId = p, + Status = RemediationStatus.PermissionDenied, Forced = false, Message = "lacks ALTER" + } + }; + + var result = await new ForcePlanHandler().ApplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + var outcome = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.PermissionDenied, outcome.Status); + Assert.False(outcome.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + Assert.Equal(1, exec.ForceCalls); + } + + [Fact] + public async Task Apply_AuditTableAbsent_HardBlocks_NoMutation_NoAuditNoUnloggedWarning() + { + var exec = new FakeExecutor { AuditTableExists = false }; + + var result = await new ForcePlanHandler().ApplyAsync( + ForceAction(Target(), Target("Other", 9, 9)), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.False(o.AppliedButUnlogged); + Assert.Contains("3.0.0", o.Message); + }); + Assert.Equal(0, exec.ForceCalls); + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task Apply_Skipped_IsNotForced_AndAuditedSkipped() + { + var exec = new FakeExecutor + { + ForceFunc = (db, q, p) => new ForcePlanOutcome + { + Database = db, QueryId = q, PlanId = p, Status = RemediationStatus.Skipped, Forced = false, Message = "already forced" + } + }; + + var result = await new ForcePlanHandler().ApplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + var outcome = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Skipped, outcome.Status); + Assert.False(outcome.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + } + + [Fact] + public async Task Apply_GateChangesAfterPreflight_AbortsTarget() + { + var exec = new FakeExecutor + { + PreflightFunc = (db, q, p) => new TargetPreflight + { + Database = db, QueryId = q, PlanId = p, + CurrentDatabase = db, HasAlter = true, QueryStoreState = "READ_WRITE", + PlanPresent = true, IsForcedPlan = false, ForceFailureCount = 0 + }, + ForceFunc = (db, q, p) => new ForcePlanOutcome + { + Database = db, QueryId = q, PlanId = p, Status = RemediationStatus.Skipped, Forced = false, Message = "plan vanished" + } + }; + + var handler = new ForcePlanHandler(); + var pre = await handler.PreflightAsync(ForceAction(Target()), exec, CancellationToken.None); + Assert.Equal(RemediationDisposition.Ok, pre.Targets.Single().Disposition); + + var result = await handler.ApplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + Assert.NotEqual(RemediationStatus.Success, result.Outcomes.Single().Status); + } + + [Fact] + public async Task Apply_OneTargetThrows_OthersStillRun() + { + var exec = new FakeExecutor + { + ForceFunc = (db, q, p) => + { + if (q == 1) throw new InvalidOperationException("boom"); + return new ForcePlanOutcome { Database = db, QueryId = q, PlanId = p, Status = RemediationStatus.Success, Forced = true }; + } + }; + + var result = await new ForcePlanHandler().ApplyAsync( + ForceAction(Target("A", 1, 1), Target("B", 2, 2)), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.Equal(RemediationStatus.Error, result.Outcomes[0].Status); + Assert.Equal(RemediationStatus.Success, result.Outcomes[1].Status); + } + + [Fact] + public async Task Apply_ForceSucceedsButAuditWriteFails_FlagsAppliedButUnlogged() + { + var exec = new FakeExecutor { AuditWriteResult = false }; + + var result = await new ForcePlanHandler().ApplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + var outcome = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, outcome.Status); + Assert.False(outcome.AuditWritten); + Assert.True(outcome.AppliedButUnlogged); + } + + [Theory] + [InlineData("Other", true, "READ_WRITE", true, false, 0, RemediationDisposition.BlockWrongDatabase)] + [InlineData("AdventureWorks", false, "READ_WRITE", true, false, 0, RemediationDisposition.BlockNoAlter)] + [InlineData("AdventureWorks", true, "READ_ONLY", true, false, 0, RemediationDisposition.BlockQueryStoreOff)] + [InlineData("AdventureWorks", true, "READ_WRITE", false, false, 0, RemediationDisposition.BlockStale)] + [InlineData("AdventureWorks", true, "READ_WRITE", true, true, 0, RemediationDisposition.AlreadyForced)] + [InlineData("AdventureWorks", true, "READ_WRITE", true, false, 3, RemediationDisposition.WarnFailing)] + [InlineData("AdventureWorks", true, "READ_WRITE", true, false, 0, RemediationDisposition.Ok)] + public async Task Preflight_ClassifiesDisposition( + string currentDb, bool hasAlter, string qsState, bool planPresent, bool isForced, long failCount, RemediationDisposition expected) + { + var exec = new FakeExecutor + { + PreflightFunc = (db, q, p) => new TargetPreflight + { + Database = db, QueryId = q, PlanId = p, + CurrentDatabase = currentDb, HasAlter = hasAlter, QueryStoreState = qsState, + PlanPresent = planPresent, IsForcedPlan = isForced, ForceFailureCount = failCount + } + }; + + var pre = await new ForcePlanHandler().PreflightAsync(ForceAction(Target()), exec, CancellationToken.None); + Assert.Equal(expected, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task Preflight_AuditTableAbsent_OverridesPerTargetDisposition() + { + var exec = new FakeExecutor + { + AuditTableExists = false, + PreflightFunc = (db, q, p) => new TargetPreflight + { + Database = db, QueryId = q, PlanId = p, + CurrentDatabase = db, HasAlter = true, QueryStoreState = "READ_WRITE", PlanPresent = true + } + }; + + var pre = await new ForcePlanHandler().PreflightAsync(ForceAction(Target()), exec, CancellationToken.None); + Assert.False(pre.AuditTableExists); + Assert.Equal(RemediationDisposition.BlockAuditTableAbsent, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task Unapply_NoPriorForce_Skips_NoUnforce() + { + var exec = new FakeExecutor { PriorForce = false }; + + var result = await new ForcePlanHandler().UnapplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + Assert.Equal(RemediationStatus.Skipped, result.Outcomes.Single().Status); + Assert.Equal(0, exec.UnforceCalls); + } + + [Fact] + public async Task Unapply_WithPriorForce_Unforces_AuditsUnforce() + { + var exec = new FakeExecutor { PriorForce = true }; + + var result = await new ForcePlanHandler().UnapplyAsync(ForceAction(Target()), exec, Identity, CancellationToken.None); + + Assert.Equal(RemediationStatus.Success, result.Outcomes.Single().Status); + Assert.Equal(1, exec.UnforceCalls); + Assert.Equal("unforce", Assert.Single(exec.AuditRecords).Action); + } + + // ════════════════════════════════════════════════════════════════════════════ + // B3 Phase 2 — always-safe DB-config fixes (DB_CONFIG) + // ════════════════════════════════════════════════════════════════════════════ + + private static AnalysisFinding DbConfigFinding(List? rows = null) => new() + { + ServerId = 1, + ServerName = "TestServer", + Category = "config_issues", + StoryPath = "DB_CONFIG", + StoryPathHash = "dbconfig00000001", + RootFactKey = "DB_CONFIG", + DrillDown = new Dictionary + { + ["config_issues"] = rows ?? new List + { + new { database = "Foo", recovery_model = "FULL", rcsi = true, query_store = true, + issues = new[] { "auto_shrink ON", "auto_close ON" }, + auto_shrink = true, auto_close = true, page_verify = "CHECKSUM" } + } + } + }; + + private static RemediationAction DbConfigAction(params DbConfigTarget[] targets) => + new("DB_CONFIG", "set", Array.Empty(), targets.ToList()); + + // ── Extractor: only the three safe settings, NEVER RCSI ────────────────────── + + [Fact] + public void ExtractDbConfig_EmitsOnlySafeSettings_NeverRcsi() + { + var finding = DbConfigFinding(new List + { + new { database = "Db1", rcsi = false, query_store = false, + auto_shrink = true, auto_close = true, page_verify = "NONE", + issues = new[] { "auto_shrink ON", "auto_close ON", "RCSI OFF", "page_verify=NONE" } } + }); + + var targets = FactRemediation.ExtractDbConfigTargets(finding); + + // 3 safe targets (shrink, close, page_verify) — RCSI is NEVER emitted. + Assert.Equal(3, targets.Count); + Assert.Contains(targets, t => t.Setting == DbConfigSetting.AutoShrinkOff && t.CurrentValue == "ON"); + Assert.Contains(targets, t => t.Setting == DbConfigSetting.AutoCloseOff && t.CurrentValue == "ON"); + Assert.Contains(targets, t => t.Setting == DbConfigSetting.PageVerifyChecksum && t.CurrentValue == "NONE"); + // No enum value for RCSI exists, but assert defensively none slipped through as page_verify. + Assert.All(targets, t => Assert.True(Enum.IsDefined(typeof(DbConfigSetting), t.Setting))); + } + + [Fact] + public void ExtractDbConfig_PageVerifyChecksum_IsNotEmitted() + { + var finding = DbConfigFinding(new List + { + new { database = "Db1", rcsi = true, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new string[0] } + }); + + Assert.Empty(FactRemediation.ExtractDbConfigTargets(finding)); + } + + [Fact] + public void ExtractDbConfig_EmptyDatabase_IsSkipped() + { + var finding = DbConfigFinding(new List + { + new { database = "", rcsi = true, query_store = true, auto_shrink = true, auto_close = false, page_verify = "CHECKSUM", issues = new string[0] } + }); + + Assert.Empty(FactRemediation.ExtractDbConfigTargets(finding)); + } + + [Fact] + public void ExtractDbConfig_MultipleDatabases_MultipleTargets() + { + var finding = DbConfigFinding(new List + { + new { database = "A", rcsi = true, query_store = true, auto_shrink = true, auto_close = false, page_verify = "CHECKSUM", issues = new string[0] }, + new { database = "B", rcsi = true, query_store = true, auto_shrink = false, auto_close = false, page_verify = "TORN_PAGE_DETECTION", issues = new string[0] } + }); + + var targets = FactRemediation.ExtractDbConfigTargets(finding); + Assert.Equal(2, targets.Count); + Assert.Equal("A", targets[0].Database); + Assert.Equal(DbConfigSetting.AutoShrinkOff, targets[0].Setting); + Assert.Equal("B", targets[1].Database); + Assert.Equal(DbConfigSetting.PageVerifyChecksum, targets[1].Setting); + Assert.Equal("TORN_PAGE_DETECTION", targets[1].CurrentValue); + } + + [Fact] + public void BuildAction_DbConfig_OnlyRcsi_ReturnsNull() + { + // A DB whose only issue is RCSI OFF yields no safe target → no action → no Apply button. + var finding = DbConfigFinding(new List + { + new { database = "Db1", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" } } + }); + + Assert.Null(FactRemediation.BuildAction(finding)); + } + + [Fact] + public void BuildAction_DbConfig_ProducesSetActionWithTargets() + { + var action = FactRemediation.BuildAction(DbConfigFinding()); + + Assert.NotNull(action); + Assert.Equal("DB_CONFIG", action!.FactKey); + Assert.Equal("set", action.Action); + Assert.Empty(action.Targets); // force-plan list is empty for DB_CONFIG + Assert.NotNull(action.DbConfigTargets); + Assert.Equal(2, action.DbConfigTargets!.Count); // shrink + close + } + + // ── Render-stability golden (incl. "was X" + RCSI-excluded note) ───────────── + + [Fact] + public void GenerateForFinding_DbConfig_RenderStability_ByteForByte() + { + var finding = DbConfigFinding(new List + { + new { database = "Foo", rcsi = false, query_store = true, + auto_shrink = true, auto_close = true, page_verify = "NONE", + issues = new[] { "auto_shrink ON", "auto_close ON", "RCSI OFF", "page_verify=NONE" } } + }); + + const string expected = + "-- Database: Foo\n" + + "ALTER DATABASE [Foo] SET AUTO_SHRINK OFF; -- was ON\n" + + "ALTER DATABASE [Foo] SET AUTO_CLOSE OFF; -- was ON\n" + + "ALTER DATABASE [Foo] SET PAGE_VERIFY CHECKSUM; -- was NONE\n" + + "-- NOTE: [Foo] also has RCSI OFF — intentionally NOT auto-fixed (test on a copy first)."; + + var actual = FactRemediation.GenerateForFinding(finding); + Assert.NotNull(actual); + Assert.Equal(expected, actual!.Replace("\r\n", "\n")); + } + + // ── Handler: audit-absent hard block, fail-closed, freshness-skip, db-not-found, success ── + + [Fact] + public async Task DbConfig_AuditTableAbsent_HardBlocks_NoMutation_NoAudit() + { + var exec = new FakeExecutor { AuditTableExists = false }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("Bar", DbConfigSetting.AutoCloseOff, "ON")), + exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.Contains("3.0.0", o.Message); + }); + Assert.Equal(0, exec.SetDbCalls); + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task DbConfig_PermissionDenied_FailsClosed_AuditSkipped() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.PermissionDenied, Applied = false, + Message = "lacks ALTER", PriorValue = "ON" + } + }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.PermissionDenied, o.Status); + Assert.False(o.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + Assert.Equal(1, exec.SetDbCalls); + } + + [Fact] + public async Task DbConfig_AlreadyDesired_Skips_NoMutationFlag_AuditSkipped() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.Skipped, Applied = false, + Message = "already desired", PriorValue = "OFF" + } + }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "OFF")), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Skipped, o.Status); + Assert.False(o.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + } + + [Fact] + public async Task DbConfig_DatabaseNotFound_Blocks_AuditAborted() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.Blocked, Applied = false, + Message = "not found", PriorValue = null + } + }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Gone", DbConfigSetting.AutoShrinkOff, "ON")), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.Equal("aborted", Assert.Single(exec.AuditRecords).Result); + } + + [Fact] + public async Task DbConfig_Success_AppliesAndAudits_NullIds_PriorValue_PreciseAction() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "sa", PriorValue = "ON", + GeneratedSql = "ALTER DATABASE [Foo] SET AUTO_SHRINK OFF;", GateSpid = 7, ExecSpid = 7 + } + }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.True(o.AuditWritten); + Assert.False(o.AppliedButUnlogged); + + var row = Assert.Single(exec.AuditRecords); + Assert.Equal("DB_CONFIG", row.FactKey); + Assert.Equal("set_auto_shrink_off", row.Action); // precise taxonomy (24/19/18 chars) + Assert.Equal("success", row.Result); + Assert.Null(row.QueryId); // B-1: DB_CONFIG rows have no IDs + Assert.Null(row.PlanId); + Assert.Equal("ON", row.PriorValue); + Assert.Contains("ALTER DATABASE", row.GeneratedSql); + } + + [Fact] + public async Task DbConfig_AppliesButAuditFails_FlagsAppliedButUnlogged() + { + var exec = new FakeExecutor { AuditWriteResult = false }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.False(o.AuditWritten); + Assert.True(o.AppliedButUnlogged); + } + + [Fact] + public async Task DbConfig_OneTargetThrows_OthersStillRun() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => + { + if (db == "A") throw new InvalidOperationException("boom"); + return new DbConfigOutcome { Database = db, Setting = s, Status = RemediationStatus.Success, Applied = true, PriorValue = "ON" }; + } + }; + + var result = await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("A", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("B", DbConfigSetting.AutoCloseOff, "ON")), + exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.Equal(RemediationStatus.Error, result.Outcomes[0].Status); + Assert.Equal(RemediationStatus.Success, result.Outcomes[1].Status); + } + + [Fact] + public async Task DbConfig_PreciseTaxonomy_AllThreeSettings() + { + var exec = new FakeExecutor(); + await new DbConfigHandler().ApplyAsync( + DbConfigAction( + new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("Foo", DbConfigSetting.AutoCloseOff, "ON"), + new DbConfigTarget("Foo", DbConfigSetting.PageVerifyChecksum, "NONE")), + exec, Identity, CancellationToken.None); + + var actions = exec.AuditRecords.Select(r => r.Action).ToList(); + Assert.Contains("set_auto_shrink_off", actions); + Assert.Contains("set_auto_close_off", actions); + Assert.Contains("set_page_verify_checksum", actions); // 24 chars — needs varchar(32) + param(32) + } + + // ── Preflight dispositions ─────────────────────────────────────────────────── + + [Theory] + [InlineData(false, true, false, RemediationDisposition.BlockDatabaseNotFound)] + [InlineData(true, false, false, RemediationDisposition.BlockNoAlter)] + [InlineData(true, true, true, RemediationDisposition.AlreadyInDesiredState)] + [InlineData(true, true, false, RemediationDisposition.Ok)] + public async Task DbConfig_Preflight_ClassifiesDisposition(bool exists, bool hasAlter, bool alreadyDesired, RemediationDisposition expected) + { + var exec = new FakeExecutor + { + DbPreflightFunc = (db, s) => new DbConfigPreflight + { + Database = db, Setting = s, DatabaseExists = exists, HasAlter = hasAlter, + AlreadyInDesiredState = alreadyDesired, CurrentValue = "ON" + } + }; + + var pre = await new DbConfigHandler().PreflightAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, CancellationToken.None); + Assert.Equal(expected, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task DbConfig_Preflight_AuditTableAbsent_OverridesDisposition() + { + var exec = new FakeExecutor { AuditTableExists = false }; + + var pre = await new DbConfigHandler().PreflightAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, CancellationToken.None); + + Assert.False(pre.AuditTableExists); + Assert.Equal(RemediationDisposition.BlockAuditTableAbsent, pre.Targets.Single().Disposition); + } + + // ── Apply-only enforcement (m-1) + SupportsUnapply ─────────────────────────── + + [Fact] + public void DbConfig_SupportsUnapply_IsFalse_NotDestructive() + { + var handler = new DbConfigHandler(); + Assert.False(handler.SupportsUnapply); + Assert.False(handler.IsDestructive); + } + + [Fact] + public async Task DbConfig_UnapplyAsync_Throws() + { + await Assert.ThrowsAsync(() => + new DbConfigHandler().UnapplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), new FakeExecutor(), Identity, CancellationToken.None)); + } + + [Fact] + public void CoreMachineryMarkers_CoverNewPrivilegedSurface() + { + // M-2: the reachability guard must list the new handler + executor methods, + // or a future UI/MCP file referencing them would silently pass the guard. + Assert.Contains("DbConfigHandler", CoreMachineryMarkers); + Assert.Contains("SetDatabaseOptionAsync", CoreMachineryMarkers); + Assert.Contains("PreflightDbConfigAsync", CoreMachineryMarkers); + } + + [Fact] + public void Registry_ResolvesDbConfigHandler() + { + var registry = new RemediationHandlerRegistry(new IRemediationHandler[] { new ForcePlanHandler(), new DbConfigHandler() }); + Assert.IsType(registry.TryGet("DB_CONFIG")); + Assert.IsType(registry.TryGet("PLAN_REGRESSION")); + } + + [Fact] + public void Rcsi_Handler_IsRegistered_AndReachableThroughGate() + { + // PR-B makes RCSI LIVE: the PRODUCTION wiring registers RcsiHandler so the Apply + // affordance can appear and route to the destructive handler — but ONLY through + // the gated RemediationApplyService facade (proven by the Gate_* behavioural tests + // and the reachability guards CoreMachinery_OnlyReferencedInRemediationCore + + // GatedEntry_ReferencedOnlyBySanctionedUiPath). + var productionRegistry = new RemediationHandlerRegistry( + new IRemediationHandler[] { new ForcePlanHandler(), new DbConfigHandler(), new RcsiHandler() }); + Assert.IsType(productionRegistry.TryGet("RCSI")); + + // Assert at the source level that the PRODUCTION wiring now constructs RcsiHandler. + var dir = FindDashboardSourceDir(); + var serviceSrc = File.ReadAllText(Path.Combine(dir, "Services", "Remediation", "RemediationApplyService.cs")); + Assert.Contains("new RcsiHandler()", serviceSrc); + + // The always-safe DbConfigHandler still routes ONLY DB_CONFIG — never RCSI — so + // the two affordances can never cross at the registry/handler layer. + Assert.IsType(productionRegistry.TryGet("DB_CONFIG")); + Assert.NotEqual("RCSI", productionRegistry.TryGet("DB_CONFIG")!.FactKey); + } + + [Fact] + public void CoreMachineryMarkers_Include_RcsiHandler() + { + // m-3: the destructive handler must be in the reachability guard. + Assert.Contains("RcsiHandler", CoreMachineryMarkers); + } + + // ════════════════════════════════════════════════════════════════════════════ + // B3 PHASE 3 — RCSI destructive-consent privileged core (PR-A; UNREGISTERED) + // ════════════════════════════════════════════════════════════════════════════ + + /// A DB_CONFIG finding with RCSI OFF + the §3.3 enrichment present. + private static AnalysisFinding RcsiOffFinding(int blocking = 12, int deadlocks = 3, int? rwPct = 80, string db = "Foo") => new() + { + ServerId = 1, + ServerName = "TestServer", + Category = "config_issues", + StoryPath = "DB_CONFIG", + StoryPathHash = "dbconfig00000099", + RootFactKey = "DB_CONFIG", + DrillDown = new Dictionary + { + ["config_issues"] = new List + { + new { database = db, recovery_model = "FULL", rcsi = false, query_store = true, + issues = new[] { "RCSI OFF" }, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + rcsi_blocking_events = blocking, rcsi_deadlocks = deadlocks, + rcsi_reader_writer_pct = rwPct } + } + } + }; + + private static RemediationAction RcsiAction(string db = "Foo") => + new("RCSI", "set", Array.Empty(), new[] { new DbConfigTarget(db, DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }); + + // ── RcsiHandler invariants ─────────────────────────────────────────────────── + + [Fact] + public void Rcsi_Handler_IsDestructive_And_ApplyOnly() + { + var handler = new RcsiHandler(); + Assert.Equal("RCSI", handler.FactKey); + Assert.True(handler.IsDestructive); // the first (and only) true in the codebase + Assert.False(handler.SupportsUnapply); + } + + [Fact] + public async Task Rcsi_UnapplyAsync_Throws() + { + await Assert.ThrowsAsync(() => + new RcsiHandler().UnapplyAsync(RcsiAction(), new FakeExecutor(), Identity, CancellationToken.None)); + } + + // ── Executor RCSI arm: own builder, no-injection, same-string (against the + // executor's OWN BuildAlterStatement — not the display renderer) ─────────── + + [Fact] + public void Rcsi_Build_SetClause_IsHardcodedLiteral_RegardlessOfName() + { + foreach (var name in new[] { "Db", "Db]; DROP TABLE x--", "[weird]", "Ünîçödé" }) + { + var stmt = DatabaseService.BuildAlterStatement(name, DbConfigSetting.ReadCommittedSnapshotOn); + Assert.EndsWith("SET READ_COMMITTED_SNAPSHOT ON;", stmt); + } + } + + [Theory] + [InlineData("normal")] + [InlineData("has]bracket")] + [InlineData("has]]double")] + [InlineData("ev;il-- GO DROP")] + [InlineData("'; ALTER DATABASE master SET")] + public void Rcsi_Build_BracketsIdentifierInert_NoInjection(string name) + { + var stmt = DatabaseService.BuildAlterStatement(name, DbConfigSetting.ReadCommittedSnapshotOn); + var expectedToken = "[" + name.Replace("]", "]]") + "]"; + Assert.Equal($"ALTER DATABASE {expectedToken} SET READ_COMMITTED_SNAPSHOT ON;", stmt); + } + + [Fact] + public void Rcsi_Build_SameString_ValidatedNameIsBracketedExactly() + { + const string validated = " Padded Name "; + var stmt = DatabaseService.BuildAlterStatement(validated, DbConfigSetting.ReadCommittedSnapshotOn); + Assert.Equal("ALTER DATABASE [ Padded Name ] SET READ_COMMITTED_SNAPSHOT ON;", stmt); + Assert.NotEqual(stmt, DatabaseService.BuildAlterStatement(validated.TrimEnd(), DbConfigSetting.ReadCommittedSnapshotOn)); + } + + [Fact] + public void Rcsi_SetClause_ExecutorAndService_AreReadCommittedSnapshotOn() + { + // m-1: all clause-builder copies render the RCSI arm (executor's own builder). + Assert.EndsWith("SET READ_COMMITTED_SNAPSHOT ON;", + DatabaseService.BuildAlterStatement("X", DbConfigSetting.ReadCommittedSnapshotOn)); + } + + [Fact] + public void Rcsi_SettingTitle_IsFriendly_NotRawEnumName() + { + // m-2: a RCSI confirm row must not render "ReadCommittedSnapshotOn". + Assert.Equal("Read Committed Snapshot Isolation", + DbConfigHandler.SettingTitle(DbConfigSetting.ReadCommittedSnapshotOn)); + } + + // ── RcsiHandler apply behaviours against the faked executor ────────────────── + + [Fact] + public async Task Rcsi_AuditTableAbsent_HardBlocks_NoMutation_NoAudit() + { + var exec = new FakeExecutor { AuditTableExists = false }; + var result = await new RcsiHandler().ApplyAsync(RcsiAction(), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.Equal(0, exec.SetDbCalls); + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task Rcsi_PermissionDenied_FailsClosed_NoElevation() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.PermissionDenied, Applied = false, + Message = "lacks ALTER", PriorValue = "OFF" + } + }; + var result = await new RcsiHandler().ApplyAsync(RcsiAction(), exec, Identity, CancellationToken.None); + + Assert.Equal(RemediationStatus.PermissionDenied, Assert.Single(result.Outcomes).Status); + Assert.Equal(1, exec.SetDbCalls); + } + + [Fact] + public async Task Rcsi_AlreadyOn_Skips_ConsentAcknowledgedStillTrue() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.Skipped, Applied = false, + Message = "already on", PriorValue = "ON", GateSpid = 7, ExecSpid = 7 + } + }; + var result = await new RcsiHandler().ApplyAsync(RcsiAction(), exec, Identity, CancellationToken.None); + + Assert.Equal(RemediationStatus.Skipped, Assert.Single(result.Outcomes).Status); + var rec = Assert.Single(exec.AuditRecords); + Assert.Equal("skipped", rec.Result); + Assert.True(rec.ConsentAcknowledged); // gate was satisfied to reach apply + Assert.Equal("RCSI", rec.FactKey); + Assert.Equal("set_rcsi_on", rec.Action); + } + + [Fact] + public async Task Rcsi_Success_WritesAudit_ConsentAcknowledgedTrue_PriorValueOff() + { + var exec = new FakeExecutor + { + SetDbFunc = (db, s) => new DbConfigOutcome + { + Database = db, Setting = s, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "sa", PriorValue = "OFF", + GeneratedSql = "ALTER DATABASE [Foo] SET READ_COMMITTED_SNAPSHOT ON;", GateSpid = 9, ExecSpid = 9 + } + }; + var result = await new RcsiHandler().ApplyAsync(RcsiAction(), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.True(o.AuditWritten); + var rec = Assert.Single(exec.AuditRecords); + Assert.True(rec.ConsentAcknowledged); + Assert.Equal("OFF", rec.PriorValue); + Assert.Equal("success", rec.Result); + } + + [Fact] + public async Task DbConfig_And_ForcePlan_WriteConsentAcknowledgedFalse() + { + // Regression-guard: the non-destructive paths must persist consent_acknowledged = 0. + var exec = new FakeExecutor(); + await new DbConfigHandler().ApplyAsync( + DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), exec, Identity, CancellationToken.None); + Assert.All(exec.AuditRecords, r => Assert.False(r.ConsentAcknowledged)); + + var exec2 = new FakeExecutor(); + await new ForcePlanHandler().ApplyAsync( + new RemediationAction("PLAN_REGRESSION", "force", new List { Target() }), + exec2, Identity, CancellationToken.None); + Assert.All(exec2.AuditRecords, r => Assert.False(r.ConsentAcknowledged)); + } + + // ── BuildRcsiAction (M2-1 boolean polarity) + BuildAction regression-guard ─── + + [Fact] + public void BuildRcsiAction_EmitsOnRcsiOff_WithEnrichment() + { + var action = FactRemediation.BuildRcsiAction(RcsiOffFinding()); + Assert.NotNull(action); + Assert.Equal("RCSI", action!.FactKey); + Assert.Equal("set", action.Action); + Assert.Empty(action.Targets); + var t = Assert.Single(action.DbConfigTargets!); + Assert.Equal(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting); + Assert.Equal("OFF", t.CurrentValue); + Assert.Equal("Foo", t.Database); + } + + [Fact] + public void BuildRcsiAction_ReturnsNull_WhenRcsiOn() + { + // M2-1: rcsi == true means RCSI is ON — the affordance must NOT appear. + var finding = DbConfigFinding(new List + { + new { database = "Foo", rcsi = true, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new string[0], + rcsi_blocking_events = 99, rcsi_deadlocks = 9, rcsi_reader_writer_pct = 90 } + }); + Assert.Null(FactRemediation.BuildRcsiAction(finding)); + } + + [Fact] + public void BuildRcsiAction_ReturnsNull_WhenEnrichmentAbsent() + { + // RCSI off but no §3.3 enrichment (legacy alert) → no affordance. + var finding = DbConfigFinding(new List + { + new { database = "Foo", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" } } + }); + Assert.Null(FactRemediation.BuildRcsiAction(finding)); + } + + [Fact] + public void BuildAction_NeverPutsRcsiInSafeTargets_ButCarriesRcsiTargets() + { + // The always-safe BuildAction must STILL never put RCSI in the EXECUTED DbConfigTargets + // (DbConfigHandler runs those). RCSI is now CARRIED on the action's RcsiTargets purely so + // the Recommendations reader can fan per-db RCSI cards — never executed from here. On an + // enriched, CONTENDED RCSI-off finding the action exists (so the reader can fan), has NO + // safe DbConfigTargets, and carries exactly the one RCSI target. + var rcsiOnly = FactRemediation.BuildAction(RcsiOffFinding()); + Assert.NotNull(rcsiOnly); + Assert.Equal("DB_CONFIG", rcsiOnly!.FactKey); + Assert.True(rcsiOnly.DbConfigTargets is null || rcsiOnly.DbConfigTargets.Count == 0); + var carried = Assert.Single(rcsiOnly.RcsiTargets!); + Assert.Equal("Foo", carried.Database); + Assert.Equal(12, carried.Figures.BlockingEvents); + + // And a mixed finding (RCSI off + a safe setting) yields the safe target in + // DbConfigTargets (never RCSI) AND carries the RCSI target separately. + var mixed = DbConfigFinding(new List + { + new { database = "Foo", rcsi = false, query_store = true, + auto_shrink = true, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "auto_shrink ON", "RCSI OFF" }, + rcsi_blocking_events = 1, rcsi_deadlocks = 0, rcsi_reader_writer_pct = 50 } + }); + var safe = FactRemediation.BuildAction(mixed); + Assert.NotNull(safe); + Assert.Equal("DB_CONFIG", safe!.FactKey); + Assert.NotEmpty(safe.DbConfigTargets!); + Assert.All(safe.DbConfigTargets!, t => Assert.NotEqual(DbConfigSetting.ReadCommittedSnapshotOn, t.Setting)); + Assert.Single(safe.RcsiTargets!); // RCSI carried for the card fan-out, not executed + } + + [Fact] + public void CollectRcsiTargets_OnlyReaderWriterContention_WriterWriterAndUnknownExcluded() + { + // RCSI only relieves reader-vs-writer blocking. The gate is the reader/writer SHARE + // (>= FactRiskDisclosure.ReaderWriterMeaningfulPct), NOT raw blocking/deadlock counts: + // - reader/writer-dominant -> recommended + // - writer/writer-dominant -> NOT recommended, even with HEAVY blocking (RCSI does + // nothing for X/IX/U vs X/IX/U contention) + // - no blocked-process detail -> NOT recommended (pct null — can't confirm RCSI helps) + var finding = DbConfigFinding(new List + { + new { database = "ReaderWriter", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" }, + rcsi_blocking_events = 40, rcsi_deadlocks = 2, rcsi_reader_writer_pct = 85 }, + new { database = "WriterWriter", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" }, + // Heavy blocking + deadlocks, but almost all writer/writer — RCSI won't relieve it. + rcsi_blocking_events = 500, rcsi_deadlocks = 12, rcsi_reader_writer_pct = 15 }, + new { database = "Unknown", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" }, + rcsi_blocking_events = 30, rcsi_deadlocks = 0, rcsi_reader_writer_pct = (int?)null }, + new { database = "AtThreshold", rcsi = false, query_store = true, + auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + issues = new[] { "RCSI OFF" }, + rcsi_blocking_events = 10, rcsi_deadlocks = 0, rcsi_reader_writer_pct = 50 } + }); + + var collected = FactRemediation.CollectRcsiTargets(finding); + + Assert.Equal(2, collected.Count); + Assert.DoesNotContain(collected, t => t.Database == "WriterWriter"); // heavy blocking, but writer/writer + Assert.DoesNotContain(collected, t => t.Database == "Unknown"); // no reader/writer detail captured + var rw = Assert.Single(collected, t => t.Database == "ReaderWriter"); + Assert.Equal(40, rw.Figures.BlockingEvents); + Assert.Equal(85, rw.Figures.ReaderWriterPct); + Assert.Single(collected, t => t.Database == "AtThreshold"); // pct == threshold qualifies + + // BuildAction carries exactly the two reader/writer targets (no safe DbConfigTargets here). + var action = FactRemediation.BuildAction(finding); + Assert.NotNull(action); + Assert.Equal(2, action!.RcsiTargets!.Count); + Assert.True(action.DbConfigTargets is null || action.DbConfigTargets.Count == 0); + } + + // ── FactRiskDisclosure: two-sided, honest-both-directions, golden prose ────── + + [Fact] + public void RiskDisclosure_Null_ForNonDestructiveFactKey() + { + Assert.Null(FactRiskDisclosure.GetForAction(DbConfigAction(new DbConfigTarget("Foo", DbConfigSetting.AutoShrinkOff, "ON")), DbConfigFinding())); + Assert.Null(FactRiskDisclosure.GetForAction(new RemediationAction("PLAN_REGRESSION", "force", new List { Target() }), null)); + } + + [Fact] + public void RiskDisclosure_Rcsi_HasAtLeastOneOfEachSide() + { + var d = FactRiskDisclosure.GetForAction(RcsiAction(), RcsiOffFinding()); + Assert.NotNull(d); + Assert.NotEmpty(d!.RisksOfChanging); + Assert.NotEmpty(d.RisksOfNotChanging); + // The validated identifier is substituted, bracketed. + Assert.Contains(d.RisksOfChanging, r => r.Text.Contains("[Foo]")); + } + + [Fact] + public void RiskDisclosure_HighReaderWriterPct_SaysRcsiEliminates() + { + var d = FactRiskDisclosure.GetForAction(RcsiAction(), RcsiOffFinding(blocking: 50, deadlocks: 4, rwPct: 85))!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("85%") && r.Text.Contains("RCSI eliminates")); + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("50") && r.Text.Contains("blocked-process events") && r.Text.Contains("4 deadlocks")); + } + + [Fact] + public void RiskDisclosure_LowReaderWriterPct_SaysRcsiWontResolveThis() + { + // Writer/writer-dominant: the honest-both-directions safety feature. + var d = FactRiskDisclosure.GetForAction(RcsiAction(), RcsiOffFinding(blocking: 40, deadlocks: 2, rwPct: 10))!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("10%") && r.Text.Contains("RCSI does NOT resolve")); + Assert.DoesNotContain(d.RisksOfNotChanging, r => r.Text.Contains("RCSI eliminates")); + } + + [Fact] + public void RiskDisclosure_NoData_ShowsWeakCaseBaseline() + { + var d = FactRiskDisclosure.GetForAction(RcsiAction(), RcsiOffFinding(blocking: 0, deadlocks: 0, rwPct: null))!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("Little or no reader/writer blocking")); + } + + [Fact] + public void RiskDisclosure_NullFinding_DegradesToWeakCaseBaseline() + { + var d = FactRiskDisclosure.GetForAction(RcsiAction(), null)!; + Assert.NotEmpty(d.RisksOfChanging); + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("Little or no reader/writer blocking")); + } + + [Fact] + public void RiskDisclosure_Rcsi_GoldenProse_ChangingSide() + { + var d = FactRiskDisclosure.GetForAction(RcsiAction(), RcsiOffFinding())!; + Assert.Equal(3, d.RisksOfChanging.Count); + Assert.StartsWith("Enabling RCSI on [Foo] takes a brief exclusive database lock", d.RisksOfChanging[0].Text); + Assert.Contains("row-version load to tempdb", d.RisksOfChanging[1].Text); + Assert.Contains("Reader/writer concurrency semantics change", d.RisksOfChanging[2].Text); + } + + // ════════════════════════════════════════════════════════════════════════════ + // CLEAR CACHED PLAN (DBCC FREEPROCCACHE) — destructive privileged core (PR-A; + // UNREGISTERED). The two catastrophic axes: never-bare/null-handle-guard, and the + // ALTER-SERVER-STATE fail-closed gate. Plus the §2a row-level detector exclusion. + // ════════════════════════════════════════════════════════════════════════════ + + /// A CPU finding carrying an abnormal_cpu_plans drill-down with one qualifying row. + private static AnalysisFinding CpuFinding(List? rows = null, string rootKey = "CPU_SQL_PERCENT") => new() + { + ServerId = 1, + ServerName = "TestServer", + Category = "cpu", + StoryPath = rootKey, + StoryPathHash = "cpuplan000000001", + RootFactKey = rootKey, + DrillDown = new Dictionary + { + ["abnormal_cpu_plans"] = rows ?? new List + { + new + { + query_hash = "0xABCDEF0123456789", + database = "AdventureWorks", + current_cpu_per_exec_ms = 45.0, + baseline_cpu_per_exec_ms = 9.0, + anomaly_ratio = 5.0, + execution_count = 1200L, + total_cpu_ms = 54000.0, + latest_plan_handle = "0x06000100ABCD", + query_text = "SELECT * FROM dbo.BigTable WHERE x = @p", + cpu_percent = 62, + plan_regression_cofired = false, + parameter_sensitivity_cofired = false + } + } + } + }; + + private static RemediationAction ClearAction(string hash = "0xABCDEF0123456789", string db = "AdventureWorks") => + new("CLEAR_PLAN", "clear", Array.Empty(), + ClearPlanTargets: new[] { new ClearPlanTarget(db, hash, 45.0, 9.0, 5.0, "0x06") }, + ClearPlanFigures: new ClearPlanFigures(45.0, 9.0, 5.0, 62, false, false)); + + // ── BuildClearPlanAction (parallel builder) + BuildAction-unchanged guard ──── + + [Fact] + public void BuildClearPlanAction_EmitsOnAbnormalCpuPlans() + { + var action = FactRemediation.BuildClearPlanAction(CpuFinding()); + Assert.NotNull(action); + Assert.Equal("CLEAR_PLAN", action!.FactKey); + Assert.Equal("clear", action.Action); + Assert.Empty(action.Targets); + Assert.Null(action.DbConfigTargets); + var t = Assert.Single(action.ClearPlanTargets!); + Assert.Equal("0xABCDEF0123456789", t.QueryHash); + Assert.Equal("AdventureWorks", t.Database); + Assert.Equal(5.0, t.AnomalyRatio); + // The figures are carried for the at-apply dialog (no finding in hand). + Assert.NotNull(action.ClearPlanFigures); + Assert.Equal(45.0, action.ClearPlanFigures!.CurrentCpuPerExecMs); + Assert.Equal(62, action.ClearPlanFigures.CpuPercent); + } + + [Fact] + public void BuildClearPlanAction_CpuSpikeRootKey_AlsoEmits() + { + var action = FactRemediation.BuildClearPlanAction(CpuFinding(rootKey: "CPU_SPIKE")); + Assert.NotNull(action); + Assert.Equal("CLEAR_PLAN", action!.FactKey); + } + + [Fact] + public void BuildClearPlanAction_ReturnsNull_WhenNoDrillDown() + { + var finding = CpuFinding(); + finding.DrillDown = new Dictionary(); // no abnormal_cpu_plans + Assert.Null(FactRemediation.BuildClearPlanAction(finding)); + } + + [Fact] + public void BuildClearPlanAction_ReturnsNull_WhenNotCpuFinding() + { + var finding = CpuFinding(); + finding.RootFactKey = "PLAN_REGRESSION"; + Assert.Null(FactRemediation.BuildClearPlanAction(finding)); + } + + [Fact] + public void BuildClearPlanAction_RejectsRowsWithoutValidQueryHash() + { + // A row whose query_hash is blank / not a hex handle can never become a target — + // it could never resolve a plan handle (defends the never-resolve-garbage edge). + var action = FactRemediation.BuildClearPlanAction(CpuFinding(new List + { + new { query_hash = "", database = "Db", current_cpu_per_exec_ms = 10.0, baseline_cpu_per_exec_ms = 1.0, + anomaly_ratio = 10.0, execution_count = 5L, total_cpu_ms = 5000.0, latest_plan_handle = "", + query_text = "", cpu_percent = 10, plan_regression_cofired = false, parameter_sensitivity_cofired = false }, + new { query_hash = "notahex", database = "Db", current_cpu_per_exec_ms = 10.0, baseline_cpu_per_exec_ms = 1.0, + anomaly_ratio = 10.0, execution_count = 5L, total_cpu_ms = 5000.0, latest_plan_handle = "", + query_text = "", cpu_percent = 10, plan_regression_cofired = false, parameter_sensitivity_cofired = false } + })); + Assert.Null(action); + } + + [Fact] + public void BuildAction_Unchanged_NeverEmitsClearPlan_OnCpuFinding() + { + // The regression guard (§9): the parallel builder does not leak into BuildAction — + // a CPU finding carrying abnormal_cpu_plans yields NO action from BuildAction. + Assert.Null(FactRemediation.BuildAction(CpuFinding())); + } + + // ── FactRiskDisclosure CLEAR_PLAN arm (two-sided, real figures, both steers) ── + + [Fact] + public void RiskDisclosure_ClearPlan_HasAtLeastOneOfEachSide_RealFigures() + { + var d = FactRiskDisclosure.GetForAction(ClearAction(), CpuFinding()); + Assert.NotNull(d); + Assert.NotEmpty(d!.RisksOfChanging); + Assert.NotEmpty(d.RisksOfNotChanging); + // Real figures substituted from the carried ClearPlanFigures. + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("5.0x") && r.Text.Contains("45.0 ms") && r.Text.Contains("9.0 ms") && r.Text.Contains("62%")); + // The changing side discloses the recompile + not-guaranteed-better + per-handle blast radius. + Assert.Contains(d.RisksOfChanging, r => r.Text.Contains("forces a recompile")); + Assert.Contains(d.RisksOfChanging, r => r.Text.Contains("NOT guaranteed to produce a better plan")); + Assert.Contains(d.RisksOfChanging, r => r.Text.Contains("EVERY currently-cached plan")); + } + + [Fact] + public void RiskDisclosure_ClearPlan_PlanRegressionCoFired_SteersToForce() + { + var action = new RemediationAction("CLEAR_PLAN", "clear", Array.Empty(), + ClearPlanTargets: new[] { new ClearPlanTarget("Db", "0xAA") }, + ClearPlanFigures: new ClearPlanFigures(40.0, 8.0, 5.0, 50, PlanRegressionCoFired: true, ParameterSensitivityCoFired: false)); + var d = FactRiskDisclosure.GetForAction(action, null)!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("forcing it") && r.Text.Contains("more durable")); + Assert.DoesNotContain(d.RisksOfNotChanging, r => r.Text.Contains("parameter-sensitive")); + } + + [Fact] + public void RiskDisclosure_ClearPlan_ParameterSensitivityCoFired_SaysWontDurablyHelp() + { + var action = new RemediationAction("CLEAR_PLAN", "clear", Array.Empty(), + ClearPlanTargets: new[] { new ClearPlanTarget("Db", "0xAA") }, + ClearPlanFigures: new ClearPlanFigures(40.0, 8.0, 5.0, 50, PlanRegressionCoFired: false, ParameterSensitivityCoFired: true)); + var d = FactRiskDisclosure.GetForAction(action, null)!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("parameter-sensitive") && r.Text.Contains("WON'T durably")); + } + + [Fact] + public void RiskDisclosure_ClearPlan_FiguresSurvivePersistence_NoFinding() + { + // The RcsiInactionFigures analog: with NO finding (apply-time), the carried + // ClearPlanFigures still render the real numbers. + var d = FactRiskDisclosure.GetForAction(ClearAction(), null)!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("5.0x") && r.Text.Contains("62%")); + } + + // ── ClearPlanHandler invariants ────────────────────────────────────────────── + + [Fact] + public void ClearPlan_Handler_IsDestructive_And_ApplyOnly() + { + var handler = new ClearPlanHandler(); + Assert.Equal("CLEAR_PLAN", handler.FactKey); + Assert.True(handler.IsDestructive); // the second true in the codebase + Assert.False(handler.SupportsUnapply); // you cannot un-clear a cache + } + + [Fact] + public async Task ClearPlan_UnapplyAsync_Throws() + { + await Assert.ThrowsAsync(() => + new ClearPlanHandler().UnapplyAsync(ClearAction(), new FakeExecutor(), Identity, CancellationToken.None)); + } + + [Fact] + public void ClearPlan_Handler_IsRegistered_AndReachableThroughGate() + { + // PR-B makes CLEAR_PLAN LIVE: the PRODUCTION wiring registers ClearPlanHandler so the + // "Clear cached plan (advanced)" affordance can appear and route to the destructive + // handler — but ONLY through the gated RemediationApplyService facade (proven by the + // Gate_* behavioural tests + the reachability guards CoreMachinery_OnlyReferencedInRemediationCore + // and GatedEntry_ReferencedOnlyBySanctionedUiPath). + var productionRegistry = new RemediationHandlerRegistry( + new IRemediationHandler[] { new ForcePlanHandler(), new DbConfigHandler(), new RcsiHandler(), new ClearPlanHandler() }); + Assert.IsType(productionRegistry.TryGet("CLEAR_PLAN")); + + // Assert at the source level that the PRODUCTION wiring now constructs ClearPlanHandler. + var dir = FindDashboardSourceDir(); + var serviceSrc = File.ReadAllText(Path.Combine(dir, "Services", "Remediation", "RemediationApplyService.cs")); + Assert.Contains("new ClearPlanHandler()", serviceSrc); + + // The destructive CLEAR_PLAN handler can NEVER be reached through the always-safe + // force-plan / DB-config handlers, nor cross with the other destructive (RCSI) one: + // every fact key resolves to its OWN distinct handler type. + Assert.IsType(productionRegistry.TryGet("PLAN_REGRESSION")); + Assert.IsType(productionRegistry.TryGet("DB_CONFIG")); + Assert.IsType(productionRegistry.TryGet("RCSI")); + Assert.NotEqual("CLEAR_PLAN", productionRegistry.TryGet("DB_CONFIG")!.FactKey); + Assert.NotEqual("CLEAR_PLAN", productionRegistry.TryGet("PLAN_REGRESSION")!.FactKey); + Assert.NotEqual("CLEAR_PLAN", productionRegistry.TryGet("RCSI")!.FactKey); + } + + [Fact] + public void CoreMachineryMarkers_Include_ClearPlanSurface() + { + Assert.Contains("ClearPlanHandler", CoreMachineryMarkers); + Assert.Contains("ClearProcCacheAsync", CoreMachineryMarkers); + } + + // ── ClearPlanHandler apply behaviours against the faked executor ───────────── + + [Fact] + public async Task ClearPlan_AuditTableAbsent_HardBlocks_NoMutation_NoAudit() + { + var exec = new FakeExecutor { AuditTableExists = false }; + var result = await new ClearPlanHandler().ApplyAsync(ClearAction(), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.Equal(0, exec.ClearPlanCalls); // no DBCC path even attempted + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task ClearPlan_PermissionDenied_FailsClosed_NoElevation() + { + // The DOMINANT runtime path: the least-privilege login lacks ALTER SERVER STATE. + var exec = new FakeExecutor + { + ClearPlanFunc = h => new ClearPlanOutcome + { + QueryHash = h, Status = RemediationStatus.PermissionDenied, Cleared = false, HandlesCleared = 0, + ExecutingLogin = "PerfMonLogin", + Message = "lacks ALTER SERVER STATE ... GRANT ALTER SERVER STATE TO [PerfMonLogin];", + PriorValue = "0 plans (permission denied)" + } + }; + var result = await new ClearPlanHandler().ApplyAsync(ClearAction(), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.PermissionDenied, o.Status); + Assert.Equal(1, exec.ClearPlanCalls); + var rec = Assert.Single(exec.AuditRecords); + Assert.Equal("skipped", rec.Result); // PermissionDenied audits as skipped + Assert.True(rec.ConsentAcknowledged); + Assert.Equal("CLEAR_PLAN", rec.FactKey); + Assert.Equal("clear_cached_plan", rec.Action); + Assert.Null(rec.QueryId); + Assert.Null(rec.PlanId); + Assert.Contains("GRANT ALTER SERVER STATE", rec.ErrorMessage); + } + + [Fact] + public async Task ClearPlan_NoSurvivingHandle_Skips() + { + var exec = new FakeExecutor + { + ClearPlanFunc = h => new ClearPlanOutcome + { + QueryHash = h, Status = RemediationStatus.Skipped, Cleared = false, HandlesCleared = 0, + ExecutingLogin = "sa", Message = "No cached plan is currently present", PriorValue = "0 plans (no surviving handle)" + } + }; + var result = await new ClearPlanHandler().ApplyAsync(ClearAction(), exec, Identity, CancellationToken.None); + + Assert.Equal(RemediationStatus.Skipped, Assert.Single(result.Outcomes).Status); + var rec = Assert.Single(exec.AuditRecords); + Assert.Equal("skipped", rec.Result); + Assert.True(rec.ConsentAcknowledged); + } + + [Fact] + public async Task ClearPlan_Success_WritesAudit_ConsentTrue_NullIds_Action() + { + var exec = new FakeExecutor(); // default success outcome + var result = await new ClearPlanHandler().ApplyAsync(ClearAction(), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.True(o.AuditWritten); + var rec = Assert.Single(exec.AuditRecords); + Assert.Equal("success", rec.Result); + Assert.True(rec.ConsentAcknowledged); + Assert.Equal("clear_cached_plan", rec.Action); + Assert.Null(rec.QueryId); + Assert.Null(rec.PlanId); + Assert.Contains("DBCC FREEPROCCACHE", rec.GeneratedSql); + // The audited action string fits the VarChar(32) @action param (17 chars). + Assert.True(rec.Action.Length <= 32); + } + + // ── Never-bare / null-handle-guard structural assertions (M-1) ─────────────── + // + // The single-connection SPID equality + the actual DBCC text are executor/real-server + // concerns (cannot be exercised against a faked executor). These assert, at the SOURCE + // level, that the executor ONLY ever builds the single-`@plan_handle` form, binds the + // handle as a typed param, filters plan_handle IS NOT NULL, rejects null/zero-length in + // C# before any DBCC string, and never emits the bare/whole-cache form. + + [Fact] + public void Executor_NeverEmitsBareFreeProcCache_OnlyTypedSingleHandleForm() + { + var dir = FindDashboardSourceDir(); + var src = File.ReadAllText(Path.Combine(dir, "Services", "DatabaseService.Remediation.cs")); + + // The ONLY DBCC text is the single-handle form with a typed @plan_handle param. + Assert.Contains("DBCC FREEPROCCACHE(@plan_handle)", src); + Assert.Contains("@plan_handle", src); + Assert.Contains("SqlDbType.VarBinary, 64", src); + + // The catastrophic bare/whole-cache form must NOT appear anywhere: no + // no-argument FREEPROCCACHE statement and no empty-arg form. + Assert.DoesNotContain("FREEPROCCACHE;", src); + Assert.DoesNotContain("FREEPROCCACHE ;", src); + Assert.DoesNotContain("FREEPROCCACHE()", src); // no empty-arg form + + // The live resolve filters plan_handle IS NOT NULL (the BAD_ACTOR precedent), + // and the C# guard rejects null/zero-length BEFORE any DBCC string is built. + Assert.Contains("plan_handle IS NOT NULL", src); + Assert.Contains("handle.Length == 0", src); + } + + [Fact] + public void Executor_GateIsNamedAlterServerState_FailClosed() + { + var dir = FindDashboardSourceDir(); + var src = File.ReadAllText(Path.Combine(dir, "Services", "DatabaseService.Remediation.cs")); + + // The NAMED server permission, fail-closed via ISNULL(...,0). NOT the generic + // (NULL,NULL,'ALTER') token (which returns NULL even for sysadmin). + Assert.Contains("HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER SERVER STATE')", src); + Assert.Contains("ISNULL(HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER SERVER STATE'), 0)", src); + Assert.DoesNotContain("HAS_PERMS_BY_NAME(NULL, NULL, 'ALTER')", src); + // PermissionDenied carries grant guidance. + Assert.Contains("GRANT ALTER SERVER STATE", src); + } + + [Fact] + public void Executor_HexHandleParse_RejectsMalformed_NeverThrows() + { + // The query_hash → binary(8) bind path: a typed param, never concatenated. A + // malformed value parses to null (→ Skip), never a DBCC string. + Assert.Null(DatabaseService.TryParseHexHandle(null)); + Assert.Null(DatabaseService.TryParseHexHandle("")); + Assert.Null(DatabaseService.TryParseHexHandle("0x")); + Assert.Null(DatabaseService.TryParseHexHandle("0xABC")); // odd length + Assert.Null(DatabaseService.TryParseHexHandle("0xZZ")); // not hex + var ok = DatabaseService.TryParseHexHandle("0xABCDEF0123456789"); + Assert.NotNull(ok); + Assert.Equal(8, ok!.Length); + // No "0x" prefix is also accepted (raw hex). + Assert.Equal(8, DatabaseService.TryParseHexHandle("ABCDEF0123456789")!.Length); + } + + // ── Detector row-level exclusion (§2a / R2-MOD-A / R2-MOD-B) ───────────────── + // + // The production detector runs this logic as T-SQL against collect.query_stats; the + // PlanCacheAnomalyDetector reference encodes the SAME row-level exclusion + per-exec + // math so the catastrophic correctness rules are headlessly guarded. + + private static readonly DateTime ServerUp = new(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime CurrentStart = new(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc); + + private static PlanCacheAnomalyDetector.StatRow Row( + string hash, string plan, DateTime t, long workerUs, long execs, + DateTime? serverStart = null, string sqlH = "S1", int so = 0, int eo = -1) => + new(hash, sqlH, so, eo, plan, t, serverStart ?? ServerUp, workerUs, execs); + + [Fact] + public void Detector_LeakyFirstCollection_YieldsNoTarget() + { + // R2-MOD-A: a plan_handle whose GLOBAL-first collection is the FIRST in-window row, + // carrying delta = full cumulative raw total. The leaky "≥2 in-window" gate would + // wrongly pass it; the row-level exclusion drops it. There is no earlier collection + // for (sql_handle, offsets, plan_handle) → not a real-delta row → excluded. + var rows = new List + { + // baseline window: a normal plan at low per-exec CPU, WITH a real prior. + Row("0xQ1", "P0", CurrentStart.AddHours(-10), 1_000_000, 1000), // first-collection of P0 (no prior) — excluded + Row("0xQ1", "P0", CurrentStart.AddHours(-9), 10_000, 1000), // real delta: 10us/exec + // current window: a NEW plan_handle P1 whose FIRST collected row carries 6h of + // accumulated CPU as one delta (the contamination this feature targets). + Row("0xQ1", "P1", CurrentStart.AddHours(1), 6_000_000_000, 1000), // first-collection of P1 — excluded + }; + + var results = PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart); + // The only real-delta row is the one baseline row; current window has no real-delta + // rows → no current execs → no anomaly. The fake 6h spike never counts. + Assert.Empty(results); + } + + [Fact] + public void Detector_ServerRestartRow_YieldsNoTarget() + { + // R2-MOD-B: the first post-restart row (server_start_time >= prior collection_time) + // carries delta = full cumulative raw total. The row-level predicate requires the + // prior collection_time > this row's server_start_time, so a post-restart row is + // excluded even though an earlier collection exists. + var restart = CurrentStart.AddMinutes(30); // server restarted mid-window + var rows = new List + { + Row("0xQ2", "P0", CurrentStart.AddHours(-9), 9_000, 1000), // first-collection — excluded + Row("0xQ2", "P0", CurrentStart.AddHours(-8), 9_000, 1000, ServerUp), // real delta (prior at -9 > ServerUp) + // post-restart row: huge raw total; prior collection (-8) is BEFORE the new + // server_start_time (restart), so it is NOT a real-delta row → excluded. + Row("0xQ2", "P0", CurrentStart.AddHours(1), 9_000_000_000, 1000, restart), + }; + + var results = PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart); + Assert.Empty(results); + } + + [Fact] + public void Detector_SyntheticMassRestart_ProducesNoTargetsEnMasse() + { + // A mass restart: MANY plan_handles each with a first-post-restart raw-total row in + // the current window. None has a real-delta current row → no targets en masse. + var restart = CurrentStart.AddMinutes(15); + var rows = new List(); + for (int i = 0; i < 50; i++) + { + var hash = "0xQ" + i.ToString("X"); + var plan = "P" + i; + // a real baseline so each query has a baseline per-exec + rows.Add(Row(hash, plan, CurrentStart.AddHours(-9), 5_000, 500, ServerUp, sqlH: "S" + i)); + rows.Add(Row(hash, plan, CurrentStart.AddHours(-8), 5_000, 500, ServerUp, sqlH: "S" + i)); + // the mass post-restart contaminated row in the current window + rows.Add(Row(hash, plan, CurrentStart.AddMinutes(20), 8_000_000_000, 500, restart, sqlH: "S" + i)); + } + + var results = PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart); + Assert.Empty(results); + } + + [Fact] + public void Detector_GenuinePerExecJump_OnRealDeltaRows_EmitsTarget() + { + // A genuine per-exec jump on REAL-prior-delta rows (an earlier collection exists, + // after server_start_time) DOES emit a target. Baseline ~10us/exec; current + // ~50us/exec = 5x, material CPU. + var rows = new List + { + Row("0xHOT", "P0", CurrentStart.AddHours(-9), 9_999_999, 1000), // first-collection — excluded + Row("0xHOT", "P0", CurrentStart.AddHours(-8), 10_000_000, 1000), // baseline real delta: 10us/exec + Row("0xHOT", "P0", CurrentStart.AddHours(-7), 10_000_000, 1000), // baseline real delta: 10us/exec + Row("0xHOT", "P0", CurrentStart.AddHours(1), 50_000_000, 1000), // current real delta: 50us/exec + }; + + var results = PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart); + var hit = Assert.Single(results); + Assert.Equal("0xHOT", hit.QueryHash); + Assert.True(hit.AnomalyRatio >= 4.5 && hit.AnomalyRatio <= 5.5, $"ratio was {hit.AnomalyRatio}"); + Assert.True(hit.CurrentCpuPerExecMs > hit.BaselineCpuPerExecMs); + } + + [Fact] + public void Detector_StableExpensiveQuery_NoTarget() + { + // A query that is expensive but STABLE (high but unchanging per-exec) is NOT a + // clear-plan candidate — the anomaly gate (ratio < T) excludes it. + var rows = new List + { + Row("0xSTABLE", "P0", CurrentStart.AddHours(-9), 100_000_000, 1000), // first-collection — excluded + Row("0xSTABLE", "P0", CurrentStart.AddHours(-8), 100_000_000, 1000), // baseline 100us/exec + Row("0xSTABLE", "P0", CurrentStart.AddHours(1), 105_000_000, 1000), // current 105us/exec — ~1.05x + }; + + Assert.Empty(PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart)); + } + + [Fact] + public void Detector_AnomalousButTrivialCpu_NoTarget() + { + // Abnormal per-exec ratio but below the materiality floor → no target (clearing + // a trivial-CPU query is pointless). + var rows = new List + { + Row("0xTINY", "P0", CurrentStart.AddHours(-9), 100, 10), // first-collection — excluded + Row("0xTINY", "P0", CurrentStart.AddHours(-8), 100, 10), // baseline 10us/exec + Row("0xTINY", "P0", CurrentStart.AddHours(1), 5_000, 10), // current 500us/exec = 50x but only 5ms total + }; + + Assert.Empty(PlanCacheAnomalyDetector.Evaluate(rows, CurrentStart)); + } + + // ── cpu_percent fix (PR-A security review LOW-1) ───────────────────────────── + // + // The live detector ran in T-SQL against collect.query_stats; the cpu_percent SHARE is + // computed in that SQL (numerator/denominator over the SAME §2a real-delta rows). These + // source-level assertions guard that PR-B replaced the hardcoded 0 with the real share. + + [Fact] + public void Detector_CpuPercent_IsRealWindowShare_NotHardcodedZero() + { + var dir = FindDashboardSourceDir(); + var src = File.ReadAllText(Path.Combine(dir, "Analysis", "SqlServerDrillDownCollector.cs")); + + // The CollectAbnormalCpuPlans detector must NO LONGER hardcode cpu_percent = 0. + Assert.DoesNotContain("cpu_percent = 0,", src); + + // It computes the real share: a window_total CTE (same §2a real-delta exclusion) as + // the denominator, and cpu_percent = the per-query current_total_cpu_ms / that total. + Assert.Contains("window_total", src); + Assert.Contains("cpu_percent =", src); + Assert.Contains("w.current_total_cpu_ms / NULLIF(wt.total_cpu_ms, 0)", src); + Assert.Contains("CROSS JOIN window_total AS wt", src); + } + + [Fact] + public void ClearPlanFigures_CarryRealCpuPercent_Through_BuildClearPlanAction() + { + // The carried path (NOT the live finding): a finding whose abnormal_cpu_plans row + // has a real cpu_percent flows that number onto ClearPlanFigures, and the disclosure + // renders it — so the dialog/disclosure show the real % at apply time. + var action = FactRemediation.BuildClearPlanAction(CpuFinding(new List + { + new { query_hash = "0xABCDEF0123456789", database = "Db", current_cpu_per_exec_ms = 45.0, + baseline_cpu_per_exec_ms = 9.0, anomaly_ratio = 5.0, execution_count = 1200L, + total_cpu_ms = 54000.0, latest_plan_handle = "0x06", query_text = "q", + cpu_percent = 73, plan_regression_cofired = false, parameter_sensitivity_cofired = false } + })); + Assert.NotNull(action); + Assert.Equal(73, action!.ClearPlanFigures!.CpuPercent); + Assert.NotEqual(0, action.ClearPlanFigures.CpuPercent); + + var d = FactRiskDisclosure.GetForAction(action, null)!; + Assert.Contains(d.RisksOfNotChanging, r => r.Text.Contains("73%")); + } + + // ── Full lock-mode vocabulary classifier (M-1) ─────────────────────────────── + + [Theory] + [InlineData("S", RcsiLockClass.Reader)] + [InlineData("IS", RcsiLockClass.Reader)] + [InlineData("RangeS-S", RcsiLockClass.Reader)] + [InlineData("RangeS-U", RcsiLockClass.Reader)] + [InlineData("X", RcsiLockClass.Writer)] + [InlineData("IX", RcsiLockClass.Writer)] + [InlineData("U", RcsiLockClass.Writer)] + [InlineData("UIX", RcsiLockClass.Writer)] + [InlineData("RangeX-X", RcsiLockClass.Writer)] + [InlineData("RangeI-N", RcsiLockClass.Writer)] + [InlineData("Sch-S", RcsiLockClass.Excluded)] + [InlineData("Sch-M", RcsiLockClass.Excluded)] + [InlineData("BU", RcsiLockClass.Excluded)] + [InlineData("IU", RcsiLockClass.Excluded)] + [InlineData("SIU", RcsiLockClass.Excluded)] + [InlineData("SIX", RcsiLockClass.Excluded)] + [InlineData("", RcsiLockClass.Excluded)] + [InlineData(null, RcsiLockClass.Excluded)] + public void LockModeClassifier_FullVocabulary(string? lockMode, RcsiLockClass expected) + { + Assert.Equal(expected, RcsiLockModeClassifier.Classify(lockMode)); + } + + // ── Drill-down enrichment parity (types only, per M-2) ─────────────────────── + + [Fact] + public void DrillDownEnrichment_RcsiFields_TypeParity_DashboardVsLite() + { + // The Dashboard shape carries real ints + a nullable int; the Lite shape carries + // 0/0/null. The shared extractor (FactRiskDisclosure) must read BOTH shapes with + // identical field NAMES and TYPES (int / int / nullable-int). Round-trip via JSON + // (mirrors how the collectors serialize the anonymous objects). + var dashboardRow = new + { + database = "Foo", recovery_model = "FULL", rcsi = false, query_store = true, + issues = new[] { "RCSI OFF" }, auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + rcsi_blocking_events = 12, rcsi_deadlocks = 3, rcsi_reader_writer_pct = (int?)80 + }; + var liteRow = new + { + database = "Foo", recovery_model = "FULL", rcsi = false, query_store = true, + issues = new[] { "RCSI OFF" }, auto_shrink = false, auto_close = false, page_verify = "CHECKSUM", + rcsi_blocking_events = 0, rcsi_deadlocks = 0, rcsi_reader_writer_pct = (int?)null + }; + + var dash = System.Text.Json.JsonSerializer.SerializeToElement((object)dashboardRow); + var lite = System.Text.Json.JsonSerializer.SerializeToElement((object)liteRow); + foreach (var field in new[] { "rcsi_blocking_events", "rcsi_deadlocks", "rcsi_reader_writer_pct" }) + { + Assert.True(dash.TryGetProperty(field, out var dv), $"Dashboard shape missing {field}"); + Assert.True(lite.TryGetProperty(field, out var lv), $"Lite shape missing {field}"); + // counts are Number on both; pct is Number on Dashboard and Null on Lite (nullable int) + Assert.Equal(System.Text.Json.JsonValueKind.Number, dv.ValueKind); + if (field == "rcsi_reader_writer_pct") + Assert.Equal(System.Text.Json.JsonValueKind.Null, lv.ValueKind); + else + Assert.Equal(System.Text.Json.JsonValueKind.Number, lv.ValueKind); + } + } + + // ── §1 identifier safety: no-injection / QUOTENAME / same-string (M-1) ─────── + // + // These run against the EXECUTOR's OWN statement builder (DatabaseService. + // BuildAlterStatement), NOT the display renderer (m-B). The builder is the exact + // composition SetDatabaseOptionAsync uses for the executed statement. + + [Theory] + [InlineData(DbConfigSetting.AutoShrinkOff, "SET AUTO_SHRINK OFF")] + [InlineData(DbConfigSetting.AutoCloseOff, "SET AUTO_CLOSE OFF")] + [InlineData(DbConfigSetting.PageVerifyChecksum, "SET PAGE_VERIFY CHECKSUM")] + public void Build_SetClause_IsHardcodedLiteral_RegardlessOfName(DbConfigSetting setting, string expectedClause) + { + // The SET clause is byte-identical regardless of the (pathological) db name. + foreach (var name in new[] { "Db", "Db]; DROP TABLE x--", "[weird]", "Ünîçödé" }) + { + var stmt = DatabaseService.BuildAlterStatement(name, setting); + Assert.Contains(expectedClause + ";", stmt); + Assert.EndsWith(expectedClause + ";", stmt); + } + } + + [Theory] + [InlineData("normal")] + [InlineData("has]bracket")] + [InlineData("has]]double")] + [InlineData("ev;il-- GO DROP")] + [InlineData("'; ALTER DATABASE master SET")] + [InlineData("Ünîçödé​")] // unicode + zero-width + public void Build_BracketsIdentifierInert_NoInjection(string name) + { + var stmt = DatabaseService.BuildAlterStatement(name, DbConfigSetting.AutoShrinkOff); + + // The identifier is bracketed with ]-doubling; the whole thing is one ALTER + // with exactly one bracketed token and the hardcoded clause. + var expectedToken = "[" + name.Replace("]", "]]") + "]"; + Assert.Equal($"ALTER DATABASE {expectedToken} SET AUTO_SHRINK OFF;", stmt); + + // No un-doubled close-bracket can prematurely end the identifier: the only + // ']' runs in the statement are the doubled ones we produced plus the final. + // Reconstruct: stripping the known prefix/suffix yields exactly the token. + Assert.StartsWith("ALTER DATABASE [", stmt); + } + + [Fact] + public void Build_SameString_ValidatedNameIsBracketedExactly() + { + // M-1: the bracketed token is byte-identical to the validated name passed in + // — an implementer cannot validate one variable and bracket a trimmed/cased one. + const string validated = " Padded Name "; // deliberate surrounding whitespace + var stmt = DatabaseService.BuildAlterStatement(validated, DbConfigSetting.AutoCloseOff); + Assert.Equal("ALTER DATABASE [ Padded Name ] SET AUTO_CLOSE OFF;", stmt); + + // A name differing only by trailing whitespace produces a DIFFERENT statement, + // proving the builder brackets exactly what it was given (no normalization). + var stmt2 = DatabaseService.BuildAlterStatement(validated.TrimEnd(), DbConfigSetting.AutoCloseOff); + Assert.NotEqual(stmt, stmt2); + } + + [Fact] + public void Build_DisplayRenderer_NotExecuted_ExecutorHasOwnBuilder() + { + // m-B: the executor's builder is byte-identical to the display QUOTENAME but + // is its OWN routine in the Dashboard assembly (not the private Analysis one). + // Spot-check parity on a ]-containing name. + var stmt = DatabaseService.BuildAlterStatement("a]b", DbConfigSetting.PageVerifyChecksum); + Assert.Equal("ALTER DATABASE [a]]b] SET PAGE_VERIFY CHECKSUM;", stmt); + } + + // ── Reachability-with-gate guard (replaces PR-A's no-caller guard) ────────── + // + // PR-A asserted the privileged machinery had NO non-core caller. PR-B adds a + // legitimate caller (the Apply Fix UI), so "no caller" can no longer hold. The + // invariant becomes "reachable ONLY through the gate," proven in two parts: + // + // A. The core machinery TYPES + force/unforce methods (handler, registry, + // executor, ForcePlanAsync/UnforcePlanAsync) are still referenced ONLY + // inside the remediation core — the UI never touches them directly. + // B. The single gated entry point (RemediationApplyService) is referenced + // outside the core ONLY by the sanctioned Apply Fix UI files. That facade + // runs the operator confirm before any handler.ApplyAsync (proven + // behaviourally by Gate_* in RemediationApplyServiceTests). + // + // Together: the UI reaches the privileged executor only via RemediationApplyService, + // and RemediationApplyService reaches the handler only after confirm() == true. + + private static readonly string[] CoreMachineryMarkers = + { + "ForcePlanAsync", "UnforcePlanAsync", + "RemediationHandlerRegistry", "DatabaseServiceRemediationExecutor", + "ForcePlanHandler", "IRemediationExecutor", "IRemediationHandler", + // B3 Phase 2 privileged surface (M-2): the new handler + executor methods + // must be reachable ONLY through the gate, exactly like the force-plan ones. + "DbConfigHandler", "SetDatabaseOptionAsync", "PreflightDbConfigAsync", + // B3 Phase 3 (m-3): the destructive RCSI handler must be reachable ONLY through + // the gate. It is UNREGISTERED in PR-A (dead-code-safe), but the marker still + // guards against any UI/MCP/menu file referencing it directly. + "RcsiHandler", + // Clear-cached-plan (DBCC FREEPROCCACHE) destructive core: the new handler + the + // DISTINCTIVE executor method name. UNREGISTERED in PR-A (dead-code-safe); the + // markers still guard against any UI/MCP/menu file referencing the privileged + // surface directly. ClearProcCacheAsync is distinctive enough not to substring- + // false-match an unrelated reference. + "ClearPlanHandler", "ClearProcCacheAsync", + // WS3 percent-autogrowth always-safe core: the new handler + the DISTINCTIVE + // executor method name. Reachable ONLY through the gate, exactly like DbConfigHandler. + "FileAutogrowthHandler", "SetFileGrowthAsync", "PreflightFileGrowthAsync", + // WS3 server-level config always-safe core (MAXDOP/CTFP sp_configure): the new handler + the + // DISTINCTIVE executor method names. Reachable ONLY through the gate, exactly like DbConfigHandler. + "ServerConfigHandler", "SetServerConfigAsync", "PreflightServerConfigAsync", + }; + + [Fact] + public void CoreMachinery_OnlyReferencedInRemediationCore() + { + var dashboardDir = FindDashboardSourceDir(); + + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(dashboardDir, "*.cs", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(dashboardDir, file).Replace('\\', '/'); + if (rel.StartsWith("bin/") || rel.StartsWith("obj/")) + continue; + + var allowed = + rel.StartsWith("Services/Remediation/") || + rel == "Services/DatabaseService.Remediation.cs"; + if (allowed) + continue; + + var text = File.ReadAllText(file); + if (CoreMachineryMarkers.Any(text.Contains)) + offenders.Add(rel); + } + + Assert.True(offenders.Count == 0, + "The privileged remediation machinery (force/unforce, handler, registry, executor) " + + "must be reached ONLY through RemediationApplyService — no UI/MCP/menu/command file " + + "may reference the machinery types directly. Offending files: " + string.Join(", ", offenders)); + } + + [Fact] + public void GatedEntry_ReferencedOnlyBySanctionedUiPath() + { + var dashboardDir = FindDashboardSourceDir(); + + // The ONLY files outside the remediation core allowed to reach the gated + // facade. Any other file gaining a reference to RemediationApplyService is a + // new, unreviewed path to the privileged executor and must fail the build. + var sanctioned = new HashSet(StringComparer.Ordinal) + { + "MainWindow.xaml.cs", // constructs + injects the service + "Controls/AlertsHistoryContent.xaml.cs", // threads it into the alert detail dialog + "AlertDetailWindow.xaml.cs", // invokes Apply/Un-apply via the service + "RemediationConfirmWindow.xaml.cs", // the confirm modal (gate UI) + "ServerTab.xaml.cs", // forwards it to the Recommendations sub-tab (WS1b-2) + "Controls/RecommendationsContent.xaml.cs", // invokes Apply via the service (WS1b-2) + }; + + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(dashboardDir, "*.cs", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(dashboardDir, file).Replace('\\', '/'); + if (rel.StartsWith("bin/") || rel.StartsWith("obj/")) + continue; + if (rel.StartsWith("Services/Remediation/")) + continue; + if (sanctioned.Contains(rel)) + continue; + + var text = File.ReadAllText(file); + if (text.Contains("RemediationApplyService")) + offenders.Add(rel); + } + + Assert.True(offenders.Count == 0, + "Only the sanctioned Apply Fix UI path may reference RemediationApplyService. " + + "A new reference is an unreviewed path to the privileged executor. Offending files: " + + string.Join(", ", offenders)); + } + + private static string FindDashboardSourceDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "Dashboard", "Services", "Remediation", "ForcePlanHandler.cs"); + if (File.Exists(candidate)) + return Path.Combine(dir.FullName, "Dashboard"); + dir = dir.Parent; + } + throw new DirectoryNotFoundException("Could not locate the Dashboard source directory from " + AppContext.BaseDirectory); + } + + private sealed class FakeExecutor : IRemediationExecutor + { + public bool AuditTableExists = true; + public bool PriorForce = true; + public bool AuditWriteResult = true; + public Func? PreflightFunc; + public Func? ForceFunc; + public Func? UnforceFunc; + + public int ForceCalls; + public int UnforceCalls; + public readonly List AuditRecords = new(); + + public Task PreflightForcePlanAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(PreflightFunc?.Invoke(database, queryId, planId) ?? new TargetPreflight + { + Database = database, QueryId = queryId, PlanId = planId, + CurrentDatabase = database, HasAlter = true, QueryStoreState = "READ_WRITE", PlanPresent = true + }); + + public Task AuditTableExistsAsync(CancellationToken ct) => Task.FromResult(AuditTableExists); + + public Task HasPriorForceAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(PriorForce); + + public Task ForcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + { + ForceCalls++; + return Task.FromResult(ForceFunc?.Invoke(database, queryId, planId) ?? new ForcePlanOutcome + { + Database = database, QueryId = queryId, PlanId = planId, + Status = RemediationStatus.Success, Forced = true, ExecutingLogin = "sa", GateSpid = 55, ExecSpid = 55 + }); + } + + public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + { + UnforceCalls++; + return Task.FromResult(UnforceFunc?.Invoke(database, queryId, planId) ?? new ForcePlanOutcome + { + Database = database, QueryId = queryId, PlanId = planId, + Status = RemediationStatus.Success, Forced = true, ExecutingLogin = "sa", GateSpid = 55, ExecSpid = 55 + }); + } + + // ── DB-config seams (Phase 2) ──────────────────────────────────────────── + public Func? DbPreflightFunc; + public Func? SetDbFunc; + public int SetDbCalls; + + public Task PreflightDbConfigAsync(string database, DbConfigSetting setting, CancellationToken ct) + => Task.FromResult(DbPreflightFunc?.Invoke(database, setting) ?? new DbConfigPreflight + { + Database = database, Setting = setting, DatabaseExists = true, HasAlter = true, + AlreadyInDesiredState = false, ExecutingLogin = "sa", CurrentValue = "ON" + }); + + public Task SetDatabaseOptionAsync(string database, DbConfigSetting setting, RemediationIdentity identity, CancellationToken ct) + { + SetDbCalls++; + return Task.FromResult(SetDbFunc?.Invoke(database, setting) ?? new DbConfigOutcome + { + Database = database, Setting = setting, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "sa", PriorValue = "ON", GeneratedSql = "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", + GateSpid = 55, ExecSpid = 55 + }); + } + + // ── Clear-cached-plan seam (DBCC FREEPROCCACHE) ────────────────────────── + public Func? ClearPlanFunc; + public int ClearPlanCalls; + + public Task ClearProcCacheAsync(string queryHash, RemediationIdentity identity, CancellationToken ct) + { + ClearPlanCalls++; + return Task.FromResult(ClearPlanFunc?.Invoke(queryHash) ?? new ClearPlanOutcome + { + QueryHash = queryHash, Status = RemediationStatus.Success, Cleared = true, HandlesCleared = 1, + ExecutingLogin = "sa", Message = "Cleared 1 cached plan(s).", + GeneratedSql = "DBCC FREEPROCCACHE(0xDEADBEEF);", PriorValue = "1 plan(s) cached for this query hash", + GateSpid = 55, ExecSpid = 55 + }); + } + + // ── Server-config seam (sp_configure MAXDOP/CTFP + RECONFIGURE) ─────────── + // Default-only here; the behavioural server-config coverage (and its own injectable + // fake) lives in ServerConfigHandlerTests. These satisfy the interface so this shared + // fake still compiles. + public Task PreflightServerConfigAsync(ServerConfigSetting setting, long recommendedValue, CancellationToken ct) + => Task.FromResult(new ServerConfigPreflight + { + Setting = setting, RecommendedValue = recommendedValue, + Executable = setting is ServerConfigSetting.Maxdop or ServerConfigSetting.CostThreshold, + HasPermission = true, AlreadyInDesiredState = false, ExecutingLogin = "sa", CurrentValue = 0 + }); + + public Task SetServerConfigAsync(ServerConfigSetting setting, long value, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ServerConfigOutcome + { + Setting = setting, Status = RemediationStatus.Success, Applied = true, ExecutingLogin = "sa", + PriorValue = 0, GeneratedSql = "EXEC sys.sp_configure N'show advanced options', 1; RECONFIGURE; EXEC sys.sp_configure N'max degree of parallelism', @value; RECONFIGURE;", + GateSpid = 55, ExecSpid = 55 + }); + + // ── Percent-autogrowth seam (ALTER DATABASE … MODIFY FILE FILEGROWTH) ───── + // Default-only here; the behavioural file-growth coverage (and its own injectable + // fake) lives in FileAutogrowthHandlerTests. These satisfy the interface so this + // shared force-plan/DB-config/clear-plan fake still compiles. + public Task PreflightFileGrowthAsync(string database, string logicalFileName, int growthMb, CancellationToken ct) + => Task.FromResult(new FileGrowthPreflight + { + Database = database, LogicalFileName = logicalFileName, RecommendedGrowthMb = growthMb, + DatabaseExists = true, FileExists = true, HasAlter = true, AlreadyInDesiredState = false, + ExecutingLogin = "sa", CurrentValue = "percent" + }); + + public Task SetFileGrowthAsync(string database, string logicalFileName, int growthMb, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new FileGrowthOutcome + { + Database = database, LogicalFileName = logicalFileName, Status = RemediationStatus.Success, Applied = true, + ExecutingLogin = "sa", PriorValue = "percent", + GeneratedSql = $"ALTER DATABASE [{database}] MODIFY FILE (NAME = [{logicalFileName}], FILEGROWTH = {growthMb}MB);", + GateSpid = 55, ExecSpid = 55 + }); + + public Task WriteAuditAsync(RemediationAuditRecord record, CancellationToken ct) + { + AuditRecords.Add(record); + return Task.FromResult(AuditWriteResult); + } + } +} diff --git a/Dashboard.Tests/ServerConfigHandlerTests.cs b/Dashboard.Tests/ServerConfigHandlerTests.cs new file mode 100644 index 00000000..657ad5f7 --- /dev/null +++ b/Dashboard.Tests/ServerConfigHandlerTests.cs @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Services.Remediation; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Coverage for the always-safe server-config handler (SERVER_CONFIG, WS3): the self-gating +/// against a faked executor — audit-table hard block for the +/// executable settings (no mutation), the happy path running sp_configure for MAXDOP/CTFP and +/// writing one audit row per attempt, the advise-only memory settings recording an outcome WITHOUT +/// running sp_configure (and without needing the audit table), per-target independence, the +/// preflight dispositions, and the apply-only un-apply restriction. Mirrors the DB-config / +/// file-autogrowth handler tests; the single-connection (R2-MOD-1) guarantee is an executor concern +/// (SPID equality) not provable here. +/// +public class ServerConfigHandlerTests +{ + private static readonly RemediationIdentity Identity = + new("TESTDOMAIN\\tester", "Analysis: server_config [abcd1234]"); + + private static RemediationAction ServerAction(params ServerConfigTarget[] targets) => + new("SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: targets.ToList()); + + private static ServerConfigTarget MaxdopTarget(long current = 0, long recommended = 8) => + new(ServerConfigSetting.Maxdop, current, recommended); + + private static ServerConfigTarget CtfpTarget(long current = 5, long recommended = 50) => + new(ServerConfigSetting.CostThreshold, current, recommended); + + private static ServerConfigTarget MaxMemTarget() => + new(ServerConfigSetting.MaxServerMemory, 2147483647, 2147483647); + + private static ServerConfigTarget MinMemTarget() => + new(ServerConfigSetting.MinServerMemory, 24000, 24000); + + // ── Handler contract ──────────────────────────────────────────────────────── + + [Fact] + public void FactKey_IsServerConfig() + { + Assert.Equal("SERVER_CONFIG", new ServerConfigHandler().FactKey); + } + + [Fact] + public void NotDestructive_AndApplyOnly() + { + var handler = new ServerConfigHandler(); + Assert.False(handler.IsDestructive); // sp_configure MAXDOP/CTFP is online metadata + Assert.False(handler.SupportsUnapply); // no sensible reverse for MAXDOP/CTFP + } + + [Fact] + public async Task UnapplyAsync_Throws() + { + await Assert.ThrowsAsync(() => + new ServerConfigHandler().UnapplyAsync( + ServerAction(MaxdopTarget()), new FakeServerConfigExecutor(), Identity, CancellationToken.None)); + } + + [Fact] + public void Registry_ResolvesServerConfigHandler() + { + var registry = new RemediationHandlerRegistry( + new IRemediationHandler[] { new ForcePlanHandler(), new DbConfigHandler(), new ServerConfigHandler() }); + Assert.IsType(registry.TryGet("SERVER_CONFIG")); + } + + // ── Apply: executable settings (MAXDOP / CTFP) ──────────────────────────────── + + [Fact] + public async Task HappyPath_RunsSpConfigure_ForMaxdopAndCtfp_AndAudits() + { + var exec = new FakeServerConfigExecutor(); // default success + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget(0, 8), CtfpTarget(5, 50)), exec, Identity, CancellationToken.None); + + // One SetServerConfigAsync per executable target, each with its own recommended value. + Assert.Equal(2, exec.SetCalls); + Assert.Contains((ServerConfigSetting.Maxdop, 8L), exec.SetArgs); + Assert.Contains((ServerConfigSetting.CostThreshold, 50L), exec.SetArgs); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.True(o.AuditWritten); + Assert.False(o.AppliedButUnlogged); + }); + + // Audit rows: one per attempt, the precise taxonomy, server sentinel db, non-consent. + Assert.Equal(2, exec.AuditRecords.Count); + Assert.All(exec.AuditRecords, r => + { + Assert.Equal("SERVER_CONFIG", r.FactKey); + Assert.Equal("success", r.Result); + Assert.False(r.ConsentAcknowledged); + Assert.Null(r.QueryId); + Assert.Null(r.PlanId); + Assert.False(string.IsNullOrEmpty(r.TargetDatabase)); // NOT NULL column sentinel + }); + Assert.Contains(exec.AuditRecords, r => r.Action == "set_maxdop"); + Assert.Contains(exec.AuditRecords, r => r.Action == "set_cost_threshold"); + } + + [Fact] + public async Task AuditTableAbsent_HardBlocks_ExecutableTargets_NoMutation() + { + var exec = new FakeServerConfigExecutor { AuditTableExists = false }; + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget(), CtfpTarget()), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Blocked, o.Status); + Assert.False(o.AuditWritten); + Assert.Contains("3.0.0", o.Message); + }); + Assert.Equal(0, exec.SetCalls); // NOT mutated + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task PermissionDenied_FailsClosed_AuditSkipped() + { + var exec = new FakeServerConfigExecutor + { + SetFunc = (setting, value) => new ServerConfigOutcome + { + Setting = setting, Status = RemediationStatus.PermissionDenied, Applied = false, + Message = "lacks ALTER SETTINGS", PriorValue = 0 + } + }; + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.PermissionDenied, o.Status); + Assert.False(o.AppliedButUnlogged); + Assert.Equal("skipped", Assert.Single(exec.AuditRecords).Result); + Assert.Equal(1, exec.SetCalls); + } + + [Fact] + public async Task AppliesButAuditFails_FlagsAppliedButUnlogged() + { + var exec = new FakeServerConfigExecutor { AuditWriteResult = false }; + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Success, o.Status); + Assert.False(o.AuditWritten); + Assert.True(o.AppliedButUnlogged); + } + + [Fact] + public async Task OneTargetThrows_OthersStillRun() + { + var exec = new FakeServerConfigExecutor + { + SetFunc = (setting, value) => + { + if (setting == ServerConfigSetting.Maxdop) throw new InvalidOperationException("boom"); + return new ServerConfigOutcome { Setting = setting, Status = RemediationStatus.Success, Applied = true, PriorValue = 5 }; + } + }; + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget(), CtfpTarget()), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.Contains(result.Outcomes, o => o.Status == RemediationStatus.Error); + Assert.Contains(result.Outcomes, o => o.Status == RemediationStatus.Success); + Assert.Equal(2, exec.AuditRecords.Count); // error target still audited + } + + // ── Advise-only memory settings: NEVER run sp_configure ─────────────────────── + + [Fact] + public async Task MemoryTargets_AreAdviseOnly_NoMutation_NoAudit() + { + var exec = new FakeServerConfigExecutor(); + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxMemTarget(), MinMemTarget()), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.All(result.Outcomes, o => + { + Assert.Equal(RemediationStatus.Skipped, o.Status); + Assert.Contains("advise-only", o.Message); + Assert.False(o.AuditWritten); + }); + Assert.Equal(0, exec.SetCalls); // executor never touched for memory + Assert.Empty(exec.AuditRecords); + } + + [Fact] + public async Task MemoryTargets_AdviseOnly_EvenWhenAuditTableAbsent() + { + // Advise-only targets never mutate and never need the audit table, so an absent table must + // still yield a clean advise-only result (not a spurious block). + var exec = new FakeServerConfigExecutor { AuditTableExists = false }; + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxMemTarget()), exec, Identity, CancellationToken.None); + + var o = Assert.Single(result.Outcomes); + Assert.Equal(RemediationStatus.Skipped, o.Status); + Assert.Contains("advise-only", o.Message); + Assert.Equal(0, exec.SetCalls); + } + + [Fact] + public async Task MixedTargets_MaxdopApplied_MemoryAdviseOnly() + { + var exec = new FakeServerConfigExecutor(); + + var result = await new ServerConfigHandler().ApplyAsync( + ServerAction(MaxdopTarget(0, 8), MaxMemTarget()), exec, Identity, CancellationToken.None); + + Assert.Equal(2, result.Outcomes.Count); + Assert.Equal(1, exec.SetCalls); // only MAXDOP ran sp_configure + Assert.Contains((ServerConfigSetting.Maxdop, 8L), exec.SetArgs); + Assert.DoesNotContain(exec.SetArgs, a => a.Item1 == ServerConfigSetting.MaxServerMemory); + // One success audit (MAXDOP); memory advise-only writes nothing. + Assert.Single(exec.AuditRecords); + Assert.Equal("set_maxdop", exec.AuditRecords[0].Action); + } + + // ── Preflight dispositions ──────────────────────────────────────────────────── + + [Theory] + [InlineData(true, false, RemediationDisposition.Ok)] // has perm, not already -> ready + [InlineData(true, true, RemediationDisposition.AlreadyInDesiredState)] // already at recommended + [InlineData(false, false, RemediationDisposition.BlockNoAlter)] // no permission + public async Task Preflight_ClassifiesExecutableDisposition(bool hasPerm, bool alreadyDesired, RemediationDisposition expected) + { + var exec = new FakeServerConfigExecutor + { + PreflightFunc = (setting, rec) => new ServerConfigPreflight + { + Setting = setting, RecommendedValue = rec, Executable = true, + HasPermission = hasPerm, AlreadyInDesiredState = alreadyDesired, CurrentValue = 0 + } + }; + + var pre = await new ServerConfigHandler().PreflightAsync( + ServerAction(MaxdopTarget()), exec, CancellationToken.None); + Assert.Equal(expected, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task Preflight_MemoryTarget_IsAdviseOnlyDisposition() + { + var exec = new FakeServerConfigExecutor(); + + var pre = await new ServerConfigHandler().PreflightAsync( + ServerAction(MaxMemTarget()), exec, CancellationToken.None); + + Assert.Equal(RemediationDisposition.AdviseOnly, pre.Targets.Single().Disposition); + } + + [Fact] + public async Task Preflight_AuditTableAbsent_OverridesExecutableDisposition_NotAdviseOnly() + { + var exec = new FakeServerConfigExecutor { AuditTableExists = false }; + + var pre = await new ServerConfigHandler().PreflightAsync( + ServerAction(MaxdopTarget(), MaxMemTarget()), exec, CancellationToken.None); + + Assert.False(pre.AuditTableExists); + // Executable MAXDOP blocked by absent table; advise-only memory stays AdviseOnly. + Assert.Contains(pre.Targets, t => t.Disposition == RemediationDisposition.BlockAuditTableAbsent); + Assert.Contains(pre.Targets, t => t.Disposition == RemediationDisposition.AdviseOnly); + } + + /// + /// A standalone fake of exercising only the audit + the + /// server-config seam; the other members return harmless defaults. + /// + private sealed class FakeServerConfigExecutor : IRemediationExecutor + { + public bool AuditTableExists = true; + public bool AuditWriteResult = true; + + public Func? PreflightFunc; + public Func? SetFunc; + public int SetCalls; + public readonly List<(ServerConfigSetting Setting, long Value)> SetArgs = new(); + public readonly List AuditRecords = new(); + + public Task AuditTableExistsAsync(CancellationToken ct) => Task.FromResult(AuditTableExists); + + public Task PreflightServerConfigAsync(ServerConfigSetting setting, long recommendedValue, CancellationToken ct) + => Task.FromResult(PreflightFunc?.Invoke(setting, recommendedValue) ?? new ServerConfigPreflight + { + Setting = setting, RecommendedValue = recommendedValue, + Executable = setting is ServerConfigSetting.Maxdop or ServerConfigSetting.CostThreshold, + HasPermission = true, AlreadyInDesiredState = false, ExecutingLogin = "sa", CurrentValue = 0 + }); + + public Task SetServerConfigAsync(ServerConfigSetting setting, long value, RemediationIdentity identity, CancellationToken ct) + { + SetCalls++; + SetArgs.Add((setting, value)); + return Task.FromResult(SetFunc?.Invoke(setting, value) ?? new ServerConfigOutcome + { + Setting = setting, Status = RemediationStatus.Success, Applied = true, ExecutingLogin = "sa", + PriorValue = 0, GeneratedSql = $"EXEC sys.sp_configure N'...', {value}; RECONFIGURE;", + GateSpid = 55, ExecSpid = 55 + }); + } + + public Task WriteAuditAsync(RemediationAuditRecord record, CancellationToken ct) + { + AuditRecords.Add(record); + return Task.FromResult(AuditWriteResult); + } + + // ── Unused seams (harmless defaults) ────────────────────────────────────── + public Task PreflightForcePlanAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(new TargetPreflight { Database = database, QueryId = queryId, PlanId = planId }); + public Task HasPriorForceAsync(string database, long queryId, long planId, CancellationToken ct) + => Task.FromResult(false); + public Task ForcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database, QueryId = queryId, PlanId = planId, Status = RemediationStatus.Success }); + public Task UnforcePlanAsync(string database, long queryId, long planId, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ForcePlanOutcome { Database = database, QueryId = queryId, PlanId = planId, Status = RemediationStatus.Success }); + public Task PreflightDbConfigAsync(string database, DbConfigSetting setting, CancellationToken ct) + => Task.FromResult(new DbConfigPreflight { Database = database, Setting = setting }); + public Task SetDatabaseOptionAsync(string database, DbConfigSetting setting, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new DbConfigOutcome { Database = database, Setting = setting, Status = RemediationStatus.Success }); + public Task PreflightFileGrowthAsync(string database, string logicalFileName, int growthMb, CancellationToken ct) + => Task.FromResult(new FileGrowthPreflight { Database = database, LogicalFileName = logicalFileName, RecommendedGrowthMb = growthMb }); + public Task SetFileGrowthAsync(string database, string logicalFileName, int growthMb, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new FileGrowthOutcome { Database = database, LogicalFileName = logicalFileName, Status = RemediationStatus.Success }); + public Task ClearProcCacheAsync(string queryHash, RemediationIdentity identity, CancellationToken ct) + => Task.FromResult(new ClearPlanOutcome { QueryHash = queryHash, Status = RemediationStatus.Success }); + } +} diff --git a/Dashboard.Tests/ServerConfigRecommendationTests.cs b/Dashboard.Tests/ServerConfigRecommendationTests.cs new file mode 100644 index 00000000..bd0daf8c --- /dev/null +++ b/Dashboard.Tests/ServerConfigRecommendationTests.cs @@ -0,0 +1,295 @@ +/* + * 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 PerformanceMonitor.Analysis; +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Controls; +using PerformanceMonitorDashboard.Services.Recommendations; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS3 server-level config: the FactRemediation builder (edition-aware capped MAXDOP / flat CTFP / +/// advise-only memory), the persisted-action DTO round-trip, the Recommendations reader fan-out +/// (one card per ServerConfigTarget; MAXDOP/CTFP carry Apply, memory cards are copy-paste only), and +/// the card affordance model (Copy + Apply / Copy-only, NOT incidents). +/// +public class ServerConfigRecommendationTests +{ + // ── Builder: edition-aware capped MAXDOP, flat CTFP, advise-only memory ──────── + + private static AnalysisFinding ServerConfigFinding(string rootKey, object row) => new() + { + ServerId = 1, + ServerName = "SQL2022", + Category = "config", + StoryPath = rootKey, + StoryPathHash = "sc000001", + RootFactKey = rootKey, + DrillDown = new Dictionary + { + ["server_config"] = new List { row } + } + }; + + [Theory] + // Enterprise (3) -> 8, capped at cores-per-socket when smaller. + [InlineData(3, 16, 8)] // 8 <= 16 cores -> 8 + [InlineData(3, 4, 4)] // 8 capped to 4 cores + [InlineData(3, 0, 8)] // cores unknown -> edition value stands + // Standard (2) / unknown -> 4. + [InlineData(2, 16, 4)] + [InlineData(0, 16, 4)] + [InlineData(2, 2, 2)] // 4 capped to 2 cores + // Express (4) -> 1. + [InlineData(4, 16, 1)] + public void BuildServerConfig_Maxdop_EditionAware_CappedAtCores(int edition, int cores, long expectedRecommended) + { + var finding = ServerConfigFinding("CONFIG_MAXDOP", + new { setting = "maxdop", current_value = 0, edition, cores_per_socket = cores }); + + var action = FactRemediation.BuildServerConfigAction(finding); + + Assert.NotNull(action); + Assert.Equal("SERVER_CONFIG", action!.FactKey); + var t = Assert.Single(action.ServerConfigTargets!); + Assert.Equal(ServerConfigSetting.Maxdop, t.Setting); + Assert.Equal(0, t.CurrentValue); + Assert.Equal(expectedRecommended, t.RecommendedValue); + } + + [Fact] + public void BuildServerConfig_Ctfp_RecommendsFifty() + { + var finding = ServerConfigFinding("CONFIG_CTFP", + new { setting = "ctfp", current_value = 5, edition = 2, cores_per_socket = 8 }); + + var action = FactRemediation.BuildServerConfigAction(finding); + + var t = Assert.Single(action!.ServerConfigTargets!); + Assert.Equal(ServerConfigSetting.CostThreshold, t.Setting); + Assert.Equal(5, t.CurrentValue); + Assert.Equal(50, t.RecommendedValue); + } + + [Fact] + public void BuildServerConfig_MaxMemory_AdviseOnly_CarriesCurrent() + { + var finding = ServerConfigFinding("CONFIG_MAX_MEMORY_MB", + new { setting = "max_memory", current_value = 2147483647L, edition = 2, cores_per_socket = 8 }); + + var action = FactRemediation.BuildServerConfigAction(finding); + + var t = Assert.Single(action!.ServerConfigTargets!); + Assert.Equal(ServerConfigSetting.MaxServerMemory, t.Setting); + // Advise-only: recommended carries current (the executor refuses to apply it). + Assert.Equal(2147483647L, t.CurrentValue); + Assert.Equal(2147483647L, t.RecommendedValue); + } + + [Fact] + public void BuildServerConfig_MinMemory_AdviseOnly() + { + var finding = ServerConfigFinding("CONFIG_MIN_MAX_MEMORY_NARROW", + new { setting = "min_memory", current_value = 24000L, edition = 2, cores_per_socket = 8 }); + + var action = FactRemediation.BuildServerConfigAction(finding); + + var t = Assert.Single(action!.ServerConfigTargets!); + Assert.Equal(ServerConfigSetting.MinServerMemory, t.Setting); + Assert.Equal(24000L, t.CurrentValue); + } + + [Fact] + public void BuildServerConfig_NoDrillDown_ReturnsNull() + { + var finding = new AnalysisFinding { ServerId = 1, ServerName = "S", RootFactKey = "CONFIG_MAXDOP" }; + Assert.Null(FactRemediation.BuildServerConfigAction(finding)); + } + + [Fact] + public void BuildSpConfigureStatement_RendersHardcodedNameAndValue() + { + Assert.Equal( + "EXEC sys.sp_configure N'max degree of parallelism', 8;\nRECONFIGURE;", + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.Maxdop, 8)); + Assert.Equal( + "EXEC sys.sp_configure N'cost threshold for parallelism', 50;\nRECONFIGURE;", + FactRemediation.BuildSpConfigureStatement(ServerConfigSetting.CostThreshold, 50)); + } + + // ── DTO round-trip (persisted remediation_action_json) ──────────────────────── + + [Fact] + public void ServerConfigAction_RoundTripsThroughDto() + { + var action = new RemediationAction( + "SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: new[] + { + new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8), + new ServerConfigTarget(ServerConfigSetting.MaxServerMemory, 2147483647, 2147483647) + }); + + var json = AlertContextSerializer.SerializeAction(action); + Assert.NotNull(json); + var restored = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(restored); + Assert.Equal("SERVER_CONFIG", restored!.FactKey); + Assert.NotNull(restored.ServerConfigTargets); + Assert.Equal(2, restored.ServerConfigTargets!.Count); + + var maxdop = Assert.Single(restored.ServerConfigTargets, t => t.Setting == ServerConfigSetting.Maxdop); + Assert.Equal(0, maxdop.CurrentValue); + Assert.Equal(8, maxdop.RecommendedValue); + + var mem = Assert.Single(restored.ServerConfigTargets, t => t.Setting == ServerConfigSetting.MaxServerMemory); + Assert.Equal(2147483647L, mem.CurrentValue); + } + + [Fact] + public void LegacyActionWithoutServerConfig_DeserializesNull() + { + // A force-plan action (no ServerConfigTargets) must round-trip with the new field null — + // backward compatibility for legacy persisted contexts. + var action = new RemediationAction("PLAN_REGRESSION", "force", + new[] { new ForcePlanTarget("Db", 1, 2) }); + var restored = AlertContextSerializer.DeserializeAction(AlertContextSerializer.SerializeAction(action)); + Assert.NotNull(restored); + Assert.Null(restored!.ServerConfigTargets); + } + + // ── Reader fan-out: one card per target; MAXDOP/CTFP Apply, memory copy-only ─── + + private static AnalysisFinding RootedFinding(string rootKey, RemediationAction action) => new() + { + ServerId = 1, + ServerName = "SQL2022", + DatabaseName = null, // server-scoped + Severity = 0.4, + Category = "config", + RootFactKey = rootKey, + StoryPathHash = "h", + StoryPath = "p", + Remediation = action + }; + + [Fact] + public void Reader_FansAllFourServerConfigCards_WithCorrectAffordances() + { + // One synthetic finding carrying all four targets — exercises the reader's fan loop directly + // (each CONFIG_* roots its own finding in production; the loop turns N targets into N cards). + var action = new RemediationAction( + "SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: new[] + { + new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8), + new ServerConfigTarget(ServerConfigSetting.CostThreshold, 5, 50), + new ServerConfigTarget(ServerConfigSetting.MaxServerMemory, 2147483647, 2147483647), + new ServerConfigTarget(ServerConfigSetting.MinServerMemory, 24000, 24000), + }); + + var items = RecommendationsReader.MapEngineFindings(RootedFinding("CONFIG_MAXDOP", action)); + + Assert.Equal(4, items.Count); + Assert.All(items, i => + { + Assert.Equal(RecommendationSource.Engine, i.Source); + Assert.Null(i.Database); // server-scoped + Assert.Equal(RecommendationSetting.None, i.Setting); // never de-dupes + Assert.True(i.IsServerConfigAdvisory); + Assert.False(string.IsNullOrEmpty(i.CopyPasteSql)); // every card has copy-paste + Assert.Contains("SQL2022", i.Title); // title names the server + }); + + // MAXDOP card: Apply present (single-target SERVER_CONFIG action), title + copy-paste. + var maxdop = Assert.Single(items, i => i.Title.StartsWith("MAXDOP is 0")); + Assert.NotNull(maxdop.Remediation); + Assert.Equal("SERVER_CONFIG", maxdop.Remediation!.FactKey); + var mt = Assert.Single(maxdop.Remediation.ServerConfigTargets!); + Assert.Equal(ServerConfigSetting.Maxdop, mt.Setting); + Assert.Equal(8, mt.RecommendedValue); + Assert.Contains("max degree of parallelism", maxdop.CopyPasteSql); + + // CTFP card: Apply present. + var ctfp = Assert.Single(items, i => i.Title.StartsWith("Cost Threshold for Parallelism is 5")); + Assert.NotNull(ctfp.Remediation); + Assert.Equal(ServerConfigSetting.CostThreshold, Assert.Single(ctfp.Remediation!.ServerConfigTargets!).Setting); + Assert.Contains("cost threshold for parallelism", ctfp.CopyPasteSql); + + // max memory card: NO Apply (advise-only), copy-paste only. + var maxMem = Assert.Single(items, i => i.Title.StartsWith("max server memory is unconfigured")); + Assert.Null(maxMem.Remediation); + Assert.Contains("max server memory (MB)", maxMem.CopyPasteSql); + + // min memory card: NO Apply. + var minMem = Assert.Single(items, i => i.Title.StartsWith("min server memory is pinned near max")); + Assert.Null(minMem.Remediation); + Assert.Contains("min server memory (MB)", minMem.CopyPasteSql); + } + + // ── Affordance model: Copy + Apply (MAXDOP/CTFP), Copy-only (memory), NOT incidents ── + + private static RecommendationCardViewModel Card(RecommendationItem item) => new(item, "SQL2022"); + + [Fact] + public void MaxdopCard_ShowsCopyFixAndApply_NotIncident() + { + var item = new RecommendationItem + { + CanonicalSeverity = CanonicalSeverity.Warning, + RawSeverity = 0.4, + Source = RecommendationSource.Engine, + Setting = RecommendationSetting.None, + IsServerConfigAdvisory = true, + Title = "MAXDOP is 0 — SQL2022", + CopyPasteSql = "EXEC sys.sp_configure N'max degree of parallelism', 8;\nRECONFIGURE;", + Remediation = new RemediationAction("SERVER_CONFIG", "set", Array.Empty(), + ServerConfigTargets: new[] { new ServerConfigTarget(ServerConfigSetting.Maxdop, 0, 8) }), + StoryPathHash = "h" + }; + var card = Card(item); + + Assert.False(card.IsIncident); + Assert.False(card.ShowOpenInActiveQueries); + Assert.False(card.ShowAskAi); + Assert.True(card.ShowCopyFix); + Assert.True(card.ShowApply); + } + + [Fact] + public void MaxMemoryCard_ShowsCopyFixOnly_NoApply_NotIncident() + { + // The advise-only memory card carries NO Remediation, but IsServerConfigAdvisory keeps it a + // config fix (Copy fix) rather than a time-bound incident — and Apply is hidden (no action). + var item = new RecommendationItem + { + CanonicalSeverity = CanonicalSeverity.Warning, + RawSeverity = 0.4, + Source = RecommendationSource.Engine, + Setting = RecommendationSetting.None, + IsServerConfigAdvisory = true, + Title = "max server memory is unconfigured — SQL2022", + CopyPasteSql = "EXEC sys.sp_configure N'max server memory (MB)', 28672;\nRECONFIGURE;", + Remediation = null, + StoryPathHash = "h" + }; + var card = Card(item); + + Assert.False(card.IsIncident); // NOT a time-bound incident + Assert.False(card.ShowOpenInActiveQueries); // no incident affordances + Assert.False(card.ShowAskAi); + Assert.True(card.ShowCopyFix); // copy-paste shown + Assert.False(card.ShowApply); // advise-only — no Apply + } +} diff --git a/Dashboard.Tests/ServerPropertiesResilienceTests.cs b/Dashboard.Tests/ServerPropertiesResilienceTests.cs new file mode 100644 index 00000000..247afb05 --- /dev/null +++ b/Dashboard.Tests/ServerPropertiesResilienceTests.cs @@ -0,0 +1,60 @@ +using PerformanceMonitorDashboard.Analysis; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS5 version-skew resilience: the server_properties SELECT references the server-health columns +/// only when the DB has them, so a not-yet-upgraded server (Dashboard updated before that server's +/// PerformanceMonitor DB got the WS5 upgrade) reads the core hardware columns without a bind error +/// — keeping SERVER_HARDWARE flowing — instead of the whole collector throwing. +/// +public class ServerPropertiesResilienceTests +{ + [Fact] + public void Query_AllHealthColumns_SelectsThemPlusCore() + { + var sql = SqlServerFactCollector.BuildServerPropertiesQuery(hasLpim: true, hasIfi: true, hasDumps: true); + + Assert.Contains("lock_pages_in_memory", sql); + Assert.Contains("instant_file_initialization_enabled", sql); + Assert.Contains("memory_dump_count", sql); + Assert.Contains("cpu_count", sql); + Assert.Contains("FROM collect.server_properties", sql); + } + + [Fact] + public void Query_NoHealthColumns_OmitsThem_ButKeepsCore() + { + var sql = SqlServerFactCollector.BuildServerPropertiesQuery(hasLpim: false, hasIfi: false, hasDumps: false); + + // The WS5 columns must NOT be referenced — that is what avoids the bind error on a + // not-yet-upgraded server. + Assert.DoesNotContain("lock_pages_in_memory", sql); + Assert.DoesNotContain("instant_file_initialization_enabled", sql); + Assert.DoesNotContain("memory_dump_count", sql); + + // The core hardware columns are still selected, so SERVER_HARDWARE keeps flowing. + Assert.Contains("cpu_count", sql); + Assert.Contains("product_version", sql); + Assert.Contains("FROM collect.server_properties", sql); + } + + // The point of per-column probing: a partial / out-of-order schema (some columns present, some + // not) selects EXACTLY the present ones and never references an absent one — so the previous + // all-or-nothing assumption (and its coupling to the migration's column order) can't bite. + [Theory] + [InlineData(true, false, true)] // lpim + dumps present, ifi absent + [InlineData(false, true, false)] // ifi only + [InlineData(true, false, false)] // lpim only + [InlineData(false, false, true)] // dumps only + public void Query_PartialSubset_ReferencesOnlyPresentColumns(bool hasLpim, bool hasIfi, bool hasDumps) + { + var sql = SqlServerFactCollector.BuildServerPropertiesQuery(hasLpim, hasIfi, hasDumps); + + Assert.Equal(hasLpim, sql.Contains("lock_pages_in_memory")); + Assert.Equal(hasIfi, sql.Contains("instant_file_initialization_enabled")); + Assert.Equal(hasDumps, sql.Contains("memory_dump_count")); + Assert.Contains("cpu_count", sql); // core columns always present + } +} diff --git a/Dashboard.Tests/WebhookPayloadBrandingTests.cs b/Dashboard.Tests/WebhookPayloadBrandingTests.cs new file mode 100644 index 00000000..cd5c0bee --- /dev/null +++ b/Dashboard.Tests/WebhookPayloadBrandingTests.cs @@ -0,0 +1,42 @@ +using PerformanceMonitor.Notifications; +using PerformanceMonitorDashboard.Services; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// Identical-output invariant for Plan E Stage E3c: the shared WebhookAlertService payload +/// builders, fed Dashboard's wired AlertBranding (via EmailAlertService.Branding), must +/// reproduce Dashboard's pre-E3c Teams/Slack payloads — edition string "Performance Monitor +/// Dashboard" and NO snooze hint (Dashboard's AlertBranding.SnoozeHint is null). +/// +public class WebhookPayloadBrandingTests +{ + [Fact] + public void BuildTeamsPayload_DashboardBranding_IncludesEditionAndOmitsSnooze() + { + var branding = EmailAlertService.Branding; + Assert.Equal("Performance Monitor Dashboard", branding.EditionName); + Assert.Null(branding.SnoozeHint); + + var payload = WebhookAlertService.BuildTeamsPayload( + "High CPU", "TestServer", "95%", "90%", branding); + + Assert.Contains("Performance Monitor Dashboard", payload); + Assert.DoesNotContain("Performance Monitor Lite", payload); + // Lite's snooze hint must never appear under Dashboard branding. + Assert.DoesNotContain("Manage Mute Rules", payload); + } + + [Fact] + public void BuildSlackPayload_DashboardBranding_IncludesEditionAndOmitsSnooze() + { + var branding = EmailAlertService.Branding; + + var payload = WebhookAlertService.BuildSlackPayload( + "High CPU", "TestServer", "95%", "90%", branding); + + Assert.Contains("Sent by Performance Monitor Dashboard", payload); + Assert.DoesNotContain("Manage Mute Rules", payload); + } +} diff --git a/Dashboard/AboutWindow.xaml b/Dashboard/AboutWindow.xaml index 6d3d0134..3d729894 100644 --- a/Dashboard/AboutWindow.xaml +++ b/Dashboard/AboutWindow.xaml @@ -1,60 +1,60 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - GitHub Repository - - - - - Report an Issue - - - -