diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index 738cd06..297fa5f 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -89,6 +89,16 @@ protected override void OnExit(ExitEventArgs e) private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) { var exception = e.ExceptionObject as Exception; + + /* Silently swallow Hardcodet TrayToolTip race condition (issue #422) when it + escapes the Dispatcher path — happens during tray-Exit shutdown when the + Dispatcher's exception hooks are torn down before the tray library finishes. */ + if (exception != null && IsTrayToolTipCrash(exception)) + { + Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422) in AppDomain handler"); + return; + } + Logger.Fatal("Unhandled AppDomain Exception", exception ?? new Exception("Unknown exception")); if (e.IsTerminating) diff --git a/Dashboard/Controls/MemoryContent.CopyExport.cs b/Dashboard/Controls/MemoryContent.CopyExport.cs new file mode 100644 index 0000000..75b0d92 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.CopyExport.cs @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.CurrentCell.Item != null) + { + var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); + if (!string.IsNullOrEmpty(cellContent)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(cellContent, false); + } + } + } + } + + private void CopyRow_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.SelectedItem != null) + { + var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(rowText, false); + } + } + } + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.Items.Count > 0) + { + var sb = new StringBuilder(); + + // Add headers + var headers = new List(); + foreach (var column in dataGrid.Columns) + { + if (column is DataGridBoundColumn) + { + headers.Add(Helpers.DataGridClipboardBehavior.GetHeaderText(column)); + } + } + sb.AppendLine(string.Join("\t", headers)); + + // Add all rows + foreach (var item in dataGrid.Items) + { + sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); + } + + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(sb.ToString(), false); + } + } + } + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); + if (dataGrid != null && dataGrid.Items.Count > 0) + { + string prefix = "memory"; + + + var saveFileDialog = new SaveFileDialog + { + FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" + }; + + if (saveFileDialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + + // Add headers + var headers = new List(); + foreach (var column in dataGrid.Columns) + { + if (column is DataGridBoundColumn) + { + headers.Add(TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); + } + } + sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); + + // Add all rows + foreach (var item in dataGrid.Items) + { + var values = TabHelpers.GetRowValues(dataGrid, item); + sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); + } + + File.WriteAllText(saveFileDialog.FileName, sb.ToString()); + MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryClerks.cs b/Dashboard/Controls/MemoryContent.MemoryClerks.cs new file mode 100644 index 0000000..2703cb5 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryClerks.cs @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Clerks + + private async System.Threading.Tasks.Task RefreshMemoryClerksAsync() + { + if (_databaseService == null) return; + + try + { + if (!MemoryClerksChart.Plot.GetPlottables().Any()) + { + MemoryClerksLoading.IsLoading = true; + MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; + } + + var clerkTypes = await _databaseService.GetDistinctMemoryClerkTypesAsync(_memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); + PopulateMemoryClerkPicker(clerkTypes); + await UpdateMemoryClerksChartFromPickerAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory clerks: {ex.Message}"); + } + finally + { + MemoryClerksLoading.IsLoading = false; + } + } + + private void PopulateMemoryClerkPicker(List clerkTypes) + { + var previouslySelected = new HashSet(_memoryClerkItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); + var topClerks = previouslySelected.Count == 0 ? new HashSet(clerkTypes.Take(5)) : null; + _memoryClerkItems = clerkTypes.Select(c => new SelectableItem + { + DisplayName = c, + IsSelected = previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c)) + }).ToList(); + RefreshMemoryClerkListOrder(); + } + + private void RefreshMemoryClerkListOrder() + { + if (_memoryClerkItems == null) return; + _memoryClerkItems = _memoryClerkItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.DisplayName) + .ToList(); + ApplyMemoryClerkFilter(); + UpdateMemoryClerkCount(); + } + + private void UpdateMemoryClerkCount() + { + if (_memoryClerkItems == null || MemoryClerkCountText == null) return; + int count = _memoryClerkItems.Count(x => x.IsSelected); + MemoryClerkCountText.Text = $"{count} selected"; + } + + private void ApplyMemoryClerkFilter() + { + var search = MemoryClerkSearchBox?.Text?.Trim() ?? ""; + MemoryClerksList.ItemsSource = null; + if (string.IsNullOrEmpty(search)) + MemoryClerksList.ItemsSource = _memoryClerkItems; + else + MemoryClerksList.ItemsSource = _memoryClerkItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + private void MemoryClerkSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyMemoryClerkFilter(); + + private void MemoryClerkSelectTop_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var topClerks = new HashSet(_memoryClerkItems.Take(5).Select(x => x.DisplayName)); + foreach (var item in _memoryClerkItems) + item.IsSelected = topClerks.Contains(item.DisplayName); + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerkClearAll_Click(object sender, RoutedEventArgs e) + { + _isUpdatingMemoryClerkSelection = true; + var visible = (MemoryClerksList.ItemsSource as IEnumerable)?.ToList() ?? _memoryClerkItems; + foreach (var item in visible) item.IsSelected = false; + _isUpdatingMemoryClerkSelection = false; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private void MemoryClerk_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingMemoryClerkSelection) return; + RefreshMemoryClerkListOrder(); + _ = UpdateMemoryClerksChartFromPickerAsync(); + } + + private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync() + { + if (_databaseService == null) return; + + try + { + var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); + + if (_legendPanels.TryGetValue(MemoryClerksChart, out var existingPanel) && existingPanel != null) + { + MemoryClerksChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryClerksChart] = null; + } + MemoryClerksChart.Plot.Clear(); + _memoryClerksHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryClerksChart); + + DateTime rangeEnd = _memoryClerksToDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = _memoryClerksFromDate ?? rangeEnd.AddHours(-_memoryClerksHoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (selected.Count > 0) + { + var selectedTypes = selected.Select(s => s.DisplayName).ToList(); + var data = await _databaseService.GetMemoryClerksByTypesAsync(selectedTypes, _memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); + var dataList = data.ToList(); + + MemoryClerksNoDataMessage.Visibility = dataList.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + + if (dataList.Count > 0) + { + var colors = TabHelpers.ChartColors; + int colorIndex = 0; + + foreach (var clerkType in selectedTypes) + { + var clerkData = dataList.Where(d => d.ClerkType == clerkType) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (clerkData.Count >= 1) + { + var timePoints = clerkData.Select(d => d.CollectionTime); + var values = clerkData.Select(d => (double)d.PagesMb); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = MemoryClerksChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = clerkType.Length > 20 ? clerkType.Substring(0, 20) + "..." : clerkType; + scatter.LegendText = label; + _memoryClerksHover?.Add(scatter, label); + colorIndex++; + } + } + + _legendPanels[MemoryClerksChart] = MemoryClerksChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryClerksChart.Plot.Legend.FontSize = 12; + } + + UpdateMemoryClerksSummaryPanel(dataList); + } + else + { + MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; + MemoryClerksTotalText.Text = "N/A"; + MemoryClerksTopText.Text = "N/A"; + } + + MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryClerksChart.Plot.YLabel("MB"); + MemoryClerksChart.Plot.Axes.AutoScaleY(); + var clerksLimits = MemoryClerksChart.Plot.Axes.GetLimits(); + MemoryClerksChart.Plot.Axes.SetLimitsY(0, clerksLimits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryClerksChart); + MemoryClerksChart.Refresh(); + } + catch (Exception ex) + { + Logger.Error($"Error updating memory clerks chart: {ex.Message}"); + } + } + + private void UpdateMemoryClerksSummaryPanel(List dataList) + { + if (dataList == null || dataList.Count == 0) + { + MemoryClerksTotalText.Text = "N/A"; + MemoryClerksTopText.Text = "N/A"; + return; + } + + var latestTime = dataList.Max(d => d.CollectionTime); + var latestData = dataList + .Where(d => d.CollectionTime == latestTime) + .Where(d => d.ClerkType == null || !d.ClerkType.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var totalMb = latestData.Sum(d => d.PagesMb); + MemoryClerksTotalText.Text = string.Format(CultureInfo.CurrentCulture, "{0:N0} MB", totalMb); + + var topClerk = latestData.OrderByDescending(d => d.PagesMb).FirstOrDefault(); + if (topClerk != null) + { + var name = topClerk.ClerkType ?? "Unknown"; + if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) + name = name.Substring(12); + if (name.Length > 20) name = name.Substring(0, 20) + "..."; + MemoryClerksTopText.Text = string.Format(CultureInfo.CurrentCulture, "{0} ({1:N0} MB)", name, topClerk.PagesMb); + } + else + { + MemoryClerksTopText.Text = "N/A"; + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryGrants.cs b/Dashboard/Controls/MemoryContent.MemoryGrants.cs new file mode 100644 index 0000000..3885cc7 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryGrants.cs @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Grants + + private sealed class PoolGrantPoint + { + public DateTime CollectionTime { get; set; } + public int PoolId { get; set; } + public double AvailableMemoryMb { get; set; } + public double GrantedMemoryMb { get; set; } + public double UsedMemoryMb { get; set; } + public double GranteeCount { get; set; } + public double WaiterCount { get; set; } + public double TimeoutErrorCountDelta { get; set; } + public double ForcedGrantCountDelta { get; set; } + } + + private async System.Threading.Tasks.Task RefreshMemoryGrantsAsync() + { + if (_databaseService == null) return; + + try + { + if (!MemoryGrantSizingChart.Plot.GetPlottables().Any()) + { + MemoryGrantSizingLoading.IsLoading = true; + MemoryGrantSizingNoData.Visibility = Visibility.Collapsed; + MemoryGrantActivityNoData.Visibility = Visibility.Collapsed; + } + + var data = await _databaseService.GetMemoryGrantStatsAsync(_memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + var dataList = data.ToList(); + + bool hasData = dataList.Count > 0; + MemoryGrantSizingNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; + MemoryGrantActivityNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; + + // Aggregate across resource_semaphore_id within each pool + var aggregated = dataList + .GroupBy(d => new { d.CollectionTime, d.PoolId }) + .Select(g => new PoolGrantPoint + { + CollectionTime = g.Key.CollectionTime, + PoolId = g.Key.PoolId, + AvailableMemoryMb = g.Sum(x => (double)(x.AvailableMemoryMb ?? 0)), + GrantedMemoryMb = g.Sum(x => (double)(x.GrantedMemoryMb ?? 0)), + UsedMemoryMb = g.Sum(x => (double)(x.UsedMemoryMb ?? 0)), + GranteeCount = g.Sum(x => (double)(x.GranteeCount ?? 0)), + WaiterCount = g.Sum(x => (double)(x.WaiterCount ?? 0)), + TimeoutErrorCountDelta = g.Sum(x => (double)(x.TimeoutErrorCountDelta ?? 0)), + ForcedGrantCountDelta = g.Sum(x => (double)(x.ForcedGrantCountDelta ?? 0)) + }) + .OrderBy(d => d.CollectionTime) + .ToList(); + + LoadMemoryGrantSizingChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + LoadMemoryGrantActivityChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory grants: {ex.Message}"); + MemoryGrantSizingNoData.Visibility = Visibility.Visible; + MemoryGrantActivityNoData.Visibility = Visibility.Visible; + } + finally + { + MemoryGrantSizingLoading.IsLoading = false; + MemoryGrantActivityLoading.IsLoading = false; + } + } + + private void LoadMemoryGrantSizingChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryGrantSizingChart, out var existingPanel) && existingPanel != null) + { + MemoryGrantSizingChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryGrantSizingChart] = null; + } + MemoryGrantSizingChart.Plot.Clear(); + _memoryGrantSizingHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); + + var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); + int colorIndex = 0; + var colors = TabHelpers.ChartColors; + bool hasData = false; + + foreach (var poolId in poolIds) + { + var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + if (poolData.Count == 0) continue; + hasData = true; + + var metrics = new (string Name, Func Selector)[] { + ("Available MB", d => d.AvailableMemoryMb), + ("Granted MB", d => d.GrantedMemoryMb), + ("Used MB", d => d.UsedMemoryMb) + }; + + foreach (var (metricName, selector) in metrics) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + poolData.Select(d => d.CollectionTime), + poolData.Select(selector)); + + if (xs.Length > 0) + { + var scatter = MemoryGrantSizingChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = $"Pool {poolId}: {metricName}"; + scatter.LegendText = label; + _memoryGrantSizingHover?.Add(scatter, label); + colorIndex++; + } + } + } + + if (hasData) + { + _legendPanels[MemoryGrantSizingChart] = MemoryGrantSizingChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryGrantSizingChart.Plot.Legend.FontSize = 12; + } + + MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryGrantSizingChart.Plot.YLabel("MB"); + MemoryGrantSizingChart.Plot.Axes.AutoScaleY(); + var limits = MemoryGrantSizingChart.Plot.Axes.GetLimits(); + MemoryGrantSizingChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryGrantSizingChart); + MemoryGrantSizingChart.Refresh(); + } + + private void LoadMemoryGrantActivityChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryGrantActivityChart, out var existingPanel) && existingPanel != null) + { + MemoryGrantActivityChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryGrantActivityChart] = null; + } + MemoryGrantActivityChart.Plot.Clear(); + _memoryGrantActivityHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); + + var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); + int colorIndex = 0; + var colors = TabHelpers.ChartColors; + bool hasData = false; + + foreach (var poolId in poolIds) + { + var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); + if (poolData.Count == 0) continue; + hasData = true; + + var metrics = new (string Name, Func Selector)[] { + ("Grantees", d => d.GranteeCount), + ("Waiters", d => d.WaiterCount), + ("Timeouts", d => d.TimeoutErrorCountDelta), + ("Forced Grants", d => d.ForcedGrantCountDelta) + }; + + foreach (var (metricName, selector) in metrics) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + poolData.Select(d => d.CollectionTime), + poolData.Select(selector)); + + if (xs.Length > 0) + { + var scatter = MemoryGrantActivityChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + var label = $"Pool {poolId}: {metricName}"; + scatter.LegendText = label; + _memoryGrantActivityHover?.Add(scatter, label); + colorIndex++; + } + } + } + + if (hasData) + { + _legendPanels[MemoryGrantActivityChart] = MemoryGrantActivityChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryGrantActivityChart.Plot.Legend.FontSize = 12; + } + + MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryGrantActivityChart.Plot.YLabel("Count"); + MemoryGrantActivityChart.Plot.Axes.AutoScaleY(); + var limits = MemoryGrantActivityChart.Plot.Axes.GetLimits(); + MemoryGrantActivityChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); + TabHelpers.LockChartVerticalAxis(MemoryGrantActivityChart); + MemoryGrantActivityChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryPressure.cs b/Dashboard/Controls/MemoryContent.MemoryPressure.cs new file mode 100644 index 0000000..f401d62 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryPressure.cs @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Pressure Events + + private async System.Threading.Tasks.Task RefreshMemoryPressureEventsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetMemoryPressureEventsAsync(_memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); + LoadMemoryPressureEventsChart(data.ToList(), _memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory pressure events: {ex.Message}"); + } + } + + private void LoadMemoryPressureEventsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryPressureEventsChart, out var existingPanel) && existingPanel != null) + { + MemoryPressureEventsChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryPressureEventsChart] = null; + } + MemoryPressureEventsChart.Plot.Clear(); + _memoryPressureEventsHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); + + // 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) + { + 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(); + + 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; + + 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; + } + } + + if (!hasData) + { + double xCenter = xMin + (xMax - xMin) / 2; + 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; + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); + + TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); + MemoryPressureEventsChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.MemoryStats.cs b/Dashboard/Controls/MemoryContent.MemoryStats.cs new file mode 100644 index 0000000..56c08fa --- /dev/null +++ b/Dashboard/Controls/MemoryContent.MemoryStats.cs @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Memory Stats + + private async System.Threading.Tasks.Task RefreshMemoryStatsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetMemoryStatsAsync(_memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); + var dataList = data.ToList(); + LoadMemoryStatsOverviewChart(dataList, _memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading memory stats: {ex.Message}"); + } + } + + private void LoadMemoryStatsOverviewChart(List memoryData, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(MemoryStatsOverviewChart, out var existingPanel) && existingPanel != null) + { + MemoryStatsOverviewChart.Plot.Axes.Remove(existingPanel); + _legendPanels[MemoryStatsOverviewChart] = null; + } + MemoryStatsOverviewChart.Plot.Clear(); + _memoryStatsOverviewHover?.Clear(); + TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); + + var dataList = memoryData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); + // Total Memory series with gap filling + var (totalXs, totalYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.TotalMemoryMb)); + + // Buffer Pool series with gap filling + var (bufferXs, bufferYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.BufferPoolMb)); + + // Plan Cache series with gap filling + var (cacheXs, cacheYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.PlanCacheMb)); + + // Available Physical Memory series with gap filling + var (availXs, availYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.AvailablePhysicalMemoryMb)); + + if (totalXs.Length > 0) + { + // Add pressure warning spans first (so they appear behind the lines) + AddPressureWarningSpans(dataList); + + var totalScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(totalXs, totalYs); + totalScatter.LineWidth = 2; + totalScatter.MarkerSize = 5; + totalScatter.Color = TabHelpers.ChartColors[9]; + totalScatter.LegendText = "Total Memory"; + _memoryStatsOverviewHover?.Add(totalScatter, "Total Memory"); + + var bufferScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(bufferXs, bufferYs); + bufferScatter.LineWidth = 2; + bufferScatter.MarkerSize = 5; + bufferScatter.Color = TabHelpers.ChartColors[0]; + bufferScatter.LegendText = "Buffer Pool"; + _memoryStatsOverviewHover?.Add(bufferScatter, "Buffer Pool"); + + var cacheScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(cacheXs, cacheYs); + cacheScatter.LineWidth = 2; + cacheScatter.MarkerSize = 5; + cacheScatter.Color = TabHelpers.ChartColors[1]; + cacheScatter.LegendText = "Plan Cache"; + _memoryStatsOverviewHover?.Add(cacheScatter, "Plan Cache"); + + var availScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(availXs, availYs); + availScatter.LineWidth = 2; + availScatter.MarkerSize = 5; + availScatter.Color = TabHelpers.ChartColors[2]; + availScatter.LegendText = "Available Physical"; + _memoryStatsOverviewHover?.Add(availScatter, "Available Physical"); + + _legendPanels[MemoryStatsOverviewChart] = MemoryStatsOverviewChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + MemoryStatsOverviewChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = MemoryStatsOverviewChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryStatsOverviewChart.Plot.Axes.SetLimitsX(xMin, xMax); + MemoryStatsOverviewChart.Plot.YLabel("MB"); + // Fixed negative space for legend + MemoryStatsOverviewChart.Plot.Axes.AutoScaleY(); + var memOverviewLimits = MemoryStatsOverviewChart.Plot.Axes.GetLimits(); + MemoryStatsOverviewChart.Plot.Axes.SetLimitsY(0, memOverviewLimits.Top * 1.05); + + TabHelpers.LockChartVerticalAxis(MemoryStatsOverviewChart); + MemoryStatsOverviewChart.Refresh(); + + // Update summary panel + UpdateMemoryStatsSummaryPanel(dataList); + } + + private void AddPressureWarningSpans(List dataList) + { + // Track whether we've added legend entries (only want one per type) + bool bpLegendAdded = false; + bool pcLegendAdded = false; + + // Find time ranges where pressure warnings are active + foreach (var item in dataList) + { + if (item.BufferPoolPressureWarning || item.PlanCachePressureWarning) + { + // Add a vertical line at this time point to indicate pressure + var x = item.CollectionTime.ToOADate(); + var vline = MemoryStatsOverviewChart.Plot.Add.VerticalLine(x); + vline.LineWidth = 1; + vline.LinePattern = ScottPlot.LinePattern.Dotted; + + if (item.BufferPoolPressureWarning && item.PlanCachePressureWarning) + { + vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.5); + // Add legend entry for BP pressure (covers "both" case too) + if (!bpLegendAdded) + { + vline.LegendText = "BP Pressure"; + bpLegendAdded = true; + } + } + else if (item.BufferPoolPressureWarning) + { + vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.3); + if (!bpLegendAdded) + { + vline.LegendText = "BP Pressure"; + bpLegendAdded = true; + } + } + else + { + vline.Color = TabHelpers.ChartColors[2].WithAlpha(0.3); + if (!pcLegendAdded) + { + vline.LegendText = "PC Pressure"; + pcLegendAdded = true; + } + } + } + } + } + + private void UpdateMemoryStatsSummaryPanel(List dataList) + { + if (dataList == null || dataList.Count == 0) + { + MemoryStatsPhysicalText.Text = "N/A"; + MemoryStatsSqlServerText.Text = "N/A"; + MemoryStatsTargetText.Text = "N/A"; + MemoryStatsBPPercentText.Text = "N/A"; + MemoryStatsPCPercentText.Text = "N/A"; + MemoryStatsUtilPercentText.Text = "N/A"; + MemoryStatsPressureText.Text = "None"; + MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + return; + } + + // Use the most recent data point + var latest = dataList.OrderByDescending(d => d.CollectionTime).First(); + + // Absolute GB values + MemoryStatsPhysicalText.Text = latest.TotalPhysicalMemoryMb.HasValue + ? $"{latest.TotalPhysicalMemoryMb.Value / 1024.0m:F1} GB" + : "N/A"; + + MemoryStatsSqlServerText.Text = $"{latest.PhysicalMemoryInUseMb / 1024.0m:F1} GB"; + + MemoryStatsTargetText.Text = latest.CommittedTargetMemoryMb.HasValue + ? $"{latest.CommittedTargetMemoryMb.Value / 1024.0m:F1} GB" + : "N/A"; + + // Buffer Pool and Plan Cache with GB and percentage + MemoryStatsBPPercentText.Text = latest.BufferPoolPercentage.HasValue + ? $"{latest.BufferPoolMb / 1024.0m:F1} GB ({latest.BufferPoolPercentage:F1}%)" + : $"{latest.BufferPoolMb / 1024.0m:F1} GB"; + + MemoryStatsPCPercentText.Text = latest.PlanCachePercentage.HasValue + ? $"{latest.PlanCacheMb / 1024.0m:F1} GB ({latest.PlanCachePercentage:F1}%)" + : $"{latest.PlanCacheMb / 1024.0m:F1} GB"; + + MemoryStatsUtilPercentText.Text = $"{latest.MemoryUtilizationPercentage}%"; + + // Build pressure status text + var pressures = new List(); + if (latest.BufferPoolPressureWarning) pressures.Add("BP"); + if (latest.PlanCachePressureWarning) pressures.Add("PC"); + + if (pressures.Count > 0) + { + MemoryStatsPressureText.Text = string.Join(", ", pressures); + MemoryStatsPressureText.Foreground = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(0xFF, 0x69, 0x69)); // Light red + } + else + { + MemoryStatsPressureText.Text = "None"; + MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.PlanCache.cs b/Dashboard/Controls/MemoryContent.PlanCache.cs new file mode 100644 index 0000000..6560b39 --- /dev/null +++ b/Dashboard/Controls/MemoryContent.PlanCache.cs @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class MemoryContent : UserControl + { + #region Plan Cache + + private async System.Threading.Tasks.Task RefreshPlanCacheAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetPlanCacheStatsAsync(_planCacheHoursBack, _planCacheFromDate, _planCacheToDate); + LoadPlanCacheChart(data.ToList(), _planCacheHoursBack, _planCacheFromDate, _planCacheToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading plan cache stats: {ex.Message}"); + } + } + + private void LoadPlanCacheChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(PlanCacheChart, out var existingPanel) && existingPanel != null) + { + PlanCacheChart.Plot.Axes.Remove(existingPanel); + _legendPanels[PlanCacheChart] = null; + } + PlanCacheChart.Plot.Clear(); + _planCacheHover?.Clear(); + TabHelpers.ApplyThemeToChart(PlanCacheChart); + + var dataList = data?.ToList() ?? new List(); + if (dataList.Count > 0) + { + // Group by collection time and get single-use vs multi-use sizes + var grouped = dataList.GroupBy(d => d.CollectionTime) + .Select(g => new { + Time = g.Key, + SingleUseSizeMb = g.Sum(x => x.SingleUseSizeMb), + MultiUseSizeMb = g.Sum(x => x.MultiUseSizeMb) + }) + .OrderBy(x => x.Time) + .ToList(); + + if (grouped.Count > 0) + { + // Single-Use series with gap filling + var (singleXs, singleYs) = TabHelpers.FillTimeSeriesGaps( + grouped.Select(d => d.Time), + grouped.Select(d => (double)d.SingleUseSizeMb)); + + var singleScatter = PlanCacheChart.Plot.Add.Scatter(singleXs, singleYs); + singleScatter.LineWidth = 2; + singleScatter.MarkerSize = 5; + singleScatter.Color = TabHelpers.ChartColors[3]; + singleScatter.LegendText = "Single-Use"; + _planCacheHover?.Add(singleScatter, "Single-Use"); + + // Multi-Use series with gap filling + var (multiXs, multiYs) = TabHelpers.FillTimeSeriesGaps( + grouped.Select(d => d.Time), + grouped.Select(d => (double)d.MultiUseSizeMb)); + + var multiScatter = PlanCacheChart.Plot.Add.Scatter(multiXs, multiYs); + multiScatter.LineWidth = 2; + multiScatter.MarkerSize = 5; + multiScatter.Color = TabHelpers.ChartColors[1]; + multiScatter.LegendText = "Multi-Use"; + _planCacheHover?.Add(multiScatter, "Multi-Use"); + + _legendPanels[PlanCacheChart] = PlanCacheChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + PlanCacheChart.Plot.Legend.FontSize = 12; + } + + // Update summary panel with latest data point + var latestOldestPlan = dataList + .Where(d => d.OldestPlanCreateTime.HasValue) + .OrderByDescending(d => d.CollectionTime) + .FirstOrDefault(); + var latestTime = dataList.Max(d => d.CollectionTime); + int totalPlans = dataList.Where(d => d.CollectionTime == latestTime).Sum(d => d.TotalPlans); + UpdatePlanCacheSummary(latestOldestPlan, totalPlans); + } + else + { + UpdatePlanCacheSummary(null, 0); + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = PlanCacheChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); + PlanCacheChart.Plot.Axes.SetLimitsX(xMin, xMax); + PlanCacheChart.Plot.YLabel("MB"); + // Fixed negative space for legend + PlanCacheChart.Plot.Axes.AutoScaleY(); + var planCacheLimits = PlanCacheChart.Plot.Axes.GetLimits(); + PlanCacheChart.Plot.Axes.SetLimitsY(0, planCacheLimits.Top * 1.05); + + TabHelpers.LockChartVerticalAxis(PlanCacheChart); + PlanCacheChart.Refresh(); + } + + private void UpdatePlanCacheSummary(PlanCacheStatsItem? oldestPlanData, int totalPlans) + { + if (oldestPlanData?.OldestPlanCreateTime != null) + { + var age = ServerTimeHelper.ServerNow - oldestPlanData.OldestPlanCreateTime.Value; + string ageText; + if (age.TotalDays >= 1) + ageText = $"{age.Days}d {age.Hours}h"; + else if (age.TotalHours >= 1) + ageText = $"{age.Hours}h {age.Minutes}m"; + else + ageText = $"{age.Minutes}m"; + + PlanCacheOldestPlanText.Text = ageText; + + // Color code based on age - older is better (more stable) + if (age.TotalHours < 1) + PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed); + else if (age.TotalHours < 24) + PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Orange); + else + PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + else + { + PlanCacheOldestPlanText.Text = "N/A"; + PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + + PlanCacheTotalPlansText.Text = totalPlans > 0 ? totalPlans.ToString("N0", CultureInfo.CurrentCulture) : "N/A"; + } + + #endregion + } +} diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index dbe405e..1ce108c 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -1,1356 +1,256 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Controls.Primitives; -using System.Windows.Data; -using Microsoft.Win32; -using PerformanceMonitorDashboard.Helpers; -using PerformanceMonitorDashboard.Models; -using PerformanceMonitorDashboard.Services; - -namespace PerformanceMonitorDashboard.Controls -{ - /// - /// UserControl for the Memory tab content. - /// Displays memory stats, grants, clerks, and plan cache analysis. - /// - public partial class MemoryContent : UserControl - { - public event Action? ChartDrillDownRequested; - - private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, - Func hoverGetter, string label, string chartType) - { - menu.Items.Insert(0, new Separator()); - var item = new MenuItem { Header = label }; - menu.Items.Insert(0, item); - - menu.Opened += (s, _) => - { - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = hoverGetter()?.GetNearestSeries(pos); - item.Tag = nearest?.Time; - item.IsEnabled = nearest.HasValue; - }; - - item.Click += (s, _) => - { - if (item.Tag is DateTime time) - ChartDrillDownRequested?.Invoke(chartType, time); - }; - } - - private DatabaseService? _databaseService; - - // Memory Stats state - private int _memoryStatsHoursBack = 24; - private DateTime? _memoryStatsFromDate; - private DateTime? _memoryStatsToDate; - - // Memory Grants state - private int _memoryGrantsHoursBack = 24; - private DateTime? _memoryGrantsFromDate; - private DateTime? _memoryGrantsToDate; - - // Memory Clerks state - private int _memoryClerksHoursBack = 24; - private DateTime? _memoryClerksFromDate; - private DateTime? _memoryClerksToDate; - - // Plan Cache state - private int _planCacheHoursBack = 24; - private DateTime? _planCacheFromDate; - private DateTime? _planCacheToDate; - - // Memory Pressure Events state - private int _memoryPressureEventsHoursBack = 24; - private DateTime? _memoryPressureEventsFromDate; - private DateTime? _memoryPressureEventsToDate; - - // Filter state dictionaries removed - no more grids with filters in this control - - // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) - private Dictionary _legendPanels = new(); - - // Memory Clerks picker state - private List _memoryClerkItems = new(); - private bool _isUpdatingMemoryClerkSelection; - - // Chart hover tooltips - private Helpers.ChartHoverHelper? _memoryStatsOverviewHover; - private Helpers.ChartHoverHelper? _memoryGrantSizingHover; - private Helpers.ChartHoverHelper? _memoryGrantActivityHover; - private Helpers.ChartHoverHelper? _memoryClerksHover; - private Helpers.ChartHoverHelper? _planCacheHover; - private Helpers.ChartHoverHelper? _memoryPressureEventsHover; - - // No DataGrids with filters - all tabs are chart-only - - public MemoryContent() - { - InitializeComponent(); - SetupChartContextMenus(); - Loaded += OnLoaded; - Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => - { - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - DisposeChartHelpers(); - }; - - // Apply dark theme immediately so charts don't flash white before data loads - TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); - TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); - TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); - TabHelpers.ApplyThemeToChart(MemoryClerksChart); - TabHelpers.ApplyThemeToChart(PlanCacheChart); - TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - - _memoryStatsOverviewHover = new Helpers.ChartHoverHelper(MemoryStatsOverviewChart, "MB"); - _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); - _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, "count"); - _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); - _planCacheHover = new Helpers.ChartHoverHelper(PlanCacheChart, "MB"); - _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); - } - - public void DisposeChartHelpers() - { - _memoryStatsOverviewHover?.Dispose(); - _memoryGrantSizingHover?.Dispose(); - _memoryGrantActivityHover?.Dispose(); - _memoryClerksHover?.Dispose(); - _planCacheHover?.Dispose(); - _memoryPressureEventsHover?.Dispose(); - } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - // No grids to configure - all tabs are chart-only now - } - - private void OnThemeChanged(string _) - { - foreach (var field in GetType().GetFields( - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) - { - if (field.GetValue(this) is ScottPlot.WPF.WpfPlot chart) - { - Helpers.TabHelpers.ApplyThemeToChart(chart); - chart.Refresh(); - } - } - } - - private void SetupChartContextMenus() - { - // Memory Stats Overview chart - var memOverviewMenu = TabHelpers.SetupChartContextMenu(MemoryStatsOverviewChart, "Memory_Stats_Overview", "collect.memory_stats"); - AddDrillDown(MemoryStatsOverviewChart, memOverviewMenu, () => _memoryStatsOverviewHover, "Show Active Queries at This Time", "Memory"); - - // Memory Grant charts - var grantSizingMenu = TabHelpers.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing", "collect.memory_grant_stats"); - AddDrillDown(MemoryGrantSizingChart, grantSizingMenu, () => _memoryGrantSizingHover, "Show Active Queries at This Time", "MemoryGrant"); - var grantActivityMenu = TabHelpers.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity", "collect.memory_grant_stats"); - AddDrillDown(MemoryGrantActivityChart, grantActivityMenu, () => _memoryGrantActivityHover, "Show Active Queries at This Time", "MemoryGrant"); - - // Memory Clerks chart - var clerksMenu = TabHelpers.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks", "collect.memory_clerks_stats"); - AddDrillDown(MemoryClerksChart, clerksMenu, () => _memoryClerksHover, "Show Active Queries at This Time", "MemoryClerks"); - - // Plan Cache chart - TabHelpers.SetupChartContextMenu(PlanCacheChart, "Plan_Cache", "collect.plan_cache_stats"); - - // Memory Pressure Events chart - var pressureMenu = TabHelpers.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events", "collect.memory_pressure_events"); - AddDrillDown(MemoryPressureEventsChart, pressureMenu, () => _memoryPressureEventsHover, "Show Active Queries at This Time", "MemoryPressure"); - } - - /// - /// Initializes the control with required dependencies. - /// - public void Initialize(DatabaseService databaseService) - { - _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); - } - - /// - /// Sets the time range for all memory sub-tabs. - /// - public void SetTimeRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) - { - _memoryStatsHoursBack = hoursBack; - _memoryStatsFromDate = fromDate; - _memoryStatsToDate = toDate; - - _memoryGrantsHoursBack = hoursBack; - _memoryGrantsFromDate = fromDate; - _memoryGrantsToDate = toDate; - - _memoryClerksHoursBack = hoursBack; - _memoryClerksFromDate = fromDate; - _memoryClerksToDate = toDate; - - _planCacheHoursBack = hoursBack; - _planCacheFromDate = fromDate; - _planCacheToDate = toDate; - - _memoryPressureEventsHoursBack = hoursBack; - _memoryPressureEventsFromDate = fromDate; - _memoryPressureEventsToDate = toDate; - } - - /// - /// Refreshes memory data. When fullRefresh is false, only the visible sub-tab is refreshed. - /// - public async Task RefreshAllDataAsync(bool fullRefresh = true) - { - try - { - using var _ = Helpers.MethodProfiler.StartTiming("Memory"); - - if (fullRefresh) - { - // Run all independent refreshes in parallel for initial load / manual refresh - await Task.WhenAll( - RefreshMemoryStatsAsync(), - RefreshMemoryGrantsAsync(), - RefreshMemoryClerksAsync(), - RefreshPlanCacheAsync(), - RefreshMemoryPressureEventsAsync() - ); - } - else - { - // Only refresh the visible sub-tab - switch (SubTabControl.SelectedIndex) - { - case 0: await RefreshMemoryStatsAsync(); break; - case 1: await RefreshMemoryGrantsAsync(); break; - case 2: await RefreshMemoryClerksAsync(); break; - case 3: await RefreshPlanCacheAsync(); break; - case 4: await RefreshMemoryPressureEventsAsync(); break; - } - } - } - catch (Exception ex) - { - Logger.Error($"Error refreshing Memory data: {ex.Message}", ex); - } - } - - #region Memory Stats - - private async System.Threading.Tasks.Task RefreshMemoryStatsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetMemoryStatsAsync(_memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); - var dataList = data.ToList(); - LoadMemoryStatsOverviewChart(dataList, _memoryStatsHoursBack, _memoryStatsFromDate, _memoryStatsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory stats: {ex.Message}"); - } - } - - private void LoadMemoryStatsOverviewChart(List memoryData, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryStatsOverviewChart, out var existingPanel) && existingPanel != null) - { - MemoryStatsOverviewChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryStatsOverviewChart] = null; - } - MemoryStatsOverviewChart.Plot.Clear(); - _memoryStatsOverviewHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); - - var dataList = memoryData?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - // Total Memory series with gap filling - var (totalXs, totalYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.TotalMemoryMb)); - - // Buffer Pool series with gap filling - var (bufferXs, bufferYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.BufferPoolMb)); - - // Plan Cache series with gap filling - var (cacheXs, cacheYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.PlanCacheMb)); - - // Available Physical Memory series with gap filling - var (availXs, availYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.AvailablePhysicalMemoryMb)); - - if (totalXs.Length > 0) - { - // Add pressure warning spans first (so they appear behind the lines) - AddPressureWarningSpans(dataList); - - var totalScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(totalXs, totalYs); - totalScatter.LineWidth = 2; - totalScatter.MarkerSize = 5; - totalScatter.Color = TabHelpers.ChartColors[9]; - totalScatter.LegendText = "Total Memory"; - _memoryStatsOverviewHover?.Add(totalScatter, "Total Memory"); - - var bufferScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(bufferXs, bufferYs); - bufferScatter.LineWidth = 2; - bufferScatter.MarkerSize = 5; - bufferScatter.Color = TabHelpers.ChartColors[0]; - bufferScatter.LegendText = "Buffer Pool"; - _memoryStatsOverviewHover?.Add(bufferScatter, "Buffer Pool"); - - var cacheScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(cacheXs, cacheYs); - cacheScatter.LineWidth = 2; - cacheScatter.MarkerSize = 5; - cacheScatter.Color = TabHelpers.ChartColors[1]; - cacheScatter.LegendText = "Plan Cache"; - _memoryStatsOverviewHover?.Add(cacheScatter, "Plan Cache"); - - var availScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(availXs, availYs); - availScatter.LineWidth = 2; - availScatter.MarkerSize = 5; - availScatter.Color = TabHelpers.ChartColors[2]; - availScatter.LegendText = "Available Physical"; - _memoryStatsOverviewHover?.Add(availScatter, "Available Physical"); - - _legendPanels[MemoryStatsOverviewChart] = MemoryStatsOverviewChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryStatsOverviewChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = MemoryStatsOverviewChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - MemoryStatsOverviewChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryStatsOverviewChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryStatsOverviewChart.Plot.YLabel("MB"); - // Fixed negative space for legend - MemoryStatsOverviewChart.Plot.Axes.AutoScaleY(); - var memOverviewLimits = MemoryStatsOverviewChart.Plot.Axes.GetLimits(); - MemoryStatsOverviewChart.Plot.Axes.SetLimitsY(0, memOverviewLimits.Top * 1.05); - - TabHelpers.LockChartVerticalAxis(MemoryStatsOverviewChart); - MemoryStatsOverviewChart.Refresh(); - - // Update summary panel - UpdateMemoryStatsSummaryPanel(dataList); - } - - private void AddPressureWarningSpans(List dataList) - { - // Track whether we've added legend entries (only want one per type) - bool bpLegendAdded = false; - bool pcLegendAdded = false; - - // Find time ranges where pressure warnings are active - foreach (var item in dataList) - { - if (item.BufferPoolPressureWarning || item.PlanCachePressureWarning) - { - // Add a vertical line at this time point to indicate pressure - var x = item.CollectionTime.ToOADate(); - var vline = MemoryStatsOverviewChart.Plot.Add.VerticalLine(x); - vline.LineWidth = 1; - vline.LinePattern = ScottPlot.LinePattern.Dotted; - - if (item.BufferPoolPressureWarning && item.PlanCachePressureWarning) - { - vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.5); - // Add legend entry for BP pressure (covers "both" case too) - if (!bpLegendAdded) - { - vline.LegendText = "BP Pressure"; - bpLegendAdded = true; - } - } - else if (item.BufferPoolPressureWarning) - { - vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.3); - if (!bpLegendAdded) - { - vline.LegendText = "BP Pressure"; - bpLegendAdded = true; - } - } - else - { - vline.Color = TabHelpers.ChartColors[2].WithAlpha(0.3); - if (!pcLegendAdded) - { - vline.LegendText = "PC Pressure"; - pcLegendAdded = true; - } - } - } - } - } - - private void UpdateMemoryStatsSummaryPanel(List dataList) - { - if (dataList == null || dataList.Count == 0) - { - MemoryStatsPhysicalText.Text = "N/A"; - MemoryStatsSqlServerText.Text = "N/A"; - MemoryStatsTargetText.Text = "N/A"; - MemoryStatsBPPercentText.Text = "N/A"; - MemoryStatsPCPercentText.Text = "N/A"; - MemoryStatsUtilPercentText.Text = "N/A"; - MemoryStatsPressureText.Text = "None"; - MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - return; - } - - // Use the most recent data point - var latest = dataList.OrderByDescending(d => d.CollectionTime).First(); - - // Absolute GB values - MemoryStatsPhysicalText.Text = latest.TotalPhysicalMemoryMb.HasValue - ? $"{latest.TotalPhysicalMemoryMb.Value / 1024.0m:F1} GB" - : "N/A"; - - MemoryStatsSqlServerText.Text = $"{latest.PhysicalMemoryInUseMb / 1024.0m:F1} GB"; - - MemoryStatsTargetText.Text = latest.CommittedTargetMemoryMb.HasValue - ? $"{latest.CommittedTargetMemoryMb.Value / 1024.0m:F1} GB" - : "N/A"; - - // Buffer Pool and Plan Cache with GB and percentage - MemoryStatsBPPercentText.Text = latest.BufferPoolPercentage.HasValue - ? $"{latest.BufferPoolMb / 1024.0m:F1} GB ({latest.BufferPoolPercentage:F1}%)" - : $"{latest.BufferPoolMb / 1024.0m:F1} GB"; - - MemoryStatsPCPercentText.Text = latest.PlanCachePercentage.HasValue - ? $"{latest.PlanCacheMb / 1024.0m:F1} GB ({latest.PlanCachePercentage:F1}%)" - : $"{latest.PlanCacheMb / 1024.0m:F1} GB"; - - MemoryStatsUtilPercentText.Text = $"{latest.MemoryUtilizationPercentage}%"; - - // Build pressure status text - var pressures = new List(); - if (latest.BufferPoolPressureWarning) pressures.Add("BP"); - if (latest.PlanCachePressureWarning) pressures.Add("PC"); - - if (pressures.Count > 0) - { - MemoryStatsPressureText.Text = string.Join(", ", pressures); - MemoryStatsPressureText.Foreground = new System.Windows.Media.SolidColorBrush( - System.Windows.Media.Color.FromRgb(0xFF, 0x69, 0x69)); // Light red - } - else - { - MemoryStatsPressureText.Text = "None"; - MemoryStatsPressureText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - } - - #endregion - - #region Memory Grants - - private sealed class PoolGrantPoint - { - public DateTime CollectionTime { get; set; } - public int PoolId { get; set; } - public double AvailableMemoryMb { get; set; } - public double GrantedMemoryMb { get; set; } - public double UsedMemoryMb { get; set; } - public double GranteeCount { get; set; } - public double WaiterCount { get; set; } - public double TimeoutErrorCountDelta { get; set; } - public double ForcedGrantCountDelta { get; set; } - } - - private async System.Threading.Tasks.Task RefreshMemoryGrantsAsync() - { - if (_databaseService == null) return; - - try - { - if (!MemoryGrantSizingChart.Plot.GetPlottables().Any()) - { - MemoryGrantSizingLoading.IsLoading = true; - MemoryGrantSizingNoData.Visibility = Visibility.Collapsed; - MemoryGrantActivityNoData.Visibility = Visibility.Collapsed; - } - - var data = await _databaseService.GetMemoryGrantStatsAsync(_memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - var dataList = data.ToList(); - - bool hasData = dataList.Count > 0; - MemoryGrantSizingNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; - MemoryGrantActivityNoData.Visibility = hasData ? Visibility.Collapsed : Visibility.Visible; - - // Aggregate across resource_semaphore_id within each pool - var aggregated = dataList - .GroupBy(d => new { d.CollectionTime, d.PoolId }) - .Select(g => new PoolGrantPoint - { - CollectionTime = g.Key.CollectionTime, - PoolId = g.Key.PoolId, - AvailableMemoryMb = g.Sum(x => (double)(x.AvailableMemoryMb ?? 0)), - GrantedMemoryMb = g.Sum(x => (double)(x.GrantedMemoryMb ?? 0)), - UsedMemoryMb = g.Sum(x => (double)(x.UsedMemoryMb ?? 0)), - GranteeCount = g.Sum(x => (double)(x.GranteeCount ?? 0)), - WaiterCount = g.Sum(x => (double)(x.WaiterCount ?? 0)), - TimeoutErrorCountDelta = g.Sum(x => (double)(x.TimeoutErrorCountDelta ?? 0)), - ForcedGrantCountDelta = g.Sum(x => (double)(x.ForcedGrantCountDelta ?? 0)) - }) - .OrderBy(d => d.CollectionTime) - .ToList(); - - LoadMemoryGrantSizingChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - LoadMemoryGrantActivityChart(aggregated, _memoryGrantsHoursBack, _memoryGrantsFromDate, _memoryGrantsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory grants: {ex.Message}"); - MemoryGrantSizingNoData.Visibility = Visibility.Visible; - MemoryGrantActivityNoData.Visibility = Visibility.Visible; - } - finally - { - MemoryGrantSizingLoading.IsLoading = false; - MemoryGrantActivityLoading.IsLoading = false; - } - } - - private void LoadMemoryGrantSizingChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryGrantSizingChart, out var existingPanel) && existingPanel != null) - { - MemoryGrantSizingChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryGrantSizingChart] = null; - } - MemoryGrantSizingChart.Plot.Clear(); - _memoryGrantSizingHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); - - var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); - int colorIndex = 0; - var colors = TabHelpers.ChartColors; - bool hasData = false; - - foreach (var poolId in poolIds) - { - var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - if (poolData.Count == 0) continue; - hasData = true; - - var metrics = new (string Name, Func Selector)[] { - ("Available MB", d => d.AvailableMemoryMb), - ("Granted MB", d => d.GrantedMemoryMb), - ("Used MB", d => d.UsedMemoryMb) - }; - - foreach (var (metricName, selector) in metrics) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps( - poolData.Select(d => d.CollectionTime), - poolData.Select(selector)); - - if (xs.Length > 0) - { - var scatter = MemoryGrantSizingChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = $"Pool {poolId}: {metricName}"; - scatter.LegendText = label; - _memoryGrantSizingHover?.Add(scatter, label); - colorIndex++; - } - } - } - - if (hasData) - { - _legendPanels[MemoryGrantSizingChart] = MemoryGrantSizingChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryGrantSizingChart.Plot.Legend.FontSize = 12; - } - - MemoryGrantSizingChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryGrantSizingChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryGrantSizingChart.Plot.YLabel("MB"); - MemoryGrantSizingChart.Plot.Axes.AutoScaleY(); - var limits = MemoryGrantSizingChart.Plot.Axes.GetLimits(); - MemoryGrantSizingChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryGrantSizingChart); - MemoryGrantSizingChart.Refresh(); - } - - private void LoadMemoryGrantActivityChart(List aggregated, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryGrantActivityChart, out var existingPanel) && existingPanel != null) - { - MemoryGrantActivityChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryGrantActivityChart] = null; - } - MemoryGrantActivityChart.Plot.Clear(); - _memoryGrantActivityHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); - - var poolIds = aggregated.Select(d => d.PoolId).Distinct().OrderBy(id => id).ToList(); - int colorIndex = 0; - var colors = TabHelpers.ChartColors; - bool hasData = false; - - foreach (var poolId in poolIds) - { - var poolData = aggregated.Where(d => d.PoolId == poolId).OrderBy(d => d.CollectionTime).ToList(); - if (poolData.Count == 0) continue; - hasData = true; - - var metrics = new (string Name, Func Selector)[] { - ("Grantees", d => d.GranteeCount), - ("Waiters", d => d.WaiterCount), - ("Timeouts", d => d.TimeoutErrorCountDelta), - ("Forced Grants", d => d.ForcedGrantCountDelta) - }; - - foreach (var (metricName, selector) in metrics) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps( - poolData.Select(d => d.CollectionTime), - poolData.Select(selector)); - - if (xs.Length > 0) - { - var scatter = MemoryGrantActivityChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = $"Pool {poolId}: {metricName}"; - scatter.LegendText = label; - _memoryGrantActivityHover?.Add(scatter, label); - colorIndex++; - } - } - } - - if (hasData) - { - _legendPanels[MemoryGrantActivityChart] = MemoryGrantActivityChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryGrantActivityChart.Plot.Legend.FontSize = 12; - } - - MemoryGrantActivityChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryGrantActivityChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryGrantActivityChart.Plot.YLabel("Count"); - MemoryGrantActivityChart.Plot.Axes.AutoScaleY(); - var limits = MemoryGrantActivityChart.Plot.Axes.GetLimits(); - MemoryGrantActivityChart.Plot.Axes.SetLimitsY(0, limits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryGrantActivityChart); - MemoryGrantActivityChart.Refresh(); - } - - #endregion - - #region Memory Clerks - - private async System.Threading.Tasks.Task RefreshMemoryClerksAsync() - { - if (_databaseService == null) return; - - try - { - if (!MemoryClerksChart.Plot.GetPlottables().Any()) - { - MemoryClerksLoading.IsLoading = true; - MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; - } - - var clerkTypes = await _databaseService.GetDistinctMemoryClerkTypesAsync(_memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); - PopulateMemoryClerkPicker(clerkTypes); - await UpdateMemoryClerksChartFromPickerAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory clerks: {ex.Message}"); - } - finally - { - MemoryClerksLoading.IsLoading = false; - } - } - - private void PopulateMemoryClerkPicker(List clerkTypes) - { - var previouslySelected = new HashSet(_memoryClerkItems.Where(i => i.IsSelected).Select(i => i.DisplayName)); - var topClerks = previouslySelected.Count == 0 ? new HashSet(clerkTypes.Take(5)) : null; - _memoryClerkItems = clerkTypes.Select(c => new SelectableItem - { - DisplayName = c, - IsSelected = previouslySelected.Contains(c) || (topClerks != null && topClerks.Contains(c)) - }).ToList(); - RefreshMemoryClerkListOrder(); - } - - private void RefreshMemoryClerkListOrder() - { - if (_memoryClerkItems == null) return; - _memoryClerkItems = _memoryClerkItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => x.DisplayName) - .ToList(); - ApplyMemoryClerkFilter(); - UpdateMemoryClerkCount(); - } - - private void UpdateMemoryClerkCount() - { - if (_memoryClerkItems == null || MemoryClerkCountText == null) return; - int count = _memoryClerkItems.Count(x => x.IsSelected); - MemoryClerkCountText.Text = $"{count} selected"; - } - - private void ApplyMemoryClerkFilter() - { - var search = MemoryClerkSearchBox?.Text?.Trim() ?? ""; - MemoryClerksList.ItemsSource = null; - if (string.IsNullOrEmpty(search)) - MemoryClerksList.ItemsSource = _memoryClerkItems; - else - MemoryClerksList.ItemsSource = _memoryClerkItems.Where(i => i.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - private void MemoryClerkSearch_TextChanged(object sender, TextChangedEventArgs e) => ApplyMemoryClerkFilter(); - - private void MemoryClerkSelectTop_Click(object sender, RoutedEventArgs e) - { - _isUpdatingMemoryClerkSelection = true; - var topClerks = new HashSet(_memoryClerkItems.Take(5).Select(x => x.DisplayName)); - foreach (var item in _memoryClerkItems) - item.IsSelected = topClerks.Contains(item.DisplayName); - _isUpdatingMemoryClerkSelection = false; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private void MemoryClerkClearAll_Click(object sender, RoutedEventArgs e) - { - _isUpdatingMemoryClerkSelection = true; - var visible = (MemoryClerksList.ItemsSource as IEnumerable)?.ToList() ?? _memoryClerkItems; - foreach (var item in visible) item.IsSelected = false; - _isUpdatingMemoryClerkSelection = false; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private void MemoryClerk_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingMemoryClerkSelection) return; - RefreshMemoryClerkListOrder(); - _ = UpdateMemoryClerksChartFromPickerAsync(); - } - - private async System.Threading.Tasks.Task UpdateMemoryClerksChartFromPickerAsync() - { - if (_databaseService == null) return; - - try - { - var selected = _memoryClerkItems.Where(i => i.IsSelected).Take(20).ToList(); - - if (_legendPanels.TryGetValue(MemoryClerksChart, out var existingPanel) && existingPanel != null) - { - MemoryClerksChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryClerksChart] = null; - } - MemoryClerksChart.Plot.Clear(); - _memoryClerksHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryClerksChart); - - DateTime rangeEnd = _memoryClerksToDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = _memoryClerksFromDate ?? rangeEnd.AddHours(-_memoryClerksHoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (selected.Count > 0) - { - var selectedTypes = selected.Select(s => s.DisplayName).ToList(); - var data = await _databaseService.GetMemoryClerksByTypesAsync(selectedTypes, _memoryClerksHoursBack, _memoryClerksFromDate, _memoryClerksToDate); - var dataList = data.ToList(); - - MemoryClerksNoDataMessage.Visibility = dataList.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - - if (dataList.Count > 0) - { - var colors = TabHelpers.ChartColors; - int colorIndex = 0; - - foreach (var clerkType in selectedTypes) - { - var clerkData = dataList.Where(d => d.ClerkType == clerkType) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (clerkData.Count >= 1) - { - var timePoints = clerkData.Select(d => d.CollectionTime); - var values = clerkData.Select(d => (double)d.PagesMb); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = MemoryClerksChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - var label = clerkType.Length > 20 ? clerkType.Substring(0, 20) + "..." : clerkType; - scatter.LegendText = label; - _memoryClerksHover?.Add(scatter, label); - colorIndex++; - } - } - - _legendPanels[MemoryClerksChart] = MemoryClerksChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - MemoryClerksChart.Plot.Legend.FontSize = 12; - } - - UpdateMemoryClerksSummaryPanel(dataList); - } - else - { - MemoryClerksNoDataMessage.Visibility = Visibility.Collapsed; - MemoryClerksTotalText.Text = "N/A"; - MemoryClerksTopText.Text = "N/A"; - } - - MemoryClerksChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryClerksChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryClerksChart.Plot.YLabel("MB"); - MemoryClerksChart.Plot.Axes.AutoScaleY(); - var clerksLimits = MemoryClerksChart.Plot.Axes.GetLimits(); - MemoryClerksChart.Plot.Axes.SetLimitsY(0, clerksLimits.Top * 1.05); - TabHelpers.LockChartVerticalAxis(MemoryClerksChart); - MemoryClerksChart.Refresh(); - } - catch (Exception ex) - { - Logger.Error($"Error updating memory clerks chart: {ex.Message}"); - } - } - - private void UpdateMemoryClerksSummaryPanel(List dataList) - { - if (dataList == null || dataList.Count == 0) - { - MemoryClerksTotalText.Text = "N/A"; - MemoryClerksTopText.Text = "N/A"; - return; - } - - var latestTime = dataList.Max(d => d.CollectionTime); - var latestData = dataList - .Where(d => d.CollectionTime == latestTime) - .Where(d => d.ClerkType == null || !d.ClerkType.Contains("BUFFERPOOL", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - var totalMb = latestData.Sum(d => d.PagesMb); - MemoryClerksTotalText.Text = string.Format(CultureInfo.CurrentCulture, "{0:N0} MB", totalMb); - - var topClerk = latestData.OrderByDescending(d => d.PagesMb).FirstOrDefault(); - if (topClerk != null) - { - var name = topClerk.ClerkType ?? "Unknown"; - if (name.StartsWith("MEMORYCLERK_", StringComparison.OrdinalIgnoreCase)) - name = name.Substring(12); - if (name.Length > 20) name = name.Substring(0, 20) + "..."; - MemoryClerksTopText.Text = string.Format(CultureInfo.CurrentCulture, "{0} ({1:N0} MB)", name, topClerk.PagesMb); - } - else - { - MemoryClerksTopText.Text = "N/A"; - } - } - - #endregion - - #region Plan Cache - - private async System.Threading.Tasks.Task RefreshPlanCacheAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetPlanCacheStatsAsync(_planCacheHoursBack, _planCacheFromDate, _planCacheToDate); - LoadPlanCacheChart(data.ToList(), _planCacheHoursBack, _planCacheFromDate, _planCacheToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading plan cache stats: {ex.Message}"); - } - } - - private void LoadPlanCacheChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(PlanCacheChart, out var existingPanel) && existingPanel != null) - { - PlanCacheChart.Plot.Axes.Remove(existingPanel); - _legendPanels[PlanCacheChart] = null; - } - PlanCacheChart.Plot.Clear(); - _planCacheHover?.Clear(); - TabHelpers.ApplyThemeToChart(PlanCacheChart); - - var dataList = data?.ToList() ?? new List(); - if (dataList.Count > 0) - { - // Group by collection time and get single-use vs multi-use sizes - var grouped = dataList.GroupBy(d => d.CollectionTime) - .Select(g => new { - Time = g.Key, - SingleUseSizeMb = g.Sum(x => x.SingleUseSizeMb), - MultiUseSizeMb = g.Sum(x => x.MultiUseSizeMb) - }) - .OrderBy(x => x.Time) - .ToList(); - - if (grouped.Count > 0) - { - // Single-Use series with gap filling - var (singleXs, singleYs) = TabHelpers.FillTimeSeriesGaps( - grouped.Select(d => d.Time), - grouped.Select(d => (double)d.SingleUseSizeMb)); - - var singleScatter = PlanCacheChart.Plot.Add.Scatter(singleXs, singleYs); - singleScatter.LineWidth = 2; - singleScatter.MarkerSize = 5; - singleScatter.Color = TabHelpers.ChartColors[3]; - singleScatter.LegendText = "Single-Use"; - _planCacheHover?.Add(singleScatter, "Single-Use"); - - // Multi-Use series with gap filling - var (multiXs, multiYs) = TabHelpers.FillTimeSeriesGaps( - grouped.Select(d => d.Time), - grouped.Select(d => (double)d.MultiUseSizeMb)); - - var multiScatter = PlanCacheChart.Plot.Add.Scatter(multiXs, multiYs); - multiScatter.LineWidth = 2; - multiScatter.MarkerSize = 5; - multiScatter.Color = TabHelpers.ChartColors[1]; - multiScatter.LegendText = "Multi-Use"; - _planCacheHover?.Add(multiScatter, "Multi-Use"); - - _legendPanels[PlanCacheChart] = PlanCacheChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - PlanCacheChart.Plot.Legend.FontSize = 12; - } - - // Update summary panel with latest data point - var latestOldestPlan = dataList - .Where(d => d.OldestPlanCreateTime.HasValue) - .OrderByDescending(d => d.CollectionTime) - .FirstOrDefault(); - var latestTime = dataList.Max(d => d.CollectionTime); - int totalPlans = dataList.Where(d => d.CollectionTime == latestTime).Sum(d => d.TotalPlans); - UpdatePlanCacheSummary(latestOldestPlan, totalPlans); - } - else - { - UpdatePlanCacheSummary(null, 0); - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = PlanCacheChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - PlanCacheChart.Plot.Axes.DateTimeTicksBottomDateChange(); - PlanCacheChart.Plot.Axes.SetLimitsX(xMin, xMax); - PlanCacheChart.Plot.YLabel("MB"); - // Fixed negative space for legend - PlanCacheChart.Plot.Axes.AutoScaleY(); - var planCacheLimits = PlanCacheChart.Plot.Axes.GetLimits(); - PlanCacheChart.Plot.Axes.SetLimitsY(0, planCacheLimits.Top * 1.05); - - TabHelpers.LockChartVerticalAxis(PlanCacheChart); - PlanCacheChart.Refresh(); - } - - private void UpdatePlanCacheSummary(PlanCacheStatsItem? oldestPlanData, int totalPlans) - { - if (oldestPlanData?.OldestPlanCreateTime != null) - { - var age = ServerTimeHelper.ServerNow - oldestPlanData.OldestPlanCreateTime.Value; - string ageText; - if (age.TotalDays >= 1) - ageText = $"{age.Days}d {age.Hours}h"; - else if (age.TotalHours >= 1) - ageText = $"{age.Hours}h {age.Minutes}m"; - else - ageText = $"{age.Minutes}m"; - - PlanCacheOldestPlanText.Text = ageText; - - // Color code based on age - older is better (more stable) - if (age.TotalHours < 1) - PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed); - else if (age.TotalHours < 24) - PlanCacheOldestPlanText.Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Orange); - else - PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - else - { - PlanCacheOldestPlanText.Text = "N/A"; - PlanCacheOldestPlanText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - - PlanCacheTotalPlansText.Text = totalPlans > 0 ? totalPlans.ToString("N0", CultureInfo.CurrentCulture) : "N/A"; - } - - #endregion - - #region Memory Pressure Events - - private async System.Threading.Tasks.Task RefreshMemoryPressureEventsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetMemoryPressureEventsAsync(_memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); - LoadMemoryPressureEventsChart(data.ToList(), _memoryPressureEventsHoursBack, _memoryPressureEventsFromDate, _memoryPressureEventsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading memory pressure events: {ex.Message}"); - } - } - - private void LoadMemoryPressureEventsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(MemoryPressureEventsChart, out var existingPanel) && existingPanel != null) - { - MemoryPressureEventsChart.Plot.Axes.Remove(existingPanel); - _legendPanels[MemoryPressureEventsChart] = null; - } - MemoryPressureEventsChart.Plot.Clear(); - _memoryPressureEventsHover?.Clear(); - TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - - // 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) - { - 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(); - - 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; - - 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; - } - } - - if (!hasData) - { - double xCenter = xMin + (xMax - xMin) / 2; - 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; - } - - MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); - MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); - - TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); - MemoryPressureEventsChart.Refresh(); - } - - #endregion - - - #region Context Menu Handlers - - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(Helpers.DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - string prefix = "memory"; - - - var saveFileDialog = new SaveFileDialog - { - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } - - #endregion - } -} +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +namespace PerformanceMonitorDashboard.Controls +{ + /// + /// UserControl for the Memory tab content. + /// Displays memory stats, grants, clerks, and plan cache analysis. + /// + public partial class MemoryContent : UserControl + { + public event Action? ChartDrillDownRequested; + + private void AddDrillDown(ScottPlot.WPF.WpfPlot chart, ContextMenu menu, + Func hoverGetter, string label, string chartType) + { + menu.Items.Insert(0, new Separator()); + var item = new MenuItem { Header = label }; + menu.Items.Insert(0, item); + + menu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = hoverGetter()?.GetNearestSeries(pos); + item.Tag = nearest?.Time; + item.IsEnabled = nearest.HasValue; + }; + + item.Click += (s, _) => + { + if (item.Tag is DateTime time) + ChartDrillDownRequested?.Invoke(chartType, time); + }; + } + + private DatabaseService? _databaseService; + + // Memory Stats state + private int _memoryStatsHoursBack = 24; + private DateTime? _memoryStatsFromDate; + private DateTime? _memoryStatsToDate; + + // Memory Grants state + private int _memoryGrantsHoursBack = 24; + private DateTime? _memoryGrantsFromDate; + private DateTime? _memoryGrantsToDate; + + // Memory Clerks state + private int _memoryClerksHoursBack = 24; + private DateTime? _memoryClerksFromDate; + private DateTime? _memoryClerksToDate; + + // Plan Cache state + private int _planCacheHoursBack = 24; + private DateTime? _planCacheFromDate; + private DateTime? _planCacheToDate; + + // Memory Pressure Events state + private int _memoryPressureEventsHoursBack = 24; + private DateTime? _memoryPressureEventsFromDate; + private DateTime? _memoryPressureEventsToDate; + + // Filter state dictionaries removed - no more grids with filters in this control + + // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) + private Dictionary _legendPanels = new(); + + // Memory Clerks picker state + private List _memoryClerkItems = new(); + private bool _isUpdatingMemoryClerkSelection; + + // Chart hover tooltips + private Helpers.ChartHoverHelper? _memoryStatsOverviewHover; + private Helpers.ChartHoverHelper? _memoryGrantSizingHover; + private Helpers.ChartHoverHelper? _memoryGrantActivityHover; + private Helpers.ChartHoverHelper? _memoryClerksHover; + private Helpers.ChartHoverHelper? _planCacheHover; + private Helpers.ChartHoverHelper? _memoryPressureEventsHover; + + // No DataGrids with filters - all tabs are chart-only + + public MemoryContent() + { + InitializeComponent(); + SetupChartContextMenus(); + Loaded += OnLoaded; + Helpers.ThemeManager.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => + { + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + DisposeChartHelpers(); + }; + + // Apply dark theme immediately so charts don't flash white before data loads + TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); + TabHelpers.ApplyThemeToChart(MemoryGrantSizingChart); + TabHelpers.ApplyThemeToChart(MemoryGrantActivityChart); + TabHelpers.ApplyThemeToChart(MemoryClerksChart); + TabHelpers.ApplyThemeToChart(PlanCacheChart); + TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); + + _memoryStatsOverviewHover = new Helpers.ChartHoverHelper(MemoryStatsOverviewChart, "MB"); + _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); + _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, "count"); + _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); + _planCacheHover = new Helpers.ChartHoverHelper(PlanCacheChart, "MB"); + _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); + } + + public void DisposeChartHelpers() + { + _memoryStatsOverviewHover?.Dispose(); + _memoryGrantSizingHover?.Dispose(); + _memoryGrantActivityHover?.Dispose(); + _memoryClerksHover?.Dispose(); + _planCacheHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + // No grids to configure - all tabs are chart-only now + } + + private void OnThemeChanged(string _) + { + foreach (var field in GetType().GetFields( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)) + { + if (field.GetValue(this) is ScottPlot.WPF.WpfPlot chart) + { + Helpers.TabHelpers.ApplyThemeToChart(chart); + chart.Refresh(); + } + } + } + + private void SetupChartContextMenus() + { + // Memory Stats Overview chart + var memOverviewMenu = TabHelpers.SetupChartContextMenu(MemoryStatsOverviewChart, "Memory_Stats_Overview", "collect.memory_stats"); + AddDrillDown(MemoryStatsOverviewChart, memOverviewMenu, () => _memoryStatsOverviewHover, "Show Active Queries at This Time", "Memory"); + + // Memory Grant charts + var grantSizingMenu = TabHelpers.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing", "collect.memory_grant_stats"); + AddDrillDown(MemoryGrantSizingChart, grantSizingMenu, () => _memoryGrantSizingHover, "Show Active Queries at This Time", "MemoryGrant"); + var grantActivityMenu = TabHelpers.SetupChartContextMenu(MemoryGrantActivityChart, "Memory_Grant_Activity", "collect.memory_grant_stats"); + AddDrillDown(MemoryGrantActivityChart, grantActivityMenu, () => _memoryGrantActivityHover, "Show Active Queries at This Time", "MemoryGrant"); + + // Memory Clerks chart + var clerksMenu = TabHelpers.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks", "collect.memory_clerks_stats"); + AddDrillDown(MemoryClerksChart, clerksMenu, () => _memoryClerksHover, "Show Active Queries at This Time", "MemoryClerks"); + + // Plan Cache chart + TabHelpers.SetupChartContextMenu(PlanCacheChart, "Plan_Cache", "collect.plan_cache_stats"); + + // Memory Pressure Events chart + var pressureMenu = TabHelpers.SetupChartContextMenu(MemoryPressureEventsChart, "Memory_Pressure_Events", "collect.memory_pressure_events"); + AddDrillDown(MemoryPressureEventsChart, pressureMenu, () => _memoryPressureEventsHover, "Show Active Queries at This Time", "MemoryPressure"); + } + + /// + /// Initializes the control with required dependencies. + /// + public void Initialize(DatabaseService databaseService) + { + _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); + } + + /// + /// Sets the time range for all memory sub-tabs. + /// + public void SetTimeRange(int hoursBack, DateTime? fromDate = null, DateTime? toDate = null) + { + _memoryStatsHoursBack = hoursBack; + _memoryStatsFromDate = fromDate; + _memoryStatsToDate = toDate; + + _memoryGrantsHoursBack = hoursBack; + _memoryGrantsFromDate = fromDate; + _memoryGrantsToDate = toDate; + + _memoryClerksHoursBack = hoursBack; + _memoryClerksFromDate = fromDate; + _memoryClerksToDate = toDate; + + _planCacheHoursBack = hoursBack; + _planCacheFromDate = fromDate; + _planCacheToDate = toDate; + + _memoryPressureEventsHoursBack = hoursBack; + _memoryPressureEventsFromDate = fromDate; + _memoryPressureEventsToDate = toDate; + } + + /// + /// Refreshes memory data. When fullRefresh is false, only the visible sub-tab is refreshed. + /// + public async Task RefreshAllDataAsync(bool fullRefresh = true) + { + try + { + using var _ = Helpers.MethodProfiler.StartTiming("Memory"); + + if (fullRefresh) + { + // Run all independent refreshes in parallel for initial load / manual refresh + await Task.WhenAll( + RefreshMemoryStatsAsync(), + RefreshMemoryGrantsAsync(), + RefreshMemoryClerksAsync(), + RefreshPlanCacheAsync(), + RefreshMemoryPressureEventsAsync() + ); + } + else + { + // Only refresh the visible sub-tab + switch (SubTabControl.SelectedIndex) + { + case 0: await RefreshMemoryStatsAsync(); break; + case 1: await RefreshMemoryGrantsAsync(); break; + case 2: await RefreshMemoryClerksAsync(); break; + case 3: await RefreshPlanCacheAsync(); break; + case 4: await RefreshMemoryPressureEventsAsync(); break; + } + } + } + catch (Exception ex) + { + Logger.Error($"Error refreshing Memory data: {ex.Message}", ex); + } + } + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs new file mode 100644 index 0000000..50be20f --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) + { + var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); + if (!string.IsNullOrEmpty(cellContent)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(cellContent, false); + } + } + } + } + + private void CopyRow_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) + { + var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); + if (!string.IsNullOrEmpty(rowText)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(rowText, false); + } + } + } + } + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid) + { + var sb = new StringBuilder(); + + var headers = grid.Columns.Select(c => Helpers.DataGridClipboardBehavior.GetHeaderText(c)); + sb.AppendLine(string.Join("\t", headers)); + + foreach (var item in grid.Items) + { + var values = new List(); + foreach (var column in grid.Columns) + { + var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; + if (binding != null) + { + var prop = item.GetType().GetProperty(binding.Path.Path); + var value = prop?.GetValue(item)?.ToString() ?? string.Empty; + values.Add(value); + } + } + sb.AppendLine(string.Join("\t", values)); + } + + if (sb.Length > 0) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(sb.ToString(), false); + } + } + } + } + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid) + { + var dialog = new SaveFileDialog + { + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = ".csv", + FileName = $"ResourceMetrics_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" + }; + + if (dialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + + var sep = TabHelpers.CsvSeparator; + var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(c), sep)); + sb.AppendLine(string.Join(sep, headers)); + + foreach (var item in grid.Items) + { + var values = new List(); + foreach (var column in grid.Columns) + { + var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; + if (binding != null) + { + var prop = item.GetType().GetProperty(binding.Path.Path); + values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); + } + } + sb.AppendLine(string.Join(sep, values)); + } + + File.WriteAllText(dialog.FileName, sb.ToString()); + } + catch (Exception ex) + { + Logger.Error($"Error exporting to CSV: {ex.Message}", ex); + MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs b/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs new file mode 100644 index 0000000..6186730 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region File I/O Latency Tab + + private async Task LoadFileIoLatencyChartsAsync() + { + if (_databaseService == null) return; + + DateTime rangeEnd = _fileIoToDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = _fileIoFromDate ?? rangeEnd.AddHours(-_fileIoHoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + var colors = TabHelpers.ChartColors; + + // Load User DB data only - TempDB latency moved to TempDB Stats tab + var userDbData = await _databaseService.GetFileIoLatencyTimeSeriesAsync(isTempDb: false, _fileIoHoursBack, _fileIoFromDate, _fileIoToDate); + LoadFileIoChart(UserDbReadLatencyChart, userDbData, d => d.ReadLatencyMs, "Read Latency (ms)", colors, xMin, xMax, _fileIoReadHover, d => d.ReadQueuedLatencyMs); + LoadFileIoChart(UserDbWriteLatencyChart, userDbData, d => d.WriteLatencyMs, "Write Latency (ms)", colors, xMin, xMax, _fileIoWriteHover, d => d.WriteQueuedLatencyMs); + } + + private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List data, Func latencySelector, string yLabel, ScottPlot.Color[] colors, double xMin, double xMax, Helpers.ChartHoverHelper? hover = null, Func? queuedSelector = null) + { + DateTime rangeStart = DateTime.FromOADate(xMin); + DateTime rangeEnd = DateTime.FromOADate(xMax); + + // Remove previously stored legend panel by reference (ScottPlot issue #4717) + if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) + { + chart.Plot.Axes.Remove(existingPanel); + _legendPanels[chart] = null; + } + chart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(chart); + hover?.Clear(); + + // Check if any queued data exists (only render overlay if there's real data) + bool hasQueuedData = queuedSelector != null && data != null && data.Any(d => queuedSelector(d) > 0); + + if (data != null && data.Count > 0) + { + // Get all unique time points for gap filling + // Group by file (database + filename) + var fileGroups = data.GroupBy(d => $"{d.DatabaseName}.{d.FileName}") + .Where(g => g.Any(x => latencySelector(x) > 0)) + .OrderByDescending(g => g.Average(x => (double)latencySelector(x))) + .Take(10) + .ToList(); + + int colorIndex = 0; + foreach (var group in fileGroups) + { + var fileData = group.OrderBy(d => d.CollectionTime).ToList(); + if (fileData.Count >= 1) + { + var timePoints = fileData.Select(d => d.CollectionTime); + var values = fileData.Select(d => (double)latencySelector(d)); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = chart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + var color = colors[colorIndex % colors.Length]; + scatter.Color = color; + + // Use just the filename for legend (not database.filename which is redundant) + var fileName = fileData.First().FileName; + scatter.LegendText = fileName; + hover?.Add(scatter, fileName); + + // Add queued I/O overlay as dashed line with same color + if (hasQueuedData) + { + var queuedValues = fileData.Select(d => (double)queuedSelector!(d)); + if (queuedValues.Any(v => v > 0)) + { + var (qxs, qys) = TabHelpers.FillTimeSeriesGaps(timePoints, queuedValues); + var queuedScatter = chart.Plot.Add.Scatter(qxs, qys); + queuedScatter.LineWidth = 2; + queuedScatter.MarkerSize = 0; + queuedScatter.Color = color; + queuedScatter.LinePattern = ScottPlot.LinePattern.Dashed; + queuedScatter.LegendText = $"{fileName} (queued)"; + hover?.Add(queuedScatter, $"{fileName} (queued)"); + } + } + + colorIndex++; + } + } + + if (fileGroups.Count > 0) + { + // Store legend panel reference for removal on refresh (ScottPlot issue #4717) + _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + chart.Plot.Legend.FontSize = 12; + } + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = chart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + chart.Plot.Axes.DateTimeTicksBottomDateChange(); + chart.Plot.Axes.SetLimitsX(xMin, xMax); + chart.Plot.YLabel(yLabel); + TabHelpers.LockChartVerticalAxis(chart); + chart.Refresh(); + } + + private async Task LoadFileIoThroughputChartsAsync() + { + if (_databaseService == null) return; + + DateTime rangeEnd = _fileIoToDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = _fileIoFromDate ?? rangeEnd.AddHours(-_fileIoHoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + var colors = TabHelpers.ChartColors; + + var throughputData = await _databaseService.GetFileIoThroughputTimeSeriesAsync(isTempDb: false, _fileIoHoursBack, _fileIoFromDate, _fileIoToDate); + LoadFileIoChart(FileIoReadThroughputChart, throughputData, d => d.ReadThroughputMbPerSec, "Read Throughput (MB/s)", colors, xMin, xMax, _fileIoReadThroughputHover); + LoadFileIoChart(FileIoWriteThroughputChart, throughputData, d => d.WriteThroughputMbPerSec, "Write Throughput (MB/s)", colors, xMin, xMax, _fileIoWriteThroughputHover); + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs b/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs new file mode 100644 index 0000000..79c1a48 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Latch Stats Tab + + private async Task RefreshLatchStatsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetLatchStatsTopNAsync(5, _latchStatsHoursBack, _latchStatsFromDate, _latchStatsToDate); + LoadLatchStatsChart(data, _latchStatsHoursBack, _latchStatsFromDate, _latchStatsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading latch stats: {ex.Message}", ex); + } + } + + private void LoadLatchStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(LatchStatsChart, out var existingPanel) && existingPanel != null) + { + LatchStatsChart.Plot.Axes.Remove(existingPanel); + _legendPanels[LatchStatsChart] = null; + } + LatchStatsChart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(LatchStatsChart); + _latchStatsHover?.Clear(); + + var dataList = data?.ToList() ?? new List(); + if (dataList.Count > 0) + { + // Get all unique time points for gap filling + var topLatches = dataList.GroupBy(d => d.LatchClass) + .Select(g => new { LatchClass = g.Key, TotalWait = g.Sum(x => x.WaitTimeSec) }) + .OrderByDescending(x => x.TotalWait) + .Take(5) + .Select(x => x.LatchClass) + .ToList(); + + var colors = TabHelpers.ChartColors; + int colorIndex = 0; + + foreach (var latchClass in topLatches) + { + var latchData = dataList.Where(d => d.LatchClass == latchClass) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (latchData.Count >= 1) + { + var timePoints = latchData.Select(d => d.CollectionTime); + var values = latchData.Select(d => (double)(d.WaitTimeMsPerSecond ?? 0)); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = LatchStatsChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + scatter.LegendText = latchClass?.Length > 20 ? latchClass.Substring(0, 20) + "..." : latchClass ?? ""; + _latchStatsHover?.Add(scatter, latchClass ?? ""); + colorIndex++; + } + } + + _legendPanels[LatchStatsChart] = LatchStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + LatchStatsChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = LatchStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + LatchStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + LatchStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); + TabHelpers.SetChartYLimitsWithLegendPadding(LatchStatsChart); + LatchStatsChart.Plot.YLabel("Wait Time (ms/sec)"); + TabHelpers.LockChartVerticalAxis(LatchStatsChart); + LatchStatsChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs b/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs new file mode 100644 index 0000000..4b0b0c9 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Perfmon Counters Tab + + private bool _isUpdatingPerfmonSelection = false; + + private void PerfmonCountersList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // Not used - we handle via checkbox changes instead + } + + private async void PerfmonCounter_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingPerfmonSelection) return; + RefreshPerfmonCounterListOrder(); + await UpdatePerfmonCountersChartAsync(); + } + + private void RefreshPerfmonCounterListOrder() + { + if (_perfmonCounterItems == null) return; + // Sort: checked items first, then alphabetically + var sorted = _perfmonCounterItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.CounterName) + .ToList(); + _perfmonCounterItems = sorted; + ApplyPerfmonCounterSearchFilter(); + } + + private void PerfmonCounterSearch_TextChanged(object sender, TextChangedEventArgs e) + { + ApplyPerfmonCounterSearchFilter(); + } + + private void ApplyPerfmonCounterSearchFilter() + { + if (_perfmonCounterItems == null) + { + PerfmonCountersList.ItemsSource = null; + return; + } + + var searchText = PerfmonCounterSearchBox?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(searchText)) + { + PerfmonCountersList.ItemsSource = null; + PerfmonCountersList.ItemsSource = _perfmonCounterItems; + } + else + { + var filtered = _perfmonCounterItems + .Where(c => c.CounterName.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + c.ObjectName.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + PerfmonCountersList.ItemsSource = null; + PerfmonCountersList.ItemsSource = filtered; + } + } + + private async void PerfmonCounters_SelectAll_Click(object sender, RoutedEventArgs e) + { + if (_perfmonCounterItems == null) return; + _isUpdatingPerfmonSelection = true; + foreach (var item in _perfmonCounterItems) + { + item.IsSelected = true; + } + _isUpdatingPerfmonSelection = false; + await UpdatePerfmonCountersChartAsync(); + } + + private async void PerfmonCounters_ClearAll_Click(object sender, RoutedEventArgs e) + { + if (_perfmonCounterItems == null) return; + _isUpdatingPerfmonSelection = true; + foreach (var item in _perfmonCounterItems) + { + item.IsSelected = false; + } + _isUpdatingPerfmonSelection = false; + await UpdatePerfmonCountersChartAsync(); + } + + private async void PerfmonPack_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_perfmonCounterItems == null || _perfmonCounterItems.Count == 0) return; + if (PerfmonPackCombo.SelectedItem is not string pack) return; + + _isUpdatingPerfmonSelection = true; + + /* Clear search so all counters are visible */ + if (PerfmonCounterSearchBox != null) + PerfmonCounterSearchBox.Text = ""; + + /* Uncheck everything first */ + foreach (var item in _perfmonCounterItems) + item.IsSelected = false; + + if (pack == PerfmonPacks.AllCounters) + { + /* "All Counters" selects the General Throughput defaults */ + var defaultSet = new HashSet(PerfmonPacks.Packs["General Throughput"], StringComparer.OrdinalIgnoreCase); + foreach (var item in _perfmonCounterItems) + { + if (defaultSet.Contains(item.CounterName)) + item.IsSelected = true; + } + } + else if (PerfmonPacks.Packs.TryGetValue(pack, out var packCounters)) + { + var packSet = new HashSet(packCounters, StringComparer.OrdinalIgnoreCase); + int count = 0; + foreach (var item in _perfmonCounterItems) + { + if (count >= 12) break; + if (packSet.Contains(item.CounterName)) + { + item.IsSelected = true; + count++; + } + } + } + + _isUpdatingPerfmonSelection = false; + RefreshPerfmonCounterListOrder(); + await UpdatePerfmonCountersChartAsync(); + } + + private async void PerfmonCounters_Refresh_Click(object sender, RoutedEventArgs e) + { + try + { + await RefreshPerfmonCountersTabAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error refreshing perfmon counters: {ex.Message}", ex); + } + } + + private async Task RefreshPerfmonCountersTabAsync() + { + if (_databaseService == null) return; + + /* Initialize pack ComboBox once */ + if (PerfmonPackCombo.Items.Count == 0) + { + PerfmonPackCombo.ItemsSource = PerfmonPacks.PackNames; + PerfmonPackCombo.SelectedItem = "General Throughput"; + } + + try + { + // Lightweight query: get only distinct counter names for the picker + var counterNames = await _databaseService.GetPerfmonCounterNamesAsync(_perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); + + // Remember previously selected counters + var previouslySelected = _perfmonCounterItems?.Where(x => x.IsSelected).Select(x => x.FullName).ToHashSet() ?? new HashSet(); + + // Build unique counter list from lightweight query + var counters = counterNames + .OrderBy(c => c.ObjectName) + .ThenBy(c => c.CounterName) + .Select(c => new PerfmonCounterSelectionItem + { + ObjectName = c.ObjectName, + CounterName = c.CounterName, + IsSelected = previouslySelected.Contains($"{c.ObjectName} - {c.CounterName}") + }) + .ToList(); + + // If nothing was previously selected, default select General Throughput pack + if (!counters.Any(c => c.IsSelected)) + { + var defaultCounters = PerfmonPacks.Packs["General Throughput"]; + var defaultSet = new HashSet(defaultCounters, StringComparer.OrdinalIgnoreCase); + foreach (var item in counters.Where(c => defaultSet.Contains(c.CounterName))) + { + item.IsSelected = true; + } + } + + _perfmonCounterItems = counters; + // Sort so checked items appear at top + RefreshPerfmonCounterListOrder(); + + // Fetch data only for selected counters + await UpdatePerfmonCountersChartAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error loading perfmon counters: {ex.Message}"); + } + } + + private async Task UpdatePerfmonCountersChartAsync() + { + if (_databaseService == null || _perfmonCounterItems == null) return; + + var selectedCounterNames = _perfmonCounterItems + .Where(x => x.IsSelected) + .Select(x => x.CounterName) + .Distinct() + .ToArray(); + + if (selectedCounterNames.Length == 0) + { + _allPerfmonCountersData = new List(); + } + else + { + var data = await _databaseService.GetPerfmonStatsFilteredAsync( + selectedCounterNames, _perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); + _allPerfmonCountersData = data?.ToList() ?? new List(); + } + + LoadPerfmonCountersChart(_allPerfmonCountersData, _perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); + } + + private void LoadPerfmonCountersChart(List? data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(PerfmonCountersChart, out var existingPerfmonPanel) && existingPerfmonPanel != null) + { + PerfmonCountersChart.Plot.Axes.Remove(existingPerfmonPanel); + _legendPanels[PerfmonCountersChart] = null; + } + PerfmonCountersChart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(PerfmonCountersChart); + _perfmonHover?.Clear(); + + if (data == null || data.Count == 0 || _perfmonCounterItems == null) + { + PerfmonCountersChart.Refresh(); + return; + } + + // Get selected counters + var selectedCounters = _perfmonCounterItems.Where(x => x.IsSelected).ToList(); + if (selectedCounters.Count == 0) + { + PerfmonCountersChart.Refresh(); + return; + } + + var colors = TabHelpers.ChartColors; + + // Get all time points across all counters for gap filling + int colorIndex = 0; + foreach (var counter in selectedCounters.Take(12)) // Limit to 12 counters + { + // Get data for this counter (aggregated across all instances) + var counterData = data + .Where(d => d.ObjectName == counter.ObjectName && d.CounterName == counter.CounterName) + .GroupBy(d => d.CollectionTime) + .Select(g => new { + CollectionTime = g.Key, + Value = g.Sum(x => x.CntrValuePerSecond ?? x.CntrValueDelta ?? x.CntrValue) + }) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (counterData.Count >= 1) + { + var timePoints = counterData.Select(d => d.CollectionTime); + var values = counterData.Select(d => (double)d.Value); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = PerfmonCountersChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; // Show small markers to ensure visibility + scatter.Color = colors[colorIndex % colors.Length]; + scatter.LegendText = counter.CounterName; + _perfmonHover?.Add(scatter, counter.CounterName); + + colorIndex++; + } + } + + if (colorIndex > 0) + { + _legendPanels[PerfmonCountersChart] = PerfmonCountersChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + PerfmonCountersChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = PerfmonCountersChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + PerfmonCountersChart.Plot.Axes.DateTimeTicksBottomDateChange(); + PerfmonCountersChart.Plot.Axes.SetLimitsX(xMin, xMax); + TabHelpers.SetChartYLimitsWithLegendPadding(PerfmonCountersChart); + PerfmonCountersChart.Plot.YLabel("Value/sec"); + TabHelpers.LockChartVerticalAxis(PerfmonCountersChart); + PerfmonCountersChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs b/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs new file mode 100644 index 0000000..4cb7807 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Session Stats Tab + + private async Task RefreshSessionStatsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetSessionStatsAsync(_sessionStatsHoursBack, _sessionStatsFromDate, _sessionStatsToDate); + LoadSessionStatsChart(data, _sessionStatsHoursBack, _sessionStatsFromDate, _sessionStatsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading session stats: {ex.Message}", ex); + } + } + + private void LoadSessionStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(SessionStatsChart, out var existingSessionPanel) && existingSessionPanel != null) + { + SessionStatsChart.Plot.Axes.Remove(existingSessionPanel); + _legendPanels[SessionStatsChart] = null; + } + SessionStatsChart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(SessionStatsChart); + _sessionStatsHover?.Clear(); + + var dataList = data?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); + if (dataList.Count > 0) + { + var timePoints = dataList.Select(d => d.CollectionTime); + double[] totalCounts = dataList.Select(d => (double)d.TotalSessions).ToArray(); + double[] runningCounts = dataList.Select(d => (double)d.RunningSessions).ToArray(); + double[] sleepingCounts = dataList.Select(d => (double)d.SleepingSessions).ToArray(); + + if (totalCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, totalCounts.Select(c => c)); + var totalScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + totalScatter.LineWidth = 2; + totalScatter.MarkerSize = 5; + totalScatter.Color = TabHelpers.ChartColors[0]; + totalScatter.LegendText = "Total"; + _sessionStatsHover?.Add(totalScatter, "Total"); + } + + if (runningCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, runningCounts.Select(c => c)); + var runningScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + runningScatter.LineWidth = 2; + runningScatter.MarkerSize = 5; + runningScatter.Color = TabHelpers.ChartColors[1]; + runningScatter.LegendText = "Running"; + _sessionStatsHover?.Add(runningScatter, "Running"); + } + + if (sleepingCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, sleepingCounts.Select(c => c)); + var sleepingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + sleepingScatter.LineWidth = 2; + sleepingScatter.MarkerSize = 5; + sleepingScatter.Color = TabHelpers.ChartColors[2]; + sleepingScatter.LegendText = "Sleeping"; + _sessionStatsHover?.Add(sleepingScatter, "Sleeping"); + } + + double[] backgroundCounts = dataList.Select(d => (double)d.BackgroundSessions).ToArray(); + if (backgroundCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, backgroundCounts.Select(c => c)); + var backgroundScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + backgroundScatter.LineWidth = 2; + backgroundScatter.MarkerSize = 5; + backgroundScatter.Color = TabHelpers.ChartColors[4]; + backgroundScatter.LegendText = "Background"; + _sessionStatsHover?.Add(backgroundScatter, "Background"); + } + + double[] dormantCounts = dataList.Select(d => (double)d.DormantSessions).ToArray(); + if (dormantCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, dormantCounts.Select(c => c)); + var dormantScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + dormantScatter.LineWidth = 2; + dormantScatter.MarkerSize = 5; + dormantScatter.Color = TabHelpers.ChartColors[5]; + dormantScatter.LegendText = "Dormant"; + _sessionStatsHover?.Add(dormantScatter, "Dormant"); + } + + double[] idleOver30MinCounts = dataList.Select(d => (double)d.IdleSessionsOver30Min).ToArray(); + if (idleOver30MinCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, idleOver30MinCounts.Select(c => c)); + var idleScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + idleScatter.LineWidth = 2; + idleScatter.MarkerSize = 5; + idleScatter.Color = TabHelpers.ChartColors[9]; + idleScatter.LegendText = "Idle >30m"; + _sessionStatsHover?.Add(idleScatter, "Idle >30m"); + } + + double[] waitingForMemoryCounts = dataList.Select(d => (double)d.SessionsWaitingForMemory).ToArray(); + if (waitingForMemoryCounts.Any(c => c > 0)) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, waitingForMemoryCounts.Select(c => c)); + var waitingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); + waitingScatter.LineWidth = 2; + waitingScatter.MarkerSize = 5; + waitingScatter.Color = TabHelpers.ChartColors[3]; + waitingScatter.LegendText = "Waiting for Memory"; + _sessionStatsHover?.Add(waitingScatter, "Waiting for Memory"); + } + + // Update summary panel with latest data point + var latestData = dataList.LastOrDefault(); + UpdateSessionStatsSummary(latestData); + + _legendPanels[SessionStatsChart] = SessionStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + SessionStatsChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = SessionStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + UpdateSessionStatsSummary(null); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + SessionStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + SessionStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); + TabHelpers.SetChartYLimitsWithLegendPadding(SessionStatsChart); + SessionStatsChart.Plot.YLabel("Session Count"); + TabHelpers.LockChartVerticalAxis(SessionStatsChart); + SessionStatsChart.Refresh(); + } + + private void UpdateSessionStatsSummary(SessionStatsItem? data) + { + if (data != null) + { + SessionStatsTopAppText.Text = !string.IsNullOrEmpty(data.TopApplicationName) + ? $"{data.TopApplicationName} ({data.TopApplicationConnections ?? 0})" + : "N/A"; + SessionStatsTopHostText.Text = !string.IsNullOrEmpty(data.TopHostName) + ? $"{data.TopHostName} ({data.TopHostConnections ?? 0})" + : "N/A"; + SessionStatsDatabasesText.Text = data.DatabasesWithConnections.ToString(CultureInfo.CurrentCulture); + } + else + { + SessionStatsTopAppText.Text = "N/A"; + SessionStatsTopHostText.Text = "N/A"; + SessionStatsDatabasesText.Text = "N/A"; + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs b/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs new file mode 100644 index 0000000..09b2235 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Spinlock Stats Tab + + private async Task RefreshSpinlockStatsAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetSpinlockStatsTopNAsync(5, _spinlockStatsHoursBack, _spinlockStatsFromDate, _spinlockStatsToDate); + LoadSpinlockStatsChart(data, _spinlockStatsHoursBack, _spinlockStatsFromDate, _spinlockStatsToDate); + } + catch (Exception ex) + { + Logger.Error($"Error loading spinlock stats: {ex.Message}", ex); + } + } + + private void LoadSpinlockStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(SpinlockStatsChart, out var existingSpinlockPanel) && existingSpinlockPanel != null) + { + SpinlockStatsChart.Plot.Axes.Remove(existingSpinlockPanel); + _legendPanels[SpinlockStatsChart] = null; + } + SpinlockStatsChart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(SpinlockStatsChart); + _spinlockStatsHover?.Clear(); + + var dataList = data?.ToList() ?? new List(); + if (dataList.Count > 0) + { + // Get all unique time points for gap filling + var topSpinlocks = dataList.GroupBy(d => d.SpinlockName) + .Select(g => new { SpinlockName = g.Key, TotalCollisions = g.Sum(x => x.CollisionsPerSecond ?? 0) }) + .OrderByDescending(x => x.TotalCollisions) + .Take(5) + .Select(x => x.SpinlockName) + .ToList(); + + var colors = TabHelpers.ChartColors; + int colorIndex = 0; + + foreach (var spinlock in topSpinlocks) + { + var spinlockData = dataList.Where(d => d.SpinlockName == spinlock) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (spinlockData.Count >= 1) + { + var timePoints = spinlockData.Select(d => d.CollectionTime); + var values = spinlockData.Select(d => (double)(d.CollisionsPerSecond ?? 0)); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = SpinlockStatsChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + scatter.LegendText = spinlock?.Length > 20 ? spinlock.Substring(0, 20) + "..." : spinlock ?? ""; + _spinlockStatsHover?.Add(scatter, spinlock ?? ""); + colorIndex++; + } + } + + _legendPanels[SpinlockStatsChart] = SpinlockStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + SpinlockStatsChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = SpinlockStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + SpinlockStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + SpinlockStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); + TabHelpers.SetChartYLimitsWithLegendPadding(SpinlockStatsChart); + SpinlockStatsChart.Plot.YLabel("Collisions/sec"); + TabHelpers.LockChartVerticalAxis(SpinlockStatsChart); + SpinlockStatsChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs b/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs new file mode 100644 index 0000000..c06f8d8 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region TempDB Stats Tab + + private async Task RefreshTempdbStatsAsync() + { + if (_databaseService == null) return; + + try + { + // Load TempDB usage stats + var data = await _databaseService.GetTempdbStatsAsync(_tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); + LoadTempdbStatsChart(data, _tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); + + // Load TempDB latency charts (moved from File I/O Latency tab) + await LoadTempdbLatencyChartsAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error loading tempdb stats: {ex.Message}", ex); + } + } + + private async Task LoadTempdbLatencyChartsAsync() + { + if (_databaseService == null) return; + + DateTime rangeEnd = _tempdbStatsToDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = _tempdbStatsFromDate ?? rangeEnd.AddHours(-_tempdbStatsHoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + var tempDbData = await _databaseService.GetFileIoLatencyTimeSeriesAsync(isTempDb: true, _tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); + LoadCombinedTempDbLatencyChart(tempDbData, xMin, xMax); + } + + private void LoadCombinedTempDbLatencyChart(List data, double xMin, double xMax) + { + DateTime rangeStart = DateTime.FromOADate(xMin); + DateTime rangeEnd = DateTime.FromOADate(xMax); + + // Remove previously stored legend panel by reference (ScottPlot issue #4717) + if (_legendPanels.TryGetValue(TempDbLatencyChart, out var existingPanel) && existingPanel != null) + { + TempDbLatencyChart.Plot.Axes.Remove(existingPanel); + _legendPanels[TempDbLatencyChart] = null; + } + TempDbLatencyChart.Plot.Clear(); + _tempDbLatencyHover?.Clear(); + TabHelpers.ApplyThemeToChart(TempDbLatencyChart); + + if (data != null && data.Count > 0) + { + // Aggregate all TempDB files into single read/write latency values per time point + var aggregated = data + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => new + { + Time = g.Key, + AvgReadLatency = g.Average(x => (double)x.ReadLatencyMs), + AvgWriteLatency = g.Average(x => (double)x.WriteLatencyMs) + }) + .ToList(); + + // Read Latency series + var (readXs, readYs) = TabHelpers.FillTimeSeriesGaps( + aggregated.Select(d => d.Time), + aggregated.Select(d => d.AvgReadLatency)); + var readScatter = TempDbLatencyChart.Plot.Add.Scatter(readXs, readYs); + readScatter.LineWidth = 2; + readScatter.MarkerSize = 5; + readScatter.Color = TabHelpers.ChartColors[0]; + readScatter.LegendText = "Read Latency"; + _tempDbLatencyHover?.Add(readScatter, "Read Latency"); + + // Write Latency series + var (writeXs, writeYs) = TabHelpers.FillTimeSeriesGaps( + aggregated.Select(d => d.Time), + aggregated.Select(d => d.AvgWriteLatency)); + var writeScatter = TempDbLatencyChart.Plot.Add.Scatter(writeXs, writeYs); + writeScatter.LineWidth = 2; + writeScatter.MarkerSize = 5; + writeScatter.Color = TabHelpers.ChartColors[2]; + writeScatter.LegendText = "Write Latency"; + _tempDbLatencyHover?.Add(writeScatter, "Write Latency"); + + // Store legend panel reference for removal on refresh (ScottPlot issue #4717) + _legendPanels[TempDbLatencyChart] = TempDbLatencyChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + TempDbLatencyChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = TempDbLatencyChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + TempDbLatencyChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempDbLatencyChart.Plot.Axes.SetLimitsX(xMin, xMax); + TabHelpers.SetChartYLimitsWithLegendPadding(TempDbLatencyChart); + TempDbLatencyChart.Plot.YLabel("Latency (ms)"); + TabHelpers.LockChartVerticalAxis(TempDbLatencyChart); + TempDbLatencyChart.Refresh(); + } + + private void LoadTempdbStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(TempdbStatsChart, out var existingTempdbPanel) && existingTempdbPanel != null) + { + TempdbStatsChart.Plot.Axes.Remove(existingTempdbPanel); + _legendPanels[TempdbStatsChart] = null; + } + TempdbStatsChart.Plot.Clear(); + _tempdbStatsHover?.Clear(); + TabHelpers.ApplyThemeToChart(TempdbStatsChart); + + var dataList = data?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); + if (dataList.Count > 0) + { + // User Objects series + var (userXs, userYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.UserObjectReservedMb)); + var userScatter = TempdbStatsChart.Plot.Add.Scatter(userXs, userYs); + userScatter.LineWidth = 2; + userScatter.MarkerSize = 5; + userScatter.Color = TabHelpers.ChartColors[0]; + userScatter.LegendText = "User Objects"; + _tempdbStatsHover?.Add(userScatter, "User Objects"); + + // Version Store series + var (versionXs, versionYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.VersionStoreReservedMb)); + var versionScatter = TempdbStatsChart.Plot.Add.Scatter(versionXs, versionYs); + versionScatter.LineWidth = 2; + versionScatter.MarkerSize = 5; + versionScatter.Color = TabHelpers.ChartColors[1]; + versionScatter.LegendText = "Version Store"; + _tempdbStatsHover?.Add(versionScatter, "Version Store"); + + // Internal Objects series + var (internalXs, internalYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.InternalObjectReservedMb)); + var internalScatter = TempdbStatsChart.Plot.Add.Scatter(internalXs, internalYs); + internalScatter.LineWidth = 2; + internalScatter.MarkerSize = 5; + internalScatter.Color = TabHelpers.ChartColors[2]; + internalScatter.LegendText = "Internal Objects"; + _tempdbStatsHover?.Add(internalScatter, "Internal Objects"); + + // Unallocated (free space) series + var (unallocXs, unallocYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + dataList.Select(d => (double)d.UnallocatedMb)); + if (unallocYs.Any(y => y > 0)) + { + var unallocScatter = TempdbStatsChart.Plot.Add.Scatter(unallocXs, unallocYs); + unallocScatter.LineWidth = 2; + unallocScatter.MarkerSize = 5; + unallocScatter.Color = TabHelpers.ChartColors[9]; + unallocScatter.LegendText = "Unallocated"; + _tempdbStatsHover?.Add(unallocScatter, "Unallocated"); + } + + // Top Task Total MB series (worst session's usage) + var topTaskValues = dataList.Select(d => (double)(d.TopTaskTotalMb ?? 0)).ToArray(); + if (topTaskValues.Any(v => v > 0)) + { + var (topTaskXs, topTaskYs) = TabHelpers.FillTimeSeriesGaps( + dataList.Select(d => d.CollectionTime), + topTaskValues); + var topTaskScatter = TempdbStatsChart.Plot.Add.Scatter(topTaskXs, topTaskYs); + topTaskScatter.LineWidth = 2; + topTaskScatter.MarkerSize = 5; + topTaskScatter.Color = TabHelpers.ChartColors[3]; + topTaskScatter.LegendText = "Top Task"; + } + + // Update summary panel with latest data point + var latestData = dataList.LastOrDefault(); + UpdateTempdbStatsSummary(latestData); + + _legendPanels[TempdbStatsChart] = TempdbStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + TempdbStatsChart.Plot.Legend.FontSize = 12; + } + else + { + UpdateTempdbStatsSummary(null); + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = TempdbStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + TempdbStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + TempdbStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); + TempdbStatsChart.Plot.Axes.AutoScaleY(); + TempdbStatsChart.Plot.YLabel("MB"); + TabHelpers.LockChartVerticalAxis(TempdbStatsChart); + TempdbStatsChart.Refresh(); + } + + private void UpdateTempdbStatsSummary(TempdbStatsItem? data) + { + if (data != null) + { + TempdbSessionsText.Text = $"{data.TotalSessionsUsingTempdb} ({data.SessionsWithUserObjects} user, {data.SessionsWithInternalObjects} internal)"; + + var warnings = new System.Collections.Generic.List(); + if (data.VersionStoreHighWarning) warnings.Add("Version Store High"); + if (data.AllocationContentionWarning) warnings.Add("Allocation Contention"); + TempdbWarningsText.Text = warnings.Count > 0 ? string.Join(", ", warnings) : "None"; + TempdbWarningsText.Foreground = warnings.Count > 0 + ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed) + : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + else + { + TempdbSessionsText.Text = "N/A"; + TempdbWarningsText.Text = "N/A"; + TempdbWarningsText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs b/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs new file mode 100644 index 0000000..2b60bb5 --- /dev/null +++ b/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Windows.Data; +using System.Text; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; +using PerformanceMonitorDashboard.Helpers; +using ScottPlot.WPF; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class ResourceMetricsContent : UserControl + { + #region Wait Stats Detail Tab + + private void WaitTypesList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // Not used - we handle via checkbox changes instead + } + + private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) + { + if (_isUpdatingWaitTypeSelection) return; + RefreshWaitTypeListOrder(); + await UpdateWaitStatsDetailChartAsync(); + } + + private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) + { + contextMenu.Items.Insert(0, new Separator()); + var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; + drillDownItem.Click += ShowQueriesForWaitType_Click; + contextMenu.Items.Insert(0, drillDownItem); + + contextMenu.Opened += (s, _) => + { + var pos = System.Windows.Input.Mouse.GetPosition(chart); + var nearest = _waitStatsHover?.GetNearestSeries(pos); + if (nearest.HasValue) + { + drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); + drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; + drillDownItem.IsEnabled = true; + } + else + { + drillDownItem.Tag = null; + drillDownItem.Header = "Show Queries With This Wait"; + drillDownItem.IsEnabled = false; + } + }; + } + + private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuItem menuItem) return; + if (menuItem.Tag is not ValueTuple tag) return; + if (_databaseService == null) return; + + // ±15 minute window around the clicked point + var fromDate = tag.Item2.AddMinutes(-15); + var toDate = tag.Item2.AddMinutes(15); + + var window = new WaitDrillDownWindow( + _databaseService, tag.Item1, 1, fromDate, toDate); + window.Owner = Window.GetWindow(this); + window.ShowDialog(); + } + + private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_allWaitStatsDetailData != null) + LoadWaitStatsDetailChart(_allWaitStatsDetailData, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); + } + + private void RefreshWaitTypeListOrder() + { + if (_waitTypeItems == null) return; + // Sort: checked items first, then alphabetically + var sorted = _waitTypeItems + .OrderByDescending(x => x.IsSelected) + .ThenBy(x => x.WaitType) + .ToList(); + _waitTypeItems = sorted; + ApplyWaitTypeSearchFilter(); + UpdateWaitTypeCount(); + } + + private void UpdateWaitTypeCount() + { + if (_waitTypeItems == null || WaitTypeCountText == null) return; + int count = _waitTypeItems.Count(x => x.IsSelected); + WaitTypeCountText.Text = $"{count} / 30 selected"; + WaitTypeCountText.Foreground = count >= 30 + ? new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#E57373")!) + : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); + } + + private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e) + { + ApplyWaitTypeSearchFilter(); + } + + private void ApplyWaitTypeSearchFilter() + { + if (_waitTypeItems == null) + { + WaitTypesList.ItemsSource = null; + return; + } + + var searchText = WaitTypeSearchBox?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(searchText)) + { + WaitTypesList.ItemsSource = null; + WaitTypesList.ItemsSource = _waitTypeItems; + } + else + { + var filtered = _waitTypeItems + .Where(c => c.WaitType.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + WaitTypesList.ItemsSource = null; + WaitTypesList.ItemsSource = filtered; + } + } + + private async void WaitTypes_SelectAll_Click(object sender, RoutedEventArgs e) + { + if (_waitTypeItems == null) return; + _isUpdatingWaitTypeSelection = true; + var topWaits = TabHelpers.GetDefaultWaitTypes(_waitTypeItems.Select(x => x.WaitType).ToList()); + foreach (var item in _waitTypeItems) + { + item.IsSelected = topWaits.Contains(item.WaitType); + } + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + await UpdateWaitStatsDetailChartAsync(); + } + + private async void WaitTypes_ClearAll_Click(object sender, RoutedEventArgs e) + { + if (_waitTypeItems == null) return; + _isUpdatingWaitTypeSelection = true; + foreach (var item in _waitTypeItems) + { + item.IsSelected = false; + } + _isUpdatingWaitTypeSelection = false; + RefreshWaitTypeListOrder(); + await UpdateWaitStatsDetailChartAsync(); + } + + private async void WaitStatsDetail_Refresh_Click(object sender, RoutedEventArgs e) + { + try + { + await RefreshWaitStatsDetailTabAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error refreshing wait stats detail: {ex.Message}", ex); + } + } + + private async Task RefreshWaitStatsDetailTabAsync() + { + if (_databaseService == null) return; + + try + { + // Lightweight query: get only distinct wait type names for the picker + var waitTypeNames = await _databaseService.GetWaitTypeNamesAsync(_waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); + + // Remember previously selected wait types + var previouslySelected = _waitTypeItems?.Where(x => x.IsSelected).Select(x => x.WaitType).ToHashSet() ?? new HashSet(); + + // Build unique wait type list, sorted by total wait time descending + var waitTypes = waitTypeNames + .Select(w => new WaitTypeSelectionItem + { + WaitType = w.WaitType, + IsSelected = previouslySelected.Contains(w.WaitType) + }) + .ToList(); + + // Ensure poison waits are always in the picker even if they have no collected data + foreach (var poisonWait in TabHelpers.PoisonWaits) + { + if (!waitTypes.Any(w => string.Equals(w.WaitType, poisonWait, StringComparison.OrdinalIgnoreCase))) + { + waitTypes.Add(new WaitTypeSelectionItem + { + WaitType = poisonWait, + IsSelected = previouslySelected.Contains(poisonWait) + }); + } + } + + // If nothing was previously selected, apply poison waits + usual suspects + top 10 + if (!waitTypes.Any(w => w.IsSelected)) + { + var topWaits = TabHelpers.GetDefaultWaitTypes(waitTypes.Select(w => w.WaitType).ToList()); + foreach (var item in waitTypes.Where(w => topWaits.Contains(w.WaitType))) + { + item.IsSelected = true; + } + } + + _waitTypeItems = waitTypes; + // Sort so checked items appear at top + RefreshWaitTypeListOrder(); + + // Fetch data only for selected wait types + await UpdateWaitStatsDetailChartAsync(); + } + catch (Exception ex) + { + Logger.Error($"Error loading wait stats detail: {ex.Message}"); + } + } + + private async Task UpdateWaitStatsDetailChartAsync() + { + if (_databaseService == null || _waitTypeItems == null) return; + + var selectedWaitTypes = _waitTypeItems + .Where(x => x.IsSelected) + .Select(x => x.WaitType) + .ToArray(); + + if (selectedWaitTypes.Length == 0) + { + _allWaitStatsDetailData = new List(); + } + else + { + var data = await _databaseService.GetWaitStatsDataForTypesAsync( + selectedWaitTypes, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); + _allWaitStatsDetailData = data?.ToList() ?? new List(); + } + + LoadWaitStatsDetailChart(_allWaitStatsDetailData, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); + } + + private void LoadWaitStatsDetailChart(List? data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(WaitStatsDetailChart, out var existingWaitStatsPanel) && existingWaitStatsPanel != null) + { + WaitStatsDetailChart.Plot.Axes.Remove(existingWaitStatsPanel); + _legendPanels[WaitStatsDetailChart] = null; + } + bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; + + WaitStatsDetailChart.Plot.Clear(); + TabHelpers.ApplyThemeToChart(WaitStatsDetailChart); + _waitStatsHover?.Clear(); + if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; + + if (data == null || data.Count == 0 || _waitTypeItems == null) + { + WaitStatsDetailChart.Refresh(); + return; + } + + // Get selected wait types + var selectedWaitTypes = _waitTypeItems.Where(x => x.IsSelected).ToList(); + if (selectedWaitTypes.Count == 0) + { + WaitStatsDetailChart.Refresh(); + return; + } + var colors = TabHelpers.ChartColors; + + // Get all time points across all wait types for gap filling + int colorIndex = 0; + foreach (var waitType in selectedWaitTypes.Take(20)) // Limit to 20 wait types + { + // Get data for this wait type + var waitTypeData = data + .Where(d => d.WaitType == waitType.WaitType) + .GroupBy(d => d.CollectionTime) + .Select(g => new { + CollectionTime = g.Key, + WaitTimeMsPerSecond = g.Sum(x => x.WaitTimeMsPerSecond), + AvgMsPerWait = g.Average(x => x.AvgMsPerWait) + }) + .OrderBy(d => d.CollectionTime) + .ToList(); + + if (waitTypeData.Count >= 1) + { + var timePoints = waitTypeData.Select(d => d.CollectionTime); + var values = useAvgPerWait + ? waitTypeData.Select(d => (double)d.AvgMsPerWait) + : waitTypeData.Select(d => (double)d.WaitTimeMsPerSecond); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); + + var scatter = WaitStatsDetailChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + + // Truncate legend text if too long + string legendText = waitType.WaitType; + if (legendText.Length > 25) + legendText = legendText.Substring(0, 22) + "..."; + scatter.LegendText = legendText; + _waitStatsHover?.Add(scatter, waitType.WaitType); + + colorIndex++; + } + } + + if (colorIndex > 0) + { + _legendPanels[WaitStatsDetailChart] = WaitStatsDetailChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + WaitStatsDetailChart.Plot.Legend.FontSize = 12; + } + else + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = WaitStatsDetailChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + 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)"); + TabHelpers.LockChartVerticalAxis(WaitStatsDetailChart); + WaitStatsDetailChart.Refresh(); + } + + #endregion + } +} diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index bcc2d7c..3d4c3af 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -323,722 +323,6 @@ await Task.WhenAll( } } - #region Latch Stats Tab - - private async Task RefreshLatchStatsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetLatchStatsTopNAsync(5, _latchStatsHoursBack, _latchStatsFromDate, _latchStatsToDate); - LoadLatchStatsChart(data, _latchStatsHoursBack, _latchStatsFromDate, _latchStatsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading latch stats: {ex.Message}", ex); - } - } - - private void LoadLatchStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(LatchStatsChart, out var existingPanel) && existingPanel != null) - { - LatchStatsChart.Plot.Axes.Remove(existingPanel); - _legendPanels[LatchStatsChart] = null; - } - LatchStatsChart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(LatchStatsChart); - _latchStatsHover?.Clear(); - - var dataList = data?.ToList() ?? new List(); - if (dataList.Count > 0) - { - // Get all unique time points for gap filling - var topLatches = dataList.GroupBy(d => d.LatchClass) - .Select(g => new { LatchClass = g.Key, TotalWait = g.Sum(x => x.WaitTimeSec) }) - .OrderByDescending(x => x.TotalWait) - .Take(5) - .Select(x => x.LatchClass) - .ToList(); - - var colors = TabHelpers.ChartColors; - int colorIndex = 0; - - foreach (var latchClass in topLatches) - { - var latchData = dataList.Where(d => d.LatchClass == latchClass) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (latchData.Count >= 1) - { - var timePoints = latchData.Select(d => d.CollectionTime); - var values = latchData.Select(d => (double)(d.WaitTimeMsPerSecond ?? 0)); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = LatchStatsChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - scatter.LegendText = latchClass?.Length > 20 ? latchClass.Substring(0, 20) + "..." : latchClass ?? ""; - _latchStatsHover?.Add(scatter, latchClass ?? ""); - colorIndex++; - } - } - - _legendPanels[LatchStatsChart] = LatchStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - LatchStatsChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = LatchStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - LatchStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - LatchStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(LatchStatsChart); - LatchStatsChart.Plot.YLabel("Wait Time (ms/sec)"); - TabHelpers.LockChartVerticalAxis(LatchStatsChart); - LatchStatsChart.Refresh(); - } - - #endregion - - #region Spinlock Stats Tab - - private async Task RefreshSpinlockStatsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetSpinlockStatsTopNAsync(5, _spinlockStatsHoursBack, _spinlockStatsFromDate, _spinlockStatsToDate); - LoadSpinlockStatsChart(data, _spinlockStatsHoursBack, _spinlockStatsFromDate, _spinlockStatsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading spinlock stats: {ex.Message}", ex); - } - } - - private void LoadSpinlockStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(SpinlockStatsChart, out var existingSpinlockPanel) && existingSpinlockPanel != null) - { - SpinlockStatsChart.Plot.Axes.Remove(existingSpinlockPanel); - _legendPanels[SpinlockStatsChart] = null; - } - SpinlockStatsChart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(SpinlockStatsChart); - _spinlockStatsHover?.Clear(); - - var dataList = data?.ToList() ?? new List(); - if (dataList.Count > 0) - { - // Get all unique time points for gap filling - var topSpinlocks = dataList.GroupBy(d => d.SpinlockName) - .Select(g => new { SpinlockName = g.Key, TotalCollisions = g.Sum(x => x.CollisionsPerSecond ?? 0) }) - .OrderByDescending(x => x.TotalCollisions) - .Take(5) - .Select(x => x.SpinlockName) - .ToList(); - - var colors = TabHelpers.ChartColors; - int colorIndex = 0; - - foreach (var spinlock in topSpinlocks) - { - var spinlockData = dataList.Where(d => d.SpinlockName == spinlock) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (spinlockData.Count >= 1) - { - var timePoints = spinlockData.Select(d => d.CollectionTime); - var values = spinlockData.Select(d => (double)(d.CollisionsPerSecond ?? 0)); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = SpinlockStatsChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - scatter.LegendText = spinlock?.Length > 20 ? spinlock.Substring(0, 20) + "..." : spinlock ?? ""; - _spinlockStatsHover?.Add(scatter, spinlock ?? ""); - colorIndex++; - } - } - - _legendPanels[SpinlockStatsChart] = SpinlockStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - SpinlockStatsChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = SpinlockStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - SpinlockStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - SpinlockStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(SpinlockStatsChart); - SpinlockStatsChart.Plot.YLabel("Collisions/sec"); - TabHelpers.LockChartVerticalAxis(SpinlockStatsChart); - SpinlockStatsChart.Refresh(); - } - - #endregion - - #region TempDB Stats Tab - - private async Task RefreshTempdbStatsAsync() - { - if (_databaseService == null) return; - - try - { - // Load TempDB usage stats - var data = await _databaseService.GetTempdbStatsAsync(_tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); - LoadTempdbStatsChart(data, _tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); - - // Load TempDB latency charts (moved from File I/O Latency tab) - await LoadTempdbLatencyChartsAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error loading tempdb stats: {ex.Message}", ex); - } - } - - private async Task LoadTempdbLatencyChartsAsync() - { - if (_databaseService == null) return; - - DateTime rangeEnd = _tempdbStatsToDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = _tempdbStatsFromDate ?? rangeEnd.AddHours(-_tempdbStatsHoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - var tempDbData = await _databaseService.GetFileIoLatencyTimeSeriesAsync(isTempDb: true, _tempdbStatsHoursBack, _tempdbStatsFromDate, _tempdbStatsToDate); - LoadCombinedTempDbLatencyChart(tempDbData, xMin, xMax); - } - - private void LoadCombinedTempDbLatencyChart(List data, double xMin, double xMax) - { - DateTime rangeStart = DateTime.FromOADate(xMin); - DateTime rangeEnd = DateTime.FromOADate(xMax); - - // Remove previously stored legend panel by reference (ScottPlot issue #4717) - if (_legendPanels.TryGetValue(TempDbLatencyChart, out var existingPanel) && existingPanel != null) - { - TempDbLatencyChart.Plot.Axes.Remove(existingPanel); - _legendPanels[TempDbLatencyChart] = null; - } - TempDbLatencyChart.Plot.Clear(); - _tempDbLatencyHover?.Clear(); - TabHelpers.ApplyThemeToChart(TempDbLatencyChart); - - if (data != null && data.Count > 0) - { - // Aggregate all TempDB files into single read/write latency values per time point - var aggregated = data - .GroupBy(d => d.CollectionTime) - .OrderBy(g => g.Key) - .Select(g => new - { - Time = g.Key, - AvgReadLatency = g.Average(x => (double)x.ReadLatencyMs), - AvgWriteLatency = g.Average(x => (double)x.WriteLatencyMs) - }) - .ToList(); - - // Read Latency series - var (readXs, readYs) = TabHelpers.FillTimeSeriesGaps( - aggregated.Select(d => d.Time), - aggregated.Select(d => d.AvgReadLatency)); - var readScatter = TempDbLatencyChart.Plot.Add.Scatter(readXs, readYs); - readScatter.LineWidth = 2; - readScatter.MarkerSize = 5; - readScatter.Color = TabHelpers.ChartColors[0]; - readScatter.LegendText = "Read Latency"; - _tempDbLatencyHover?.Add(readScatter, "Read Latency"); - - // Write Latency series - var (writeXs, writeYs) = TabHelpers.FillTimeSeriesGaps( - aggregated.Select(d => d.Time), - aggregated.Select(d => d.AvgWriteLatency)); - var writeScatter = TempDbLatencyChart.Plot.Add.Scatter(writeXs, writeYs); - writeScatter.LineWidth = 2; - writeScatter.MarkerSize = 5; - writeScatter.Color = TabHelpers.ChartColors[2]; - writeScatter.LegendText = "Write Latency"; - _tempDbLatencyHover?.Add(writeScatter, "Write Latency"); - - // Store legend panel reference for removal on refresh (ScottPlot issue #4717) - _legendPanels[TempDbLatencyChart] = TempDbLatencyChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - TempDbLatencyChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = TempDbLatencyChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - TempDbLatencyChart.Plot.Axes.DateTimeTicksBottomDateChange(); - TempDbLatencyChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(TempDbLatencyChart); - TempDbLatencyChart.Plot.YLabel("Latency (ms)"); - TabHelpers.LockChartVerticalAxis(TempDbLatencyChart); - TempDbLatencyChart.Refresh(); - } - - private void LoadTempdbStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(TempdbStatsChart, out var existingTempdbPanel) && existingTempdbPanel != null) - { - TempdbStatsChart.Plot.Axes.Remove(existingTempdbPanel); - _legendPanels[TempdbStatsChart] = null; - } - TempdbStatsChart.Plot.Clear(); - _tempdbStatsHover?.Clear(); - TabHelpers.ApplyThemeToChart(TempdbStatsChart); - - var dataList = data?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - if (dataList.Count > 0) - { - // User Objects series - var (userXs, userYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.UserObjectReservedMb)); - var userScatter = TempdbStatsChart.Plot.Add.Scatter(userXs, userYs); - userScatter.LineWidth = 2; - userScatter.MarkerSize = 5; - userScatter.Color = TabHelpers.ChartColors[0]; - userScatter.LegendText = "User Objects"; - _tempdbStatsHover?.Add(userScatter, "User Objects"); - - // Version Store series - var (versionXs, versionYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.VersionStoreReservedMb)); - var versionScatter = TempdbStatsChart.Plot.Add.Scatter(versionXs, versionYs); - versionScatter.LineWidth = 2; - versionScatter.MarkerSize = 5; - versionScatter.Color = TabHelpers.ChartColors[1]; - versionScatter.LegendText = "Version Store"; - _tempdbStatsHover?.Add(versionScatter, "Version Store"); - - // Internal Objects series - var (internalXs, internalYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.InternalObjectReservedMb)); - var internalScatter = TempdbStatsChart.Plot.Add.Scatter(internalXs, internalYs); - internalScatter.LineWidth = 2; - internalScatter.MarkerSize = 5; - internalScatter.Color = TabHelpers.ChartColors[2]; - internalScatter.LegendText = "Internal Objects"; - _tempdbStatsHover?.Add(internalScatter, "Internal Objects"); - - // Unallocated (free space) series - var (unallocXs, unallocYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - dataList.Select(d => (double)d.UnallocatedMb)); - if (unallocYs.Any(y => y > 0)) - { - var unallocScatter = TempdbStatsChart.Plot.Add.Scatter(unallocXs, unallocYs); - unallocScatter.LineWidth = 2; - unallocScatter.MarkerSize = 5; - unallocScatter.Color = TabHelpers.ChartColors[9]; - unallocScatter.LegendText = "Unallocated"; - _tempdbStatsHover?.Add(unallocScatter, "Unallocated"); - } - - // Top Task Total MB series (worst session's usage) - var topTaskValues = dataList.Select(d => (double)(d.TopTaskTotalMb ?? 0)).ToArray(); - if (topTaskValues.Any(v => v > 0)) - { - var (topTaskXs, topTaskYs) = TabHelpers.FillTimeSeriesGaps( - dataList.Select(d => d.CollectionTime), - topTaskValues); - var topTaskScatter = TempdbStatsChart.Plot.Add.Scatter(topTaskXs, topTaskYs); - topTaskScatter.LineWidth = 2; - topTaskScatter.MarkerSize = 5; - topTaskScatter.Color = TabHelpers.ChartColors[3]; - topTaskScatter.LegendText = "Top Task"; - } - - // Update summary panel with latest data point - var latestData = dataList.LastOrDefault(); - UpdateTempdbStatsSummary(latestData); - - _legendPanels[TempdbStatsChart] = TempdbStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - TempdbStatsChart.Plot.Legend.FontSize = 12; - } - else - { - UpdateTempdbStatsSummary(null); - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = TempdbStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - TempdbStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - TempdbStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); - TempdbStatsChart.Plot.Axes.AutoScaleY(); - TempdbStatsChart.Plot.YLabel("MB"); - TabHelpers.LockChartVerticalAxis(TempdbStatsChart); - TempdbStatsChart.Refresh(); - } - - private void UpdateTempdbStatsSummary(TempdbStatsItem? data) - { - if (data != null) - { - TempdbSessionsText.Text = $"{data.TotalSessionsUsingTempdb} ({data.SessionsWithUserObjects} user, {data.SessionsWithInternalObjects} internal)"; - - var warnings = new System.Collections.Generic.List(); - if (data.VersionStoreHighWarning) warnings.Add("Version Store High"); - if (data.AllocationContentionWarning) warnings.Add("Allocation Contention"); - TempdbWarningsText.Text = warnings.Count > 0 ? string.Join(", ", warnings) : "None"; - TempdbWarningsText.Foreground = warnings.Count > 0 - ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.OrangeRed) - : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - else - { - TempdbSessionsText.Text = "N/A"; - TempdbWarningsText.Text = "N/A"; - TempdbWarningsText.Foreground = (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - } - - #endregion - - #region Session Stats Tab - - private async Task RefreshSessionStatsAsync() - { - if (_databaseService == null) return; - - try - { - var data = await _databaseService.GetSessionStatsAsync(_sessionStatsHoursBack, _sessionStatsFromDate, _sessionStatsToDate); - LoadSessionStatsChart(data, _sessionStatsHoursBack, _sessionStatsFromDate, _sessionStatsToDate); - } - catch (Exception ex) - { - Logger.Error($"Error loading session stats: {ex.Message}", ex); - } - } - - private void LoadSessionStatsChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(SessionStatsChart, out var existingSessionPanel) && existingSessionPanel != null) - { - SessionStatsChart.Plot.Axes.Remove(existingSessionPanel); - _legendPanels[SessionStatsChart] = null; - } - SessionStatsChart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(SessionStatsChart); - _sessionStatsHover?.Clear(); - - var dataList = data?.OrderBy(d => d.CollectionTime).ToList() ?? new List(); - if (dataList.Count > 0) - { - var timePoints = dataList.Select(d => d.CollectionTime); - double[] totalCounts = dataList.Select(d => (double)d.TotalSessions).ToArray(); - double[] runningCounts = dataList.Select(d => (double)d.RunningSessions).ToArray(); - double[] sleepingCounts = dataList.Select(d => (double)d.SleepingSessions).ToArray(); - - if (totalCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, totalCounts.Select(c => c)); - var totalScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - totalScatter.LineWidth = 2; - totalScatter.MarkerSize = 5; - totalScatter.Color = TabHelpers.ChartColors[0]; - totalScatter.LegendText = "Total"; - _sessionStatsHover?.Add(totalScatter, "Total"); - } - - if (runningCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, runningCounts.Select(c => c)); - var runningScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - runningScatter.LineWidth = 2; - runningScatter.MarkerSize = 5; - runningScatter.Color = TabHelpers.ChartColors[1]; - runningScatter.LegendText = "Running"; - _sessionStatsHover?.Add(runningScatter, "Running"); - } - - if (sleepingCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, sleepingCounts.Select(c => c)); - var sleepingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - sleepingScatter.LineWidth = 2; - sleepingScatter.MarkerSize = 5; - sleepingScatter.Color = TabHelpers.ChartColors[2]; - sleepingScatter.LegendText = "Sleeping"; - _sessionStatsHover?.Add(sleepingScatter, "Sleeping"); - } - - double[] backgroundCounts = dataList.Select(d => (double)d.BackgroundSessions).ToArray(); - if (backgroundCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, backgroundCounts.Select(c => c)); - var backgroundScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - backgroundScatter.LineWidth = 2; - backgroundScatter.MarkerSize = 5; - backgroundScatter.Color = TabHelpers.ChartColors[4]; - backgroundScatter.LegendText = "Background"; - _sessionStatsHover?.Add(backgroundScatter, "Background"); - } - - double[] dormantCounts = dataList.Select(d => (double)d.DormantSessions).ToArray(); - if (dormantCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, dormantCounts.Select(c => c)); - var dormantScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - dormantScatter.LineWidth = 2; - dormantScatter.MarkerSize = 5; - dormantScatter.Color = TabHelpers.ChartColors[5]; - dormantScatter.LegendText = "Dormant"; - _sessionStatsHover?.Add(dormantScatter, "Dormant"); - } - - double[] idleOver30MinCounts = dataList.Select(d => (double)d.IdleSessionsOver30Min).ToArray(); - if (idleOver30MinCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, idleOver30MinCounts.Select(c => c)); - var idleScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - idleScatter.LineWidth = 2; - idleScatter.MarkerSize = 5; - idleScatter.Color = TabHelpers.ChartColors[9]; - idleScatter.LegendText = "Idle >30m"; - _sessionStatsHover?.Add(idleScatter, "Idle >30m"); - } - - double[] waitingForMemoryCounts = dataList.Select(d => (double)d.SessionsWaitingForMemory).ToArray(); - if (waitingForMemoryCounts.Any(c => c > 0)) - { - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, waitingForMemoryCounts.Select(c => c)); - var waitingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys); - waitingScatter.LineWidth = 2; - waitingScatter.MarkerSize = 5; - waitingScatter.Color = TabHelpers.ChartColors[3]; - waitingScatter.LegendText = "Waiting for Memory"; - _sessionStatsHover?.Add(waitingScatter, "Waiting for Memory"); - } - - // Update summary panel with latest data point - var latestData = dataList.LastOrDefault(); - UpdateSessionStatsSummary(latestData); - - _legendPanels[SessionStatsChart] = SessionStatsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - SessionStatsChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = SessionStatsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - UpdateSessionStatsSummary(null); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - SessionStatsChart.Plot.Axes.DateTimeTicksBottomDateChange(); - SessionStatsChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(SessionStatsChart); - SessionStatsChart.Plot.YLabel("Session Count"); - TabHelpers.LockChartVerticalAxis(SessionStatsChart); - SessionStatsChart.Refresh(); - } - - private void UpdateSessionStatsSummary(SessionStatsItem? data) - { - if (data != null) - { - SessionStatsTopAppText.Text = !string.IsNullOrEmpty(data.TopApplicationName) - ? $"{data.TopApplicationName} ({data.TopApplicationConnections ?? 0})" - : "N/A"; - SessionStatsTopHostText.Text = !string.IsNullOrEmpty(data.TopHostName) - ? $"{data.TopHostName} ({data.TopHostConnections ?? 0})" - : "N/A"; - SessionStatsDatabasesText.Text = data.DatabasesWithConnections.ToString(CultureInfo.CurrentCulture); - } - else - { - SessionStatsTopAppText.Text = "N/A"; - SessionStatsTopHostText.Text = "N/A"; - SessionStatsDatabasesText.Text = "N/A"; - } - } - - #endregion - - #region File I/O Latency Tab - - private async Task LoadFileIoLatencyChartsAsync() - { - if (_databaseService == null) return; - - DateTime rangeEnd = _fileIoToDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = _fileIoFromDate ?? rangeEnd.AddHours(-_fileIoHoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - var colors = TabHelpers.ChartColors; - - // Load User DB data only - TempDB latency moved to TempDB Stats tab - var userDbData = await _databaseService.GetFileIoLatencyTimeSeriesAsync(isTempDb: false, _fileIoHoursBack, _fileIoFromDate, _fileIoToDate); - LoadFileIoChart(UserDbReadLatencyChart, userDbData, d => d.ReadLatencyMs, "Read Latency (ms)", colors, xMin, xMax, _fileIoReadHover, d => d.ReadQueuedLatencyMs); - LoadFileIoChart(UserDbWriteLatencyChart, userDbData, d => d.WriteLatencyMs, "Write Latency (ms)", colors, xMin, xMax, _fileIoWriteHover, d => d.WriteQueuedLatencyMs); - } - - private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List data, Func latencySelector, string yLabel, ScottPlot.Color[] colors, double xMin, double xMax, Helpers.ChartHoverHelper? hover = null, Func? queuedSelector = null) - { - DateTime rangeStart = DateTime.FromOADate(xMin); - DateTime rangeEnd = DateTime.FromOADate(xMax); - - // Remove previously stored legend panel by reference (ScottPlot issue #4717) - if (_legendPanels.TryGetValue(chart, out var existingPanel) && existingPanel != null) - { - chart.Plot.Axes.Remove(existingPanel); - _legendPanels[chart] = null; - } - chart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(chart); - hover?.Clear(); - - // Check if any queued data exists (only render overlay if there's real data) - bool hasQueuedData = queuedSelector != null && data != null && data.Any(d => queuedSelector(d) > 0); - - if (data != null && data.Count > 0) - { - // Get all unique time points for gap filling - // Group by file (database + filename) - var fileGroups = data.GroupBy(d => $"{d.DatabaseName}.{d.FileName}") - .Where(g => g.Any(x => latencySelector(x) > 0)) - .OrderByDescending(g => g.Average(x => (double)latencySelector(x))) - .Take(10) - .ToList(); - - int colorIndex = 0; - foreach (var group in fileGroups) - { - var fileData = group.OrderBy(d => d.CollectionTime).ToList(); - if (fileData.Count >= 1) - { - var timePoints = fileData.Select(d => d.CollectionTime); - var values = fileData.Select(d => (double)latencySelector(d)); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = chart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - var color = colors[colorIndex % colors.Length]; - scatter.Color = color; - - // Use just the filename for legend (not database.filename which is redundant) - var fileName = fileData.First().FileName; - scatter.LegendText = fileName; - hover?.Add(scatter, fileName); - - // Add queued I/O overlay as dashed line with same color - if (hasQueuedData) - { - var queuedValues = fileData.Select(d => (double)queuedSelector!(d)); - if (queuedValues.Any(v => v > 0)) - { - var (qxs, qys) = TabHelpers.FillTimeSeriesGaps(timePoints, queuedValues); - var queuedScatter = chart.Plot.Add.Scatter(qxs, qys); - queuedScatter.LineWidth = 2; - queuedScatter.MarkerSize = 0; - queuedScatter.Color = color; - queuedScatter.LinePattern = ScottPlot.LinePattern.Dashed; - queuedScatter.LegendText = $"{fileName} (queued)"; - hover?.Add(queuedScatter, $"{fileName} (queued)"); - } - } - - colorIndex++; - } - } - - if (fileGroups.Count > 0) - { - // Store legend panel reference for removal on refresh (ScottPlot issue #4717) - _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - chart.Plot.Legend.FontSize = 12; - } - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = chart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - chart.Plot.Axes.DateTimeTicksBottomDateChange(); - chart.Plot.Axes.SetLimitsX(xMin, xMax); - chart.Plot.YLabel(yLabel); - TabHelpers.LockChartVerticalAxis(chart); - chart.Refresh(); - } - - private async Task LoadFileIoThroughputChartsAsync() - { - if (_databaseService == null) return; - - DateTime rangeEnd = _fileIoToDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = _fileIoFromDate ?? rangeEnd.AddHours(-_fileIoHoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - var colors = TabHelpers.ChartColors; - - var throughputData = await _databaseService.GetFileIoThroughputTimeSeriesAsync(isTempDb: false, _fileIoHoursBack, _fileIoFromDate, _fileIoToDate); - LoadFileIoChart(FileIoReadThroughputChart, throughputData, d => d.ReadThroughputMbPerSec, "Read Throughput (MB/s)", colors, xMin, xMax, _fileIoReadThroughputHover); - LoadFileIoChart(FileIoWriteThroughputChart, throughputData, d => d.WriteThroughputMbPerSec, "Write Throughput (MB/s)", colors, xMin, xMax, _fileIoWriteThroughputHover); - } - - #endregion - #region Server Trends Tab private (DateTime From, DateTime To)? ComparisonRange { get; set; } @@ -1066,765 +350,6 @@ private async Task RefreshServerTrendsAsync() } #endregion - - #region Context Menu Handlers - - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var sb = new StringBuilder(); - - var headers = grid.Columns.Select(c => Helpers.DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"ResourceMetrics_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } - - #endregion - - #region Perfmon Counters Tab - - private bool _isUpdatingPerfmonSelection = false; - - private void PerfmonCountersList_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // Not used - we handle via checkbox changes instead - } - - private async void PerfmonCounter_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingPerfmonSelection) return; - RefreshPerfmonCounterListOrder(); - await UpdatePerfmonCountersChartAsync(); - } - - private void RefreshPerfmonCounterListOrder() - { - if (_perfmonCounterItems == null) return; - // Sort: checked items first, then alphabetically - var sorted = _perfmonCounterItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => x.CounterName) - .ToList(); - _perfmonCounterItems = sorted; - ApplyPerfmonCounterSearchFilter(); - } - - private void PerfmonCounterSearch_TextChanged(object sender, TextChangedEventArgs e) - { - ApplyPerfmonCounterSearchFilter(); - } - - private void ApplyPerfmonCounterSearchFilter() - { - if (_perfmonCounterItems == null) - { - PerfmonCountersList.ItemsSource = null; - return; - } - - var searchText = PerfmonCounterSearchBox?.Text?.Trim() ?? string.Empty; - if (string.IsNullOrEmpty(searchText)) - { - PerfmonCountersList.ItemsSource = null; - PerfmonCountersList.ItemsSource = _perfmonCounterItems; - } - else - { - var filtered = _perfmonCounterItems - .Where(c => c.CounterName.Contains(searchText, StringComparison.OrdinalIgnoreCase) || - c.ObjectName.Contains(searchText, StringComparison.OrdinalIgnoreCase)) - .ToList(); - PerfmonCountersList.ItemsSource = null; - PerfmonCountersList.ItemsSource = filtered; - } - } - - private async void PerfmonCounters_SelectAll_Click(object sender, RoutedEventArgs e) - { - if (_perfmonCounterItems == null) return; - _isUpdatingPerfmonSelection = true; - foreach (var item in _perfmonCounterItems) - { - item.IsSelected = true; - } - _isUpdatingPerfmonSelection = false; - await UpdatePerfmonCountersChartAsync(); - } - - private async void PerfmonCounters_ClearAll_Click(object sender, RoutedEventArgs e) - { - if (_perfmonCounterItems == null) return; - _isUpdatingPerfmonSelection = true; - foreach (var item in _perfmonCounterItems) - { - item.IsSelected = false; - } - _isUpdatingPerfmonSelection = false; - await UpdatePerfmonCountersChartAsync(); - } - - private async void PerfmonPack_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_perfmonCounterItems == null || _perfmonCounterItems.Count == 0) return; - if (PerfmonPackCombo.SelectedItem is not string pack) return; - - _isUpdatingPerfmonSelection = true; - - /* Clear search so all counters are visible */ - if (PerfmonCounterSearchBox != null) - PerfmonCounterSearchBox.Text = ""; - - /* Uncheck everything first */ - foreach (var item in _perfmonCounterItems) - item.IsSelected = false; - - if (pack == PerfmonPacks.AllCounters) - { - /* "All Counters" selects the General Throughput defaults */ - var defaultSet = new HashSet(PerfmonPacks.Packs["General Throughput"], StringComparer.OrdinalIgnoreCase); - foreach (var item in _perfmonCounterItems) - { - if (defaultSet.Contains(item.CounterName)) - item.IsSelected = true; - } - } - else if (PerfmonPacks.Packs.TryGetValue(pack, out var packCounters)) - { - var packSet = new HashSet(packCounters, StringComparer.OrdinalIgnoreCase); - int count = 0; - foreach (var item in _perfmonCounterItems) - { - if (count >= 12) break; - if (packSet.Contains(item.CounterName)) - { - item.IsSelected = true; - count++; - } - } - } - - _isUpdatingPerfmonSelection = false; - RefreshPerfmonCounterListOrder(); - await UpdatePerfmonCountersChartAsync(); - } - - private async void PerfmonCounters_Refresh_Click(object sender, RoutedEventArgs e) - { - try - { - await RefreshPerfmonCountersTabAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error refreshing perfmon counters: {ex.Message}", ex); - } - } - - private async Task RefreshPerfmonCountersTabAsync() - { - if (_databaseService == null) return; - - /* Initialize pack ComboBox once */ - if (PerfmonPackCombo.Items.Count == 0) - { - PerfmonPackCombo.ItemsSource = PerfmonPacks.PackNames; - PerfmonPackCombo.SelectedItem = "General Throughput"; - } - - try - { - // Lightweight query: get only distinct counter names for the picker - var counterNames = await _databaseService.GetPerfmonCounterNamesAsync(_perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); - - // Remember previously selected counters - var previouslySelected = _perfmonCounterItems?.Where(x => x.IsSelected).Select(x => x.FullName).ToHashSet() ?? new HashSet(); - - // Build unique counter list from lightweight query - var counters = counterNames - .OrderBy(c => c.ObjectName) - .ThenBy(c => c.CounterName) - .Select(c => new PerfmonCounterSelectionItem - { - ObjectName = c.ObjectName, - CounterName = c.CounterName, - IsSelected = previouslySelected.Contains($"{c.ObjectName} - {c.CounterName}") - }) - .ToList(); - - // If nothing was previously selected, default select General Throughput pack - if (!counters.Any(c => c.IsSelected)) - { - var defaultCounters = PerfmonPacks.Packs["General Throughput"]; - var defaultSet = new HashSet(defaultCounters, StringComparer.OrdinalIgnoreCase); - foreach (var item in counters.Where(c => defaultSet.Contains(c.CounterName))) - { - item.IsSelected = true; - } - } - - _perfmonCounterItems = counters; - // Sort so checked items appear at top - RefreshPerfmonCounterListOrder(); - - // Fetch data only for selected counters - await UpdatePerfmonCountersChartAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error loading perfmon counters: {ex.Message}"); - } - } - - private async Task UpdatePerfmonCountersChartAsync() - { - if (_databaseService == null || _perfmonCounterItems == null) return; - - var selectedCounterNames = _perfmonCounterItems - .Where(x => x.IsSelected) - .Select(x => x.CounterName) - .Distinct() - .ToArray(); - - if (selectedCounterNames.Length == 0) - { - _allPerfmonCountersData = new List(); - } - else - { - var data = await _databaseService.GetPerfmonStatsFilteredAsync( - selectedCounterNames, _perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); - _allPerfmonCountersData = data?.ToList() ?? new List(); - } - - LoadPerfmonCountersChart(_allPerfmonCountersData, _perfmonCountersHoursBack, _perfmonCountersFromDate, _perfmonCountersToDate); - } - - private void LoadPerfmonCountersChart(List? data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(PerfmonCountersChart, out var existingPerfmonPanel) && existingPerfmonPanel != null) - { - PerfmonCountersChart.Plot.Axes.Remove(existingPerfmonPanel); - _legendPanels[PerfmonCountersChart] = null; - } - PerfmonCountersChart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(PerfmonCountersChart); - _perfmonHover?.Clear(); - - if (data == null || data.Count == 0 || _perfmonCounterItems == null) - { - PerfmonCountersChart.Refresh(); - return; - } - - // Get selected counters - var selectedCounters = _perfmonCounterItems.Where(x => x.IsSelected).ToList(); - if (selectedCounters.Count == 0) - { - PerfmonCountersChart.Refresh(); - return; - } - - var colors = TabHelpers.ChartColors; - - // Get all time points across all counters for gap filling - int colorIndex = 0; - foreach (var counter in selectedCounters.Take(12)) // Limit to 12 counters - { - // Get data for this counter (aggregated across all instances) - var counterData = data - .Where(d => d.ObjectName == counter.ObjectName && d.CounterName == counter.CounterName) - .GroupBy(d => d.CollectionTime) - .Select(g => new { - CollectionTime = g.Key, - Value = g.Sum(x => x.CntrValuePerSecond ?? x.CntrValueDelta ?? x.CntrValue) - }) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (counterData.Count >= 1) - { - var timePoints = counterData.Select(d => d.CollectionTime); - var values = counterData.Select(d => (double)d.Value); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = PerfmonCountersChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; // Show small markers to ensure visibility - scatter.Color = colors[colorIndex % colors.Length]; - scatter.LegendText = counter.CounterName; - _perfmonHover?.Add(scatter, counter.CounterName); - - colorIndex++; - } - } - - if (colorIndex > 0) - { - _legendPanels[PerfmonCountersChart] = PerfmonCountersChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - PerfmonCountersChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = PerfmonCountersChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - PerfmonCountersChart.Plot.Axes.DateTimeTicksBottomDateChange(); - PerfmonCountersChart.Plot.Axes.SetLimitsX(xMin, xMax); - TabHelpers.SetChartYLimitsWithLegendPadding(PerfmonCountersChart); - PerfmonCountersChart.Plot.YLabel("Value/sec"); - TabHelpers.LockChartVerticalAxis(PerfmonCountersChart); - PerfmonCountersChart.Refresh(); - } - - #endregion - - #region Wait Stats Detail Tab - - private void WaitTypesList_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // Not used - we handle via checkbox changes instead - } - - private async void WaitType_CheckChanged(object sender, RoutedEventArgs e) - { - if (_isUpdatingWaitTypeSelection) return; - RefreshWaitTypeListOrder(); - await UpdateWaitStatsDetailChartAsync(); - } - - private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu) - { - contextMenu.Items.Insert(0, new Separator()); - var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" }; - drillDownItem.Click += ShowQueriesForWaitType_Click; - contextMenu.Items.Insert(0, drillDownItem); - - contextMenu.Opened += (s, _) => - { - var pos = System.Windows.Input.Mouse.GetPosition(chart); - var nearest = _waitStatsHover?.GetNearestSeries(pos); - if (nearest.HasValue) - { - drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time); - drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}"; - drillDownItem.IsEnabled = true; - } - else - { - drillDownItem.Tag = null; - drillDownItem.Header = "Show Queries With This Wait"; - drillDownItem.IsEnabled = false; - } - }; - } - - private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - if (menuItem.Tag is not ValueTuple tag) return; - if (_databaseService == null) return; - - // ±15 minute window around the clicked point - var fromDate = tag.Item2.AddMinutes(-15); - var toDate = tag.Item2.AddMinutes(15); - - var window = new WaitDrillDownWindow( - _databaseService, tag.Item1, 1, fromDate, toDate); - window.Owner = Window.GetWindow(this); - window.ShowDialog(); - } - - private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - if (_allWaitStatsDetailData != null) - LoadWaitStatsDetailChart(_allWaitStatsDetailData, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); - } - - private void RefreshWaitTypeListOrder() - { - if (_waitTypeItems == null) return; - // Sort: checked items first, then alphabetically - var sorted = _waitTypeItems - .OrderByDescending(x => x.IsSelected) - .ThenBy(x => x.WaitType) - .ToList(); - _waitTypeItems = sorted; - ApplyWaitTypeSearchFilter(); - UpdateWaitTypeCount(); - } - - private void UpdateWaitTypeCount() - { - if (_waitTypeItems == null || WaitTypeCountText == null) return; - int count = _waitTypeItems.Count(x => x.IsSelected); - WaitTypeCountText.Text = $"{count} / 30 selected"; - WaitTypeCountText.Foreground = count >= 30 - ? new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#E57373")!) - : (System.Windows.Media.Brush)FindResource("ForegroundBrush"); - } - - private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e) - { - ApplyWaitTypeSearchFilter(); - } - - private void ApplyWaitTypeSearchFilter() - { - if (_waitTypeItems == null) - { - WaitTypesList.ItemsSource = null; - return; - } - - var searchText = WaitTypeSearchBox?.Text?.Trim() ?? string.Empty; - if (string.IsNullOrEmpty(searchText)) - { - WaitTypesList.ItemsSource = null; - WaitTypesList.ItemsSource = _waitTypeItems; - } - else - { - var filtered = _waitTypeItems - .Where(c => c.WaitType.Contains(searchText, StringComparison.OrdinalIgnoreCase)) - .ToList(); - WaitTypesList.ItemsSource = null; - WaitTypesList.ItemsSource = filtered; - } - } - - private async void WaitTypes_SelectAll_Click(object sender, RoutedEventArgs e) - { - if (_waitTypeItems == null) return; - _isUpdatingWaitTypeSelection = true; - var topWaits = TabHelpers.GetDefaultWaitTypes(_waitTypeItems.Select(x => x.WaitType).ToList()); - foreach (var item in _waitTypeItems) - { - item.IsSelected = topWaits.Contains(item.WaitType); - } - _isUpdatingWaitTypeSelection = false; - RefreshWaitTypeListOrder(); - await UpdateWaitStatsDetailChartAsync(); - } - - private async void WaitTypes_ClearAll_Click(object sender, RoutedEventArgs e) - { - if (_waitTypeItems == null) return; - _isUpdatingWaitTypeSelection = true; - foreach (var item in _waitTypeItems) - { - item.IsSelected = false; - } - _isUpdatingWaitTypeSelection = false; - RefreshWaitTypeListOrder(); - await UpdateWaitStatsDetailChartAsync(); - } - - private async void WaitStatsDetail_Refresh_Click(object sender, RoutedEventArgs e) - { - try - { - await RefreshWaitStatsDetailTabAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error refreshing wait stats detail: {ex.Message}", ex); - } - } - - private async Task RefreshWaitStatsDetailTabAsync() - { - if (_databaseService == null) return; - - try - { - // Lightweight query: get only distinct wait type names for the picker - var waitTypeNames = await _databaseService.GetWaitTypeNamesAsync(_waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); - - // Remember previously selected wait types - var previouslySelected = _waitTypeItems?.Where(x => x.IsSelected).Select(x => x.WaitType).ToHashSet() ?? new HashSet(); - - // Build unique wait type list, sorted by total wait time descending - var waitTypes = waitTypeNames - .Select(w => new WaitTypeSelectionItem - { - WaitType = w.WaitType, - IsSelected = previouslySelected.Contains(w.WaitType) - }) - .ToList(); - - // Ensure poison waits are always in the picker even if they have no collected data - foreach (var poisonWait in TabHelpers.PoisonWaits) - { - if (!waitTypes.Any(w => string.Equals(w.WaitType, poisonWait, StringComparison.OrdinalIgnoreCase))) - { - waitTypes.Add(new WaitTypeSelectionItem - { - WaitType = poisonWait, - IsSelected = previouslySelected.Contains(poisonWait) - }); - } - } - - // If nothing was previously selected, apply poison waits + usual suspects + top 10 - if (!waitTypes.Any(w => w.IsSelected)) - { - var topWaits = TabHelpers.GetDefaultWaitTypes(waitTypes.Select(w => w.WaitType).ToList()); - foreach (var item in waitTypes.Where(w => topWaits.Contains(w.WaitType))) - { - item.IsSelected = true; - } - } - - _waitTypeItems = waitTypes; - // Sort so checked items appear at top - RefreshWaitTypeListOrder(); - - // Fetch data only for selected wait types - await UpdateWaitStatsDetailChartAsync(); - } - catch (Exception ex) - { - Logger.Error($"Error loading wait stats detail: {ex.Message}"); - } - } - - private async Task UpdateWaitStatsDetailChartAsync() - { - if (_databaseService == null || _waitTypeItems == null) return; - - var selectedWaitTypes = _waitTypeItems - .Where(x => x.IsSelected) - .Select(x => x.WaitType) - .ToArray(); - - if (selectedWaitTypes.Length == 0) - { - _allWaitStatsDetailData = new List(); - } - else - { - var data = await _databaseService.GetWaitStatsDataForTypesAsync( - selectedWaitTypes, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); - _allWaitStatsDetailData = data?.ToList() ?? new List(); - } - - LoadWaitStatsDetailChart(_allWaitStatsDetailData, _waitStatsDetailHoursBack, _waitStatsDetailFromDate, _waitStatsDetailToDate); - } - - private void LoadWaitStatsDetailChart(List? data, int hoursBack, DateTime? fromDate, DateTime? toDate) - { - DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; - DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); - double xMin = rangeStart.ToOADate(); - double xMax = rangeEnd.ToOADate(); - - if (_legendPanels.TryGetValue(WaitStatsDetailChart, out var existingWaitStatsPanel) && existingWaitStatsPanel != null) - { - WaitStatsDetailChart.Plot.Axes.Remove(existingWaitStatsPanel); - _legendPanels[WaitStatsDetailChart] = null; - } - bool useAvgPerWait = WaitStatsMetricCombo?.SelectedIndex == 1; - - WaitStatsDetailChart.Plot.Clear(); - TabHelpers.ApplyThemeToChart(WaitStatsDetailChart); - _waitStatsHover?.Clear(); - if (_waitStatsHover != null) _waitStatsHover.Unit = useAvgPerWait ? "ms/wait" : "ms/sec"; - - if (data == null || data.Count == 0 || _waitTypeItems == null) - { - WaitStatsDetailChart.Refresh(); - return; - } - - // Get selected wait types - var selectedWaitTypes = _waitTypeItems.Where(x => x.IsSelected).ToList(); - if (selectedWaitTypes.Count == 0) - { - WaitStatsDetailChart.Refresh(); - return; - } - var colors = TabHelpers.ChartColors; - - // Get all time points across all wait types for gap filling - int colorIndex = 0; - foreach (var waitType in selectedWaitTypes.Take(20)) // Limit to 20 wait types - { - // Get data for this wait type - var waitTypeData = data - .Where(d => d.WaitType == waitType.WaitType) - .GroupBy(d => d.CollectionTime) - .Select(g => new { - CollectionTime = g.Key, - WaitTimeMsPerSecond = g.Sum(x => x.WaitTimeMsPerSecond), - AvgMsPerWait = g.Average(x => x.AvgMsPerWait) - }) - .OrderBy(d => d.CollectionTime) - .ToList(); - - if (waitTypeData.Count >= 1) - { - var timePoints = waitTypeData.Select(d => d.CollectionTime); - var values = useAvgPerWait - ? waitTypeData.Select(d => (double)d.AvgMsPerWait) - : waitTypeData.Select(d => (double)d.WaitTimeMsPerSecond); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, values); - - var scatter = WaitStatsDetailChart.Plot.Add.Scatter(xs, ys); - scatter.LineWidth = 2; - scatter.MarkerSize = 5; - scatter.Color = colors[colorIndex % colors.Length]; - - // Truncate legend text if too long - string legendText = waitType.WaitType; - if (legendText.Length > 25) - legendText = legendText.Substring(0, 22) + "..."; - scatter.LegendText = legendText; - _waitStatsHover?.Add(scatter, waitType.WaitType); - - colorIndex++; - } - } - - if (colorIndex > 0) - { - _legendPanels[WaitStatsDetailChart] = WaitStatsDetailChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); - WaitStatsDetailChart.Plot.Legend.FontSize = 12; - } - else - { - double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = WaitStatsDetailChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); - noDataText.LabelFontSize = 14; - noDataText.LabelFontColor = ScottPlot.Colors.Gray; - noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; - } - - 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)"); - TabHelpers.LockChartVerticalAxis(WaitStatsDetailChart); - WaitStatsDetailChart.Refresh(); - } - - #endregion } /// diff --git a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs new file mode 100644 index 0000000..d192a67 --- /dev/null +++ b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class SystemEventsContent : UserControl + { + #region CPU Tasks Tab + + private async System.Threading.Tasks.Task RefreshCPUTasksAsync() + { + if (_databaseService == null) return; + + try + { + var data = await _databaseService.GetHealthParserCPUTasksAsync(_cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); + // Grid removed per todo.md #15 - chart + summary only + LoadCPUTasksChart(data, _cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); + UpdateCPUTasksSummaryPanel(data); + } + catch (Exception ex) + { + Logger.Error($"Error loading CPU tasks: {ex.Message}", ex); + } + } + + private void LoadCPUTasksChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + if (_legendPanels.TryGetValue(CPUTasksChart, out var existingCPUTasksPanel) && existingCPUTasksPanel != null) + { + CPUTasksChart.Plot.Axes.Remove(existingCPUTasksPanel); + _legendPanels[CPUTasksChart] = null; + } + CPUTasksChart.Plot.Clear(); + _cpuTasksHover?.Clear(); + TabHelpers.ApplyThemeToChart(CPUTasksChart); + + var dataList = data?.Where(d => d.EventTime.HasValue).ToList() ?? new List(); + bool hasData = false; + if (dataList.Count > 0) + { + // Group by hour + var grouped = dataList + .GroupBy(d => new DateTime(d.EventTime!.Value.Year, d.EventTime!.Value.Month, d.EventTime!.Value.Day, d.EventTime!.Value.Hour, 0, 0)) + .OrderBy(g => g.Key) + .ToList(); + + if (grouped.Count > 0) + { + hasData = true; + var timePoints = grouped.Select(g => g.Key); + + // Workers Created series + double[] workersCreated = grouped.Select(g => (double)g.Max(i => i.WorkersCreated ?? 0)).ToArray(); + var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, workersCreated.Select(c => c)); + var scatter = CPUTasksChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = TabHelpers.ChartColors[0]; + scatter.LegendText = "Workers Created"; + _cpuTasksHover?.Add(scatter, "Workers Created"); + + // Max Workers threshold line (horizontal) + var maxWorkersValue = dataList.Max(d => d.MaxWorkers ?? 0); + if (maxWorkersValue > 0) + { + var hLine = CPUTasksChart.Plot.Add.HorizontalLine(maxWorkersValue); + hLine.Color = TabHelpers.ChartColors[2]; + hLine.LineWidth = 2; + hLine.LinePattern = ScottPlot.LinePattern.Dashed; + hLine.LegendText = $"Max Workers ({maxWorkersValue})"; + } + + // Add scatter point markers for serious issues (grouped by hour at Y=0) + // Unresolvable Deadlocks - red points + var unresolvableDLByHour = dataList + .Where(d => d.HasUnresolvableDeadlockOccurred == true && d.EventTime.HasValue) + .GroupBy(d => new DateTime(d.EventTime!.Value.Year, d.EventTime!.Value.Month, d.EventTime!.Value.Day, d.EventTime!.Value.Hour, 0, 0)) + .Select(g => new { Time = g.Key, Count = g.Count() }) + .OrderBy(x => x.Time) + .ToList(); + if (unresolvableDLByHour.Count > 0) + { + var dlXs = unresolvableDLByHour.Select(b => b.Time.ToOADate()).ToArray(); + var dlYs = unresolvableDLByHour.Select(b => 0.0).ToArray(); + var dlScatter = CPUTasksChart.Plot.Add.Scatter(dlXs, dlYs); + dlScatter.LineWidth = 0; + dlScatter.Color = TabHelpers.ChartColors[3]; + dlScatter.LegendText = "Unresolvable DL"; + dlScatter.MarkerSize = 10; + dlScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle; + } + + // Scheduler Deadlocks - orange points + var schedDLByHour = dataList + .Where(d => d.HasDeadlockedSchedulersOccurred == true && d.EventTime.HasValue) + .GroupBy(d => new DateTime(d.EventTime!.Value.Year, d.EventTime!.Value.Month, d.EventTime!.Value.Day, d.EventTime!.Value.Hour, 0, 0)) + .Select(g => new { Time = g.Key, Count = g.Count() }) + .OrderBy(x => x.Time) + .ToList(); + if (schedDLByHour.Count > 0) + { + var schedXs = schedDLByHour.Select(b => b.Time.ToOADate()).ToArray(); + var schedYs = schedDLByHour.Select(b => 0.0).ToArray(); + var schedScatter = CPUTasksChart.Plot.Add.Scatter(schedXs, schedYs); + schedScatter.LineWidth = 0; + schedScatter.Color = TabHelpers.ChartColors[2]; + schedScatter.LegendText = "Sched Deadlock"; + schedScatter.MarkerSize = 10; + schedScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle; + } + + // Blocking events - yellow points sized by count per hour + var blockingByHour = dataList + .Where(d => d.DidBlockingOccur == true && d.EventTime.HasValue) + .GroupBy(d => new DateTime(d.EventTime!.Value.Year, d.EventTime!.Value.Month, d.EventTime!.Value.Day, d.EventTime!.Value.Hour, 0, 0)) + .Select(g => new { Time = g.Key, Count = g.Count() }) + .OrderBy(x => x.Time) + .ToList(); + if (blockingByHour.Count > 0) + { + // Place markers at Y=0 (bottom of chart) + var blockingXs = blockingByHour.Select(b => b.Time.ToOADate()).ToArray(); + var blockingYs = blockingByHour.Select(b => 0.0).ToArray(); // At bottom + var blockingScatter = CPUTasksChart.Plot.Add.Scatter(blockingXs, blockingYs); + blockingScatter.LineWidth = 0; // No connecting line + blockingScatter.Color = TabHelpers.ChartColors[6]; + blockingScatter.LegendText = "Blocking"; + // Size points based on count - min 8, max 20, scaled by count + var maxCount = blockingByHour.Max(b => b.Count); + var sizes = blockingByHour.Select(b => 8f + (12f * b.Count / Math.Max(maxCount, 1))).ToArray(); + // ScottPlot 5 doesn't support per-point sizes easily, so use average size + var avgSize = sizes.Average(); + blockingScatter.MarkerSize = (float)Math.Max(avgSize, 10); + blockingScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle; + } + + _legendPanels[CPUTasksChart] = CPUTasksChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + CPUTasksChart.Plot.Legend.FontSize = 12; + } + } + + if (!hasData) + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = CPUTasksChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + noDataText.LabelFontSize = 14; + noDataText.LabelFontColor = ScottPlot.Colors.Gray; + noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; + } + + CPUTasksChart.Plot.Axes.DateTimeTicksBottomDateChange(); + CPUTasksChart.Plot.Axes.SetLimitsX(xMin, xMax); + CPUTasksChart.Plot.YLabel("Workers"); + TabHelpers.LockChartVerticalAxis(CPUTasksChart); + CPUTasksChart.Refresh(); + } + + private void UpdateCPUTasksSummaryPanel(List dataList) + { + if (dataList == null || dataList.Count == 0) + { + CPUTasksUnresolvableDLText.Text = "0"; + CPUTasksSchedDLText.Text = "0"; + CPUTasksBlockingText.Text = "0"; + CPUTasksPendingNoBlockText.Text = "0"; + // Reset colors + CPUTasksUnresolvableDLText.Foreground = new System.Windows.Media.SolidColorBrush( + (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#CCCCCC")); + CPUTasksSchedDLText.Foreground = new System.Windows.Media.SolidColorBrush( + (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#CCCCCC")); + return; + } + + // Unresolvable Deadlocks count + var unresolvableDLCount = dataList.Count(d => d.HasUnresolvableDeadlockOccurred == true); + CPUTasksUnresolvableDLText.Text = unresolvableDLCount.ToString("N0", CultureInfo.CurrentCulture); + CPUTasksUnresolvableDLText.Foreground = unresolvableDLCount > 0 + ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Red) + : new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#CCCCCC")); + + // Scheduler Deadlocks count + var schedDLCount = dataList.Count(d => d.HasDeadlockedSchedulersOccurred == true); + CPUTasksSchedDLText.Text = schedDLCount.ToString("N0", CultureInfo.CurrentCulture); + CPUTasksSchedDLText.Foreground = schedDLCount > 0 + ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Colors.Red) + : new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#CCCCCC")); + + // Blocking Events count + var blockingCount = dataList.Count(d => d.DidBlockingOccur == true); + CPUTasksBlockingText.Text = blockingCount.ToString("N0", CultureInfo.CurrentCulture); + + // Pending w/o Blocking - events with pending tasks > 0 but no blocking + var pendingNoBlockCount = dataList.Count(d => (d.PendingTasks ?? 0) > 0 && d.DidBlockingOccur != true); + CPUTasksPendingNoBlockText.Text = pendingNoBlockCount.ToString("N0", CultureInfo.CurrentCulture); + } + + // CPUTasksFilter_Click removed - grid removed per todo.md #15 + + // ApplyCPUTasksFilters removed - grid removed per todo.md #15 + + // UpdateCPUTasksFilterButtonStyles removed - grid removed per todo.md #15 + + // CPUTasksFilterTextBox_TextChanged removed - grid removed per todo.md #15 + + // CPUTasksNumericFilterTextBox_TextChanged removed - grid removed per todo.md #15 + + // CPUTasksBoolFilter_Changed removed - grid removed per todo.md #15 + + // ApplyCPUTasksFilter removed - grid removed per todo.md #15 + + #endregion + } +} diff --git a/Dashboard/Controls/SystemEventsContent.CopyExport.cs b/Dashboard/Controls/SystemEventsContent.CopyExport.cs new file mode 100644 index 0000000..bdce51e --- /dev/null +++ b/Dashboard/Controls/SystemEventsContent.CopyExport.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class SystemEventsContent : UserControl + { + #region Context Menu Handlers + + private void CopyCell_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) + { + var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); + if (!string.IsNullOrEmpty(cellContent)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(cellContent, false); + } + } + } + } + + private void CopyRow_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) + { + var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); + if (!string.IsNullOrEmpty(rowText)) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(rowText, false); + } + } + } + } + + private void CopyAllRows_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid) + { + var sb = new StringBuilder(); + + // Header row + var headers = grid.Columns.Select(c => Helpers.DataGridClipboardBehavior.GetHeaderText(c)); + sb.AppendLine(string.Join("\t", headers)); + + // Data rows + foreach (var item in grid.Items) + { + var values = new List(); + foreach (var column in grid.Columns) + { + var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; + if (binding != null) + { + var prop = item.GetType().GetProperty(binding.Path.Path); + var value = prop?.GetValue(item)?.ToString() ?? string.Empty; + values.Add(value); + } + } + sb.AppendLine(string.Join("\t", values)); + } + + if (sb.Length > 0) + { + /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ + Clipboard.SetDataObject(sb.ToString(), false); + } + } + } + } + + private void ExportToCsv_Click(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) + { + if (contextMenu.PlacementTarget is DataGrid grid) + { + var dialog = new SaveFileDialog + { + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = ".csv", + FileName = $"SystemEvents_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" + }; + + if (dialog.ShowDialog() == true) + { + try + { + var sb = new StringBuilder(); + + // Header row + var sep = TabHelpers.CsvSeparator; + var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(Helpers.DataGridClipboardBehavior.GetHeaderText(c), sep)); + sb.AppendLine(string.Join(sep, headers)); + + // Data rows + foreach (var item in grid.Items) + { + var values = new List(); + foreach (var column in grid.Columns) + { + var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; + if (binding != null) + { + var prop = item.GetType().GetProperty(binding.Path.Path); + values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); + } + } + sb.AppendLine(string.Join(sep, values)); + } + + File.WriteAllText(dialog.FileName, sb.ToString()); + } + catch (Exception ex) + { + Logger.Error($"Error exporting to CSV: {ex.Message}", ex); + MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } + } + } + + #endregion + } +} diff --git a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs new file mode 100644 index 0000000..0faaea1 --- /dev/null +++ b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using Microsoft.Win32; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + + +namespace PerformanceMonitorDashboard.Controls +{ + public partial class SystemEventsContent : UserControl + { + #region Shared Filter Popup Methods + + private void ShowFilterPopup(Button button, string columnName, string targetGrid, + Dictionary filters, + Action onApplied, + Action onCleared) + { + // Create popup if needed + if (_filterPopup == null) + { + _filterPopupContent = new ColumnFilterPopup(); + _filterPopup = new Popup + { + Child = _filterPopupContent, + StaysOpen = false, + Placement = PlacementMode.Bottom, + AllowsTransparency = true + }; + } + + // Disconnect previous event handlers + _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; + + // Set up current target and reconnect handlers + _currentFilterTarget = targetGrid; + _filterPopupContent.FilterApplied += FilterPopup_FilterApplied; + _filterPopupContent.FilterCleared += FilterPopup_FilterCleared; + + // Initialize with current filter state + filters.TryGetValue(columnName, out var existingFilter); + _filterPopupContent.Initialize(columnName, existingFilter); + + // Position and show + _filterPopup.PlacementTarget = button; + _filterPopup.IsOpen = true; + } + + private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + + switch (_currentFilterTarget) + { + // SystemHealth case removed - grid removed per todo.md #18 + case "SevereErrors": + UpdateFilterState(_severeErrorsFilters, e.FilterState); + ApplySevereErrorsFilters(); + UpdateSevereErrorsFilterButtonStyles(); + break; + // IOIssues case removed - grid removed per todo.md #19 + // SchedulerIssues case removed - grid removed per todo.md #13 + // MemoryConditions case removed - grid removed per todo.md #14 + // CPUTasks case removed - grid removed per todo.md #15 + case "MemoryBroker": + UpdateFilterState(_memoryBrokerFilters, e.FilterState); + ApplyMemoryBrokerFilters(); + UpdateMemoryBrokerFilterButtonStyles(); + break; + // MemoryNodeOOM case removed - DataGrid removed per GitHub issue #13 + } + } + + private void FilterPopup_FilterCleared(object? sender, EventArgs e) + { + if (_filterPopup != null) + _filterPopup.IsOpen = false; + } + + private void UpdateFilterState(Dictionary filters, ColumnFilterState filterState) + { + if (filterState.IsActive) + { + filters[filterState.ColumnName] = filterState; + } + else + { + filters.Remove(filterState.ColumnName); + } + } + + private void UpdateFilterButtonStyle(DataGrid dataGrid, string columnName, Dictionary filters) + { + // Find the button in the column header + foreach (var column in dataGrid.Columns) + { + if (column.Header is StackPanel stackPanel) + { + var button = stackPanel.Children.OfType