Skip to content

Commit 838f40f

Browse files
Merge pull request #220 from erikdarlingdata/dev
Release v1.5.0 — maximum benefit scoring
2 parents cbd0c6d + 3ca9398 commit 838f40f

17 files changed

Lines changed: 736 additions & 40 deletions

File tree

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ public void LoadPlan(string planXml, string label, string? queryText = null)
275275

276276
_currentPlan = ShowPlanParser.Parse(planXml);
277277
PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load());
278+
BenefitScorer.Score(_currentPlan);
278279

279280
var allStatements = _currentPlan.Batches
280281
.SelectMany(b => b.Statements)
@@ -1725,14 +1726,21 @@ private void ShowPropertiesPanel(PlanNode node)
17251726
if (s.PlanWarnings.Count > 0)
17261727
{
17271728
var planWarningsPanel = new StackPanel();
1728-
foreach (var w in s.PlanWarnings)
1729+
var sortedPlanWarnings = s.PlanWarnings
1730+
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
1731+
.ThenByDescending(w => w.Severity)
1732+
.ThenBy(w => w.WarningType);
1733+
foreach (var w in sortedPlanWarnings)
17291734
{
17301735
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
17311736
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
17321737
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
1738+
var planWarnHeader = w.MaxBenefitPercent.HasValue
1739+
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
1740+
: $"\u26A0 {w.WarningType}";
17331741
warnPanel.Children.Add(new TextBlock
17341742
{
1735-
Text = $"\u26A0 {w.WarningType}",
1743+
Text = planWarnHeader,
17361744
FontWeight = FontWeight.SemiBold,
17371745
FontSize = 11,
17381746
Foreground = new SolidColorBrush(Color.Parse(warnColor))
@@ -1788,14 +1796,21 @@ private void ShowPropertiesPanel(PlanNode node)
17881796
if (node.HasWarnings)
17891797
{
17901798
var warningsPanel = new StackPanel();
1791-
foreach (var w in node.Warnings)
1799+
var sortedNodeWarnings = node.Warnings
1800+
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
1801+
.ThenByDescending(w => w.Severity)
1802+
.ThenBy(w => w.WarningType);
1803+
foreach (var w in sortedNodeWarnings)
17921804
{
17931805
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
17941806
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
17951807
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
1808+
var nodeWarnHeader = w.MaxBenefitPercent.HasValue
1809+
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
1810+
: $"\u26A0 {w.WarningType}";
17961811
warnPanel.Children.Add(new TextBlock
17971812
{
1798-
Text = $"\u26A0 {w.WarningType}",
1813+
Text = nodeWarnHeader,
17991814
FontWeight = FontWeight.SemiBold,
18001815
FontSize = 11,
18011816
Foreground = new SolidColorBrush(Color.Parse(warnColor))
@@ -2140,18 +2155,21 @@ private object BuildNodeTooltipContent(PlanNode node, List<PlanWarning>? allWarn
21402155

21412156
if (allWarnings != null)
21422157
{
2143-
// Root node: show distinct warning type names only
2158+
// Root node: show distinct warning type names only, sorted by max benefit
21442159
var distinct = warnings
21452160
.GroupBy(w => w.WarningType)
2146-
.Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count()))
2147-
.OrderByDescending(g => g.MaxSeverity)
2161+
.Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(),
2162+
MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1)))
2163+
.OrderByDescending(g => g.MaxBenefit)
2164+
.ThenByDescending(g => g.MaxSeverity)
21482165
.ThenBy(g => g.Type);
21492166

2150-
foreach (var (type, severity, count) in distinct)
2167+
foreach (var (type, severity, count, maxBenefit) in distinct)
21512168
{
21522169
var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
21532170
: severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
2154-
var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}";
2171+
var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : "";
2172+
var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}";
21552173
stack.Children.Add(new TextBlock
21562174
{
21572175
Text = label,

src/PlanViewer.App/Mcp/McpQueryStoreTools.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public static async Task<string> GetQueryStoreTop(
124124
.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
125125
var parsed = ShowPlanParser.Parse(xml);
126126
PlanAnalyzer.Analyze(parsed);
127+
BenefitScorer.Score(parsed);
127128

128129
var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList();
129130

src/PlanViewer.App/PlanViewer.App.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<ApplicationManifest>app.manifest</ApplicationManifest>
77
<ApplicationIcon>EDD.ico</ApplicationIcon>
88
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
9-
<Version>1.4.3</Version>
9+
<Version>1.5.0</Version>
1010
<Authors>Erik Darling</Authors>
1111
<Company>Darling Data LLC</Company>
1212
<Product>Performance Studio</Product>

src/PlanViewer.App/Services/AdviceContentBuilder.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Avalonia.Layout;
88
using Avalonia.Media;
99
using PlanViewer.Core.Output;
10+
using PlanViewer.Core.Services;
1011

1112
namespace PlanViewer.App.Services;
1213

@@ -994,6 +995,12 @@ private static StackPanel CreateWaitStatLine(string waitName, string waitValue,
994995
var waitBrush = GetWaitCategoryBrush(waitName);
995996
tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush });
996997
tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush });
998+
999+
// Inline description label for the wait type
1000+
var label = PlanAnalyzer.GetWaitLabel(waitName);
1001+
if (!string.IsNullOrEmpty(label))
1002+
tb.Inlines.Add(new Run(" " + label) { Foreground = MutedBrush, FontSize = 11 });
1003+
9971004
wrapper.Children.Add(tb);
9981005

9991006
// Proportional bar scaled to max wait in group
@@ -1119,6 +1126,41 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType)
11191126
items.Add(($"Memory grant: {grantedMB:F1} MB ({usedPct:F0}% used)", memBrush));
11201127
}
11211128

1129+
// Wait profile classification
1130+
if (stmt.WaitStats.Count > 0)
1131+
{
1132+
var totalMs = stmt.WaitStats.Sum(w => w.WaitTimeMs);
1133+
if (totalMs > 0)
1134+
{
1135+
long ioMs = 0, cpuMs = 0, parallelMs = 0, lockMs = 0;
1136+
foreach (var w in stmt.WaitStats)
1137+
{
1138+
var wt = w.WaitType.ToUpperInvariant();
1139+
if (wt.StartsWith("PAGEIOLATCH") || wt.Contains("IO_COMPLETION"))
1140+
ioMs += w.WaitTimeMs;
1141+
else if (wt == "SOS_SCHEDULER_YIELD")
1142+
cpuMs += w.WaitTimeMs;
1143+
else if (wt.StartsWith("CX"))
1144+
parallelMs += w.WaitTimeMs;
1145+
else if (wt.StartsWith("LCK_"))
1146+
lockMs += w.WaitTimeMs;
1147+
}
1148+
1149+
// Pick the dominant category (>= 30% of total)
1150+
var categories = new List<(string label, long ms)>();
1151+
if (ioMs * 100 / totalMs >= 30) categories.Add(("I/O", ioMs));
1152+
if (cpuMs * 100 / totalMs >= 30) categories.Add(("CPU", cpuMs));
1153+
if (parallelMs * 100 / totalMs >= 30) categories.Add(("parallelism", parallelMs));
1154+
if (lockMs * 100 / totalMs >= 30) categories.Add(("lock contention", lockMs));
1155+
1156+
if (categories.Count > 0)
1157+
{
1158+
var label = string.Join(" + ", categories.Select(c => c.label));
1159+
items.Add(($"{label} bound ({totalMs:N0}ms total wait time)", InfoBrush));
1160+
}
1161+
}
1162+
}
1163+
11221164
// Warning counts by severity
11231165
var criticalCount = stmt.Warnings.Count(w =>
11241166
w.Severity.Equals("Critical", StringComparison.OrdinalIgnoreCase));

src/PlanViewer.Cli/Commands/AnalyzeCommand.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo
207207

208208
var plan = ShowPlanParser.Parse(planXml);
209209
PlanAnalyzer.Analyze(plan, analyzerConfig);
210+
BenefitScorer.Score(plan);
210211

211212
if (plan.Batches.Count == 0)
212213
{
@@ -400,6 +401,7 @@ private static async Task RunLiveAsync(
400401
// Parse, analyze, map result
401402
var plan = ShowPlanParser.Parse(planXml);
402403
PlanAnalyzer.Analyze(plan, analyzerConfig);
404+
BenefitScorer.Score(plan);
403405
var result = ResultMapper.Map(plan, $"{name}.sql");
404406

405407
if (warningsOnly)

src/PlanViewer.Cli/Commands/QueryStoreCommand.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ private static async Task RunAsync(
273273
// Parse, analyze, map
274274
var plan = ShowPlanParser.Parse(qsPlan.PlanXml);
275275
PlanAnalyzer.Analyze(plan, analyzerConfig);
276+
BenefitScorer.Score(plan);
276277
var result = ResultMapper.Map(plan, $"{label}.sqlplan");
277278

278279
if (warningsOnly)

src/PlanViewer.Core/Models/PlanModels.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,17 @@ public class PlanWarning
373373
public string Message { get; set; } = "";
374374
public PlanWarningSeverity Severity { get; set; }
375375
public SpillDetail? SpillDetails { get; set; }
376+
377+
/// <summary>
378+
/// Maximum percentage of elapsed time that could be saved by addressing this finding.
379+
/// null = not quantifiable, 0 = calculated as negligible.
380+
/// </summary>
381+
public double? MaxBenefitPercent { get; set; }
382+
383+
/// <summary>
384+
/// Short actionable fix suggestion (e.g., "Add INCLUDE (columns) to index").
385+
/// </summary>
386+
public string? ActionableFix { get; set; }
376387
}
377388

378389
public enum PlanWarningSeverity { Info, Warning, Critical }

src/PlanViewer.Core/Output/AnalysisResult.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ public class WarningResult
208208

209209
[JsonPropertyName("node_id")]
210210
public int? NodeId { get; set; }
211+
212+
[JsonPropertyName("max_benefit_percent")]
213+
public double? MaxBenefitPercent { get; set; }
214+
215+
[JsonPropertyName("actionable_fix")]
216+
public string? ActionableFix { get; set; }
211217
}
212218

213219
public class MissingIndexResult

src/PlanViewer.Core/Output/HtmlExporter.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ .card h3 {
185185
.sev-info { color: var(--info); }
186186
.warn-op { font-size: 0.75rem; font-weight: 500; color: var(--text-secondary); }
187187
.warn-type { font-size: 0.75rem; font-weight: 600; }
188+
.warn-benefit { font-size: 0.7rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.04); }
188189
.warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; }
189190
190191
/* Query text */
@@ -428,14 +429,22 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
428429
if (infoCount > 0) sb.Append($" <span class=\"warn-badge info\">{infoCount}</span>");
429430
sb.AppendLine("</h3>");
430431

431-
foreach (var w in allWarnings)
432+
// Sort by benefit descending (nulls last), then severity, then type
433+
var sorted = allWarnings
434+
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
435+
.ThenBy(w => w.Severity switch { "Critical" => 0, "Warning" => 1, _ => 2 })
436+
.ThenBy(w => w.Type);
437+
438+
foreach (var w in sorted)
432439
{
433440
var sevLower = w.Severity.ToLower();
434441
sb.AppendLine($"<div class=\"warning-item {sevLower}\">");
435442
sb.AppendLine($"<span class=\"sev sev-{sevLower}\">{Encode(w.Severity)}</span>");
436443
if (w.Operator != null)
437444
sb.AppendLine($"<span class=\"warn-op\">{Encode(w.Operator)}</span>");
438445
sb.AppendLine($"<span class=\"warn-type\">{Encode(w.Type)}</span>");
446+
if (w.MaxBenefitPercent.HasValue)
447+
sb.AppendLine($"<span class=\"warn-benefit\">up to {w.MaxBenefitPercent:N0}% benefit</span>");
439448
sb.AppendLine($"<span class=\"warn-msg\">{Encode(w.Message)}</span>");
440449
sb.AppendLine("</div>");
441450
}

src/PlanViewer.Core/Output/ResultMapper.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ private static StatementResult MapStatement(PlanStatement stmt)
158158
{
159159
Type = w.WarningType,
160160
Severity = w.Severity.ToString(),
161-
Message = w.Message
161+
Message = w.Message,
162+
MaxBenefitPercent = w.MaxBenefitPercent,
163+
ActionableFix = w.ActionableFix
162164
});
163165
}
164166

@@ -259,7 +261,9 @@ private static OperatorResult MapNode(PlanNode node)
259261
Severity = w.Severity.ToString(),
260262
Message = w.Message,
261263
Operator = $"{node.PhysicalOp} (Node {node.NodeId})",
262-
NodeId = node.NodeId
264+
NodeId = node.NodeId,
265+
MaxBenefitPercent = w.MaxBenefitPercent,
266+
ActionableFix = w.ActionableFix
263267
});
264268
}
265269

0 commit comments

Comments
 (0)