diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 0f3acb1..fcd6bd6 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -110,7 +110,8 @@ Background="{DynamicResource BackgroundDarkBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"> - pct >= 100 ? $"{pct:N0}" : $"{pct:N1}"; + private static bool HasSpillInPlanTree(PlanNode node) + { + foreach (var w in node.Warnings) + if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInPlanTree(child)) return true; + return false; + } + #endregion #region Node Selection & Properties Panel @@ -2809,37 +2818,42 @@ void AddRow(string label, string value, string? color = null) static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" : pct >= 20 ? "#FFB347" : "#E57373"; - // Runtime stats (actual plans) - if (statement.QueryTimeStats != null) + // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red), + // any operator spilled (orange), otherwise tier by utilization. + static string MemoryGrantColor(double pctUsed, bool hasSpill) { - AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); - AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); - if (statement.QueryUdfCpuTimeMs > 0) - AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); - if (statement.QueryUdfElapsedTimeMs > 0) - AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + if (pctUsed > 100) return "#E57373"; + if (hasSpill) return "#FFB347"; + if (pctUsed >= 40) return "#E4E6EB"; + if (pctUsed >= 20) return "#FFB347"; + return "#E57373"; } - // Compile time — plan-level property (category B). Show regardless of - // threshold so it's always visible, not just when Rule 19 fires. - if (statement.CompileTimeMs > 0) - AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + // E7: rename the panel title for estimated plans + var isEstimated = statement.QueryTimeStats == null; + RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary"; - // Memory grant — color by utilization percentage - if (statement.MemoryGrant != null) + var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); + + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. + + if (statement.QueryTimeStats != null) { - var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantColor = EfficiencyColor(grantPct); - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)", - grantColor); - if (mg.GrantWaitTimeMs > 0) - AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms"); + if (statement.QueryTimeStats.ElapsedTimeMs > 0) + { + long externalWaitMs = 0; + foreach (var w in statement.WaitStats) + if (BenefitScorer.IsExternalWait(w.WaitType)) + externalWaitMs += w.WaitTimeMs; + var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs); + var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs; + AddRow("CPU:Elapsed", ratio.ToString("N2")); + } } - // DOP + parallelism efficiency — color by efficiency + // DOP + parallelism efficiency if (statement.DegreeOfParallelism > 0) { var dopText = statement.DegreeOfParallelism.ToString(); @@ -2849,9 +2863,6 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" statement.QueryTimeStats.CpuTimeMs > 0 && statement.DegreeOfParallelism > 1) { - // Speedup ratio: CPU/elapsed = 1.0 means serial, = DOP means perfect parallelism. - // Subtract external/preemptive wait time from CPU — those waits are CPU-busy - // in kernel and inflate the ratio without representing real query work. long externalWaitMs = 0; foreach (var w in statement.WaitStats) if (BenefitScorer.IsExternalWait(w.WaitType)) @@ -2868,7 +2879,37 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" else if (statement.NonParallelPlanReason != null) AddRow("Serial", statement.NonParallelPlanReason); - // Thread stats — color by utilization + if (statement.QueryTimeStats != null) + { + AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms"); + if (statement.QueryUdfCpuTimeMs > 0) + AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms"); + if (statement.QueryUdfElapsedTimeMs > 0) + AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms"); + } + + // Compile stats (category B plan-level property) + if (statement.CompileTimeMs > 0) + AddRow("Compile", $"{statement.CompileTimeMs:N0}ms"); + if (statement.CachedPlanSizeKB > 0) + AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); + + // Memory grant — color per new tiers, spill indicator if any operator spilled + if (statement.MemoryGrant != null) + { + var mg = statement.MemoryGrant; + var grantPct = mg.GrantedMemoryKB > 0 + ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; + var grantColor = MemoryGrantColor(grantPct, hasSpillInTree); + var spillTag = hasSpillInTree ? " ⚠ spill" : ""; + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantColor); + if (mg.GrantWaitTimeMs > 0) + AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373"); + } + + // Thread stats if (statement.ThreadStats != null) { var ts = statement.ThreadStats; @@ -2889,21 +2930,13 @@ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB" } } - // CE model - if (statement.CardinalityEstimationModelVersion > 0) - AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); - - // Compile stats (always available) - if (statement.CompileTimeMs > 0) - AddRow("Compile time", $"{statement.CompileTimeMs:N0}ms"); - if (statement.CachedPlanSizeKB > 0) - AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB"); - - // Optimization level + // Optimization + CE model if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) AddRow("Optimization", statement.StatementOptmLevel); if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + if (statement.CardinalityEstimationModelVersion > 0) + AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 3064690..f6091dd 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -6,7 +6,7 @@ app.manifest EDD.ico true - 1.7.8 + 1.8.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index c9c1a26..04f6f4e 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -167,6 +167,20 @@ public class MemoryGrantResult [JsonPropertyName("estimated_available_memory_grant_kb")] public long EstimatedAvailableMemoryGrantKB { get; set; } + + /// + /// Optimizer's pre-execution "desired" grant (parallel-adjusted). + /// Non-zero on estimated plans; pairs with DesiredKB serial-required as fallback + /// when no runtime-granted memory exists (#215 E6). + /// + [JsonPropertyName("desired_kb")] + public long DesiredKB { get; set; } + + /// + /// Optimizer's pre-execution serial-required grant (memory minimum before DOP scaling). + /// + [JsonPropertyName("serial_required_kb")] + public long SerialRequiredKB { get; set; } } public class QueryTimeResult diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 73e5950..cf8ac8a 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -109,10 +109,15 @@ .statement h2 { /* Insights grid */ .insights { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 0.75rem; margin-bottom: 0.75rem; } .card { border-radius: 6px; border: 1px solid var(--border); overflow: hidden; } -.card h3 { +.card h3, .card > summary { padding: 0.4rem 0.75rem; font-size: 0.8rem; font-weight: 500; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem; + list-style: none; cursor: pointer; } +.card > summary::-webkit-details-marker { display: none; } +.card > summary::before { content: ""\25B8""; font-size: 0.7rem; color: var(--text-muted); width: 0.7rem; } +details.card[open] > summary::before { content: ""\25BE""; } +.card.waits summary { color: #2a4365; } .card-body { padding: 0.5rem 0.75rem; font-size: 0.8rem; } .card.runtime { background: var(--card-runtime); border-color: var(--card-runtime-border); } .card.runtime h3 { color: #2c5282; } @@ -189,6 +194,7 @@ .card h3 { .warn-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; } .warn-legacy { font-size: 0.65rem; font-weight: 600; color: var(--text-muted); padding: 0.05rem 0.3rem; border-radius: 3px; background: rgba(0,0,0,0.08); text-transform: uppercase; letter-spacing: 0.05em; } .warn-fix { font-size: 0.75rem; color: var(--text-secondary); font-style: italic; flex-basis: 100%; border-left: 2px solid var(--border); padding-left: 0.5rem; margin-top: 0.15rem; } +.spill-tag { font-size: 0.75rem; font-weight: 600; color: var(--orange); margin-left: 0.4rem; } /* Query text */ details { margin-bottom: 0.75rem; } @@ -294,14 +300,18 @@ private static void WriteStatement(StringBuilder sb, AnalysisResult result, Stat private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) { + var isEstimated = stmt.QueryTime == null; + var hasSpill = HasSpillInTree(stmt.OperatorTree); sb.AppendLine("
"); - sb.AppendLine("

Runtime

"); + sb.AppendLine($"

{(isEstimated ? "Predicted Runtime" : "Runtime")}

"); sb.AppendLine("
"); - WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); + + // 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. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); - WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); if (stmt.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0, stmt.QueryTime.CpuTimeMs - stmt.QueryTime.ExternalWaitMs); @@ -309,27 +319,68 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) WriteRow(sb, "CPU:Elapsed", ratio.ToString("N2")); } } - if (stmt.CompileTimeMs > 0) - WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.DegreeOfParallelism > 0) WriteRow(sb, "DOP", stmt.DegreeOfParallelism.ToString()); if (stmt.NonParallelReason != null) WriteRow(sb, "Serial", Encode(stmt.NonParallelReason)); + if (stmt.QueryTime != null) + WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); + if (stmt.CompileTimeMs > 0) + WriteRow(sb, "Compile", $"{stmt.CompileTimeMs:N0} ms"); if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasSpill); WriteRow(sb, "Memory", FormatKB(stmt.MemoryGrant.GrantedKB) + " granted"); - sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)
"); + var spillTag = hasSpill ? " ⚠ spill" : ""; + sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%){spillTag}
"); + } + else if (isEstimated && stmt.MemoryGrant != null && stmt.MemoryGrant.DesiredKB > 0) + { + // #215 E6: estimated plans — show the optimizer's pre-execution desired grant + WriteRow(sb, "Memory (estimated)", FormatKB(stmt.MemoryGrant.DesiredKB) + " desired"); + 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()); + WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine("
"); } + /// + /// Memory grant color tiers (#215 C1 + E8 + E9): + /// - > 100% used: eff-bad (grant was too small, may have thrashed memory) + /// - any operator spilled: eff-warn (grant was nominally enough but something spilled) + /// - >= 40% used: eff-good (healthy utilization) + /// - 20-39%: eff-warn (some over-grant) + /// - < 20%: eff-bad (significant over-grant) + /// + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + { + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) + return true; + } + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private static void WriteMissingIndexCard(StringBuilder sb, StatementResult stmt) { sb.AppendLine($"
"); @@ -393,11 +444,12 @@ private static void WriteParametersCard(StringBuilder sb, StatementResult stmt) private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, bool hasActualStats) { - sb.AppendLine("
"); - sb.Append("

Wait Stats"); + // Collapsible (#215 E12): default-closed so improvement items aren't pushed below the fold. + sb.AppendLine("
"); + sb.Append("Wait Stats"); if (stmt.WaitStats.Count > 0) sb.Append($" {stmt.WaitStats.Sum(w => w.WaitTimeMs):N0} ms"); - sb.AppendLine("

"); + sb.AppendLine(""); sb.AppendLine("
"); if (stmt.WaitStats.Count > 0) { @@ -425,7 +477,7 @@ private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, b sb.AppendLine($"
{(hasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")}
"); } sb.AppendLine("
"); - sb.AppendLine("
"); + sb.AppendLine(""); } private static void WriteWarnings(StringBuilder sb, StatementResult stmt) diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index f73dd06..35d1f51 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -105,7 +105,9 @@ private static StatementResult MapStatement(PlanStatement stmt) MaxUsedKB = stmt.MemoryGrant.MaxUsedMemoryKB, GrantWaitMs = stmt.MemoryGrant.GrantWaitTimeMs, FeedbackAdjusted = stmt.MemoryGrant.IsMemoryGrantFeedbackAdjusted, - EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0 + EstimatedAvailableMemoryGrantKB = stmt.HardwareProperties?.EstimatedAvailableMemoryGrant ?? 0, + DesiredKB = stmt.MemoryGrant.DesiredMemoryKB, + SerialRequiredKB = stmt.MemoryGrant.SerialRequiredMemoryKB }; } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 69ada1b..aae2d0e 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -433,6 +433,49 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) }); } + // Rule 36: Dynamic cursor (#215 E1). Dynamic cursors can prevent index usage + // because they must tolerate underlying data changes between fetches, forcing + // scans and extra work per fetch. Switching to FAST_FORWARD, STATIC, or KEYSET + // often delivers a dramatic improvement. + if (!cfg.IsRuleDisabled(36) + && string.Equals(stmt.CursorActualType, "Dynamic", StringComparison.OrdinalIgnoreCase)) + { + var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Dynamic Cursor", + Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 37: CURSOR declaration without LOCAL (#215 E3). Default cursor scope + // is GLOBAL in SQL Server, which puts cursors in a shared namespace and can + // bloat the plan cache (Erik's writeup: + // https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). + if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText)) + { + // DECLARE [qualifier(s)] CURSOR ... FOR + // Flags the declaration if LOCAL isn't among the qualifiers before CURSOR. + var cursorDeclMatch = Regex.Match( + stmt.StatementText, + @"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (cursorDeclMatch.Success) + { + var qualifiers = cursorDeclMatch.Groups[1].Value; + if (!Regex.IsMatch(qualifiers, @"\bLOCAL\b", RegexOptions.IgnoreCase)) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Cursor Missing LOCAL", + Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + // Rules 25 (Ineffective Parallelism) and 31 (Parallel Wait Bottleneck) were removed. // The CPU:Elapsed ratio is now shown in the runtime summary, and wait stats speak // for themselves — no need for meta-warnings guessing at causes. @@ -883,7 +926,16 @@ _ when nonSargableReason.StartsWith("Function call") => var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; if (!string.IsNullOrEmpty(details.Summary)) message += $" {details.Summary}"; - message += " Check that you have appropriate indexes."; + + // #215 E2: if the statement is executing a dynamic cursor, that's usually + // the reason an index didn't get used. Call it out so the user looks there + // first rather than hunting for a missing index. + var isDynamicCursor = string.Equals(stmt.CursorActualType, "Dynamic", + StringComparison.OrdinalIgnoreCase); + if (isDynamicCursor) + message += " This query is running inside a dynamic cursor, which can prevent index usage; changing the cursor type (FAST_FORWARD / STATIC / KEYSET) often fixes scans like this without any indexing change."; + else + message += " Check that you have appropriate indexes."; // I/O waits specifically confirm the scan is hitting disk — elevate if (HasSignificantIoWaits(stmt.WaitStats) && details.CostPct >= 50 diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index d31b574..8fbfdac 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -144,23 +144,20 @@ else
@* Runtime Summary *@ + @{ + var isEstimatedRuntime = ActiveStmt!.QueryTime == null; + var hasAnySpill = HasSpillInTree(ActiveStmt!.OperatorTree); + }
-

Runtime

+

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

-
- Cost - @ActiveStmt!.EstimatedCost.ToString("N2") -
+ @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ @if (ActiveStmt!.QueryTime != null) {
Elapsed @ActiveStmt!.QueryTime.ElapsedTimeMs.ToString("N0") ms
-
- CPU - @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms -
@if (ActiveStmt!.QueryTime.ElapsedTimeMs > 0) { var effectiveCpu = Math.Max(0L, ActiveStmt!.QueryTime.CpuTimeMs - ActiveStmt!.QueryTime.ExternalWaitMs); @@ -171,13 +168,6 @@ else
} } - @if (ActiveStmt!.CompileTimeMs > 0) - { -
- Compile - @ActiveStmt!.CompileTimeMs.ToString("N0") ms -
- } @if (ActiveStmt!.DegreeOfParallelism > 0) {
@@ -192,18 +182,52 @@ else @ActiveStmt!.NonParallelReason
} + @if (ActiveStmt!.QueryTime != null) + { +
+ CPU + @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms +
+ } + @if (ActiveStmt!.CompileTimeMs > 0) + { +
+ Compile + @ActiveStmt!.CompileTimeMs.ToString("N0") ms +
+ } @if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0) { var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100; - var effClass = pctUsed >= 40 ? "eff-good" : pctUsed >= 20 ? "eff-warn" : "eff-bad"; + var effClass = GetMemoryGrantColorClass(pctUsed, hasAnySpill);
Memory @FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted
Used - @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + + @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) + @if (hasAnySpill) + { + ⚠ spill + } + +
+ } + else if (isEstimatedRuntime && ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.DesiredKB > 0) + { +
+ Memory (estimated) + @FormatKB(ActiveStmt!.MemoryGrant.DesiredKB) desired
+ @if (ActiveStmt!.MemoryGrant.SerialRequiredKB > 0 && ActiveStmt!.MemoryGrant.SerialRequiredKB != ActiveStmt!.MemoryGrant.DesiredKB) + { +
+ Serial required + @FormatKB(ActiveStmt!.MemoryGrant.SerialRequiredKB) +
+ } } @if (ActiveStmt!.OptimizationLevel != null) { @@ -219,6 +243,10 @@ else @ActiveStmt!.CardinalityEstimationModel
} +
+ Cost + @ActiveStmt!.EstimatedCost.ToString("N2") +
@@ -285,14 +313,14 @@ else - @* Wait Stats *@ -
-

Wait Stats + @* Wait Stats — collapsible (#215 E12): default-closed so it doesn't push improvement items below the fold *@ +
+ Wait Stats @if (ActiveStmt!.WaitStats.Count > 0) { @ActiveStmt!.WaitStats.Sum(w => w.WaitTimeMs).ToString("N0") ms } -

+
@if (ActiveStmt!.WaitStats.Count > 0) { @@ -322,7 +350,7 @@ else
@(result.Summary.HasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")
}
-
+ @* Warnings strip *@ @@ -1684,6 +1712,27 @@ else return v == null ? "?" : $"v{v.Major}.{v.Minor}.{v.Build}"; } + // Memory grant color tiers (#215 C1 + E8 + E9): + // > 100%: over-used grant (red). Spill in plan: orange. Otherwise: tier by utilization. + private static string GetMemoryGrantColorClass(double pctUsed, bool hasSpill) + { + if (pctUsed > 100) return "eff-bad"; + if (hasSpill) return "eff-warn"; + if (pctUsed >= 40) return "eff-good"; + if (pctUsed >= 20) return "eff-warn"; + return "eff-bad"; + } + + private static bool HasSpillInTree(OperatorResult? node) + { + if (node == null) return false; + foreach (var w in node.Warnings) + if (w.Type.EndsWith(" Spill", StringComparison.Ordinal)) return true; + foreach (var child in node.Children) + if (HasSpillInTree(child)) return true; + return false; + } + private string activeTab = "paste"; private string planXml = ""; private string? errorMessage; diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 7ab9dfd..b44467c 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -541,7 +541,8 @@ textarea::placeholder { overflow: hidden; } -.insight-card h4 { +.insight-card h4, +.insight-card > summary { padding: 0.4rem 0.75rem; font-size: 0.8rem; font-weight: 500; @@ -549,8 +550,24 @@ textarea::placeholder { display: flex; align-items: center; gap: 0.5rem; + list-style: none; + cursor: pointer; +} + +.insight-card > summary::-webkit-details-marker { display: none; } + +.insight-card > summary::before { + content: "\25B8"; + font-size: 0.7rem; + color: var(--text-muted); + transition: transform 0.15s ease; + width: 0.7rem; } +.insight-card[open] > summary::before { content: "\25BE"; } + +details.insight-card:not([open]) { border-bottom: 1px solid var(--border); } + .insight-body { padding: 0.5rem 0.75rem; font-size: 0.8rem; @@ -834,6 +851,13 @@ textarea::placeholder { margin-right: 0.4rem; } +.spill-tag { + font-size: 0.7rem; + font-weight: 600; + color: var(--warning-color); + margin-left: 0.4rem; +} + .warn-legacy { font-size: 0.65rem; font-weight: 600;