diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
new file mode 100644
index 0000000..822bafc
--- /dev/null
+++ b/.github/workflows/nightly.yml
@@ -0,0 +1,161 @@
+name: Nightly Build
+
+on:
+ schedule:
+ # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT)
+ - cron: '0 6 * * *'
+ workflow_dispatch: # manual trigger
+
+permissions:
+ contents: write
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ outputs:
+ has_changes: ${{ steps.check.outputs.has_changes }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: dev
+ fetch-depth: 0
+
+ - name: Check for new commits in last 24 hours
+ id: check
+ run: |
+ RECENT=$(git log --since="24 hours ago" --oneline | head -1)
+ if [ -n "$RECENT" ]; then
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ echo "New commits found — building nightly"
+ else
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ echo "No new commits — skipping nightly build"
+ fi
+
+ build:
+ needs: check
+ if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch'
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: dev
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Set nightly version
+ id: version
+ shell: pwsh
+ run: |
+ $base = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ $date = Get-Date -Format "yyyyMMdd"
+ $nightly = "$base-nightly.$date"
+ echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT
+ echo "Nightly version: $nightly"
+
+ - name: Restore dependencies
+ run: |
+ dotnet restore src/PlanViewer.Core/PlanViewer.Core.csproj
+ dotnet restore src/PlanViewer.App/PlanViewer.App.csproj
+ dotnet restore src/PlanViewer.Cli/PlanViewer.Cli.csproj
+ dotnet restore tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
+
+ - name: Run tests
+ run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal
+
+ - name: Publish App (all platforms)
+ run: |
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
+
+ - name: Package artifacts
+ shell: pwsh
+ env:
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ New-Item -ItemType Directory -Force -Path releases
+
+ # Package Windows and Linux as flat zips
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
+ Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
+ }
+
+ # Package macOS as proper .app bundles
+ foreach ($rid in @('osx-x64', 'osx-arm64')) {
+ $appName = "PerformanceStudio.app"
+ $bundleDir = "publish/$rid-bundle/$appName"
+
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
+
+ Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
+
+ if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
+ }
+
+ $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
+ $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
+
+ if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
+ }
+
+ $wrapperDir = "publish/$rid-bundle"
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
+
+ Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
+ }
+
+ - name: Generate checksums
+ shell: pwsh
+ run: |
+ $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
+ $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$hash $($_.Name)"
+ }
+ $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
+ Write-Host "Checksums:"
+ $checksums | ForEach-Object { Write-Host $_ }
+
+ - name: Delete previous nightly release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0
+ shell: pwsh
+
+ - name: Create nightly release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: pwsh
+ run: |
+ $version = "${{ steps.version.outputs.VERSION }}"
+ $sha = git rev-parse --short HEAD
+ $body = @"
+ Automated nightly build from ``dev`` branch.
+
+ **Version:** ``$version``
+ **Commit:** ``$sha``
+ **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC")
+
+ > These builds include the latest changes and may be unstable.
+ > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest).
+ "@
+
+ gh release create nightly `
+ --target dev `
+ --title "Nightly Build ($version)" `
+ --notes $body `
+ --prerelease `
+ releases/*.zip releases/SHA256SUMS.txt
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 503228f..db9d492 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -246,12 +246,13 @@
HorizontalContentAlignment="Left"
VerticalContentAlignment="Top"
Background="{DynamicResource BackgroundBrush}">
-
+
+
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 3be8573..363d967 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -18,6 +18,7 @@
using PlanViewer.App.Helpers;
using PlanViewer.App.Mcp;
using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
using PlanViewer.Core.Services;
using AvaloniaPath = Avalonia.Controls.Shapes.Path;
@@ -132,8 +133,9 @@ public PlanViewerControl()
_splitterColumn = planGrid.ColumnDefinitions[3];
_propertiesColumn = planGrid.ColumnDefinitions[4];
- // ScaleTransform is the RenderTransform of PlanCanvas
- _zoomTransform = (ScaleTransform)PlanCanvas.RenderTransform!;
+ // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas
+ var layoutTransform = this.FindControl("PlanLayoutTransform")!;
+ _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
}
@@ -166,6 +168,11 @@ public ServerMetadata? Metadata
}
}
+ // Events for MainWindow to wire up advice/repro actions
+ public event EventHandler? HumanAdviceRequested;
+ public event EventHandler? RobotAdviceRequested;
+ public event EventHandler? CopyReproRequested;
+
public void LoadPlan(string planXml, string label, string? queryText = null)
{
_label = label;
@@ -284,6 +291,10 @@ private void RenderStatement(PlanStatement statement)
// Scroll to top-left so the plan root is immediately visible
PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
+ // Canvas-level context menu (zoom, advice, repro, save)
+ // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable
+ PlanScrollViewer.ContextMenu = BuildCanvasContextMenu();
+
CostText.Text = "";
}
@@ -331,6 +342,8 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
if (totalWarningCount > 0)
{
var allWarnings = new List();
+ if (_currentStatement != null)
+ allWarnings.AddRange(_currentStatement.PlanWarnings);
CollectWarnings(node, allWarnings);
ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings));
}
@@ -595,10 +608,6 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child)
figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
geometry.Figures!.Add(figure);
- var rowText = child.HasActualStats
- ? $"Actual Rows: {child.ActualRows:N0}"
- : $"Estimated Rows: {child.EstimateRows:N0}";
-
var path = new AvaloniaPath
{
Data = geometry,
@@ -606,10 +615,77 @@ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child)
StrokeThickness = thickness,
StrokeJoin = PenLineJoin.Round
};
- ToolTip.SetTip(path, rowText);
+ ToolTip.SetTip(path, BuildEdgeTooltipContent(child));
return path;
}
+ private object BuildEdgeTooltipContent(PlanNode child)
+ {
+ var panel = new StackPanel { MinWidth = 240 };
+
+ void AddRow(string label, string value)
+ {
+ var row = new Grid();
+ row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+ var lbl = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 12,
+ Margin = new Thickness(0, 1, 12, 1)
+ };
+ var val = new TextBlock
+ {
+ Text = value,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)),
+ FontSize = 12,
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetColumn(lbl, 0);
+ Grid.SetColumn(val, 1);
+ row.Children.Add(lbl);
+ row.Children.Add(val);
+ panel.Children.Add(row);
+ }
+
+ if (child.HasActualStats)
+ AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}");
+
+ AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}");
+
+ var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds;
+ var estimatedRowsAllExec = child.EstimateRows * executions;
+ AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}");
+
+ if (child.EstimatedRowSize > 0)
+ {
+ AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize));
+ var dataSize = estimatedRowsAllExec * child.EstimatedRowSize;
+ AddRow("Estimated Data Size", FormatBytes(dataSize));
+ }
+
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 6),
+ CornerRadius = new CornerRadius(4),
+ Child = panel
+ };
+ }
+
+ private static string FormatBytes(double bytes)
+ {
+ if (bytes < 1024) return $"{bytes:N0} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB";
+ if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB";
+ return $"{bytes / (1024L * 1024 * 1024):N1} GB";
+ }
+
#endregion
#region Node Selection & Properties Panel
@@ -692,6 +768,48 @@ private ContextMenu BuildNodeContextMenu(PlanNode node)
return menu;
}
+ private ContextMenu BuildCanvasContextMenu()
+ {
+ var menu = new ContextMenu();
+
+ // Zoom
+ var zoomInItem = new MenuItem { Header = "Zoom In" };
+ zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep);
+ menu.Items.Add(zoomInItem);
+
+ var zoomOutItem = new MenuItem { Header = "Zoom Out" };
+ zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep);
+ menu.Items.Add(zoomOutItem);
+
+ var fitItem = new MenuItem { Header = "Fit to View" };
+ fitItem.Click += ZoomFit_Click;
+ menu.Items.Add(fitItem);
+
+ menu.Items.Add(new Separator());
+
+ // Advice
+ var humanAdviceItem = new MenuItem { Header = "Human Advice" };
+ humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(humanAdviceItem);
+
+ var robotAdviceItem = new MenuItem { Header = "Robot Advice" };
+ robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(robotAdviceItem);
+
+ menu.Items.Add(new Separator());
+
+ // Repro & Save
+ var copyReproItem = new MenuItem { Header = "Copy Repro Script" };
+ copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(copyReproItem);
+
+ var saveItem = new MenuItem { Header = "Save .sqlplan" };
+ saveItem.Click += SavePlan_Click;
+ menu.Items.Add(saveItem);
+
+ return menu;
+ }
+
private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
{
var topLevel = TopLevel.GetTopLevel(this);
@@ -786,7 +904,7 @@ private void ShowPropertiesPanel(PlanNode node)
|| !string.IsNullOrEmpty(node.InnerSideJoinColumns)
|| !string.IsNullOrEmpty(node.OuterSideJoinColumns)
|| !string.IsNullOrEmpty(node.ActionColumn)
- || node.ManyToMany || node.BitmapCreator
+ || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator
|| node.SortDistinct || node.StartupExpression
|| node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
|| node.WithTies || node.Remoting || node.LocalParallelism
@@ -851,8 +969,10 @@ private void ShowPropertiesPanel(PlanNode node)
AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true);
if (!string.IsNullOrEmpty(node.OuterSideJoinColumns))
AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true);
- if (node.ManyToMany)
- AddPropertyRow("Many to Many", "True");
+ if (node.PhysicalOp == "Merge Join")
+ AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No");
+ else if (node.ManyToMany)
+ AddPropertyRow("Many to Many", "Yes");
if (!string.IsNullOrEmpty(node.ConstantScanValues))
AddPropertyRow("Values", node.ConstantScanValues, isCode: true);
if (!string.IsNullOrEmpty(node.UdxUsedColumns))
@@ -2165,9 +2285,22 @@ private void ShowParameters(PlanStatement statement)
// Annotations
if (allCompiledNull && parameters.Count > 0)
{
- AddParameterAnnotation(
- "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed",
- "#FFB347");
+ var hasOptimizeForUnknown = statement.StatementText
+ .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase)
+ && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase);
+
+ if (hasOptimizeForUnknown)
+ {
+ AddParameterAnnotation(
+ "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values",
+ "#6BB5FF");
+ }
+ else
+ {
+ AddParameterAnnotation(
+ "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed",
+ "#FFB347");
+ }
}
var unresolved = FindUnresolvedVariables(statement.StatementText, parameters);
@@ -2490,7 +2623,7 @@ static string EfficiencyColor(double pct) => pct >= 80 ? "#E4E6EB"
? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100;
var grantColor = EfficiencyColor(grantPct);
AddRow("Memory grant",
- $"{mg.GrantedMemoryKB:N0} KB granted, {mg.MaxUsedMemoryKB:N0} KB used ({grantPct:N0}%)",
+ $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%)",
grantColor);
if (mg.GrantWaitTimeMs > 0)
AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373");
@@ -2703,6 +2836,7 @@ private void ZoomFit_Click(object? sender, RoutedEventArgs e)
var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height);
SetZoom(Math.Min(fitZoom, 1.0));
+ PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
}
private void SetZoom(double level)
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index d0f0fd4..fadeed6 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -694,7 +694,7 @@ private void HumanAdvice_Click(object? sender, RoutedEventArgs e)
if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }
var text = TextFormatter.Format(analysis);
- ShowAdviceWindow("Advice for Humans", text);
+ ShowAdviceWindow("Advice for Humans", text, analysis);
}
private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
@@ -706,9 +706,9 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
ShowAdviceWindow("Advice for Robots", json);
}
- private void ShowAdviceWindow(string title, string content)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
{
- var styledContent = AdviceContentBuilder.Build(content);
+ var styledContent = AdviceContentBuilder.Build(content, analysis);
var scrollViewer = new ScrollViewer
{
diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml
index 68372c0..40e7c87 100644
--- a/src/PlanViewer.App/MainWindow.axaml
+++ b/src/PlanViewer.App/MainWindow.axaml
@@ -21,6 +21,8 @@
+
+
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index bc9d8a1..dc61e5f 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -35,17 +35,22 @@ public partial class MainWindow : Window
private McpHostService? _mcpHost;
private CancellationTokenSource? _mcpCts;
private int _queryCounter;
+ private AppSettings _appSettings;
public MainWindow()
{
_credentialService = CredentialServiceFactory.Create();
_connectionStore = new ConnectionStore();
+ _appSettings = AppSettingsService.Load();
// Listen for file paths from other instances (e.g. SSMS extension)
StartPipeServer();
InitializeComponent();
+ // Build the Recent Plans submenu from saved state
+ RebuildRecentPlansMenu();
+
// Wire up drag-and-drop
AddHandler(DragDrop.DropEvent, OnDrop);
AddHandler(DragDrop.DragOverEvent, OnDragOver);
@@ -88,7 +93,7 @@ public MainWindow()
}
}, RoutingStrategies.Tunnel);
- // Accept command-line argument or open a default query editor
+ // Accept command-line argument or restore previously open plans
var args = Environment.GetCommandLineArgs();
if (args.Length > 1 && File.Exists(args[1]))
{
@@ -96,8 +101,8 @@ public MainWindow()
}
else
{
- // Open with a query editor so toolbar buttons are visible on startup
- NewQuery_Click(this, new RoutedEventArgs());
+ // Restore plans that were open in the previous session
+ RestoreOpenPlans();
}
// Start MCP server if enabled in settings
@@ -162,6 +167,9 @@ private void StartMcpServer()
protected override async void OnClosed(EventArgs e)
{
+ // Save the list of currently open file-based plans for session restore
+ SaveOpenPlans();
+
_pipeCts.Cancel();
if (_mcpHost != null && _mcpCts != null)
@@ -360,6 +368,9 @@ private void LoadPlanFile(string filePath)
MainTabControl.Items.Add(tab);
MainTabControl.SelectedItem = tab;
UpdateEmptyOverlay();
+
+ // Track in recent plans list and persist
+ TrackRecentPlan(filePath);
}
catch (Exception ex)
{
@@ -444,7 +455,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
{
if (viewer.CurrentPlan == null) return;
var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
- ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis));
+ ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis);
};
robotBtn.Click += (_, _) =>
@@ -507,6 +518,11 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
}
};
+ // Wire up context menu events from PlanViewerControl
+ viewer.HumanAdviceRequested += (_, _) => humanBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ viewer.RobotAdviceRequested += (_, _) => robotBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+ viewer.CopyReproRequested += async (_, _) => copyReproBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent));
+
var getActualPlanBtn = new Button
{
Content = "\u25b6 Run Repro",
@@ -561,9 +577,9 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
return panel;
}
- private void ShowAdviceWindow(string title, string content)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
{
- var styledContent = AdviceContentBuilder.Build(content);
+ var styledContent = AdviceContentBuilder.Build(content, analysis);
var scrollViewer = new ScrollViewer
{
@@ -1170,6 +1186,137 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer)
}
}
+ // ── Recent Plans & Session Restore ────────────────────────────────────
+
+ ///
+ /// Adds a file path to the recent plans list, saves settings, and rebuilds the menu.
+ ///
+ private void TrackRecentPlan(string filePath)
+ {
+ AppSettingsService.AddRecentPlan(_appSettings, filePath);
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+ }
+
+ ///
+ /// Rebuilds the Recent Plans submenu from the current settings.
+ /// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator.
+ ///
+ private void RebuildRecentPlansMenu()
+ {
+ RecentPlansMenu.Items.Clear();
+
+ if (_appSettings.RecentPlans.Count == 0)
+ {
+ var emptyItem = new MenuItem
+ {
+ Header = "(empty)",
+ IsEnabled = false
+ };
+ RecentPlansMenu.Items.Add(emptyItem);
+ return;
+ }
+
+ foreach (var path in _appSettings.RecentPlans)
+ {
+ var fileName = Path.GetFileName(path);
+ var directory = Path.GetDirectoryName(path) ?? "";
+
+ // Show "filename — directory" so the user can distinguish same-named files
+ var displayText = string.IsNullOrEmpty(directory)
+ ? fileName
+ : $"{fileName} — {directory}";
+
+ var item = new MenuItem
+ {
+ Header = displayText,
+ Tag = path
+ };
+
+ item.Click += RecentPlanItem_Click;
+ RecentPlansMenu.Items.Add(item);
+ }
+
+ RecentPlansMenu.Items.Add(new Separator());
+
+ var clearItem = new MenuItem { Header = "Clear Recent Plans" };
+ clearItem.Click += ClearRecentPlans_Click;
+ RecentPlansMenu.Items.Add(clearItem);
+ }
+
+ private void RecentPlanItem_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem item || item.Tag is not string path)
+ return;
+
+ if (!File.Exists(path))
+ {
+ // File was moved or deleted — remove from the list and notify the user
+ AppSettingsService.RemoveRecentPlan(_appSettings, path);
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+
+ ShowError($"The file no longer exists and has been removed from recent plans:\n\n{path}");
+ return;
+ }
+
+ LoadPlanFile(path);
+ }
+
+ private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e)
+ {
+ _appSettings.RecentPlans.Clear();
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+ }
+
+ ///
+ /// Saves the file paths of all currently open file-based plan tabs.
+ ///
+ private void SaveOpenPlans()
+ {
+ _appSettings.OpenPlans.Clear();
+
+ foreach (var item in MainTabControl.Items)
+ {
+ if (item is not TabItem tab) continue;
+
+ var path = GetTabFilePath(tab);
+ if (!string.IsNullOrEmpty(path))
+ _appSettings.OpenPlans.Add(path);
+ }
+
+ AppSettingsService.Save(_appSettings);
+ }
+
+ ///
+ /// Restores plan tabs from the previous session. Skips files that no longer exist.
+ /// Falls back to a new query tab if nothing was restored.
+ ///
+ private void RestoreOpenPlans()
+ {
+ var restored = false;
+
+ foreach (var path in _appSettings.OpenPlans)
+ {
+ if (File.Exists(path))
+ {
+ LoadPlanFile(path);
+ restored = true;
+ }
+ }
+
+ // Clear the open plans list now that we've restored
+ _appSettings.OpenPlans.Clear();
+ AppSettingsService.Save(_appSettings);
+
+ if (!restored)
+ {
+ // Nothing to restore — open a fresh query editor like before
+ NewQuery_Click(this, new RoutedEventArgs());
+ }
+ }
+
private void ShowError(string message)
{
var dialog = new Window
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 0cea721..4bdf48c 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -5,7 +5,7 @@
enable
app.manifest
true
- 1.0.0
+ 1.1.0
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index 35f1c37..eeff7d6 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -1,8 +1,12 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
+using Avalonia.Layout;
using Avalonia.Media;
+using PlanViewer.Core.Output;
namespace PlanViewer.App.Services;
@@ -24,8 +28,13 @@ internal static class AdviceContentBuilder
private static readonly SolidColorBrush SqlKeywordBrush = new(Color.Parse("#569CD6"));
private static readonly SolidColorBrush SeparatorBrush = new(Color.Parse("#2A2D35"));
private static readonly SolidColorBrush WarningAccentBrush = new(Color.Parse("#332A1A"));
+ private static readonly SolidColorBrush CardBackgroundBrush = new(Color.Parse("#1A2233"));
+ private static readonly SolidColorBrush AmberBarBrush = new(Color.Parse("#FFB347"));
+ private static readonly SolidColorBrush BlueBarBrush = new(Color.Parse("#4FA3FF"));
private static readonly FontFamily MonoFont = new("Consolas, Menlo, monospace");
+ private const double MaxBarWidth = 200.0;
+
private static readonly HashSet PhysicalOperators = new(StringComparer.OrdinalIgnoreCase)
{
"Sort", "Filter", "Bitmap", "Hash Match", "Merge Join", "Nested Loops",
@@ -55,7 +64,14 @@ internal static class AdviceContentBuilder
"NOLOCK", "READUNCOMMITTED", "READCOMMITTED", "SERIALIZABLE", "HOLDLOCK"
};
+ private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled);
+
public static StackPanel Build(string content)
+ {
+ return Build(content, null);
+ }
+
+ public static StackPanel Build(string content, AnalysisResult? analysis)
{
var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) };
var lines = content.Split('\n');
@@ -63,6 +79,8 @@ public static StackPanel Build(string content)
var codeBlockIndent = 0;
var isStatementText = false;
var inSubSection = false; // tracks sub-sections within a statement
+ var statementIndex = -1; // tracks which statement we're in (0-based)
+ var needsTriageCard = false; // inject card on next blank line after SQL text
for (int i = 0; i < lines.Length; i++)
{
@@ -71,7 +89,17 @@ public static StackPanel Build(string content)
// Empty lines — small spacer
if (string.IsNullOrWhiteSpace(line))
{
- panel.Children.Add(new Border { Height = 6 });
+ // Inject triage card on the blank line between SQL text and details
+ if (needsTriageCard && analysis != null && statementIndex >= 0
+ && statementIndex < analysis.Statements.Count)
+ {
+ var card = CreateTriageSummaryCard(analysis.Statements[statementIndex]);
+ if (card != null)
+ panel.Children.Add(card);
+ needsTriageCard = false;
+ }
+
+ panel.Children.Add(new Border { Height = 8 });
inCodeBlock = false;
isStatementText = false;
inSubSection = false;
@@ -112,7 +140,11 @@ public static StackPanel Build(string content)
// Statement text follows "Statement N:"
if (headerText.StartsWith("Statement"))
+ {
isStatementText = true;
+ statementIndex++;
+ needsTriageCard = true;
+ }
continue;
}
@@ -120,7 +152,6 @@ public static StackPanel Build(string content)
// Statement text (SQL) — highlight keywords
if (isStatementText)
{
- isStatementText = false;
panel.Children.Add(BuildSqlHighlightedLine(line));
continue;
}
@@ -154,7 +185,7 @@ public static StackPanel Build(string content)
FontSize = 12,
FontStyle = Avalonia.Media.FontStyle.Italic,
Foreground = MutedBrush,
- Margin = new Avalonia.Thickness(16, 2, 0, 4),
+ Margin = new Avalonia.Thickness(20, 2, 0, 4),
TextWrapping = TextWrapping.Wrap
});
continue;
@@ -168,7 +199,7 @@ public static StackPanel Build(string content)
FontFamily = MonoFont,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 1, 0, 1)
+ Margin = new Avalonia.Thickness(12, 1, 0, 1)
};
var sniffIdx = line.IndexOf("[SNIFFING]");
tb.Inlines!.Add(new Run(line[..sniffIdx].TrimStart()) { Foreground = ValueBrush });
@@ -178,6 +209,13 @@ public static StackPanel Build(string content)
continue;
}
+ // Missing index impact line: "dbo.Posts (impact: 95%)"
+ if (trimmed.Contains("(impact:") && trimmed.EndsWith("%)"))
+ {
+ panel.Children.Add(CreateMissingIndexImpactLine(trimmed));
+ continue;
+ }
+
// CREATE INDEX lines (multi-line: CREATE..., ON..., INCLUDE..., WHERE...)
if (trimmed.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase))
{
@@ -209,26 +247,51 @@ public static StackPanel Build(string content)
FontSize = 12,
Foreground = CodeBrush,
TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 1, 0, 1)
+ Margin = new Avalonia.Thickness(12, 1, 0, 1)
});
continue;
}
- // Expensive operator timing lines: "4,616ms CPU (61%), 586ms elapsed (62%)"
- // Must start with a digit to avoid catching "Runtime: 1,234ms elapsed, 1,200ms CPU"
- if ((trimmed.Contains("ms CPU") || trimmed.Contains("ms elapsed"))
- && trimmed.Length > 0 && char.IsDigit(trimmed[0]))
+ // Expensive operators section: highlight operator name + grouped timing + stats
+ // Handles both "Operator (Object):" and bare "Sort:" forms
+ if (trimmed.EndsWith("):") ||
+ (trimmed.EndsWith(":") && PhysicalOperators.Contains(trimmed[..^1])))
{
- panel.Children.Add(CreateOperatorTimingLine(trimmed));
+ // Peek ahead for timing line and stats line to group with operator
+ string? timingLine = null;
+ string? statsLine = null;
+ var peekIdx = i + 1;
+ if (peekIdx < lines.Length)
+ {
+ var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart();
+ if ((nextTrimmed.Contains("ms CPU") || nextTrimmed.Contains("ms elapsed"))
+ && nextTrimmed.Length > 0 && char.IsDigit(nextTrimmed[0]))
+ {
+ timingLine = nextTrimmed;
+ peekIdx++;
+ }
+ }
+ // Stats line: "17,142,169 rows, 4,691,534 logical reads, 884 physical reads"
+ if (peekIdx < lines.Length)
+ {
+ var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart();
+ if (nextTrimmed.Contains("rows") && nextTrimmed.Length > 0
+ && char.IsDigit(nextTrimmed[0]))
+ {
+ statsLine = nextTrimmed;
+ peekIdx++;
+ }
+ }
+ i = peekIdx - 1; // skip consumed lines
+ panel.Children.Add(CreateOperatorGroup(line, timingLine, statsLine));
continue;
}
- // Expensive operators section: highlight operator name
- // Handles both "Operator (Object):" and bare "Sort:" forms
- if (trimmed.EndsWith("):") ||
- (trimmed.EndsWith(":") && PhysicalOperators.Contains(trimmed[..^1])))
+ // Standalone timing lines (fallback for lines not grouped with an operator)
+ if ((trimmed.Contains("ms CPU") || trimmed.Contains("ms elapsed"))
+ && trimmed.Length > 0 && char.IsDigit(trimmed[0]))
{
- panel.Children.Add(CreateOperatorLine(line));
+ panel.Children.Add(CreateOperatorTimingLine(trimmed));
continue;
}
@@ -239,12 +302,12 @@ public static StackPanel Build(string content)
inSubSection = true;
panel.Children.Add(new SelectableTextBlock
{
- Text = " " + trimmed,
+ Text = trimmed,
FontFamily = MonoFont,
- FontSize = 12,
+ FontSize = 13,
FontWeight = FontWeight.SemiBold,
Foreground = LabelBrush,
- Margin = new Avalonia.Thickness(0, 6, 0, 2),
+ Margin = new Avalonia.Thickness(8, 6, 0, 4),
TextWrapping = TextWrapping.Wrap
});
continue;
@@ -259,13 +322,14 @@ public static StackPanel Build(string content)
FontFamily = MonoFont,
FontSize = 12,
Foreground = MutedBrush,
- Margin = new Avalonia.Thickness(8, 1, 0, 1),
+ Margin = new Avalonia.Thickness(12, 1, 0, 1),
TextWrapping = TextWrapping.Wrap
});
continue;
}
- // Wait stats lines: " WAITTYPE: 1,234ms" — color by category
+ // Wait stats lines: " WAITTYPE: 1,234ms" — color by category with proportional bars
+ // Collect entire group, find global max, then render all with consistent bar scaling
if (trimmed.Contains("ms") && trimmed.Contains(':'))
{
var waitColon = trimmed.IndexOf(':');
@@ -275,7 +339,38 @@ public static StackPanel Build(string content)
var waitValue = trimmed[(waitColon + 1)..].Trim();
if (waitValue.EndsWith("ms") && waitName == waitName.ToUpperInvariant() && !waitName.Contains(' '))
{
- panel.Children.Add(CreateWaitStatLine(waitName, waitValue, inSubSection));
+ // Collect all wait stat lines in this group
+ var waitGroup = new List<(string name, string value)>
+ {
+ (waitName, waitValue)
+ };
+ while (i + 1 < lines.Length)
+ {
+ var nextLine = lines[i + 1].TrimEnd('\r').TrimStart();
+ if (string.IsNullOrWhiteSpace(nextLine)) break;
+ var nextColon = nextLine.IndexOf(':');
+ if (nextColon <= 0 || nextColon >= nextLine.Length - 1) break;
+ var nextName = nextLine[..nextColon];
+ var nextVal = nextLine[(nextColon + 1)..].Trim();
+ if (!nextVal.EndsWith("ms") || nextName != nextName.ToUpperInvariant()
+ || nextName.Contains(' '))
+ break;
+ waitGroup.Add((nextName, nextVal));
+ i++;
+ }
+
+ // Find global max for bar scaling
+ var maxWaitMs = 0.0;
+ foreach (var (_, val) in waitGroup)
+ {
+ var ms = ParseWaitMs(val);
+ if (ms > maxWaitMs) maxWaitMs = ms;
+ }
+
+ // Render all lines with consistent scaling
+ foreach (var (name, val) in waitGroup)
+ panel.Children.Add(CreateWaitStatLine(name, val, maxWaitMs));
+
continue;
}
}
@@ -288,7 +383,7 @@ public static StackPanel Build(string content)
var labelPart = line[..colonIdx].TrimStart();
if (labelPart.Length < 40 && !labelPart.Contains('(') && !labelPart.Contains('='))
{
- var indent = inSubSection ? 8.0 : 0.0;
+ var indent = inSubSection ? 12.0 : 8.0;
var tb = new SelectableTextBlock
{
FontFamily = MonoFont,
@@ -335,7 +430,7 @@ private static SelectableTextBlock BuildSqlHighlightedLine(string line)
FontFamily = MonoFont,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(4, 1, 0, 1)
+ Margin = new Avalonia.Thickness(8, 1, 0, 1)
};
int pos = 0;
@@ -375,7 +470,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr
FontFamily = MonoFont,
FontSize = 12,
TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(8, 2, 0, 2)
+ Margin = new Avalonia.Thickness(8, 3, 0, 3)
};
foreach (var tag in new[] { "[Critical]", "[Warning]", "[Info]" })
@@ -417,10 +512,10 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr
tb.Inlines.Add(new Run("\n" + part)
{ Foreground = ValueBrush });
}
- else if (part.StartsWith("• "))
+ else if (part.StartsWith("\u2022 "))
{
// Bullet stats: bullet in muted, value in white
- tb.Inlines.Add(new Run("\n • ")
+ tb.Inlines.Add(new Run("\n \u2022 ")
{ Foreground = MutedBrush });
tb.Inlines.Add(new Run(part[2..])
{ Foreground = ValueBrush });
@@ -455,7 +550,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr
BorderBrush = severityBrush,
BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
Padding = new Avalonia.Thickness(0),
- Margin = new Avalonia.Thickness(4, 2, 0, 2),
+ Margin = new Avalonia.Thickness(12, 4, 0, 4),
Child = tb
};
}
@@ -468,7 +563,7 @@ private static Border CreateWarningBlock(string line, SolidColorBrush severityBr
BorderBrush = severityBrush,
BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
Padding = new Avalonia.Thickness(0),
- Margin = new Avalonia.Thickness(4, 2, 0, 2),
+ Margin = new Avalonia.Thickness(12, 4, 0, 4),
Child = tb
};
}
@@ -503,22 +598,73 @@ private static SelectableTextBlock CreateOperatorLine(string line)
return tb;
}
+ ///
+ /// Groups an operator name with its timing line, CPU bar, and stats in a single
+ /// container with a purple left accent border for clear visual association.
+ ///
+ private static Border CreateOperatorGroup(string operatorLine, string? timingLine, string? statsLine)
+ {
+ var groupPanel = new StackPanel();
+
+ // Operator name (no extra margin — Border provides it)
+ var opTb = CreateOperatorLine(operatorLine);
+ opTb.Margin = new Avalonia.Thickness(0);
+ groupPanel.Children.Add(opTb);
+
+ // Timing + CPU bar
+ if (timingLine != null)
+ {
+ var timingPanel = CreateOperatorTimingLine(timingLine);
+ timingPanel.Margin = new Avalonia.Thickness(4, 2, 0, 0);
+ groupPanel.Children.Add(timingPanel);
+ }
+
+ // Stats: rows, logical reads, physical reads
+ if (statsLine != null)
+ {
+ groupPanel.Children.Add(new SelectableTextBlock
+ {
+ Text = statsLine,
+ FontFamily = MonoFont,
+ FontSize = 12,
+ Foreground = MutedBrush,
+ Margin = new Avalonia.Thickness(4, 0, 0, 0),
+ TextWrapping = TextWrapping.Wrap
+ });
+ }
+
+ return new Border
+ {
+ BorderBrush = OperatorBrush,
+ BorderThickness = new Avalonia.Thickness(2, 0, 0, 0),
+ Padding = new Avalonia.Thickness(8, 2, 0, 2),
+ Margin = new Avalonia.Thickness(12, 2, 0, 4),
+ Child = groupPanel
+ };
+ }
+
///
/// Renders timing line like "4,616ms CPU (61%), 586ms elapsed (62%)"
- /// with ms values in white and percentages in amber.
+ /// with ms values in white and percentages in amber, plus a proportional bar.
///
- private static SelectableTextBlock CreateOperatorTimingLine(string trimmed)
+ private static StackPanel CreateOperatorTimingLine(string trimmed)
{
+ var wrapper = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(16, 1, 0, 1)
+ };
+
var tb = new SelectableTextBlock
{
FontFamily = MonoFont,
FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(16, 1, 0, 1)
+ TextWrapping = TextWrapping.Wrap
};
// Split by ", " to get timing parts like "4,616ms CPU (61%)" and "586ms elapsed (62%)"
var parts = trimmed.Split(new[] { ", " }, StringSplitOptions.RemoveEmptyEntries);
+ int? cpuPct = null;
+
for (int i = 0; i < parts.Length; i++)
{
if (i > 0)
@@ -531,38 +677,123 @@ private static SelectableTextBlock CreateOperatorTimingLine(string trimmed)
{
var timePart = part[..pctStart].TrimEnd();
var pctPart = part[pctStart..];
- var brush = timePart.Contains("CPU") ? ValueBrush : MutedBrush;
+ var brush = ValueBrush;
tb.Inlines!.Add(new Run(timePart) { Foreground = brush });
- tb.Inlines.Add(new Run(" " + pctPart) { Foreground = WarningBrush });
+ tb.Inlines.Add(new Run(" " + pctPart) { Foreground = WarningBrush, FontSize = 11 });
+
+ // Capture CPU percentage for the bar
+ if (timePart.Contains("CPU"))
+ {
+ var match = CpuPercentRegex.Match(part);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out var pctVal))
+ cpuPct = pctVal;
+ }
}
else
{
- var brush = part.Contains("CPU") ? ValueBrush : MutedBrush;
+ var brush = ValueBrush;
tb.Inlines!.Add(new Run(part) { Foreground = brush });
}
}
- return tb;
+ wrapper.Children.Add(tb);
+
+ // Add proportional CPU bar
+ if (cpuPct.HasValue && cpuPct.Value > 0)
+ {
+ wrapper.Children.Add(new Border
+ {
+ Width = MaxBarWidth * (cpuPct.Value / 100.0),
+ Height = 4,
+ Background = AmberBarBrush,
+ CornerRadius = new Avalonia.CornerRadius(2),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Avalonia.Thickness(0, 0, 0, 4)
+ });
+ }
+
+ return wrapper;
}
- private static SelectableTextBlock CreateWaitStatLine(string waitName, string waitValue, bool indented)
+ private static StackPanel CreateWaitStatLine(string waitName, string waitValue, double maxWaitMs)
{
- var leftMargin = indented ? 16.0 : 8.0;
+ var wrapper = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(12, 1, 0, 1)
+ };
+
var tb = new SelectableTextBlock
{
FontFamily = MonoFont,
FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(leftMargin, 1, 0, 1)
+ TextWrapping = TextWrapping.Wrap
};
var waitBrush = GetWaitCategoryBrush(waitName);
tb.Inlines!.Add(new Run(waitName) { Foreground = waitBrush });
tb.Inlines.Add(new Run(": " + waitValue) { Foreground = ValueBrush });
+ wrapper.Children.Add(tb);
+
+ // Proportional bar scaled to max wait in group
+ var ms = ParseWaitMs(waitValue);
+ if (ms > 0 && maxWaitMs > 0)
+ {
+ var barWidth = MaxBarWidth * (ms / maxWaitMs);
+ wrapper.Children.Add(new Border
+ {
+ Width = Math.Max(2, barWidth),
+ Height = 4,
+ Background = waitBrush,
+ CornerRadius = new Avalonia.CornerRadius(2),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Avalonia.Thickness(0, 0, 0, 2)
+ });
+ }
+
+ return wrapper;
+ }
+
+ ///
+ /// Renders a missing index impact line like "dbo.Posts (impact: 95%)" with
+ /// the table name in value color and the impact colored by severity.
+ ///
+ private static SelectableTextBlock CreateMissingIndexImpactLine(string trimmed)
+ {
+ var tb = new SelectableTextBlock
+ {
+ FontFamily = MonoFont,
+ FontSize = 12,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Avalonia.Thickness(12, 2, 0, 0)
+ };
+
+ var impactStart = trimmed.IndexOf("(impact:");
+ var tableName = trimmed[..impactStart].TrimEnd();
+ var impactPart = trimmed[impactStart..];
+
+ // Parse the percentage to pick a color
+ var pctStr = impactPart.Replace("(impact:", "").Replace("%)", "").Trim();
+ var impactBrush = MutedBrush;
+ if (double.TryParse(pctStr, out var pct))
+ {
+ impactBrush = pct >= 70 ? CriticalBrush : (pct >= 40 ? WarningBrush : InfoBrush);
+ }
+
+ tb.Inlines!.Add(new Run(tableName + " ") { Foreground = ValueBrush });
+ tb.Inlines.Add(new Run(impactPart) { Foreground = impactBrush, FontWeight = FontWeight.SemiBold });
return tb;
}
+ ///
+ /// Parses a wait stat value like "1,234ms" into a double.
+ ///
+ private static double ParseWaitMs(string waitValue)
+ {
+ var numStr = waitValue.Replace("ms", "").Replace(",", "").Trim();
+ return double.TryParse(numStr, out var val) ? val : 0;
+ }
+
private static SolidColorBrush GetWaitCategoryBrush(string waitType)
{
// CPU-related
@@ -590,4 +821,103 @@ private static SolidColorBrush GetWaitCategoryBrush(string waitType)
return LabelBrush; // default muted
}
+
+ ///
+ /// Creates a per-statement triage summary card showing key findings at a glance.
+ ///
+ private static Border? CreateTriageSummaryCard(StatementResult stmt)
+ {
+ var items = new List<(string text, SolidColorBrush brush)>();
+
+ // Parallel efficiency
+ var dop = stmt.DegreeOfParallelism;
+ if (dop > 1 && stmt.QueryTime != null && stmt.QueryTime.ElapsedTimeMs > 0)
+ {
+ var cpuMs = (double)stmt.QueryTime.CpuTimeMs;
+ var elapsedMs = (double)stmt.QueryTime.ElapsedTimeMs;
+ // efficiency = (cpu/elapsed - 1) / (dop - 1) * 100, clamped 0-100
+ var ratio = cpuMs / elapsedMs;
+ var efficiency = (ratio - 1.0) / (dop - 1.0) * 100.0;
+ efficiency = Math.Clamp(efficiency, 0, 100);
+ var effBrush = efficiency < 50 ? CriticalBrush : (efficiency < 75 ? WarningBrush : InfoBrush);
+ items.Add(($"\u26A0 {efficiency:F0}% parallel efficiency (DOP {dop})", effBrush));
+ }
+
+ // Memory grant — color by utilization efficiency
+ if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0)
+ {
+ var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0;
+ var usedPct = stmt.MemoryGrant.MaxUsedKB > 0
+ ? (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100.0
+ : 0.0;
+ // Red: <10% used (massive waste), Amber: <50%, Blue: <80%, Green-ish (info): >=80%
+ var memBrush = usedPct < 10 ? CriticalBrush
+ : usedPct < 50 ? WarningBrush
+ : InfoBrush;
+ items.Add(($"Memory grant: {grantedMB:F1} MB ({usedPct:F0}% used)", memBrush));
+ }
+
+ // Warning counts by severity
+ var criticalCount = stmt.Warnings.Count(w =>
+ w.Severity.Equals("Critical", StringComparison.OrdinalIgnoreCase));
+ var warningCount = stmt.Warnings.Count(w =>
+ w.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase));
+ if (criticalCount > 0 || warningCount > 0)
+ {
+ var parts = new List();
+ if (criticalCount > 0)
+ parts.Add($"{criticalCount} critical");
+ if (warningCount > 0)
+ parts.Add($"{warningCount} warning{(warningCount != 1 ? "s" : "")}");
+ var countBrush = criticalCount > 0 ? CriticalBrush : WarningBrush;
+ items.Add((string.Join(", ", parts), countBrush));
+ }
+
+ // Missing indexes
+ if (stmt.MissingIndexes.Count > 0)
+ {
+ items.Add(($"{stmt.MissingIndexes.Count} missing index suggestion{(stmt.MissingIndexes.Count != 1 ? "s" : "")}", InfoBrush));
+ }
+
+ // Spill warnings
+ var spillCount = stmt.Warnings.Count(w =>
+ w.Type.Contains("Spill", StringComparison.OrdinalIgnoreCase));
+ if (spillCount > 0)
+ {
+ items.Add(($"{spillCount} spill warning{(spillCount != 1 ? "s" : "")}", CriticalBrush));
+ }
+
+ if (items.Count == 0)
+ return null;
+
+ var cardPanel = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(4)
+ };
+
+ for (int idx = 0; idx < items.Count; idx++)
+ {
+ var (text, brush) = items[idx];
+ var isHeadline = idx == 0;
+ cardPanel.Children.Add(new SelectableTextBlock
+ {
+ Text = text,
+ FontFamily = MonoFont,
+ FontSize = isHeadline ? 13 : 12,
+ FontWeight = isHeadline ? FontWeight.SemiBold : FontWeight.Normal,
+ Foreground = brush,
+ Margin = new Avalonia.Thickness(4, 2, 0, 2),
+ TextWrapping = TextWrapping.Wrap
+ });
+ }
+
+ return new Border
+ {
+ Background = CardBackgroundBrush,
+ CornerRadius = new Avalonia.CornerRadius(6),
+ Padding = new Avalonia.Thickness(8, 4, 8, 4),
+ Margin = new Avalonia.Thickness(0, 4, 0, 6),
+ Child = cardPanel
+ };
+ }
}
diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs
new file mode 100644
index 0000000..e8faf34
--- /dev/null
+++ b/src/PlanViewer.App/Services/AppSettingsService.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PlanViewer.App.Services;
+
+///
+/// Persists recent plans and open session state to a JSON file in the app's local data directory.
+///
+internal sealed class AppSettingsService
+{
+ private const int MaxRecentPlans = 10;
+ private static readonly string SettingsDir;
+ private static readonly string SettingsPath;
+
+ static AppSettingsService()
+ {
+ SettingsDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "PerformanceStudio");
+ SettingsPath = Path.Combine(SettingsDir, "appsettings.json");
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ ///
+ /// Loads settings from disk. Returns default settings if the file is missing or corrupt.
+ ///
+ public static AppSettings Load()
+ {
+ try
+ {
+ if (!File.Exists(SettingsPath))
+ return new AppSettings();
+
+ var json = File.ReadAllText(SettingsPath);
+ var settings = JsonSerializer.Deserialize(json, JsonOptions);
+ return settings ?? new AppSettings();
+ }
+ catch
+ {
+ return new AppSettings();
+ }
+ }
+
+ ///
+ /// Saves settings to disk. Silently ignores write failures.
+ ///
+ public static void Save(AppSettings settings)
+ {
+ try
+ {
+ Directory.CreateDirectory(SettingsDir);
+ var json = JsonSerializer.Serialize(settings, JsonOptions);
+ File.WriteAllText(SettingsPath, json);
+ }
+ catch
+ {
+ // Best-effort persistence — don't crash the app
+ }
+ }
+
+ ///
+ /// Adds a file path to the recent plans list (most recent first).
+ /// Deduplicates by full path (case-insensitive on Windows).
+ ///
+ public static void AddRecentPlan(AppSettings settings, string filePath)
+ {
+ var fullPath = Path.GetFullPath(filePath);
+
+ // Remove any existing entry for this path
+ settings.RecentPlans.RemoveAll(p =>
+ string.Equals(p, fullPath, StringComparison.OrdinalIgnoreCase));
+
+ // Insert at the front
+ settings.RecentPlans.Insert(0, fullPath);
+
+ // Trim to max size
+ if (settings.RecentPlans.Count > MaxRecentPlans)
+ settings.RecentPlans.RemoveRange(MaxRecentPlans, settings.RecentPlans.Count - MaxRecentPlans);
+ }
+
+ ///
+ /// Removes a specific path from the recent plans list.
+ ///
+ public static void RemoveRecentPlan(AppSettings settings, string filePath)
+ {
+ settings.RecentPlans.RemoveAll(p =>
+ string.Equals(p, filePath, StringComparison.OrdinalIgnoreCase));
+ }
+}
+
+///
+/// Serializable settings model for the application.
+///
+internal sealed class AppSettings
+{
+ ///
+ /// Most recently opened plan file paths, newest first. Max 10.
+ ///
+ [JsonPropertyName("recent_plans")]
+ public List RecentPlans { get; set; } = new();
+
+ ///
+ /// File paths that were open when the app last closed — restored on next launch.
+ ///
+ [JsonPropertyName("open_plans")]
+ public List OpenPlans { get; set; } = new();
+}
diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs
index eb7f99e..04ccc80 100644
--- a/src/PlanViewer.Core/Output/TextFormatter.cs
+++ b/src/PlanViewer.Core/Output/TextFormatter.cs
@@ -62,13 +62,15 @@ public static void WriteText(AnalysisResult result, TextWriter writer)
writer.WriteLine($"Runtime: {stmt.QueryTime.ElapsedTimeMs:N0}ms elapsed, {stmt.QueryTime.CpuTimeMs:N0}ms CPU");
if (stmt.MemoryGrant != null && stmt.MemoryGrant.GrantedKB > 0)
{
- var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0;
- var usedMB = stmt.MemoryGrant.MaxUsedKB / 1024.0;
- var pctUsed = grantedMB > 0 ? usedMB / grantedMB * 100 : 0;
+ var pctUsed = stmt.MemoryGrant.GrantedKB > 0
+ ? (double)stmt.MemoryGrant.MaxUsedKB / stmt.MemoryGrant.GrantedKB * 100 : 0;
var pctContext = "";
if (result.ServerContext?.MaxServerMemoryMB > 0)
+ {
+ var grantedMB = stmt.MemoryGrant.GrantedKB / 1024.0;
pctContext = $", {grantedMB / result.ServerContext.MaxServerMemoryMB * 100:N1}% of max server memory";
- writer.WriteLine($"Memory grant: {grantedMB:N1} MB granted, {usedMB:N1} MB used ({pctUsed:N0}% utilized{pctContext})");
+ }
+ writer.WriteLine($"Memory grant: {FormatMemoryGrantKB(stmt.MemoryGrant.GrantedKB)} granted, {FormatMemoryGrantKB(stmt.MemoryGrant.MaxUsedKB)} used ({pctUsed:N0}% utilized{pctContext})");
}
// Expensive operators — promoted to right after memory grant.
@@ -323,6 +325,21 @@ private static void WriteGroupedOperatorWarnings(List warnings, T
}
}
+ ///
+ /// Formats a memory value given in KB to a human-readable string.
+ /// Under 1,024 KB: show KB (e.g., "512 KB").
+ /// 1,024 KB to 1,048,576 KB: show MB with 1 decimal (e.g., "533.3 MB").
+ /// Over 1,048,576 KB: show GB with 2 decimals (e.g., "2.14 GB").
+ ///
+ public static string FormatMemoryGrantKB(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";
+ }
+
///
/// Replaces newlines with unit separator (U+001F) so multi-line warning messages
/// survive the top-level line split in AdviceContentBuilder.Build().
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index 75ae9b2..f16e976 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -274,54 +274,44 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
});
}
- // Rule 25: Ineffective parallelism — parallel plan where CPU ≈ elapsed
- // In an effective parallel plan, CPU time should be significantly higher than
- // elapsed time because multiple threads are working simultaneously.
- // When they're nearly equal (ratio 0.8–1.3), the work ran essentially serially.
+ // Rule 25: Ineffective parallelism — DOP-aware efficiency scoring
+ // Efficiency = (speedup - 1) / (DOP - 1) * 100
+ // where speedup = CPU / Elapsed. At DOP 1 speedup=1 (0%), at DOP=speedup (100%).
+ // Rule 31: Parallel wait bottleneck — elapsed >> CPU means threads waiting, not working.
if (!cfg.IsRuleDisabled(25) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null)
{
var cpu = stmt.QueryTimeStats.CpuTimeMs;
var elapsed = stmt.QueryTimeStats.ElapsedTimeMs;
+ var dop = stmt.DegreeOfParallelism;
if (elapsed >= 1000 && cpu > 0)
{
- var ratio = (double)cpu / elapsed;
- if (ratio >= 0.8 && ratio <= 1.3)
+ var speedup = (double)cpu / elapsed;
+ var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0));
+
+ if (speedup < 0.5 && !cfg.IsRuleDisabled(31))
{
+ // CPU well below Elapsed: threads are waiting, not doing CPU work
+ var waitPct = (1.0 - speedup) * 100;
stmt.PlanWarnings.Add(new PlanWarning
{
- WarningType = "Ineffective Parallelism",
- Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) but CPU time ({cpu:N0}ms) is nearly equal to elapsed time ({elapsed:N0}ms). " +
- $"The work ran essentially serially despite the overhead of parallelism. " +
- $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.",
+ WarningType = "Parallel Wait Bottleneck",
+ Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " +
+ $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " +
+ $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.",
Severity = PlanWarningSeverity.Warning
});
}
- }
- }
-
- // Rule 31: Parallel wait bottleneck — elapsed time significantly exceeds CPU time
- // When elapsed >> CPU in a parallel plan, threads are spending time waiting rather
- // than doing CPU work. Common causes: spills to tempdb, physical I/O, blocking,
- // memory grant waits, or other resource contention.
- if (!cfg.IsRuleDisabled(31) && stmt.DegreeOfParallelism > 1 && stmt.QueryTimeStats != null)
- {
- var cpu = stmt.QueryTimeStats.CpuTimeMs;
- var elapsed = stmt.QueryTimeStats.ElapsedTimeMs;
-
- if (elapsed >= 1000 && cpu > 0)
- {
- var ratio = (double)cpu / elapsed;
- if (ratio < 0.8)
+ else if (efficiency < 40)
{
- var waitPct = (1.0 - ratio) * 100;
+ // CPU >= Elapsed but well below DOP potential — parallelism is ineffective
stmt.PlanWarnings.Add(new PlanWarning
{
- WarningType = "Parallel Wait Bottleneck",
- Message = $"Parallel plan (DOP {stmt.DegreeOfParallelism}) with elapsed time ({elapsed:N0}ms) significantly exceeding CPU time ({cpu:N0}ms). " +
- $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " +
- $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.",
- Severity = PlanWarningSeverity.Warning
+ WarningType = "Ineffective Parallelism",
+ Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " +
+ $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " +
+ $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.",
+ Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
@@ -388,6 +378,55 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg)
}
}
}
+
+ // Rule 22 (statement-level): Table variable warnings
+ // Walk the tree to find table variable references, then emit statement-level warnings
+ if (!cfg.IsRuleDisabled(22) && stmt.RootNode != null)
+ {
+ var hasTableVar = false;
+ var isModification = stmt.StatementType is "INSERT" or "UPDATE" or "DELETE" or "MERGE";
+ var modifiesTableVar = false;
+ CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar);
+
+ if (hasTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.",
+ Severity = PlanWarningSeverity.Warning
+ });
+ }
+
+ if (modifiesTableVar)
+ {
+ stmt.PlanWarnings.Add(new PlanWarning
+ {
+ WarningType = "Table Variable",
+ Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.",
+ Severity = PlanWarningSeverity.Critical
+ });
+ }
+ }
+ }
+
+ private static void CheckForTableVariables(PlanNode node, bool isModification,
+ ref bool hasTableVar, ref bool modifiesTableVar)
+ {
+ if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
+ {
+ hasTableVar = true;
+ // The modification target is typically an Insert/Update/Delete operator on a table variable
+ if (isModification && (node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase)
+ || node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)))
+ {
+ modifiesTableVar = true;
+ }
+ }
+ foreach (var child in node.Children)
+ CheckForTableVariables(child, isModification, ref hasTableVar, ref modifiesTableVar);
}
private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg)
@@ -561,22 +600,39 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
// Rule 8: Parallel thread skew (actual plans with per-thread stats)
// Only warn when there are enough rows to meaningfully distribute across threads
+ // Filter out thread 0 (coordinator) which typically does 0 rows in parallel operators
if (!cfg.IsRuleDisabled(8) && node.PerThreadStats.Count > 1)
{
- var totalRows = node.PerThreadStats.Sum(t => t.ActualRows);
- var minRowsForSkew = node.PerThreadStats.Count * 1000;
+ var workerThreads = node.PerThreadStats.Where(t => t.ThreadId > 0).ToList();
+ if (workerThreads.Count < 2) workerThreads = node.PerThreadStats; // fallback
+ var totalRows = workerThreads.Sum(t => t.ActualRows);
+ var minRowsForSkew = workerThreads.Count * 1000;
if (totalRows >= minRowsForSkew)
{
- var maxThread = node.PerThreadStats.OrderByDescending(t => t.ActualRows).First();
+ var maxThread = workerThreads.OrderByDescending(t => t.ActualRows).First();
var skewRatio = (double)maxThread.ActualRows / totalRows;
- var skewThreshold = node.PerThreadStats.Count == 2 ? 0.75 : 0.50;
+ // At DOP 2, a 60/40 split is normal — use higher threshold
+ var skewThreshold = workerThreads.Count <= 2 ? 0.80 : 0.50;
if (skewRatio >= skewThreshold)
{
+ var message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much.";
+ var severity = PlanWarningSeverity.Warning;
+
+ // Batch mode sorts produce all output on a single thread by design
+ // unless their parent is a batch mode Window Aggregate
+ if (node.PhysicalOp == "Sort"
+ && (node.ActualExecutionMode ?? node.ExecutionMode) == "Batch"
+ && node.Parent?.PhysicalOp != "Window Aggregate")
+ {
+ message += " Batch mode sorts produce all output rows on a single thread by design, unless feeding a batch mode Window Aggregate.";
+ severity = PlanWarningSeverity.Info;
+ }
+
node.Warnings.Add(new PlanWarning
{
WarningType = "Parallel Skew",
- Message = $"Thread {maxThread.ThreadId} processed {skewRatio:P0} of rows ({maxThread.ActualRows:N0}/{totalRows:N0}). Work is heavily skewed to one thread, so parallelism isn't helping much.",
- Severity = PlanWarningSeverity.Warning
+ Message = message,
+ Severity = severity
});
}
}
@@ -603,7 +659,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
{
WarningType = "Key Lookup",
Message = $"Key Lookup — SQL Server found rows via a nonclustered index but had to go back to the clustered index for additional columns. Alter the nonclustered index to add the predicate column as a key column or as an INCLUDE column.\nPredicate: {Truncate(node.Predicate, 200)}",
- Severity = PlanWarningSeverity.Warning
+ Severity = PlanWarningSeverity.Critical
});
}
@@ -829,35 +885,39 @@ _ when nonSargableReason.StartsWith("Function call") =>
});
}
- // Rule 24: Top above a scan on the inner side of Nested Loops
- // This pattern means the scan executes once per outer row, and the Top
- // limits each iteration — but with no supporting index the scan is a
- // linear search repeated potentially millions of times.
- if (!cfg.IsRuleDisabled(24) && node.PhysicalOp == "Nested Loops" && node.Children.Count >= 2)
+ // Rule 24: Top above a scan
+ // Detects Top or Top N Sort operators feeding from a scan. This often means the
+ // query is scanning the entire table/index and sorting just to return a few rows,
+ // when an appropriate index could satisfy the request directly.
+ if (!cfg.IsRuleDisabled(24))
{
- var inner = node.Children[1];
-
- // Walk through pass-through operators to find Top
- while (inner.PhysicalOp == "Compute Scalar" && inner.Children.Count > 0)
- inner = inner.Children[0];
+ var isTop = node.PhysicalOp == "Top";
+ var isTopNSort = node.LogicalOp == "Top N Sort";
- if (inner.PhysicalOp == "Top" && inner.Children.Count > 0)
+ if ((isTop || isTopNSort) && node.Children.Count > 0)
{
// Walk through pass-through operators below the Top to find the scan
- var scanCandidate = inner.Children[0];
- while (scanCandidate.PhysicalOp == "Compute Scalar" && scanCandidate.Children.Count > 0)
+ var scanCandidate = node.Children[0];
+ while ((scanCandidate.PhysicalOp == "Compute Scalar" || scanCandidate.PhysicalOp == "Parallelism")
+ && scanCandidate.Children.Count > 0)
scanCandidate = scanCandidate.Children[0];
if (IsScanOperator(scanCandidate))
{
+ var topLabel = isTopNSort ? "Top N Sort" : "Top";
+ var onInner = node.Parent?.PhysicalOp == "Nested Loops" && node.Parent.Children.Count >= 2
+ && node.Parent.Children[1] == node;
+ var innerNote = onInner
+ ? $" This is on the inner side of Nested Loops (Node {node.Parent!.NodeId}), so the scan repeats for every outer row."
+ : "";
var predInfo = !string.IsNullOrEmpty(scanCandidate.Predicate)
? " The scan has a residual predicate, so it may read many rows before the Top is satisfied."
: "";
- inner.Warnings.Add(new PlanWarning
+ node.Warnings.Add(new PlanWarning
{
WarningType = "Top Above Scan",
- Message = $"Top operator reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}) on the inner side of Nested Loops (Node {node.NodeId}).{predInfo} Check that you have appropriate indexes to convert the scan into a seek.",
- Severity = PlanWarningSeverity.Warning
+ Message = $"{topLabel} reads from {scanCandidate.PhysicalOp} (Node {scanCandidate.NodeId}).{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.",
+ Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning
});
}
}
@@ -1097,8 +1157,8 @@ private static bool IsOrExpansionChain(PlanNode concatenationNode)
while (parent != null && parent.PhysicalOp == "Compute Scalar")
parent = parent.Parent;
- // Expect TopN Sort
- if (parent == null || parent.LogicalOp != "TopN Sort")
+ // Expect TopN Sort (XML says "TopN Sort", parser normalizes to "Top N Sort")
+ if (parent == null || parent.LogicalOp != "Top N Sort")
return false;
// Walk up to Merge Interval
diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs
index 2d3037f..01bfcd8 100644
--- a/src/PlanViewer.Core/Services/ShowPlanParser.cs
+++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs
@@ -652,6 +652,10 @@ private static PlanNode ParseRelOp(XElement relOpEl)
var physicalOpEl = GetOperatorElement(relOpEl);
if (physicalOpEl != null)
{
+ // Top N Sort — XML element is but PhysicalOp is "Sort"
+ if (physicalOpEl.Name.LocalName == "TopSort")
+ node.LogicalOp = "Top N Sort";
+
// Object reference (table/index name) — scoped to stop at child RelOps
var objEl = ScopedDescendants(physicalOpEl, Ns + "Object").FirstOrDefault();
if (objEl != null)
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index 61cd337..e8815ab 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -443,8 +443,13 @@ public void Rule22_TableVariable_DetectsAtSignInObjectName()
var plan = PlanTestHelper.LoadAndAnalyze("table_variable_plan.sqlplan");
var warnings = PlanTestHelper.WarningsOfType(plan, "Table Variable");
- Assert.Single(warnings);
- Assert.Contains("lack column-level statistics", warnings[0].Message);
+ // Node-level + statement-level warnings
+ Assert.True(warnings.Count >= 2);
+ Assert.Contains(warnings, w => w.Message.Contains("lack column-level statistics"));
+ // Statement-level stats warning
+ var stmtWarnings = PlanTestHelper.FirstStatement(plan).PlanWarnings
+ .Where(w => w.WarningType == "Table Variable").ToList();
+ Assert.Contains(stmtWarnings, w => w.Message.Contains("lack column-level statistics"));
}
// ---------------------------------------------------------------
@@ -562,13 +567,13 @@ public void Rule29_ImplicitConvertSeekPlan_UpgradedToCritical()
[Fact]
public void Rule25_IneffectiveParallelism_DetectedWhenCpuEqualsElapsed()
{
- // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (ratio ~1.0)
+ // serially-parallel: DOP 8 but CPU 17,110ms ≈ elapsed 17,112ms (efficiency ~0%)
var plan = PlanTestHelper.LoadAndAnalyze("serially-parallel.sqlplan");
var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism");
Assert.Single(warnings);
Assert.Contains("DOP 8", warnings[0].Message);
- Assert.Contains("ran essentially serially", warnings[0].Message);
+ Assert.Contains("% efficient", warnings[0].Message);
}
[Fact]
@@ -632,13 +637,15 @@ public void Rule30_MissingIndexQuality_DetectsWideOrLowImpact()
public void Rule31_ParallelWaitBottleneck_DetectedWhenElapsedExceedsCpu()
{
// excellent-parallel-spill: DOP 4, CPU 172,222ms vs elapsed 225,870ms
- // ratio ~0.76 (< 0.8) — threads are waiting more than working
+ // speedup ~0.76 — CPU < Elapsed but >= 0.5, so fires as Ineffective Parallelism
+ // (wait bottleneck only fires when speedup < 0.5 — extreme waiting)
var plan = PlanTestHelper.LoadAndAnalyze("excellent-parallel-spill.sqlplan");
- var warnings = PlanTestHelper.WarningsOfType(plan, "Parallel Wait Bottleneck");
- Assert.Single(warnings);
+ // At DOP 4 with speedup 0.76, efficiency ≈ 0% — fires Ineffective Parallelism
+ var warnings = PlanTestHelper.WarningsOfType(plan, "Ineffective Parallelism");
+ Assert.NotEmpty(warnings);
Assert.Contains("DOP 4", warnings[0].Message);
- Assert.Contains("waiting", warnings[0].Message);
+ Assert.Contains("% efficient", warnings[0].Message);
}
// ---------------------------------------------------------------