From de610a1e4bd3bee1ee86d1b50e5ffab53581bce7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:30:50 -0400 Subject: [PATCH] Release v2.8.0 (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implements #843 in Lite * Implements #843 for Full Dashboard * Add trailing newlines to ScrollPanBehavior files Co-Authored-By: Claude Opus 4.6 (1M context) * Harden DuckDB queries: parameterize values, escape paths, fix IsArchiving race Addresses security findings from #840: - #846: Escape single quotes in file paths interpolated into read_parquet() and COPY TO - #847: Use DuckDB $1 parameters for DateTime values instead of string interpolation - #849: Make IsArchiving volatile-backed to prevent stale reads across threads Co-Authored-By: Claude Opus 4.6 (1M context) * Encrypt webhook URLs with DPAPI via Windows Credential Manager Moves Teams and Slack webhook URLs from plaintext settings.json/preferences.json to Windows Credential Manager (DPAPI-encrypted), matching the existing pattern used for SMTP passwords and SQL Server credentials. Includes automatic migration: on first settings load, any plaintext URLs are moved to Credential Manager and removed from the JSON file. Closes #848 Co-Authored-By: Claude Opus 4.6 (1M context) * Lazy-load server tabs: only load visible tab on open, full-load on first visit Initial tab open and Refresh button now only load the currently visible tab. First switch to any tab triggers a full refresh of that tab (all sub-tabs). Subsequent refreshes only hit the active sub-tab. Ctrl+Click on Refresh Tab (or Ctrl+F5) refreshes all tabs at once. Apply to All Tabs retains existing full-refresh behavior. Fixes #835 — prevents heavy queries (e.g. GetQueryStatsAsync) from running on tab open when the user is only viewing Overview. Co-Authored-By: Claude Opus 4.6 (1M context) * Cap query/procedure/query store grid results to TOP 500 GetQueryStatsAsync, GetProcedureStatsAsync, and GetQueryStoreDataAsync were returning unbounded result sets. With 49 databases and 742K rows in query_stats over 3 days, the GROUP BY with plan XML could produce thousands of rows and timeout after 120 seconds. TOP 500 ordered by avg CPU desc is plenty for a grid view and prevents the query from consuming unbounded memory on large installations. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove pointless WAITFOR DECOMPRESS filters from stats/store queries The CAST(DECOMPRESS(...)) NOT LIKE N'WAITFOR%' filter was decompressing query text on every row in query_stats and query_store_data just to skip WAITFOR queries. WAITFOR has no plan and no meaningful stats — it only matters in query snapshots (active sessions), where the filter remains. On a 742K-row query_stats table, this was a significant contributor to the 120-second query timeouts reported in #835. The snapshot filters (report.query_snapshots) and MCP phased queries are untouched — they filter after TOP on already-hydrated text. Co-Authored-By: Claude Opus 4.6 (1M context) * Refactor query/procedure/query store stats to phased DECOMPRESS approach All three grid queries now use a 3-phase pattern: 1. Aggregate numerics into temp table (no DECOMPRESS) 2. Sum across lifetimes, rank TOP 500 3. OUTER APPLY to decompress text/plan for only the 500 winners On a 742K-row query_stats table, this reduces DECOMPRESS calls from 742K to 500 — eliminating the 16+ minute query times reported in #835. Matches the existing phased pattern used by the MCP query tools. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix FinOps TDE recommendation on SQL Server 2019+ (#854) TDE moved to Standard Edition in SQL 2019, so dm_db_persisted_sku_features no longer reports it as Enterprise-only. Add version check to give version-appropriate licensing guidance instead of falsely claiming no databases use TDE. Co-Authored-By: Claude Opus 4.6 (1M context) * Sync PlanAnalyzer and BenefitScorer from PerformanceStudio (Apr 9-16) Port PS PRs #216, #217, #219, #224, #229, #230, #231 to PM. PlanAnalyzer changes: - Rule 5: Suppress for Key Lookups (point lookups mislead per-execution estimates) - Rule 8: Enhanced parallel skew with batch mode sort detection and practical context - Rule 9: Large memory grant shows top 3 consumers sorted by row count - Rule 10: Key lookup overhaul — show output columns, check predicate filtering, softer advice - Rules 11/12/29: Suppress on 0-execution nodes (operator never ran) - Rule 11: I/O wait severity elevation when scan hits disk - Rule 24: FormatNodeRef helper includes object name for data access operators - Rule 26: Suppress when row goal prediction was correct, specific cause detection - Wait stats: DescribeWaitType with full wait type coverage, multi-wait summary - New helpers: GetWaitLabel, HasSignificantIoWaits, IdentifyRowGoalCause, FormatNodeRef - GetOperatorOwnElapsedMs changed to internal for BenefitScorer access BenefitScorer (new file): - Stage 1: MaxBenefitPercent for operator-level rules (filter, spill, lookup, etc.) - Stage 2: Wait stats benefit scoring with parallel allocation (Joe's formula) PlanModels additions: - MaxBenefitPercent and ActionableFix on PlanWarning - WaitBenefit class and WaitBenefits list on PlanStatement Co-Authored-By: Claude Opus 4.6 (1M context) * Fall back to single-database mode when Azure master is inaccessible (#857) On Azure SQL DB, some logins (e.g. Microsoft Dynamics 365 FO) are granted access only to a specific user database and not to master. The three collectors that enumerate databases via master — query_stats, database_size_stats, file_io_stats — would fail the first time and produce an empty screen. GetAzureDatabaseListAsync now catches known access-denied/login-failed errors from the master connection, caches the per-server decision, and returns the connection's InitialCatalog as a single-element list. The three callers already loop per-database, so single-DB mode works without further changes. Co-Authored-By: Claude Opus 4.7 (1M context) * Add nonclustered indexes for query/procedure/query store lookups Phase 3 OUTER APPLY hydration of compressed query_text/plan_text was forcing an Eager Index Spool over the full collect.query_stats table (and similar for procedure_stats / query_store_data), which took 104 seconds on a 742K-row table in #835. Changes: - Remove CONVERT(binary(8), nvarchar-hash, 1) anti-pattern from OUTER APPLY WHERE clauses by keeping query_hash as native binary(8) in temp tables. query_hash is only converted to nvarchar(20) in the final output projection. - Add three nonclustered indexes (install script and upgrade script): IX_query_stats_hash_lookup (query_hash, database_name, collection_time DESC) IX_procedure_stats_name_lookup (database_name, schema_name, object_name, collection_time DESC) IX_query_store_data_id_lookup (database_name, query_id, collection_time DESC) - Indexes use SORT_IN_TEMPDB = ON and DATA_COMPRESSION = PAGE. - ONLINE = ON is applied conditionally via dynamic SQL based on SERVERPROPERTY('EngineEdition') — Enterprise/Developer/Azure only, since Standard/Web/Express don't support online index operations. Tested against CADelete's 742K-row table: Phase 3 went from 104s to well under 1s (5s total for the full three-phase query). Fixes #835 Co-Authored-By: Claude Opus 4.7 (1M context) * Scope query snapshots to current database on Azure SQL DB (#857) On Azure SQL Database, logins without access to master can't resolve cross-database rows returned by sys.dm_exec_requests, which caused the Live Snapshot button and the query snapshots collector to error in D365FO-style environments (reported by @TrudAX in #857 after PR #858). BuildQuerySnapshotsQuery now takes an isAzureSqlDatabase flag and emits AND der.database_id = DB_ID() only when true. Boxed SQL Server, MI, and elastic pool behavior is unchanged. The Live Snapshot button path gets the flag through a new ServerTab constructor parameter wired from the cached ServerConnectionStatus.SqlEngineEdition. Co-Authored-By: Claude Opus 4.7 (1M context) * Polish Lite chart axes and sub-tab styling - Chart X-axis prints the date line only on the first tick and on ticks where the date changes; all other ticks show time only. Format respects current culture (en-GB → dd/MM, de-DE → dd.MM, 24h clocks, etc.). Implemented as a DateTimeTicksBottomDateChange() extension in Lite/Helpers/AxesExtensions.cs and applied to every DateTimeTicksBottom call site in ServerTab and CorrelatedTimelineLanesControl. - Server name no longer duplicated in the ServerTab header status line; ConnectionStatusText now shows just "Connecting..." / "Last refresh: ...". - Chart tick label font bumped from 12 to 13 for readability. - New SubTabItemStyle (thin accent underline, transparent background) in all three themes, applied to Queries / Memory / File I/O / Blocking / Perfmon / Running Jobs sub-TabControls so sub-tab selection no longer looks identical to main-tab selection. Co-Authored-By: Claude Opus 4.7 (1M context) * Port Lite chart/tab polish to Dashboard + LSP diagnostics cleanup Dashboard polish (ports the same items merged to Lite in #862): - New Dashboard/Helpers/AxesExtensions.cs with DateTimeTicksBottomDateChange(), culture-aware (dd/MM for en-GB, dd.MM for de-DE, 24h clocks, etc.). All 52 call sites of DateTimeTicksBottom() across 10 files swapped to use it. - TabHelpers.ApplyTheme + ReapplyAxisColors bump chart tick label font from 12 to 13 so numbers read cleaner on wide charts. - SubTabItemStyle added to Dark / Light / CoolBreeze themes: thin accent underline + transparent background instead of filled cyan, so sub-tabs don't look identical to main tabs when selected. Wired via ItemContainerStyle on 11 sub-TabControls (Overview's inner tabs, Collection Health's inner tabs, Locking, ConfigChanges, CurrentConfig, FinOps, Memory, ResourceMetrics ×2, SystemEvents, QueryPerformance). LSP diagnostics cleanup (tracked work from chore/lsp-diagnostics-cleanup): - Small nullability/warning fixes across Dashboard and Lite services, analysis helpers, and BenefitScorer / PlanAnalyzer. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Overview crosshair disappearing after tab switches / layout passes Root cause: the control wired `Unloaded += ...Dispose()` on the crosshair manager, and WPF fires Unloaded for transient reasons (tab virtualization, layout rebuilds, etc.), not just when the control is actually going away. Dispose() clears the manager's lane list, after which ReattachVLines runs over an empty list and the crosshair is gone permanently. Changes: - Remove the Unloaded → Dispose() handler in both Lite and Dashboard copies. The manager holds only managed state (a Popup + lane references) — GC will clean it up with the control. - Remove the now-redundant `_isRefreshing` flag from CorrelatedCrosshairManager. The `lane.VLine == null` check in OnMouseMove is a sufficient "not ready" guard and is self-healing once VLines are recreated. - Wrap ReattachVLines in a try/finally on the control side, with a new idempotent EnsureVLinesAttached() safety net that only creates VLines for lanes where they're still null. - Make CreateVLine catch per-lane exceptions so one failing chart can't prevent the others from recovering. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix Memory Pressure Events chart filter; add MCP interpretation (#865) Chart previously filtered to HIGH severity only (indicator>=3), which on most servers never fires, producing an empty chart even when sp_pressuredetector- level medium pressure (indicator=2) was occurring constantly. Switch to stacked bars per hour, split by SQL Server (process) vs Operating System (system), with severe events capped on top of medium in a darker shade. Extend ChartHoverHelper to support BarPlot tooltips. Add MCP guidance for interpreting indicator values and routing to the right follow-up tool. Co-Authored-By: Claude Opus 4.7 (1M context) * Port Memory Pressure Events feature to Lite (#865) Lite was missing the RING_BUFFER_RESOURCE_MONITOR collector entirely — no collector, no table, no chart, no MCP tool. This adds the full feature: - Schema: new memory_pressure_events table + index, schema v25, added to ArchivableTables, server-id-fix list, and ArchiveService. - Collector: CollectMemoryPressureEventsAsync queries the ring buffer and client-side-dedupes against DuckDB's MAX(sample_time). Azure SQL DB returns zero rows (ring buffer not exposed there). Scheduled every 5 min (Aggressive and Balanced presets) or 15 min (Low-Impact). - UI: new 'Memory Pressure Events' sub-tab on the Memory tab with the same stacked-bar chart as Dashboard (SQL Server medium/severe, Operating System medium/severe). Wired into full-load and sub-tab-switch refresh paths. - Hover: ported the BarPlot support from Dashboard's ChartHoverHelper so bar tooltips work and report the correct segment height for stacked bars. - MCP: new get_memory_pressure_events tool + the 'Interpreting Memory Pressure Events' guidance section in McpInstructions. Co-Authored-By: Claude Opus 4.7 (1M context) * Bump schema table count test to 30 for memory_pressure_events Companion update to the new memory_pressure_events table added in this PR. SchemaStatements_MatchTableCount asserts the total table count; needs to move from 29 to 30 to reflect the new table. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix blocked process report plan lookup (#867) (#868) Right-click > View Plan on a Blocked Process Reports row silently fell through (no handler case) and Get Actual Plan erred with "no query text." - Split the grid onto its own BlockedProcessContextMenu with separate View Blocked Plan / View Blocking Plan actions; drop Get Actual Plan (re-executing a mid-transaction blocked query is a foot-gun). - Parse all entries from the BPR XML's executionStack, filter the 42-byte all-zero sql_handle placeholder (dynamic SQL / system context), default stmtstart=0 / stmtend=-1 per the dm_exec_text_query_plan convention. Matches sp_HumanEventsBlockViewer's XPath and join shape. - Add FetchPlanBySqlHandleAsync keyed on sql_handle + statement offsets against sys.dm_exec_query_stats. Caller iterates frames until one resolves; falls back to a clear "plan no longer in cache" message. Co-authored-by: Claude Opus 4.7 (1M context) * Pre-filter query snapshot requests into #temp on Azure SQL DB (#857) (#869) Follow-up to #861. The DB_ID() predicate in the WHERE clause wasn't enough — the OUTER APPLYs to sys.dm_exec_sql_text and sys.dm_exec_text_query_plan were still being evaluated against master-scoped rows from sys.dm_exec_requests before the filter was applied, tripping VIEW SERVER PERFORMANCE STATE errors for DB-scoped logins (D365FO). A CTE or derived table wouldn't guarantee the filter order, so materialise the filtered request rows into #req first and drive the DMFs off that. Co-authored-by: Claude Opus 4.7 (1M context) * Stop retrying collectors after non-transient permission denial (#857) (#870) The collector loop already classifies SQL errors 229 / 297 / 300 as PERMISSIONS status and excludes them from the failure rate, but it keeps re-running the collector every interval and logging an identical denial each time. For DB-scoped logins on Azure SQL DB (e.g. D365FO) this churns the collection log and gives no new information — the permission won't change mid-session. Flag the collector on first denial and short-circuit RunCollectorAsync so we don't make the round-trip or the log entry. Flag is in-memory per (server, collector) — cleared on app restart so newly granted permissions are picked up on the next launch. Co-authored-by: Claude Opus 4.7 (1M context) * Skip live query plans on Azure SQL DB (#857) (#871) sys.dm_exec_query_statistics_xml requires VIEW SERVER PERFORMANCE STATE on Azure SQL Database regardless of scope, so DB-scoped logins (e.g. D365FO) still hit error 300 even after the #temp pre-filter landed in #869. The OUTER APPLY evaluates the DMF for every session in #req and fails on the permission check before returning rows. Force supportsLiveQueryPlan=false for SqlEngineEdition=5 in both the collector and the Live Snapshot button paths. Boxed SQL Server and Azure MI (edition 8) still get live plans as before. Co-authored-by: Claude Opus 4.7 (1M context) * Fix FinOps recommendation severity sort order (#872) Sort recommendations by severity rank (High=1, Medium=2, Low=3) instead of alphabetically. Adds SeveritySort property to RecommendationRow and uses it as SortMemberPath for the Severity column. Display strings are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix FinOps severity sort order in Dashboard (#872) Severity column was sorting alphabetically (High, Low, Medium) instead of by severity ranking. Added SeveritySort computed property on FinOpsRecommendation, ordered results by it, and wired the DataGrid column's SortMemberPath so click-sort matches the default order. Mirrors the Lite fix in PR #874. * Drop sys.dm_os_schedulers from memory_stats on Azure SQL DB (#857) (#876) Azure SQL Database DBs hosted in an elastic pool (notably D365FO customer tenants) enforce VIEW SERVER PERFORMANCE STATE on sys.dm_os_schedulers regardless of the login's DB-scoped grants — VIEW DATABASE STATE + VIEW DATABASE PERFORMANCE STATE on the user DB are not sufficient. Verified by reproducing the failure in a standard Azure SQL DB elastic pool with a contained DB user; bare sys.dm_exec_requests/sys.dm_os_sys_info/sys.dm_os_performance_counters succeed but sys.dm_os_memory_clerks / sys.dm_os_schedulers / sys.dm_os_waiting_tasks fail with error 300. The other failing collectors (memory_clerks, waiting_tasks, tempdb_stats) have no DB-scoped alternative and will stay skip-gated via #870 for these users. Co-authored-by: Claude Opus 4.7 (1M context) * Release v2.8.0: version bumps and changelog (#877) Adds nonclustered indexes to collect.query_stats, procedure_stats, and query_store_data for Dashboard grid lookups (#835). Ports Memory Pressure Events to Lite (#865). Multiple Azure SQL DB collector fixes (#857). FinOps severity sort order fix (#872). Grid auto-scrolling (#843). Co-authored-by: Claude Opus 4.7 (1M context) * Scope v2.8.0 webhook DPAPI note to Dashboard (#879) Lite webhook URLs still read from plaintext settings — avoids implying the security hardening shipped to both editions. Co-authored-by: Claude Opus 4.7 (1M context) --------- Co-authored-by: ClaudioESSilva Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 43 ++ .../Analysis/SqlServerBaselineProvider.cs | 2 +- Dashboard/Controls/ConfigChangesContent.xaml | 2 +- .../CorrelatedTimelineLanesControl.xaml.cs | 24 +- Dashboard/Controls/CurrentConfigContent.xaml | 2 +- Dashboard/Controls/FinOpsContent.xaml | 4 +- Dashboard/Controls/MemoryContent.xaml | 2 +- Dashboard/Controls/MemoryContent.xaml.cs | 150 +++- .../Controls/QueryPerformanceContent.xaml | 2 +- .../Controls/QueryPerformanceContent.xaml.cs | 4 +- .../Controls/ResourceMetricsContent.xaml | 4 +- .../Controls/ResourceMetricsContent.xaml.cs | 16 +- Dashboard/Controls/SystemEventsContent.xaml | 2 +- .../Controls/SystemEventsContent.xaml.cs | 36 +- Dashboard/Dashboard.csproj | 8 +- Dashboard/Helpers/AxesExtensions.cs | 45 ++ Dashboard/Helpers/ChartHoverHelper.cs | 51 +- .../Helpers/CorrelatedCrosshairManager.cs | 47 +- Dashboard/Helpers/ScrollPanBehavior.cs | 271 ++++++++ Dashboard/Helpers/TabHelpers.cs | 4 + Dashboard/Mcp/McpInstructions.cs | 44 ++ Dashboard/Mcp/McpSystemEventTools.cs | 12 +- Dashboard/Models/PlanModels.cs | 19 + Dashboard/ProcedureHistoryWindow.xaml.cs | 2 +- Dashboard/QueryExecutionHistoryWindow.xaml.cs | 2 +- Dashboard/QueryStatsHistoryWindow.xaml.cs | 2 +- Dashboard/ServerTab.xaml | 8 +- Dashboard/ServerTab.xaml.cs | 47 +- Dashboard/Services/BenefitScorer.cs | 653 ++++++++++++++++++ Dashboard/Services/DatabaseService.FinOps.cs | 125 ++-- .../DatabaseService.QueryPerformance.cs | 475 ++++++++----- Dashboard/Services/PlanAnalyzer.cs | 319 +++++++-- Dashboard/Services/WebhookAlertService.cs | 57 +- Dashboard/SettingsWindow.xaml.cs | 28 +- Dashboard/Themes/CoolBreezeTheme.xaml | 36 + Dashboard/Themes/DarkTheme.xaml | 36 + Dashboard/Themes/LightTheme.xaml | 36 + Dashboard/TracePatternHistoryWindow.xaml.cs | 2 +- Installer.Core/Installer.Core.csproj | 8 +- Installer/PerformanceMonitorInstaller.csproj | 8 +- Lite.Tests/DuckDbSchemaTests.cs | 4 +- Lite/App.xaml.cs | 68 +- .../CorrelatedTimelineLanesControl.xaml.cs | 17 +- Lite/Controls/FinOpsTab.xaml | 2 +- Lite/Controls/ServerTab.xaml | 47 +- Lite/Controls/ServerTab.xaml.cs | 299 +++++++- Lite/Database/DuckDbInitializer.cs | 27 +- Lite/Database/Schema.cs | 17 + Lite/Helpers/AxesExtensions.cs | 45 ++ Lite/Helpers/ChartHoverHelper.cs | 50 +- Lite/Helpers/CorrelatedCrosshairManager.cs | 43 +- Lite/Helpers/ScrollPanBehavior.cs | 271 ++++++++ Lite/MainWindow.xaml.cs | 2 +- Lite/Mcp/McpInstructions.cs | 44 ++ Lite/Mcp/McpMemoryTools.cs | 53 ++ Lite/Models/PlanModels.cs | 19 + Lite/PerformanceMonitorLite.csproj | 8 +- Lite/Services/ArchiveService.cs | 34 +- Lite/Services/BenefitScorer.cs | 653 ++++++++++++++++++ Lite/Services/LocalDataService.FinOps.cs | 154 +++-- Lite/Services/LocalDataService.Memory.cs | 50 ++ Lite/Services/LocalDataService.QueryStats.cs | 66 ++ Lite/Services/PlanAnalyzer.cs | 323 +++++++-- .../Services/RemoteCollectorService.Memory.cs | 120 +++- .../RemoteCollectorService.QuerySnapshots.cs | 141 +++- .../RemoteCollectorService.QueryStore.cs | 2 +- Lite/Services/RemoteCollectorService.cs | 132 +++- Lite/Services/ScheduleManager.cs | 4 + Lite/Themes/CoolBreezeTheme.xaml | 36 + Lite/Themes/DarkTheme.xaml | 36 + Lite/Themes/LightTheme.xaml | 36 + Lite/Windows/SettingsWindow.xaml.cs | 13 +- README.md | 13 +- install/02_create_tables.sql | 78 +++ .../01_add_query_lookup_indexes.sql | 98 +++ upgrades/2.7.0-to-2.8.0/upgrade.txt | 1 + 76 files changed, 5055 insertions(+), 589 deletions(-) create mode 100644 Dashboard/Helpers/AxesExtensions.cs create mode 100644 Dashboard/Helpers/ScrollPanBehavior.cs create mode 100644 Dashboard/Services/BenefitScorer.cs create mode 100644 Lite/Helpers/AxesExtensions.cs create mode 100644 Lite/Helpers/ScrollPanBehavior.cs create mode 100644 Lite/Services/BenefitScorer.cs create mode 100644 upgrades/2.7.0-to-2.8.0/01_add_query_lookup_indexes.sql create mode 100644 upgrades/2.7.0-to-2.8.0/upgrade.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index eee63d09..0dbc6413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ 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.8.0] - TBD + +### 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 diff --git a/Dashboard/Analysis/SqlServerBaselineProvider.cs b/Dashboard/Analysis/SqlServerBaselineProvider.cs index 1746028c..3d65ee20 100644 --- a/Dashboard/Analysis/SqlServerBaselineProvider.cs +++ b/Dashboard/Analysis/SqlServerBaselineProvider.cs @@ -463,7 +463,7 @@ private static double PoolVariance(List buckets, double grandMea return totalSumSq / (totalSamples - 1); } - private class CachedBaseline + private sealed class CachedBaseline { public DateTime ComputedAt { get; init; } public DateTime RealTime { get; init; } diff --git a/Dashboard/Controls/ConfigChangesContent.xaml b/Dashboard/Controls/ConfigChangesContent.xaml index a8e1f2d4..4f5d5e7d 100644 --- a/Dashboard/Controls/ConfigChangesContent.xaml +++ b/Dashboard/Controls/ConfigChangesContent.xaml @@ -15,7 +15,7 @@ - + diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 8fe1e734..7beb4696 100644 --- a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -31,7 +31,12 @@ public partial class CorrelatedTimelineLanesControl : UserControl public CorrelatedTimelineLanesControl() { InitializeComponent(); - Unloaded += (_, _) => _crosshairManager?.Dispose(); + /* No Unloaded → Dispose() handler: WPF fires Unloaded for transient + reasons (tab virtualization, layout rebuilds) and Dispose() clears + the crosshair manager's lane list, permanently breaking the crosshair + until the ServerTab is rebuilt. The manager holds only managed state + (a Popup + lane references) — letting GC clean it up with the control + is fine. */ } /// @@ -69,6 +74,9 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.PrepareForRefresh(); + try + { + var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); @@ -225,8 +233,18 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); } + /* VLines must be re-attached before SyncXAxes so they're part of + the render set when the chart refreshes. */ _crosshairManager?.ReattachVLines(); SyncXAxes(hoursBack, fromDate, toDate); + } + finally + { + /* Safety net: if something threw between PrepareForRefresh() and the + ReattachVLines() call above, VLines are still null. EnsureVLinesAttached + creates them only for lanes where VLine is null, so it's idempotent. */ + _crosshairManager?.EnsureVLinesAttached(); + } } /// @@ -320,7 +338,7 @@ private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, } } - BlockingChart.Plot.Axes.DateTimeTicksBottom(); + BlockingChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; TabHelpers.ReapplyAxisColors(BlockingChart); @@ -394,7 +412,7 @@ private void UpdateLane(ScottPlot.WPF.WpfPlot chart, string title, _crosshairManager?.SetLaneData(chart, times, values); - chart.Plot.Axes.DateTimeTicksBottom(); + chart.Plot.Axes.DateTimeTicksBottomDateChange(); if (chart != FileIoChart) chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; diff --git a/Dashboard/Controls/CurrentConfigContent.xaml b/Dashboard/Controls/CurrentConfigContent.xaml index ab36dbdf..2cb4fa54 100644 --- a/Dashboard/Controls/CurrentConfigContent.xaml +++ b/Dashboard/Controls/CurrentConfigContent.xaml @@ -15,7 +15,7 @@ - + diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index bfea5d36..43573d47 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -44,7 +44,7 @@ SelectionChanged="ServerSelector_SelectionChanged"/> - + @@ -90,7 +90,7 @@ - + private static void FindMemoryConsumers(PlanNode node, List consumers) + { + // Collect all consumers first, then sort by row count descending + var raw = new List<(string Label, double Rows)>(); + FindMemoryConsumersRecursive(node, raw); + + foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) + consumers.Add(label); + } + + private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) { if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; var rows = node.HasActualStats ? $"{node.ActualRows:N0} actual rows" : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add($"Sort (Node {node.NodeId}, {rows})"); + consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); } else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; var rows = node.HasActualStats ? $"{node.ActualRows:N0} actual rows" : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add($"Hash Match (Node {node.NodeId}, {rows})"); + consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); } foreach (var child in node.Children) - FindMemoryConsumers(child, consumers); + FindMemoryConsumersRecursive(child, consumers); } /// @@ -1298,7 +1385,7 @@ private static void FindMemoryConsumers(PlanNode node, List consumers) /// Exchange operators accumulate downstream wait time (e.g. from spilling /// children) so their self-time is unreliable — see sql.kiwi/2021/03. /// - private static long GetOperatorOwnElapsedMs(PlanNode node) + internal static long GetOperatorOwnElapsedMs(PlanNode node) { if (node.ActualExecutionMode == "Batch") return node.ActualElapsedMs; @@ -1536,6 +1623,37 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + /// + /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). + /// Public for use by UI components that annotate wait stats inline. + /// + public static string GetWaitLabel(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => "I/O — reading data from disk", + _ when wt.Contains("IO_COMPLETION", StringComparison.Ordinal) => "I/O — spills to TempDB or eager writes", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", + _ when wt.StartsWith("CXPACKET", StringComparison.Ordinal) || wt.StartsWith("CXCONSUMER", StringComparison.Ordinal) => "parallelism — thread skew", + _ when wt.StartsWith("CXSYNC", StringComparison.Ordinal) => "parallelism — exchange synchronization", + _ when wt == "HTBUILD" => "hash — building hash table", + _ when wt == "HTDELETE" => "hash — cleaning up hash table", + _ when wt == "HTREPARTITION" => "hash — repartitioning", + _ when wt.StartsWith("HT", StringComparison.Ordinal) => "hash operation", + _ when wt == "BPSORT" => "batch sort", + _ when wt == "BMPBUILD" => "bitmap filter build", + _ when wt.Contains("MEMORY_ALLOCATION_EXT", StringComparison.Ordinal) => "memory allocation", + _ when wt.StartsWith("PAGELATCH", StringComparison.Ordinal) => "page latch — in-memory contention", + _ when wt.StartsWith("LATCH_", StringComparison.Ordinal) => "latch contention", + _ when wt.StartsWith("LCK_", StringComparison.Ordinal) => "lock contention", + _ when wt == "LOGBUFFER" => "transaction log writes", + _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", + _ => "" + }; + } + /// /// Returns targeted advice based on statement-level wait stats, or null if no waits. /// When the dominant wait type is clear, gives specific guidance instead of generic advice. @@ -1552,29 +1670,150 @@ private static string Truncate(string value, int maxLength) var top = waits.OrderByDescending(w => w.WaitTimeMs).First(); var topPct = (double)top.WaitTimeMs / totalMs * 100; - // Only give targeted advice if the dominant wait is >= 80% of total wait time - if (topPct < 80) - return null; + // Single dominant wait — give targeted advice + if (topPct >= 80) + return DescribeWaitType(top.WaitType, topPct); + + // Multiple waits — summarize the top contributors instead of guessing + var topWaits = waits.OrderByDescending(w => w.WaitTimeMs).Take(3) + .Select(w => $"{w.WaitType} ({(double)w.WaitTimeMs / totalMs * 100:N0}%)") + .ToList(); + return $"Top waits: {string.Join(", ", topWaits)}."; + } - var waitType = top.WaitType.ToUpperInvariant(); - var advice = waitType switch + /// + /// Maps a wait type to a human-readable description with percentage context. + /// Covers all wait types observed in real execution plan files. + /// + private static string DescribeWaitType(string rawWaitType, double topPct) + { + var waitType = rawWaitType.ToUpperInvariant(); + return waitType switch { + // I/O: reading/writing data pages from disk _ when waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => - $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.", + $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.", + _ when waitType.Contains("IO_COMPLETION", StringComparison.Ordinal) => + $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Non-buffer I/O such as sort/hash spills to TempDB or eager writes.", + + // CPU: thread yielding its scheduler quantum + _ when waitType == "SOS_SCHEDULER_YIELD" => + $"CPU bound — {topPct:N0}% of wait time is {rawWaitType}. The query is consuming significant CPU. Look for expensive operators (scans, sorts, hash builds) that could be eliminated or reduced.", + + // Parallelism: exchange and synchronization waits + _ when waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) => + $"Parallel thread skew — {topPct:N0}% of wait time is {rawWaitType}. Work is unevenly distributed across parallel threads.", + _ when waitType.StartsWith("CXSYNC", StringComparison.Ordinal) => + $"Parallel synchronization — {topPct:N0}% of wait time is {rawWaitType}. Threads are waiting at exchange operators to synchronize parallel execution.", + + // Hash operations + _ when waitType.StartsWith("HT", StringComparison.Ordinal) => + $"Hash operation — {topPct:N0}% of wait time is {rawWaitType}. Time spent building, repartitioning, or cleaning up hash tables. Large hash builds may indicate missing indexes or bad row estimates.", + + // Sort/bitmap batch operations + _ when waitType == "BPSORT" => + $"Batch sort — {topPct:N0}% of wait time is {rawWaitType}. Time spent in batch-mode sort operations.", + _ when waitType == "BMPBUILD" => + $"Bitmap build — {topPct:N0}% of wait time is {rawWaitType}. Time spent building bitmap filters for hash joins.", + + // Memory allocation + _ when waitType.Contains("MEMORY_ALLOCATION_EXT", StringComparison.Ordinal) => + $"Memory allocation — {topPct:N0}% of wait time is {rawWaitType}. Frequent memory allocations during query execution.", + + // Latch contention (non-I/O) + _ when waitType.StartsWith("PAGELATCH", StringComparison.Ordinal) => + $"Page latch contention — {topPct:N0}% of wait time is {rawWaitType}. In-memory page contention, often on TempDB or hot pages.", _ when waitType.StartsWith("LATCH_", StringComparison.Ordinal) => - $"Latch contention — {topPct:N0}% of wait time is {top.WaitType}.", + $"Latch contention — {topPct:N0}% of wait time is {rawWaitType}.", + + // Lock contention _ when waitType.StartsWith("LCK_", StringComparison.Ordinal) => - $"Lock contention — {topPct:N0}% of wait time is {top.WaitType}. Other sessions are holding locks that this query needs.", - _ when waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) => - $"Parallel thread skew — {topPct:N0}% of wait time is {top.WaitType}. Work is unevenly distributed across parallel threads.", - _ when waitType.Contains("IO_COMPLETION", StringComparison.Ordinal) => - $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}.", - _ when waitType.StartsWith("RESOURCE_SEMAPHORE", StringComparison.Ordinal) => - $"Memory grant wait — {topPct:N0}% of wait time is {top.WaitType}. The query had to wait for a memory grant.", - _ => $"Dominant wait is {top.WaitType} ({topPct:N0}% of wait time)." + $"Lock contention — {topPct:N0}% of wait time is {rawWaitType}. Other sessions are holding locks that this query needs.", + + // Log writes + _ when waitType == "LOGBUFFER" => + $"Log write — {topPct:N0}% of wait time is {rawWaitType}. Waiting for transaction log buffer flushes, typically from data modifications.", + + // Network + _ when waitType == "ASYNC_NETWORK_IO" => + $"Network bound — {topPct:N0}% of wait time is {rawWaitType}. The client application is not consuming results fast enough.", + + // Physical page cache + _ when waitType == "SOS_PHYS_PAGE_CACHE" => + $"Physical page cache — {topPct:N0}% of wait time is {rawWaitType}. Contention on the physical memory page allocator.", + + _ => $"Dominant wait is {rawWaitType} ({topPct:N0}% of wait time)." }; + } + + /// + /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). + /// Used for severity elevation decisions where I/O specifically indicates disk access. + /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. + /// + private static bool HasSignificantIoWaits(List waits) + { + if (waits.Count == 0) + return false; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return false; + + long ioMs = 0; + foreach (var w in waits) + { + var wt = w.WaitType.ToUpperInvariant(); + if (wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || wt.Contains("IO_COMPLETION", StringComparison.Ordinal)) + ioMs += w.WaitTimeMs; + } + + var pct = (double)ioMs / totalMs * 100; + return ioMs >= 100 && pct >= 20; + } - return advice; + /// + /// Formats a node reference for use in warning messages. Includes object name + /// for data access operators where it helps identify which table is involved. + /// + private static string FormatNodeRef(PlanNode node) + { + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objRef = !string.IsNullOrEmpty(node.DatabaseName) + ? $"{node.DatabaseName}.{node.ObjectName}" + : node.ObjectName; + return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; + } + + return $"{node.PhysicalOp} (Node {node.NodeId})"; + } + + /// + /// Identifies the specific cause of a row goal from the statement text. + /// Returns a specific cause when detectable, or a generic list as fallback. + /// + private static string IdentifyRowGoalCause(string stmtText) + { + if (string.IsNullOrEmpty(stmtText)) + return "TOP, EXISTS, IN, or FAST hint"; + + var text = stmtText.ToUpperInvariant(); + var causes = new List(4); + + if (Regex.IsMatch(text, @"\bTOP\b")) + causes.Add("TOP"); + if (Regex.IsMatch(text, @"\bEXISTS\b")) + causes.Add("EXISTS"); + // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" + if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) + causes.Add("IN (subquery)"); + if (Regex.IsMatch(text, @"\bFAST\b")) + causes.Add("FAST hint"); + + return causes.Count > 0 + ? string.Join(", ", causes) + : "TOP, EXISTS, IN, or FAST hint"; } /// @@ -1589,7 +1828,7 @@ private static bool AllocatesResources(PlanNode node) || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); } - private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); + private sealed record ScanImpact(double CostPct, double ElapsedPct, string? Summary); /// /// Builds impact details for a scan node: what % of plan time/cost it represents, diff --git a/Dashboard/Services/WebhookAlertService.cs b/Dashboard/Services/WebhookAlertService.cs index f9cb68b2..1befa1e2 100644 --- a/Dashboard/Services/WebhookAlertService.cs +++ b/Dashboard/Services/WebhookAlertService.cs @@ -24,7 +24,10 @@ namespace PerformanceMonitorDashboard.Services public class WebhookAlertService { private const string EditionName = "Performance Monitor Dashboard"; + private const string TeamsWebhookCredentialKey = "TeamsWebhook"; + private const string SlackWebhookCredentialKey = "SlackWebhook"; private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null }; + private static readonly CredentialService s_credentialService = new(); private readonly UserPreferencesService _preferencesService; private readonly ConcurrentDictionary _cooldowns = new(); @@ -42,6 +45,50 @@ public WebhookAlertService(UserPreferencesService preferencesService) Current = this; } + /// + /// Gets a webhook URL from Windows Credential Manager. + /// + public static string GetWebhookUrl(string credentialKey) + { + try + { + var cred = s_credentialService.GetCredential(credentialKey); + return cred?.Password ?? ""; + } + catch (Exception ex) + { + Logger.Error($"Failed to retrieve webhook URL for {credentialKey}: {ex.Message}"); + return ""; + } + } + + /// + /// Saves a webhook URL to Windows Credential Manager. + /// + public static void SaveWebhookUrl(string credentialKey, string url) + { + try + { + if (string.IsNullOrWhiteSpace(url)) + { + s_credentialService.DeleteCredential(credentialKey); + } + else + { + s_credentialService.SaveCredential(credentialKey, "webhook", url); + } + } + catch (Exception ex) + { + Logger.Error($"Failed to save webhook URL for {credentialKey}: {ex.Message}"); + } + } + + public static string GetTeamsWebhookUrl() => GetWebhookUrl(TeamsWebhookCredentialKey); + public static string GetSlackWebhookUrl() => GetWebhookUrl(SlackWebhookCredentialKey); + public static void SaveTeamsWebhookUrl(string url) => SaveWebhookUrl(TeamsWebhookCredentialKey, url); + public static void SaveSlackWebhookUrl(string url) => SaveWebhookUrl(SlackWebhookCredentialKey, url); + /// /// Sends webhook alerts to all configured channels (Teams and/or Slack). /// Respects the email cooldown setting for throttling. Never throws. @@ -67,12 +114,14 @@ public async Task TrySendWebhookAlertsAsync( bool sent = false; - if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl)) + var teamsUrl = GetTeamsWebhookUrl(); + if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(teamsUrl)) { sent |= await TrySendTeamsAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); } - if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl)) + var slackUrl = GetSlackWebhookUrl(); + if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(slackUrl)) { sent |= await TrySendSlackAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); } @@ -148,7 +197,7 @@ private async Task TrySendTeamsAlertAsync( try { var payload = BuildTeamsPayload(metricName, serverName, currentValue, thresholdValue, context: context); - var error = await PostWebhookAsync(prefs.TeamsWebhookUrl, payload, prefs.TeamsProxyAddress); + var error = await PostWebhookAsync(GetTeamsWebhookUrl(), payload, prefs.TeamsProxyAddress); if (error != null) { @@ -268,7 +317,7 @@ private async Task TrySendSlackAlertAsync( try { var payload = BuildSlackPayload(metricName, serverName, currentValue, thresholdValue, context: context); - var error = await PostWebhookAsync(prefs.SlackWebhookUrl, payload, prefs.SlackProxyAddress); + var error = await PostWebhookAsync(GetSlackWebhookUrl(), payload, prefs.SlackProxyAddress); if (error != null) { diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index d4491ac8..bbdbebe1 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -210,11 +210,27 @@ private void LoadSettings() // Webhook settings (Teams / Slack) TeamsWebhookEnabledCheckBox.IsChecked = prefs.TeamsWebhookEnabled; - TeamsWebhookUrlTextBox.Text = prefs.TeamsWebhookUrl; TeamsProxyAddressTextBox.Text = prefs.TeamsProxyAddress; SlackWebhookEnabledCheckBox.IsChecked = prefs.SlackWebhookEnabled; - SlackWebhookUrlTextBox.Text = prefs.SlackWebhookUrl; SlackProxyAddressTextBox.Text = prefs.SlackProxyAddress; + + /* Migrate legacy plaintext webhook URLs to Credential Manager */ + if (!string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl)) + { + WebhookAlertService.SaveTeamsWebhookUrl(prefs.TeamsWebhookUrl); + prefs.TeamsWebhookUrl = ""; + _preferencesService.SavePreferences(prefs); + } + if (!string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl)) + { + WebhookAlertService.SaveSlackWebhookUrl(prefs.SlackWebhookUrl); + prefs.SlackWebhookUrl = ""; + _preferencesService.SavePreferences(prefs); + } + + /* Load webhook URLs from Credential Manager */ + TeamsWebhookUrlTextBox.Text = WebhookAlertService.GetTeamsWebhookUrl(); + SlackWebhookUrlTextBox.Text = WebhookAlertService.GetSlackWebhookUrl(); UpdateTeamsControlStates(); UpdateSlackControlStates(); @@ -705,12 +721,16 @@ private async void OkButton_Click(object sender, RoutedEventArgs e) // Save webhook settings (Teams / Slack) prefs.TeamsWebhookEnabled = TeamsWebhookEnabledCheckBox.IsChecked == true; - prefs.TeamsWebhookUrl = TeamsWebhookUrlTextBox.Text?.Trim() ?? ""; + prefs.TeamsWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */ prefs.TeamsProxyAddress = TeamsProxyAddressTextBox.Text?.Trim() ?? ""; prefs.SlackWebhookEnabled = SlackWebhookEnabledCheckBox.IsChecked == true; - prefs.SlackWebhookUrl = SlackWebhookUrlTextBox.Text?.Trim() ?? ""; + prefs.SlackWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */ prefs.SlackProxyAddress = SlackProxyAddressTextBox.Text?.Trim() ?? ""; + /* Save webhook URLs to Credential Manager */ + WebhookAlertService.SaveTeamsWebhookUrl(TeamsWebhookUrlTextBox.Text?.Trim() ?? ""); + WebhookAlertService.SaveSlackWebhookUrl(SlackWebhookUrlTextBox.Text?.Trim() ?? ""); + // Save MCP server settings bool mcpWasEnabled = prefs.McpEnabled; prefs.McpEnabled = McpEnabledCheckBox.IsChecked == true; diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 4eb70025..36437cbc 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -644,11 +644,46 @@ + + + + + + + + + - + + + + + + + + +