From 61f5a3beba16f112fb6396dd738b9d4f35f40f66 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Wed, 15 Apr 2026 17:20:53 +0100 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + -