Skip to content

Commit cd2bc05

Browse files
Add right-click View Plan on Dashboard Blocked Process Reports and Deadlocks grids (#880) (#883)
Mirrors the Lite treatment: pulls sql_handle + statement offsets out of the BPR or deadlock graph, runs a sys.dm_exec_query_stats / dm_exec_text_query_plan lookup against the monitored server, and opens the result in the existing plan viewer tab. BPR grid gets "View Blocked Plan" + "View Blocking Plan" (the XML has both processes). Deadlocks grid gets a single "View Plan" and finds the matching <process> by SPID — Dashboard's row model is one-per-process. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cfd7fab commit cd2bc05

2 files changed

Lines changed: 324 additions & 2 deletions

File tree

Dashboard/ServerTab.xaml

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,45 @@
3232
</MenuItem>
3333
</ContextMenu>
3434

35+
<!-- Context Menu for Blocked Process Report rows (copy/export + View Plan per side) -->
36+
<ContextMenu x:Key="BlockingEventsContextMenu">
37+
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
38+
<MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon>
39+
</MenuItem>
40+
<MenuItem Header="Copy Row" Click="CopyRow_Click">
41+
<MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon>
42+
</MenuItem>
43+
<MenuItem Header="Copy All Rows" Click="CopyAllRows_Click">
44+
<MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon>
45+
</MenuItem>
46+
<Separator/>
47+
<MenuItem Header="Export to CSV..." Click="ExportToCsv_Click">
48+
<MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon>
49+
</MenuItem>
50+
<Separator/>
51+
<MenuItem Header="View Blocked Plan" Click="ViewBlockedSidePlan_Click"/>
52+
<MenuItem Header="View Blocking Plan" Click="ViewBlockingSidePlan_Click"/>
53+
</ContextMenu>
54+
55+
<!-- Context Menu for Deadlock rows (copy/export + View Plan) -->
56+
<ContextMenu x:Key="DeadlocksContextMenu">
57+
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
58+
<MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon>
59+
</MenuItem>
60+
<MenuItem Header="Copy Row" Click="CopyRow_Click">
61+
<MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon>
62+
</MenuItem>
63+
<MenuItem Header="Copy All Rows" Click="CopyAllRows_Click">
64+
<MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon>
65+
</MenuItem>
66+
<Separator/>
67+
<MenuItem Header="Export to CSV..." Click="ExportToCsv_Click">
68+
<MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon>
69+
</MenuItem>
70+
<Separator/>
71+
<MenuItem Header="View Plan" Click="ViewDeadlockProcessPlan_Click"/>
72+
</ContextMenu>
73+
3574
<!-- Row Styles for Visual Indicators -->
3675
<Style x:Key="HealthRowStyle" TargetType="DataGridRow">
3776
<Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/>
@@ -63,6 +102,14 @@
63102
<Style x:Key="DefaultRowStyle" TargetType="DataGridRow">
64103
<Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/>
65104
</Style>
105+
106+
<Style x:Key="BlockingEventsPlanRowStyle" TargetType="DataGridRow">
107+
<Setter Property="ContextMenu" Value="{StaticResource BlockingEventsContextMenu}"/>
108+
</Style>
109+
110+
<Style x:Key="DeadlocksPlanRowStyle" TargetType="DataGridRow">
111+
<Setter Property="ContextMenu" Value="{StaticResource DeadlocksContextMenu}"/>
112+
</Style>
66113
</ResourceDictionary>
67114
</UserControl.Resources>
68115
<Grid>
@@ -619,7 +666,7 @@
619666
<controls:TimeRangeSlicerControl x:Name="BlockingSlicer" Grid.Row="0"/>
620667
<DataGrid x:Name="BlockingEventsDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
621668
GridLinesVisibility="Horizontal" CanUserResizeColumns="True"
622-
RowStyle="{StaticResource DefaultRowStyle}"
669+
RowStyle="{StaticResource BlockingEventsPlanRowStyle}"
623670
ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto">
624671
<DataGrid.Columns>
625672
<DataGridTextColumn Binding="{Binding EventTime, Converter={StaticResource ServerTimeConverter}}" Width="150">
@@ -813,7 +860,7 @@
813860
<controls:TimeRangeSlicerControl x:Name="DeadlockSlicer" Grid.Row="0"/>
814861
<DataGrid x:Name="DeadlocksDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
815862
RowHeight="28" GridLinesVisibility="Horizontal" CanUserResizeColumns="True"
816-
RowStyle="{StaticResource DefaultRowStyle}"
863+
RowStyle="{StaticResource DeadlocksPlanRowStyle}"
817864
ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto">
818865
<DataGrid.Columns>
819866
<DataGridTextColumn Binding="{Binding EventDate, Converter={StaticResource ServerTimeConverter}}" Width="150">

Dashboard/ServerTab.xaml.cs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Data;
23
using System.Globalization;
34
using System.IO;
45
using System.Linq;
@@ -12,6 +13,7 @@
1213
using System.Windows.Controls.Primitives;
1314
using System.Windows.Media;
1415
using System.Windows.Threading;
16+
using Microsoft.Data.SqlClient;
1517
using Microsoft.Win32;
1618
using PerformanceMonitorDashboard.Models;
1719
using PerformanceMonitorDashboard.Interfaces;
@@ -2044,6 +2046,279 @@ private void DownloadDeadlockGraph_Click(object sender, RoutedEventArgs e)
20442046
}
20452047
}
20462048

2049+
// ── Blocked Process Report / Deadlock plan lookup ──
2050+
2051+
/* SQL Server writes this 42-byte all-zero handle into executionStack frames
2052+
for dynamic SQL / system contexts where no persistent sql_handle exists.
2053+
Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */
2054+
private static readonly string ZeroSqlHandle = "0x" + new string('0', 84);
2055+
2056+
private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e)
2057+
=> await ShowBlockedProcessPlanAsync(sender, blockingSide: false);
2058+
2059+
private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e)
2060+
=> await ShowBlockedProcessPlanAsync(sender, blockingSide: true);
2061+
2062+
private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide)
2063+
{
2064+
if (sender is not MenuItem menuItem) return;
2065+
if (menuItem.Parent is not ContextMenu cm) return;
2066+
var grid = FindDataGridFromContextMenu(cm);
2067+
if (grid?.SelectedItem is not BlockingEventItem row) return;
2068+
2069+
var sideLabel = blockingSide ? "Blocking" : "Blocked";
2070+
var label = $"Est Plan - {sideLabel} SPID {row.Spid}";
2071+
2072+
var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide);
2073+
if (frames.Count == 0)
2074+
{
2075+
MessageBox.Show(
2076+
$"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " +
2077+
"This usually means the query ran as dynamic SQL or a system context — " +
2078+
"SQL Server records a zero handle in that case and the plan can't be recovered.",
2079+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
2080+
return;
2081+
}
2082+
2083+
string? planXml = null;
2084+
try
2085+
{
2086+
var connStr = _serverConnection.GetConnectionString(_credentialService);
2087+
foreach (var f in frames)
2088+
{
2089+
planXml = await FetchPlanBySqlHandleAsync(
2090+
connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
2091+
if (!string.IsNullOrEmpty(planXml)) break;
2092+
}
2093+
}
2094+
catch { }
2095+
2096+
if (!string.IsNullOrEmpty(planXml))
2097+
{
2098+
OpenPlanTab(planXml, label, row.QueryText);
2099+
PlanViewerTabItem.IsSelected = true;
2100+
}
2101+
else
2102+
{
2103+
MessageBox.Show(
2104+
$"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " +
2105+
"Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.",
2106+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
2107+
}
2108+
}
2109+
2110+
private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames(
2111+
string bprXml, bool blockingSide)
2112+
{
2113+
var empty = Array.Empty<(string, int, int)>();
2114+
if (string.IsNullOrWhiteSpace(bprXml)) return empty;
2115+
try
2116+
{
2117+
var doc = System.Xml.Linq.XElement.Parse(bprXml);
2118+
var processContainer = blockingSide
2119+
? doc.Element("blocking-process")
2120+
: doc.Element("blocked-process");
2121+
var stack = processContainer?.Element("process")?.Element("executionStack");
2122+
if (stack == null) return empty;
2123+
2124+
var frames = new List<(string, int, int)>();
2125+
foreach (var frame in stack.Elements("frame"))
2126+
{
2127+
var handle = frame.Attribute("sqlhandle")?.Value;
2128+
if (string.IsNullOrWhiteSpace(handle)) continue;
2129+
if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;
2130+
2131+
int stmtStart = 0;
2132+
int stmtEnd = -1;
2133+
int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart);
2134+
if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se;
2135+
2136+
frames.Add((handle!, stmtStart, stmtEnd));
2137+
}
2138+
return frames;
2139+
}
2140+
catch
2141+
{
2142+
return empty;
2143+
}
2144+
}
2145+
2146+
/* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the
2147+
<process> node, with optional <executionStack><frame sqlhandle=...>
2148+
children for the call stack. Match by SPID since Dashboard's row
2149+
model doesn't carry the process graph id. */
2150+
private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e)
2151+
{
2152+
if (sender is not MenuItem menuItem) return;
2153+
if (menuItem.Parent is not ContextMenu cm) return;
2154+
var grid = FindDataGridFromContextMenu(cm);
2155+
if (grid?.SelectedItem is not DeadlockItem row) return;
2156+
2157+
var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType;
2158+
var label = $"Est Plan - {sideLabel} SPID {row.Spid}";
2159+
2160+
var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid);
2161+
if (frames.Count == 0)
2162+
{
2163+
MessageBox.Show(
2164+
"The process has no resolvable sql_handle in the deadlock graph. " +
2165+
"This usually means the query ran as dynamic SQL or a system context — " +
2166+
"SQL Server records a zero handle in that case and the plan can't be recovered.",
2167+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
2168+
return;
2169+
}
2170+
2171+
string? planXml = null;
2172+
try
2173+
{
2174+
var connStr = _serverConnection.GetConnectionString(_credentialService);
2175+
foreach (var f in frames)
2176+
{
2177+
planXml = await FetchPlanBySqlHandleAsync(
2178+
connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
2179+
if (!string.IsNullOrEmpty(planXml)) break;
2180+
}
2181+
}
2182+
catch { }
2183+
2184+
if (!string.IsNullOrEmpty(planXml))
2185+
{
2186+
OpenPlanTab(planXml, label, row.Query);
2187+
PlanViewerTabItem.IsSelected = true;
2188+
}
2189+
else
2190+
{
2191+
MessageBox.Show(
2192+
$"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " +
2193+
"Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.",
2194+
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
2195+
}
2196+
}
2197+
2198+
private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames(
2199+
string graphXml, short? spid)
2200+
{
2201+
var empty = Array.Empty<(string, int, int)>();
2202+
if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty;
2203+
try
2204+
{
2205+
var doc = System.Xml.Linq.XElement.Parse(graphXml);
2206+
var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture);
2207+
var process = doc.Descendants("process")
2208+
.FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal));
2209+
if (process == null) return empty;
2210+
2211+
var frames = new List<(string, int, int)>();
2212+
2213+
var procHandle = process.Attribute("sqlhandle")?.Value;
2214+
if (!string.IsNullOrWhiteSpace(procHandle) &&
2215+
!string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase))
2216+
{
2217+
int ps = 0, pe = -1;
2218+
int.TryParse(process.Attribute("stmtstart")?.Value, out ps);
2219+
if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed;
2220+
frames.Add((procHandle!, ps, pe));
2221+
}
2222+
2223+
var stack = process.Element("executionStack");
2224+
if (stack != null)
2225+
{
2226+
foreach (var frame in stack.Elements("frame"))
2227+
{
2228+
var handle = frame.Attribute("sqlhandle")?.Value;
2229+
if (string.IsNullOrWhiteSpace(handle)) continue;
2230+
if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;
2231+
2232+
int fs = 0, fe = -1;
2233+
int.TryParse(frame.Attribute("stmtstart")?.Value, out fs);
2234+
if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed;
2235+
frames.Add((handle!, fs, fe));
2236+
}
2237+
}
2238+
2239+
return frames;
2240+
}
2241+
catch
2242+
{
2243+
return empty;
2244+
}
2245+
}
2246+
2247+
private static async Task<string?> FetchPlanBySqlHandleAsync(
2248+
string connectionString,
2249+
string databaseName,
2250+
string sqlHandleHex,
2251+
int statementStartOffset,
2252+
int statementEndOffset)
2253+
{
2254+
if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null;
2255+
var handleBytes = HexStringToBytes(sqlHandleHex);
2256+
if (handleBytes == null || handleBytes.Length == 0) return null;
2257+
2258+
using var connection = new SqlConnection(connectionString);
2259+
await connection.OpenAsync();
2260+
2261+
/* Database context is only used to route the execution; sys.dm_exec_query_stats
2262+
is server-scoped, so if the supplied name isn't valid we fall back to master. */
2263+
var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]";
2264+
2265+
var query = $@"
2266+
EXECUTE {quotedDbName}.sys.sp_executesql
2267+
N'
2268+
SELECT TOP (1)
2269+
query_plan_text = tqp.query_plan
2270+
FROM sys.dm_exec_query_stats AS qs
2271+
OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp
2272+
WHERE qs.sql_handle = @h
2273+
AND qs.statement_start_offset = @stmt_start
2274+
AND qs.statement_end_offset = @stmt_end
2275+
AND tqp.query_plan IS NOT NULL
2276+
ORDER BY
2277+
qs.last_execution_time DESC
2278+
OPTION(RECOMPILE);',
2279+
N'@h varbinary(64), @stmt_start int, @stmt_end int',
2280+
@h, @stmt_start, @stmt_end;";
2281+
2282+
using var command = new SqlCommand(query, connection) { CommandTimeout = 30 };
2283+
command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes });
2284+
command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset });
2285+
command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset });
2286+
var result = await command.ExecuteScalarAsync();
2287+
return result as string;
2288+
}
2289+
2290+
private static byte[]? HexStringToBytes(string hex)
2291+
{
2292+
var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0;
2293+
var len = hex.Length - start;
2294+
if (len <= 0 || (len % 2) != 0) return null;
2295+
var bytes = new byte[len / 2];
2296+
for (int i = 0; i < bytes.Length; i++)
2297+
{
2298+
if (!byte.TryParse(hex.AsSpan(start + i * 2, 2),
2299+
NumberStyles.HexNumber,
2300+
CultureInfo.InvariantCulture,
2301+
out bytes[i]))
2302+
{
2303+
return null;
2304+
}
2305+
}
2306+
return bytes;
2307+
}
2308+
2309+
/* Only accept names that are syntactically plain identifiers so we can safely
2310+
interpolate into the EXEC statement. Unknown / invalid names fall back to master. */
2311+
private static string? QuoteDatabaseName(string? dbName)
2312+
{
2313+
if (string.IsNullOrWhiteSpace(dbName)) return null;
2314+
foreach (var c in dbName)
2315+
{
2316+
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' '))
2317+
return null;
2318+
}
2319+
return "[" + dbName.Replace("]", "]]") + "]";
2320+
}
2321+
20472322
private void LoadUserPreferences()
20482323
{
20492324
var prefs = _preferencesService.GetPreferences();

0 commit comments

Comments
 (0)