diff --git a/CHANGELOG.md b/CHANGELOG.md index eee63d09..e9a156bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,92 @@ 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.9.0] - TBD + +### 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 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..0d8d0ba7 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/PlanIconMapper.cs b/Dashboard/Services/PlanIconMapper.cs index 6ed411e3..c83e8b05 100644 --- a/Dashboard/Services/PlanIconMapper.cs +++ b/Dashboard/Services/PlanIconMapper.cs @@ -175,8 +175,44 @@ public static class PlanIconMapper ["Language Construct Catch All"] = "language_construct_catch_all", }; - public static string GetIconName(string physicalOp) + public static string GetIconName(string physicalOp, string? storageType = null, string? logicalOp = null) { + // Parallelism subtypes: PhysicalOp="Parallelism" + LogicalOp identifies which. + if (string.Equals(physicalOp, "Parallelism", StringComparison.Ordinal) && logicalOp != null) + { + switch (logicalOp) + { + case "Repartition Streams": return "parallelism_repartition_streams"; + case "Distribute Streams": return "parallelism_distribute_streams"; + case "Gather Streams": return "parallelism_gather_streams"; + } + } + + // Columnstore scans surface as PhysicalOp="Clustered Index Scan" / "Index Scan" + // with Storage="ColumnStore" on the Object element. Route to the columnstore icon. + if (string.Equals(storageType, "ColumnStore", StringComparison.OrdinalIgnoreCase)) + { + switch (physicalOp) + { + case "Clustered Index Scan": + case "Index Scan": + case "Columnstore Index Scan": + return "columnstore_index_scan"; + case "Clustered Index Delete": + case "Index Delete": + return "columnstore_index_delete"; + case "Clustered Index Insert": + case "Index Insert": + return "columnstore_index_insert"; + case "Clustered Index Update": + case "Index Update": + return "columnstore_index_update"; + case "Clustered Index Merge": + case "Index Merge": + return "columnstore_index_merge"; + } + } + if (IconMap.TryGetValue(physicalOp, out var iconName)) return iconName; diff --git a/Dashboard/Services/ServerManager.cs b/Dashboard/Services/ServerManager.cs index d3686645..433a4fde 100644 --- a/Dashboard/Services/ServerManager.cs +++ b/Dashboard/Services/ServerManager.cs @@ -196,6 +196,203 @@ IF DB_ID('PerformanceMonitor') IS NOT NULL Logger.Info($"Dropped PerformanceMonitor database and Agent jobs on '{server.DisplayName}'"); } + public class PurgeResult + { + public int RowsDeleted { get; set; } + public int TableCount { get; set; } + public int DurationMs { get; set; } + public string Status { get; set; } = ""; + public string? Message { get; set; } + } + + /// + /// Runs config.data_retention against the PerformanceMonitor database on the given server. + /// + /// + /// null = use per-collector retention from config.collection_schedule. + /// 0 = TRUNCATE every collect.* table. + /// N > 0 = override every table's cutoff to N days. + /// + public async Task RunDataRetentionAsync( + ServerConnection server, + int? retentionDaysOverride) + { + var connectionString = server.GetConnectionString(_credentialService); + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "PerformanceMonitor", + ConnectTimeout = 10 + }; + + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + using (var cmd = new SqlCommand("config.data_retention", connection)) + { + cmd.CommandType = System.Data.CommandType.StoredProcedure; + cmd.CommandTimeout = 600; + + if (retentionDaysOverride.HasValue) + { + cmd.Parameters.Add(new SqlParameter("@retention_days", System.Data.SqlDbType.Int) { Value = retentionDaysOverride.Value }); + } + + await cmd.ExecuteNonQueryAsync(); + } + + using var readCmd = new SqlCommand(@" +SELECT TOP (1) + cl.collection_status, + cl.rows_collected, + cl.duration_ms, + cl.error_message +FROM config.collection_log AS cl +WHERE cl.collector_name = N'data_retention' +ORDER BY cl.collection_time DESC;", connection); + readCmd.CommandTimeout = 30; + + using var reader = await readCmd.ExecuteReaderAsync(); + var result = new PurgeResult(); + + if (await reader.ReadAsync()) + { + result.Status = reader.IsDBNull(0) ? "" : reader.GetString(0); + result.RowsDeleted = reader.IsDBNull(1) ? 0 : reader.GetInt32(1); + result.DurationMs = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); + result.Message = reader.IsDBNull(3) ? null : reader.GetString(3); + + if (result.Message is not null && result.Message.StartsWith("Cleaned ", StringComparison.Ordinal)) + { + int spaceIdx = result.Message.IndexOf(' ', 8); + if (spaceIdx > 8 && int.TryParse(result.Message.AsSpan(8, spaceIdx - 8), out int tableCount)) + { + result.TableCount = tableCount; + } + } + else if (result.Message is not null && result.Message.StartsWith("TRUNCATE all: ", StringComparison.Ordinal)) + { + int spaceIdx = result.Message.IndexOf(' ', 14); + if (spaceIdx > 14 && int.TryParse(result.Message.AsSpan(14, spaceIdx - 14), out int tableCount)) + { + result.TableCount = tableCount; + } + } + } + + Logger.Info($"Ran data_retention on '{server.DisplayName}': status={result.Status}, rowsDeleted={result.RowsDeleted}, tables={result.TableCount}, durationMs={result.DurationMs}"); + + return result; + } + + /// + /// Returns user database names (excluding system DBs and PerformanceMonitor) on the target server, + /// for use in the Excluded Databases dialog. + /// + public async Task> GetUserDatabasesAsync(ServerConnection server) + { + var connectionString = server.GetConnectionString(_credentialService); + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "master", + ConnectTimeout = 10 + }; + + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + using var cmd = new SqlCommand(@" +SELECT d.name +FROM sys.databases AS d +WHERE d.database_id > 4 +AND d.state_desc = N'ONLINE' +AND d.name <> N'PerformanceMonitor' +AND d.database_id < 32761 /*exclude contained AG system databases*/ +ORDER BY d.name;", connection); + cmd.CommandTimeout = 30; + + var names = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + names.Add(reader.GetString(0)); + } + return names; + } + + /// + /// Returns the current per-database exclusion list from config.collector_database_exclusions on the target. + /// + public async Task> GetCollectorDatabaseExclusionsAsync(ServerConnection server) + { + var connectionString = server.GetConnectionString(_credentialService); + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "PerformanceMonitor", + ConnectTimeout = 10 + }; + + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + using var cmd = new SqlCommand(@" +SELECT e.database_name +FROM config.collector_database_exclusions AS e +ORDER BY e.database_name;", connection); + cmd.CommandTimeout = 30; + + var names = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + names.Add(reader.GetString(0)); + } + return names; + } + + /// + /// Replaces the contents of config.collector_database_exclusions with the supplied list, transactionally. + /// + public async Task SaveCollectorDatabaseExclusionsAsync(ServerConnection server, IEnumerable databaseNames) + { + var connectionString = server.GetConnectionString(_credentialService); + var builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = "PerformanceMonitor", + ConnectTimeout = 10 + }; + + using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + using var transaction = connection.BeginTransaction(); + try + { + using (var deleteCmd = new SqlCommand("DELETE FROM config.collector_database_exclusions;", connection, transaction)) + { + deleteCmd.CommandTimeout = 30; + await deleteCmd.ExecuteNonQueryAsync(); + } + + foreach (var name in databaseNames.Distinct(StringComparer.OrdinalIgnoreCase)) + { + using var insertCmd = new SqlCommand( + "INSERT INTO config.collector_database_exclusions (database_name) VALUES (@name);", + connection, transaction); + insertCmd.CommandTimeout = 30; + insertCmd.Parameters.Add(new SqlParameter("@name", System.Data.SqlDbType.NVarChar, 128) { Value = name }); + await insertCmd.ExecuteNonQueryAsync(); + } + + transaction.Commit(); + Logger.Info($"Saved collector database exclusions on '{server.DisplayName}'"); + } + catch + { + transaction.Rollback(); + throw; + } + } + public void UpdateLastConnected(string id) { lock (_serversLock) diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index d99793ec..5d56c894 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -665,8 +665,10 @@ private static PlanNode ParseRelOp(XElement relOpEl) node.PhysicalOp = "Lazy " + node.PhysicalOp; } - // Map to icon - node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); + // Icon mapping is deferred until after StorageType and LogicalOp are + // parsed below, so columnstore scans (Clustered/Index Scan with + // Storage="ColumnStore") and Parallelism subtypes route to their + // specific icons. // Handle operator-specific element var physicalOpEl = GetOperatorElement(relOpEl); @@ -1365,6 +1367,11 @@ private static PlanNode ParseRelOp(XElement relOpEl) } } + // Map to icon — done here so columnstore scans (Clustered/Index Scan + // with Storage="ColumnStore") and Parallelism subtypes (which depend on + // LogicalOp) can be routed to their specific icons. + node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp, node.StorageType, node.LogicalOp); + // Recurse into child RelOps foreach (var childRelOp in FindChildRelOps(relOpEl)) { 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..a40c3af5 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -626,7 +626,7 @@ - + @@ -644,11 +644,71 @@ + + + + + + + + + + + + + + + + + + - @@ -772,7 +832,7 @@ - + @@ -782,6 +842,7 @@ + @@ -793,6 +854,7 @@ + + + + + + + + + + + + + + + + + + - @@ -781,6 +841,7 @@ + @@ -792,6 +853,7 @@ + + + + + + + + + + + + + + + + + + - @@ -772,7 +832,7 @@ - + @@ -782,6 +842,7 @@ + @@ -793,6 +854,7 @@ @@ -375,6 +378,9 @@ + + + diff --git a/Lite/Controls/PlanViewerControl.xaml b/Lite/Controls/PlanViewerControl.xaml index 8a752579..aa44384f 100644 --- a/Lite/Controls/PlanViewerControl.xaml +++ b/Lite/Controls/PlanViewerControl.xaml @@ -80,15 +80,15 @@ + Background="{DynamicResource PlanMissingIndexBgBrush}" + BorderBrush="{DynamicResource PlanMissingIndexBorderBrush}" BorderThickness="0,0,1,0"> + Background="{DynamicResource PlanWaitStatsBgBrush}"> + (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23)); + private SolidColorBrush TooltipBorderBrush => + (TryFindResource("PlanTooltipBorderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)); + private SolidColorBrush TooltipFgBrush => + (TryFindResource("PlanPanelTextBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush MutedBrush => + (TryFindResource("PlanPanelMutedBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)); + private SolidColorBrush SectionHeaderBrush => + (TryFindResource("PlanSectionHeaderBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x4F, 0xA3, 0xFF)); + private SolidColorBrush PropSeparatorBrush => + (TryFindResource("PlanPropSeparatorBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2D, 0x35)); + // Current property section for collapsible groups private StackPanel? _currentPropertySection; @@ -55,6 +64,28 @@ public partial class PlanViewerControl : UserControl public PlanViewerControl() { InitializeComponent(); + Helpers.ThemeManager.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(string _) + { + if (_currentStatement == null) return; + + var nodeToRestore = _selectedNode; + RenderStatement(_currentStatement); + + if (nodeToRestore == null) return; + + // Find the re-created border for the previously selected node and reopen properties + foreach (var child in PlanCanvas.Children) + { + if (child is Border b && b.Tag == nodeToRestore) + { + SelectNode(b, nodeToRestore); + break; + } + } } public void LoadPlan(string planXml, string label, string? queryText = null) @@ -498,6 +529,10 @@ private Border BuildEdgeTooltipContent(PlanNode child) grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); int row = 0; + var bgBrush = TooltipBgBrush; + var borderBrush = TooltipBorderBrush; + var mutedBrush = MutedBrush; + var fgBrush = TooltipFgBrush; void AddRow(string label, string value) { @@ -505,14 +540,14 @@ void AddRow(string label, string value) var lbl = new TextBlock { Text = label, - Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)), + Foreground = mutedBrush, FontSize = 12, Margin = new Thickness(0, 1, 12, 1) }; var val = new TextBlock { Text = value, - Foreground = new SolidColorBrush(Colors.White), + Foreground = fgBrush, FontSize = 12, FontWeight = FontWeights.SemiBold, HorizontalAlignment = HorizontalAlignment.Right, @@ -545,8 +580,8 @@ void AddRow(string label, string value) return new Border { - Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)), - BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)), + Background = bgBrush, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(10, 6, 10, 6), CornerRadius = new CornerRadius(4), @@ -588,6 +623,7 @@ private void SelectNode(Border border, PlanNode node) _selectedNodeOriginalBorder = border.BorderBrush; _selectedNodeOriginalThickness = border.BorderThickness; _selectedNodeBorder = border; + _selectedNode = node; border.BorderBrush = SelectionBrush; border.BorderThickness = new Thickness(2); @@ -1521,7 +1557,7 @@ private void AddPropertySection(string title) Margin = new Thickness(0, 2, 0, 0), Padding = new Thickness(0), Foreground = SectionHeaderBrush, - Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), + Background = (TryFindResource("BackgroundLighterBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)), BorderBrush = PropSeparatorBrush, BorderThickness = new Thickness(0, 0, 0, 1) }; @@ -1580,6 +1616,7 @@ private void ClosePropertiesPanel() _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; _selectedNodeBorder = null; } + _selectedNode = null; } #endregion @@ -1792,7 +1829,7 @@ private ToolTip BuildNodeTooltip(PlanNode node, List? allWarnings = return tip; } - private static void AddTooltipSection(StackPanel parent, string title) + private void AddTooltipSection(StackPanel parent, string title) { parent.Children.Add(new TextBlock { @@ -1804,7 +1841,7 @@ private static void AddTooltipSection(StackPanel parent, string title) }); } - private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) + private void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false) { var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 1, 0, 1) }; row.Children.Add(new TextBlock @@ -1847,19 +1884,19 @@ private void ShowMissingIndexes(List indexes) { Text = mi.Table, FontWeight = FontWeights.SemiBold, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")), + Foreground = TooltipFgBrush, FontSize = 12 }); headerRow.Children.Add(new TextBlock { Text = $" \u2014 Impact: ", - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")), + Foreground = MutedBrush, FontSize = 12 }); headerRow.Children.Add(new TextBlock { Text = $"{mi.Impact:F1}%", - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFB347")), + Foreground = OrangeBrush, FontSize = 12 }); itemPanel.Children.Add(headerRow); @@ -1871,7 +1908,7 @@ private void ShowMissingIndexes(List indexes) Text = mi.CreateStatement, FontFamily = new FontFamily("Consolas"), FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")), + Foreground = TooltipFgBrush, Background = Brushes.Transparent, BorderThickness = new Thickness(0), IsReadOnly = true, @@ -1944,7 +1981,7 @@ private void ShowWaitStats(List waits, bool isActualPlan) { Text = w.WaitType, FontSize = 12, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")), + Foreground = TooltipFgBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 10, 2) }; @@ -1970,7 +2007,7 @@ private void ShowWaitStats(List waits, bool isActualPlan) { Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)", FontSize = 12, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")), + Foreground = TooltipFgBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 2, 0, 2) }; @@ -2026,8 +2063,8 @@ private void ShowRuntimeSummary(PlanStatement statement) { RuntimeSummaryContent.Children.Clear(); - var labelColor = "#E4E6EB"; - var valueColor = "#E4E6EB"; + var labelBrush = MutedBrush; + var valueBrush = TooltipFgBrush; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); @@ -2042,7 +2079,7 @@ void AddRow(string label, string value) { Text = label, FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(labelColor)), + Foreground = labelBrush, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 1, 8, 1) }; @@ -2054,7 +2091,7 @@ void AddRow(string label, string value) { Text = value, FontSize = 11, - Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(valueColor)), + Foreground = valueBrush, Margin = new Thickness(0, 1, 0, 1) }; Grid.SetRow(valueText, rowIndex); diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index c04d23d4..26b018c7 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -32,12 +32,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + - @@ -723,7 +780,7 @@ - + @@ -733,6 +790,7 @@ + @@ -744,6 +802,7 @@ + + + + + + + + + + + + + + + diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml index db5aac54..1702d76a 100644 --- a/Lite/Themes/DarkTheme.xaml +++ b/Lite/Themes/DarkTheme.xaml @@ -595,11 +595,46 @@ + + + + + + + + + + + + + + + - @@ -733,6 +790,7 @@ + @@ -744,6 +802,7 @@ + + + + + + + + + + + + + + + - @@ -723,7 +780,7 @@ - + @@ -733,6 +790,7 @@ + @@ -744,6 +802,7 @@ + + + + + + + + + + + + + + + diff --git a/Lite/Windows/ExcludedDatabasesDialog.xaml b/Lite/Windows/ExcludedDatabasesDialog.xaml new file mode 100644 index 00000000..7fbbae24 --- /dev/null +++ b/Lite/Windows/ExcludedDatabasesDialog.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +