Skip to content

Commit 32fea96

Browse files
Merge pull request #227 from erikdarlingdata/dev
Merge dev to main
2 parents 32ed53d + be5c351 commit 32fea96

10 files changed

Lines changed: 411 additions & 7 deletions

File tree

src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public PlanViewerControl()
141141
var layoutTransform = this.FindControl<Avalonia.Controls.LayoutTransformControl>("PlanLayoutTransform")!;
142142
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
143143

144+
Helpers.DataGridBehaviors.Attach(StatementsGrid);
144145
}
145146

146147
/// <summary>
@@ -377,7 +378,7 @@ private void RenderStatement(PlanStatement statement)
377378
// Update banners
378379
ShowMissingIndexes(statement.MissingIndexes);
379380
ShowParameters(statement);
380-
ShowWaitStats(statement.WaitStats, statement.QueryTimeStats != null);
381+
ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null);
381382
ShowRuntimeSummary(statement);
382383
UpdateInsightsHeader();
383384

@@ -2635,7 +2636,7 @@ private static long GetChildElapsedMsSum(PlanNode node)
26352636
return sum;
26362637
}
26372638

2638-
private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
2639+
private void ShowWaitStats(List<WaitStatInfo> waits, List<WaitBenefit> benefits, bool isActualPlan)
26392640
{
26402641
WaitStatsContent.Children.Clear();
26412642

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

26522653
WaitStatsEmpty.IsVisible = false;
26532654

2655+
// Build benefit lookup
2656+
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
2657+
foreach (var wb in benefits)
2658+
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;
2659+
26542660
var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList();
26552661
var maxWait = sorted[0].WaitTimeMs;
26562662
var totalWait = sorted.Sum(w => w.WaitTimeMs);
@@ -2659,10 +2665,10 @@ private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
26592665
WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total";
26602666

26612667
// Build a single Grid for all rows so columns align
2662-
// Name and duration auto-size; bar fills remaining space
2668+
// Name, bar, duration, and benefit columns
26632669
var grid = new Grid
26642670
{
2665-
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto")
2671+
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto")
26662672
};
26672673
for (int i = 0; i < sorted.Count; i++)
26682674
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
@@ -2709,11 +2715,27 @@ private void ShowWaitStats(List<WaitStatInfo> waits, bool isActualPlan)
27092715
FontSize = 12,
27102716
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
27112717
VerticalAlignment = VerticalAlignment.Center,
2712-
Margin = new Thickness(0, 2, 0, 2)
2718+
Margin = new Thickness(0, 2, 8, 2)
27132719
};
27142720
Grid.SetRow(durationText, i);
27152721
Grid.SetColumn(durationText, 2);
27162722
grid.Children.Add(durationText);
2723+
2724+
// Benefit % (if available)
2725+
if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0)
2726+
{
2727+
var benefitText = new TextBlock
2728+
{
2729+
Text = $"up to {benefitPct:N0}%",
2730+
FontSize = 11,
2731+
Foreground = new SolidColorBrush(Color.Parse("#8b949e")),
2732+
VerticalAlignment = VerticalAlignment.Center,
2733+
Margin = new Thickness(0, 2, 0, 2)
2734+
};
2735+
Grid.SetRow(benefitText, i);
2736+
Grid.SetColumn(benefitText, 3);
2737+
grid.Children.Add(benefitText);
2738+
}
27172739
}
27182740

27192741
WaitStatsContent.Children.Add(grid);

src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
6363
_slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
6464
InitializeComponent();
6565
ResultsGrid.ItemsSource = _filteredRows;
66+
Helpers.DataGridBehaviors.Attach(ResultsGrid);
6667
EnsureFilterPopup();
6768
SetupColumnHeaders();
6869
PopulateDatabaseBox(databases, initialDatabase);

src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
104104
_maxHoursBack = slicerDaysBack * 24;
105105
InitializeComponent();
106106

107+
Helpers.DataGridBehaviors.Attach(HistoryDataGrid);
108+
107109
QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
108110
QueryTextBox.Text = queryText;
109111

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using System;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using Avalonia.Controls.Primitives;
5+
using Avalonia.Input;
6+
using Avalonia.Interactivity;
7+
using Avalonia.VisualTree;
8+
9+
namespace PlanViewer.App.Helpers;
10+
11+
/// <summary>
12+
/// Attaches middle-mouse-button pan behavior to a DataGrid.
13+
/// </summary>
14+
public static class DataGridBehaviors
15+
{
16+
/// <summary>Attach middle-click pan behavior to <paramref name="grid"/>.</summary>
17+
public static void Attach(DataGrid grid)
18+
{
19+
AttachMiddleClickPan(grid);
20+
}
21+
22+
// ─────────────────────────────────────────────────────────────────────────
23+
// Middle-mouse-button drag → pan (scroll) the grid
24+
// ─────────────────────────────────────────────────────────────────────────
25+
26+
private static void AttachMiddleClickPan(DataGrid grid)
27+
{
28+
Point panStart = default;
29+
double scrollStartH = 0, scrollStartV = 0;
30+
bool panning = false;
31+
ScrollBar? hBar = null, vBar = null;
32+
bool barsResolved = false;
33+
34+
// Avalonia's DataGrid has no ScrollViewer in its template — it manages scrolling
35+
// itself via PART_HorizontalScrollbar and PART_VerticalScrollbar. Resolve them
36+
// lazily (visual tree isn't populated until after TemplateApplied).
37+
void ResolveScrollBars()
38+
{
39+
if (barsResolved) return;
40+
barsResolved = true;
41+
foreach (var d in grid.GetVisualDescendants())
42+
{
43+
if (d is not ScrollBar sb) continue;
44+
if (sb.Name == "PART_HorizontalScrollbar") hBar = sb;
45+
else if (sb.Name == "PART_VerticalScrollbar") vBar = sb;
46+
if (hBar != null && vBar != null) break;
47+
}
48+
}
49+
50+
// Re-resolve scroll bars if the template is ever re-applied.
51+
grid.TemplateApplied += (_, _) => { barsResolved = false; hBar = null; vBar = null; };
52+
53+
// RoutingStrategies.Direct|Bubble + handledEventsToo:true ensures the handler fires
54+
// even though DataGrid rows/cells mark PointerPressed handled (for row selection).
55+
grid.AddHandler(InputElement.PointerPressedEvent, (object? _, PointerPressedEventArgs e) =>
56+
{
57+
if (e.GetCurrentPoint(grid).Properties.PointerUpdateKind != PointerUpdateKind.MiddleButtonPressed) return;
58+
59+
ResolveScrollBars();
60+
61+
panning = true;
62+
panStart = e.GetPosition(grid);
63+
scrollStartH = hBar?.Value ?? 0;
64+
scrollStartV = vBar?.Value ?? 0;
65+
e.Pointer.Capture(grid);
66+
grid.Cursor = new Cursor(StandardCursorType.SizeAll);
67+
e.Handled = true;
68+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
69+
70+
grid.AddHandler(InputElement.PointerMovedEvent, (object? _, PointerEventArgs e) =>
71+
{
72+
if (!panning) return;
73+
74+
// Release pan if the middle button was lifted outside a PointerReleased event.
75+
if (!e.GetCurrentPoint(grid).Properties.IsMiddleButtonPressed)
76+
{
77+
panning = false;
78+
e.Pointer.Capture(null);
79+
grid.Cursor = null;
80+
return;
81+
}
82+
83+
var delta = e.GetPosition(grid) - panStart;
84+
85+
if (hBar is not null)
86+
{
87+
hBar.Value = Math.Clamp(scrollStartH - delta.X, hBar.Minimum, hBar.Maximum);
88+
// Raise Thumb.DragDeltaEvent on the scrollbar — a public routed event whose
89+
// ScrollBar class handler calls OnScroll → fires Scroll event → DataGrid
90+
// processes the new Value without any reflection on private members.
91+
hBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
92+
}
93+
if (vBar is not null)
94+
{
95+
vBar.Value = Math.Clamp(scrollStartV - delta.Y, vBar.Minimum, vBar.Maximum);
96+
vBar.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent });
97+
}
98+
99+
e.Handled = true;
100+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
101+
102+
grid.AddHandler(InputElement.PointerReleasedEvent, (object? _, PointerReleasedEventArgs e) =>
103+
{
104+
if (!panning) return;
105+
panning = false;
106+
e.Pointer.Capture(null);
107+
grid.Cursor = null;
108+
e.Handled = true;
109+
}, RoutingStrategies.Direct | RoutingStrategies.Bubble, handledEventsToo: true);
110+
}
111+
}

src/PlanViewer.Core/Models/PlanModels.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public class PlanStatement
6262
public SetOptionsInfo? SetOptions { get; set; }
6363
public List<PlanParameter> Parameters { get; set; } = new();
6464
public List<WaitStatInfo> WaitStats { get; set; } = new();
65+
public List<WaitBenefit> WaitBenefits { get; set; } = new();
6566
public QueryTimeInfo? QueryTimeStats { get; set; }
6667

6768
// MaxQueryMemory + QueryPlan-level warnings
@@ -447,6 +448,13 @@ public class PlanParameter
447448
public string? RuntimeValue { get; set; }
448449
}
449450

451+
public class WaitBenefit
452+
{
453+
public string WaitType { get; set; } = "";
454+
public double MaxBenefitPercent { get; set; }
455+
public string Category { get; set; } = "";
456+
}
457+
450458
public class WaitStatInfo
451459
{
452460
public string WaitType { get; set; } = "";

src/PlanViewer.Core/Output/AnalysisResult.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ public class StatementResult
139139
[JsonPropertyName("wait_stats")]
140140
public List<WaitStatResult> WaitStats { get; set; } = new();
141141

142+
// Wait stats benefit analysis
143+
[JsonPropertyName("wait_benefits")]
144+
public List<WaitBenefitResult> WaitBenefits { get; set; } = new();
145+
142146
// Cursor metadata
143147
[JsonPropertyName("cursor")]
144148
public CursorResult? Cursor { get; set; }
@@ -353,6 +357,18 @@ public class WaitStatResult
353357
public long WaitCount { get; set; }
354358
}
355359

360+
public class WaitBenefitResult
361+
{
362+
[JsonPropertyName("wait_type")]
363+
public string WaitType { get; set; } = "";
364+
365+
[JsonPropertyName("max_benefit_percent")]
366+
public double MaxBenefitPercent { get; set; }
367+
368+
[JsonPropertyName("category")]
369+
public string Category { get; set; } = "";
370+
}
371+
356372
public class CursorResult
357373
{
358374
[JsonPropertyName("name")]

src/PlanViewer.Core/Output/HtmlExporter.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,14 +391,22 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b
391391
sb.AppendLine("<div class=\"card-body\">");
392392
if (stmt.WaitStats.Count > 0)
393393
{
394+
// Build benefit lookup
395+
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
396+
foreach (var wb in stmt.WaitBenefits)
397+
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;
398+
394399
var maxWait = stmt.WaitStats.Max(w => w.WaitTimeMs);
395400
foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs))
396401
{
397402
var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0;
403+
var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct)
404+
? $" <span class=\"warn-benefit\">up to {pct:N0}%</span>"
405+
: "";
398406
sb.AppendLine("<div class=\"wait-row\">");
399407
sb.AppendLine($"<span class=\"wait-type\">{Encode(w.WaitType)}</span>");
400408
sb.AppendLine($"<div class=\"wait-bar-container\"><div class=\"wait-bar\" style=\"width:{barPct:F0}%\"></div></div>");
401-
sb.AppendLine($"<span class=\"wait-ms\">{w.WaitTimeMs:N0} ms</span>");
409+
sb.AppendLine($"<span class=\"wait-ms\">{w.WaitTimeMs:N0} ms{benefitTag}</span>");
402410
sb.AppendLine("</div>");
403411
}
404412
}

src/PlanViewer.Core/Output/ResultMapper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,17 @@ private static StatementResult MapStatement(PlanStatement stmt)
129129
});
130130
}
131131

132+
// Wait stat benefits
133+
foreach (var wb in stmt.WaitBenefits)
134+
{
135+
result.WaitBenefits.Add(new WaitBenefitResult
136+
{
137+
WaitType = wb.WaitType,
138+
MaxBenefitPercent = wb.MaxBenefitPercent,
139+
Category = wb.Category
140+
});
141+
}
142+
132143
// Parameters — flag potential sniffing issues
133144
foreach (var p in stmt.Parameters)
134145
{

src/PlanViewer.Core/Output/TextFormatter.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,18 @@ public static void WriteText(AnalysisResult result, TextWriter writer)
131131
if (stmt.WaitStats.Count > 0)
132132
{
133133
writer.WriteLine("Wait stats:");
134+
// Build a lookup from wait type to benefit %
135+
var benefitLookup = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
136+
foreach (var wb in stmt.WaitBenefits)
137+
benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;
138+
134139
foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs))
135-
writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms");
140+
{
141+
var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct)
142+
? $" (up to {pct:N0}% benefit)"
143+
: "";
144+
writer.WriteLine($" {w.WaitType}: {w.WaitTimeMs:N0}ms{benefitTag}");
145+
}
136146
}
137147

138148
if (stmt.Parameters.Count > 0)

0 commit comments

Comments
 (0)