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
46 changes: 41 additions & 5 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public partial class PlanViewerControl : UserControl
private const double MaxZoom = 3.0;
private string _label = "";

/// <summary>
/// Full path on disk when the plan was loaded from a file.
/// </summary>
public string? SourceFilePath { get; set; }

// Node selection
private Border? _selectedNodeBorder;
private IBrush? _selectedNodeOriginalBorder;
Expand Down Expand Up @@ -418,14 +423,21 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
// Operator name
var fgBrush = FindBrushResource("ForegroundBrush");

// Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc.
var opLabel = node.PhysicalOp;
if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp)
&& node.LogicalOp != "Parallelism")
{
opLabel = $"Parallelism\n({node.LogicalOp})";
}
stack.Children.Add(new TextBlock
{
Text = node.PhysicalOp,
Text = opLabel,
FontSize = 10,
FontWeight = FontWeight.SemiBold,
Foreground = fgBrush,
TextAlignment = TextAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.Wrap,
MaxWidth = PlanLayoutEngine.NodeWidth - 16,
HorizontalAlignment = HorizontalAlignment.Center
});
Expand Down Expand Up @@ -471,7 +483,7 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
stack.Children.Add(new TextBlock
{
Text = $"CPU: {ownCpuSec:F3}s",
FontSize = 9,
FontSize = 10,
Foreground = cpuBrush,
TextAlignment = TextAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center
Expand Down Expand Up @@ -2248,10 +2260,34 @@ private static long GetOwnElapsedMs(PlanNode node)
if (node.ActualElapsedMs <= 0) return 0;
var mode = node.ActualExecutionMode ?? node.ExecutionMode;
if (mode == "Batch") return node.ActualElapsedMs;
var childSum = GetChildElapsedMsSum(node);
return Math.Max(0, node.ActualElapsedMs - childSum);

// Exchange operators: Thread 0 is the coordinator whose elapsed time is the
// wall clock for the entire parallel branch — not the operator's own work.
if (IsExchangeOperator(node))
{
// If we have worker thread data, use max of worker threads
var workerMax = node.PerThreadStats
.Where(t => t.ThreadId > 0)
.Select(t => t.ActualElapsedMs)
.DefaultIfEmpty(0)
.Max();
if (workerMax > 0)
{
var childSum = GetChildElapsedMsSum(node);
return Math.Max(0, workerMax - childSum);
}
// Thread 0 only (coordinator) — exchange does negligible own work
return 0;
}

var childElapsedSum = GetChildElapsedMsSum(node);
return Math.Max(0, node.ActualElapsedMs - childElapsedSum);
}

private static bool IsExchangeOperator(PlanNode node) =>
node.PhysicalOp == "Parallelism"
|| node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams";

private static long GetChildCpuMsSum(PlanNode node)
{
long sum = 0;
Expand Down
30 changes: 30 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ private void LoadPlanFile(string filePath)

var viewer = new PlanViewerControl();
viewer.LoadPlan(xml, fileName);
viewer.SourceFilePath = filePath;

// Wrap viewer with advice toolbar
var content = CreatePlanTabContent(viewer);
Expand Down Expand Up @@ -859,11 +860,17 @@ private TabItem CreateTab(string label, Control content)
closeBtn.Click += CloseTab_Click;

// Right-click context menu
var copyPathItem = new MenuItem { Header = "Copy Path", Tag = tab };
// Only visible when tab content has a file path
var filePath = GetTabFilePath(tab);
copyPathItem.IsVisible = filePath != null;

var contextMenu = new ContextMenu
{
Items =
{
new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } },
copyPathItem,
new Separator(),
new MenuItem { Header = "Close", Tag = tab, InputGesture = new KeyGesture(Key.W, KeyModifiers.Control) },
new MenuItem { Header = "Close Other Tabs", Tag = tab },
Expand Down Expand Up @@ -901,6 +908,15 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e)
StartRename((StackPanel)parts[0], (TextBlock)parts[1]);
break;

case "Copy Path":
if (item.Tag is TabItem pathTab)
{
var path = GetTabFilePath(pathTab);
if (path != null)
_ = this.Clipboard?.SetTextAsync(path);
}
break;

case "Close":
if (item.Tag is TabItem tab)
{
Expand All @@ -927,6 +943,20 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e)
}
}

private static string? GetTabFilePath(TabItem tab)
{
// Plans opened from file are wrapped in a DockPanel with the viewer as the last child
if (tab.Content is DockPanel dp)
{
foreach (var child in dp.Children)
{
if (child is PlanViewerControl v)
return v.SourceFilePath;
}
}
return null;
}

private void StartRename(StackPanel header, TextBlock headerText)
{
var textBox = new TextBox
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.Core/Output/TextFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -430,4 +430,5 @@ private static long GetChildElapsedSum(OperatorResult node)
}
return sum;
}

}
1 change: 1 addition & 0 deletions src/PlanViewer.Core/Services/PlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@
// Rule 28: Row Count Spool — NOT IN with nullable column
// Pattern: Row Count Spool with high rewinds, child scan has IS NULL predicate,
// and statement text contains NOT IN
if (!cfg.IsRuleDisabled(28) && node.PhysicalOp.Contains("Row Count Spool"))

Check warning on line 883 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.

Check warning on line 883 in src/PlanViewer.Core/Services/PlanAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Dereference of a possibly null reference.
{
var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds;
if (rewinds > 10000 && HasNotInPattern(node, stmt))
Expand Down Expand Up @@ -1003,6 +1003,7 @@
!node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase);
}


/// <summary>
/// Detects non-SARGable patterns in scan predicates.
/// Returns a description of the issue, or null if the predicate is fine.
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.Core/Services/ShowPlanParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.PhysicalOp = "Lazy " + node.PhysicalOp;
}


// Map to icon
node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp);

Expand Down
Loading