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
32 changes: 27 additions & 5 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public PlanViewerControl()
var layoutTransform = this.FindControl<Avalonia.Controls.LayoutTransformControl>("PlanLayoutTransform")!;
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;

Helpers.DataGridBehaviors.Attach(StatementsGrid);
}

/// <summary>
Expand Down Expand Up @@ -377,7 +378,7 @@ private void RenderStatement(PlanStatement statement)
// Update banners
ShowMissingIndexes(statement.MissingIndexes);
ShowParameters(statement);
ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null);
ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null);
ShowRuntimeSummary(statement);
UpdateInsightsHeader();

Expand Down Expand Up @@ -2635,7 +2636,7 @@ private static long GetChildElapsedMsSum(PlanNode node)
return sum;
}

private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
private void ShowWaitStats(List<WaitStatInfo> waits, List<WaitBenefit> benefits, bool isActualPlan)
{
WaitStatsContent.Children.Clear();

Expand All @@ -2651,6 +2652,11 @@ private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)

WaitStatsEmpty.IsVisible = false;

// Build benefit lookup
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var wb in benefits)
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;

var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList();
var maxWait = sorted[0].WaitTimeMs;
var totalWait = sorted.Sum(w => w.WaitTimeMs);
Expand All @@ -2659,10 +2665,10 @@ private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total";

// Build a single Grid for all rows so columns align
// Name and duration auto-size; bar fills remaining space
// Name, bar, duration, and benefit columns
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto")
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto")
};
for (int i = 0; i < sorted.Count; i++)
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
Expand Down Expand Up @@ -2709,11 +2715,27 @@ private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
FontSize = 12,
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 0, 2)
Margin = new Thickness(0, 2, 8, 2)
};
Grid.SetRow(durationText, i);
Grid.SetColumn(durationText, 2);
grid.Children.Add(durationText);

// Benefit % (if available)
if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0)
{
var benefitText = new TextBlock
{
Text = $"up to {benefitPct:N0}%",
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse("#8b949e")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 2, 0, 2)
};
Grid.SetRow(benefitText, i);
Grid.SetColumn(benefitText, 3);
grid.Children.Add(benefitText);
}
}

WaitStatsContent.Children.Add(grid);
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
_slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
InitializeComponent();
ResultsGrid.ItemsSource = _filteredRows;
Helpers.DataGridBehaviors.Attach(ResultsGrid);
EnsureFilterPopup();
SetupColumnHeaders();
PopulateDatabaseBox(databases, initialDatabase);
Expand Down
2 changes: 2 additions & 0 deletions src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
_maxHoursBack = slicerDaysBack * 24;
InitializeComponent();

Helpers.DataGridBehaviors.Attach(HistoryDataGrid);

QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
QueryTextBox.Text = queryText;

Expand Down
111 changes: 111 additions & 0 deletions src/PlanViewer.App/Helpers/DataGridBehaviors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;

namespace PlanViewer.App.Helpers;

/// <summary>
/// Attaches middle-mouse-button pan behavior to a DataGrid.
/// </summary>
public static class DataGridBehaviors
{
/// <summary>Attach middle-click pan behavior to <paramref name="grid"/>.</summary>
public static void Attach(DataGrid grid)
{
AttachMiddleClickPan(grid);
}

// ─────────────────────────────────────────────────────────────────────────
// Middle-mouse-button drag → pan (scroll) the grid
// ─────────────────────────────────────────────────────────────────────────

private static void AttachMiddleClickPan(DataGrid grid)
{
Point panStart = default;
double scrollStartH = 0, scrollStartV = 0;
bool panning = false;
ScrollBar? hBar = null, vBar = null;
bool barsResolved = false;

// Avalonia's DataGrid has no ScrollViewer in its template — it manages scrolling
// itself via PART_HorizontalScrollbar and PART_VerticalScrollbar. Resolve them
// lazily (visual tree isn't populated until after TemplateApplied).
void ResolveScrollBars()
{
if (barsResolved) return;
barsResolved = true;
foreach (var d in grid.GetVisualDescendants())
{
if (d is not ScrollBar sb) continue;
if (sb.Name == "PART_HorizontalScrollbar") hBar = sb;
else if (sb.Name == "PART_VerticalScrollbar") vBar = sb;
if (hBar != null && vBar != null) break;
}
}

// Re-resolve scroll bars if the template is ever re-applied.
grid.TemplateApplied += (_, _) => { barsResolved = false; hBar = null; vBar = null; };

// RoutingStrategies.Direct|Bubble + handledEventsToo:true ensures the handler fires
// even though DataGrid rows/cells mark PointerPressed handled (for row selection).
grid.AddHandler(InputElement.PointerPressedEvent, (object? _, PointerPressedEventArgs e) =>
{
if (e.GetCurrentPoint(grid).Properties.PointerUpdateKind != PointerUpdateKind.MiddleButtonPressed) return;

ResolveScrollBars();

panning = true;
panStart = e.GetPosition(grid);
scrollStartH = hBar?.Value ?? 0;
scrollStartV = vBar?.Value ?? 0;
e.Pointer.Capture(grid);
grid.Cursor = new Cursor(StandardCursorType.SizeAll);
e.Handled = true;
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);

grid.AddHandler(InputElement.PointerMovedEvent, (object? _, PointerEventArgs e) =>
{
if (!panning) return;

// Release pan if the middle button was lifted outside a PointerReleased event.
if (!e.GetCurrentPoint(grid).Properties.IsMiddleButtonPressed)
{
panning = false;
e.Pointer.Capture(null);
grid.Cursor = null;
return;
}

var delta = e.GetPosition(grid) - panStart;

if (hBar is not null)
{
hBar.Value = Math.Clamp(scrollStartH - delta.X, hBar.Minimum, hBar.Maximum);
// Raise Thumb.DragDeltaEvent on the scrollbar — a public routed event whose
// ScrollBar class handler calls OnScroll → fires Scroll event → DataGrid
// processes the new Value without any reflection on private members.
hBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
}
if (vBar is not null)
{
vBar.Value = Math.Clamp(scrollStartV - delta.Y, vBar.Minimum, vBar.Maximum);
vBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
}

e.Handled = true;
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);

grid.AddHandler(InputElement.PointerReleasedEvent, (object? _, PointerReleasedEventArgs e) =>
{
if (!panning) return;
panning = false;
e.Pointer.Capture(null);
grid.Cursor = null;
e.Handled = true;
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
}
}
8 changes: 8 additions & 0 deletions src/PlanViewer.Core/Models/PlanModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public class PlanStatement
public SetOptionsInfo? SetOptions { get; set; }
public List<PlanParameter> Parameters { get; set; } = new();
public List<WaitStatInfo> WaitStats { get; set; } = new();
public List<WaitBenefit> WaitBenefits { get; set; } = new();
public QueryTimeInfo? QueryTimeStats { get; set; }

// MaxQueryMemory + QueryPlan-level warnings
Expand Down Expand Up @@ -447,6 +448,13 @@ public class PlanParameter
public string? RuntimeValue { get; set; }
}

public class WaitBenefit
{
public string WaitType { get; set; } = "";
public double MaxBenefitPercent { get; set; }
public string Category { get; set; } = "";
}

public class WaitStatInfo
{
public string WaitType { get; set; } = "";
Expand Down
16 changes: 16 additions & 0 deletions src/PlanViewer.Core/Output/AnalysisResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ public class StatementResult
[JsonPropertyName("wait_stats")]
public List<WaitStatResult> WaitStats { get; set; } = new();

// Wait stats benefit analysis
[JsonPropertyName("wait_benefits")]
public List<WaitBenefitResult> WaitBenefits { get; set; } = new();

// Cursor metadata
[JsonPropertyName("cursor")]
public CursorResult? Cursor { get; set; }
Expand Down Expand Up @@ -353,6 +357,18 @@ public class WaitStatResult
public long WaitCount { get; set; }
}

public class WaitBenefitResult
{
[JsonPropertyName("wait_type")]
public string WaitType { get; set; } = "";

[JsonPropertyName("max_benefit_percent")]
public double MaxBenefitPercent { get; set; }

[JsonPropertyName("category")]
public string Category { get; set; } = "";
}

public class CursorResult
{
[JsonPropertyName("name")]
Expand Down
10 changes: 9 additions & 1 deletion src/PlanViewer.Core/Output/HtmlExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,14 +391,22 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b
sb.AppendLine("<div class=\"card-body\">");
if (stmt.WaitStats.Count > 0)
{
// Build benefit lookup
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var wb in stmt.WaitBenefits)
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;

var maxWait = stmt.WaitStats.Max(w => w.WaitTimeMs);
foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs))
{
var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0;
var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct)
? $" <span class=\"warn-benefit\">up to {pct:N0}%</span>"
: "";
sb.AppendLine("<div class=\"wait-row\">");
sb.AppendLine($"<span class=\"wait-type\">{Encode(w.WaitType)}</span>");
sb.AppendLine($"<div class=\"wait-bar-container\"><div class=\"wait-bar\" style=\"width:{barPct:F0}%\"></div></div>");
sb.AppendLine($"<span class=\"wait-ms\">{w.WaitTimeMs:N0} ms</span>");
sb.AppendLine($"<span class=\"wait-ms\">{w.WaitTimeMs:N0} ms{benefitTag}</span>");
sb.AppendLine("</div>");
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/PlanViewer.Core/Output/ResultMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ private static StatementResult MapStatement(PlanStatement stmt)
});
}

// Wait stat benefits
foreach (var wb in stmt.WaitBenefits)
{
result.WaitBenefits.Add(new WaitBenefitResult
{
WaitType = wb.WaitType,
MaxBenefitPercent = wb.MaxBenefitPercent,
Category = wb.Category
});
}

// Parameters — flag potential sniffing issues
foreach (var p in stmt.Parameters)
{
Expand Down
12 changes: 11 additions & 1 deletion src/PlanViewer.Core/Output/TextFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,18 @@ public static void WriteText(AnalysisResult result, TextWriter writer)
if (stmt.WaitStats.Count > 0)
{
writer.WriteLine("Wait stats:");
// Build a lookup from wait type to benefit %
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var wb in stmt.WaitBenefits)
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;

foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs))
writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms");
{
var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct)
? $" (up to {pct:N0}% benefit)"
: "";
writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms{benefitTag}");
}
}

if (stmt.Parameters.Count > 0)
Expand Down
Loading
Loading