diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs new file mode 100644 index 0000000..d633038 --- /dev/null +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -0,0 +1,537 @@ +using System.IO; +using System.Text; +using System.Web; + +namespace PlanViewer.Core.Output; + +/// +/// Generates a self-contained HTML file from an AnalysisResult. +/// The output is a single .html file with embedded CSS that can be +/// opened in any browser offline — no server or internet required. +/// +public static class HtmlExporter +{ + public static string Export(AnalysisResult result, string textOutput) + { + var sb = new StringBuilder(32768); + sb.AppendLine(""); + sb.AppendLine(""); + + WriteHead(sb, result); + sb.AppendLine(""); + WriteHeader(sb, result); + sb.AppendLine("
"); + + for (int i = 0; i < result.Statements.Count; i++) + { + WriteStatement(sb, result, result.Statements[i], i); + } + + WriteTextAnalysis(sb, textOutput); + sb.AppendLine("
"); + WriteFooter(sb); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + + private static void WriteHead(StringBuilder sb, AnalysisResult result) + { + var title = Encode($"Plan Analysis — {result.PlanSource}"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{title}"); + sb.AppendLine(""); + sb.AppendLine(""); + } + + private static void WriteCss(StringBuilder sb) + { + sb.Append(@" +:root { + --accent: #2eaef1; + --bg: #ffffff; + --bg-surface: #f5f5f5; + --text: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --border: #e0e0e0; + --critical: #d32f2f; + --orange: #e67e22; + --warning-color: #f39c12; + --info: #2eaef1; + --missing: #8e44ad; + --card-runtime: #f0f4f8; + --card-indexes: #fef8f0; + --card-params: #f0f8f0; + --card-waits: #f0f4fa; + --card-runtime-border: #c8d8e8; + --card-indexes-border: #e8d8c0; + --card-params-border: #c0d8c0; + --card-waits-border: #c0c8e0; +} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { + background: var(--bg); color: var(--text); + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; line-height: 1.5; +} +.export-header { + padding: 0.6rem 2rem; background: #333333; + border-bottom: 3px solid var(--accent); color: #fff; +} +.export-header-content { + display: flex; align-items: center; gap: 1rem; + max-width: 1200px; margin: 0 auto; flex-wrap: wrap; +} +.export-header h1 { font-size: 1rem; font-weight: 600; } +.plan-type { + font-size: 0.75rem; padding: 0.15rem 0.5rem; + border-radius: 3px; font-weight: 500; +} +.plan-type.actual { background: #e8f5e9; color: #2e7d32; } +.plan-type.estimated { background: #fff3e0; color: #e65100; } +.build-version { font-size: 0.8rem; color: #bbb; } +main { max-width: 1200px; margin: 0 auto; padding: 1rem 2rem; } + +/* Statement */ +.statement { margin-bottom: 2rem; } +.statement h2 { + font-size: 1.1rem; font-weight: 600; color: var(--text); + padding-bottom: 0.4rem; border-bottom: 2px solid var(--accent); + margin-bottom: 0.75rem; +} + +/* 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 { + 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; +} +.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; } +.card.indexes { background: var(--card-indexes); border-color: var(--card-indexes-border); } +.card.indexes h3 { color: #9c4221; } +.card.params { background: var(--card-params); border-color: var(--card-params-border); } +.card.params h3 { color: #276749; } +.card.waits { background: var(--card-waits); border-color: var(--card-waits-border); } +.card.waits h3 { color: #2a4365; } +.row { display: flex; justify-content: space-between; padding: 0.15rem 0; } +.label { color: var(--text-secondary); font-size: 0.75rem; } +.value { font-weight: 500; font-size: 0.8rem; } +.eff-good { color: #2e7d32; } .eff-warn { color: var(--orange); } .eff-bad { color: var(--critical); } +.card-count { font-size: 0.7rem; background: var(--bg-surface); padding: 0.1rem 0.4rem; border-radius: 8px; color: var(--text-secondary); } +.card-empty { color: var(--text-muted); font-style: italic; } + +/* Missing indexes */ +.mi-item { margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--card-indexes-border); } +.mi-item:last-child { border-bottom: none; margin-bottom: 0; } +.mi-table { font-weight: 500; } +.mi-impact { font-size: 0.75rem; color: var(--text-secondary); } +.mi-impact-val { color: var(--orange); font-weight: 500; } +pre.mi-create { + font-family: 'Cascadia Code', Consolas, monospace; font-size: 0.7rem; + background: rgba(255,255,255,0.5); padding: 0.3rem 0.5rem; + border-radius: 3px; margin-top: 0.25rem; white-space: pre-wrap; word-break: break-word; +} + +/* Params table */ +.params-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; } +.params-table th { + text-align: left; font-weight: 500; color: var(--text-secondary); + padding: 0.2rem 0.4rem; border-bottom: 1px solid var(--card-params-border); font-size: 0.7rem; +} +.params-table td { padding: 0.2rem 0.4rem; } +.sniffing-row { background: #fdecea; } +.sniffing-val { color: var(--critical); font-weight: 600; } + +/* Wait stats */ +.wait-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; } +.wait-type { flex: 0 0 auto; min-width: 120px; font-size: 0.75rem; } +.wait-bar-container { flex: 1; height: 10px; background: #e8ecf0; border-radius: 5px; overflow: hidden; } +.wait-bar { height: 100%; background: var(--accent); border-radius: 5px; } +.wait-ms { flex: 0 0 auto; font-size: 0.75rem; font-weight: 500; min-width: 60px; text-align: right; } + +/* Warnings */ +.warnings-section { margin-bottom: 0.75rem; } +.warnings-section h3 { + font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem; + display: flex; align-items: center; gap: 0.5rem; +} +.warn-badge { + font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 8px; + color: #fff; font-weight: 600; +} +.warn-badge.critical { background: var(--critical); } +.warn-badge.warning { background: var(--warning-color); } +.warn-badge.info { background: var(--info); } +.warning-item { + padding: 0.3rem 0.5rem; margin-bottom: 0.25rem; + border-left: 3px solid var(--border); font-size: 0.8rem; + display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: baseline; +} +.warning-item.critical { border-left-color: var(--critical); background: #fdecea; } +.warning-item.warning { border-left-color: var(--warning-color); background: #fef8e8; } +.warning-item.info { border-left-color: var(--info); background: #e8f4fd; } +.sev { font-size: 0.7rem; font-weight: 600; padding: 0.05rem 0.3rem; border-radius: 3px; } +.sev-critical { color: var(--critical); } +.sev-warning { color: var(--warning-color); } +.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-msg { font-size: 0.8rem; color: var(--text); flex-basis: 100%; } + +/* Query text */ +details { margin-bottom: 0.75rem; } +details summary { + cursor: pointer; font-size: 0.85rem; font-weight: 500; + color: var(--text-secondary); padding: 0.3rem 0; +} +details summary:hover { color: var(--accent); } +pre.query-text, pre.text-output { + font-family: 'Cascadia Code', Consolas, monospace; font-size: 0.8rem; + background: var(--bg-surface); padding: 0.75rem; border-radius: 4px; + border: 1px solid var(--border); white-space: pre-wrap; word-break: break-word; + overflow-x: auto; max-height: 400px; overflow-y: auto; +} + +/* Operator tree */ +.op-tree { margin-bottom: 0.75rem; } +.op-tree h3 { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem; } +.op-node { + padding: 0.25rem 0.4rem; margin: 0.15rem 0; + border-left: 2px solid var(--border); font-size: 0.8rem; +} +.op-node.expensive { border-left-color: var(--critical); background: #fef0f0; } +.op-node.has-warnings { border-left-color: var(--warning-color); } +.op-name { font-weight: 500; } +.op-cost { color: var(--text-muted); font-size: 0.75rem; } +.op-rows { color: var(--text-secondary); font-size: 0.75rem; } +.op-object { color: var(--accent); font-size: 0.75rem; } +.op-time { font-size: 0.75rem; } +.op-warn-icon { color: var(--warning-color); } +.op-children { margin-left: 1.25rem; } + +/* Footer */ +.export-footer { + text-align: center; padding: 1.5rem 2rem; + border-top: 1px solid var(--border); margin-top: 2rem; + font-size: 0.85rem; color: var(--text-muted); +} +.export-footer a { color: var(--accent); text-decoration: none; } + +@media print { + .export-header { background: #333 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + details[open] > summary ~ * { display: block; } + pre { max-height: none !important; overflow: visible !important; } +} +@media (max-width: 768px) { + .insights { grid-template-columns: 1fr; } + main { padding: 0.5rem; } +} +"); + } + + private static void WriteHeader(StringBuilder sb, AnalysisResult result) + { + var planType = result.Summary.HasActualStats ? "Actual Plan" : "Estimated Plan"; + var planClass = result.Summary.HasActualStats ? "actual" : "estimated"; + + sb.AppendLine("
"); + sb.AppendLine("
"); + sb.AppendLine("

Performance Studio — Plan Analysis

"); + sb.AppendLine($"{planType}"); + if (result.SqlServerBuild != null) + sb.AppendLine($"{Encode(result.SqlServerBuild)}"); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void WriteStatement(StringBuilder sb, AnalysisResult result, StatementResult stmt, int index) + { + sb.AppendLine("
"); + + if (result.Statements.Count > 1) + sb.AppendLine($"

Statement {index + 1}

"); + + // Insights grid + sb.AppendLine("
"); + WriteRuntimeCard(sb, stmt); + WriteMissingIndexCard(sb, stmt); + WriteParametersCard(sb, stmt); + WriteWaitStatsCard(sb, stmt, result.Summary.HasActualStats); + sb.AppendLine("
"); + + // Warnings + WriteWarnings(sb, stmt); + + // Query text + sb.AppendLine("
"); + sb.AppendLine("Query Text"); + sb.AppendLine($"
{Encode(stmt.StatementText)}
"); + sb.AppendLine("
"); + + // Operator tree + if (stmt.OperatorTree != null) + { + sb.AppendLine("
"); + sb.AppendLine("

Operator Tree

"); + WriteOperatorNode(sb, stmt.OperatorTree, stmt); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + } + + private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) + { + sb.AppendLine("
"); + sb.AppendLine("

Runtime

"); + sb.AppendLine("
"); + WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); + if (stmt.QueryTime != null) + { + WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); + WriteRow(sb, "CPU", $"{stmt.QueryTime.CpuTimeMs:N0} ms"); + } + if (stmt.DegreeOfParallelism > 0) + WriteRow(sb, "DOP", stmt.DegreeOfParallelism.ToString()); + if (stmt.NonParallelReason != null) + WriteRow(sb, "Serial", Encode(stmt.NonParallelReason)); + if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0) + { + var pctUsed = (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100; + var effClass = pctUsed >= 80 ? "eff-good" : pctUsed >= 40 ? "eff-warn" : "eff-bad"; + WriteRow(sb, "Memory", FormatKB(stmt.MemoryGrant.GrantedKB) + " granted"); + sb.AppendLine($"
Used{FormatKB(stmt.MemoryGrant.MaxUsedKB)} ({pctUsed:N0}%)
"); + } + if (stmt.OptimizationLevel != null) + WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); + if (stmt.CardinalityEstimationModel > 0) + WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void WriteMissingIndexCard(StringBuilder sb, StatementResult stmt) + { + sb.AppendLine($"
"); + sb.AppendLine($"

Missing Indexes {stmt.MissingIndexes.Count}

"); + sb.AppendLine("
"); + if (stmt.MissingIndexes.Count > 0) + { + foreach (var mi in stmt.MissingIndexes) + { + sb.AppendLine("
"); + sb.AppendLine($"
{Encode(mi.Table)}
"); + sb.AppendLine($"
Impact: {mi.Impact:F0}%
"); + sb.AppendLine($"
{Encode(mi.CreateStatement)}
"); + sb.AppendLine("
"); + } + } + else + { + sb.AppendLine("
No missing index suggestions
"); + } + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void WriteParametersCard(StringBuilder sb, StatementResult stmt) + { + sb.AppendLine($"
"); + sb.AppendLine($"

Parameters {stmt.Parameters.Count}

"); + sb.AppendLine("
"); + if (stmt.Parameters.Count > 0) + { + var hasRuntime = stmt.Parameters.Any(p => p.RuntimeValue != null); + sb.AppendLine(""); + sb.AppendLine(""); + if (hasRuntime) sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var p in stmt.Parameters) + { + var rowClass = p.SniffingIssue ? " class=\"sniffing-row\"" : ""; + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine($""); + if (hasRuntime) + { + var valClass = p.SniffingIssue ? " class=\"sniffing-val\"" : ""; + sb.AppendLine($"{Encode(p.RuntimeValue ?? "")}"); + } + sb.AppendLine(""); + } + sb.AppendLine("
NameTypeCompiledRuntime
{Encode(p.Name)}{Encode(p.DataType)}{Encode(p.CompiledValue ?? "?")}
"); + } + else + { + sb.AppendLine("
No parameters
"); + } + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void WriteWaitStatsCard(StringBuilder sb, StatementResult stmt, bool hasActualStats) + { + 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("
"); + if (stmt.WaitStats.Count > 0) + { + var maxWait = stmt.WaitStats.Max(w => w.WaitTimeMs); + foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) + { + var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; + sb.AppendLine("
"); + sb.AppendLine($"{Encode(w.WaitType)}"); + sb.AppendLine($"
"); + sb.AppendLine($"{w.WaitTimeMs:N0} ms"); + sb.AppendLine("
"); + } + } + else + { + sb.AppendLine($"
{(hasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")}
"); + } + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + private static void WriteWarnings(StringBuilder sb, StatementResult stmt) + { + var allWarnings = new List(stmt.Warnings); + if (stmt.OperatorTree != null) + CollectNodeWarnings(stmt.OperatorTree, allWarnings); + + if (allWarnings.Count == 0) return; + + var critCount = allWarnings.Count(w => w.Severity == "Critical"); + var warnCount = allWarnings.Count(w => w.Severity == "Warning"); + var infoCount = allWarnings.Count(w => w.Severity == "Info"); + + sb.AppendLine("
"); + sb.Append("

Warnings"); + if (critCount > 0) sb.Append($" {critCount}"); + if (warnCount > 0) sb.Append($" {warnCount}"); + if (infoCount > 0) sb.Append($" {infoCount}"); + sb.AppendLine("

"); + + foreach (var w in allWarnings) + { + var sevLower = w.Severity.ToLower(); + sb.AppendLine($"
"); + sb.AppendLine($"{Encode(w.Severity)}"); + if (w.Operator != null) + sb.AppendLine($"{Encode(w.Operator)}"); + sb.AppendLine($"{Encode(w.Type)}"); + sb.AppendLine($"{Encode(w.Message)}"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + } + + private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, StatementResult stmt) + { + var classes = "op-node"; + if (node.CostPercent >= 25) classes += " expensive"; + if (node.Warnings.Count > 0) classes += " has-warnings"; + + sb.AppendLine($"
"); + + // Operator name + cost + var opLabel = node.PhysicalOp; + if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != "Parallelism") + opLabel = $"Parallelism ({node.LogicalOp})"; + + sb.Append($"{Encode(opLabel)}"); + sb.Append($" Cost: {node.CostPercent}%"); + + if (node.Warnings.Count > 0) + sb.Append($" "); + + // Rows + if (node.ActualRows.HasValue) + { + var est = node.EstimatedRows; + var ratio = est > 0 ? (double)node.ActualRows.Value / est : 0; + var accuracy = est > 0 ? $" ({ratio * 100:F0}%)" : ""; + sb.Append($" {node.ActualRows.Value:N0} of {est:N0} rows{accuracy}"); + } + else + { + sb.Append($" {node.EstimatedRows:N0} est. rows"); + } + + // Timing (actual plans) + if (node.ActualElapsedMs.HasValue && node.ActualElapsedMs > 0) + sb.Append($" {node.ActualElapsedMs.Value:N0}ms"); + + // Object + if (!string.IsNullOrEmpty(node.ObjectName)) + sb.Append($" {Encode(node.ObjectName)}"); + + sb.AppendLine(); + + // Children + if (node.Children.Count > 0) + { + sb.AppendLine("
"); + foreach (var child in node.Children) + WriteOperatorNode(sb, child, stmt); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + } + + private static void WriteTextAnalysis(StringBuilder sb, string textOutput) + { + sb.AppendLine("
"); + sb.AppendLine("Full Text Analysis"); + sb.AppendLine($"
{Encode(textOutput)}
"); + sb.AppendLine("
"); + } + + private static void WriteFooter(StringBuilder sb) + { + var year = DateTime.Now.Year; + var date = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); + sb.AppendLine("
"); + sb.AppendLine($"
Exported {date} — Performance Studio
"); + sb.AppendLine($"
Copyright © 2019-{year} Darling Data
"); + sb.AppendLine("
"); + } + + private static void WriteRow(StringBuilder sb, string label, string value) + { + sb.AppendLine($"
{label}{value}
"); + } + + private static void CollectNodeWarnings(OperatorResult node, List warnings) + { + warnings.AddRange(node.Warnings); + foreach (var child in node.Children) + CollectNodeWarnings(child, warnings); + } + + private static string FormatKB(long kb) + { + if (kb < 1024) return $"{kb:N0} KB"; + if (kb < 1024 * 1024) return $"{kb / 1024.0:N1} MB"; + return $"{kb / (1024.0 * 1024.0):N2} GB"; + } + + private static string Encode(string text) => HttpUtility.HtmlEncode(text); +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index f4c829d..d89458d 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -124,7 +124,12 @@ private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) { // Rule 3: Serial plan with reason - if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason)) + // Skip: trivial cost (< 0.01), TRIVIAL optimization (can't go parallel anyway), + // and 0ms actual elapsed time (not worth flagging). + if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) + && stmt.StatementSubTreeCost >= 0.01 + && stmt.StatementOptmLevel != "TRIVIAL" + && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) { var reason = stmt.NonParallelPlanReason switch { @@ -136,11 +141,14 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) _ => stmt.NonParallelPlanReason }; + // Only warn (not info) when the user explicitly forced serial execution + var isExplicit = stmt.NonParallelPlanReason is "MaxDOPSetToOne" or "QueryHintNoParallelSet"; + stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Serial Plan", Message = $"Query running serially: {reason}.", - Severity = PlanWarningSeverity.Warning + Severity = isExplicit ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info }); } @@ -226,7 +234,7 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) stmt.PlanWarnings.Add(new PlanWarning { WarningType = "UDF Execution", - Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.", + Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } @@ -234,7 +242,8 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) // Rule 20: Local variables without RECOMPILE // Parameters with no CompiledValue are likely local variables — the optimizer // cannot sniff their values and uses density-based ("unknown") estimates. - if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0) + // Skip trivial statements (simple variable assignments) where estimate quality doesn't matter. + if (!cfg.IsRuleDisabled(20) && stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01) { var unsnifffedParams = stmt.Parameters .Where(p => string.IsNullOrEmpty(p.CompiledValue)) @@ -441,21 +450,42 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi { // Rule 1: Filter operators — rows survived the tree just to be discarded // Quantify the impact by summing child subtree cost (reads, CPU, time). - if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)) + // Suppress when the filter's child subtree is trivial (low I/O, fast, cheap). + if (!cfg.IsRuleDisabled(1) && node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate) + && node.Children.Count > 0) { - var impact = QuantifyFilterImpact(node); - var predicate = Truncate(node.Predicate, 200); - var message = "Filter operator discarding rows late in the plan."; - if (!string.IsNullOrEmpty(impact)) - message += $"\n{impact}"; - message += $"\nPredicate: {predicate}"; + // Gate: skip trivial filters based on actual stats or estimated cost + bool isTrivial; + if (node.HasActualStats) + { + long childReads = 0; + foreach (var child in node.Children) + childReads += SumSubtreeReads(child); + var childElapsed = node.Children.Max(c => c.ActualElapsedMs); + isTrivial = childReads < 128 && childElapsed < 10; + } + else + { + var childCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); + isTrivial = childCost < 1.0; + } - node.Warnings.Add(new PlanWarning + if (!isTrivial) { - WarningType = "Filter Operator", - Message = message, - Severity = PlanWarningSeverity.Warning - }); + var impact = QuantifyFilterImpact(node); + var predicate = Truncate(node.Predicate, 200); + var message = "Filter operator discarding rows late in the plan."; + if (!string.IsNullOrEmpty(impact)) + message += $"\n{impact}"; + message += $"\nPredicate: {predicate}"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Filter Operator", + Message = message, + Severity = PlanWarningSeverity.Warning + }); + } } // Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly @@ -480,7 +510,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi node.Warnings.Add(new PlanWarning { WarningType = "UDF Execution", - Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump the query results to a #temp table first and apply the UDF only to the final result set.", + Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } @@ -541,7 +571,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi node.Warnings.Add(new PlanWarning { WarningType = "Scalar UDF", - Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Rewrite as an inline table-valued function, or dump results to a #temp table and apply the UDF only to the final result set.", + Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = PlanWarningSeverity.Warning }); } @@ -938,12 +968,17 @@ _ when nonSargableReason.StartsWith("Function call") => node.EstimateRowsWithoutRowGoal > node.EstimateRows) { var reduction = node.EstimateRowsWithoutRowGoal / node.EstimateRows; - node.Warnings.Add(new PlanWarning + // Require at least a 2x reduction to be worth mentioning — "1 to 1" or + // tiny floating-point differences that display identically are noise + if (reduction >= 2.0) { - WarningType = "Row Goal", - Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", - Severity = PlanWarningSeverity.Info - }); + node.Warnings.Add(new PlanWarning + { + WarningType = "Row Goal", + Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to TOP, EXISTS, IN, or FAST hint. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", + Severity = PlanWarningSeverity.Info + }); + } } // Rule 28: Row Count Spool — NOT IN with nullable column @@ -1177,6 +1212,13 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode) if (parent == null || parent.PhysicalOp != "Nested Loops") return false; + // If this Nested Loops is inside an Anti/Semi Join, this is a NOT IN/IN + // subquery pattern (Merge Interval optimizing range lookups), not an OR expansion + var nlParent = parent.Parent; + if (nlParent != null && nlParent.LogicalOp != null && + nlParent.LogicalOp.Contains("Semi")) + return false; + return true; } diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 5cf9f11..62ebf42 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -1665,7 +1665,8 @@ private static List ParseWarningsFromElement(XElement warningsEl) }); } - // Memory grant warning + // Memory grant warning (from plan XML) — gate at 1 GB to avoid noise on small grants + // All values are in KB, consistent with MemoryGrantInfo element var memWarnEl = warningsEl.Element(Ns + "MemoryGrantWarning"); if (memWarnEl != null) { @@ -1673,12 +1674,17 @@ private static List ParseWarningsFromElement(XElement warningsEl) var requested = ParseLong(memWarnEl.Attribute("RequestedMemory")?.Value); var granted = ParseLong(memWarnEl.Attribute("GrantedMemory")?.Value); var maxUsed = ParseLong(memWarnEl.Attribute("MaxUsedMemory")?.Value); - result.Add(new PlanWarning + if (granted >= 1048576) // 1 GB in KB { - WarningType = "Memory Grant", - Message = $"{kind}: Requested {requested:N0} KB, Granted {granted:N0} KB, Used {maxUsed:N0} KB", - Severity = PlanWarningSeverity.Warning - }); + var grantedMB = granted / 1024.0; + var usedMB = maxUsed / 1024.0; + result.Add(new PlanWarning + { + WarningType = "Memory Grant", + Message = $"{kind}: Granted {grantedMB:N0} MB, Used {usedMB:N0} MB", + Severity = PlanWarningSeverity.Warning + }); + } } // Implicit conversions diff --git a/src/PlanViewer.Web/Layout/MainLayout.razor b/src/PlanViewer.Web/Layout/MainLayout.razor index 3981503..d56f076 100644 --- a/src/PlanViewer.Web/Layout/MainLayout.razor +++ b/src/PlanViewer.Web/Layout/MainLayout.razor @@ -2,9 +2,16 @@
- + - Performance Studio + Free SQL Server Query Plan Analysis +
@@ -20,7 +27,10 @@ diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 8e1cfd4..7fa47bf 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -1,10 +1,11 @@ @page "/" +@inject IJSRuntime JS @if (result == null) {
-

Execution Plan Analysis

+

Free SQL Server Query Plan Analysis

Paste or upload a .sqlplan file. Your plan XML never leaves your browser.

@if (errorMessage != null) @@ -45,6 +46,7 @@ else
+ @sourceLabel @(result.Summary.HasActualStats ? "Actual Plan" : "Estimated Plan") @@ -59,16 +61,24 @@ else @if (result.Statements.Count > 1) {
- @for (int si = 0; si < result.Statements.Count; si++) + @foreach (var entry in SortedStatementIndexes) { - var idx = si; + var idx = entry; var isActive = idx == activeStatement; - } @@ -97,192 +107,1477 @@ else @ActiveStmt!.QueryTime.CpuTimeMs.ToString("N0") ms
} - @if (ActiveStmt!.DegreeOfParallelism > 0) + @if (ActiveStmt!.DegreeOfParallelism > 0) + { +
+ DOP + @ActiveStmt!.DegreeOfParallelism +
+ } + @if (ActiveStmt!.NonParallelReason != null) + { +
+ Serial + @ActiveStmt!.NonParallelReason +
+ } + @if (ActiveStmt!.MemoryGrant != null && ActiveStmt!.MemoryGrant.GrantedKB > 0) + { + var pctUsed = (double)ActiveStmt!.MemoryGrant.MaxUsedKB / ActiveStmt!.MemoryGrant.GrantedKB * 100; + var effClass = pctUsed >= 80 ? "eff-good" : pctUsed >= 60 ? "eff-ok" : pctUsed >= 40 ? "eff-warn" : "eff-bad"; +
+ Memory + @FormatKB(ActiveStmt!.MemoryGrant.GrantedKB) granted +
+
+ Used + @FormatKB(ActiveStmt!.MemoryGrant.MaxUsedKB) (@pctUsed.ToString("N0")%) +
+ } + @if (ActiveStmt!.OptimizationLevel != null) + { +
+ Optimization + @ActiveStmt!.OptimizationLevel +
+ } + @if (ActiveStmt!.CardinalityEstimationModel > 0) + { +
+ CE Model + @ActiveStmt!.CardinalityEstimationModel +
+ } +
+
+ + @* Missing Indexes *@ +
+

Missing Indexes @ActiveStmt!.MissingIndexes.Count

+
+ @if (ActiveStmt!.MissingIndexes.Count > 0) + { + @foreach (var mi in ActiveStmt!.MissingIndexes) + { +
+
@mi.Table
+
Impact: @mi.Impact.ToString("F0")%
+
@mi.CreateStatement
+
+ } + } + else + { +
No missing index suggestions
+ } +
+
+ + @* Parameters *@ +
+

Parameters @ActiveStmt!.Parameters.Count

+
+ @if (ActiveStmt!.Parameters.Count > 0) + { + + + + + + + @if (ActiveStmt!.Parameters.Any(p => p.RuntimeValue != null)) + { + + } + + + + @foreach (var p in ActiveStmt!.Parameters) + { + + + + + @if (ActiveStmt!.Parameters.Any(pp => pp.RuntimeValue != null)) + { + + } + + } + +
NameTypeCompiledRuntime
@p.Name@p.DataType@(p.CompiledValue ?? "?")@(p.RuntimeValue ?? "")
+ } + else + { +
No parameters
+ } +
+
+ + @* Wait Stats *@ +
+

Wait Stats + @if (ActiveStmt!.WaitStats.Count > 0) + { + @ActiveStmt!.WaitStats.Sum(w => w.WaitTimeMs).ToString("N0") ms + } +

+
+ @if (ActiveStmt!.WaitStats.Count > 0) + { + var maxWait = ActiveStmt!.WaitStats.Max(w => w.WaitTimeMs); + @foreach (var w in ActiveStmt!.WaitStats.OrderByDescending(w => w.WaitTimeMs)) + { + var barPct = maxWait > 0 ? (double)w.WaitTimeMs / maxWait * 100 : 0; +
+ @w.WaitType +
+
+
+ @w.WaitTimeMs.ToString("N0") ms +
+ } + } + else + { +
@(result.Summary.HasActualStats ? "No waits recorded" : "Estimated plan — no wait stats")
+ } +
+
+
+ + @* Warnings strip *@ + @if (GetAllWarnings(ActiveStmt!).Count > 0) + { +
+

Warnings + @{ + var allWarns = GetAllWarnings(ActiveStmt!); + var critCount = allWarns.Count(w => w.Severity == "Critical"); + var warnCount = allWarns.Count(w => w.Severity == "Warning"); + var infoCount = allWarns.Count(w => w.Severity == "Info"); + } + @if (critCount > 0) { @critCount } + @if (warnCount > 0) { @warnCount } + @if (infoCount > 0) { @infoCount } +

+
+ @foreach (var w in GetAllWarnings(ActiveStmt!)) + { +
+ @w.Severity + @if (w.Operator != null) + { + @w.Operator + } + @w.Type + @w.Message +
+ } +
+
+ } + + @* Statement text *@ +
+ Query Text +
@ActiveStmt!.StatementText
+
+ + @* Plan tree *@ + @if (ActiveStmtPlan?.RootNode != null) + { + var extents = PlanLayoutEngine.GetExtents(ActiveStmtPlan!.RootNode); +
+
+ + @RenderConnectors(ActiveStmtPlan!.RootNode) + + @RenderPlanNodes(ActiveStmtPlan!.RootNode, true) +
+
+ } + + @* Full text output *@ +
+ Full Text Analysis +
@textOutput
+
+ + @* === Operator Properties Panel === *@ + @if (selectedNode != null) + { + } - - @* Full text output *@ -
- Full Text Analysis -
@textOutput
-
} @code { @@ -295,6 +1590,7 @@ else private string? textOutput; private string? sourceLabel; private int activeStatement = 0; + private PlanNode? selectedNode; private StatementResult? ActiveStmt => result?.Statements.ElementAtOrDefault(activeStatement); private PlanStatement? ActiveStmtPlan => parsedPlan?.Batches.SelectMany(b => b.Statements).ElementAtOrDefault(activeStatement); @@ -389,6 +1685,17 @@ else errorMessage = null; planXml = ""; activeStatement = 0; + selectedNode = null; + } + + private async Task ExportHtml() + { + if (result == null || textOutput == null) return; + var html = HtmlExporter.Export(result, textOutput); + var fileName = (sourceLabel ?? "plan") + ".html"; + if (fileName.EndsWith(".sqlplan.html", StringComparison.OrdinalIgnoreCase)) + fileName = fileName[..^".sqlplan.html".Length] + ".html"; + await JS.InvokeVoidAsync("downloadFile", fileName, html); } private RenderFragment RenderPlanNodes(PlanNode node, bool isRoot) => builder => @@ -399,12 +1706,14 @@ else var parallelClass = node.Parallel ? " parallel" : ""; builder.OpenElement(0, "div"); - builder.AddAttribute(1, "class", $"plan-node{costClass}{warningClass}{parallelClass}"); + var selectedClass = node == selectedNode ? " selected" : ""; + builder.AddAttribute(1, "class", $"plan-node{costClass}{warningClass}{parallelClass}{selectedClass}"); builder.AddAttribute(2, "style", $"left: {node.X}px; top: {node.Y}px; width: {PlanLayoutEngine.NodeWidth}px; height: {height}px;"); var tooltip = BuildTooltip(node); builder.AddAttribute(3, "title", tooltip); + builder.AddAttribute(50, "onclick", EventCallback.Factory.Create(this, () => SelectNode(node))); // Icon row builder.OpenElement(4, "div"); @@ -625,4 +1934,103 @@ else if (kb < 1024 * 1024) return $"{kb / 1024.0:N1} MB"; return $"{kb / (1024.0 * 1024.0):N2} GB"; } + + private static string FormatMs(long ms) + { + if (ms < 1000) return $"{ms:N0} ms"; + return $"{ms / 1000.0:F3} s"; + } + + private void SelectNode(PlanNode node) + { + selectedNode = selectedNode == node ? null : node; + } + + private void CloseProperties() + { + selectedNode = null; + } + + private void SelectStatement(int idx) + { + activeStatement = idx; + selectedNode = null; + } + + private bool IsRootNode => selectedNode != null && ActiveStmtPlan?.RootNode == selectedNode; + + // Sort statement tabs: actual plans by elapsed time (desc), estimated by cost (desc) + private IEnumerable SortedStatementIndexes + { + get + { + if (result == null) return Enumerable.Empty(); + var indexes = Enumerable.Range(0, result.Statements.Count); + if (result.Summary.HasActualStats) + return indexes.OrderByDescending(i => result.Statements[i].QueryTime?.ElapsedTimeMs ?? 0); + return indexes.OrderByDescending(i => result.Statements[i].EstimatedCost); + } + } + + private static string GetOperatorLabel(PlanNode node) + { + if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp) && node.LogicalOp != "Parallelism") + return $"Parallelism ({node.LogicalOp})"; + return node.PhysicalOp; + } + + private static bool HasPredicates(PlanNode node) => + !string.IsNullOrEmpty(node.SeekPredicates) || + !string.IsNullOrEmpty(node.Predicate) || + !string.IsNullOrEmpty(node.HashKeysProbe) || + !string.IsNullOrEmpty(node.HashKeysBuild) || + !string.IsNullOrEmpty(node.BuildResidual) || + !string.IsNullOrEmpty(node.ProbeResidual) || + !string.IsNullOrEmpty(node.MergeResidual) || + !string.IsNullOrEmpty(node.PassThru) || + !string.IsNullOrEmpty(node.SetPredicate); + + private static bool HasOperatorDetails(PlanNode node) => + !string.IsNullOrEmpty(node.OrderBy) || + !string.IsNullOrEmpty(node.GroupBy) || + !string.IsNullOrEmpty(node.TopExpression) || + !string.IsNullOrEmpty(node.InnerSideJoinColumns) || + !string.IsNullOrEmpty(node.OuterSideJoinColumns) || + !string.IsNullOrEmpty(node.OuterReferences) || + !string.IsNullOrEmpty(node.DefinedValues) || + !string.IsNullOrEmpty(node.HashKeys) || + !string.IsNullOrEmpty(node.PartitionColumns) || + !string.IsNullOrEmpty(node.SegmentColumn) || + !string.IsNullOrEmpty(node.ConstantScanValues) || + !string.IsNullOrEmpty(node.ActionColumn) || + !string.IsNullOrEmpty(node.OriginalActionColumn) || + !string.IsNullOrEmpty(node.OffsetExpression) || + !string.IsNullOrEmpty(node.TvfParameters) || + !string.IsNullOrEmpty(node.UdxName) || + !string.IsNullOrEmpty(node.UdxUsedColumns) || + !string.IsNullOrEmpty(node.TieColumns) || + !string.IsNullOrEmpty(node.PartitioningType) || + !string.IsNullOrEmpty(node.PartitionId) || + !string.IsNullOrEmpty(node.StarJoinOperationType) || + !string.IsNullOrEmpty(node.ProbeColumn) || + node.ManyToMany || node.SortDistinct || node.BitmapCreator || + node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch || + node.Remoting || node.LocalParallelism || node.StartupExpression || + node.DMLRequestSort || node.SpoolStack || node.WithTies || + node.IsStarJoin || node.InRow || node.ComputeSequence || + node.RowCount || node.GroupExecuted || node.RemoteDataAccess || + node.OptimizedHalloweenProtectionUsed || + node.NonClusteredIndexCount > 0 || node.TopRows > 0 || + node.RollupHighestLevel > 0 || node.ForceSeekColumnCount > 0 || + node.StatsCollectionId > 0; + + private static bool HasMemoryInfo(PlanNode node) => + (node.MemoryGrantKB.HasValue && node.MemoryGrantKB > 0) || + (node.DesiredMemoryKB.HasValue && node.DesiredMemoryKB > 0) || + (node.MaxUsedMemoryKB.HasValue && node.MaxUsedMemoryKB > 0) || + node.InputMemoryGrantKB > 0 || + node.OutputMemoryGrantKB > 0 || + node.UsedMemoryGrantKB > 0 || + node.MemoryFractionInput > 0 || + node.MemoryFractionOutput > 0; } diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 07e5b54..0ae249b 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -31,6 +31,7 @@ + diff --git a/src/PlanViewer.Web/_Imports.razor b/src/PlanViewer.Web/_Imports.razor index f8fec2d..e40d810 100644 --- a/src/PlanViewer.Web/_Imports.razor +++ b/src/PlanViewer.Web/_Imports.razor @@ -3,6 +3,7 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop @using PlanViewer.Web @using PlanViewer.Web.Layout @using PlanViewer.Core.Models diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 2a7c101..95b0cb4 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -51,7 +51,7 @@ html, body { background: var(--bg); color: var(--text); - font-family: 'Armata', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + font-family: 'Montserrat', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; font-size: 14px; line-height: 1.5; } @@ -72,7 +72,7 @@ header { } .header-logo { - height: 28px; + height: 40px; width: auto; } @@ -83,9 +83,32 @@ header { } .header-title { + font-family: 'Montserrat', sans-serif; font-size: 1rem; + font-weight: 600; + color: #ffffff; + letter-spacing: 0.3px; +} + +.header-nav { + display: flex; + gap: 1.5rem; + margin-left: auto; +} + +.header-nav a { color: #e0e0e0; - letter-spacing: 0.5px; + text-decoration: none; + font-size: 1rem; + font-weight: 500; + padding: 0.3rem 0.6rem; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} + +.header-nav a:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); } /* === Footer === */ @@ -94,7 +117,8 @@ footer { padding: 1.5rem 2rem; border-top: 1px solid var(--border); margin-top: 2rem; - font-size: 0.8rem; + font-size: 1.1rem; + font-weight: 500; color: var(--text-muted); } @@ -136,12 +160,14 @@ main { font-size: 1.5rem; color: var(--text); margin-bottom: 0.25rem; - font-weight: 400; + font-weight: 700; } .privacy { - color: var(--text-muted); - font-size: 0.9rem; + color: var(--text); + font-family: 'Montserrat', sans-serif; + font-size: 1.05rem; + font-weight: 600; margin-bottom: 1.5rem; } @@ -317,12 +343,17 @@ textarea::placeholder { color: var(--text); } -.stmt-tab-cost { +.stmt-tab-cost, .stmt-tab-time { color: var(--text-muted); margin-left: 0.25rem; font-size: 0.75rem; } +.stmt-tab-time { + color: var(--text-secondary); + font-weight: 500; +} + .stmt-tab-warns { display: inline-block; background: var(--warning-color); @@ -342,6 +373,12 @@ textarea::placeholder { margin-bottom: 0.75rem; } +/* Give params and waits cards extra width when populated */ +.insight-card.params.has-items, +.insight-card.waits.has-items { + grid-column: span 2; +} + .insight-card { border-radius: 6px; border: 1px solid var(--border); @@ -514,7 +551,7 @@ textarea::placeholder { } .wait-type { - min-width: 120px; + min-width: 180px; font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 0.7rem; } @@ -561,13 +598,17 @@ textarea::placeholder { } .warn-count-badge { - background: var(--critical); color: #fff; font-size: 0.65rem; padding: 0.05rem 0.4rem; border-radius: 8px; + background: var(--critical); } +.warn-count-badge.critical { background: var(--critical); } +.warn-count-badge.warning { background: var(--warning-color); } +.warn-count-badge.info { background: var(--accent); } + .warnings-list { padding: 0.5rem 0.75rem; max-height: 300px; @@ -689,7 +730,7 @@ textarea::placeholder { justify-content: center; align-items: center; text-align: center; - cursor: default; + cursor: pointer; transition: box-shadow 0.15s; } @@ -824,7 +865,7 @@ textarea::placeholder { min-height: 200px; color: var(--text-muted); font-size: 1.1rem; - font-family: 'Armata', sans-serif; + font-family: 'Montserrat', sans-serif; } /* === Blazor Error UI === */ @@ -856,3 +897,223 @@ textarea::placeholder { grid-template-columns: 1fr; } } + +/* === Selected Node === */ +.plan-node.selected { + box-shadow: 0 0 0 2px var(--accent); + border-color: var(--accent); + background: #f0f8ff; +} + +/* === Properties Panel === */ +.properties-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 380px; + z-index: 100; + background: var(--bg); + border-left: 2px solid var(--accent); + display: flex; + flex-direction: column; + box-shadow: -4px 0 12px rgba(0, 0, 0, 0.08); + animation: slide-in 0.15s ease-out; +} + +@keyframes slide-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +/* Push main content when panel is open */ +main:has(.properties-panel) { + padding-right: 390px; + max-width: none; +} + +.prop-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.prop-header-info { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.prop-header-icon { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.prop-header-op { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); + line-height: 1.3; +} + +.prop-header-sub { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.prop-close { + background: none; + border: none; + font-size: 1.4rem; + color: var(--text-muted); + cursor: pointer; + padding: 0 0.25rem; + line-height: 1; + flex-shrink: 0; +} + +.prop-close:hover { + color: var(--text); +} + +.prop-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem 0; +} + +/* Sections */ +.prop-section { + border-bottom: 1px solid var(--border); +} + +.prop-section summary { + padding: 0.4rem 1rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--accent); + cursor: pointer; + user-select: none; + background: var(--bg); +} + +.prop-section summary:hover { + background: var(--bg-surface); +} + +.prop-section[open] summary { + border-bottom: 1px solid var(--border); +} + +/* Property grid */ +.prop-grid { + padding: 0.35rem 1rem; +} + +.prop-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0.2rem 0; + gap: 0.75rem; + font-size: 0.78rem; +} + +.prop-row.full { + flex-direction: column; + gap: 0.15rem; +} + +.prop-row.indent { + padding-left: 1rem; +} + +.prop-label { + color: var(--text-secondary); + font-size: 0.75rem; + flex-shrink: 0; + white-space: nowrap; +} + +.prop-value { + text-align: right; + color: var(--text); + font-weight: 500; + word-break: break-word; + min-width: 0; +} + +.prop-row.full .prop-value { + text-align: left; +} + +.prop-value.code { + font-family: 'Cascadia Code', 'Consolas', monospace; + font-size: 0.72rem; + font-weight: 400; + text-align: left; + background: var(--bg-surface); + padding: 0.15rem 0.4rem; + border-radius: 3px; + white-space: pre-wrap; + word-break: break-all; +} + +.prop-value.flag { + color: var(--accent); + font-weight: 600; +} + +.prop-value.flag.warn { + color: var(--orange-red); +} + +/* Warnings in panel */ +.prop-warn-count { + font-size: 0.65rem; + background: var(--critical); + color: #fff; + padding: 0.05rem 0.4rem; + border-radius: 8px; + font-weight: 600; +} + +.prop-warning { + padding: 0.3rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.78rem; +} + +.prop-warning:last-child { + border-bottom: none; +} + +.prop-warning-type { + font-weight: 600; + color: var(--orange); + display: block; + font-size: 0.75rem; +} + +.prop-warning-msg { + color: var(--text-secondary); + display: block; + font-size: 0.72rem; + margin-top: 0.1rem; +} + +/* Properties panel responsive */ +@media (max-width: 700px) { + .properties-panel { + width: 100%; + } + main:has(.properties-panel) { + padding-right: 0; + } +} diff --git a/src/PlanViewer.Web/wwwroot/darling-data-logo.jpg b/src/PlanViewer.Web/wwwroot/darling-data-logo.jpg new file mode 100644 index 0000000..ce80e22 Binary files /dev/null and b/src/PlanViewer.Web/wwwroot/darling-data-logo.jpg differ diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index a503897..a9b9670 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -3,11 +3,11 @@ - Performance Studio + Free SQL Server Query Plan Analysis — Darling Data - + @@ -21,5 +21,18 @@ + diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs new file mode 100644 index 0000000..b26fb9d --- /dev/null +++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs @@ -0,0 +1,66 @@ +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public class HtmlExporterTests +{ + [Fact] + public void Export_ProducesValidHtml_WithWarnings() + { + 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, "test-plan.sqlplan"); + var textOutput = TextFormatter.Format(result); + + var html = HtmlExporter.Export(result, textOutput); + + Assert.Contains("", html); + Assert.Contains("Performance Studio", html); + Assert.Contains("plan-type", html); + Assert.Contains("Full Text Analysis", html); + // Should contain operator tree + Assert.Contains("op-node", html); + // Should contain the text analysis output + Assert.Contains("=== Summary ===", html); + } + + [Fact] + public void Export_HandlesMultipleStatements() + { + var plan = PlanTestHelper.LoadAndAnalyze("excellent-parallel-spill.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "multi-stmt.sqlplan"); + var textOutput = TextFormatter.Format(result); + + var html = HtmlExporter.Export(result, textOutput); + + Assert.Contains("", html); + // Should encode HTML entities properly + Assert.DoesNotContain("", "").Replace("", "")); + } + + [Fact] + public void Export_EscapesHtmlInQueryText() + { + var plan = PlanTestHelper.LoadAndAnalyze("convert_implicit_plan.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "test.sqlplan"); + var textOutput = TextFormatter.Format(result); + + var html = HtmlExporter.Export(result, textOutput); + + // The HTML should be well-formed — no unescaped angle brackets in user content + Assert.Contains("", html); + Assert.Contains("", html); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 28ad002..d0656aa 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -173,14 +173,15 @@ public void Rule08_ParallelSkew_DetectedOnHighRowScan() // --------------------------------------------------------------- [Fact] - public void Rule09a_ExcessiveMemoryGrant_DetectedInLazySpoolPlan() + public void Rule09a_ExcessiveMemoryGrant_SmallGrantSuppressed() { + // lazy_spool_plan has a 1 MB grant — well under the 1 GB threshold. + // The XML MemoryGrantWarning should be suppressed (not worth surfacing). var plan = PlanTestHelper.LoadAndAnalyze("lazy_spool_plan.sqlplan"); - // The parser may surface this as a plan-level warning from XML var allWarnings = PlanTestHelper.AllWarnings(plan); - Assert.Contains(allWarnings, w => - w.WarningType.Contains("Memory Grant") || w.WarningType == "Excessive Memory Grant"); + Assert.DoesNotContain(allWarnings, w => + w.WarningType == "Memory Grant"); } // --------------------------------------------------------------- @@ -777,4 +778,70 @@ public void NoJoinPredicate_AppearsInTextFormatterOutput() Assert.Contains("No Join Predicate", text); Assert.Contains("often misleading", text); } + + // --------------------------------------------------------------- + // Issue #178: Warning improvement verification (test1.sqlplan) + // --------------------------------------------------------------- + + [Fact] + public void Issue178_5_SerialPlanSuppressedOnTrivialStatement() + { + // Statement 1 is a trivial variable assignment (cost ~0.000001) — no Serial Plan warning + // Uses private test plan from .internal (not committed to git) + var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan"); + if (plan == null) return; // Skip if plan not available + var stmt1 = plan.Batches.SelectMany(b => b.Statements).First(); + + Assert.True(stmt1.StatementSubTreeCost < 0.01, "Statement 1 should be trivial cost"); + Assert.DoesNotContain(stmt1.PlanWarnings, w => w.WarningType == "Serial Plan"); + } + + [Fact] + public void Issue178_9_JoinOrNotTriggeredByMergeInterval() + { + // Statement 8 has a Merge Interval inside a NOT IN anti-semi join — not a genuine OR expansion + var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan"); + if (plan == null) return; + var stmt8 = plan.Batches.SelectMany(b => b.Statements).ElementAt(7); + var allNodeWarnings = PlanTestHelper.AllNodeWarnings(stmt8); + + Assert.DoesNotContain(allNodeWarnings, w => w.WarningType == "Join OR Clause"); + } + + [Fact] + public void Issue178_12_RowGoal1to1Suppressed() + { + // Row Goal "1 to 1 (1x reduction)" should not fire — requires >= 2x reduction + var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan"); + if (plan == null) return; + var allWarnings = PlanTestHelper.AllWarnings(plan); + + Assert.DoesNotContain(allWarnings, w => + w.WarningType == "Row Goal" && w.Message.Contains("1x reduction")); + } + + [Fact] + public void Issue178_6_LocalVariableSuppressedOnTrivialStatement() + { + // Statement 1 is a trivial variable assignment (cost ~0.000001) — no Local Variables warning + var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan"); + if (plan == null) return; + var stmt1 = plan.Batches.SelectMany(b => b.Statements).First(); + + Assert.True(stmt1.StatementSubTreeCost < 0.01); + Assert.DoesNotContain(stmt1.PlanWarnings, w => w.WarningType == "Local Variables"); + } + + [Fact] + public void Issue178_7_FilterSuppressedOnTrivialChildIO() + { + // Statement 5 has a Filter with 19 reads and 0-1ms child — should be suppressed + var plan = PlanTestHelper.LoadFromInternal("test1.sqlplan"); + if (plan == null) return; + var stmt5 = plan.Batches.SelectMany(b => b.Statements).ElementAt(4); + var filterWarnings = PlanTestHelper.AllNodeWarnings(stmt5) + .Where(w => w.WarningType == "Filter Operator").ToList(); + + Assert.Empty(filterWarnings); + } } diff --git a/tests/PlanViewer.Core.Tests/PlanTestHelper.cs b/tests/PlanViewer.Core.Tests/PlanTestHelper.cs index f14be76..1e5e85a 100644 --- a/tests/PlanViewer.Core.Tests/PlanTestHelper.cs +++ b/tests/PlanViewer.Core.Tests/PlanTestHelper.cs @@ -80,6 +80,39 @@ public static PlanStatement FirstStatement(ParsedPlan plan) return null; } + /// + /// Loads a plan from .internal/examples (private plans not committed to git). + /// Returns null if the file doesn't exist so tests can skip gracefully. + /// + public static ParsedPlan? LoadFromInternal(string planFileName) + { + // Walk up from bin/Debug/net8.0 to find the repo root + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, ".internal"))) + dir = dir.Parent; + if (dir == null) return null; + + var path = Path.Combine(dir.FullName, ".internal", "examples", planFileName); + if (!File.Exists(path)) return null; + + var xml = File.ReadAllText(path); + xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + return plan; + } + + /// + /// Gets all node-level warnings for a single statement. + /// + public static List AllNodeWarnings(PlanStatement stmt) + { + var warnings = new List(); + if (stmt.RootNode != null) + CollectNodeWarnings(stmt.RootNode, warnings); + return warnings; + } + private static void CollectNodeWarnings(PlanNode node, List warnings) { warnings.AddRange(node.Warnings);