diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 75fb26f..2db8609 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -495,13 +495,45 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1) var iconBitmap = IconHelper.LoadIcon(node.IconName); if (iconBitmap != null) { - iconRow.Children.Add(new Image + var iconImage = new Image { Source = iconBitmap, Width = 32, Height = 32, Margin = new Thickness(0, 0, 0, 2) - }); + }; + + // Distinguish Parallelism subtypes (Repartition / Distribute / Gather Streams) + // by overlaying a small letter on the shared parallelism.png icon. + var parallelismGlyph = GetParallelismGlyph(node); + if (parallelismGlyph != null) + { + var iconGrid = new Grid { Margin = new Thickness(0, 0, 0, 2) }; + iconGrid.Children.Add(iconImage); + iconImage.Margin = default; + iconGrid.Children.Add(new Border + { + Width = 14, Height = 14, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Bottom, + Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)), + CornerRadius = new CornerRadius(7), + Child = new TextBlock + { + Text = parallelismGlyph, + FontSize = 9, + FontWeight = FontWeight.Bold, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }); + iconRow.Children.Add(iconGrid); + } + else + { + iconRow.Children.Add(iconImage); + } } // Warning indicator badge (orange triangle with !) @@ -3062,6 +3094,24 @@ private void UpdateInsightsHeader() InsightsHeader.Text = " Plan Insights"; } + /// + /// For Parallelism nodes, returns a single-letter glyph distinguishing + /// the three subtypes: R(epartition) / D(istribute) / G(ather) Streams. + /// Returns null for non-parallelism operators. + /// + private static string? GetParallelismGlyph(PlanNode node) + { + if (!string.Equals(node.PhysicalOp, "Parallelism", StringComparison.Ordinal)) + return null; + return node.LogicalOp switch + { + "Repartition Streams" => "R", + "Distribute Streams" => "D", + "Gather Streams" => "G", + _ => null + }; + } + private static string GetWaitCategory(string waitType) { if (waitType.StartsWith("SOS_SCHEDULER_YIELD") || diff --git a/src/PlanViewer.Core/Services/PlanIconMapper.cs b/src/PlanViewer.Core/Services/PlanIconMapper.cs index d7d0580..d329d7d 100644 --- a/src/PlanViewer.Core/Services/PlanIconMapper.cs +++ b/src/PlanViewer.Core/Services/PlanIconMapper.cs @@ -178,9 +178,36 @@ public static class PlanIconMapper /// /// Returns the icon file name (without extension) for a given physical operator. + /// When is "ColumnStore" on a *Index Scan, + /// routes to the columnstore variant. /// - public static string GetIconName(string physicalOp) + public static string GetIconName(string physicalOp, string? storageType = null) { + // Columnstore scans surface as PhysicalOp="Clustered Index Scan" / "Index Scan" + // with Storage="ColumnStore" on the Object element. Route to the columnstore icon. + if (string.Equals(storageType, "ColumnStore", StringComparison.OrdinalIgnoreCase)) + { + switch (physicalOp) + { + case "Clustered Index Scan": + case "Index Scan": + case "Columnstore Index Scan": + return "columnstore_index_scan"; + case "Clustered Index Delete": + case "Index Delete": + return "columnstore_index_delete"; + case "Clustered Index Insert": + case "Index Insert": + return "columnstore_index_insert"; + case "Clustered Index Update": + case "Index Update": + return "columnstore_index_update"; + case "Clustered Index Merge": + case "Index Merge": + return "columnstore_index_merge"; + } + } + if (IconMap.TryGetValue(physicalOp, out var iconName)) return iconName; diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index db1a374..35194c5 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -667,8 +667,9 @@ private static PlanNode ParseRelOp(XElement relOpEl) } - // Map to icon - node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp); + // Icon mapping is deferred until after StorageType is parsed below, + // so columnstore scans (which surface as Clustered/Index Scan with + // Storage="ColumnStore") can be routed to the columnstore icon. // Handle operator-specific element var physicalOpEl = GetOperatorElement(relOpEl); @@ -1368,6 +1369,11 @@ private static PlanNode ParseRelOp(XElement relOpEl) } } + // Map to icon — done here so columnstore scans (which surface as + // Clustered/Index Scan with Storage="ColumnStore") can be routed to + // the columnstore icon. + node.IconName = PlanIconMapper.GetIconName(node.PhysicalOp, node.StorageType); + // Recurse into child RelOps foreach (var childRelOp in FindChildRelOps(relOpEl)) {