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
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors:
website: "https://erikdarling.com"
repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
license: MIT
version: "1.20.0"
version: "1.21.0"
date-released: "2026-08-21"
keywords:
- sql-server
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.20.0</Version>
<Version>1.21.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
43 changes: 43 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ private void SelectNode(Border border, PlanNode node)
UpdateMinimapSelection(node);
}

/// <summary>
/// Selects the operator with <paramref name="nodeId"/> and scrolls it into view, so a warning
/// can take you to where it came from (#440). Returns false when the plan has no such operator,
/// which is what keeps a stale or wrong origin from silently scrolling somewhere arbitrary.
/// </summary>
private bool TryNavigateToNode(int nodeId)
{
foreach (var child in PlanCanvas.Children)
{
if (child is not Border border ||
!_nodeBorderMap.TryGetValue(border, out var node) ||
node.NodeId != nodeId)
{
continue;
}

SelectNode(border, node);
ScrollNodeIntoView(node);
return true;
}

return false;
}

/// <summary>
/// Centres the operator in the viewport. Node coordinates are unscaled layout positions, so they
/// are multiplied by the zoom level to get canvas pixels; the offset is then clamped, because
/// asking a ScrollViewer for a negative offset on a plan smaller than the viewport just leaves
/// it where it was and looks like the navigation did nothing.
/// </summary>
private void ScrollNodeIntoView(PlanNode node)
{
var targetX = node.X * _zoomLevel - (PlanScrollViewer.Bounds.Width / 2);
var targetY = node.Y * _zoomLevel - (PlanScrollViewer.Bounds.Height / 2);

var maxX = Math.Max(0, PlanScrollViewer.Extent.Width - PlanScrollViewer.Viewport.Width);
var maxY = Math.Max(0, PlanScrollViewer.Extent.Height - PlanScrollViewer.Viewport.Height);

PlanScrollViewer.Offset = new Vector(
Math.Clamp(targetX, 0, maxX),
Math.Clamp(targetY, 0, maxY));
}

private ContextMenu BuildNodeContextMenu(PlanNode node)
{
var menu = new ContextMenu();
Expand Down
78 changes: 76 additions & 2 deletions src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -822,13 +822,15 @@ private void ShowPropertiesPanel(PlanNode node)
var planWarnHeader = w.MaxBenefitPercent.HasValue
? $"\u26A0 {w.WarningType}{sourceTag}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: $"\u26A0 {w.WarningType}{sourceTag}{legacyTag}";
warnPanel.Children.Add(new TextBlock
var planWarnHeaderBlock = new TextBlock
{
Text = planWarnHeader,
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse(warnColor))
});
};
AttachOriginNavigation(planWarnHeaderBlock, planWarnHeader, w.OriginNodeIds);
warnPanel.Children.Add(planWarnHeaderBlock);
warnPanel.Children.Add(new TextBlock
{
Text = w.Message,
Expand Down Expand Up @@ -875,6 +877,78 @@ private void ShowPropertiesPanel(PlanNode node)
PropertiesContent.Children.Add(planWarningsExpander);
}

/* === Operator Warnings (#440) ===
Every warning hanging off an operator anywhere in this statement, gathered in one
place and each one a link to its operator.

This section is the point of #440. The reporter's case was "the plan is huge and the
warning origin is murky", and the operator warnings are exactly the ones with a
murky origin - but until now the only way to see one was to already have clicked the
operator it was on, which is no help when you do not know which operator to click.
Nothing is removed from the per-operator panel; this is an index into it. */
var operatorWarnings = WarningIndex.CollectOperatorWarnings(s.RootNode);
if (operatorWarnings.Count > 0)
{
var operatorWarningsPanel = new StackPanel();
foreach (var (originNode, w) in operatorWarnings
.OrderByDescending(x => x.Warning.MaxBenefitPercent ?? -1)
.ThenByDescending(x => x.Warning.Severity)
.ThenBy(x => x.Warning.WarningType))
{
var opWarnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var opWarnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
var opBenefit = w.MaxBenefitPercent.HasValue
? $" \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: "";
var opHeaderText =
$"\u26A0 {w.WarningType}{WarningSourceTag(w)}{(w.IsLegacy ? " [legacy]" : "")}{opBenefit}";
var opHeader = new TextBlock
{
Text = opHeaderText,
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse(opWarnColor))
};
AttachOriginNavigation(opHeader, opHeaderText, w.OriginNodeIds);
opWarnPanel.Children.Add(opHeader);
opWarnPanel.Children.Add(new TextBlock
{
Text = OperatorOriginLabel(originNode),
FontSize = 11,
Foreground = SectionHeaderBrush,
Margin = new Thickness(16, 0, 0, 0)
});
operatorWarningsPanel.Children.Add(opWarnPanel);
}

var operatorWarningsExpander = new Expander
{
/* Collapsed by default, unlike Plan Warnings. On a large plan this is the
longest section in the panel, and expanding it by default would push the
statement's own details off screen - the opposite of the problem #440 is
about. */
IsExpanded = false,
Header = new TextBlock
{
Text = $"Operator Warnings ({operatorWarnings.Count})",
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = SectionHeaderBrush
},
Content = operatorWarningsPanel,
Margin = new Thickness(0, 2, 0, 0),
Padding = new Thickness(0),
Foreground = SectionHeaderBrush,
Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
BorderBrush = PropSeparatorBrush,
BorderThickness = new Thickness(0, 0, 0, 1),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch
};
PropertiesContent.Children.Add(operatorWarningsExpander);
}

// === Missing Indexes ===
if (s.MissingIndexes.Count > 0)
{
Expand Down
40 changes: 40 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,46 @@ private static string FormatBytes(double bytes)
private static string FormatBenefitPercent(double pct) =>
pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";

/// <summary>
/// How an operator is named in the aggregated warning index (#440) — enough to recognise it
/// before clicking, matching what the operator's own panel puts in its header.
/// </summary>
private static string OperatorOriginLabel(PlanNode node) =>
string.IsNullOrEmpty(node.FullObjectName)
? $"Node {node.NodeId} \u00B7 {node.PhysicalOp}"
: $"Node {node.NodeId} \u00B7 {node.PhysicalOp} on {node.FullObjectName}";

/// <summary>
/// Turns a warning header into a link to the operator it came from (#440).
///
/// <para>Only when the warning actually knows. Findings with no operator origin — "High Compile
/// CPU" happened before a row was read — are left as plain text rather than given a link that
/// goes somewhere arbitrary, because a reader would believe it.</para>
///
/// <para>Where a warning came from several operators, the first is the navigation target and the
/// rest are named in the tooltip, so the count is visible rather than silently dropped.</para>
/// </summary>
private void AttachOriginNavigation(TextBlock header, string headerText, List<int> originNodeIds)
{
if (originNodeIds.Count == 0)
return;

header.Text = headerText + " \u2192";
header.Cursor = new Cursor(StandardCursorType.Hand);
ToolTip.SetTip(header, originNodeIds.Count == 1
? $"Go to operator (Node {originNodeIds[0]})"
: $"Go to Node {originNodeIds[0]} — also from {string.Join(", ", originNodeIds.Skip(1).Select(id => "Node " + id))}");

header.PointerPressed += (_, e) =>
{
if (!e.GetCurrentPoint(header).Properties.IsLeftButtonPressed)
return;

if (TryNavigateToNode(originNodeIds[0]))
e.Handled = true;
};
}

/// <summary>
/// #436: marks the warnings SQL Server itself wrote into the plan, so they are not read as one of
/// our inferences. Only the engine's are tagged — they are the minority, and a badge on every
Expand Down
5 changes: 5 additions & 0 deletions src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ private void SetStatus(string text, bool autoClear = true)

StatusText.Text = text;

/* The bar is one line and trims with an ellipsis, so a long message - an error, usually -
is readable only on hover. Setting the tip to the same text costs nothing when it fits and
is the difference between a truncated error and a recoverable one when it does not. */
ToolTip.SetTip(StatusText, string.IsNullOrEmpty(text) ? null : text);

if (autoClear && !string.IsNullOrEmpty(text))
{
var cts = new CancellationTokenSource();
Expand Down
62 changes: 46 additions & 16 deletions src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,17 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
Margin = new Avalonia.Thickness(0, 0, 0, 12)
};

var statusLabel = new TextBlock
/* #448: SelectableTextBlock and wrapping, because this label doubles as the place a query
failure is reported. A SQL error is the one string in this app a user most needs to copy
somewhere else, and unwrapped it was being clipped by the panel. */
var statusLabel = new SelectableTextBlock
{
Text = $"Capturing {planType.ToLower()} plan...",
FontSize = 14,
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
HorizontalAlignment = HorizontalAlignment.Center
HorizontalAlignment = HorizontalAlignment.Center,
TextAlignment = Avalonia.Media.TextAlignment.Center,
TextWrapping = TextWrapping.Wrap
};

var cancelBtn = new Button
Expand Down Expand Up @@ -211,18 +216,44 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
}
catch (SqlException ex)
{
statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
progressBar.IsVisible = false;
cancelBtn.IsVisible = false;
ShowExecutionFailure(loadingPanel, statusLabel, progressBar, cancelBtn, ex.Message);
}
catch (Exception ex)
{
statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
progressBar.IsVisible = false;
cancelBtn.IsVisible = false;
ShowExecutionFailure(loadingPanel, statusLabel, progressBar, cancelBtn, ex.Message);
}
}

/// <summary>
/// Reports a query failure in the plan tab, in full (#448).
///
/// <para>It used to be cut to 100 characters with an ellipsis, in a panel fixed at 300px wide
/// holding a non-wrapping label — three separate reasons the same message got clipped, and
/// between them a SQL error was routinely unreadable. 100 characters does not even reach the end
/// of "Msg 208, Level 16, State 1, Procedure X, Line N" before the sentence naming the actual
/// problem starts.</para>
///
/// <para>The panel is sized for a spinner and a Cancel button, which is why it is narrow; on
/// failure it is re-sized for prose. MaxWidth rather than Width, so a short error stays compact
/// and a long one is bounded at a readable measure instead of running the width of the window.</para>
/// </summary>
internal static void ShowExecutionFailure(
StackPanel panel,
SelectableTextBlock statusLabel,
ProgressBar progressBar,
Button cancelBtn,
string message)
{
panel.Width = double.NaN;
panel.MaxWidth = 640;

statusLabel.Text = message;
statusLabel.Foreground = new SolidColorBrush(Color.Parse("#E57373"));

progressBar.IsVisible = false;
cancelBtn.IsVisible = false;
}

private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
{
var viewer = GetSelectedPlanViewer();
Expand Down Expand Up @@ -274,12 +305,15 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
Margin = new Avalonia.Thickness(0, 0, 0, 12)
};

var statusLabel = new TextBlock
/* #448: see the note on the estimated-plan path — this label reports failures too. */
var statusLabel = new SelectableTextBlock
{
Text = "Capturing actual plan...",
FontSize = 14,
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
HorizontalAlignment = HorizontalAlignment.Center
HorizontalAlignment = HorizontalAlignment.Center,
TextAlignment = Avalonia.Media.TextAlignment.Center,
TextWrapping = TextWrapping.Wrap
};

var cancelBtn = new Button
Expand Down Expand Up @@ -385,15 +419,11 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
}
catch (SqlException ex)
{
statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
progressBar.IsVisible = false;
cancelBtn.IsVisible = false;
ShowExecutionFailure(loadingPanel, statusLabel, progressBar, cancelBtn, ex.Message);
}
catch (Exception ex)
{
statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
progressBar.IsVisible = false;
cancelBtn.IsVisible = false;
ShowExecutionFailure(loadingPanel, statusLabel, progressBar, cancelBtn, ex.Message);
}
finally
{
Expand Down
36 changes: 34 additions & 2 deletions src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,38 @@ private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e)
}
}

/// <summary>
/// #447: asks the window, not this session. Comparing the plan from one query against the plan
/// from another is the ordinary case, and counting only this session's own tabs left the button
/// disabled in both — the reporter had to save a plan and reopen it to get at a comparison the
/// app could already do.
/// </summary>
private void UpdateCompareButtonState()
{
if (TopLevel.GetTopLevel(this) is MainWindow owner)
{
/* Refreshes every session, not just this one: a plan appearing here can be the second
plan that makes Compare available over THERE. */
owner.RefreshComparePlanAvailability();
return;
}

/* No owning window — the control is being hosted somewhere else or is not attached yet.
Fall back to what this session can see rather than leaving the button in a stale state. */
SetCompareAvailability(CountOwnPlans() >= 2);
}

internal void SetCompareAvailability(bool enabled) => ComparePlansButton.IsEnabled = enabled;

private int CountOwnPlans()
{
int planCount = 0;
foreach (var item in SubTabControl.Items)
{
if (item is TabItem t && t.Content is PlanViewerControl v && v.CurrentPlan != null)
planCount++;
}
ComparePlansButton.IsEnabled = planCount >= 2;
return planCount;
}

private static string GetTabLabel(TabItem tab)
Expand All @@ -242,10 +265,19 @@ private static string GetTabLabel(TabItem tab)

private void ComparePlans_Click(object? sender, RoutedEventArgs e)
{
/* #447: hand off to the window's picker, which lists plans from every session and labels
them "Query 1 > Plan". This session's own picker cannot see the other query's plan, which
is the whole complaint. */
if (TopLevel.GetTopLevel(this) is MainWindow owner)
{
owner.ShowCompareDialog();
return;
}

var planTabs = GetPlanTabs().ToList();
if (planTabs.Count < 2)
{
SetStatus("Need at least 2 plan tabs to compare");
SetStatus("Need at least 2 plans open to compare");
return;
}

Expand Down
Loading
Loading