diff --git a/Dashboard/Models/BlockedSessionTrendItem.cs b/Dashboard/Models/BlockedSessionTrendItem.cs new file mode 100644 index 00000000..3ef85634 --- /dev/null +++ b/Dashboard/Models/BlockedSessionTrendItem.cs @@ -0,0 +1,19 @@ +/* + * 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; + +namespace PerformanceMonitorDashboard.Models +{ + public class BlockedSessionTrendItem + { + public DateTime CollectionTime { get; set; } + public string DatabaseName { get; set; } = string.Empty; + public int BlockedCount { get; set; } + } +} diff --git a/Dashboard/Models/WaitingTaskTrendItem.cs b/Dashboard/Models/WaitingTaskTrendItem.cs new file mode 100644 index 00000000..5869e4d2 --- /dev/null +++ b/Dashboard/Models/WaitingTaskTrendItem.cs @@ -0,0 +1,19 @@ +/* + * 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; + +namespace PerformanceMonitorDashboard.Models +{ + public class WaitingTaskTrendItem + { + public DateTime CollectionTime { get; set; } + public string WaitType { get; set; } = string.Empty; + public long TotalWaitMs { get; set; } + } +} diff --git a/Dashboard/ServerTab.xaml b/Dashboard/ServerTab.xaml index cb6d5f09..c9bffc01 100644 --- a/Dashboard/ServerTab.xaml +++ b/Dashboard/ServerTab.xaml @@ -550,6 +550,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 47410495..29fe02b0 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -79,6 +79,8 @@ public partial class ServerTab : UserControl private Helpers.ChartHoverHelper? _deadlocksHover; private Helpers.ChartHoverHelper? _deadlockWaitTimeHover; private Helpers.ChartHoverHelper? _collectorDurationHover; + private Helpers.ChartHoverHelper? _currentWaitsDurationHover; + private Helpers.ChartHoverHelper? _currentWaitsBlockedHover; public ServerTab(ServerConnection serverConnection, int utcOffsetMinutes = 0) { @@ -94,6 +96,8 @@ public ServerTab(ServerConnection serverConnection, int utcOffsetMinutes = 0) _deadlocksHover = new Helpers.ChartHoverHelper(BlockingStatsDeadlocksChart, "events"); _deadlockWaitTimeHover = new Helpers.ChartHoverHelper(BlockingStatsDeadlockWaitTimeChart, "ms"); _collectorDurationHover = new Helpers.ChartHoverHelper(CollectorDurationChart, "ms"); + _currentWaitsDurationHover = new Helpers.ChartHoverHelper(CurrentWaitsDurationChart, "ms"); + _currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); _serverConnection = serverConnection; UtcOffsetMinutes = utcOffsetMinutes; @@ -1332,6 +1336,8 @@ private async Task LoadDataAsync() var deadlocksTask = _databaseService.GetDeadlocksAsync(); var blockingStatsTask = _databaseService.GetBlockingDeadlockStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); var lockWaitStatsTask = _databaseService.GetLockWaitStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + var currentWaitsDurationTask = _databaseService.GetWaitingTaskTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + var currentWaitsBlockedTask = _databaseService.GetBlockedSessionTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); var performanceTask = PerformanceTab.RefreshAllDataAsync(); var memoryTask = MemoryTab.RefreshAllDataAsync(); @@ -1347,7 +1353,7 @@ private async Task LoadDataAsync() // Wait for everything to complete before _isRefreshing resets await Task.WhenAll( - healthTask, durationLogsTask, blockingEventsTask, deadlocksTask, blockingStatsTask, lockWaitStatsTask, + healthTask, durationLogsTask, blockingEventsTask, deadlocksTask, blockingStatsTask, lockWaitStatsTask, currentWaitsDurationTask, currentWaitsBlockedTask, performanceTask, memoryTask, resourceOverviewTask, runningJobsTask, resourceMetricsTask, dailySummaryTask, criticalIssuesTask, defaultTraceTask, currentConfigTask, configChangesTask, systemEventsTask); @@ -1390,6 +1396,10 @@ await Task.WhenAll( var lockWaitStats = await lockWaitStatsTask; LoadBlockingStatsCharts(blockingStats, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); LoadLockWaitStatsChart(lockWaitStats, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + var currentWaitsDuration = await currentWaitsDurationTask; + var currentWaitsBlocked = await currentWaitsBlockedTask; + LoadCurrentWaitsDurationChart(currentWaitsDuration, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + LoadCurrentWaitsBlockedChart(currentWaitsBlocked, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); } catch (Exception blockingStatsEx) { @@ -1671,7 +1681,9 @@ private async void BlockingStats_Refresh_Click(object? sender, RoutedEventArgs e var blockingStatsTask = _databaseService.GetBlockingDeadlockStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); var lockWaitStatsTask = _databaseService.GetLockWaitStatsAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); - await Task.WhenAll(blockingStatsTask, lockWaitStatsTask); + var currentWaitsDurationTask = _databaseService.GetWaitingTaskTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + var currentWaitsBlockedTask = _databaseService.GetBlockedSessionTrendAsync(_blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + await Task.WhenAll(blockingStatsTask, lockWaitStatsTask, currentWaitsDurationTask, currentWaitsBlockedTask); var data = await blockingStatsTask; var lockWaitStats = await lockWaitStatsTask; @@ -1679,6 +1691,8 @@ private async void BlockingStats_Refresh_Click(object? sender, RoutedEventArgs e // Load charts with explicit time range for proper axis scaling LoadBlockingStatsCharts(data, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); LoadLockWaitStatsChart(lockWaitStats, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + LoadCurrentWaitsDurationChart(await currentWaitsDurationTask, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); + LoadCurrentWaitsBlockedChart(await currentWaitsBlockedTask, _blockingStatsHoursBack, _blockingStatsFromDate, _blockingStatsToDate); StatusText.Text = $"Loaded {data.Count} blocking/deadlock stats records"; } catch (Exception ex) @@ -1935,6 +1949,120 @@ private void LoadLockWaitStatsChart(List data, int hoursBack, LockWaitStatsChart.Refresh(); } + private void LoadCurrentWaitsDurationChart(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(CurrentWaitsDurationChart, out var existingPanel) && existingPanel != null) + { + CurrentWaitsDurationChart.Plot.Axes.Remove(existingPanel); + _legendPanels[CurrentWaitsDurationChart] = null; + } + CurrentWaitsDurationChart.Plot.Clear(); + _currentWaitsDurationHover?.Clear(); + ApplyDarkModeToChart(CurrentWaitsDurationChart); + + var waitTypes = data.Select(d => d.WaitType).Distinct().OrderBy(w => w).ToList(); + var colors = TabHelpers.ChartColors; + + int colorIndex = 0; + foreach (var waitType in waitTypes) + { + var waitTypeData = data.Where(d => d.WaitType == waitType).OrderBy(d => d.CollectionTime).ToList(); + if (waitTypeData.Count > 0) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + waitTypeData.Select(d => d.CollectionTime), + waitTypeData.Select(d => (double)d.TotalWaitMs)); + + var scatter = CurrentWaitsDurationChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + scatter.LegendText = waitType; + _currentWaitsDurationHover?.Add(scatter, waitType); + colorIndex++; + } + } + + if (data.Count == 0) + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = CurrentWaitsDurationChart.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; + } + + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(xMin, xMax); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + _legendPanels[CurrentWaitsDurationChart] = CurrentWaitsDurationChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + CurrentWaitsDurationChart.Plot.Legend.FontSize = 12; + LockChartVerticalAxis(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + } + + private void LoadCurrentWaitsBlockedChart(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(CurrentWaitsBlockedChart, out var existingPanel) && existingPanel != null) + { + CurrentWaitsBlockedChart.Plot.Axes.Remove(existingPanel); + _legendPanels[CurrentWaitsBlockedChart] = null; + } + CurrentWaitsBlockedChart.Plot.Clear(); + _currentWaitsBlockedHover?.Clear(); + ApplyDarkModeToChart(CurrentWaitsBlockedChart); + + var databases = data.Select(d => d.DatabaseName).Distinct().OrderBy(d => d).ToList(); + var colors = TabHelpers.ChartColors; + + int colorIndex = 0; + foreach (var db in databases) + { + var dbData = data.Where(d => d.DatabaseName == db).OrderBy(d => d.CollectionTime).ToList(); + if (dbData.Count > 0) + { + var (xs, ys) = TabHelpers.FillTimeSeriesGaps( + dbData.Select(d => d.CollectionTime), + dbData.Select(d => (double)d.BlockedCount)); + + var scatter = CurrentWaitsBlockedChart.Plot.Add.Scatter(xs, ys); + scatter.LineWidth = 2; + scatter.MarkerSize = 5; + scatter.Color = colors[colorIndex % colors.Length]; + scatter.LegendText = db; + _currentWaitsBlockedHover?.Add(scatter, db); + colorIndex++; + } + } + + if (data.Count == 0) + { + double xCenter = xMin + (xMax - xMin) / 2; + var noDataText = CurrentWaitsBlockedChart.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; + } + + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(xMin, xMax); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + _legendPanels[CurrentWaitsBlockedChart] = CurrentWaitsBlockedChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); + CurrentWaitsBlockedChart.Plot.Legend.FontSize = 12; + LockChartVerticalAxis(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + } + // ==================================================================== // Context Menu Event Handlers // ==================================================================== diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index fd80e20e..e61e43da 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -2911,5 +2911,143 @@ AND dest.text IS NOT NULL return items; } + + public async Task> GetWaitingTaskTrendAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query; + if (fromDate.HasValue && toDate.HasValue) + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + wt.collection_time, + wt.wait_type, + total_wait_ms = SUM(wt.wait_duration_ms) + FROM collect.waiting_tasks AS wt + WHERE wt.collection_time >= @from_date + AND wt.collection_time <= @to_date + AND wt.wait_type IS NOT NULL + GROUP BY + wt.collection_time, + wt.wait_type + ORDER BY + wt.collection_time, + wt.wait_type;"; + } + else + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + wt.collection_time, + wt.wait_type, + total_wait_ms = SUM(wt.wait_duration_ms) + FROM collect.waiting_tasks AS wt + WHERE wt.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + AND wt.wait_type IS NOT NULL + GROUP BY + wt.collection_time, + wt.wait_type + ORDER BY + wt.collection_time, + wt.wait_type;"; + } + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new WaitingTaskTrendItem + { + CollectionTime = reader.GetDateTime(0), + WaitType = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + TotalWaitMs = reader.IsDBNull(2) ? 0L : Convert.ToInt64(reader.GetValue(2), CultureInfo.InvariantCulture) + }); + } + + return items; + } + + public async Task> GetBlockedSessionTrendAsync(int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + var items = new List(); + + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query; + if (fromDate.HasValue && toDate.HasValue) + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + wt.collection_time, + wt.database_name, + blocked_count = COUNT(*) + FROM collect.waiting_tasks AS wt + WHERE wt.blocking_session_id > 0 + AND wt.collection_time >= @from_date + AND wt.collection_time <= @to_date + AND wt.database_name IS NOT NULL + GROUP BY + wt.collection_time, + wt.database_name + ORDER BY + wt.collection_time, + wt.database_name;"; + } + else + { + query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT + wt.collection_time, + wt.database_name, + blocked_count = COUNT(*) + FROM collect.waiting_tasks AS wt + WHERE wt.blocking_session_id > 0 + AND wt.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME()) + AND wt.database_name IS NOT NULL + GROUP BY + wt.collection_time, + wt.database_name + ORDER BY + wt.collection_time, + wt.database_name;"; + } + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack }); + if (fromDate.HasValue) command.Parameters.Add(new SqlParameter("@from_date", SqlDbType.DateTime2) { Value = fromDate.Value }); + if (toDate.HasValue) command.Parameters.Add(new SqlParameter("@to_date", SqlDbType.DateTime2) { Value = toDate.Value }); + + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new BlockedSessionTrendItem + { + CollectionTime = reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? string.Empty : reader.GetString(1), + BlockedCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2), CultureInfo.InvariantCulture) + }); + } + + return items; + } } } diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index d77dbb59..fb0bc2d5 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -771,6 +771,20 @@ + + + + + + + + + + + + + + _memoryClerkItems = new(); @@ -174,6 +176,8 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, ""); + _currentWaitsDurationHover = new Helpers.ChartHoverHelper(CurrentWaitsDurationChart, "ms"); + _currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); /* Initial load is triggered by MainWindow.ConnectToServer calling RefreshData() after collectors finish - no Loaded handler needed */ @@ -510,10 +514,13 @@ await System.Threading.Tasks.Task.WhenAll( var procDurationTrendTask = SafeQueryAsync(() => _dataService.GetProcedureDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); var queryStoreDurationTrendTask = SafeQueryAsync(() => _dataService.GetQueryStoreDurationTrendAsync(_serverId, hoursBack, fromDate, toDate)); var executionCountTrendTask = SafeQueryAsync(() => _dataService.GetExecutionCountTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsDurationTask = SafeQueryAsync(() => _dataService.GetWaitingTaskTrendAsync(_serverId, hoursBack, fromDate, toDate)); + var currentWaitsBlockedTask = SafeQueryAsync(() => _dataService.GetBlockedSessionTrendAsync(_serverId, hoursBack, fromDate, toDate)); await System.Threading.Tasks.Task.WhenAll( lockWaitTrendTask, blockingTrendTask, deadlockTrendTask, - queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask); + queryDurationTrendTask, procDurationTrendTask, queryStoreDurationTrendTask, executionCountTrendTask, + currentWaitsDurationTask, currentWaitsBlockedTask); loadSw.Stop(); @@ -563,6 +570,8 @@ await System.Threading.Tasks.Task.WhenAll( UpdateLockWaitTrendChart(lockWaitTrendTask.Result, hoursBack, fromDate, toDate); UpdateBlockingTrendChart(blockingTrendTask.Result, hoursBack, fromDate, toDate); UpdateDeadlockTrendChart(deadlockTrendTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsDurationChart(currentWaitsDurationTask.Result, hoursBack, fromDate, toDate); + UpdateCurrentWaitsBlockedChart(currentWaitsBlockedTask.Result, hoursBack, fromDate, toDate); UpdateQueryDurationTrendChart(queryDurationTrendTask.Result); UpdateProcDurationTrendChart(procDurationTrendTask.Result); UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); @@ -1192,6 +1201,138 @@ private void UpdateDeadlockTrendChart(List data, int hoursBack, Date DeadlockTrendChart.Refresh(); } + /* ========== Current Waits Charts ========== */ + + private void UpdateCurrentWaitsDurationChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(CurrentWaitsDurationChart); + ApplyDarkTheme(CurrentWaitsDurationChart); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _currentWaitsDurationHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsDurationChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Current Waits"; + zeroLine.Color = ScottPlot.Color.FromHex("#4FC3F7"); + zeroLine.MarkerSize = 0; + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.WaitType).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.TotalWaitMs).ToArray(); + + var plot = CurrentWaitsDurationChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.LineWidth = 2; + plot.MarkerSize = 5; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _currentWaitsDurationHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsDurationChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsDurationChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Plot.YLabel("Total Wait Duration (ms)"); + SetChartYLimitsWithLegendPadding(CurrentWaitsDurationChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsDurationChart); + CurrentWaitsDurationChart.Refresh(); + } + + private void UpdateCurrentWaitsBlockedChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(CurrentWaitsBlockedChart); + ApplyDarkTheme(CurrentWaitsBlockedChart); + + DateTime rangeStart, rangeEnd; + if (fromDate.HasValue && toDate.HasValue) + { + rangeStart = fromDate.Value; + rangeEnd = toDate.Value; + } + else + { + rangeEnd = DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + rangeStart = rangeEnd.AddHours(-hoursBack); + } + + _currentWaitsBlockedHover?.Clear(); + if (data.Count == 0) + { + var zeroLine = CurrentWaitsBlockedChart.Plot.Add.Scatter( + new[] { rangeStart.ToOADate(), rangeEnd.ToOADate() }, + new[] { 0.0, 0.0 }); + zeroLine.LegendText = "Blocked Sessions"; + zeroLine.Color = ScottPlot.Color.FromHex("#E57373"); + zeroLine.MarkerSize = 0; + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + return; + } + + var grouped = data.GroupBy(d => d.DatabaseName).OrderBy(g => g.Key).ToList(); + double globalMax = 0; + + for (int i = 0; i < grouped.Count; i++) + { + var group = grouped[i]; + var ordered = group.OrderBy(t => t.CollectionTime).ToList(); + var times = ordered.Select(t => t.CollectionTime.AddMinutes(UtcOffsetMinutes).ToOADate()).ToArray(); + var values = ordered.Select(t => (double)t.BlockedCount).ToArray(); + + var plot = CurrentWaitsBlockedChart.Plot.Add.Scatter(times, values); + plot.LegendText = group.Key; + plot.LineWidth = 2; + plot.MarkerSize = 5; + plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]); + _currentWaitsBlockedHover?.Add(plot, group.Key); + + if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max()); + } + + CurrentWaitsBlockedChart.Plot.Axes.DateTimeTicksBottom(); + CurrentWaitsBlockedChart.Plot.Axes.SetLimitsX(rangeStart.ToOADate(), rangeEnd.ToOADate()); + ReapplyAxisColors(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Plot.YLabel("Blocked Sessions"); + SetChartYLimitsWithLegendPadding(CurrentWaitsBlockedChart, 0, globalMax > 0 ? globalMax : 1); + ShowChartLegend(CurrentWaitsBlockedChart); + CurrentWaitsBlockedChart.Refresh(); + } + /* ========== Performance Trend Charts ========== */ private void UpdateQueryDurationTrendChart(List data) diff --git a/Lite/Services/LocalDataService.WaitingTasks.cs b/Lite/Services/LocalDataService.WaitingTasks.cs index e9ca02ad..0f0a3202 100644 --- a/Lite/Services/LocalDataService.WaitingTasks.cs +++ b/Lite/Services/LocalDataService.WaitingTasks.cs @@ -57,6 +57,97 @@ FROM waiting_tasks return items; } + + /// + /// Gets waiting task duration trend grouped by wait type for charting. + /// + public async Task> GetWaitingTaskTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + wait_type, + SUM(wait_duration_ms) AS total_wait_ms +FROM waiting_tasks +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3 +AND wait_type IS NOT NULL +GROUP BY + collection_time, + wait_type +ORDER BY + collection_time, + wait_type"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new WaitingTaskTrendPoint + { + CollectionTime = reader.GetDateTime(0), + WaitType = reader.IsDBNull(1) ? "" : reader.GetString(1), + TotalWaitMs = reader.IsDBNull(2) ? 0 : ToInt64(reader.GetValue(2)) + }); + } + return items; + } + + /// + /// Gets blocked session count trend grouped by database for charting. + /// + public async Task> GetBlockedSessionTrendAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + collection_time, + database_name, + COUNT(*) AS blocked_count +FROM waiting_tasks +WHERE server_id = $1 +AND blocking_session_id > 0 +AND collection_time >= $2 +AND collection_time <= $3 +AND database_name IS NOT NULL +GROUP BY + collection_time, + database_name +ORDER BY + collection_time, + database_name"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new BlockedSessionTrendPoint + { + CollectionTime = reader.GetDateTime(0), + DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), + BlockedCount = reader.IsDBNull(2) ? 0 : Convert.ToInt32(reader.GetValue(2)) + }); + } + return items; + } } public class WaitingTaskRow @@ -75,3 +166,17 @@ public class WaitingTaskRow ? $"{WaitDurationMs / 1000.0:F1} s" : $"{WaitDurationMs / 60000.0:F1} min"; } + +public class WaitingTaskTrendPoint +{ + public DateTime CollectionTime { get; set; } + public string WaitType { get; set; } = ""; + public long TotalWaitMs { get; set; } +} + +public class BlockedSessionTrendPoint +{ + public DateTime CollectionTime { get; set; } + public string DatabaseName { get; set; } = ""; + public int BlockedCount { get; set; } +} diff --git a/Lite/Services/RemoteCollectorService.WaitingTasks.cs b/Lite/Services/RemoteCollectorService.WaitingTasks.cs index 168bef69..e3b31f70 100644 --- a/Lite/Services/RemoteCollectorService.WaitingTasks.cs +++ b/Lite/Services/RemoteCollectorService.WaitingTasks.cs @@ -32,7 +32,6 @@ private async Task CollectWaitingTasksAsync(ServerConnection server, Cancel wait_type = wt.wait_type, wait_duration_ms = wt.wait_duration_ms, blocking_session_id = wt.blocking_session_id, - resource_description = wt.resource_description, database_name = DB_NAME(er.database_id) FROM sys.dm_os_waiting_tasks AS wt LEFT JOIN sys.dm_exec_requests AS er @@ -73,8 +72,7 @@ AND wt.wait_type IS NOT NULL var waitType = reader.IsDBNull(1) ? null : reader.GetString(1); var waitDurationMs = reader.IsDBNull(2) ? 0L : reader.GetInt64(2); var blockingSessionId = reader.IsDBNull(3) ? (short?)null : reader.GetInt16(3); - var resourceDescription = reader.IsDBNull(4) ? null : reader.GetString(4); - var databaseName = reader.IsDBNull(5) ? null : reader.GetString(5); + var databaseName = reader.IsDBNull(4) ? null : reader.GetString(4); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) @@ -85,7 +83,7 @@ AND wt.wait_type IS NOT NULL .AppendValue(waitType) .AppendValue(waitDurationMs) .AppendValue(blockingSessionId.HasValue ? (int?)blockingSessionId.Value : null) - .AppendValue(resourceDescription) + .AppendValue((string?)null) /* resource_description — no longer collected */ .AppendValue(databaseName) .EndRow();