diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs
index cfa3812..bbb482d 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs
@@ -30,7 +30,8 @@ private void ShowRuntimeSummary(PlanStatement statement)
};
int rowIndex = 0;
- void AddRow(string label, string value, string? brushKey = null)
+ // nested: the row is a detail of the row above it, so its label is indented under that row's.
+ void AddRow(string label, string value, string? brushKey = null, bool nested = false)
{
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
@@ -40,7 +41,7 @@ void AddRow(string label, string value, string? brushKey = null)
FontSize = 11,
Foreground = labelBrush,
HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 1, 8, 1)
+ Margin = new Thickness(nested ? 12 : 0, 1, 8, 1)
};
Grid.SetRow(labelText, rowIndex);
Grid.SetColumn(labelText, 0);
@@ -84,7 +85,8 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill)
var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode);
- // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost.
+ // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost.
+ // #613 moved CE Model above Optimization, so Optimization and its nested early abort reason end the list.
// Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors.
if (statement.QueryTimeStats != null)
@@ -188,13 +190,15 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill)
}
}
- // Optimization + CE model
- if (!string.IsNullOrEmpty(statement.StatementOptmLevel))
- AddRow("Optimization", statement.StatementOptmLevel);
- if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason))
- AddRow("Early abort", statement.StatementOptmEarlyAbortReason);
+ // CE model, then Optimization. #613: the early abort reason is part of the optimization
+ // result, not a fact of its own, so it sits under the Optimization row.
if (statement.CardinalityEstimationModelVersion > 0)
AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString());
+ var hasOptimizationRow = !string.IsNullOrEmpty(statement.StatementOptmLevel);
+ if (hasOptimizationRow)
+ AddRow("Optimization", statement.StatementOptmLevel!);
+ if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason))
+ AddRow("Early abort", statement.StatementOptmEarlyAbortReason, nested: hasOptimizationRow);
if (grid.Children.Count > 0)
{
diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs
index 4aad7cc..137ba3b 100644
--- a/src/PlanViewer.Core/Output/HtmlExporter.cs
+++ b/src/PlanViewer.Core/Output/HtmlExporter.cs
@@ -308,8 +308,9 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt)
sb.AppendLine("
");
// Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile →
- // Memory → Used → Optimization → CE Model → Cost. Puts the important
- // measurements on top and groups related metrics together.
+ // Memory → Used → CE Model → Optimization → Cost. Puts the important
+ // measurements on top and groups related metrics together. #613 moved CE Model
+ // above Optimization, the same order as the App's Runtime Summary.
if (stmt.QueryTime != null)
{
WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms");
@@ -343,10 +344,10 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt)
if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB)
WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB));
}
- if (stmt.OptimizationLevel != null)
- WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel));
if (stmt.CardinalityEstimationModel > 0)
WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString());
+ if (stmt.OptimizationLevel != null)
+ WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel));
WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2"));
sb.AppendLine("
");
sb.AppendLine("");
diff --git a/src/PlanViewer.Web/Pages/InsightsPanel.razor b/src/PlanViewer.Web/Pages/InsightsPanel.razor
index 4ccdfcc..836adc1 100644
--- a/src/PlanViewer.Web/Pages/InsightsPanel.razor
+++ b/src/PlanViewer.Web/Pages/InsightsPanel.razor
@@ -8,7 +8,8 @@
@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")
- @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@
+ @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost.
+ #613 moved CE Model above Optimization, the same order as the App's Runtime Summary. *@
@if (Stmt.QueryTime != null)
{
@@ -86,18 +87,18 @@
}
}
- @if (Stmt.OptimizationLevel != null)
+ @if (Stmt.CardinalityEstimationModel > 0)
{
- Optimization
- @Stmt.OptimizationLevel
+ CE Model
+ @Stmt.CardinalityEstimationModel
}
- @if (Stmt.CardinalityEstimationModel > 0)
+ @if (Stmt.OptimizationLevel != null)
{
- CE Model
- @Stmt.CardinalityEstimationModel
+ Optimization
+ @Stmt.OptimizationLevel
}
diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs
index f316bdb..b4734ac 100644
--- a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs
+++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs
@@ -115,6 +115,24 @@ public void Export_CraftedSeverityOnAnOperator_IsMappedToo()
Assert.Contains("
", html);
}
+ /// #613: the runtime card lists CE Model above Optimization, the same order as the App's Runtime Summary.
+ [Fact]
+ public void Export_RuntimeCard_ListsCeModelAboveOptimization()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan");
+ foreach (var batch in plan.Batches)
+ foreach (var stmt in batch.Statements)
+ PlanLayoutEngine.Layout(stmt);
+
+ var result = ResultMapper.Map(plan, "key_lookup_plan.sqlplan");
+ var html = HtmlExporter.Export(result, TextFormatter.Format(result));
+
+ var ceModel = html.IndexOf("CE Model", StringComparison.Ordinal);
+ var optimization = html.IndexOf("Optimization", StringComparison.Ordinal);
+ Assert.True(ceModel >= 0, "no CE Model row");
+ Assert.True(optimization > ceModel, "Optimization is not below CE Model");
+ }
+
private static string ExportWithSeverity(string? severity, bool onOperator = false)
{
var warning = new WarningResult { Severity = severity!, Type = "demo", Message = "demo" };
diff --git a/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs
new file mode 100644
index 0000000..d9721de
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs
@@ -0,0 +1,94 @@
+using System.IO;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.LogicalTree;
+using PlanViewer.App.Controls;
+
+namespace PlanViewer.Core.Tests;
+
+///
+/// #613: the early abort reason is part of the optimization result (StatementOptmEarlyAbortReason
+/// only exists for a FULL optimization), so the Runtime Summary shows it as a detail of the
+/// Optimization row: on the next row, with its label indented and its value in the same column.
+/// CE model moved up above Optimization, so Optimization and its reason end the list.
+///
+public class RuntimeSummaryEarlyAbortTests
+{
+ [Fact]
+ public void EarlyAbort_IsNestedUnderOptimization()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var viewer = LoadPlan(stripOptimizationLevel: false);
+ var window = new Window { Content = viewer, Width = 1400, Height = 900 };
+ window.Show();
+ window.UpdateLayout();
+
+ var grid = SummaryGrid(viewer);
+ var ceModel = Label(grid, "CE model");
+ var optimization = Label(grid, "Optimization");
+ var earlyAbort = Label(grid, "Early abort");
+
+ Assert.Equal(Grid.GetRow(ceModel) + 1, Grid.GetRow(optimization));
+ Assert.Equal(Grid.GetRow(optimization) + 1, Grid.GetRow(earlyAbort));
+ Assert.Equal(grid.RowDefinitions.Count - 1, Grid.GetRow(earlyAbort));
+ Assert.True(earlyAbort.Margin.Left > optimization.Margin.Left,
+ $"Early abort label is not indented: {earlyAbort.Margin.Left} vs {optimization.Margin.Left}");
+ Assert.Equal("GoodEnoughPlanFound", Value(grid, earlyAbort));
+ });
+ }
+
+ [Fact]
+ public void EarlyAbort_WithoutAnOptimizationRow_IsNotIndented()
+ {
+ HeadlessUi.Run(() =>
+ {
+ var viewer = LoadPlan(stripOptimizationLevel: true);
+ var window = new Window { Content = viewer, Width = 1400, Height = 900 };
+ window.Show();
+ window.UpdateLayout();
+
+ var grid = SummaryGrid(viewer);
+ Assert.DoesNotContain(grid.Children.OfType(), t => t.Text == "Optimization");
+
+ var earlyAbort = Label(grid, "Early abort");
+ var elapsed = Label(grid, "Elapsed");
+ Assert.Equal(elapsed.Margin.Left, earlyAbort.Margin.Left);
+ });
+ }
+
+ ///
+ /// key_lookup_plan.sqlplan is an actual plan with StatementOptmLevel="FULL" and
+ /// StatementOptmEarlyAbortReason="GoodEnoughPlanFound". Removing the level leaves a reason
+ /// with no row to nest under.
+ ///
+ private static PlanViewerControl LoadPlan(bool stripOptimizationLevel)
+ {
+ var path = Path.Combine("Plans", "key_lookup_plan.sqlplan");
+ Assert.True(File.Exists(path), $"Test plan not found: {path}");
+ var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
+
+ const string level = "StatementOptmLevel=\"FULL\"";
+ Assert.Contains(level, xml);
+ Assert.Contains("StatementOptmEarlyAbortReason=\"GoodEnoughPlanFound\"", xml);
+ if (stripOptimizationLevel)
+ xml = xml.Replace(level, "");
+
+ var viewer = new PlanViewerControl();
+ Assert.True(viewer.LoadPlan(xml, "key_lookup_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}");
+ return viewer;
+ }
+
+ private static Grid SummaryGrid(PlanViewerControl viewer)
+ {
+ var panel = viewer.GetLogicalDescendants().OfType().First(p => p.Name == "RuntimeSummaryContent");
+ return (Grid)panel.Children.Single();
+ }
+
+ private static TextBlock Label(Grid grid, string label)
+ => grid.Children.OfType().Single(t => Grid.GetColumn(t) == 0 && t.Text == label);
+
+ private static string Value(Grid grid, TextBlock label)
+ => grid.Children.OfType()
+ .Single(t => Grid.GetColumn(t) == 1 && Grid.GetRow(t) == Grid.GetRow(label)).Text ?? "";
+}