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
36 changes: 27 additions & 9 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ public void LoadPlan(string planXml, string label, string? queryText = null)

_currentPlan = ShowPlanParser.Parse(planXml);
PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load());
BenefitScorer.Score(_currentPlan);

var allStatements = _currentPlan.Batches
.SelectMany(b => b.Statements)
Expand Down Expand Up @@ -1725,14 +1726,21 @@ private void ShowPropertiesPanel(PlanNode node)
if (s.PlanWarnings.Count > 0)
{
var planWarningsPanel = new StackPanel();
foreach (var w in s.PlanWarnings)
var sortedPlanWarnings = s.PlanWarnings
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
.ThenByDescending(w => w.Severity)
.ThenBy(w => w.WarningType);
foreach (var w in sortedPlanWarnings)
{
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
var planWarnHeader = w.MaxBenefitPercent.HasValue
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
: $"\u26A0 {w.WarningType}";
warnPanel.Children.Add(new TextBlock
{
Text = $"\u26A0 {w.WarningType}",
Text = planWarnHeader,
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse(warnColor))
Expand Down Expand Up @@ -1788,14 +1796,21 @@ private void ShowPropertiesPanel(PlanNode node)
if (node.HasWarnings)
{
var warningsPanel = new StackPanel();
foreach (var w in node.Warnings)
var sortedNodeWarnings = node.Warnings
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
.ThenByDescending(w => w.Severity)
.ThenBy(w => w.WarningType);
foreach (var w in sortedNodeWarnings)
{
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
var nodeWarnHeader = w.MaxBenefitPercent.HasValue
? $"\u26A0 {w.WarningType} \u2014 up to {w.MaxBenefitPercent:N0}% benefit"
: $"\u26A0 {w.WarningType}";
warnPanel.Children.Add(new TextBlock
{
Text = $"\u26A0 {w.WarningType}",
Text = nodeWarnHeader,
FontWeight = FontWeight.SemiBold,
FontSize = 11,
Foreground = new SolidColorBrush(Color.Parse(warnColor))
Expand Down Expand Up @@ -2140,18 +2155,21 @@ private object BuildNodeTooltipContent(PlanNode node, List<PlanWarning>? allWarn

if (allWarnings != null)
{
// Root node: show distinct warning type names only
// Root node: show distinct warning type names only, sorted by max benefit
var distinct = warnings
.GroupBy(w => w.WarningType)
.Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count()))
.OrderByDescending(g => g.MaxSeverity)
.Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(),
MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1)))
.OrderByDescending(g => g.MaxBenefit)
.ThenByDescending(g => g.MaxSeverity)
.ThenBy(g => g.Type);

foreach (var (type, severity, count) in distinct)
foreach (var (type, severity, count, maxBenefit) in distinct)
{
var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
: severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var label = count > 1 ? $"\u26A0 {type} ({count})" : $"\u26A0 {type}";
var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : "";
var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}";
stack.Children.Add(new TextBlock
{
Text = label,
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public static async Task<string> GetQueryStoreTop(
.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
var parsed = ShowPlanParser.Parse(xml);
PlanAnalyzer.Analyze(parsed);
BenefitScorer.Score(parsed);

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

Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>EDD.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Version>1.4.3</Version>
<Version>1.5.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
2 changes: 2 additions & 0 deletions src/PlanViewer.Cli/Commands/AnalyzeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo

var plan = ShowPlanParser.Parse(planXml);
PlanAnalyzer.Analyze(plan, analyzerConfig);
BenefitScorer.Score(plan);

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

if (warningsOnly)
Expand Down
1 change: 1 addition & 0 deletions src/PlanViewer.Cli/Commands/QueryStoreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ private static async Task RunAsync(
// Parse, analyze, map
var plan = ShowPlanParser.Parse(qsPlan.PlanXml);
PlanAnalyzer.Analyze(plan, analyzerConfig);
BenefitScorer.Score(plan);
var result = ResultMapper.Map(plan, $"{label}.sqlplan");

if (warningsOnly)
Expand Down
11 changes: 11 additions & 0 deletions src/PlanViewer.Core/Models/PlanModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,17 @@ public class PlanWarning
public string Message { get; set; } = "";
public PlanWarningSeverity Severity { get; set; }
public SpillDetail? SpillDetails { get; set; }

/// <summary>
/// Maximum percentage of elapsed time that could be saved by addressing this finding.
/// null = not quantifiable, 0 = calculated as negligible.
/// </summary>
public double? MaxBenefitPercent { get; set; }

/// <summary>
/// Short actionable fix suggestion (e.g., "Add INCLUDE (columns) to index").
/// </summary>
public string? ActionableFix { get; set; }
}

public enum PlanWarningSeverity { Info, Warning, Critical }
Expand Down
6 changes: 6 additions & 0 deletions src/PlanViewer.Core/Output/AnalysisResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ public class WarningResult

[JsonPropertyName("node_id")]
public int? NodeId { get; set; }

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

[JsonPropertyName("actionable_fix")]
public string? ActionableFix { get; set; }
}

public class MissingIndexResult
Expand Down
11 changes: 10 additions & 1 deletion src/PlanViewer.Core/Output/HtmlExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ .card h3 {
.sev-info { color: var(--info); }
.warn-op { font-size: 0.75rem; font-weight: 500; color: var(--text-secondary); }
.warn-type { font-size: 0.75rem; font-weight: 600; }
.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); }
.warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; }

/* Query text */
Expand Down Expand Up @@ -428,14 +429,22 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
if (infoCount > 0) sb.Append($" <span class=\"warn-badge info\">{infoCount}</span>");
sb.AppendLine("</h3>");

foreach (var w in allWarnings)
// Sort by benefit descending (nulls last), then severity, then type
var sorted = allWarnings
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
.ThenBy(w => w.Severity switch { "Critical" => 0, "Warning" => 1, _ => 2 })
.ThenBy(w => w.Type);

foreach (var w in sorted)
{
var sevLower = w.Severity.ToLower();
sb.AppendLine($"<div class=\"warning-item {sevLower}\">");
sb.AppendLine($"<span class=\"sev sev-{sevLower}\">{Encode(w.Severity)}</span>");
if (w.Operator != null)
sb.AppendLine($"<span class=\"warn-op\">{Encode(w.Operator)}</span>");
sb.AppendLine($"<span class=\"warn-type\">{Encode(w.Type)}</span>");
if (w.MaxBenefitPercent.HasValue)
sb.AppendLine($"<span class=\"warn-benefit\">up to {w.MaxBenefitPercent:N0}% benefit</span>");
sb.AppendLine($"<span class=\"warn-msg\">{Encode(w.Message)}</span>");
sb.AppendLine("</div>");
}
Expand Down
8 changes: 6 additions & 2 deletions src/PlanViewer.Core/Output/ResultMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ private static StatementResult MapStatement(PlanStatement stmt)
{
Type = w.WarningType,
Severity = w.Severity.ToString(),
Message = w.Message
Message = w.Message,
MaxBenefitPercent = w.MaxBenefitPercent,
ActionableFix = w.ActionableFix
});
}

Expand Down Expand Up @@ -259,7 +261,9 @@ private static OperatorResult MapNode(PlanNode node)
Severity = w.Severity.ToString(),
Message = w.Message,
Operator = $"{node.PhysicalOp} (Node {node.NodeId})",
NodeId = node.NodeId
NodeId = node.NodeId,
MaxBenefitPercent = w.MaxBenefitPercent,
ActionableFix = w.ActionableFix
});
}

Expand Down
39 changes: 27 additions & 12 deletions src/PlanViewer.Core/Output/TextFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,17 @@ public static void WriteText(AnalysisResult result, TextWriter writer)
writer.WriteLine("Plan warnings:");
var hasDetailedMemoryGrant = stmt.Warnings.Any(w =>
w.Type == "Excessive Memory Grant" || w.Type == "Large Memory Grant");
foreach (var w in stmt.Warnings)
var sortedWarnings = stmt.Warnings
.Where(w => !(w.Type == "Memory Grant" && hasDetailedMemoryGrant))
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
.ThenBy(w => w.Severity switch { "Critical" => 0, "Warning" => 1, _ => 2 })
.ThenBy(w => w.Type);
foreach (var w in sortedWarnings)
{
// Skip raw XML "Memory Grant" when analyzer provides better context
if (w.Type == "Memory Grant" && hasDetailedMemoryGrant)
continue;
writer.WriteLine($" [{w.Severity}] {w.Type}: {EscapeNewlines(w.Message)}");
var benefitTag = w.MaxBenefitPercent.HasValue
? $" (up to {w.MaxBenefitPercent:N0}% benefit)"
: "";
writer.WriteLine($" [{w.Severity}] {w.Type}{benefitTag}: {EscapeNewlines(w.Message)}");
}
}

Expand Down Expand Up @@ -272,10 +277,17 @@ private static void CollectNodeWarnings(OperatorResult node, List<WarningResult>

private static void WriteGroupedOperatorWarnings(List<WarningResult> warnings, TextWriter writer)
{
// Sort by benefit descending (nulls last), then severity, then type
var sorted = warnings
.OrderByDescending(w => w.MaxBenefitPercent ?? -1)
.ThenBy(w => w.Severity switch { "Critical" => 0, "Warning" => 1, _ => 2 })
.ThenBy(w => w.Type)
.ToList();

// Split each message into "data | explanation" at the last sentence boundary
// that starts with "The " (the harm assessment). Group by shared explanation.
var entries = new List<(string Severity, string Operator, string Data, string? Explanation)>();
foreach (var w in warnings)
var entries = new List<(string Severity, string Operator, string Data, string? Explanation, double? Benefit)>();
foreach (var w in sorted)
{
var msg = w.Message;
string data;
Expand All @@ -293,14 +305,13 @@ private static void WriteGroupedOperatorWarnings(List<WarningResult> warnings, T
data = msg;
}

entries.Add((w.Severity, w.Operator ?? "?", data, explanation));
entries.Add((w.Severity, w.Operator ?? "?", data, explanation, w.MaxBenefitPercent));
}

// Group entries that share the same severity, type, and explanation
// Sort criticals before warnings before info
// Preserve the pre-sorted order (benefit desc, severity, type)
var grouped = entries
.GroupBy(e => (e.Severity, e.Explanation ?? ""))
.OrderBy(g => g.Key.Item1 switch { "Critical" => 0, "Warning" => 1, _ => 2 })
.ToList();

foreach (var group in grouped)
Expand All @@ -310,7 +321,10 @@ private static void WriteGroupedOperatorWarnings(List<WarningResult> warnings, T
{
// Multiple operators with the same explanation — list compactly
foreach (var item in items)
writer.WriteLine($" [{item.Severity}] {item.Operator}: {EscapeNewlines(item.Data)}");
{
var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : "";
writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(item.Data)}");
}
writer.WriteLine($" -> {group.Key.Item2}");
}
else
Expand All @@ -319,7 +333,8 @@ private static void WriteGroupedOperatorWarnings(List<WarningResult> warnings, T
foreach (var item in items)
{
var full = item.Explanation != null ? $"{item.Data}. {item.Explanation}" : item.Data;
writer.WriteLine($" [{item.Severity}] {item.Operator}: {EscapeNewlines(full)}");
var benefitTag = item.Benefit.HasValue ? $" (up to {item.Benefit:N0}% benefit)" : "";
writer.WriteLine($" [{item.Severity}] {item.Operator}{benefitTag}: {EscapeNewlines(full)}");
}
}
}
Expand Down
Loading
Loading