From 60d538a860a9873e6a6ad475dd6aa9612a53b2cb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:18:28 -0400 Subject: [PATCH] Add HTML export for plan analysis (issue #182, Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained HTML export that works offline — no server needed. Includes insights, warnings, operator tree, and full text analysis. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Output/HtmlExporter.cs | 537 ++++++++++++++++++ src/PlanViewer.Web/Pages/Index.razor | 12 + src/PlanViewer.Web/PlanViewer.Web.csproj | 1 + src/PlanViewer.Web/_Imports.razor | 1 + src/PlanViewer.Web/wwwroot/index.html | 13 + .../HtmlExporterTests.cs | 66 +++ 6 files changed, 630 insertions(+) create mode 100644 src/PlanViewer.Core/Output/HtmlExporter.cs create mode 100644 tests/PlanViewer.Core.Tests/HtmlExporterTests.cs 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.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 6aae734..7fa47bf 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -1,4 +1,5 @@ @page "/" +@inject IJSRuntime JS @if (result == null) { @@ -45,6 +46,7 @@ else
+ @sourceLabel @(result.Summary.HasActualStats ? "Actual Plan" : "Estimated Plan") @@ -1686,6 +1688,16 @@ else 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 => { var height = PlanLayoutEngine.GetNodeHeight(node); 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/index.html b/src/PlanViewer.Web/wwwroot/index.html index 543dff4..a9b9670 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -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); + } +}