Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion Dashboard/Controls/ResourceMetricsContent.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ private void SetupChartContextMenus()
TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats");

// Wait Stats Detail chart
TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu);
}

/// <summary>
Expand Down Expand Up @@ -1813,6 +1814,48 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e)
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<string, DateTime> 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)
Expand Down
44 changes: 44 additions & 0 deletions Dashboard/Helpers/ChartHoverHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,50 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
_scatters.Add((scatter, label));

/// <summary>
/// Returns the nearest series label and data-point time for the given mouse position,
/// or null if no series is close enough.
/// </summary>
public (string Label, DateTime Time)? GetNearestSeries(Point mousePos)
{
if (_scatters.Count == 0) return null;
try
{
var dpi = VisualTreeHelper.GetDpi(_chart);
var pixel = new ScottPlot.Pixel(
(float)(mousePos.X * dpi.DpiScaleX),
(float)(mousePos.Y * dpi.DpiScaleY));
var mouseCoords = _chart.Plot.GetCoordinates(pixel);

double bestYDistance = double.MaxValue;
ScottPlot.DataPoint bestPoint = default;
string bestLabel = "";
bool found = false;

foreach (var (scatter, label) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
if (!nearest.IsReal) continue;
var nearestPixel = _chart.Plot.GetPixel(
new ScottPlot.Coordinates(nearest.X, nearest.Y));
double dx = Math.Abs(nearestPixel.X - pixel.X);
double dy = Math.Abs(nearestPixel.Y - pixel.Y);
if (dx < 80 && dy < bestYDistance)
{
bestYDistance = dy;
bestPoint = nearest;
bestLabel = label;
found = true;
}
}

if (found)
return (bestLabel, DateTime.FromOADate(bestPoint.X));
}
catch { }
return null;
}

private void OnMouseMove(object sender, MouseEventArgs e)
{
if (_scatters.Count == 0) return;
Expand Down
4 changes: 3 additions & 1 deletion Dashboard/Helpers/TabHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ public static string FormatForExport(object? value)
/// <param name="chart">The WpfPlot chart control</param>
/// <param name="chartName">A descriptive name for the chart (used in filenames)</param>
/// <param name="dataSource">Optional SQL view/table name that populates this chart</param>
public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
{
var contextMenu = new ContextMenu();

Expand Down Expand Up @@ -786,6 +786,8 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string
chart.Plot.Axes.AutoScale();
chart.Refresh();
};

return contextMenu;
}

/// <summary>
Expand Down
169 changes: 169 additions & 0 deletions Dashboard/Helpers/WaitDrillDownHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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.Linq;

namespace PerformanceMonitorDashboard.Helpers;

/// <summary>
/// Classifies wait types for drill-down behavior and walks blocking chains
/// to find head blockers. Used by WaitDrillDownWindow.
/// </summary>
public static class WaitDrillDownHelper
{
public enum WaitCategory
{
/// <summary>Wait is too brief to appear in snapshots. Show all queries sorted by correlated metric.</summary>
Correlated,
/// <summary>Walk blocking chain to find head blockers (LCK_M_*).</summary>
Chain,
/// <summary>Sessions may lack worker threads, unlikely to appear in snapshots.</summary>
Uncapturable,
/// <summary>Attempt direct wait_type filter; may return empty for brief waits.</summary>
Filtered
}

public sealed record WaitClassification(
WaitCategory Category,
string SortProperty,
string Description
);

/// <summary>
/// Lightweight result from the chain walker — just the head blocker identity and blocked count.
/// Callers look up the original full row by (CollectionTime, SessionId).
/// </summary>
public sealed record HeadBlockerInfo(
DateTime CollectionTime,
int SessionId,
int BlockedSessionCount,
string BlockingPath
);

public sealed record SnapshotInfo
{
public int SessionId { get; init; }
public int BlockingSessionId { get; init; }
public DateTime CollectionTime { get; init; }
public string DatabaseName { get; init; } = "";
public string Status { get; init; } = "";
public string QueryText { get; init; } = "";
public string? WaitType { get; init; }
public long WaitTimeMs { get; init; }
public long CpuTimeMs { get; init; }
public long Reads { get; init; }
public long Writes { get; init; }
public long LogicalReads { get; init; }
}

private const int MaxChainDepth = 20;

public static WaitClassification Classify(string waitType)
{
if (string.IsNullOrEmpty(waitType))
return new WaitClassification(WaitCategory.Filtered, "WaitTimeMs", "Unknown");

return waitType switch
{
"SOS_SCHEDULER_YIELD" =>
new(WaitCategory.Correlated, "CpuTimeMs", "CPU pressure — showing high-CPU queries active during this period"),
"WRITELOG" =>
new(WaitCategory.Correlated, "Writes", "Transaction log writes — showing high-write queries active during this period"),
"CXPACKET" or "CXCONSUMER" =>
new(WaitCategory.Correlated, "Dop", "Parallelism — showing parallel queries active during this period"),
"RESOURCE_SEMAPHORE" or "RESOURCE_SEMAPHORE_QUERY_COMPILE" =>
new(WaitCategory.Correlated, "GrantedQueryMemoryGb", "Memory grant pressure — showing high-memory queries active during this period"),
"THREADPOOL" =>
new(WaitCategory.Uncapturable, "CpuTimeMs", "Thread pool starvation — sessions may not appear in snapshots"),
"LATCH_EX" or "LATCH_UP" =>
new(WaitCategory.Correlated, "CpuTimeMs", "Latch contention — showing high-CPU queries active during this period"),
_ when waitType.StartsWith("PAGEIOLATCH_", StringComparison.OrdinalIgnoreCase) =>
new(WaitCategory.Correlated, "Reads", "Disk I/O — showing high-read queries active during this period"),
_ when waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase) =>
new(WaitCategory.Chain, "", "Lock contention — showing head blockers"),
_ =>
new(WaitCategory.Filtered, "WaitTimeMs", "Filtered by wait type")
};
}

/// <summary>
/// Walks blocking chains to find head blockers.
/// Returns lightweight HeadBlockerInfo records — callers look up original full rows
/// by (CollectionTime, SessionId) to preserve all columns.
/// </summary>
public static List<HeadBlockerInfo> WalkBlockingChains(
IEnumerable<SnapshotInfo> waiters,
IEnumerable<SnapshotInfo> allSnapshots)
{
var byTime = allSnapshots
.GroupBy(s => s.CollectionTime)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(s => s.SessionId));

var headBlockers = new Dictionary<(DateTime, int), (SnapshotInfo Info, HashSet<int> BlockedSessions)>();

foreach (var waiter in waiters)
{
if (!byTime.TryGetValue(waiter.CollectionTime, out var sessionsAtTime))
continue;

var head = FindHeadBlocker(waiter, sessionsAtTime);
if (head == null)
continue;

var key = (waiter.CollectionTime, head.SessionId);
if (!headBlockers.TryGetValue(key, out var existing))
{
existing = (head, new HashSet<int>());
headBlockers[key] = existing;
}

existing.BlockedSessions.Add(waiter.SessionId);
}

return headBlockers.Values
.Select(hb => new HeadBlockerInfo(
hb.Info.CollectionTime,
hb.Info.SessionId,
hb.BlockedSessions.Count,
$"Head SPID {hb.Info.SessionId} blocking {hb.BlockedSessions.Count} session(s)"))
.OrderByDescending(r => r.BlockedSessionCount)
.ThenByDescending(r => r.CollectionTime)
.ToList();
}

private static SnapshotInfo? FindHeadBlocker(
SnapshotInfo waiter,
Dictionary<int, SnapshotInfo> sessionsAtTime)
{
var visited = new HashSet<int>();
var current = waiter;

for (int depth = 0; depth < MaxChainDepth; depth++)
{
if (!visited.Add(current.SessionId))
return current; // cycle detected — treat current as head

var blockerId = current.BlockingSessionId;

// Head blocker: not blocked by anyone, or blocked by self, or blocker not found
if (blockerId <= 0 || blockerId == current.SessionId)
return current;

if (!sessionsAtTime.TryGetValue(blockerId, out var blocker))
return current; // blocker not in snapshot — treat current as head

current = blocker;
}

return current; // max depth — treat current as head
}
}
3 changes: 3 additions & 0 deletions Dashboard/Models/QuerySnapshotItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,8 @@ public class QuerySnapshotItem

// Property alias for XAML binding compatibility
public string? QueryText => SqlText;

// Chain mode — set by WaitDrillDownWindow when showing head blockers
public string ChainBlockingPath { get; set; } = "";
}
}
Loading
Loading