From 61f5a3beba16f112fb6396dd738b9d4f35f40f66 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Wed, 15 Apr 2026 17:20:53 +0100 Subject: [PATCH 01/49] Implements #843 in Lite --- Lite/Helpers/ScrollPanBehavior.cs | 271 ++++++++++++++++++++++++++++++ Lite/Themes/CoolBreezeTheme.xaml | 2 + Lite/Themes/DarkTheme.xaml | 2 + Lite/Themes/LightTheme.xaml | 2 + 4 files changed, 277 insertions(+) create mode 100644 Lite/Helpers/ScrollPanBehavior.cs diff --git a/Lite/Helpers/ScrollPanBehavior.cs b/Lite/Helpers/ScrollPanBehavior.cs new file mode 100644 index 00000000..e823c8f9 --- /dev/null +++ b/Lite/Helpers/ScrollPanBehavior.cs @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; + +namespace PerformanceMonitorLite.Helpers; + +/// +/// Enables middle-mouse drag panning for scrollable controls such as DataGrid and ListView. +/// +public static class ScrollPanBehavior +{ + public static readonly DependencyProperty EnableMiddleClickPanningProperty = + DependencyProperty.RegisterAttached( + "EnableMiddleClickPanning", + typeof(bool), + typeof(ScrollPanBehavior), + new PropertyMetadata(false, OnEnableMiddleClickPanningChanged)); + + private static readonly DependencyProperty PanStateProperty = + DependencyProperty.RegisterAttached( + "PanState", + typeof(PanState), + typeof(ScrollPanBehavior), + new PropertyMetadata(null)); + + public static bool GetEnableMiddleClickPanning(DependencyObject obj) => (bool)obj.GetValue(EnableMiddleClickPanningProperty); + public static void SetEnableMiddleClickPanning(DependencyObject obj, bool value) => obj.SetValue(EnableMiddleClickPanningProperty, value); + + private static PanState GetOrCreatePanState(FrameworkElement element) + { + if (element.GetValue(PanStateProperty) is not PanState state) + { + state = new PanState(); + element.SetValue(PanStateProperty, state); + } + + return state; + } + + private static void OnEnableMiddleClickPanningChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not FrameworkElement element) + { + return; + } + + var isEnabled = (bool)e.NewValue; + + if (isEnabled) + { + element.Loaded += OnLoaded; + element.Unloaded += OnUnloaded; + element.PreviewMouseDown += OnPreviewMouseDown; + element.PreviewMouseMove += OnPreviewMouseMove; + element.PreviewMouseUp += OnPreviewMouseUp; + element.LostMouseCapture += OnLostMouseCapture; + } + else + { + element.Loaded -= OnLoaded; + element.Unloaded -= OnUnloaded; + element.PreviewMouseDown -= OnPreviewMouseDown; + element.PreviewMouseMove -= OnPreviewMouseMove; + element.PreviewMouseUp -= OnPreviewMouseUp; + element.LostMouseCapture -= OnLostMouseCapture; + StopPanning(element, restoreCursor: true); + } + } + + private static void OnLoaded(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement element) + { + GetOrCreatePanState(element).ScrollViewer = FindVisualChild(element); + } + } + + private static void OnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement element) + { + StopPanning(element, restoreCursor: false); + GetOrCreatePanState(element).ScrollViewer = null; + } + } + + private static void OnPreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Middle + || sender is not FrameworkElement element + || !CanStartPanning(e.OriginalSource as DependencyObject)) + { + return; + } + + var state = GetOrCreatePanState(element); + state.ScrollViewer ??= FindVisualChild(element); + + if (state.ScrollViewer is null) + { + return; + } + + if (state.ScrollViewer.ScrollableWidth <= 0 && state.ScrollViewer.ScrollableHeight <= 0) + { + return; + } + + state.IsPanning = true; + state.StartPoint = e.GetPosition(state.ScrollViewer); + state.StartHorizontalOffset = state.ScrollViewer.HorizontalOffset; + state.StartVerticalOffset = state.ScrollViewer.VerticalOffset; + state.OriginalCursor = element.Cursor; + + element.Cursor = Cursors.ScrollAll; + element.CaptureMouse(); + e.Handled = true; + } + + private static void OnPreviewMouseMove(object sender, MouseEventArgs e) + { + if (sender is not FrameworkElement element) + { + return; + } + + var state = GetOrCreatePanState(element); + if (!state.IsPanning || state.ScrollViewer is null) + { + return; + } + + var currentPoint = e.GetPosition(state.ScrollViewer); + var deltaX = currentPoint.X - state.StartPoint.X; + var deltaY = currentPoint.Y - state.StartPoint.Y; + + state.ScrollViewer.ScrollToHorizontalOffset(ClampOffset(state.StartHorizontalOffset - deltaX, state.ScrollViewer.ScrollableWidth)); + state.ScrollViewer.ScrollToVerticalOffset(ClampOffset(state.StartVerticalOffset - deltaY, state.ScrollViewer.ScrollableHeight)); + e.Handled = true; + } + + private static void OnPreviewMouseUp(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.Middle || sender is not FrameworkElement element) + { + return; + } + + if (!GetOrCreatePanState(element).IsPanning) + { + return; + } + + StopPanning(element, restoreCursor: true); + e.Handled = true; + } + + private static void OnLostMouseCapture(object sender, MouseEventArgs e) + { + if (sender is FrameworkElement element) + { + StopPanning(element, restoreCursor: true); + } + } + + private static void StopPanning(FrameworkElement element, bool restoreCursor) + { + var state = GetOrCreatePanState(element); + if (!state.IsPanning) + { + if (restoreCursor) + { + element.ClearValue(FrameworkElement.CursorProperty); + } + + return; + } + + state.IsPanning = false; + + if (restoreCursor) + { + if (state.OriginalCursor is null) + { + element.ClearValue(FrameworkElement.CursorProperty); + } + else + { + element.Cursor = state.OriginalCursor; + } + } + + state.OriginalCursor = null; + + if (Mouse.Captured == element) + { + element.ReleaseMouseCapture(); + } + } + + private static bool CanStartPanning(DependencyObject? source) + { + while (source is not null) + { + if (source is ScrollBar + || source is Thumb + || source is DataGridColumnHeader + || source is GridViewColumnHeader + || source is TextBoxBase + || source is PasswordBox + || source is ComboBox + || source is ComboBoxItem + || source is ButtonBase) + { + return false; + } + + source = VisualTreeHelper.GetParent(source); + } + + return true; + } + + private static double ClampOffset(double value, double maxValue) => Math.Max(0, Math.Min(maxValue, value)); + + private static T? FindVisualChild(DependencyObject? parent) where T : DependencyObject + { + if (parent is null) + { + return null; + } + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T target) + { + return target; + } + + var nested = FindVisualChild(child); + if (nested is not null) + { + return nested; + } + } + + return null; + } + + private sealed class PanState + { + public bool IsPanning { get; set; } + public Point StartPoint { get; set; } + public double StartHorizontalOffset { get; set; } + public double StartVerticalOffset { get; set; } + public Cursor? OriginalCursor { get; set; } + public ScrollViewer? ScrollViewer { get; set; } + } +} \ No newline at end of file diff --git a/Lite/Themes/CoolBreezeTheme.xaml b/Lite/Themes/CoolBreezeTheme.xaml index 739f20bb..d22edec4 100644 --- a/Lite/Themes/CoolBreezeTheme.xaml +++ b/Lite/Themes/CoolBreezeTheme.xaml @@ -600,6 +600,7 @@ + + + diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml index 3d64f59d..869af3de 100644 --- a/Lite/Themes/DarkTheme.xaml +++ b/Lite/Themes/DarkTheme.xaml @@ -595,6 +595,40 @@ + + + diff --git a/Lite/Themes/LightTheme.xaml b/Lite/Themes/LightTheme.xaml index e47e2411..e9908300 100644 --- a/Lite/Themes/LightTheme.xaml +++ b/Lite/Themes/LightTheme.xaml @@ -595,6 +595,40 @@ + + + From 64ddd017c23e62158d3759af7dc65bf7d0349066 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:06:12 -0400 Subject: [PATCH 16/49] Port Lite chart/tab polish to Dashboard + LSP diagnostics cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Analysis/SqlServerBaselineProvider.cs | 2 +- Dashboard/Controls/ConfigChangesContent.xaml | 2 +- .../CorrelatedTimelineLanesControl.xaml.cs | 4 +- Dashboard/Controls/CurrentConfigContent.xaml | 2 +- Dashboard/Controls/FinOpsContent.xaml | 2 +- Dashboard/Controls/MemoryContent.xaml | 2 +- Dashboard/Controls/MemoryContent.xaml.cs | 12 ++--- .../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/Helpers/AxesExtensions.cs | 45 +++++++++++++++++++ .../Helpers/CorrelatedCrosshairManager.cs | 4 +- Dashboard/Helpers/TabHelpers.cs | 4 ++ Dashboard/ProcedureHistoryWindow.xaml.cs | 2 +- Dashboard/QueryExecutionHistoryWindow.xaml.cs | 2 +- Dashboard/QueryStatsHistoryWindow.xaml.cs | 2 +- Dashboard/ServerTab.xaml | 6 +-- Dashboard/ServerTab.xaml.cs | 24 +++++----- Dashboard/Services/BenefitScorer.cs | 20 ++++----- Dashboard/Services/PlanAnalyzer.cs | 8 ++-- Dashboard/Themes/CoolBreezeTheme.xaml | 34 ++++++++++++++ Dashboard/Themes/DarkTheme.xaml | 34 ++++++++++++++ Dashboard/Themes/LightTheme.xaml | 34 ++++++++++++++ Dashboard/TracePatternHistoryWindow.xaml.cs | 2 +- Lite/Services/BenefitScorer.cs | 20 ++++----- Lite/Services/PlanAnalyzer.cs | 9 ++-- .../RemoteCollectorService.QueryStore.cs | 2 +- 30 files changed, 245 insertions(+), 97 deletions(-) create mode 100644 Dashboard/Helpers/AxesExtensions.cs 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..7d8f878f 100644 --- a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -320,7 +320,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 +394,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..e635803b 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -44,7 +44,7 @@ SelectionChanged="ServerSelector_SelectionChanged"/> - + diff --git a/Dashboard/Controls/MemoryContent.xaml b/Dashboard/Controls/MemoryContent.xaml index 36db005e..45a0140e 100644 --- a/Dashboard/Controls/MemoryContent.xaml +++ b/Dashboard/Controls/MemoryContent.xaml @@ -32,7 +32,7 @@ - + diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index 522a6005..bc07e18d 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -353,7 +353,7 @@ private void LoadMemoryStatsOverviewChart(List memoryData, int noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottom(); + MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryStatsOverviewChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryStatsOverviewChart.Plot.YLabel("MB"); // Fixed negative space for legend @@ -605,7 +605,7 @@ private void LoadMemoryGrantSizingChart(List aggregated, int hou MemoryGrantSizingChart.Plot.Legend.FontSize = 12; } - MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottom(); + MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryGrantSizingChart.Plot.YLabel("MB"); MemoryGrantSizingChart.Plot.Axes.AutoScaleY(); @@ -675,7 +675,7 @@ private void LoadMemoryGrantActivityChart(List aggregated, int h MemoryGrantActivityChart.Plot.Legend.FontSize = 12; } - MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottom(); + MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryGrantActivityChart.Plot.YLabel("Count"); MemoryGrantActivityChart.Plot.Axes.AutoScaleY(); @@ -856,7 +856,7 @@ private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync MemoryClerksTopText.Text = "N/A"; } - MemoryClerksChart.Plot.Axes.DateTimeTicksBottom(); + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryClerksChart.Plot.YLabel("MB"); MemoryClerksChart.Plot.Axes.AutoScaleY(); @@ -1001,7 +1001,7 @@ private void LoadPlanCacheChart(IEnumerable data, int hoursB noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - PlanCacheChart.Plot.Axes.DateTimeTicksBottom(); + PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); PlanCacheChart.Plot.Axes.SetLimitsX(xMin, xMax); PlanCacheChart.Plot.YLabel("MB"); // Fixed negative space for legend @@ -1120,7 +1120,7 @@ private void LoadMemoryPressureEventsChart(IEnumerable noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottom(); + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryPressureEventsChart.Plot.YLabel("Event Count"); // Fixed negative space for legend diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml index 0c1694ec..235f8efd 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml +++ b/Dashboard/Controls/QueryPerformanceContent.xaml @@ -46,7 +46,7 @@ - + diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index 8417afa8..0099aba5 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -2437,7 +2437,7 @@ private void LoadDurationChart(WpfPlot chart, IEnumerable tre _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); chart.Plot.Legend.FontSize = 12; - chart.Plot.Axes.DateTimeTicksBottom(); + chart.Plot.Axes.DateTimeTicksBottomDateChange(); chart.Plot.Axes.SetLimitsX(xMin, xMax); chart.Plot.YLabel("Duration (ms/sec)"); TabHelpers.LockChartVerticalAxis(chart); @@ -2492,7 +2492,7 @@ private void LoadExecChart(IEnumerable execTrends, int hours _legendPanels[QueryPerfTrendsExecChart] = QueryPerfTrendsExecChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); QueryPerfTrendsExecChart.Plot.Legend.FontSize = 12; - QueryPerfTrendsExecChart.Plot.Axes.DateTimeTicksBottom(); + QueryPerfTrendsExecChart.Plot.Axes.DateTimeTicksBottomDateChange(); QueryPerfTrendsExecChart.Plot.Axes.SetLimitsX(xMin, xMax); QueryPerfTrendsExecChart.Plot.YLabel("Executions/sec"); TabHelpers.LockChartVerticalAxis(QueryPerfTrendsExecChart); diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml index 9bcdac6e..471c8b3e 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml +++ b/Dashboard/Controls/ResourceMetricsContent.xaml @@ -25,7 +25,7 @@ - + @@ -148,7 +148,7 @@ - + diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index 74329671..bcc2d7c3 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -404,7 +404,7 @@ private void LoadLatchStatsChart(IEnumerable data, int hoursBack noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - LatchStatsChart.Plot.Axes.DateTimeTicksBottom(); + LatchStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); LatchStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(LatchStatsChart); LatchStatsChart.Plot.YLabel("Wait Time (ms/sec)"); @@ -495,7 +495,7 @@ private void LoadSpinlockStatsChart(IEnumerable data, int hou noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - SpinlockStatsChart.Plot.Axes.DateTimeTicksBottom(); + SpinlockStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); SpinlockStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(SpinlockStatsChart); SpinlockStatsChart.Plot.YLabel("Collisions/sec"); @@ -603,7 +603,7 @@ private void LoadCombinedTempDbLatencyChart(List da noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - TempDbLatencyChart.Plot.Axes.DateTimeTicksBottom(); + TempDbLatencyChart.Plot.Axes.DateTimeTicksBottomDateChange(); TempDbLatencyChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(TempDbLatencyChart); TempDbLatencyChart.Plot.YLabel("Latency (ms)"); @@ -708,7 +708,7 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - TempdbStatsChart.Plot.Axes.DateTimeTicksBottom(); + TempdbStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); TempdbStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); TempdbStatsChart.Plot.Axes.AutoScaleY(); TempdbStatsChart.Plot.YLabel("MB"); @@ -879,7 +879,7 @@ private void LoadSessionStatsChart(IEnumerable data, int hours noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - SessionStatsChart.Plot.Axes.DateTimeTicksBottom(); + SessionStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); SessionStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(SessionStatsChart); SessionStatsChart.Plot.YLabel("Session Count"); @@ -1014,7 +1014,7 @@ private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List? data, int hoursBac noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - PerfmonCountersChart.Plot.Axes.DateTimeTicksBottom(); + PerfmonCountersChart.Plot.Axes.DateTimeTicksBottomDateChange(); PerfmonCountersChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(PerfmonCountersChart); PerfmonCountersChart.Plot.YLabel("Value/sec"); @@ -1816,7 +1816,7 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - WaitStatsDetailChart.Plot.Axes.DateTimeTicksBottom(); + WaitStatsDetailChart.Plot.Axes.DateTimeTicksBottomDateChange(); WaitStatsDetailChart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.SetChartYLimitsWithLegendPadding(WaitStatsDetailChart); WaitStatsDetailChart.Plot.YLabel(useAvgPerWait ? "Avg Wait Time (ms/wait)" : "Wait Time (ms/sec)"); diff --git a/Dashboard/Controls/SystemEventsContent.xaml b/Dashboard/Controls/SystemEventsContent.xaml index 3434838c..6bbfb75a 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml +++ b/Dashboard/Controls/SystemEventsContent.xaml @@ -31,7 +31,7 @@ - + diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index aa3e1c20..d24a2f56 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -528,7 +528,7 @@ private void LoadCorruptionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - BadPagesChart.Plot.Axes.DateTimeTicksBottom(); + BadPagesChart.Plot.Axes.DateTimeTicksBottomDateChange(); BadPagesChart.Plot.Axes.SetLimitsX(xMin, xMax); BadPagesChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(BadPagesChart); @@ -557,7 +557,7 @@ private void LoadCorruptionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - DumpRequestsChart.Plot.Axes.DateTimeTicksBottom(); + DumpRequestsChart.Plot.Axes.DateTimeTicksBottomDateChange(); DumpRequestsChart.Plot.Axes.SetLimitsX(xMin, xMax); DumpRequestsChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(DumpRequestsChart); @@ -586,7 +586,7 @@ private void LoadCorruptionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - AccessViolationsChart.Plot.Axes.DateTimeTicksBottom(); + AccessViolationsChart.Plot.Axes.DateTimeTicksBottomDateChange(); AccessViolationsChart.Plot.Axes.SetLimitsX(xMin, xMax); AccessViolationsChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(AccessViolationsChart); @@ -615,7 +615,7 @@ private void LoadCorruptionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - WriteAccessViolationsChart.Plot.Axes.DateTimeTicksBottom(); + WriteAccessViolationsChart.Plot.Axes.DateTimeTicksBottomDateChange(); WriteAccessViolationsChart.Plot.Axes.SetLimitsX(xMin, xMax); WriteAccessViolationsChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(WriteAccessViolationsChart); @@ -656,7 +656,7 @@ private void LoadContentionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - NonYieldingTasksChart.Plot.Axes.DateTimeTicksBottom(); + NonYieldingTasksChart.Plot.Axes.DateTimeTicksBottomDateChange(); NonYieldingTasksChart.Plot.Axes.SetLimitsX(xMin, xMax); NonYieldingTasksChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(NonYieldingTasksChart); @@ -685,7 +685,7 @@ private void LoadContentionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - LatchWarningsChart.Plot.Axes.DateTimeTicksBottom(); + LatchWarningsChart.Plot.Axes.DateTimeTicksBottomDateChange(); LatchWarningsChart.Plot.Axes.SetLimitsX(xMin, xMax); LatchWarningsChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(LatchWarningsChart); @@ -748,7 +748,7 @@ private void LoadContentionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - SickSpinlocksChart.Plot.Axes.DateTimeTicksBottom(); + SickSpinlocksChart.Plot.Axes.DateTimeTicksBottomDateChange(); SickSpinlocksChart.Plot.Axes.SetLimitsX(xMin, xMax); SickSpinlocksChart.Plot.YLabel("Backoffs"); TabHelpers.LockChartVerticalAxis(SickSpinlocksChart); @@ -798,7 +798,7 @@ private void LoadContentionEventsCharts(List data, noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - CpuComparisonChart.Plot.Axes.DateTimeTicksBottom(); + CpuComparisonChart.Plot.Axes.DateTimeTicksBottomDateChange(); CpuComparisonChart.Plot.Axes.SetLimitsX(xMin, xMax); CpuComparisonChart.Plot.Axes.SetLimitsY(0, 100); // Fixed Y-axis for CPU percentage CpuComparisonChart.Plot.YLabel("CPU %"); @@ -899,7 +899,7 @@ private void LoadSevereErrorsChart(IEnumerable data noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - SevereErrorsChart.Plot.Axes.DateTimeTicksBottom(); + SevereErrorsChart.Plot.Axes.DateTimeTicksBottomDateChange(); SevereErrorsChart.Plot.Axes.SetLimitsX(xMin, xMax); SevereErrorsChart.Plot.YLabel("Event Count"); TabHelpers.LockChartVerticalAxis(SevereErrorsChart); @@ -1058,7 +1058,7 @@ private void LoadIOIssuesChart(IEnumerable data, int ho noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - IOIssuesChart.Plot.Axes.DateTimeTicksBottom(); + IOIssuesChart.Plot.Axes.DateTimeTicksBottomDateChange(); IOIssuesChart.Plot.Axes.SetLimitsX(xMin, xMax); IOIssuesChart.Plot.YLabel("Count"); TabHelpers.LockChartVerticalAxis(IOIssuesChart); @@ -1152,7 +1152,7 @@ private void LoadLongestPendingIOChart(IEnumerable data noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - LongestPendingIOChart.Plot.Axes.DateTimeTicksBottom(); + LongestPendingIOChart.Plot.Axes.DateTimeTicksBottomDateChange(); LongestPendingIOChart.Plot.Axes.SetLimitsX(xMin, xMax); LongestPendingIOChart.Plot.YLabel("Duration (ms)"); TabHelpers.LockChartVerticalAxis(LongestPendingIOChart); @@ -1251,7 +1251,7 @@ long ParseNonYield(string? value) noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - SchedulerIssuesChart.Plot.Axes.DateTimeTicksBottom(); + SchedulerIssuesChart.Plot.Axes.DateTimeTicksBottomDateChange(); SchedulerIssuesChart.Plot.Axes.SetLimitsX(xMin, xMax); SchedulerIssuesChart.Plot.YLabel("Total Non-Yield Time (ms)"); TabHelpers.LockChartVerticalAxis(SchedulerIssuesChart); @@ -1401,7 +1401,7 @@ private void LoadMemoryConditionsChart(IEnumerable data, int h noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - CPUTasksChart.Plot.Axes.DateTimeTicksBottom(); + CPUTasksChart.Plot.Axes.DateTimeTicksBottomDateChange(); CPUTasksChart.Plot.Axes.SetLimitsX(xMin, xMax); CPUTasksChart.Plot.YLabel("Workers"); TabHelpers.LockChartVerticalAxis(CPUTasksChart); @@ -1780,7 +1780,7 @@ private void LoadMemoryBrokerChart(IEnumerable dat /* Finalize both charts */ foreach (var chart in new[] { MemoryBrokerChart, MemoryBrokerRatioChart }) { - chart.Plot.Axes.DateTimeTicksBottom(); + chart.Plot.Axes.DateTimeTicksBottomDateChange(); chart.Plot.Axes.SetLimitsX(xMin, xMax); TabHelpers.LockChartVerticalAxis(chart); chart.Refresh(); @@ -1933,7 +1933,7 @@ private void LoadMemoryNodeOOMChart(IEnumerable d noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - MemoryNodeOOMChart.Plot.Axes.DateTimeTicksBottom(); + MemoryNodeOOMChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryNodeOOMChart.Plot.Axes.SetLimitsX(xMin, xMax); MemoryNodeOOMChart.Plot.YLabel("Event Count"); TabHelpers.LockChartVerticalAxis(MemoryNodeOOMChart); @@ -1983,7 +1983,7 @@ private void LoadMemoryNodeOOMUtilChart(IEnumerableCulture's short-date pattern with the year component removed (e.g. "M/d" en-US, "dd/MM" en-GB, "dd.MM" de-DE). + private static readonly string MonthDayPattern = BuildMonthDayPattern(); + + private static string BuildMonthDayPattern() + { + var p = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern; + p = Regex.Replace(p, @"y+", ""); + p = Regex.Replace(p, @"^[\s/.\-]+|[\s/.\-]+$", ""); + p = Regex.Replace(p, @"([/.\-\s])\1+", "$1"); + return string.IsNullOrWhiteSpace(p) ? "M/d" : p; + } + + /// + /// Like DateTimeTicksBottom(), but prints the date line on only the first tick + /// and on ticks where the date component changes. All other ticks show time-only. + /// Date and time formats follow the current culture. + /// + public static void DateTimeTicksBottomDateChange(this ScottPlot.AxisManager axes) + { + axes.DateTimeTicksBottom(); + if (axes.Bottom.TickGenerator is ScottPlot.TickGenerators.DateTimeAutomatic gen) + { + DateTime? lastDate = null; + var culture = CultureInfo.CurrentCulture; + gen.LabelFormatter = dt => + { + var time = dt.ToString("t", culture); + if (lastDate is null || dt.Date != lastDate.Value) + { + lastDate = dt.Date; + return $"{dt.ToString(MonthDayPattern, culture)}\n{time}"; + } + return time; + }; + } + } +} diff --git a/Dashboard/Helpers/CorrelatedCrosshairManager.cs b/Dashboard/Helpers/CorrelatedCrosshairManager.cs index c49b0a7b..80e2ec7d 100644 --- a/Dashboard/Helpers/CorrelatedCrosshairManager.cs +++ b/Dashboard/Helpers/CorrelatedCrosshairManager.cs @@ -350,7 +350,7 @@ public void Dispose() _lanes.Clear(); } - private class DataSeries + private sealed class DataSeries { public string Name { get; set; } = ""; public string? Unit { get; set; } @@ -359,7 +359,7 @@ private class DataSeries public bool IsEventBased { get; set; } } - private class LaneInfo + private sealed class LaneInfo { public ScottPlot.WPF.WpfPlot Chart { get; set; } = null!; public string Label { get; set; } = ""; diff --git a/Dashboard/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs index 70f6533d..61319272 100644 --- a/Dashboard/Helpers/TabHelpers.cs +++ b/Dashboard/Helpers/TabHelpers.cs @@ -200,6 +200,8 @@ public static void ApplyThemeToChart(WpfPlot chart) chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; chart.Plot.Axes.Bottom.Label.ForeColor = textColor; chart.Plot.Axes.Left.Label.ForeColor = textColor; + chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; + chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; // Set the WPF control Background to match so no white flash appears before ScottPlot's render loop fires chart.Background = new SolidColorBrush(Color.FromRgb(figureBackground.R, figureBackground.G, figureBackground.B)); @@ -234,6 +236,8 @@ public static void ReapplyAxisColors(WpfPlot chart) chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor; chart.Plot.Axes.Bottom.Label.ForeColor = textColor; chart.Plot.Axes.Left.Label.ForeColor = textColor; + chart.Plot.Axes.Bottom.TickLabelStyle.FontSize = 13; + chart.Plot.Axes.Left.TickLabelStyle.FontSize = 13; } /// diff --git a/Dashboard/ProcedureHistoryWindow.xaml.cs b/Dashboard/ProcedureHistoryWindow.xaml.cs index 0875f951..057e07d6 100644 --- a/Dashboard/ProcedureHistoryWindow.xaml.cs +++ b/Dashboard/ProcedureHistoryWindow.xaml.cs @@ -210,7 +210,7 @@ private void UpdateChart() scatter.MarkerSize = 4; } - HistoryChart.Plot.Axes.DateTimeTicksBottom(); + HistoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); Helpers.TabHelpers.ReapplyAxisColors(HistoryChart); HistoryChart.Plot.YLabel(metricLabel); HistoryChart.Plot.XLabel("Collection Time"); diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml.cs b/Dashboard/QueryExecutionHistoryWindow.xaml.cs index 33f70367..dcdc277d 100644 --- a/Dashboard/QueryExecutionHistoryWindow.xaml.cs +++ b/Dashboard/QueryExecutionHistoryWindow.xaml.cs @@ -226,7 +226,7 @@ private void UpdateChart() colorIndex++; } - HistoryChart.Plot.Axes.DateTimeTicksBottom(); + HistoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); Helpers.TabHelpers.ReapplyAxisColors(HistoryChart); HistoryChart.Plot.YLabel(metricLabel); HistoryChart.Plot.XLabel("Collection Time"); diff --git a/Dashboard/QueryStatsHistoryWindow.xaml.cs b/Dashboard/QueryStatsHistoryWindow.xaml.cs index 755c823d..7f428289 100644 --- a/Dashboard/QueryStatsHistoryWindow.xaml.cs +++ b/Dashboard/QueryStatsHistoryWindow.xaml.cs @@ -202,7 +202,7 @@ private void UpdateChart() scatter.MarkerSize = 4; } - HistoryChart.Plot.Axes.DateTimeTicksBottom(); + HistoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); Helpers.TabHelpers.ReapplyAxisColors(HistoryChart); HistoryChart.Plot.YLabel(metricLabel); HistoryChart.Plot.XLabel("Collection Time"); diff --git a/Dashboard/ServerTab.xaml b/Dashboard/ServerTab.xaml index e6927176..5c3f601a 100644 --- a/Dashboard/ServerTab.xaml +++ b/Dashboard/ServerTab.xaml @@ -163,7 +163,7 @@ - + @@ -255,7 +255,7 @@ - + - + diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 6c9f7b1f..e793be13 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -2253,7 +2253,7 @@ private void LoadBlockingStatsCharts(List data, int h noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - BlockingStatsBlockingEventsChart.Plot.Axes.DateTimeTicksBottom(); + BlockingStatsBlockingEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingStatsBlockingEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); BlockingStatsBlockingEventsChart.Plot.YLabel("Count"); LockChartVerticalAxis(BlockingStatsBlockingEventsChart); @@ -2282,7 +2282,7 @@ private void LoadBlockingStatsCharts(List data, int h noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - BlockingStatsDurationChart.Plot.Axes.DateTimeTicksBottom(); + BlockingStatsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingStatsDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); BlockingStatsDurationChart.Plot.YLabel("Duration (ms)"); LockChartVerticalAxis(BlockingStatsDurationChart); @@ -2311,7 +2311,7 @@ private void LoadBlockingStatsCharts(List data, int h noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - BlockingStatsDeadlocksChart.Plot.Axes.DateTimeTicksBottom(); + BlockingStatsDeadlocksChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingStatsDeadlocksChart.Plot.Axes.SetLimitsX(xMin, xMax); BlockingStatsDeadlocksChart.Plot.YLabel("Count"); LockChartVerticalAxis(BlockingStatsDeadlocksChart); @@ -2340,7 +2340,7 @@ private void LoadBlockingStatsCharts(List data, int h noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - BlockingStatsDeadlockWaitTimeChart.Plot.Axes.DateTimeTicksBottom(); + BlockingStatsDeadlockWaitTimeChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingStatsDeadlockWaitTimeChart.Plot.Axes.SetLimitsX(xMin, xMax); BlockingStatsDeadlockWaitTimeChart.Plot.YLabel("Duration (ms)"); LockChartVerticalAxis(BlockingStatsDeadlockWaitTimeChart); @@ -2386,7 +2386,7 @@ private void UpdateCollectorDurationChart(List data) colorIndex++; } - CollectorDurationChart.Plot.Axes.DateTimeTicksBottom(); + CollectorDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); TabHelpers.ReapplyAxisColors(CollectorDurationChart); CollectorDurationChart.Plot.YLabel("Duration (ms)"); CollectorDurationChart.Plot.Axes.AutoScale(); @@ -2449,7 +2449,7 @@ private void LoadLockWaitStatsChart(List data, int hoursBack, noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - LockWaitStatsChart.Plot.Axes.DateTimeTicksBottom(); + LockWaitStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); LockWaitStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); LockWaitStatsChart.Plot.YLabel("Wait Time (ms/sec)"); _legendPanels[LockWaitStatsChart] = LockWaitStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); @@ -2506,7 +2506,7 @@ private void LoadCurrentWaitsDurationChart(List data, int noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottomDateChange(); CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); _legendPanels[CurrentWaitsDurationChart] = CurrentWaitsDurationChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); @@ -2563,7 +2563,7 @@ private void LoadCurrentWaitsBlockedChart(List data, in noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottomDateChange(); CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(xMin, xMax); CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); _legendPanels[CurrentWaitsBlockedChart] = CurrentWaitsBlockedChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); @@ -2903,7 +2903,7 @@ private void LoadResourceOverviewCpuChart(IEnumerable cpuData, int noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - ResourceOverviewCpuChart.Plot.Axes.DateTimeTicksBottom(); + ResourceOverviewCpuChart.Plot.Axes.DateTimeTicksBottomDateChange(); ResourceOverviewCpuChart.Plot.Axes.SetLimitsX(xMin, xMax); ResourceOverviewCpuChart.Plot.Axes.SetLimitsY(0, 100); ResourceOverviewCpuChart.Plot.YLabel("CPU %"); @@ -2966,7 +2966,7 @@ private void LoadResourceOverviewMemoryChart(IEnumerable memory noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - ResourceOverviewMemoryChart.Plot.Axes.DateTimeTicksBottom(); + ResourceOverviewMemoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); ResourceOverviewMemoryChart.Plot.Axes.SetLimitsX(xMin, xMax); ResourceOverviewMemoryChart.Plot.YLabel("MB"); LockChartVerticalAxis(ResourceOverviewMemoryChart); @@ -3043,7 +3043,7 @@ private void LoadResourceOverviewIoChart(IEnumerable ioData, in noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - ResourceOverviewIoChart.Plot.Axes.DateTimeTicksBottom(); + ResourceOverviewIoChart.Plot.Axes.DateTimeTicksBottomDateChange(); ResourceOverviewIoChart.Plot.Axes.SetLimitsX(xMin, xMax); ResourceOverviewIoChart.Plot.Axes.AutoScaleY(); ResourceOverviewIoChart.Plot.YLabel("Latency (ms)"); @@ -3115,7 +3115,7 @@ private void LoadResourceOverviewWaitChart(IEnumerable waitD noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; } - ResourceOverviewWaitChart.Plot.Axes.DateTimeTicksBottom(); + ResourceOverviewWaitChart.Plot.Axes.DateTimeTicksBottomDateChange(); ResourceOverviewWaitChart.Plot.Axes.SetLimitsX(xMin, xMax); ResourceOverviewWaitChart.Plot.Axes.AutoScaleY(); ResourceOverviewWaitChart.Plot.YLabel("Wait Time (ms/sec)"); diff --git a/Dashboard/Services/BenefitScorer.cs b/Dashboard/Services/BenefitScorer.cs index 1acf26cf..94606aba 100644 --- a/Dashboard/Services/BenefitScorer.cs +++ b/Dashboard/Services/BenefitScorer.cs @@ -616,20 +616,20 @@ internal static string ClassifyWaitType(string waitType) var wt = waitType.ToUpperInvariant(); return wt switch { - _ when wt.StartsWith("PAGEIOLATCH") => "I/O", - _ when wt.Contains("IO_COMPLETION") => "I/O", - _ when wt.StartsWith("WRITELOG") => "I/O", + _ when wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => "I/O", + _ when wt.Contains("IO_COMPLETION", StringComparison.Ordinal) => "I/O", + _ when wt.StartsWith("WRITELOG", StringComparison.Ordinal) => "I/O", _ when wt == "SOS_SCHEDULER_YIELD" => "CPU", - _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "Parallelism", - _ when wt.StartsWith("CXSYNC") => "Parallelism", - _ when wt.StartsWith("HT") => "Hash", + _ when wt.StartsWith("CXPACKET", StringComparison.Ordinal) || wt.StartsWith("CXCONSUMER", StringComparison.Ordinal) => "Parallelism", + _ when wt.StartsWith("CXSYNC", StringComparison.Ordinal) => "Parallelism", + _ when wt.StartsWith("HT", StringComparison.Ordinal) => "Hash", _ when wt == "BPSORT" => "Sort", _ when wt == "BMPBUILD" => "Hash", - _ when wt.StartsWith("PAGELATCH") => "Latch", - _ when wt.StartsWith("LATCH_") => "Latch", - _ when wt.StartsWith("LCK_") => "Lock", + _ when wt.StartsWith("PAGELATCH", StringComparison.Ordinal) => "Latch", + _ when wt.StartsWith("LATCH_", StringComparison.Ordinal) => "Latch", + _ when wt.StartsWith("LCK_", StringComparison.Ordinal) => "Lock", _ when wt == "ASYNC_NETWORK_IO" => "Network", - _ when wt.Contains("MEMORY_ALLOCATION") => "Memory", + _ when wt.Contains("MEMORY_ALLOCATION", StringComparison.Ordinal) => "Memory", _ when wt == "SOS_PHYS_PAGE_CACHE" => "Memory", _ => "Other" }; diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 254246d5..1f153ee5 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -253,7 +253,7 @@ private static void AnalyzeStatement(PlanStatement stmt) if (unsnifffedParams.Count > 0) { - var hasRecompile = stmt.StatementText.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); + var hasRecompile = (stmt.StatementText ?? "").Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); if (!hasRecompile) { var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); @@ -1099,7 +1099,7 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn // Rule 28: Row Count Spool — NOT IN with nullable column // Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate, // and statement text contains NOT IN - if (node.PhysicalOp.Contains("Row Count Spool")) + if ((node.PhysicalOp ?? "").Contains("Row Count Spool", StringComparison.Ordinal)) { var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; if (rewinds > 10000 && HasNotInPattern(node, stmt)) @@ -1118,7 +1118,7 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn if (!(node.HasActualStats && node.ActualExecutions == 0)) foreach (var w in node.Warnings.ToList()) { - if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan", StringComparison.Ordinal)) { w.Severity = PlanWarningSeverity.Critical; w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; @@ -1828,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/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 933d65e4..36437cbc 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -644,6 +644,40 @@ + + + diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml index 5e0b8324..20abf9bb 100644 --- a/Dashboard/Themes/DarkTheme.xaml +++ b/Dashboard/Themes/DarkTheme.xaml @@ -643,6 +643,40 @@ + + + diff --git a/Dashboard/Themes/LightTheme.xaml b/Dashboard/Themes/LightTheme.xaml index 903203f3..4e22f4f3 100644 --- a/Dashboard/Themes/LightTheme.xaml +++ b/Dashboard/Themes/LightTheme.xaml @@ -644,6 +644,40 @@ + + + diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index fd1de4e8..80439328 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -183,7 +183,7 @@ private void UpdateChart() scatter.MarkerSize = 4; } - HistoryChart.Plot.Axes.DateTimeTicksBottom(); + HistoryChart.Plot.Axes.DateTimeTicksBottomDateChange(); Helpers.TabHelpers.ReapplyAxisColors(HistoryChart); HistoryChart.Plot.YLabel(metricLabel); HistoryChart.Plot.XLabel("End Time"); diff --git a/Lite/Services/BenefitScorer.cs b/Lite/Services/BenefitScorer.cs index a922c9b7..7d03ae91 100644 --- a/Lite/Services/BenefitScorer.cs +++ b/Lite/Services/BenefitScorer.cs @@ -616,20 +616,20 @@ internal static string ClassifyWaitType(string waitType) var wt = waitType.ToUpperInvariant(); return wt switch { - _ when wt.StartsWith("PAGEIOLATCH") => "I/O", - _ when wt.Contains("IO_COMPLETION") => "I/O", - _ when wt.StartsWith("WRITELOG") => "I/O", + _ when wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => "I/O", + _ when wt.Contains("IO_COMPLETION", StringComparison.Ordinal) => "I/O", + _ when wt.StartsWith("WRITELOG", StringComparison.Ordinal) => "I/O", _ when wt == "SOS_SCHEDULER_YIELD" => "CPU", - _ when wt.StartsWith("CXPACKET") || wt.StartsWith("CXCONSUMER") => "Parallelism", - _ when wt.StartsWith("CXSYNC") => "Parallelism", - _ when wt.StartsWith("HT") => "Hash", + _ when wt.StartsWith("CXPACKET", StringComparison.Ordinal) || wt.StartsWith("CXCONSUMER", StringComparison.Ordinal) => "Parallelism", + _ when wt.StartsWith("CXSYNC", StringComparison.Ordinal) => "Parallelism", + _ when wt.StartsWith("HT", StringComparison.Ordinal) => "Hash", _ when wt == "BPSORT" => "Sort", _ when wt == "BMPBUILD" => "Hash", - _ when wt.StartsWith("PAGELATCH") => "Latch", - _ when wt.StartsWith("LATCH_") => "Latch", - _ when wt.StartsWith("LCK_") => "Lock", + _ when wt.StartsWith("PAGELATCH", StringComparison.Ordinal) => "Latch", + _ when wt.StartsWith("LATCH_", StringComparison.Ordinal) => "Latch", + _ when wt.StartsWith("LCK_", StringComparison.Ordinal) => "Lock", _ when wt == "ASYNC_NETWORK_IO" => "Network", - _ when wt.Contains("MEMORY_ALLOCATION") => "Memory", + _ when wt.Contains("MEMORY_ALLOCATION", StringComparison.Ordinal) => "Memory", _ when wt == "SOS_PHYS_PAGE_CACHE" => "Memory", _ => "Other" }; diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 29d0a3d6..5f7284c1 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; using PerformanceMonitorLite.Models; @@ -253,7 +250,7 @@ private static void AnalyzeStatement(PlanStatement stmt) if (unsnifffedParams.Count > 0) { - var hasRecompile = stmt.StatementText.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); + var hasRecompile = (stmt.StatementText ?? "").Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); if (!hasRecompile) { var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); @@ -1099,7 +1096,7 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn // Rule 28: Row Count Spool — NOT IN with nullable column // Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate, // and statement text contains NOT IN - if (node.PhysicalOp.Contains("Row Count Spool")) + if ((node.PhysicalOp ?? "").Contains("Row Count Spool", StringComparison.Ordinal)) { var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; if (rewinds > 10000 && HasNotInPattern(node, stmt)) @@ -1118,7 +1115,7 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn if (!(node.HasActualStats && node.ActualExecutions == 0)) foreach (var w in node.Warnings.ToList()) { - if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan", StringComparison.Ordinal)) { w.Severity = PlanWarningSeverity.Critical; w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs index 612653c3..9265c32e 100644 --- a/Lite/Services/RemoteCollectorService.QueryStore.cs +++ b/Lite/Services/RemoteCollectorService.QueryStore.cs @@ -222,7 +222,7 @@ ORDER BY /* Fall back to 13 (SQL 2016) if version detection fails */ } - bool isNew = productVersion > 13 || serverStatus.SqlEngineEdition == 5 || serverStatus.SqlEngineEdition == 8; + bool isNew = productVersion > 13 || serverStatus?.SqlEngineEdition == 5 || serverStatus?.SqlEngineEdition == 8; bool hasPlanType = productVersion >= 16; /* Build version-conditional column fragments for the Query Store query. From f1c8160d78ddbd8178ee09ff4040bd722c6c56c0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:20:09 -0400 Subject: [PATCH 17/49] Fix Overview crosshair disappearing after tab switches / layout passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../CorrelatedTimelineLanesControl.xaml.cs | 20 ++++++++- .../Helpers/CorrelatedCrosshairManager.cs | 43 ++++++++++++++++--- .../CorrelatedTimelineLanesControl.xaml.cs | 13 +++++- Lite/Helpers/CorrelatedCrosshairManager.cs | 43 ++++++++++++++++--- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 7d8f878f..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(); + } } /// diff --git a/Dashboard/Helpers/CorrelatedCrosshairManager.cs b/Dashboard/Helpers/CorrelatedCrosshairManager.cs index 80e2ec7d..a13efb2d 100644 --- a/Dashboard/Helpers/CorrelatedCrosshairManager.cs +++ b/Dashboard/Helpers/CorrelatedCrosshairManager.cs @@ -33,7 +33,6 @@ internal sealed class CorrelatedCrosshairManager : IDisposable private readonly Popup _tooltip; private readonly TextBlock _tooltipText; private DateTime _lastUpdate; - private bool _isRefreshing; public CorrelatedCrosshairManager() { @@ -144,10 +143,11 @@ public void SetComparisonLabel(string label) /// /// Clears data and VLines. Call before re-populating charts. + /// The OnMouseMove guard relies on lane.VLine == null to detect "not ready", + /// so this is self-healing: once ReattachVLines runs, crosshairs resume. /// public void PrepareForRefresh() { - _isRefreshing = true; _tooltip.IsOpen = false; _comparisonLabel = null; foreach (var lane in _lanes) @@ -162,25 +162,54 @@ public void PrepareForRefresh() /// /// Creates fresh VLine plottables on each lane's chart. - /// Must be called AFTER chart data is populated. + /// Must be called AFTER chart data is populated. Safe to call in a finally + /// block — if chart state is invalid, a failure on one lane won't prevent + /// the others from recovering. /// public void ReattachVLines() { foreach (var lane in _lanes) { - var vline = lane.Chart.Plot.Add.VerticalLine(0); + lane.VLine = CreateVLine(lane.Chart); + } + } + + /// + /// Creates VLines only for lanes that don't already have one. Idempotent — + /// safe to call from a finally block as a recovery path after an exception + /// in the main refresh flow. + /// + public void EnsureVLinesAttached() + { + foreach (var lane in _lanes) + { + if (lane.VLine != null) continue; + lane.VLine = CreateVLine(lane.Chart); + } + } + + private static ScottPlot.Plottables.VerticalLine? CreateVLine(ScottPlot.WPF.WpfPlot chart) + { + try + { + var vline = chart.Plot.Add.VerticalLine(0); vline.Color = ScottPlot.Color.FromHex("#FFFFFF").WithAlpha(100); vline.LineWidth = 1; vline.LinePattern = ScottPlot.LinePattern.Dashed; vline.IsVisible = false; - lane.VLine = vline; + return vline; + } + catch + { + /* If attach fails, return null so OnMouseMove skips this lane. + Next refresh will try again. */ + return null; } - _isRefreshing = false; } private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) { - if (_isRefreshing || sourceLane.VLine == null) return; + if (sourceLane.VLine == null) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 16) return; diff --git a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs index beb4c572..2b73b71a 100644 --- a/Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Lite/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. */ } /// @@ -203,11 +208,17 @@ await Task.WhenAll(cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fi _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, utcOffset); } 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(); _isRefreshing = false; } } diff --git a/Lite/Helpers/CorrelatedCrosshairManager.cs b/Lite/Helpers/CorrelatedCrosshairManager.cs index 75bce9bb..00098b4f 100644 --- a/Lite/Helpers/CorrelatedCrosshairManager.cs +++ b/Lite/Helpers/CorrelatedCrosshairManager.cs @@ -33,7 +33,6 @@ internal sealed class CorrelatedCrosshairManager : IDisposable private readonly Popup _tooltip; private readonly TextBlock _tooltipText; private DateTime _lastUpdate; - private bool _isRefreshing; public CorrelatedCrosshairManager() { @@ -144,10 +143,11 @@ public void SetComparisonLabel(string label) /// /// Clears data and VLines. Call before re-populating charts. + /// The OnMouseMove guard relies on lane.VLine == null to detect "not ready", + /// so this is self-healing: once ReattachVLines runs, crosshairs resume. /// public void PrepareForRefresh() { - _isRefreshing = true; _tooltip.IsOpen = false; _comparisonLabel = null; foreach (var lane in _lanes) @@ -162,25 +162,54 @@ public void PrepareForRefresh() /// /// Creates fresh VLine plottables on each lane's chart. - /// Must be called AFTER chart data is populated. + /// Must be called AFTER chart data is populated. Safe to call in a finally + /// block — if chart state is invalid, a failure on one lane won't prevent + /// the others from recovering. /// public void ReattachVLines() { foreach (var lane in _lanes) { - var vline = lane.Chart.Plot.Add.VerticalLine(0); + lane.VLine = CreateVLine(lane.Chart); + } + } + + /// + /// Creates VLines only for lanes that don't already have one. Idempotent — + /// safe to call from a finally block as a recovery path after an exception + /// in the main refresh flow. + /// + public void EnsureVLinesAttached() + { + foreach (var lane in _lanes) + { + if (lane.VLine != null) continue; + lane.VLine = CreateVLine(lane.Chart); + } + } + + private static ScottPlot.Plottables.VerticalLine? CreateVLine(ScottPlot.WPF.WpfPlot chart) + { + try + { + var vline = chart.Plot.Add.VerticalLine(0); vline.Color = ScottPlot.Color.FromHex("#FFFFFF").WithAlpha(100); vline.LineWidth = 1; vline.LinePattern = ScottPlot.LinePattern.Dashed; vline.IsVisible = false; - lane.VLine = vline; + return vline; + } + catch + { + /* If attach fails, return null so OnMouseMove skips this lane. + Next refresh will try again. */ + return null; } - _isRefreshing = false; } private void OnMouseMove(LaneInfo sourceLane, MouseEventArgs e) { - if (_isRefreshing || sourceLane.VLine == null) return; + if (sourceLane.VLine == null) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 16) return; From f014bf880567e4a8174d2532b4da2247fb76d5aa Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:15:02 -0400 Subject: [PATCH 18/49] 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) --- Dashboard/Controls/MemoryContent.xaml.cs | 138 +++++++++++++++++++---- Dashboard/Helpers/ChartHoverHelper.cs | 51 ++++++++- Dashboard/Mcp/McpInstructions.cs | 44 ++++++++ Dashboard/Mcp/McpSystemEventTools.cs | 12 +- 4 files changed, 220 insertions(+), 25 deletions(-) diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index bc07e18d..dbe405ea 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -1080,31 +1080,132 @@ private void LoadMemoryPressureEventsChart(IEnumerable _memoryPressureEventsHover?.Clear(); TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - // Only chart HIGH severity events - var dataList = data?.Where(d => d.Severity.Equals("HIGH", StringComparison.OrdinalIgnoreCase)) - .OrderBy(d => d.SampleTime).ToList() ?? new List(); + // Count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). + var dataList = data? + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList() ?? new List(); + bool hasData = false; + int maxBarCount = 0; + if (dataList.Count > 0) { - // Group by hour and count HIGH events var grouped = dataList .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) .OrderBy(g => g.Key) .ToList(); - if (grouped.Count > 0) + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + // Four series: SQL Server medium, SQL Server severe (stacked on top of medium), + // OS medium, OS severe. Stacking uses ValueBase so severe bars sit on top of medium. + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 + var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 + var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 + var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = g.Key.ToOADate(); + + if (sqlMedium > 0) + { + sqlMediumBars.Add(new ScottPlot.Bar + { + Position = x - barOffset, + ValueBase = 0, + Value = sqlMedium, + Size = barSize, + FillColor = sqlMediumColor, + LineWidth = 0 + }); + } + if (sqlSevere > 0) + { + sqlSevereBars.Add(new ScottPlot.Bar + { + Position = x - barOffset, + ValueBase = sqlMedium, + Value = sqlMedium + sqlSevere, + Size = barSize, + FillColor = sqlSevereColor, + LineWidth = 0 + }); + } + if (osMedium > 0) + { + osMediumBars.Add(new ScottPlot.Bar + { + Position = x + barOffset, + ValueBase = 0, + Value = osMedium, + Size = barSize, + FillColor = osMediumColor, + LineWidth = 0 + }); + } + if (osSevere > 0) + { + osSevereBars.Add(new ScottPlot.Bar + { + Position = x + barOffset, + ValueBase = osMedium, + Value = osMedium + osSevere, + Size = barSize, + FillColor = osSevereColor, + LineWidth = 0 + }); + } + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + bool anyBars = sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 + || osMediumBars.Count > 0 || osSevereBars.Count > 0; + + if (anyBars) { hasData = true; - var timePoints = grouped.Select(g => g.Key); - double[] highCounts = grouped.Select(g => (double)g.Count()).ToArray(); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, highCounts.Select(c => c)); - var highScatter = MemoryPressureEventsChart.Plot.Add.Scatter(xs, ys); - highScatter.LineWidth = 2; - highScatter.MarkerSize = 5; - highScatter.Color = TabHelpers.ChartColors[3]; - highScatter.LegendText = "High Pressure Events"; - _memoryPressureEventsHover?.Add(highScatter, "High Pressure Events"); + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } _legendPanels[MemoryPressureEventsChart] = MemoryPressureEventsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); MemoryPressureEventsChart.Plot.Legend.FontSize = 12; @@ -1114,7 +1215,7 @@ private void LoadMemoryPressureEventsChart(IEnumerable if (!hasData) { double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No memory pressure events in selected time range", xCenter, 0.5); noDataText.LabelFontSize = 14; noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; @@ -1122,11 +1223,8 @@ private void LoadMemoryPressureEventsChart(IEnumerable MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryPressureEventsChart.Plot.YLabel("Event Count"); - // Fixed negative space for legend - MemoryPressureEventsChart.Plot.Axes.AutoScaleY(); - var pressureLimits = MemoryPressureEventsChart.Plot.Axes.GetLimits(); - MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, pressureLimits.Top * 1.05); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); MemoryPressureEventsChart.Refresh(); diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index b1ec6f11..2ff0ab1a 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -17,6 +17,7 @@ internal sealed class ChartHoverHelper { private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); + private readonly List<(ScottPlot.Plottables.BarPlot BarPlot, string Label)> _barPlots = new(); private readonly Popup _popup; private readonly TextBlock _text; private string _unit; @@ -62,20 +63,28 @@ public void Dispose() _chart.MouseLeave -= OnMouseLeave; _popup.IsOpen = false; _scatters.Clear(); + _barPlots.Clear(); } - public void Clear() => _scatters.Clear(); + public void Clear() + { + _scatters.Clear(); + _barPlots.Clear(); + } public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) => + _barPlots.Add((barPlot, label)); + /// /// Returns the nearest series label and data-point time for the given mouse position, /// or null if no series is close enough. /// public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) { - if (_scatters.Count == 0) return null; + if (_scatters.Count == 0 && _barPlots.Count == 0) return null; try { var dpi = VisualTreeHelper.GetDpi(_chart); @@ -106,6 +115,8 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => } } + FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (found) return (bestLabel, DateTime.FromOADate(bestPoint.X)); } @@ -113,9 +124,36 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => return null; } + private void FindNearestBar(ScottPlot.Pixel pixel, ref double bestYDistance, + ref ScottPlot.DataPoint bestPoint, ref string bestLabel, ref bool found) + { + foreach (var (barPlot, label) in _barPlots) + { + foreach (var bar in barPlot.Bars) + { + var topPixel = _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position, bar.Value)); + double halfWidthPx = Math.Abs( + _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position + bar.Size / 2, bar.Value)).X + - topPixel.X); + double dx = Math.Abs(topPixel.X - pixel.X); + if (dx > halfWidthPx + 4) continue; + double dy = Math.Abs(topPixel.Y - pixel.Y); + if (dy < bestYDistance) + { + bestYDistance = dy; + // For stacked bars, report the segment height (Value - ValueBase), not the top coordinate + double segmentHeight = bar.Value - bar.ValueBase; + bestPoint = new ScottPlot.DataPoint(new ScottPlot.Coordinates(bar.Position, segmentHeight), 0); + bestLabel = label; + found = true; + } + } + } + } + private void OnMouseMove(object sender, MouseEventArgs e) { - if (_scatters.Count == 0) return; + if (_scatters.Count == 0 && _barPlots.Count == 0) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 30) return; _lastUpdate = now; @@ -158,10 +196,15 @@ pick the series closest in Y (nearest line to cursor). */ } } + FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (found) { var time = ServerTimeHelper.ConvertForDisplay(DateTime.FromOADate(bestPoint.X), ServerTimeHelper.CurrentDisplayMode); - _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}"; + string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) + ? bestPoint.Y.ToString("N0") + : bestPoint.Y.ToString("N1"); + _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time:HH:mm:ss}"; _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; _popup.IsOpen = true; diff --git a/Dashboard/Mcp/McpInstructions.cs b/Dashboard/Mcp/McpInstructions.cs index 65ba0f92..6b628b65 100644 --- a/Dashboard/Mcp/McpInstructions.cs +++ b/Dashboard/Mcp/McpInstructions.cs @@ -217,6 +217,50 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `RESOURCE_SEMAPHORE` | Memory grant pressure | `get_resource_semaphore` | | `LATCH_*` | Internal contention | `get_tempdb_trend` | + ## Interpreting Memory Pressure Events + + `get_memory_pressure_events` returns notifications from the `RING_BUFFER_RESOURCE_MONITOR` ring buffer. The `memory_indicators_process` and `memory_indicators_system` values are SQL Server's Resource Monitor signals. Indicator scale: + + - **0-1**: normal operating state, not actionable + - **2 (medium)**: Resource Monitor has crossed a threshold and is starting to respond — trimming caches, reducing memory grants. Worth investigating if sustained or frequent. + - **3+ (severe)**: aggressive response — buffer pool pages are being evicted, plan cache entries thrown out, workspace memory starved. Always worth investigating. + + The two indicators report different things: + + - `memory_indicators_process` — the SQL Server *process itself* is under memory pressure. Usually workload-induced (large memory grants, plan cache bloat, buffer pool churn). + - `memory_indicators_system` — Windows is signaling low memory *system-wide*. Something on the whole box is consuming memory; SQL Server may or may not be the culprit. + + ### What to check when process pressure (indicator >= 2) fires + + The workload is squeezing SQL Server itself. Follow-up tools: + | Signal to check | Tool | + |-----------------|------| + | Memory grant contention, workspace memory exhaustion | `get_resource_semaphore` | + | Buffer pool composition, memory clerk distribution | `get_memory_clerks` | + | Plan cache bloat (lots of single-use plans) | `get_plan_cache_bloat` | + | Page Life Expectancy, target vs total server memory | `get_memory_stats`, `get_memory_trend` | + | Queries that requested large grants during the window | `get_top_queries_by_cpu`, `get_expensive_queries` | + | `RESOURCE_SEMAPHORE` waits in the same window | `get_wait_stats`, `get_wait_trend` | + + ### What to check when system pressure (indicator >= 2) fires but process does not + + The box is tight on memory, but SQL Server's own process is not the cause. SQL Server feels Windows' low-memory notification but isn't driving it. Typical root causes: other services on the machine (anti-virus, backup agents, monitoring agents, additional SQL instances, SSIS/SSRS, RDP sessions), oversized file system cache, or VM-host memory oversubscription. Follow-up: + + | Signal to check | Tool | + |-----------------|------| + | SQL Server's memory configuration (`max server memory` vs total RAM) | `get_server_properties` | + | Is SQL Server itself actually fine? | `get_memory_stats`, `get_memory_clerks` | + + Most of the diagnosis in this case is *outside* the monitored SQL instance — tell the user to check what else is running on the host. + + ### Patterns + + - **Both process and system firing together** → real capacity problem. Add RAM, tune the workload, or reduce concurrency. + - **Process only** → workload/schema issue, not a hardware problem. Tune queries and indexes. + - **System only** → non-SQL workload on the host; SQL itself is healthy but the tenant mix is tight. + - **Bursty spikes** → correlate the pressure window with `get_running_jobs` (scheduled maintenance, index rebuilds, big reports) and `get_top_queries_by_cpu` for that period. + - **Flat-line sustained** → chronic under-provisioning; memory needs to grow or workload needs to shrink. + ## Tool Relationships - `get_wait_stats` identifies the symptom category (CPU, I/O, locks, parallelism). Other tools find the root cause. diff --git a/Dashboard/Mcp/McpSystemEventTools.cs b/Dashboard/Mcp/McpSystemEventTools.cs index 3aa2c1b5..d4ba0224 100644 --- a/Dashboard/Mcp/McpSystemEventTools.cs +++ b/Dashboard/Mcp/McpSystemEventTools.cs @@ -117,7 +117,17 @@ public static async Task GetTraceAnalysis( } } - [McpServerTool(Name = "get_memory_pressure_events"), Description("Gets memory pressure notifications from the ring buffer. Shows RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, and other memory broker notifications with process/system indicators.")] + [McpServerTool(Name = "get_memory_pressure_events"), Description(@"Gets memory pressure notifications from the RING_BUFFER_RESOURCE_MONITOR ring buffer (same source as sp_pressuredetector). Returns RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, RESOURCE_MEMPHYSICAL_HIGH, and RESOURCE_MEM_STEADY notifications with indicator values. + +Indicator scale (applies to both memory_indicators_process and memory_indicators_system): + 0-1 = normal, no pressure + 2 = medium pressure (SQL Server's Resource Monitor starts trimming caches and reducing grants) + 3+ = severe pressure (aggressive buffer pool / plan cache eviction) + +memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). +memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). + +For actionable interpretation and suggested follow-up tools, see the 'Interpreting Memory Pressure Events' section of the server instructions.")] public static async Task GetMemoryPressureEvents( ServerManager serverManager, DatabaseServiceRegistry registry, From b86250f0f4b1e9944384bc996557e5432480d351 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:52:27 -0400 Subject: [PATCH 19/49] Port Memory Pressure Events feature to Lite (#865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Lite/Controls/ServerTab.xaml | 7 + Lite/Controls/ServerTab.xaml.cs | 129 +++++++++++++++++- Lite/Database/DuckDbInitializer.cs | 19 ++- Lite/Database/Schema.cs | 17 +++ Lite/Helpers/ChartHoverHelper.cs | 50 ++++++- Lite/Mcp/McpInstructions.cs | 44 ++++++ Lite/Mcp/McpMemoryTools.cs | 53 +++++++ Lite/Services/ArchiveService.cs | 1 + Lite/Services/LocalDataService.Memory.cs | 50 +++++++ .../Services/RemoteCollectorService.Memory.cs | 104 ++++++++++++++ Lite/Services/RemoteCollectorService.cs | 1 + Lite/Services/ScheduleManager.cs | 4 + 12 files changed, 467 insertions(+), 12 deletions(-) diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index e85bc382..749a3b3e 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -1176,6 +1176,13 @@ + + + + + + + diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 24ed8f90..b15e362b 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -65,6 +65,7 @@ public partial class ServerTab : UserControl private Helpers.ChartHoverHelper? _memoryClerksHover; private Helpers.ChartHoverHelper? _memoryGrantSizingHover; private Helpers.ChartHoverHelper? _memoryGrantActivityHover; + private Helpers.ChartHoverHelper? _memoryPressureEventsHover; private Helpers.ChartHoverHelper? _currentWaitsDurationHover; private Helpers.ChartHoverHelper? _currentWaitsBlockedHover; @@ -202,6 +203,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe ApplyTheme(MemoryClerksChart); ApplyTheme(MemoryGrantSizingChart); ApplyTheme(MemoryGrantActivityChart); + ApplyTheme(MemoryPressureEventsChart); ApplyTheme(FileIoReadChart); ApplyTheme(FileIoWriteChart); ApplyTheme(FileIoReadThroughputChart); @@ -240,6 +242,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, ""); + _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); _currentWaitsDurationHover = new Helpers.ChartHoverHelper(CurrentWaitsDurationChart, "ms"); _currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); @@ -918,6 +921,7 @@ private async System.Threading.Tasks.Task RefreshAllTabsAsync(int hoursBack, Dat var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); @@ -931,7 +935,7 @@ await System.Threading.Tasks.Task.WhenAll( snapshotsTask, cpuTask, memoryTask, memoryTrendTask, queryStatsTask, procStatsTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask, deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask, - queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, + queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask, serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask, runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask); @@ -1022,6 +1026,7 @@ await System.Threading.Tasks.Task.WhenAll( UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); UpdateExecutionCountTrendChart(executionCountTrendTask.Result); UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); /* Populate pickers (preserve selections) */ PopulateWaitTypePicker(waitTypesTask.Result); @@ -1367,6 +1372,10 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date var grantChart = await _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); UpdateMemoryGrantCharts(grantChart); break; + case 3: // Memory Pressure Events + var pressureEvents = await _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); + UpdateMemoryPressureEventsChart(pressureEvents, hoursBack, fromDate, toDate); + break; } return; } @@ -1377,12 +1386,14 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); - await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask); + await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask); UpdateMemorySummary(memoryTask.Result); UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); await UpdateMemoryClerksChartFromPickerAsync(); } @@ -2033,6 +2044,119 @@ private void UpdateMemoryGrantCharts(List data) MemoryGrantActivityChart.Refresh(); } + /// + /// Stacked bar chart of memory pressure events per hour, split by SQL Server (process) vs + /// Operating System (system) and stacked by severity (medium=indicator 2, severe=indicator >= 3). + /// + private void UpdateMemoryPressureEventsChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(MemoryPressureEventsChart); + _memoryPressureEventsHover?.Clear(); + ApplyTheme(MemoryPressureEventsChart); + + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + /* Only count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). */ + var pressureRows = data + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList(); + + bool hasData = false; + int maxBarCount = 0; + + if (pressureRows.Count > 0) + { + var grouped = pressureRows + .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) + .OrderBy(g => g.Key) + .ToList(); + + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 + var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 + var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 + var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 + + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = g.Key.AddMinutes(UtcOffsetMinutes).ToOADate(); + + if (sqlMedium > 0) + sqlMediumBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = 0, Value = sqlMedium, Size = barSize, FillColor = sqlMediumColor, LineWidth = 0 }); + if (sqlSevere > 0) + sqlSevereBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = sqlMedium, Value = sqlMedium + sqlSevere, Size = barSize, FillColor = sqlSevereColor, LineWidth = 0 }); + if (osMedium > 0) + osMediumBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = 0, Value = osMedium, Size = barSize, FillColor = osMediumColor, LineWidth = 0 }); + if (osSevere > 0) + osSevereBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = osMedium, Value = osMedium + osSevere, Size = barSize, FillColor = osSevereColor, LineWidth = 0 }); + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + if (sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 || osMediumBars.Count > 0 || osSevereBars.Count > 0) + { + hasData = true; + + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } + } + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryPressureEventsChart); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + SetChartYLimitsWithLegendPadding(MemoryPressureEventsChart, 0, Math.Max(maxBarCount, 5)); + + if (hasData) + { + ShowChartLegend(MemoryPressureEventsChart); + } + + MemoryPressureEventsChart.Refresh(); + } + private void UpdateTempDbChart(List data) { ClearChart(TempDbChart); @@ -5367,6 +5491,7 @@ public void DisposeChartHelpers() _memoryClerksHover?.Dispose(); _memoryGrantSizingHover?.Dispose(); _memoryGrantActivityHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); _currentWaitsDurationHover?.Dispose(); _currentWaitsBlockedHover?.Dispose(); } diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index c7295783..e767c6d4 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -97,7 +97,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 24; + internal const int CurrentSchemaVersion = 25; private readonly string _archivePath; @@ -114,8 +114,8 @@ public DuckDbInitializer(string databasePath, ILogger? logger [ "wait_stats", "query_stats", "procedure_stats", "query_store_stats", "query_snapshots", "cpu_utilization_stats", "file_io_stats", "memory_stats", - "memory_clerks", "tempdb_stats", "perfmon_stats", "deadlocks", - "blocked_process_reports", "memory_grant_stats", "waiting_tasks", + "memory_clerks", "memory_pressure_events", "tempdb_stats", "perfmon_stats", + "deadlocks", "blocked_process_reports", "memory_grant_stats", "waiting_tasks", "running_jobs", "database_size_stats", "server_properties", "session_stats", "server_config", "database_config", "database_scoped_config", "trace_flags", "config_alert_log", @@ -639,6 +639,13 @@ New tables only — no existing table changes needed. Tables created by throw; } } + + if (fromVersion < 25) + { + /* v25: Added memory_pressure_events table for RING_BUFFER_RESOURCE_MONITOR notifications. + New table only — created by GetAllTableStatements(). */ + _logger?.LogInformation("Running migration to v25: adding memory_pressure_events table"); + } } /// @@ -651,9 +658,9 @@ private async Task FixServerIdsAsync(DuckDBConnection connection) var tablesWithServerId = new[] { "servers", "collection_log", "wait_stats", "query_stats", "cpu_utilization_stats", - "file_io_stats", "memory_stats", "memory_clerks", "deadlocks", - "procedure_stats", "query_store_stats", "query_snapshots", "tempdb_stats", - "perfmon_stats", "server_config", "database_config", + "file_io_stats", "memory_stats", "memory_clerks", "memory_pressure_events", + "deadlocks", "procedure_stats", "query_store_stats", "query_snapshots", + "tempdb_stats", "perfmon_stats", "server_config", "database_config", "blocked_process_reports", "memory_grant_stats", "waiting_tasks" }; diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index be4b3561..e8d8b7b8 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -190,6 +190,18 @@ CREATE TABLE IF NOT EXISTS memory_clerks ( memory_mb DECIMAL(18,2) )"; + public const string CreateMemoryPressureEventsTable = @" +CREATE TABLE IF NOT EXISTS memory_pressure_events ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + sample_time TIMESTAMP NOT NULL, + memory_notification VARCHAR NOT NULL, + memory_indicators_process INTEGER NOT NULL, + memory_indicators_system INTEGER NOT NULL +)"; + public const string CreateDeadlocksTable = @" CREATE TABLE IF NOT EXISTS deadlocks ( deadlock_id BIGINT PRIMARY KEY, @@ -519,6 +531,9 @@ is_optimized_locking_on BOOLEAN public const string CreateMemoryIndex = @" CREATE INDEX IF NOT EXISTS idx_memory_time ON memory_stats(server_id, collection_time)"; + public const string CreateMemoryPressureEventsIndex = @" +CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)"; + public const string CreateTempdbIndex = @" CREATE INDEX IF NOT EXISTS idx_tempdb_time ON tempdb_stats(server_id, collection_time)"; @@ -726,6 +741,7 @@ public static IEnumerable GetAllTableStatements() yield return CreateFileIoStatsTable; yield return CreateMemoryStatsTable; yield return CreateMemoryClerksTable; + yield return CreateMemoryPressureEventsTable; yield return CreateDeadlocksTable; yield return CreateProcedureStatsTable; yield return CreateQueryStoreStatsTable; @@ -769,6 +785,7 @@ public static IEnumerable GetAllIndexStatements() yield return CreateWaitingTasksIndex; yield return CreateBlockedProcessReportsIndex; yield return CreateMemoryClerksIndex; + yield return CreateMemoryPressureEventsIndex; yield return CreateDatabaseScopedConfigIndex; yield return CreateTraceFlagsIndex; yield return CreateRunningJobsIndex; diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 794b293b..28241eb4 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -17,6 +17,7 @@ internal sealed class ChartHoverHelper { private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); + private readonly List<(ScottPlot.Plottables.BarPlot BarPlot, string Label)> _barPlots = new(); private readonly Popup _popup; private readonly TextBlock _text; private string _unit; @@ -62,20 +63,28 @@ public void Dispose() _chart.MouseLeave -= OnMouseLeave; _popup.IsOpen = false; _scatters.Clear(); + _barPlots.Clear(); } - public void Clear() => _scatters.Clear(); + public void Clear() + { + _scatters.Clear(); + _barPlots.Clear(); + } public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) => + _barPlots.Add((barPlot, label)); + /// /// Returns the nearest series label and data-point time for the given mouse position, /// or null if no series is close enough. /// public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) { - if (_scatters.Count == 0) return null; + if (_scatters.Count == 0 && _barPlots.Count == 0) return null; var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( (float)(mousePos.X * dpi.DpiScaleX), @@ -103,14 +112,42 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => } } + FindNearestBar(pixel, ref bestDistance, ref bestPoint, ref bestLabel); + if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius return (bestLabel, DateTime.FromOADate(bestPoint.X)); return null; } + private void FindNearestBar(ScottPlot.Pixel pixel, ref double bestDistance, + ref ScottPlot.DataPoint bestPoint, ref string bestLabel) + { + foreach (var (barPlot, label) in _barPlots) + { + foreach (var bar in barPlot.Bars) + { + var topPixel = _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position, bar.Value)); + double halfWidthPx = Math.Abs( + _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position + bar.Size / 2, bar.Value)).X + - topPixel.X); + double dx = Math.Abs(topPixel.X - pixel.X); + if (dx > halfWidthPx + 4) continue; + double dy = Math.Abs(topPixel.Y - pixel.Y); + double dist = dx * dx + dy * dy; + if (dist < bestDistance) + { + bestDistance = dist; + double segmentHeight = bar.Value - bar.ValueBase; + bestPoint = new ScottPlot.DataPoint(new ScottPlot.Coordinates(bar.Position, segmentHeight), 0); + bestLabel = label; + } + } + } + } + private void OnMouseMove(object sender, MouseEventArgs e) { - if (_scatters.Count == 0) return; + if (_scatters.Count == 0 && _barPlots.Count == 0) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 50) return; _lastUpdate = now; @@ -145,10 +182,15 @@ private void OnMouseMove(object sender, MouseEventArgs e) } } + FindNearestBar(pixel, ref bestDistance, ref bestPoint, ref bestLabel); + if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius { var time = ServerTimeHelper.ConvertForDisplay(DateTime.FromOADate(bestPoint.X), ServerTimeHelper.CurrentDisplayMode); - _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}"; + string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) + ? bestPoint.Y.ToString("N0") + : bestPoint.Y.ToString("N1"); + _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time:HH:mm:ss}"; _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; _popup.IsOpen = true; diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index c063783b..766fcc9c 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -78,6 +78,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_memory_trend` | Memory usage over time | `server_name`, `hours_back` | | `get_memory_clerks` | Top memory consumers by clerk type | `server_name` | | `get_memory_grants` | Active/recent memory grants (detect grant pressure) | `server_name`, `hours_back` (default 1), `limit` | + | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back` | ### I/O Tools | Tool | Purpose | Key Parameters | @@ -196,6 +197,49 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo **Use `get_blocking` first** for a quick overview. **Use `get_blocked_process_reports`** when you need detailed analysis of prolonged blocking events. + ## Interpreting Memory Pressure Events + + `get_memory_pressure_events` returns notifications from the `RING_BUFFER_RESOURCE_MONITOR` ring buffer. The `memory_indicators_process` and `memory_indicators_system` values are SQL Server's Resource Monitor signals. Indicator scale: + + - **0-1**: normal operating state, not actionable + - **2 (medium)**: Resource Monitor has crossed a threshold and is starting to respond — trimming caches, reducing memory grants. Worth investigating if sustained or frequent. + - **3+ (severe)**: aggressive response — buffer pool pages are being evicted, plan cache entries thrown out, workspace memory starved. Always worth investigating. + + The two indicators report different things: + + - `memory_indicators_process` — the SQL Server *process itself* is under memory pressure. Usually workload-induced (large memory grants, plan cache bloat, buffer pool churn). + - `memory_indicators_system` — Windows is signaling low memory *system-wide*. Something on the whole box is consuming memory; SQL Server may or may not be the culprit. + + ### What to check when process pressure (indicator >= 2) fires + + The workload is squeezing SQL Server itself. Follow-up tools: + | Signal to check | Tool | + |-----------------|------| + | Memory grant contention, workspace memory pressure | `get_memory_grants` | + | Buffer pool composition, memory clerk distribution | `get_memory_clerks` | + | Page Life Expectancy, target vs total server memory | `get_memory_stats`, `get_memory_trend` | + | Queries that requested large grants during the window | `get_top_queries_by_cpu` | + | `RESOURCE_SEMAPHORE` waits in the same window | `get_wait_stats`, `get_wait_trend` | + + ### What to check when system pressure (indicator >= 2) fires but process does not + + The box is tight on memory, but SQL Server's own process is not the cause. SQL Server feels Windows' low-memory notification but isn't driving it. Typical root causes: other services on the machine (anti-virus, backup agents, monitoring agents, additional SQL instances, SSIS/SSRS, RDP sessions), oversized file system cache, or VM-host memory oversubscription. Follow-up: + + | Signal to check | Tool | + |-----------------|------| + | SQL Server's memory configuration (`max server memory` vs total RAM) | `get_server_properties` | + | Is SQL Server itself actually fine? | `get_memory_stats`, `get_memory_clerks` | + + Most of the diagnosis in this case is *outside* the monitored SQL instance — tell the user to check what else is running on the host. + + ### Patterns + + - **Both process and system firing together** → real capacity problem. Add RAM, tune the workload, or reduce concurrency. + - **Process only** → workload/schema issue, not a hardware problem. Tune queries and indexes. + - **System only** → non-SQL workload on the host; SQL itself is healthy but the tenant mix is tight. + - **Bursty spikes** → correlate the pressure window with `get_running_jobs` (scheduled maintenance, index rebuilds, big reports) and `get_top_queries_by_cpu` for that period. + - **Flat-line sustained** → chronic under-provisioning; memory needs to grow or workload needs to shrink. + ## Tool Relationships - `get_wait_stats` identifies the symptom category (CPU, I/O, locks, parallelism). Other tools find the root cause. diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 920797a8..5de9eccd 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -124,6 +124,59 @@ public static async Task GetMemoryClerks( } } + [McpServerTool(Name = "get_memory_pressure_events"), Description(@"Gets memory pressure notifications from the RING_BUFFER_RESOURCE_MONITOR ring buffer (same source as sp_pressuredetector). Returns RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, RESOURCE_MEMPHYSICAL_HIGH, and RESOURCE_MEM_STEADY notifications with indicator values. + +Indicator scale (applies to both memory_indicators_process and memory_indicators_system): + 0-1 = normal, no pressure + 2 = medium pressure (SQL Server's Resource Monitor starts trimming caches and reducing grants) + 3+ = severe pressure (aggressive buffer pool / plan cache eviction) + +memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). +memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). + +Not available on Azure SQL DB (ring buffer not exposed). For actionable interpretation and suggested follow-up tools, see the 'Interpreting Memory Pressure Events' section of the server instructions.")] + public static async Task GetMemoryPressureEvents( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + { + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + } + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetMemoryPressureEventsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + { + return "No memory pressure events found in the requested time range."; + } + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + events = rows.Select(r => new + { + sample_time = r.SampleTime.ToString("o"), + memory_notification = r.MemoryNotification, + memory_indicators_process = r.MemoryIndicatorsProcess, + memory_indicators_system = r.MemoryIndicatorsSystem + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_pressure_events", ex); + } + } + [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance.")] public static async Task GetMemoryGrants( LocalDataService dataService, diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs index 35d9c7e1..d7789e5d 100644 --- a/Lite/Services/ArchiveService.cs +++ b/Lite/Services/ArchiveService.cs @@ -56,6 +56,7 @@ internal static readonly (string Table, string TimeColumn)[] ArchivableTables = ("file_io_stats", "collection_time"), ("memory_stats", "collection_time"), ("memory_clerks", "collection_time"), + ("memory_pressure_events", "collection_time"), ("tempdb_stats", "collection_time"), ("perfmon_stats", "collection_time"), ("deadlocks", "collection_time"), diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index 3b581927..38b9e94a 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -181,6 +181,48 @@ FROM v_memory_clerks return items; } + /// + /// Gets memory pressure events (from RING_BUFFER_RESOURCE_MONITOR) for charting. + /// + public async Task> GetMemoryPressureEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + sample_time, + memory_notification, + memory_indicators_process, + memory_indicators_system +FROM v_memory_pressure_events +WHERE server_id = $1 +AND sample_time >= $2 +AND sample_time <= $3 +ORDER BY sample_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new MemoryPressureEventRow + { + SampleTime = reader.GetDateTime(0), + MemoryNotification = reader.IsDBNull(1) ? "" : reader.GetString(1), + MemoryIndicatorsProcess = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + MemoryIndicatorsSystem = reader.IsDBNull(3) ? 0 : reader.GetInt32(3) + }); + } + + return items; + } + /// /// Gets the latest memory clerk breakdown. /// @@ -252,3 +294,11 @@ public class MemoryClerkTrendPoint public string ClerkType { get; set; } = ""; public double MemoryMb { get; set; } } + +public class MemoryPressureEventRow +{ + public DateTime SampleTime { get; set; } + public string MemoryNotification { get; set; } = ""; + public int MemoryIndicatorsProcess { get; set; } + public int MemoryIndicatorsSystem { get; set; } +} diff --git a/Lite/Services/RemoteCollectorService.Memory.cs b/Lite/Services/RemoteCollectorService.Memory.cs index 4af53786..e90339e7 100644 --- a/Lite/Services/RemoteCollectorService.Memory.cs +++ b/Lite/Services/RemoteCollectorService.Memory.cs @@ -266,4 +266,108 @@ ORDER BY _logger?.LogDebug("Collected {RowCount} memory clerks for server '{Server}'", rowsCollected, server.DisplayName); return rowsCollected; } + + /// + /// Collects memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR. + /// Same source as sp_pressuredetector — reports IndicatorsProcess/IndicatorsSystem + /// (0-1 normal, 2 medium pressure, 3+ severe) alongside the notification type. + /// Azure SQL DB does not expose sys.dm_os_ring_buffers, so this collector returns 0 there. + /// + private async Task CollectMemoryPressureEventsAsync(ServerConnection server, CancellationToken cancellationToken) + { + var serverStatus = _serverManager.GetConnectionStatus(server.Id); + bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; + + _lastSqlMs = 0; + _lastDuckDbMs = 0; + + if (isAzureSqlDb) + { + /* Ring buffer is not exposed on Azure SQL DB */ + return 0; + } + + const string query = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @ms_ticks bigint, + @now datetime2(7) = SYSDATETIME(); + +SELECT @ms_ticks = dosi.ms_ticks FROM sys.dm_os_sys_info AS dosi; + +SELECT + sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), @now), + memory_notification = t.record.value('(/Record/ResourceMonitor/Notification)[1]', 'nvarchar(100)'), + memory_indicators_process = t.record.value('(/Record/ResourceMonitor/IndicatorsProcess)[1]', 'integer'), + memory_indicators_system = t.record.value('(/Record/ResourceMonitor/IndicatorsSystem)[1]', 'integer') +FROM +( + SELECT + dorb.timestamp, + record = CONVERT(xml, dorb.record) + FROM sys.dm_os_ring_buffers AS dorb + WHERE dorb.ring_buffer_type = N'RING_BUFFER_RESOURCE_MONITOR' +) AS t +ORDER BY t.timestamp +OPTION(RECOMPILE);"; + + var serverId = GetServerId(server); + var collectionTime = DateTime.UtcNow; + var rowsCollected = 0; + + /* Client-side dedup: computed sample_time cannot be filtered server-side + (it's derived from ms_ticks on each read). Fetch all and skip rows we already have. */ + var lastSampleTime = await GetLastCollectedTimeAsync( + serverId, "memory_pressure_events", "sample_time", cancellationToken); + + var sqlSw = Stopwatch.StartNew(); + using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); + using var command = new SqlCommand(query, sqlConnection); + command.CommandTimeout = CommandTimeoutSeconds; + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + sqlSw.Stop(); + _lastSqlMs = sqlSw.ElapsedMilliseconds; + + var duckSw = Stopwatch.StartNew(); + + using (var duckConnection = _duckDb.CreateConnection()) + { + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("memory_pressure_events")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var sampleTime = reader.IsDBNull(0) ? DateTime.MinValue : reader.GetDateTime(0); + if (lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) + continue; + + var notification = reader.IsDBNull(1) ? "" : reader.GetString(1); + var indicatorsProcess = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); + var indicatorsSystem = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(GetServerNameForStorage(server)) + .AppendValue(sampleTime) + .AppendValue(notification) + .AppendValue(indicatorsProcess) + .AppendValue(indicatorsSystem) + .EndRow(); + + rowsCollected++; + } + } + } + + duckSw.Stop(); + _lastDuckDbMs = duckSw.ElapsedMilliseconds; + + _logger?.LogDebug("Collected {RowCount} memory pressure events for server '{Server}'", rowsCollected, server.DisplayName); + return rowsCollected; + } } diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index c3564a71..541c4e53 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -389,6 +389,7 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam "cpu_utilization" => await CollectCpuUtilizationAsync(server, cancellationToken), "memory_stats" => await CollectMemoryStatsAsync(server, cancellationToken), "memory_clerks" => await CollectMemoryClerksAsync(server, cancellationToken), + "memory_pressure_events" => await CollectMemoryPressureEventsAsync(server, cancellationToken), "file_io_stats" => await CollectFileIoStatsAsync(server, cancellationToken), "query_stats" => await CollectQueryStatsAsync(server, cancellationToken), "procedure_stats" => await CollectProcedureStatsAsync(server, cancellationToken), diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs index d6a2bbd9..223c032c 100644 --- a/Lite/Services/ScheduleManager.cs +++ b/Lite/Services/ScheduleManager.cs @@ -38,6 +38,7 @@ public class ScheduleManager ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2, + ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["blocked_process_report"] = 1, ["running_jobs"] = 2 @@ -47,6 +48,7 @@ public class ScheduleManager ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5, + ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["blocked_process_report"] = 1, ["running_jobs"] = 5 @@ -56,6 +58,7 @@ public class ScheduleManager ["wait_stats"] = 5, ["query_stats"] = 10, ["procedure_stats"] = 10, ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5, ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30, + ["memory_pressure_events"] = 15, ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5, ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5, ["blocked_process_report"] = 5, ["running_jobs"] = 30 @@ -739,6 +742,7 @@ private static List GetDefaultSchedules() new() { Name = "file_io_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "File I/O statistics from sys.dm_io_virtual_file_stats" }, new() { Name = "memory_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Memory statistics from sys.dm_os_sys_memory and performance counters" }, new() { Name = "memory_clerks", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory clerk allocations from sys.dm_os_memory_clerks" }, + new() { Name = "memory_pressure_events", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR" }, new() { Name = "tempdb_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "TempDB space usage from sys.dm_db_file_space_usage" }, new() { Name = "perfmon_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Key performance counters from sys.dm_os_performance_counters" }, new() { Name = "deadlocks", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Deadlocks from system_health extended event session" }, From 04d2e24708a7b674db0f9c122c73dea5cad669b7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:05:32 -0400 Subject: [PATCH 20/49] 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) --- Lite.Tests/DuckDbSchemaTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs index c0526f64..1377db7a 100644 --- a/Lite.Tests/DuckDbSchemaTests.cs +++ b/Lite.Tests/DuckDbSchemaTests.cs @@ -138,8 +138,8 @@ public void SchemaStatements_MatchTableCount() foreach (var _ in Schema.GetAllTableStatements()) tableCount++; - /* 29 tables from Schema (schema_version is created separately by DuckDbInitializer) */ - Assert.Equal(29, tableCount); + /* 30 tables from Schema (schema_version is created separately by DuckDbInitializer) */ + Assert.Equal(30, tableCount); } [Fact] From 985da4a9a562f5ddbd9a10504a2f65630e32a3c1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:31:12 -0400 Subject: [PATCH 21/49] 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) --- Lite/Controls/ServerTab.xaml | 28 +++++- Lite/Controls/ServerTab.xaml.cs | 98 ++++++++++++++++++++ Lite/Services/LocalDataService.QueryStats.cs | 66 +++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 749a3b3e..6ee7b1cb 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -32,12 +32,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + - @@ -71,6 +95,11 @@ + + + + + + + @@ -619,7 +666,7 @@ @@ -813,7 +860,7 @@ diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index e793be13..cebd4e92 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -1,4 +1,5 @@ using System; +using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -12,6 +13,7 @@ using System.Windows.Controls.Primitives; using System.Windows.Media; using System.Windows.Threading; +using Microsoft.Data.SqlClient; using Microsoft.Win32; using PerformanceMonitorDashboard.Models; using PerformanceMonitorDashboard.Interfaces; @@ -2044,6 +2046,279 @@ private void DownloadDeadlockGraph_Click(object sender, RoutedEventArgs e) } } + // ── Blocked Process Report / Deadlock plan lookup ── + + /* SQL Server writes this 42-byte all-zero handle into executionStack frames + for dynamic SQL / system contexts where no persistent sql_handle exists. + Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */ + private static readonly string ZeroSqlHandle = "0x" + new string('0', 84); + + private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: false); + + private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e) + => await ShowBlockedProcessPlanAsync(sender, blockingSide: true); + + private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Parent is not ContextMenu cm) return; + var grid = FindDataGridFromContextMenu(cm); + if (grid?.SelectedItem is not BlockingEventItem row) return; + + var sideLabel = blockingSide ? "Blocking" : "Blocked"; + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + + var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide); + if (frames.Count == 0) + { + MessageBox.Show( + $"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _serverConnection.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, row.QueryText); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " + + "Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames( + string bprXml, bool blockingSide) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(bprXml)) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(bprXml); + var processContainer = blockingSide + ? doc.Element("blocking-process") + : doc.Element("blocked-process"); + var stack = processContainer?.Element("process")?.Element("executionStack"); + if (stack == null) return empty; + + var frames = new List<(string, int, int)>(); + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int stmtStart = 0; + int stmtEnd = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se; + + frames.Add((handle!, stmtStart, stmtEnd)); + } + return frames; + } + catch + { + return empty; + } + } + + /* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the + node, with optional + children for the call stack. Match by SPID since Dashboard's row + model doesn't carry the process graph id. */ + private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Parent is not ContextMenu cm) return; + var grid = FindDataGridFromContextMenu(cm); + if (grid?.SelectedItem is not DeadlockItem row) return; + + var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType; + var label = $"Est Plan - {sideLabel} SPID {row.Spid}"; + + var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid); + if (frames.Count == 0) + { + MessageBox.Show( + "The process has no resolvable sql_handle in the deadlock graph. " + + "This usually means the query ran as dynamic SQL or a system context — " + + "SQL Server records a zero handle in that case and the plan can't be recovered.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + string? planXml = null; + try + { + var connStr = _serverConnection.GetConnectionString(_credentialService); + foreach (var f in frames) + { + planXml = await FetchPlanBySqlHandleAsync( + connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd); + if (!string.IsNullOrEmpty(planXml)) break; + } + } + catch { } + + if (!string.IsNullOrEmpty(planXml)) + { + OpenPlanTab(planXml, label, row.Query); + PlanViewerTabItem.IsSelected = true; + } + else + { + MessageBox.Show( + $"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " + + "Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.", + "No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information); + } + } + + private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames( + string graphXml, short? spid) + { + var empty = Array.Empty<(string, int, int)>(); + if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty; + try + { + var doc = System.Xml.Linq.XElement.Parse(graphXml); + var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture); + var process = doc.Descendants("process") + .FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal)); + if (process == null) return empty; + + var frames = new List<(string, int, int)>(); + + var procHandle = process.Attribute("sqlhandle")?.Value; + if (!string.IsNullOrWhiteSpace(procHandle) && + !string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) + { + int ps = 0, pe = -1; + int.TryParse(process.Attribute("stmtstart")?.Value, out ps); + if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed; + frames.Add((procHandle!, ps, pe)); + } + + var stack = process.Element("executionStack"); + if (stack != null) + { + foreach (var frame in stack.Elements("frame")) + { + var handle = frame.Attribute("sqlhandle")?.Value; + if (string.IsNullOrWhiteSpace(handle)) continue; + if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue; + + int fs = 0, fe = -1; + int.TryParse(frame.Attribute("stmtstart")?.Value, out fs); + if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed; + frames.Add((handle!, fs, fe)); + } + } + + return frames; + } + catch + { + return empty; + } + } + + private static async Task FetchPlanBySqlHandleAsync( + string connectionString, + string databaseName, + string sqlHandleHex, + int statementStartOffset, + int statementEndOffset) + { + if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null; + var handleBytes = HexStringToBytes(sqlHandleHex); + if (handleBytes == null || handleBytes.Length == 0) return null; + + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + + /* Database context is only used to route the execution; sys.dm_exec_query_stats + is server-scoped, so if the supplied name isn't valid we fall back to master. */ + var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]"; + + var query = $@" +EXECUTE {quotedDbName}.sys.sp_executesql + N' +SELECT TOP (1) + query_plan_text = tqp.query_plan +FROM sys.dm_exec_query_stats AS qs +OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp +WHERE qs.sql_handle = @h +AND qs.statement_start_offset = @stmt_start +AND qs.statement_end_offset = @stmt_end +AND tqp.query_plan IS NOT NULL +ORDER BY + qs.last_execution_time DESC +OPTION(RECOMPILE);', + N'@h varbinary(64), @stmt_start int, @stmt_end int', + @h, @stmt_start, @stmt_end;"; + + using var command = new SqlCommand(query, connection) { CommandTimeout = 30 }; + command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes }); + command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset }); + command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset }); + var result = await command.ExecuteScalarAsync(); + return result as string; + } + + private static byte[]? HexStringToBytes(string hex) + { + var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0; + var len = hex.Length - start; + if (len <= 0 || (len % 2) != 0) return null; + var bytes = new byte[len / 2]; + for (int i = 0; i < bytes.Length; i++) + { + if (!byte.TryParse(hex.AsSpan(start + i * 2, 2), + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out bytes[i])) + { + return null; + } + } + return bytes; + } + + /* Only accept names that are syntactically plain identifiers so we can safely + interpolate into the EXEC statement. Unknown / invalid names fall back to master. */ + private static string? QuoteDatabaseName(string? dbName) + { + if (string.IsNullOrWhiteSpace(dbName)) return null; + foreach (var c in dbName) + { + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' ')) + return null; + } + return "[" + dbName.Replace("]", "]]") + "]"; + } + private void LoadUserPreferences() { var prefs = _preferencesService.GetPreferences(); From 5c988a092d1a42bc0cd3b7f543a6337617a8abc0 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Fri, 24 Apr 2026 14:19:11 +0100 Subject: [PATCH 33/49] Fixes #889 --- Dashboard/Controls/FinOpsContent.xaml | 6 ++ Dashboard/Controls/PlanViewerControl.xaml | 10 +-- Dashboard/Controls/PlanViewerControl.xaml.cs | 81 +++++++++++++------ .../Controls/QueryPerformanceContent.xaml | 27 ++++--- Dashboard/Themes/CoolBreezeTheme.xaml | 47 ++++++++++- Dashboard/Themes/DarkTheme.xaml | 43 +++++++++- Dashboard/Themes/LightTheme.xaml | 47 ++++++++++- 7 files changed, 213 insertions(+), 48 deletions(-) diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index 43573d47..0d8d0ba7 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -111,6 +111,9 @@ + + + @@ -416,6 +419,9 @@ + + + diff --git a/Dashboard/Controls/PlanViewerControl.xaml b/Dashboard/Controls/PlanViewerControl.xaml index d41669fc..2f720e50 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml +++ b/Dashboard/Controls/PlanViewerControl.xaml @@ -77,15 +77,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) @@ -488,14 +519,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 = TooltipFgBrush, FontSize = 12, FontWeight = FontWeights.SemiBold, HorizontalAlignment = HorizontalAlignment.Right, @@ -528,8 +559,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 = TooltipBgBrush, + BorderBrush = TooltipBorderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(10, 6, 10, 6), CornerRadius = new CornerRadius(4), @@ -571,6 +602,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); @@ -1504,7 +1536,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) }; @@ -1568,6 +1600,7 @@ private void ClosePropertiesPanel() _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness; _selectedNodeBorder = null; } + _selectedNode = null; } #endregion @@ -1780,7 +1813,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 { @@ -1792,7 +1825,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 @@ -1835,19 +1868,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); @@ -1859,7 +1892,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, @@ -1932,7 +1965,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) }; @@ -1958,7 +1991,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) }; @@ -2014,8 +2047,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 }); @@ -2030,7 +2063,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) }; @@ -2042,7 +2075,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/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml index 235f8efd..aa033e13 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml +++ b/Dashboard/Controls/QueryPerformanceContent.xaml @@ -879,6 +879,7 @@ + @@ -887,19 +888,19 @@ - + - + - + - + @@ -1194,6 +1195,7 @@ + @@ -1203,19 +1205,19 @@ - + - + - + - + @@ -1646,6 +1648,7 @@ + @@ -1654,19 +1657,19 @@ - + - + - + - + diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 36437cbc..a40c3af5 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -626,7 +626,7 @@ - + @@ -678,6 +678,31 @@ + + + + + + + + + + + + + + + @@ -785,7 +810,7 @@ - @@ -807,7 +832,7 @@ - + @@ -817,6 +842,7 @@ + @@ -1305,6 +1331,21 @@ + + + + + + + + + + + + + + + diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml index 20abf9bb..6f98ba5b 100644 --- a/Dashboard/Themes/DarkTheme.xaml +++ b/Dashboard/Themes/DarkTheme.xaml @@ -677,6 +677,31 @@ + + + + + + + + + + + + + + + @@ -784,7 +809,7 @@ - @@ -816,6 +841,7 @@ + @@ -1304,4 +1330,19 @@ + + + + + + + + + + + + + + + diff --git a/Dashboard/Themes/LightTheme.xaml b/Dashboard/Themes/LightTheme.xaml index 4e22f4f3..2bee5d50 100644 --- a/Dashboard/Themes/LightTheme.xaml +++ b/Dashboard/Themes/LightTheme.xaml @@ -626,7 +626,7 @@ - + @@ -678,6 +678,31 @@ + + + + + + + + + + + + + + + @@ -785,7 +810,7 @@ - @@ -807,7 +832,7 @@ - + @@ -817,6 +842,7 @@ + @@ -1305,4 +1331,19 @@ + + + + + + + + + + + + + + + From 6ded45bf5910610e091b5a7ecd47e9cd20f035a6 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Fri, 24 Apr 2026 14:39:09 +0100 Subject: [PATCH 34/49] fixes #889 on Lite --- Lite/Controls/FinOpsTab.xaml | 6 ++ Lite/Controls/PlanViewerControl.xaml | 10 +-- Lite/Controls/PlanViewerControl.xaml.cs | 85 ++++++++++++++++++------- Lite/Themes/CoolBreezeTheme.xaml | 42 +++++++++++- Lite/Themes/DarkTheme.xaml | 40 +++++++++++- Lite/Themes/LightTheme.xaml | 42 +++++++++++- 6 files changed, 191 insertions(+), 34 deletions(-) diff --git a/Lite/Controls/FinOpsTab.xaml b/Lite/Controls/FinOpsTab.xaml index c841008a..27c07ff4 100644 --- a/Lite/Controls/FinOpsTab.xaml +++ b/Lite/Controls/FinOpsTab.xaml @@ -97,6 +97,9 @@ + + + @@ -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/Themes/CoolBreezeTheme.xaml b/Lite/Themes/CoolBreezeTheme.xaml index 010d8a8c..6cefefad 100644 --- a/Lite/Themes/CoolBreezeTheme.xaml +++ b/Lite/Themes/CoolBreezeTheme.xaml @@ -735,8 +735,30 @@ + + + + + + + + + + + + - @@ -758,7 +780,7 @@ - + @@ -768,6 +790,7 @@ + @@ -1242,6 +1265,21 @@ + + + + + + + + + + + + + + + diff --git a/Lite/Themes/DarkTheme.xaml b/Lite/Themes/DarkTheme.xaml index 869af3de..1702d76a 100644 --- a/Lite/Themes/DarkTheme.xaml +++ b/Lite/Themes/DarkTheme.xaml @@ -735,8 +735,30 @@ + + + + + + + + + + + + - @@ -768,6 +790,7 @@ + @@ -1252,4 +1275,19 @@ + + + + + + + + + + + + + + + diff --git a/Lite/Themes/LightTheme.xaml b/Lite/Themes/LightTheme.xaml index e9908300..ce564069 100644 --- a/Lite/Themes/LightTheme.xaml +++ b/Lite/Themes/LightTheme.xaml @@ -735,8 +735,30 @@ + + + + + + + + + + + + - @@ -758,7 +780,7 @@ - + @@ -768,6 +790,7 @@ + @@ -1242,4 +1265,19 @@ + + + + + + + + + + + + + + + From 6c01da9f9a4f66f18327399c35e86393a6cd622d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:45:39 -0400 Subject: [PATCH 35/49] Add Off collection preset and re-enable-all behavior for named presets (#888) (#891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.apply_collection_preset now accepts a fourth preset, Off, which sets enabled = 0 across every row in config.collection_schedule without touching frequencies. The three existing presets (Aggressive, Balanced, Low-Impact) now also set enabled = 1 on every row before applying their frequencies, so switching back from Off reliably resumes collection — including daily/on-load collectors not in the preset list. Pair with two SQL Agent jobs to compose time-of-day windows (apply Off at the start of a quiet window, apply a named preset to resume) without needing a scheduler feature in the collectors themselves. Heads up: applying a non-Off preset overrides any manual UPDATE config.collection_schedule SET enabled = 0 on a specific collector. Documented in the proc header. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + README.md | 2 +- install/41_schedule_management.sql | 70 +++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbc6413..a0b1f540 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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) +- **`Off` collection preset** — `config.apply_collection_preset @preset_name = N'Off'` disables every collector in one call. Pair it with a second Agent job that applies a non-`Off` preset at the start of your active window to get overnight / quiet-hours scoping without writing scheduler code. Non-`Off` presets now also set `enabled = 1` across the board so the switch reliably resumes collection ([#888]) ### Changed @@ -47,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#865]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/865 [#867]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/867 [#872]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/872 +[#888]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/888 ## [2.7.0] - 2026-04-13 diff --git a/README.md b/README.md index a47b61a1..a3008652 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ All release binaries are digitally signed via [SignPath](https://signpath.io) ## What You Get -🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. +🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Off, Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. Switch presets with a pair of SQL Agent jobs to get quiet-hours / overnight windows without writing any code. 🚨 **Real-time alerts** for blocking, deadlocks, and high CPU — system tray notifications, styled HTML emails with full XML attachments, and webhook notifications for external integrations diff --git a/install/41_schedule_management.sql b/install/41_schedule_management.sql index 46c93b8a..e9944b45 100644 --- a/install/41_schedule_management.sql +++ b/install/41_schedule_management.sql @@ -142,10 +142,18 @@ GO /* Apply a named collection preset -Changes all scheduled collector frequencies in one operation. -Does not modify enabled/disabled state or daily/on-load collectors. -Valid preset names: Aggressive, Balanced, Low-Impact +Valid preset names: Off, Aggressive, Balanced, Low-Impact + +Off sets enabled = 0 on every row in config.collection_schedule and changes +nothing else — no frequency edits. The other three set enabled = 1 on every +row (so switching back from Off reliably reactivates collection) and update +frequency_minutes for the collectors they list. Daily/on-load collectors +that aren't in the preset keep their existing frequency. + +Heads up: applying a non-Off preset overrides any manual +UPDATE config.collection_schedule SET enabled = 0 on a specific collector. +If that matters in your environment, re-disable it after switching presets. */ IF OBJECT_ID(N'config.apply_collection_preset', N'P') IS NULL BEGIN @@ -169,9 +177,33 @@ BEGIN @rows_updated bigint = 0; BEGIN TRY - IF @preset_name NOT IN (N'Aggressive', N'Balanced', N'Low-Impact') + IF @preset_name NOT IN (N'Off', N'Aggressive', N'Balanced', N'Low-Impact') BEGIN - RAISERROR(N'Invalid preset name "%s". Valid presets: Aggressive, Balanced, Low-Impact', 16, 1, @preset_name); + RAISERROR(N'Invalid preset name "%s". Valid presets: Off, Aggressive, Balanced, Low-Impact', 16, 1, @preset_name); + RETURN; + END; + + /* + Off disables every collector and exits. No frequency table needed. + Pair with a second Agent job that applies a non-Off preset at the + start of your active window to resume collection. + */ + IF @preset_name = N'Off' + BEGIN + UPDATE + config.collection_schedule + SET + enabled = 0, + modified_date = SYSDATETIME(); + + SET @rows_updated = ROWCOUNT_BIG(); + + IF @debug = 1 + BEGIN + RAISERROR(N'Applied "Off" preset — %I64d collectors disabled', 0, 1, @rows_updated) WITH NOWAIT; + END; + + PRINT 'Applied "Off" collection preset (' + CONVERT(varchar(10), @rows_updated) + ' collectors disabled)'; RETURN; END; @@ -303,20 +335,27 @@ BEGIN END; /* - Apply the preset to all matching collectors. - Only updates frequency - does not change enabled/disabled state. - Skips daily/on-load collectors not in the preset. + Re-enable every collector first so a switch from Off → named preset + reliably resumes collection, including daily/on-load collectors that + aren't in the preset frequency table. + */ + UPDATE + config.collection_schedule + SET + enabled = 1, + modified_date = SYSDATETIME() + WHERE + enabled = 0; + + /* + Apply the preset frequencies to the collectors it lists. + Daily/on-load collectors not in the preset keep their existing frequency. */ UPDATE cs SET cs.frequency_minutes = p.frequency_minutes, - cs.next_run_time = - CASE - WHEN cs.enabled = 1 - THEN SYSDATETIME() - ELSE cs.next_run_time - END, + cs.next_run_time = SYSDATETIME(), cs.modified_date = SYSDATETIME() FROM config.collection_schedule AS cs JOIN @preset AS p @@ -416,11 +455,12 @@ PRINT ''; PRINT 'Available procedures:'; PRINT '- config.update_collector_frequency - Change frequency for specific collector'; PRINT '- config.set_collector_enabled - Enable/disable specific collector'; -PRINT '- config.apply_collection_preset - Apply a named preset (Aggressive, Balanced, Low-Impact)'; +PRINT '- config.apply_collection_preset - Apply a named preset (Off, Aggressive, Balanced, Low-Impact)'; PRINT '- config.show_collection_schedule - Display current schedule'; PRINT ''; PRINT 'Examples:'; PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Aggressive'', @debug = 1;'; PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Balanced'';'; PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Low-Impact'';'; +PRINT ' EXECUTE config.apply_collection_preset @preset_name = N''Off''; -- disables all collectors'; GO From fdd824a9682f8da234b4a7aaf37e63bb25e20e25 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:29:21 -0400 Subject: [PATCH 36/49] Bump DuckDB.NET to 1.5.2 Pulls in two upstream bugfix releases relevant to Lite's collector pattern: - 1.5.2 fixes unbounded row group growth on indexed tables under repeated load+insert cycles, and memory leaks/race conditions in prepared statements. - 1.5.1 hardens WAL checkpoint marking, prevents memory corruption in concurrent TrimFreeBlocks, and improves Windows UTF-8/UTF-16 handling. No storage version change within 1.5.x. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lite/PerformanceMonitorLite.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index a443fdee..7b8936e0 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -37,8 +37,8 @@ - - + + From 4bdd81e195dd5eaeab0b5ef4f7ba1e35b336813e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:46:49 -0400 Subject: [PATCH 37/49] Bump Microsoft.Extensions.*, System.Text.Json, ScottPlot.WPF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch-level upgrades only: - Microsoft.Extensions.Configuration / Configuration.Json / Hosting / Logging: 10.0.5 -> 10.0.7 (Dashboard, Lite) - System.Text.Json: 10.0.5 -> 10.0.7 (Lite) - ScottPlot.WPF: 5.1.57 -> 5.1.58 (Dashboard, Lite) Microsoft.Data.SqlClient (6.1.4 -> 7.0.1) and ModelContextProtocol (0.7.0-preview.1 -> 1.2.0) intentionally left out — both deserve dedicated PRs after a focused review. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dashboard/Dashboard.csproj | 8 ++++---- Lite/PerformanceMonitorLite.csproj | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index aff84b73..bf76c58d 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -36,12 +36,12 @@ - - - + + + - + diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index a443fdee..58a184d1 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -44,22 +44,22 @@ - + - + - + - + From 90907b4440dd38ae8652b5de9ec9cf6d1c14c094 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:02:18 -0400 Subject: [PATCH 38/49] Bump Microsoft.Data.SqlClient to 7.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SqlClient 7.0 split Azure/Entra dependencies out of the core package, so projects with Entra-Interactive auth paths (ActiveDirectoryInteractive) also need the new Microsoft.Data.SqlClient.Extensions.Azure helper to keep MFA login working at runtime. Bumps to 7.0.1 across: - Dashboard (+ Extensions.Azure 1.0.0) - Lite (+ Extensions.Azure 1.0.0) - Installer.Core (+ Extensions.Azure 1.0.0; transitive via Installer/InstallerGui/Installer.Tests) - InstallerGui (+ Extensions.Azure 1.0.0) - Installer (no Extensions.Azure — no Entra path) - Installer.Tests (no Extensions.Azure — no Entra path) Connection strings already set Encrypt explicitly everywhere, so the 4.0-era default flip is a no-op for this repo. SQL Server 2012+ remains supported (so all of 2016/2017/2019/2022/2025 stay reachable). Verified: - All six projects build clean - Installer.Tests: 46/46 passing against real SQL - Lite.Tests: 257/257 passing against real SQL - Dashboard and Lite launch and stay running Co-Authored-By: Claude Opus 4.7 (1M context) --- Dashboard/Dashboard.csproj | 3 ++- Installer.Core/Installer.Core.csproj | 3 ++- Installer.Tests/Installer.Tests.csproj | 2 +- Installer/PerformanceMonitorInstaller.csproj | 2 +- InstallerGui/InstallerGui.csproj | 3 ++- Lite/PerformanceMonitorLite.csproj | 3 ++- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index bf76c58d..2d08cfae 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -35,7 +35,8 @@ - + + diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj index c5d9b0c0..d37056cb 100644 --- a/Installer.Core/Installer.Core.csproj +++ b/Installer.Core/Installer.Core.csproj @@ -19,7 +19,8 @@ - + + diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj index 18a446f8..6c8dd23d 100644 --- a/Installer.Tests/Installer.Tests.csproj +++ b/Installer.Tests/Installer.Tests.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj index 83b6b38e..222138f1 100644 --- a/Installer/PerformanceMonitorInstaller.csproj +++ b/Installer/PerformanceMonitorInstaller.csproj @@ -31,7 +31,7 @@ - + diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj index 61d0342e..6e6a58ef 100644 --- a/InstallerGui/InstallerGui.csproj +++ b/InstallerGui/InstallerGui.csproj @@ -27,7 +27,8 @@ - + + diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index 80701a65..d8fe59b7 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -41,7 +41,8 @@ - + + From 987317c42ececed2e4d77f9dc58ec496de8bc1d0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:04:08 -0400 Subject: [PATCH 39/49] Bump ModelContextProtocol to 1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves Dashboard and Lite from the 0.7.0-preview.1 SDK to the stable 1.2.0 release. The C# SDK landed 1.0 between these two points and the codebase happens to use only the stable subset of the surface (attributes, AddMcpServer/WithHttpTransport/WithTools/MapMcp, DI-bound tool methods) — none of the breaking changes (filter API rename, RequestContext ctor, RunSessionHandler, binary-content types, McpServerHandlers, legacy SSE) touch this repo. 154 [McpServerTool] sites and 39 [McpServerToolType] sites compile unchanged. Zero source code changes — packaging only. Verified: - Dashboard and Lite build clean (0 errors) - Lite launches; the in-process MCP server starts on :5151 - A POST initialize against the new Streamable HTTP transport returns the full server capabilities and tool descriptions Co-Authored-By: Claude Opus 4.7 (1M context) --- Dashboard/Dashboard.csproj | 4 ++-- Lite/PerformanceMonitorLite.csproj | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 2d08cfae..32a0716d 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -40,8 +40,8 @@ - - + + diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index d8fe59b7..c38cb028 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -63,8 +63,8 @@ - - + + From 2366f575e11e8ea012f7a1652bf2b4b5f5f58431 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:33:39 -0400 Subject: [PATCH 40/49] Bump Microsoft.NET.Test.Sdk to 18.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch-level upgrade in the two test projects (Installer.Tests, Lite.Tests). Test-only — no shipping code touched. Verified: Installer.Tests 46/46, Lite.Tests 257/257 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- Installer.Tests/Installer.Tests.csproj | 2 +- Lite.Tests/Lite.Tests.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Installer.Tests/Installer.Tests.csproj b/Installer.Tests/Installer.Tests.csproj index 6c8dd23d..d605a241 100644 --- a/Installer.Tests/Installer.Tests.csproj +++ b/Installer.Tests/Installer.Tests.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Lite.Tests/Lite.Tests.csproj b/Lite.Tests/Lite.Tests.csproj index 874fd31f..a1c95836 100644 --- a/Lite.Tests/Lite.Tests.csproj +++ b/Lite.Tests/Lite.Tests.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From b23d64b578fdb66ea3a685ba5540f1e114584d50 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:57:54 -0400 Subject: [PATCH 41/49] Raise install loop timeout from 5 minutes to 1 hour (#884) (#886) ExecuteInstallationAsync applied StandardTimeoutSeconds (300s) to every batch in the main install script list. 98_validate_installation.sql runs every enabled collector with @debug = 1 via a cursor in a single batch, which on large databases (e.g. 7M-row collect.query_stats) takes longer than 5 minutes and fails the entire install/upgrade. ExecuteAllUpgradesAsync already uses UpgradeTimeoutSeconds (3600s) for upgrades/{from}-to-{to}/ scripts (cfd7d6e, #538/#539). Apply the same ceiling to the main install loop so files like 98_validate_installation and large-table DDL get the same hour instead of the 5 minutes that was only ever a comfortable default for small databases. Co-authored-by: Claude Opus 4.7 (1M context) --- Installer.Core/InstallationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Installer.Core/InstallationService.cs b/Installer.Core/InstallationService.cs index e26f99f5..c675a8e9 100644 --- a/Installer.Core/InstallationService.cs +++ b/Installer.Core/InstallationService.cs @@ -432,7 +432,7 @@ Files execute without transaction wrapping because many contain DDL. batchNumber++; using var command = new SqlCommand(trimmedBatch, connection); - command.CommandTimeout = StandardTimeoutSeconds; + command.CommandTimeout = UpgradeTimeoutSeconds; try { From 69fe460538a2df7e21ec4010f99cfb855132c971 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:11:56 -0400 Subject: [PATCH 42/49] Add Purge Now action to Manage Servers (#900) - New "Purge Now" button on Manage Servers window with override modes (configured / 1 / 3 / 7 / custom / all). All option uses TRUNCATE. - Right-click menu now mirrors all per-row actions (Edit, Toggle Favorite, Check Server Version, Purge Now, Remove) above existing copy/export. - config.data_retention reworked: @retention_days = NULL respects per-collector schedule, 0 TRUNCATEs every collect.* table, N > 0 overrides every cutoff to N days. Old @truncate_all parameter removed; @retention_days = 0 covers that case. Truncate path snapshots row count before wiping for reporting. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dashboard/ManageServersWindow.xaml | 8 + Dashboard/ManageServersWindow.xaml.cs | 67 ++++++++ Dashboard/PurgeNowDialog.xaml | 53 ++++++ Dashboard/PurgeNowDialog.xaml.cs | 114 +++++++++++++ Dashboard/Services/ServerManager.cs | 88 ++++++++++ install/43_data_retention.sql | 226 +++++++++++++++++++++----- 6 files changed, 515 insertions(+), 41 deletions(-) create mode 100644 Dashboard/PurgeNowDialog.xaml create mode 100644 Dashboard/PurgeNowDialog.xaml.cs diff --git a/Dashboard/ManageServersWindow.xaml b/Dashboard/ManageServersWindow.xaml index 99d1650c..c96e9863 100644 --- a/Dashboard/ManageServersWindow.xaml +++ b/Dashboard/ManageServersWindow.xaml @@ -9,6 +9,13 @@ + + + + + + @@ -91,6 +98,7 @@ public bool MultiSubnetFailover { get; set; } = false; + /// + /// User databases to skip in per-database collectors (query_store, file_io_stats, etc.). + /// System databases (master/tempdb/model/msdb) and the connection database itself are always + /// excluded by the collectors and aren't represented here. + /// + public System.Collections.Generic.List ExcludedDatabases { get; set; } = new(); + /// /// Server name with "(Read-Only)" suffix when ReadOnlyIntent is enabled. /// Used for sidebar subtitle and status text. diff --git a/Lite/Services/RemoteCollectorService.DatabaseSize.cs b/Lite/Services/RemoteCollectorService.DatabaseSize.cs index ad765fd2..7d9227ac 100644 --- a/Lite/Services/RemoteCollectorService.DatabaseSize.cs +++ b/Lite/Services/RemoteCollectorService.DatabaseSize.cs @@ -30,7 +30,7 @@ private async Task CollectDatabaseSizeStatsAsync(ServerConnection server, C var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremQuery = @" + string onPremQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET NOCOUNT ON; @@ -52,6 +52,7 @@ FROM sys.databases AS d WHERE d.state_desc = N'ONLINE' AND d.database_id > 0 AND HAS_DBACCESS(d.name) = 1 + /*EXCLUSION_FILTER_CURSOR*/ ORDER BY d.name; @@ -131,11 +132,19 @@ LEFT JOIN #file_space AS fs ON fs.database_id = mf.database_id AND fs.file_id = mf.file_id WHERE d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER_OUTER*/ ORDER BY d.name, mf.file_id OPTION(RECOMPILE);"; + /* Both filter sites (cursor SELECT and final SELECT) are in outer T-SQL, not nested dynamic SQL, + so parameter bindings work fine and the same @excl_db_N can be referenced twice. */ + var (dbSizeExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremQuery = onPremQuery + .Replace("/*EXCLUSION_FILTER_CURSOR*/", dbSizeExclusionClause) + .Replace("/*EXCLUSION_FILTER_OUTER*/", dbSizeExclusionClause); + const string azureSqlDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -231,6 +240,8 @@ ORDER BY using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(onPremQuery, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, dbSizeExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in dbSizeExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs index 203573e4..a1ac8a8e 100644 --- a/Lite/Services/RemoteCollectorService.FileIo.cs +++ b/Lite/Services/RemoteCollectorService.FileIo.cs @@ -83,8 +83,13 @@ LEFT JOIN sys.databases AS d WHERE (vfs.database_id > 4 OR vfs.database_id = 2) AND vfs.database_id < 32761 AND vfs.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ OPTION(RECOMPILE);"; + /* Azure path filters via GetAzureDatabaseListAsync; on-prem path injects here */ + var (fileIoExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + query = query.Replace("/*EXCLUSION_FILTER*/", isAzureSqlDb ? string.Empty : fileIoExclusionClause); + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -125,6 +130,8 @@ AND vfs.database_id < 32761 { using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection) { CommandTimeout = CommandTimeoutSeconds }; + var (_, fileIoExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in fileIoExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) fileStats.Add(ReadFileIoRow(reader)); diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs index 58a50570..3bd37c31 100644 --- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs +++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs @@ -32,7 +32,7 @@ so the standard NOT IN filter excludes everything. Use a simplified query. */ /* total_spills/min_spills/max_spills exist in dm_exec_procedure_stats and dm_exec_trigger_stats on all supported versions, but do NOT exist in dm_exec_function_stats on any version. Use dynamic SQL to handle this. */ - const string standardQuery = @" + string standardQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; DECLARE @@ -81,6 +81,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ UNION ALL @@ -136,6 +137,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ UNION ALL @@ -178,6 +180,7 @@ INNER JOIN sys.databases AS d WHERE d.state = 0 AND pa.dbid NOT IN (1, 3, 4, 32761, 32767, ISNULL(DB_ID(N''PerformanceMonitor''), 0)) AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ) AS combined ORDER BY total_elapsed_time DESC OPTION(RECOMPILE);' AS nvarchar(max)); @@ -220,9 +223,19 @@ No trigger stats or function stats — Azure SQL DB scope is single-database. */ FROM sys.dm_exec_procedure_stats AS s WHERE s.database_id = DB_ID() AND s.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ORDER BY s.total_elapsed_time DESC OPTION(RECOMPILE);"; + /* Standard query is dynamic SQL (built into @sql then passed to sp_executesql), so the + exclusion filter is interpolated as literal N'...' values rather than parameter bindings. + Names come from a user-picked checklist of existing DBs, escaped against single-quote + injection. forNestedDynamicSql=true doubles the escape because @sql is itself a + single-quoted T-SQL string at the outer layer. */ + string standardExclusionClause = BuildDatabaseExclusionLiteralClause( + server.ExcludedDatabases, "d.name", forNestedDynamicSql: true); + standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", standardExclusionClause); + string query = isAzureSqlDb ? azureSqlDbQuery : standardQuery; var serverId = GetServerId(server); diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index c18523c0..6e1de963 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -232,6 +232,18 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc var query = BuildQuerySnapshotsQuery(supportsLiveQueryPlan, isAzureSqlDatabase); + /* Append the per-database exclusion filter to the WHERE clause. The base query joins + sys.databases AS d, so we filter on d.name. When ExcludedDatabases is empty the + clause is "" so nothing changes. */ + var (qsExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + if (!string.IsNullOrEmpty(qsExclusionClause)) + { + /* Inject just before the OPTION clause so it lands inside the WHERE. */ + query = query.Replace( + "ORDER BY der.cpu_time DESC", + qsExclusionClause + "\nORDER BY der.cpu_time DESC"); + } + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -242,6 +254,8 @@ private async Task CollectQuerySnapshotsAsync(ServerConnection server, Canc using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, qsExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in qsExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); sqlSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs index b47705a6..4a139c3d 100644 --- a/Lite/Services/RemoteCollectorService.QueryStats.cs +++ b/Lite/Services/RemoteCollectorService.QueryStats.cs @@ -31,7 +31,7 @@ Use a simplified query that skips plan_attributes entirely — there's only one var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; - const string standardQuery = @" + string standardQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT /* PerformanceMonitorLite */ TOP (200) @@ -106,10 +106,14 @@ INNER JOIN sys.databases AS d WHERE pa.dbid NOT IN (1, 2, 3, 4, 32761, 32767, ISNULL(DB_ID(N'PerformanceMonitor'), 0)) AND st.text NOT LIKE N'%PerformanceMonitorLite%' AND qs.last_execution_time >= DATEADD(MINUTE, -10, GETDATE()) +/*EXCLUSION_FILTER*/ ORDER BY qs.total_elapsed_time DESC OPTION(RECOMPILE);"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + standardQuery = standardQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + /* Azure SQL DB: skip plan_attributes, use DB_NAME() for the single database context */ const string azureSqlDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -229,6 +233,11 @@ qs.total_elapsed_time DESC { using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + if (!isAzureSqlDb) + { + var (_, freshParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in freshParams) command.Parameters.Add(p); + } using var reader = await command.ExecuteReaderAsync(cancellationToken); diff --git a/Lite/Services/RemoteCollectorService.QueryStore.cs b/Lite/Services/RemoteCollectorService.QueryStore.cs index 9265c32e..5da8420a 100644 --- a/Lite/Services/RemoteCollectorService.QueryStore.cs +++ b/Lite/Services/RemoteCollectorService.QueryStore.cs @@ -32,7 +32,7 @@ Uses sys.database_query_store_options.actual_state instead of var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremDbQuery = @" + string onPremDbQuery = @" SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -60,6 +60,7 @@ AND d.name <> N'PerformanceMonitor' drs.database_id IS NULL /*not in any AG*/ OR drs.is_primary_replica = 1 /*primary replica*/ ) + /*EXCLUSION_FILTER*/ OPTION(RECOMPILE); OPEN db_check; @@ -103,7 +104,7 @@ FROM @result ORDER BY name;"; - const string azureDbQuery = @" + string azureDbQuery = @" SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -123,6 +124,7 @@ WHERE d.database_id > 4 AND d.database_id < 32761 AND d.state_desc = N'ONLINE' AND d.name <> N'PerformanceMonitor' + /*EXCLUSION_FILTER*/ OPTION(RECOMPILE); OPEN db_check; @@ -166,6 +168,10 @@ FROM @result ORDER BY name;"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; var serverId = GetServerId(server); @@ -187,6 +193,8 @@ ORDER BY using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) { dbCommand.CommandTimeout = CommandTimeoutSeconds; + var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in exclusionParams) dbCommand.Parameters.Add(p); using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); while (await dbReader.ReadAsync(cancellationToken)) { diff --git a/Lite/Services/RemoteCollectorService.ServerConfig.cs b/Lite/Services/RemoteCollectorService.ServerConfig.cs index 66f4b604..6feecd76 100644 --- a/Lite/Services/RemoteCollectorService.ServerConfig.cs +++ b/Lite/Services/RemoteCollectorService.ServerConfig.cs @@ -149,6 +149,8 @@ private async Task CollectDatabaseConfigAsync(ServerConnection server, Canc is_optimized_locking_on = d.is_optimized_locking_on"; } + var (dbConfigExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + var query = $@" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; @@ -158,6 +160,7 @@ FROM sys.databases AS d WHERE (d.database_id > 4 OR d.database_id = 2) AND d.database_id < 32761 AND d.name <> N'PerformanceMonitor' +{dbConfigExclusionClause} ORDER BY d.name OPTION(RECOMPILE);"; @@ -173,6 +176,8 @@ ORDER BY d.name using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, dbConfigExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in dbConfigExclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) @@ -329,7 +334,7 @@ private async Task CollectDatabaseScopedConfigAsync(ServerConnection server var serverStatus = _serverManager.GetConnectionStatus(server.Id); bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5; - const string onPremDbQuery = @" + string onPremDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@ -347,10 +352,11 @@ AND d.name <> N'PerformanceMonitor' drs.database_id IS NULL /*not in any AG*/ OR drs.is_primary_replica = 1 /*primary replica*/ ) +/*EXCLUSION_FILTER*/ ORDER BY d.name OPTION(RECOMPILE);"; - const string azureDbQuery = @" + string azureDbQuery = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT @@ -360,9 +366,14 @@ FROM sys.databases AS d AND d.database_id < 32761 AND d.name <> N'PerformanceMonitor' AND d.state_desc = N'ONLINE' +/*EXCLUSION_FILTER*/ ORDER BY d.name OPTION(RECOMPILE);"; + var (scopedExclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + onPremDbQuery = onPremDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); + azureDbQuery = azureDbQuery.Replace("/*EXCLUSION_FILTER*/", scopedExclusionClause); + string dbQuery = isAzureSqlDb ? azureDbQuery : onPremDbQuery; var serverId = GetServerId(server); @@ -379,6 +390,8 @@ ORDER BY d.name using (var dbCommand = new SqlCommand(dbQuery, sqlConnection)) { dbCommand.CommandTimeout = CommandTimeoutSeconds; + var (_, scopedExclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in scopedExclusionParams) dbCommand.Parameters.Add(p); using var dbReader = await dbCommand.ExecuteReaderAsync(cancellationToken); while (await dbReader.ReadAsync(cancellationToken)) { diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs index bd9373b1..aaa7d2d0 100644 --- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs +++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs @@ -24,7 +24,7 @@ public partial class RemoteCollectorService /// private async Task CollectWaitingTasksAsync(ServerConnection server, CancellationToken cancellationToken) { - const string query = @" + string query = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT /* PerformanceMonitorLite */ @@ -42,8 +42,12 @@ LEFT JOIN sys.databases AS d AND wt.session_id <> @@SPID AND wt.wait_type IS NOT NULL AND er.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) +/*EXCLUSION_FILTER*/ OPTION(RECOMPILE);"; + var (exclusionClause, _) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + query = query.Replace("/*EXCLUSION_FILTER*/", exclusionClause); + var serverId = GetServerId(server); var collectionTime = DateTime.UtcNow; var rowsCollected = 0; @@ -54,6 +58,8 @@ AND wt.wait_type IS NOT NULL using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); using var command = new SqlCommand(query, sqlConnection); command.CommandTimeout = CommandTimeoutSeconds; + var (_, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "d.name"); + foreach (var p in exclusionParams) command.Parameters.Add(p); using var reader = await command.ExecuteReaderAsync(cancellationToken); sqlSw.Stop(); diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index fdecb7d8..055f3702 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -634,15 +634,18 @@ protected async Task> GetAzureDatabaseListAsync(ServerConnection se InitialCatalog = "master" }.ConnectionString; + var (exclusionClause, exclusionParams) = BuildDatabaseExclusionFilter(server.ExcludedDatabases, "name"); + var databases = new List(); try { using var conn = new SqlConnection(connStr); await conn.OpenAsync(cancellationToken); using var cmd = new SqlCommand( - "SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 ORDER BY name;", + $"SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 {exclusionClause} ORDER BY name;", conn) { CommandTimeout = CommandTimeoutSeconds }; + foreach (var p in exclusionParams) cmd.Parameters.Add(p); using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) databases.Add(reader.GetString(0)); @@ -668,6 +671,54 @@ protected async Task> GetAzureDatabaseListAsync(ServerConnection se } } + /// + /// Builds a SQL fragment and matching SqlParameters for excluding the supplied database names. + /// When the list is empty, returns ("", []) so callers can splice without effect. + /// Each name is parameterized — works on every supported SQL Server version (no STRING_SPLIT/OPENJSON + /// compatibility-level dependency). + /// + /// Names from server.ExcludedDatabases. + /// SQL column to filter, e.g. "d.name". + internal static (string Clause, List Parameters) BuildDatabaseExclusionFilter( + IList? excludedDatabaseNames, string columnExpression) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + return (string.Empty, new List()); + + var paramNames = new List(excludedDatabaseNames.Count); + var sqlParams = new List(excludedDatabaseNames.Count); + for (int i = 0; i < excludedDatabaseNames.Count; i++) + { + string p = $"@excl_db_{i}"; + paramNames.Add(p); + sqlParams.Add(new SqlParameter(p, System.Data.SqlDbType.NVarChar, 128) { Value = excludedDatabaseNames[i] }); + } + return ($"AND {columnExpression} NOT IN ({string.Join(", ", paramNames)})", sqlParams); + } + + /// + /// Builds a SQL fragment with database names interpolated as literal N'...' values. + /// Use this for dynamic SQL paths where parameter binding is awkward (e.g. inside + /// a string passed to sp_executesql). Names come from user-picked checklists of + /// existing databases, so literal interpolation with single-quote escaping is safe. + /// When forNestedDynamicSql=true, doubles the escape for use inside an outer T-SQL + /// string that itself becomes a dynamic-SQL @sql variable. + /// + internal static string BuildDatabaseExclusionLiteralClause( + IList? excludedDatabaseNames, string columnExpression, bool forNestedDynamicSql = false) + { + if (excludedDatabaseNames is null || excludedDatabaseNames.Count == 0) + return string.Empty; + + string escapedQuote = forNestedDynamicSql ? "''" : "'"; + string Escape(string s) => forNestedDynamicSql + ? s.Replace("'", "''''") + : s.Replace("'", "''"); + + var quoted = excludedDatabaseNames.Select(n => $"N{escapedQuote}{Escape(n)}{escapedQuote}"); + return $"AND {columnExpression} NOT IN ({string.Join(", ", quoted)})"; + } + private static List SingleDbOrEmpty(string? targetDb) { if (string.IsNullOrEmpty(targetDb) || string.Equals(targetDb, "master", StringComparison.OrdinalIgnoreCase)) 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +