diff --git a/Darling/Darling.Tests/Viewer4579Tests.cs b/Darling/Darling.Tests/Viewer4579Tests.cs new file mode 100644 index 0000000000..136b4b7bca --- /dev/null +++ b/Darling/Darling.Tests/Viewer4579Tests.cs @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.PlanAnalysis; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4579: plan-edge coloring by the child operator's actual-vs-estimated row ratio, matching +/// erikdarlingdata/PerformanceStudio dev's GetLinkColorBrush. is a +/// new type, so every assertion here is a compile-only RED against dev. +/// +public sealed class Viewer4579Tests +{ + private const double Limit = 10.0; + + [Fact] + public void EstimatedPlan_AlwaysNeutral() + { + // hasActualStats = false, even with a wildly diverging ratio, stays neutral (estimated plans + // never get accuracy-based color). + Assert.Equal(PlanEdgeColourKey.Neutral, PlanEdgeColour.ForChild(false, 1_000_000, 1, Limit)); + } + + [Fact] + public void ActualPlan_ExactMatch_IsNeutral() + { + Assert.Equal(PlanEdgeColourKey.Neutral, PlanEdgeColour.ForChild(true, 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_ZeroEstimate_ZeroActual_IsNeutral() + { + // estRows == 0 and actualRows == 0 => ratio treated as 1.0 (PS's exact fallback). + Assert.Equal(PlanEdgeColourKey.Neutral, PlanEdgeColour.ForChild(true, 0, 0, Limit)); + } + + [Fact] + public void ActualPlan_ZeroEstimate_NonZeroActual_IsFluoRed() + { + // estRows == 0 and actualRows > 0 => ratio treated as double.MaxValue, the top underestimate tier. + Assert.Equal(PlanEdgeColourKey.FluoRed, PlanEdgeColour.ForChild(true, 5, 0, Limit)); + } + + // ---- Underestimate side (accuracyRatio > 1: more actual rows than estimated) ---- + + [Theory] + [InlineData(9.9)] // just inside the neutral band + [InlineData(1.0 / 9.9)] + public void ActualPlan_JustInsideNeutralBand_IsNeutral(double ratio) + { + Assert.Equal(PlanEdgeColourKey.Neutral, PlanEdgeColour.ForChild(true, ratio * 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_AtLimit_IsNeutral() + { + // accuracyRatio == limit is still inside the closed neutral band ([1/limit, limit]). + Assert.Equal(PlanEdgeColourKey.Neutral, PlanEdgeColour.ForChild(true, Limit * 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_JustAboveLimit_IsLightOrange() + { + Assert.Equal(PlanEdgeColourKey.LightOrange, PlanEdgeColour.ForChild(true, Limit * 100 + 1, 100, Limit)); + } + + [Fact] + public void ActualPlan_AtLimitTimes10_IsFluoOrange() + { + Assert.Equal(PlanEdgeColourKey.FluoOrange, PlanEdgeColour.ForChild(true, Limit * 10 * 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_JustBelowLimitTimes10_IsLightOrange() + { + Assert.Equal(PlanEdgeColourKey.LightOrange, PlanEdgeColour.ForChild(true, (Limit * 10 - 0.001) * 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_AtLimitTimes100_IsFluoRed() + { + Assert.Equal(PlanEdgeColourKey.FluoRed, PlanEdgeColour.ForChild(true, Limit * 100 * 100, 100, Limit)); + } + + [Fact] + public void ActualPlan_JustBelowLimitTimes100_IsFluoOrange() + { + Assert.Equal(PlanEdgeColourKey.FluoOrange, PlanEdgeColour.ForChild(true, (Limit * 100 - 0.001) * 100, 100, Limit)); + } + + // ---- Overestimate side (accuracyRatio < 1: fewer actual rows than estimated) ---- + + [Fact] + public void ActualPlan_JustBelowInverseLimit_IsBlue() + { + Assert.Equal(PlanEdgeColourKey.Blue, PlanEdgeColour.ForChild(true, 100, Limit * 100 + 1, Limit)); + } + + [Fact] + public void ActualPlan_AtInverseLimitTimes10_IsBlue() + { + // Overestimate tiers use a strict '<' boundary (PS's exact form), so exactly at the tier + // boundary the ratio has not yet crossed into the next tier. + Assert.Equal(PlanEdgeColourKey.Blue, PlanEdgeColour.ForChild(true, 100, Limit * 10 * 100, Limit)); + } + + [Fact] + public void ActualPlan_JustAboveInverseLimitTimes10_IsBlue() + { + Assert.Equal(PlanEdgeColourKey.Blue, PlanEdgeColour.ForChild(true, 100, (Limit * 10 - 0.001) * 100, Limit)); + } + + [Fact] + public void ActualPlan_AtInverseLimitTimes100_IsLightBlue() + { + // Same strict '<' boundary behavior at the top tier. + Assert.Equal(PlanEdgeColourKey.LightBlue, PlanEdgeColour.ForChild(true, 100, Limit * 100 * 100, Limit)); + } + + [Fact] + public void ActualPlan_JustAboveInverseLimitTimes100_IsLightBlue() + { + Assert.Equal(PlanEdgeColourKey.LightBlue, PlanEdgeColour.ForChild(true, 100, (Limit * 100 - 0.001) * 100, Limit)); + } + + // ---- Divergence-limit floor ---- + + [Fact] + public void DivergenceLimit_BelowFloor_IsClampedToTwo() + { + // limit = 1 is floored to 2.0 (PS's Math.Max(2.0, ...)); a ratio of 3 is then past the floored + // limit, not inside a (nonsensical) [1, 1] neutral band. + Assert.Equal(PlanEdgeColourKey.LightOrange, PlanEdgeColour.ForChild(true, 300, 100, 1.0)); + } + + [Fact] + public void DefaultDivergenceLimit_MatchesPerformanceStudio() + { + Assert.Equal(10.0, PlanEdgeColour.DefaultDivergenceLimit); + } + + [Fact] + public void MinDivergenceLimit_MatchesPerformanceStudio() + { + Assert.Equal(2.0, PlanEdgeColour.MinDivergenceLimit); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs index eb45dacabc..16258cb414 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs @@ -104,7 +104,7 @@ public static class ViewerActualPlanFlow /// public static async Task OpenFloatingPlanAsync(Window owner, string planXml, string label, string? queryText) { - var viewer = new PlanViewerControl(); + var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = ViewerExportSettings.AccuracyRatioDivergenceLimit }; try { await viewer.LoadPlan(planXml, label, queryText); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs index ef5073bf0d..9b323b6b25 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs @@ -94,6 +94,15 @@ public sealed class ViewerAppSettings /// applied at startup and live-previewed from the Settings window via the shared ThemeManager. public string ColorTheme { get; set; } = "Dark"; + /// + /// How far a plan operator's actual row count may diverge from its estimate before the plan viewer + /// colors the edge feeding it (#4579), matching PerformanceStudio's AccuracyRatioDivergenceLimit + /// setting. The viewer floors this at + /// when it renders, so a hand-edited value below that has no effect. Mirrors Lite's + /// App.AccuracyRatioDivergenceLimit. + /// + public double AccuracyRatioDivergenceLimit { get; set; } = PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit; + /* ---------------- Notifications / alert thresholds (mirrors Lite App.*) ---------------- */ public bool MinimizeToTray { get; set; } = true; @@ -203,6 +212,9 @@ internal ViewerAppSettings Normalize() TimeDisplayMode = (TimeDisplayMode is "ServerTime" or "LocalTime" or "UTC") ? TimeDisplayMode : "ServerTime"; OverviewSortMode = (OverviewSortMode is "Cpu" or "Name") ? OverviewSortMode : "Cpu"; ColorTheme = (ColorTheme is "Dark" or "Light" or "CoolBreeze") ? ColorTheme : "Dark"; + AccuracyRatioDivergenceLimit = AccuracyRatioDivergenceLimit >= PerformanceMonitor.PlanAnalysis.PlanEdgeColour.MinDivergenceLimit + ? AccuracyRatioDivergenceLimit + : PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit; AlertCpuThreshold = Clamp(AlertCpuThreshold, 1, 100, 80); AlertCpuMode = (AlertCpuMode is "Total" or "SqlOnly") ? AlertCpuMode : "Total"; @@ -354,7 +366,12 @@ public static void Apply(ViewerAppSettings settings) { ArgumentNullException.ThrowIfNull(settings); CsvSeparator = settings.CsvSeparator; + AccuracyRatioDivergenceLimit = settings.AccuracyRatioDivergenceLimit; } + + /// Current plan-edge accuracy-ratio divergence limit (#4579). Defaults to + /// . + public static double AccuracyRatioDivergenceLimit { get; set; } = PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit; } /// diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs index ff751b4e36..d22c786b9f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs @@ -54,7 +54,7 @@ public partial class ViewerServerTab private async Task OpenPlanTab(string planXml, string label, string? queryText = null) { HidePlanLoading(); - var viewer = new PlanViewerControl(); + var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = ViewerExportSettings.AccuracyRatioDivergenceLimit }; try { await viewer.LoadPlan(planXml, label, queryText); diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs index c5e3387d11..e7d8ce5d4e 100644 --- a/Lite/App.xaml.cs +++ b/Lite/App.xaml.cs @@ -110,6 +110,14 @@ being handed back the stale in-memory version. The coordinator owns the mutex + /// public static int AutoRefreshIntervalSeconds { get; set; } = 60; + /// + /// How far a plan operator's actual row count may diverge from its estimate before the plan viewer + /// colors the edge feeding it (#4579), matching PerformanceStudio's AccuracyRatioDivergenceLimit + /// setting. The viewer floors this at + /// when it renders, so a hand-edited settings.json value below that has no effect. + /// + public static double AccuracyRatioDivergenceLimit { get; set; } = PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit; + /* Alert settings */ public static bool AlertsEnabled { get; set; } = true; public static bool NotifyConnectionChanges { get; set; } = true; @@ -987,6 +995,11 @@ default_time_range_hours has with the TimeRangeCombo restore switch. */ AutoRefreshIntervalSeconds = refreshSeconds.WholeNumber(AutoRefreshIntervalSeconds); } + if (read.TryGetProperty("accuracy_ratio_divergence_limit", out var divergenceLimit)) + { + AccuracyRatioDivergenceLimit = divergenceLimit.Number(AccuracyRatioDivergenceLimit, 2.0, 1000000.0); + } + /* #2444: this loader named its keys even when it had only one, which is the behaviour LoadAlertSettings could not manage across eighty-seven. It says so through the shared reporter instead of its own log line, so the startup dialog can name these keys beside diff --git a/Lite/Controls/ServerTab.Plans.cs b/Lite/Controls/ServerTab.Plans.cs index 1db3033f68..9e2fb6c566 100644 --- a/Lite/Controls/ServerTab.Plans.cs +++ b/Lite/Controls/ServerTab.Plans.cs @@ -252,7 +252,7 @@ private void ViewQueryStorePlanInline_Click(object sender, RoutedEventArgs e) private async Task OpenPlanTab(string planXml, string label, string? queryText = null) { HidePlanLoading(); - var viewer = new PlanViewerControl(); + var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = App.AccuracyRatioDivergenceLimit }; try { /* LoadPlan parses+analyzes off the UI thread; it throws XmlException for malformed diff --git a/Lite/Windows/PlanViewerWindow.xaml.cs b/Lite/Windows/PlanViewerWindow.xaml.cs index 30fe0de442..d84b864a40 100644 --- a/Lite/Windows/PlanViewerWindow.xaml.cs +++ b/Lite/Windows/PlanViewerWindow.xaml.cs @@ -22,6 +22,7 @@ public partial class PlanViewerWindow : Window public PlanViewerWindow() { InitializeComponent(); + Viewer.AccuracyRatioDivergenceLimit = App.AccuracyRatioDivergenceLimit; // The shared PlanViewerControl needs an explicit Cleanup() to unsubscribe ThemeManager. Closed += (_, _) => Viewer.Cleanup(); } diff --git a/Lite/config/settings.sample.json b/Lite/config/settings.sample.json index aaf8a921dc..afc82ea770 100644 --- a/Lite/config/settings.sample.json +++ b/Lite/config/settings.sample.json @@ -158,6 +158,10 @@ "auto_refresh_enabled": true, "auto_refresh_interval_seconds": 60, // 30 | 60 | 300 + // How far a plan operator's actual row count may diverge from its estimate before the plan viewer + // colors the edge feeding it. Floored at 2.0 when the viewer renders. + "accuracy_ratio_divergence_limit": 10, + // ---- Overview and sidebar state ----------------------------------------------------------------- // Written by the app as you use it, not settings you would normally hand-edit. "overview_sort_mode": "Cpu", // "Cpu" | "Name"; unrecognised -> "Cpu" diff --git a/PerformanceMonitor.PlanAnalysis/PlanEdgeColour.cs b/PerformanceMonitor.PlanAnalysis/PlanEdgeColour.cs new file mode 100644 index 0000000000..b176fbc871 --- /dev/null +++ b/PerformanceMonitor.PlanAnalysis/PlanEdgeColour.cs @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +namespace PerformanceMonitor.PlanAnalysis; + +/// +/// One of the seven colors a plan-edge (elbow connector) can take, keyed by how far a child +/// operator's actual row count diverged from its estimate. is the plain +/// default edge color; the other six are the three-tier overestimate/underestimate ramps. +/// +public enum PlanEdgeColourKey +{ + /// Estimated plan, or actual within the neutral band: the plain default edge color. + Neutral, + + /// Underestimated (more actual rows than estimated), first tier past the limit. + LightOrange, + + /// Underestimated, second tier (limit x 10). + FluoOrange, + + /// Underestimated, third tier (limit x 100). + FluoRed, + + /// Overestimated (fewer actual rows than estimated), first tier past the limit. + Blue, + + /// Overestimated, second tier (limit x 10). + LightBlue, + + /// Overestimated, third tier (limit x 100). + FluoBlue, +} + +/// +/// Pure helper computing plan-edge color by the estimate-vs-actual row-count ratio of the CHILD +/// operator feeding that edge, matching erikdarlingdata/PerformanceStudio's +/// PlanViewerControl.Rendering.cs GetLinkColorBrush exactly (colors only actual plans; +/// estimated plans keep the neutral default). Kept here (no WPF dependency) so a unit test can pin +/// every tier boundary without needing PerformanceMonitor.Ui, which is net10.0-windows-only. +/// +public static class PlanEdgeColour +{ + /// The floor PerformanceStudio clamps the divergence-limit setting to. + public const double MinDivergenceLimit = 2.0; + + /// PerformanceStudio's default divergence-limit setting (before clamping). + public const double DefaultDivergenceLimit = 10.0; + + /// + /// Returns the color key for the edge feeding . + /// is the raw setting value; this clamps it to itself, so callers + /// may pass the setting unclamped. + /// + public static PlanEdgeColourKey ForChild(bool hasActualStats, double actualRows, double estimateRows, double divergenceLimit) + { + if (!hasActualStats) + return PlanEdgeColourKey.Neutral; + + divergenceLimit = System.Math.Max(MinDivergenceLimit, divergenceLimit); + + var accuracyRatio = estimateRows > 0 + ? actualRows / estimateRows + : (actualRows > 0 ? double.MaxValue : 1.0); + + // Within the neutral band — keep the default color. + if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) + return PlanEdgeColourKey.Neutral; + + // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated). + if (accuracyRatio > divergenceLimit) + { + if (accuracyRatio >= divergenceLimit * 100) + return PlanEdgeColourKey.FluoRed; + if (accuracyRatio >= divergenceLimit * 10) + return PlanEdgeColourKey.FluoOrange; + return PlanEdgeColourKey.LightOrange; + } + + // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated). + if (accuracyRatio < 1.0 / (divergenceLimit * 100)) + return PlanEdgeColourKey.FluoBlue; + if (accuracyRatio < 1.0 / (divergenceLimit * 10)) + return PlanEdgeColourKey.LightBlue; + return PlanEdgeColourKey.Blue; + } +} diff --git a/PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs b/PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs index 49b494bb96..f31935e941 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs +++ b/PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs @@ -371,7 +371,7 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child) return new WpfPath { Data = geometry, - Stroke = EdgeBrush, + Stroke = GetLinkColorBrush(child), StrokeThickness = thickness, StrokeLineJoin = PenLineJoin.Round, ToolTip = BuildEdgeTooltipContent(child), @@ -379,6 +379,27 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child) }; } + /// + /// Returns the brush for the edge feeding , colored by how far its actual + /// row count diverged from its estimate. Only actual plans get non-default colors; matches + /// erikdarlingdata/PerformanceStudio's GetLinkColorBrush exactly. The pure ratio-to-tier logic + /// lives in so it can be pinned without WPF. + /// + private SolidColorBrush GetLinkColorBrush(PlanNode child) + { + var key = PlanEdgeColour.ForChild(child.HasActualStats, child.ActualRows, child.EstimateRows, AccuracyRatioDivergenceLimit); + return key switch + { + PlanEdgeColourKey.LightOrange => EdgeLightOrangeBrush, + PlanEdgeColourKey.FluoOrange => EdgeFluoOrangeBrush, + PlanEdgeColourKey.FluoRed => EdgeFluoRedBrush, + PlanEdgeColourKey.Blue => EdgeBlueBrush, + PlanEdgeColourKey.LightBlue => EdgeLightBlueBrush, + PlanEdgeColourKey.FluoBlue => EdgeFluoBlueBrush, + _ => EdgeBrush, + }; + } + private Border BuildEdgeTooltipContent(PlanNode child) { var grid = new Grid { MinWidth = 240 }; diff --git a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs index d7ada0dabc..e8dee688cb 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs +++ b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs @@ -39,6 +39,22 @@ public partial class PlanViewerControl : UserControl private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80)); private static readonly SolidColorBrush OrangeBrush = new(Color.FromRgb(0xFF, 0xB3, 0x47)); + // Edge accuracy-ratio colors, matching erikdarlingdata/PerformanceStudio's PlanViewerControl exactly. + private static readonly SolidColorBrush EdgeLightOrangeBrush = new(Color.FromRgb(0xFF, 0xB7, 0x4D)); + private static readonly SolidColorBrush EdgeFluoOrangeBrush = new(Color.FromRgb(0xFF, 0x8C, 0x00)); + private static readonly SolidColorBrush EdgeFluoRedBrush = new(Color.FromRgb(0xFF, 0x17, 0x44)); + private static readonly SolidColorBrush EdgeBlueBrush = new(Color.FromRgb(0x42, 0x8B, 0xCA)); + private static readonly SolidColorBrush EdgeLightBlueBrush = new(Color.FromRgb(0x64, 0xB5, 0xF6)); + private static readonly SolidColorBrush EdgeFluoBlueBrush = new(Color.FromRgb(0x00, 0xE5, 0xFF)); + + /// + /// How far a child operator's actual row count may diverge from its estimate before the edge feeding + /// it is colored; matches PerformanceStudio's AccuracyRatioDivergenceLimit setting (default 10, + /// floored at when applied). Hosts set this from their + /// own settings store before rendering. + /// + public double AccuracyRatioDivergenceLimit { get; set; } = PlanEdgeColour.DefaultDivergenceLimit; + // Theme-aware brushes resolved at call time from Application.Resources private SolidColorBrush TooltipBgBrush => (TryFindResource("PlanTooltipBgBrush") as SolidColorBrush) ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1D, 0x23));