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
51 changes: 49 additions & 2 deletions Dashboard/ServerTab.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,45 @@
</MenuItem>
</ContextMenu>

<!-- Context Menu for Blocked Process Report rows (copy/export + View Plan per side) -->
<ContextMenu x:Key="BlockingEventsContextMenu">
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
<MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy Row" Click="CopyRow_Click">
<MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy All Rows" Click="CopyAllRows_Click">
<MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="Export to CSV..." Click="ExportToCsv_Click">
<MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="View Blocked Plan" Click="ViewBlockedSidePlan_Click"/>
<MenuItem Header="View Blocking Plan" Click="ViewBlockingSidePlan_Click"/>
</ContextMenu>

<!-- Context Menu for Deadlock rows (copy/export + View Plan) -->
<ContextMenu x:Key="DeadlocksContextMenu">
<MenuItem Header="Copy Cell" Click="CopyCell_Click">
<MenuItem.Icon><TextBlock Text="📋"/></MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy Row" Click="CopyRow_Click">
<MenuItem.Icon><TextBlock Text="📄"/></MenuItem.Icon>
</MenuItem>
<MenuItem Header="Copy All Rows" Click="CopyAllRows_Click">
<MenuItem.Icon><TextBlock Text="📑"/></MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="Export to CSV..." Click="ExportToCsv_Click">
<MenuItem.Icon><TextBlock Text="📊"/></MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="View Plan" Click="ViewDeadlockProcessPlan_Click"/>
</ContextMenu>

<!-- Row Styles for Visual Indicators -->
<Style x:Key="HealthRowStyle" TargetType="DataGridRow">
<Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/>
Expand Down Expand Up @@ -63,6 +102,14 @@
<Style x:Key="DefaultRowStyle" TargetType="DataGridRow">
<Setter Property="ContextMenu" Value="{StaticResource DataGridContextMenu}"/>
</Style>

<Style x:Key="BlockingEventsPlanRowStyle" TargetType="DataGridRow">
<Setter Property="ContextMenu" Value="{StaticResource BlockingEventsContextMenu}"/>
</Style>

<Style x:Key="DeadlocksPlanRowStyle" TargetType="DataGridRow">
<Setter Property="ContextMenu" Value="{StaticResource DeadlocksContextMenu}"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
Expand Down Expand Up @@ -619,7 +666,7 @@
<controls:TimeRangeSlicerControl x:Name="BlockingSlicer" Grid.Row="0"/>
<DataGrid x:Name="BlockingEventsDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal" CanUserResizeColumns="True"
RowStyle="{StaticResource DefaultRowStyle}"
RowStyle="{StaticResource BlockingEventsPlanRowStyle}"
ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding EventTime, Converter={StaticResource ServerTimeConverter}}" Width="150">
Expand Down Expand Up @@ -813,7 +860,7 @@
<controls:TimeRangeSlicerControl x:Name="DeadlockSlicer" Grid.Row="0"/>
<DataGrid x:Name="DeadlocksDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
RowHeight="28" GridLinesVisibility="Horizontal" CanUserResizeColumns="True"
RowStyle="{StaticResource DefaultRowStyle}"
RowStyle="{StaticResource DeadlocksPlanRowStyle}"
ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding EventDate, Converter={StaticResource ServerTimeConverter}}" Width="150">
Expand Down
275 changes: 275 additions & 0 deletions Dashboard/ServerTab.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
Expand All @@ -12,6 +13,7 @@
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Threading;
using Microsoft.Data.SqlClient;
using Microsoft.Win32;
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Interfaces;
Expand Down Expand Up @@ -2044,6 +2046,279 @@
}
}

// ── Blocked Process Report / Deadlock plan lookup ──

/* SQL Server writes this 42-byte all-zero handle into executionStack frames
for dynamic SQL / system contexts where no persistent sql_handle exists.
Filter matches sp_HumanEventsBlockViewer's XPath exclusion. */
private static readonly string ZeroSqlHandle = "0x" + new string('0', 84);

private async void ViewBlockedSidePlan_Click(object sender, RoutedEventArgs e)
=> await ShowBlockedProcessPlanAsync(sender, blockingSide: false);

private async void ViewBlockingSidePlan_Click(object sender, RoutedEventArgs e)
=> await ShowBlockedProcessPlanAsync(sender, blockingSide: true);

private async Task ShowBlockedProcessPlanAsync(object sender, bool blockingSide)
{
if (sender is not MenuItem menuItem) return;
if (menuItem.Parent is not ContextMenu cm) return;
var grid = FindDataGridFromContextMenu(cm);
if (grid?.SelectedItem is not BlockingEventItem row) return;

var sideLabel = blockingSide ? "Blocking" : "Blocked";
var label = $"Est Plan - {sideLabel} SPID {row.Spid}";

var frames = ExtractBlockedProcessFrames(row.BlockedProcessReportXml, blockingSide);
if (frames.Count == 0)
{
MessageBox.Show(
$"The {sideLabel.ToLowerInvariant()} process report has no resolvable sql_handle. " +
"This usually means the query ran as dynamic SQL or a system context — " +
"SQL Server records a zero handle in that case and the plan can't be recovered.",
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}

string? planXml = null;
try
{
var connStr = _serverConnection.GetConnectionString(_credentialService);
foreach (var f in frames)
{
planXml = await FetchPlanBySqlHandleAsync(
connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
if (!string.IsNullOrEmpty(planXml)) break;
}
}
catch { }

if (!string.IsNullOrEmpty(planXml))
{
OpenPlanTab(planXml, label, row.QueryText);
PlanViewerTabItem.IsSelected = true;
}
else
{
MessageBox.Show(
$"The plan for the {sideLabel.ToLowerInvariant()} query is no longer in the plan cache on {_serverConnection.DisplayName}. " +
"Blocked process reports only give us a sql_handle — if that plan has been evicted, we can't recover it.",
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
}
}

private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractBlockedProcessFrames(
string bprXml, bool blockingSide)
{
var empty = Array.Empty<(string, int, int)>();
if (string.IsNullOrWhiteSpace(bprXml)) return empty;
try
{
var doc = System.Xml.Linq.XElement.Parse(bprXml);
var processContainer = blockingSide
? doc.Element("blocking-process")
: doc.Element("blocked-process");
var stack = processContainer?.Element("process")?.Element("executionStack");
if (stack == null) return empty;

var frames = new List<(string, int, int)>();
foreach (var frame in stack.Elements("frame"))
{
var handle = frame.Attribute("sqlhandle")?.Value;
if (string.IsNullOrWhiteSpace(handle)) continue;
if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;

int stmtStart = 0;
int stmtEnd = -1;
int.TryParse(frame.Attribute("stmtstart")?.Value, out stmtStart);

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2133 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractBlockedProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)
if (int.TryParse(frame.Attribute("stmtend")?.Value, out var se)) stmtEnd = se;

frames.Add((handle!, stmtStart, stmtEnd));
}
return frames;
}
catch
{
return empty;
}
}

/* Deadlock graph XML puts sqlhandle/stmtstart/stmtend directly on the
<process> node, with optional <executionStack><frame sqlhandle=...>
children for the call stack. Match by SPID since Dashboard's row
model doesn't carry the process graph id. */
private async void ViewDeadlockProcessPlan_Click(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem menuItem) return;
if (menuItem.Parent is not ContextMenu cm) return;
var grid = FindDataGridFromContextMenu(cm);
if (grid?.SelectedItem is not DeadlockItem row) return;

var sideLabel = string.IsNullOrWhiteSpace(row.DeadlockType) ? "Process" : row.DeadlockType;
var label = $"Est Plan - {sideLabel} SPID {row.Spid}";

var frames = ExtractDeadlockProcessFrames(row.DeadlockGraph, row.Spid);
if (frames.Count == 0)
{
MessageBox.Show(
"The process has no resolvable sql_handle in the deadlock graph. " +
"This usually means the query ran as dynamic SQL or a system context — " +
"SQL Server records a zero handle in that case and the plan can't be recovered.",
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}

string? planXml = null;
try
{
var connStr = _serverConnection.GetConnectionString(_credentialService);
foreach (var f in frames)
{
planXml = await FetchPlanBySqlHandleAsync(
connStr, row.DatabaseName, f.SqlHandle, f.StmtStart, f.StmtEnd);
if (!string.IsNullOrEmpty(planXml)) break;
}
}
catch { }

if (!string.IsNullOrEmpty(planXml))
{
OpenPlanTab(planXml, label, row.Query);
PlanViewerTabItem.IsSelected = true;
}
else
{
MessageBox.Show(
$"The plan for this process is no longer in the plan cache on {_serverConnection.DisplayName}. " +
"Deadlock graphs only give us a sql_handle — if that plan has been evicted, we can't recover it.",
"No Plan Available", MessageBoxButton.OK, MessageBoxImage.Information);
}
}

private static IReadOnlyList<(string SqlHandle, int StmtStart, int StmtEnd)> ExtractDeadlockProcessFrames(
string graphXml, short? spid)
{
var empty = Array.Empty<(string, int, int)>();
if (string.IsNullOrWhiteSpace(graphXml) || !spid.HasValue) return empty;
try
{
var doc = System.Xml.Linq.XElement.Parse(graphXml);
var spidStr = spid.Value.ToString(CultureInfo.InvariantCulture);
var process = doc.Descendants("process")
.FirstOrDefault(p => string.Equals(p.Attribute("spid")?.Value, spidStr, StringComparison.Ordinal));
if (process == null) return empty;

var frames = new List<(string, int, int)>();

var procHandle = process.Attribute("sqlhandle")?.Value;
if (!string.IsNullOrWhiteSpace(procHandle) &&
!string.Equals(procHandle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase))
{
int ps = 0, pe = -1;
int.TryParse(process.Attribute("stmtstart")?.Value, out ps);

Check warning on line 2218 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2218 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2218 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2218 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2218 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)
if (int.TryParse(process.Attribute("stmtend")?.Value, out var peParsed)) pe = peParsed;
frames.Add((procHandle!, ps, pe));
}

var stack = process.Element("executionStack");
if (stack != null)
{
foreach (var frame in stack.Elements("frame"))
{
var handle = frame.Attribute("sqlhandle")?.Value;
if (string.IsNullOrWhiteSpace(handle)) continue;
if (string.Equals(handle, ZeroSqlHandle, StringComparison.OrdinalIgnoreCase)) continue;

int fs = 0, fe = -1;
int.TryParse(frame.Attribute("stmtstart")?.Value, out fs);

Check warning on line 2233 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2233 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2233 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2233 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)

Check warning on line 2233 in Dashboard/ServerTab.xaml.cs

View workflow job for this annotation

GitHub Actions / build

ExtractDeadlockProcessFrames calls TryParse but does not explicitly check whether the conversion succeeded. Either use the return value in a conditional statement or verify that the call site expects that the out argument will be set to the default value when the conversion fails. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1806)
if (int.TryParse(frame.Attribute("stmtend")?.Value, out var feParsed)) fe = feParsed;
frames.Add((handle!, fs, fe));
}
}

return frames;
}
catch
{
return empty;
}
}

private static async Task<string?> FetchPlanBySqlHandleAsync(
string connectionString,
string databaseName,
string sqlHandleHex,
int statementStartOffset,
int statementEndOffset)
{
if (string.IsNullOrWhiteSpace(sqlHandleHex)) return null;
var handleBytes = HexStringToBytes(sqlHandleHex);
if (handleBytes == null || handleBytes.Length == 0) return null;

using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();

/* Database context is only used to route the execution; sys.dm_exec_query_stats
is server-scoped, so if the supplied name isn't valid we fall back to master. */
var quotedDbName = QuoteDatabaseName(databaseName) ?? "[master]";

var query = $@"
EXECUTE {quotedDbName}.sys.sp_executesql
N'
SELECT TOP (1)
query_plan_text = tqp.query_plan
FROM sys.dm_exec_query_stats AS qs
OUTER APPLY sys.dm_exec_text_query_plan(qs.plan_handle, qs.statement_start_offset, qs.statement_end_offset) AS tqp
WHERE qs.sql_handle = @h
AND qs.statement_start_offset = @stmt_start
AND qs.statement_end_offset = @stmt_end
AND tqp.query_plan IS NOT NULL
ORDER BY
qs.last_execution_time DESC
OPTION(RECOMPILE);',
N'@h varbinary(64), @stmt_start int, @stmt_end int',
@h, @stmt_start, @stmt_end;";

using var command = new SqlCommand(query, connection) { CommandTimeout = 30 };
command.Parameters.Add(new SqlParameter("@h", SqlDbType.VarBinary, 64) { Value = handleBytes });
command.Parameters.Add(new SqlParameter("@stmt_start", SqlDbType.Int) { Value = statementStartOffset });
command.Parameters.Add(new SqlParameter("@stmt_end", SqlDbType.Int) { Value = statementEndOffset });
var result = await command.ExecuteScalarAsync();
return result as string;
}

private static byte[]? HexStringToBytes(string hex)
{
var start = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? 2 : 0;
var len = hex.Length - start;
if (len <= 0 || (len % 2) != 0) return null;
var bytes = new byte[len / 2];
for (int i = 0; i < bytes.Length; i++)
{
if (!byte.TryParse(hex.AsSpan(start + i * 2, 2),
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out bytes[i]))
{
return null;
}
}
return bytes;
}

/* Only accept names that are syntactically plain identifiers so we can safely
interpolate into the EXEC statement. Unknown / invalid names fall back to master. */
private static string? QuoteDatabaseName(string? dbName)
{
if (string.IsNullOrWhiteSpace(dbName)) return null;
foreach (var c in dbName)
{
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '$' || c == '#' || c == '-' || c == ' '))
return null;
}
return "[" + dbName.Replace("]", "]]") + "]";
}

private void LoadUserPreferences()
{
var prefs = _preferencesService.GetPreferences();
Expand Down
Loading