From e537463d9df4f8e0b15a20b13e84ece4f9c1b0be Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:30:20 -0400 Subject: [PATCH 01/10] Add operator properties panel to web app (issue #176) Click any plan tree node to open a right-side properties panel with full operator details. Matches desktop app parity across all 37 sections including per-thread stats, predicates, costs, memory, and root-only statement info. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/Pages/Index.razor | 1369 +++++++++++++++++++++++- src/PlanViewer.Web/wwwroot/css/app.css | 222 +++- 2 files changed, 1588 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 8e1cfd4..a57d061 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -63,7 +63,7 @@ else { var idx = si; var isActive = idx == activeStatement; - + + +
+ + @* General *@ +
+ General +
+
Physical Op@selectedNode.PhysicalOp
+
Logical Op@selectedNode.LogicalOp
+
Node ID@selectedNode.NodeId
+ @if (!string.IsNullOrEmpty(selectedNode.ExecutionMode)) + { +
Execution Mode@selectedNode.ExecutionMode
+ } + @if (!string.IsNullOrEmpty(selectedNode.ActualExecutionMode) && selectedNode.ActualExecutionMode != selectedNode.ExecutionMode) + { +
Actual Exec Mode@selectedNode.ActualExecutionMode
+ } +
Parallel@(selectedNode.Parallel ? "True" : "False")
+ @if (selectedNode.Partitioned) + { +
PartitionedYes
+ } + @if (selectedNode.EstimatedDOP > 0) + { +
Estimated DOP@selectedNode.EstimatedDOP
+ } + @if (!string.IsNullOrEmpty(selectedNode.FullObjectName)) + { +
Ordered@(selectedNode.Ordered ? "True" : "False")
+ @if (!string.IsNullOrEmpty(selectedNode.ScanDirection)) + { +
Scan Direction@selectedNode.ScanDirection
+ } +
Forced Index@(selectedNode.ForcedIndex ? "True" : "False")
+
ForceScan@(selectedNode.ForceScan ? "True" : "False")
+
ForceSeek@(selectedNode.ForceSeek ? "True" : "False")
+
NoExpandHint@(selectedNode.NoExpandHint ? "True" : "False")
+ @if (selectedNode.Lookup) + { +
LookupYes
+ } + @if (selectedNode.DynamicSeek) + { +
Dynamic SeekYes
+ } + } + @if (!string.IsNullOrEmpty(selectedNode.StorageType)) + { +
Storage@selectedNode.StorageType
+ } + @if (selectedNode.IsAdaptive) + { +
AdaptiveYes
+ } + @if (selectedNode.SpillOccurredDetail) + { +
Spill OccurredYes
+ } +
+
+ + @* Object *@ + @if (!string.IsNullOrEmpty(selectedNode.ObjectName)) + { +
+ Object +
+
Name@(selectedNode.FullObjectName ?? selectedNode.ObjectName)
+ @if (!string.IsNullOrEmpty(selectedNode.IndexName)) + { +
Index@selectedNode.IndexName
+ } + @if (!string.IsNullOrEmpty(selectedNode.IndexKind)) + { +
Index Kind@selectedNode.IndexKind
+ } + @if (selectedNode.FilteredIndex) + { +
FilteredYes
+ } + @if (!string.IsNullOrEmpty(selectedNode.ObjectAlias)) + { +
Alias@selectedNode.ObjectAlias
+ } + @if (!string.IsNullOrEmpty(selectedNode.DatabaseName)) + { +
Database@selectedNode.DatabaseName
+ } + @if (!string.IsNullOrEmpty(selectedNode.ServerName)) + { +
Server@selectedNode.ServerName
+ } + @if (selectedNode.TableReferenceId > 0) + { +
Table Ref ID@selectedNode.TableReferenceId
+ } +
+
+ } + + @* Costs *@ +
+ Costs +
+
Operator Cost@selectedNode.EstimatedOperatorCost.ToString("N4") (@(selectedNode.CostPercent)%)
+
Subtree Cost@selectedNode.EstimatedTotalSubtreeCost.ToString("N4")
+
I/O Cost@selectedNode.EstimateIO.ToString("N4")
+
CPU Cost@selectedNode.EstimateCPU.ToString("N4")
+
+
+ + @* Rows *@ +
+ Rows +
+
Est. Executions@((1 + selectedNode.EstimateRebinds).ToString("N0"))
+
Est. Rows Per Exec@selectedNode.EstimateRows.ToString("N1")
+
Est. Rows All Execs@((selectedNode.EstimateRows * Math.Max(1, 1 + selectedNode.EstimateRebinds)).ToString("N1"))
+ @if (selectedNode.EstimatedRowsRead > 0) + { +
Est. Rows Read@selectedNode.EstimatedRowsRead.ToString("N0")
+ } + @if (selectedNode.EstimatedRowSize > 0) + { +
Avg Row Size@selectedNode.EstimatedRowSize B
+ } + @if (selectedNode.TableCardinality > 0) + { +
Table Cardinality@selectedNode.TableCardinality.ToString("N0")
+ } + @if (selectedNode.EstimateRebinds > 0) + { +
Est. Rebinds@selectedNode.EstimateRebinds.ToString("N2")
+ } + @if (selectedNode.EstimateRewinds > 0) + { +
Est. Rewinds@selectedNode.EstimateRewinds.ToString("N2")
+ } + @if (selectedNode.EstimateRowsWithoutRowGoal > 0 && Math.Abs(selectedNode.EstimateRowsWithoutRowGoal - selectedNode.EstimateRows) > 0.01) + { +
Est. Rows (No Goal)@selectedNode.EstimateRowsWithoutRowGoal.ToString("N0")
+ } +
+
+ + @* Actual Statistics *@ + @if (selectedNode.HasActualStats) + { +
+ Actual Statistics +
+
Actual Rows@selectedNode.ActualRows.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats) + { +
Thread @t.ThreadId@t.ActualRows.ToString("N0")
+ } + } + @if (selectedNode.ActualRowsRead > 0) + { +
Actual Rows Read@selectedNode.ActualRowsRead.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualRowsRead > 0)) + { +
Thread @t.ThreadId@t.ActualRowsRead.ToString("N0")
+ } + } + } +
Actual Executions@selectedNode.ActualExecutions.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats) + { +
Thread @t.ThreadId@t.ActualExecutions.ToString("N0")
+ } + } + @if (selectedNode.ActualRebinds > 0) + { +
Actual Rebinds@selectedNode.ActualRebinds.ToString("N0")
+ } + @if (selectedNode.ActualRewinds > 0) + { +
Actual Rewinds@selectedNode.ActualRewinds.ToString("N0")
+ } + @if (selectedNode.PartitionsAccessed > 0) + { +
Partitions Accessed@selectedNode.PartitionsAccessed
+ @if (!string.IsNullOrEmpty(selectedNode.PartitionRanges)) + { +
Partition Ranges@selectedNode.PartitionRanges
+ } + } +
+
+ + @* Actual Timing *@ + @if (selectedNode.ActualElapsedMs > 0 || selectedNode.ActualCPUMs > 0 || selectedNode.UdfCpuTimeMs > 0 || selectedNode.UdfElapsedTimeMs > 0) + { +
+ Actual Timing +
+ @if (selectedNode.ActualElapsedMs > 0) + { +
Elapsed Time@FormatMs(selectedNode.ActualElapsedMs)
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualElapsedMs > 0)) + { +
Thread @t.ThreadId@FormatMs(t.ActualElapsedMs)
+ } + } + } + @if (selectedNode.ActualCPUMs > 0) + { +
CPU Time@FormatMs(selectedNode.ActualCPUMs)
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualCPUMs > 0)) + { +
Thread @t.ThreadId@FormatMs(t.ActualCPUMs)
+ } + } + } + @if (selectedNode.UdfElapsedTimeMs > 0) + { +
UDF Elapsed@FormatMs(selectedNode.UdfElapsedTimeMs)
+ } + @if (selectedNode.UdfCpuTimeMs > 0) + { +
UDF CPU@FormatMs(selectedNode.UdfCpuTimeMs)
+ } +
+
+ } + + @* Actual I/O *@ + @if (selectedNode.ActualLogicalReads > 0 || selectedNode.ActualPhysicalReads > 0 || selectedNode.ActualScans > 0 || selectedNode.ActualReadAheads > 0 || selectedNode.ActualSegmentReads > 0 || selectedNode.ActualSegmentSkips > 0) + { +
+ Actual I/O +
+
Logical Reads@selectedNode.ActualLogicalReads.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualLogicalReads > 0)) + { +
Thread @t.ThreadId@t.ActualLogicalReads.ToString("N0")
+ } + } + @if (selectedNode.ActualPhysicalReads > 0) + { +
Physical Reads@selectedNode.ActualPhysicalReads.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualPhysicalReads > 0)) + { +
Thread @t.ThreadId@t.ActualPhysicalReads.ToString("N0")
+ } + } + } + @if (selectedNode.ActualScans > 0) + { +
Scans@selectedNode.ActualScans.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualScans > 0)) + { +
Thread @t.ThreadId@t.ActualScans.ToString("N0")
+ } + } + } + @if (selectedNode.ActualReadAheads > 0) + { +
Read-Ahead Reads@selectedNode.ActualReadAheads.ToString("N0")
+ @if (selectedNode.PerThreadStats.Count > 1) + { + @foreach (var t in selectedNode.PerThreadStats.Where(t => t.ActualReadAheads > 0)) + { +
Thread @t.ThreadId@t.ActualReadAheads.ToString("N0")
+ } + } + } + @if (selectedNode.ActualSegmentReads > 0) + { +
Segment Reads@selectedNode.ActualSegmentReads.ToString("N0")
+ } + @if (selectedNode.ActualSegmentSkips > 0) + { +
Segment Skips@selectedNode.ActualSegmentSkips.ToString("N0")
+ } +
+
+ } + + @* Actual LOB I/O *@ + @if (selectedNode.ActualLobLogicalReads > 0 || selectedNode.ActualLobPhysicalReads > 0 || selectedNode.ActualLobReadAheads > 0) + { +
+ Actual LOB I/O +
+ @if (selectedNode.ActualLobLogicalReads > 0) + { +
LOB Logical Reads@selectedNode.ActualLobLogicalReads.ToString("N0")
+ } + @if (selectedNode.ActualLobPhysicalReads > 0) + { +
LOB Physical Reads@selectedNode.ActualLobPhysicalReads.ToString("N0")
+ } + @if (selectedNode.ActualLobReadAheads > 0) + { +
LOB Read-Aheads@selectedNode.ActualLobReadAheads.ToString("N0")
+ } +
+
+ } + } + + @* Predicates *@ + @if (HasPredicates(selectedNode)) + { +
+ Predicates +
+ @if (!string.IsNullOrEmpty(selectedNode.SeekPredicates)) + { +
Seek@selectedNode.SeekPredicates
+ } + @if (!string.IsNullOrEmpty(selectedNode.Predicate)) + { +
Predicate@selectedNode.Predicate
+ } + @if (!string.IsNullOrEmpty(selectedNode.HashKeysBuild)) + { +
Hash Keys (Build)@selectedNode.HashKeysBuild
+ } + @if (!string.IsNullOrEmpty(selectedNode.HashKeysProbe)) + { +
Hash Keys (Probe)@selectedNode.HashKeysProbe
+ } + @if (!string.IsNullOrEmpty(selectedNode.BuildResidual)) + { +
Build Residual@selectedNode.BuildResidual
+ } + @if (!string.IsNullOrEmpty(selectedNode.ProbeResidual)) + { +
Probe Residual@selectedNode.ProbeResidual
+ } + @if (!string.IsNullOrEmpty(selectedNode.MergeResidual)) + { +
Merge Residual@selectedNode.MergeResidual
+ } + @if (!string.IsNullOrEmpty(selectedNode.PassThru)) + { +
Pass Through@selectedNode.PassThru
+ } + @if (!string.IsNullOrEmpty(selectedNode.SetPredicate)) + { +
Set Predicate@selectedNode.SetPredicate
+ } + @if (selectedNode.GuessedSelectivity) + { +
Guessed SelectivityYes
+ } +
+
+ } + + @* Output *@ + @if (!string.IsNullOrEmpty(selectedNode.OutputColumns)) + { +
+ Output +
+
Columns@selectedNode.OutputColumns
+
+
+ } + + @* Operator Details *@ + @if (HasOperatorDetails(selectedNode)) + { +
+ Operator Details +
+ @if (!string.IsNullOrEmpty(selectedNode.OrderBy)) + { +
Order By@selectedNode.OrderBy
+ } + @if (!string.IsNullOrEmpty(selectedNode.GroupBy)) + { +
Group By@selectedNode.GroupBy
+ } + @if (!string.IsNullOrEmpty(selectedNode.TopExpression)) + { +
Top@selectedNode.TopExpression@(selectedNode.IsPercent ? " PERCENT" : "")@(selectedNode.WithTies ? " WITH TIES" : "")
+ } + @if (!string.IsNullOrEmpty(selectedNode.OffsetExpression)) + { +
Offset@selectedNode.OffsetExpression
+ } + @if (!string.IsNullOrEmpty(selectedNode.InnerSideJoinColumns)) + { +
Inner Join Cols@selectedNode.InnerSideJoinColumns
+ } + @if (!string.IsNullOrEmpty(selectedNode.OuterSideJoinColumns)) + { +
Outer Join Cols@selectedNode.OuterSideJoinColumns
+ } + @if (!string.IsNullOrEmpty(selectedNode.OuterReferences)) + { +
Outer References@selectedNode.OuterReferences
+ } + @if (!string.IsNullOrEmpty(selectedNode.DefinedValues)) + { +
Defined Values@selectedNode.DefinedValues
+ } + @if (selectedNode.ManyToMany) + { +
Many to ManyYes
+ } + @if (selectedNode.SortDistinct) + { +
Sort DistinctYes
+ } + @if (selectedNode.BitmapCreator) + { +
Bitmap CreatorYes
+ } + @if (selectedNode.NLOptimized) + { +
NL OptimizedYes
+ } + @if (selectedNode.WithOrderedPrefetch) + { +
Ordered PrefetchYes
+ } + @if (selectedNode.WithUnorderedPrefetch) + { +
Unordered PrefetchYes
+ } + @if (selectedNode.NonClusteredIndexCount > 0) + { +
NC Indexes@selectedNode.NonClusteredIndexCount maintained
+ @foreach (var ncIdx in selectedNode.NonClusteredIndexNames) + { +
@ncIdx
+ } + } + @if (!string.IsNullOrEmpty(selectedNode.HashKeys)) + { +
Hash Keys@selectedNode.HashKeys
+ } + @if (!string.IsNullOrEmpty(selectedNode.PartitionColumns)) + { +
Partition Cols@selectedNode.PartitionColumns
+ } + @if (!string.IsNullOrEmpty(selectedNode.SegmentColumn)) + { +
Segment Column@selectedNode.SegmentColumn
+ } + @if (selectedNode.StartupExpression) + { +
Startup ExpressionYes
+ } + @if (selectedNode.Remoting) + { +
RemotingYes
+ } + @if (selectedNode.LocalParallelism) + { +
Local ParallelismYes
+ } + @if (!string.IsNullOrEmpty(selectedNode.ConstantScanValues)) + { +
Constant Values@selectedNode.ConstantScanValues
+ } + @if (selectedNode.DMLRequestSort) + { +
DML Request SortYes
+ } + @if (!string.IsNullOrEmpty(selectedNode.ActionColumn)) + { +
Action Column@selectedNode.ActionColumn
+ } + @if (!string.IsNullOrEmpty(selectedNode.OriginalActionColumn)) + { +
Original Action Col@selectedNode.OriginalActionColumn
+ } + @if (!string.IsNullOrEmpty(selectedNode.TvfParameters)) + { +
TVF Parameters@selectedNode.TvfParameters
+ } + @if (!string.IsNullOrEmpty(selectedNode.UdxName)) + { +
UDX Name@selectedNode.UdxName
+ } + @if (!string.IsNullOrEmpty(selectedNode.UdxUsedColumns)) + { +
UDX Columns@selectedNode.UdxUsedColumns
+ } + @if (!string.IsNullOrEmpty(selectedNode.TieColumns)) + { +
Tie Columns@selectedNode.TieColumns
+ } + @if (selectedNode.InRow) + { +
In-RowYes
+ } + @if (selectedNode.ComputeSequence) + { +
Compute SequenceYes
+ } + @if (selectedNode.RollupHighestLevel > 0) + { +
Rollup Level@selectedNode.RollupHighestLevel
+ } + @if (selectedNode.RollupLevels.Count > 0) + { +
Rollup Levels@string.Join(", ", selectedNode.RollupLevels)
+ } + @if (selectedNode.SpoolStack) + { +
Spool StackYes
+ } + @if (selectedNode.PrimaryNodeId > 0) + { +
Primary Node ID@selectedNode.PrimaryNodeId
+ } + @if (selectedNode.IsStarJoin) + { +
Star JoinYes
+ } + @if (!string.IsNullOrEmpty(selectedNode.StarJoinOperationType)) + { +
Star Join Type@selectedNode.StarJoinOperationType
+ } + @if (!string.IsNullOrEmpty(selectedNode.ProbeColumn)) + { +
Probe Column@selectedNode.ProbeColumn
+ } + @if (!string.IsNullOrEmpty(selectedNode.PartitioningType)) + { +
Partitioning Type@selectedNode.PartitioningType
+ } + @if (!string.IsNullOrEmpty(selectedNode.PartitionId)) + { +
Partition ID@selectedNode.PartitionId
+ } + @if (selectedNode.ForceSeekColumnCount > 0) + { +
ForceSeek Cols@selectedNode.ForceSeekColumnCount
+ } + @if (selectedNode.RowCount) + { +
Row CountYes
+ } + @if (selectedNode.TopRows > 0) + { +
Top Rows@selectedNode.TopRows
+ } + @if (selectedNode.GroupExecuted) + { +
Group ExecutedYes
+ } + @if (selectedNode.RemoteDataAccess) + { +
Remote Data AccessYes
+ } + @if (selectedNode.OptimizedHalloweenProtectionUsed) + { +
Halloween ProtectionYes
+ } + @if (selectedNode.StatsCollectionId > 0) + { +
Stats Collection ID@selectedNode.StatsCollectionId
+ } +
+
+ } + + @* Adaptive Join *@ + @if (selectedNode.IsAdaptive) + { +
+ Adaptive Join +
+ @if (!string.IsNullOrEmpty(selectedNode.EstimatedJoinType)) + { +
Estimated Join@selectedNode.EstimatedJoinType
+ } + @if (!string.IsNullOrEmpty(selectedNode.ActualJoinType)) + { +
Actual Join@selectedNode.ActualJoinType
+ } + @if (selectedNode.AdaptiveThresholdRows > 0) + { +
Threshold Rows@selectedNode.AdaptiveThresholdRows.ToString("N0")
+ } +
+
+ } + + @* Scalar UDFs *@ + @if (selectedNode.ScalarUdfs.Count > 0) + { +
+ Scalar UDFs @selectedNode.ScalarUdfs.Count +
+ @foreach (var udf in selectedNode.ScalarUdfs) + { +
Function@udf.FunctionName
+ @if (udf.IsClrFunction) + { +
CLRYes
+ @if (!string.IsNullOrEmpty(udf.ClrAssembly)) + { +
Assembly@udf.ClrAssembly
+ } + @if (!string.IsNullOrEmpty(udf.ClrClass)) + { +
Class@udf.ClrClass
+ } + @if (!string.IsNullOrEmpty(udf.ClrMethod)) + { +
Method@udf.ClrMethod
+ } + } + } +
+
+ } + + @* Named Parameters *@ + @if (selectedNode.NamedParameters.Count > 0) + { +
+ Named Parameters +
+ @foreach (var np in selectedNode.NamedParameters) + { +
@np.Name@(np.ScalarString ?? "")
+ } +
+
+ } + + @* Operator Indexed Views *@ + @if (selectedNode.OperatorIndexedViews.Count > 0) + { +
+ Indexed Views +
+ @foreach (var iv in selectedNode.OperatorIndexedViews) + { +
@iv
+ } +
+
+ } + + @* Suggested Index *@ + @if (!string.IsNullOrEmpty(selectedNode.SuggestedIndex)) + { +
+ Suggested Index +
+
@selectedNode.SuggestedIndex
+
+
+ } + + @* Remote Operator *@ + @if (!string.IsNullOrEmpty(selectedNode.RemoteDestination) || !string.IsNullOrEmpty(selectedNode.RemoteSource) || !string.IsNullOrEmpty(selectedNode.RemoteObject) || !string.IsNullOrEmpty(selectedNode.RemoteQuery)) + { +
+ Remote Operator +
+ @if (!string.IsNullOrEmpty(selectedNode.RemoteDestination)) + { +
Destination@selectedNode.RemoteDestination
+ } + @if (!string.IsNullOrEmpty(selectedNode.RemoteSource)) + { +
Source@selectedNode.RemoteSource
+ } + @if (!string.IsNullOrEmpty(selectedNode.RemoteObject)) + { +
Object@selectedNode.RemoteObject
+ } + @if (!string.IsNullOrEmpty(selectedNode.RemoteQuery)) + { +
Query@selectedNode.RemoteQuery
+ } +
+
+ } + + @* Foreign Key References *@ + @if (selectedNode.ForeignKeyReferencesCount > 0) + { +
+ Foreign Key References +
+
FK References@selectedNode.ForeignKeyReferencesCount
+ @if (selectedNode.NoMatchingIndexCount > 0) + { +
No Matching Index@selectedNode.NoMatchingIndexCount
+ } + @if (selectedNode.PartialMatchingIndexCount > 0) + { +
Partial Match Index@selectedNode.PartialMatchingIndexCount
+ } +
+
+ } + + @* Memory *@ + @if (HasMemoryInfo(selectedNode)) + { +
+ Memory +
+ @if (selectedNode.MemoryGrantKB.HasValue && selectedNode.MemoryGrantKB > 0) + { +
Granted@FormatKB(selectedNode.MemoryGrantKB.Value)
+ } + @if (selectedNode.DesiredMemoryKB.HasValue && selectedNode.DesiredMemoryKB > 0) + { +
Desired@FormatKB(selectedNode.DesiredMemoryKB.Value)
+ } + @if (selectedNode.MaxUsedMemoryKB.HasValue && selectedNode.MaxUsedMemoryKB > 0) + { +
Max Used@FormatKB(selectedNode.MaxUsedMemoryKB.Value)
+ } + @if (selectedNode.InputMemoryGrantKB > 0) + { +
Input Grant@FormatKB(selectedNode.InputMemoryGrantKB)
+ } + @if (selectedNode.OutputMemoryGrantKB > 0) + { +
Output Grant@FormatKB(selectedNode.OutputMemoryGrantKB)
+ } + @if (selectedNode.UsedMemoryGrantKB > 0) + { +
Used Grant@FormatKB(selectedNode.UsedMemoryGrantKB)
+ } + @if (selectedNode.MemoryFractionInput > 0) + { +
Fraction Input@selectedNode.MemoryFractionInput.ToString("F4")
+ } + @if (selectedNode.MemoryFractionOutput > 0) + { +
Fraction Output@selectedNode.MemoryFractionOutput.ToString("F4")
+ } +
+
+ } + + @* Warnings *@ + @if (selectedNode.HasWarnings) + { +
+ Warnings @selectedNode.Warnings.Count +
+ @foreach (var w in selectedNode.Warnings) + { +
+ @w.WarningType + @w.Message +
+ } +
+
+ } + + @* Statement Info (root node only) *@ + @if (IsRootNode && ActiveStmtPlan != null) + { +
+ Statement Info +
+ @if (!string.IsNullOrEmpty(ActiveStmtPlan.StatementOptmLevel)) + { +
Optimization@ActiveStmtPlan.StatementOptmLevel
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.StatementOptmEarlyAbortReason)) + { +
Early Abort@ActiveStmtPlan.StatementOptmEarlyAbortReason
+ } + @if (ActiveStmtPlan.CardinalityEstimationModelVersion > 0) + { +
CE Model@ActiveStmtPlan.CardinalityEstimationModelVersion
+ } + @if (ActiveStmtPlan.DegreeOfParallelism > 0) + { +
DOP@ActiveStmtPlan.DegreeOfParallelism
+ } + @if (ActiveStmtPlan.CompileTimeMs > 0) + { +
Compile Time@FormatMs(ActiveStmtPlan.CompileTimeMs)
+ } + @if (ActiveStmtPlan.CompileCPUMs > 0) + { +
Compile CPU@FormatMs(ActiveStmtPlan.CompileCPUMs)
+ } + @if (ActiveStmtPlan.CompileMemoryKB > 0) + { +
Compile Memory@FormatKB(ActiveStmtPlan.CompileMemoryKB)
+ } + @if (ActiveStmtPlan.CachedPlanSizeKB > 0) + { +
Cached Plan Size@FormatKB(ActiveStmtPlan.CachedPlanSizeKB)
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.QueryHash)) + { +
Query Hash@ActiveStmtPlan.QueryHash
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.QueryPlanHash)) + { +
Plan Hash@ActiveStmtPlan.QueryPlanHash
+ } + @if (ActiveStmtPlan.BatchModeOnRowStoreUsed) + { +
Batch on RowStoreYes
+ } + @if (ActiveStmtPlan.SecurityPolicyApplied) + { +
Security PolicyYes
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.NonParallelPlanReason)) + { +
Non-Parallel Reason@ActiveStmtPlan.NonParallelPlanReason
+ } + @if (ActiveStmtPlan.EffectiveDOP > 0) + { +
Effective DOP@ActiveStmtPlan.EffectiveDOP
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.DOPFeedbackAdjusted)) + { +
DOP Feedback@ActiveStmtPlan.DOPFeedbackAdjusted
+ } + @if (ActiveStmtPlan.MaxQueryMemoryKB > 0) + { +
Max Query Memory@FormatKB(ActiveStmtPlan.MaxQueryMemoryKB)
+ } + @if (ActiveStmtPlan.QueryPlanMemoryGrantKB > 0) + { +
Plan Memory Grant@FormatKB(ActiveStmtPlan.QueryPlanMemoryGrantKB)
+ } + @if (ActiveStmtPlan.RetrievedFromCache) + { +
From CacheYes
+ } + @if (ActiveStmtPlan.StatementParameterizationType > 0) + { +
Parameterization@(ActiveStmtPlan.StatementParameterizationType == 1 ? "Forced" : ActiveStmtPlan.StatementParameterizationType == 2 ? "Simple" : ActiveStmtPlan.StatementParameterizationType.ToString())
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.StatementSqlHandle)) + { +
SQL Handle@ActiveStmtPlan.StatementSqlHandle
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.PlanGuideName)) + { +
Plan Guide@ActiveStmtPlan.PlanGuideName
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.PlanGuideDB)) + { +
Plan Guide DB@ActiveStmtPlan.PlanGuideDB
+ } + @if (ActiveStmtPlan.UsePlan) + { +
USE PLANYes
+ } + @if (ActiveStmtPlan.QueryStoreStatementHintId > 0) + { +
QS Hint ID@ActiveStmtPlan.QueryStoreStatementHintId
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.QueryStoreStatementHintText)) + { +
QS Hint@ActiveStmtPlan.QueryStoreStatementHintText
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.QueryStoreStatementHintSource)) + { +
QS Hint Source@ActiveStmtPlan.QueryStoreStatementHintSource
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.ParameterizedText)) + { +
Parameterized Text@ActiveStmtPlan.ParameterizedText
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.StmtUseDatabaseName)) + { +
USE Database@ActiveStmtPlan.StmtUseDatabaseName
+ } + @if (ActiveStmtPlan.DatabaseContextSettingsId > 0) + { +
DB Settings ID@ActiveStmtPlan.DatabaseContextSettingsId
+ } + @if (ActiveStmtPlan.ParentObjectId > 0) + { +
Parent Object ID@ActiveStmtPlan.ParentObjectId
+ } +
+
+ } + + @* Cursor Info (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && !string.IsNullOrEmpty(ActiveStmtPlan.CursorName)) + { +
+ Cursor Info +
+
Name@ActiveStmtPlan.CursorName
+ @if (!string.IsNullOrEmpty(ActiveStmtPlan.CursorActualType)) + { +
Actual Type@ActiveStmtPlan.CursorActualType
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.CursorRequestedType)) + { +
Requested Type@ActiveStmtPlan.CursorRequestedType
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.CursorConcurrency)) + { +
Concurrency@ActiveStmtPlan.CursorConcurrency
+ } + @if (ActiveStmtPlan.CursorForwardOnly) + { +
Forward OnlyYes
+ } +
+
+ } + + @* Memory Grant Info (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.MemoryGrant != null) + { +
+ Memory Grant Info +
+ @if (ActiveStmtPlan.MemoryGrant.GrantedMemoryKB > 0) + { +
Granted@FormatKB(ActiveStmtPlan.MemoryGrant.GrantedMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.MaxUsedMemoryKB > 0) + { +
Max Used@FormatKB(ActiveStmtPlan.MemoryGrant.MaxUsedMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.RequestedMemoryKB > 0) + { +
Requested@FormatKB(ActiveStmtPlan.MemoryGrant.RequestedMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.DesiredMemoryKB > 0) + { +
Desired@FormatKB(ActiveStmtPlan.MemoryGrant.DesiredMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.RequiredMemoryKB > 0) + { +
Required@FormatKB(ActiveStmtPlan.MemoryGrant.RequiredMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.SerialRequiredMemoryKB > 0) + { +
Serial Required@FormatKB(ActiveStmtPlan.MemoryGrant.SerialRequiredMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.SerialDesiredMemoryKB > 0) + { +
Serial Desired@FormatKB(ActiveStmtPlan.MemoryGrant.SerialDesiredMemoryKB)
+ } + @if (ActiveStmtPlan.MemoryGrant.GrantWaitTimeMs > 0) + { +
Grant Wait Time@FormatMs(ActiveStmtPlan.MemoryGrant.GrantWaitTimeMs)
+ } + @if (ActiveStmtPlan.MemoryGrant.LastRequestedMemoryKB > 0) + { +
Last Requested@FormatKB(ActiveStmtPlan.MemoryGrant.LastRequestedMemoryKB)
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.MemoryGrant.IsMemoryGrantFeedbackAdjusted)) + { +
Feedback Adjusted@ActiveStmtPlan.MemoryGrant.IsMemoryGrantFeedbackAdjusted
+ } +
+
+ } + + @* Feature Flags (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && (ActiveStmtPlan.ContainsInterleavedExecutionCandidates || ActiveStmtPlan.ContainsInlineScalarTsqlUdfs || ActiveStmtPlan.ContainsLedgerTables || ActiveStmtPlan.ExclusiveProfileTimeActive || ActiveStmtPlan.QueryCompilationReplay > 0 || ActiveStmtPlan.QueryVariantID > 0)) + { +
+ Feature Flags +
+ @if (ActiveStmtPlan.ContainsInterleavedExecutionCandidates) + { +
Interleaved ExecYes
+ } + @if (ActiveStmtPlan.ContainsInlineScalarTsqlUdfs) + { +
Inline Scalar UDFsYes
+ } + @if (ActiveStmtPlan.ContainsLedgerTables) + { +
Ledger TablesYes
+ } + @if (ActiveStmtPlan.ExclusiveProfileTimeActive) + { +
Exclusive ProfileYes
+ } + @if (ActiveStmtPlan.QueryCompilationReplay > 0) + { +
Compilation Replay@ActiveStmtPlan.QueryCompilationReplay
+ } + @if (ActiveStmtPlan.QueryVariantID > 0) + { +
Query Variant ID@ActiveStmtPlan.QueryVariantID
+ } +
+
+ } + + @* Set Options (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.SetOptions != null) + { +
+ Set Options +
+
ANSI_NULLS@(ActiveStmtPlan.SetOptions.AnsiNulls ? "ON" : "OFF")
+
ANSI_PADDING@(ActiveStmtPlan.SetOptions.AnsiPadding ? "ON" : "OFF")
+
ANSI_WARNINGS@(ActiveStmtPlan.SetOptions.AnsiWarnings ? "ON" : "OFF")
+
ARITHABORT@(ActiveStmtPlan.SetOptions.ArithAbort ? "ON" : "OFF")
+
CONCAT_NULL@(ActiveStmtPlan.SetOptions.ConcatNullYieldsNull ? "ON" : "OFF")
+
NUMERIC_ROUNDABORT@(ActiveStmtPlan.SetOptions.NumericRoundAbort ? "ON" : "OFF")
+
QUOTED_IDENTIFIER@(ActiveStmtPlan.SetOptions.QuotedIdentifier ? "ON" : "OFF")
+
+
+ } + + @* Hardware Properties (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.HardwareProperties != null) + { +
+ Hardware +
+ @if (ActiveStmtPlan.HardwareProperties.EstimatedAvailableMemoryGrant > 0) + { +
Available Memory@FormatKB(ActiveStmtPlan.HardwareProperties.EstimatedAvailableMemoryGrant)
+ } + @if (ActiveStmtPlan.HardwareProperties.EstimatedPagesCached > 0) + { +
Pages Cached@ActiveStmtPlan.HardwareProperties.EstimatedPagesCached.ToString("N0")
+ } + @if (ActiveStmtPlan.HardwareProperties.EstimatedAvailableDOP > 0) + { +
Available DOP@ActiveStmtPlan.HardwareProperties.EstimatedAvailableDOP
+ } + @if (ActiveStmtPlan.HardwareProperties.MaxCompileMemory > 0) + { +
Max Compile Memory@FormatKB(ActiveStmtPlan.HardwareProperties.MaxCompileMemory)
+ } +
+
+ } + + @* Handles (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && (!string.IsNullOrEmpty(ActiveStmtPlan.ParameterizedPlanHandle) || !string.IsNullOrEmpty(ActiveStmtPlan.BatchSqlHandle) || !string.IsNullOrEmpty(ActiveStmtPlan.DispatcherPlanHandle))) + { +
+ Handles +
+ @if (!string.IsNullOrEmpty(ActiveStmtPlan.ParameterizedPlanHandle)) + { +
Parameterized Plan@ActiveStmtPlan.ParameterizedPlanHandle
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.BatchSqlHandle)) + { +
Batch SQL Handle@ActiveStmtPlan.BatchSqlHandle
+ } + @if (!string.IsNullOrEmpty(ActiveStmtPlan.DispatcherPlanHandle)) + { +
Dispatcher Plan@ActiveStmtPlan.DispatcherPlanHandle
+ } +
+
+ } + + @* Trace Flags (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && ActiveStmtPlan.TraceFlags.Count > 0) + { +
+ Trace Flags @ActiveStmtPlan.TraceFlags.Count +
+ @foreach (var tf in ActiveStmtPlan.TraceFlags) + { +
TF @tf.Value@tf.Scope@(tf.IsCompileTime ? " (compile)" : "")
+ } +
+
+ } + + @* PSP Dispatcher (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.Dispatcher != null && (ActiveStmtPlan.Dispatcher.ParameterSensitivePredicates.Count > 0 || ActiveStmtPlan.Dispatcher.OptionalParameterPredicates.Count > 0)) + { +
+ PSP Dispatcher +
+ @foreach (var psp in ActiveStmtPlan.Dispatcher.ParameterSensitivePredicates) + { + @if (!string.IsNullOrEmpty(psp.PredicateText)) + { +
Predicate@psp.PredicateText
+ } +
Range[@psp.LowBoundary.ToString("N0") — @psp.HighBoundary.ToString("N0")]
+ @foreach (var stat in psp.Statistics) + { +
@(!string.IsNullOrEmpty(stat.TableName) ? $"{stat.TableName}.{stat.StatisticsName}" : stat.StatisticsName)Modified: @stat.ModificationCount.ToString("N0"), Sampled: @stat.SamplingPercent.ToString("F1")%
+ } + } + @foreach (var opt in ActiveStmtPlan.Dispatcher.OptionalParameterPredicates) + { + @if (!string.IsNullOrEmpty(opt.PredicateText)) + { +
Optional@opt.PredicateText
+ } + } +
+
+ } + + @* Cardinality Feedback (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && ActiveStmtPlan.CardinalityFeedback.Count > 0) + { +
+ Cardinality Feedback +
+ @foreach (var cf in ActiveStmtPlan.CardinalityFeedback) + { +
Node @cf.Key@cf.Value.ToString("N0") rows
+ } +
+
+ } + + @* Optimization Replay (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && !string.IsNullOrEmpty(ActiveStmtPlan.OptimizationReplayScript)) + { +
+ Optimization Replay +
+
@ActiveStmtPlan.OptimizationReplayScript
+
+
+ } + + @* Template Plan Guide (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && !string.IsNullOrEmpty(ActiveStmtPlan.TemplatePlanGuideName)) + { +
+ Template Plan Guide +
+
Name@ActiveStmtPlan.TemplatePlanGuideName
+ @if (!string.IsNullOrEmpty(ActiveStmtPlan.TemplatePlanGuideDB)) + { +
Database@ActiveStmtPlan.TemplatePlanGuideDB
+ } +
+
+ } + + @* Plan Version (root only) *@ + @if (IsRootNode && parsedPlan != null && (!string.IsNullOrEmpty(parsedPlan.BuildVersion) || !string.IsNullOrEmpty(parsedPlan.Build))) + { +
+ Plan Version +
+ @if (!string.IsNullOrEmpty(parsedPlan.BuildVersion)) + { +
Build Version@parsedPlan.BuildVersion
+ } + @if (!string.IsNullOrEmpty(parsedPlan.Build)) + { +
Build@parsedPlan.Build
+ } + @if (parsedPlan.ClusteredMode) + { +
Clustered ModeYes
+ } +
+
+ } + + @* Statistics Used (root only) *@ + @if (IsRootNode && ActiveStmtPlan != null && ActiveStmtPlan.StatsUsage.Count > 0) + { +
+ Statistics Used @ActiveStmtPlan.StatsUsage.Count +
+ @foreach (var stat in ActiveStmtPlan.StatsUsage) + { +
@(!string.IsNullOrEmpty(stat.TableName) ? $"{stat.TableName}.{stat.StatisticsName}" : stat.StatisticsName)Mod: @stat.ModificationCount.ToString("N0"), @stat.SamplingPercent.ToString("F1")%@(!string.IsNullOrEmpty(stat.LastUpdate) ? $", {stat.LastUpdate}" : "")
+ } +
+
+ } + + @* Thread Stats (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.ThreadStats != null) + { +
+ Thread Stats +
+
Branches@ActiveStmtPlan.ThreadStats.Branches
+
Used Threads@ActiveStmtPlan.ThreadStats.UsedThreads
+ @if (ActiveStmtPlan.ThreadStats.Reservations.Sum(r => r.ReservedThreads) > 0) + { +
Reserved Threads@ActiveStmtPlan.ThreadStats.Reservations.Sum(r => r.ReservedThreads)
+ @foreach (var res in ActiveStmtPlan.ThreadStats.Reservations) + { +
Node @res.NodeId@res.ReservedThreads reserved
+ } + } +
+
+ } + + @* Query Time Stats (root only) *@ + @if (IsRootNode && ActiveStmtPlan?.QueryTimeStats != null) + { +
+ Query Time Stats +
+
CPU Time@FormatMs(ActiveStmtPlan.QueryTimeStats.CpuTimeMs)
+
Elapsed Time@FormatMs(ActiveStmtPlan.QueryTimeStats.ElapsedTimeMs)
+ @if (ActiveStmtPlan.QueryUdfCpuTimeMs > 0) + { +
UDF CPU Time@FormatMs(ActiveStmtPlan.QueryUdfCpuTimeMs)
+ } + @if (ActiveStmtPlan.QueryUdfElapsedTimeMs > 0) + { +
UDF Elapsed Time@FormatMs(ActiveStmtPlan.QueryUdfElapsedTimeMs)
+ } +
+
+ } + + @* Indexed Views (root only, statement-level) *@ + @if (IsRootNode && ActiveStmtPlan != null && ActiveStmtPlan.IndexedViews.Count > 0) + { +
+ Indexed Views +
+ @foreach (var iv in ActiveStmtPlan.IndexedViews) + { +
View@iv
+ } +
+
+ } + +
+ + } } @code { @@ -295,6 +1570,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 +1665,7 @@ else errorMessage = null; planXml = ""; activeStatement = 0; + selectedNode = null; } private RenderFragment RenderPlanNodes(PlanNode node, bool isRoot) => builder => @@ -399,12 +1676,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 +1904,90 @@ 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; + + 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/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 2a7c101..7a6f18e 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -689,7 +689,7 @@ textarea::placeholder { justify-content: center; align-items: center; text-align: center; - cursor: default; + cursor: pointer; transition: box-shadow 0.15s; } @@ -856,3 +856,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; + } +} From 19a008b4abfd9d0f1ab1684006220070842072da Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:43:16 -0400 Subject: [PATCH 02/10] Gate XML MemoryGrantWarning at 1 GB to match Rule 9 threshold The plan XML's MemoryGrantWarning element fires for any grant size, including trivially small ones (1 MB). Gate at 1 GB to suppress noise, consistent with the analyzer's Rule 9 threshold. All values confirmed as KB across all example plans. Updated test to verify small grants are suppressed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/ShowPlanParser.cs | 18 ++++++++++++------ .../PlanViewer.Core.Tests/PlanAnalyzerTests.cs | 9 +++++---- 2 files changed, 17 insertions(+), 10 deletions(-) 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/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 28ad002..33d0689 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"); } // --------------------------------------------------------------- From f8c03bcd5706a47d71960429d4b293a252acb19d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:59:36 -0400 Subject: [PATCH 03/10] Fix false positive warnings from issue #178 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rule 3 (Serial Plan): Skip trivial statements with cost < 0.01 — a 0ms variable assignment shouldn't warn about MAXDOP 1 - Rule 15 (Join OR Clause): Exclude Merge Interval patterns inside anti/semi joins — NOT IN subqueries produce the same operator chain but aren't OR expansions - Rule 26 (Row Goal): Require >= 2x reduction — "1 to 1 (1x reduction)" and tiny floating-point differences are noise Tests load from .internal/examples (gitignored) and skip gracefully on CI. Closes items 5, 9, 12 from #178. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 26 +++++++++--- .../PlanAnalyzerTests.cs | 41 +++++++++++++++++++ tests/PlanViewer.Core.Tests/PlanTestHelper.cs | 33 +++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index f4c829d..642dc50 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -124,7 +124,9 @@ 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 statements (e.g., variable assignments, constant scans) — not worth warning about + if (!cfg.IsRuleDisabled(3) && !string.IsNullOrEmpty(stmt.NonParallelPlanReason) + && stmt.StatementSubTreeCost >= 0.01) { var reason = stmt.NonParallelPlanReason switch { @@ -938,12 +940,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 +1184,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/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 33d0689..dfec23e 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -778,4 +778,45 @@ 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")); + } } 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); From dc6a043961e272e1400f77c7f04ec165e061f6da Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:06:21 -0400 Subject: [PATCH 04/10] Add impact thresholds to Filter and Local Variable warnings (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rule 1 (Filter Operator): Suppress when child subtree is trivial — actual plans: < 128 reads AND < 10ms; estimated plans: cost < 1.0 - Rule 20 (Local Variables): Gate on statement cost >= 0.01 to skip trivial variable assignments where estimate quality doesn't matter Both fixes work correctly for estimated and actual plans, using cost-based fallbacks when runtime stats aren't available. Closes items 6, 7, 11 from #178. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 48 ++++++++++++++----- .../PlanAnalyzerTests.cs | 25 ++++++++++ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 642dc50..26a810e 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -236,7 +236,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)) @@ -443,21 +444,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 diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index dfec23e..d0656aa 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -819,4 +819,29 @@ public void Issue178_12_RowGoal1to1Suppressed() 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); + } } From 7b5afdd005bda1b38e41b22ba79acfaa32640fa2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:09:07 -0400 Subject: [PATCH 05/10] Improve UDF message and web UI feedback items (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rule 4/6 (Scalar UDF): Expanded remediation options — mention assigning to a variable, SQL Server 2019+ automatic UDF inlining - Warning badge: Split count by severity (critical/warning/info) with color-coded badges instead of a single combined number - Statement tabs: Show elapsed time instead of cost for actual plans - Wait stats: Wider wait type column (180px) and double-width card when populated for better readability Closes items 2, 3 (partial), 4, 10 from #178. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 6 ++--- src/PlanViewer.Web/Pages/Index.razor | 26 +++++++++++++++++--- src/PlanViewer.Web/wwwroot/css/app.css | 20 ++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 26a810e..b66d8fe 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -228,7 +228,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 }); } @@ -504,7 +504,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 }); } @@ -565,7 +565,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 }); } diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index a57d061..8f1e79c 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -63,12 +63,20 @@ else { var idx = si; var isActive = idx == activeStatement; + var s = result.Statements[idx]; } @@ -240,7 +248,17 @@ else @if (GetAllWarnings(ActiveStmt!).Count > 0) {
-

Warnings @GetAllWarnings(ActiveStmt!).Count

+

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!)) { diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 7a6f18e..829ab67 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -317,12 +317,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 +347,11 @@ textarea::placeholder { margin-bottom: 0.75rem; } +/* Wait stats card gets extra width when present */ +.insight-card.waits.has-items { + grid-column: span 2; +} + .insight-card { border-radius: 6px; border: 1px solid var(--border); @@ -514,7 +524,7 @@ textarea::placeholder { } .wait-type { - min-width: 120px; + min-width: 180px; font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 0.7rem; } @@ -561,13 +571,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; From 66c2b5f553dff693a1ea725fb086d69fa86d4783 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:13:36 -0400 Subject: [PATCH 06/10] Sort statement tabs by time/cost, swap to full Darling Data logo (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Statement tabs sorted by elapsed time (actual plans) or cost (estimated plans), slowest first — problem statement is always immediately visible - Replaced small PNG logo with full Darling Data barbell logo from erikdarling.com, bumped height to 40px for legibility - Default active statement is the first tab (sorted, so the slowest) Closes items 1, 3 from #178. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PlanViewer.Web/Layout/MainLayout.razor | 2 +- src/PlanViewer.Web/Pages/Index.razor | 17 +++++++++++++++-- src/PlanViewer.Web/wwwroot/css/app.css | 2 +- .../wwwroot/darling-data-logo.jpg | Bin 0 -> 102726 bytes 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 src/PlanViewer.Web/wwwroot/darling-data-logo.jpg diff --git a/src/PlanViewer.Web/Layout/MainLayout.razor b/src/PlanViewer.Web/Layout/MainLayout.razor index 3981503..2efde8d 100644 --- a/src/PlanViewer.Web/Layout/MainLayout.razor +++ b/src/PlanViewer.Web/Layout/MainLayout.razor @@ -2,7 +2,7 @@
- + Performance Studio
diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 8f1e79c..da00a74 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -59,9 +59,9 @@ 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; var s = result.Statements[idx]; } diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 05bbb7d..95b0cb4 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -373,7 +373,8 @@ textarea::placeholder { margin-bottom: 0.75rem; } -/* Wait stats card gets extra width when present */ +/* Give params and waits cards extra width when populated */ +.insight-card.params.has-items, .insight-card.waits.has-items { grid-column: span 2; } 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 10/10] 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); + } +}