Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions Darling/Darling.Tests/Viewer4579Tests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// #4579: plan-edge coloring by the child operator's actual-vs-estimated row ratio, matching
/// erikdarlingdata/PerformanceStudio dev's <c>GetLinkColorBrush</c>. <see cref="PlanEdgeColour"/> is a
/// new type, so every assertion here is a compile-only RED against dev.
/// </summary>
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public static class ViewerActualPlanFlow
/// </summary>
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);
Expand Down
17 changes: 17 additions & 0 deletions Darling/PerformanceMonitor.Darling.Viewer/ViewerAppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ public sealed class ViewerAppSettings
/// applied at startup and live-previewed from the Settings window via the shared <c>ThemeManager</c>.</summary>
public string ColorTheme { get; set; } = "Dark";

/// <summary>
/// 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 <c>AccuracyRatioDivergenceLimit</c>
/// setting. The viewer floors this at <see cref="PerformanceMonitor.PlanAnalysis.PlanEdgeColour.MinDivergenceLimit"/>
/// when it renders, so a hand-edited value below that has no effect. Mirrors Lite's
/// <c>App.AccuracyRatioDivergenceLimit</c>.
/// </summary>
public double AccuracyRatioDivergenceLimit { get; set; } = PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit;

/* ---------------- Notifications / alert thresholds (mirrors Lite App.*) ---------------- */

public bool MinimizeToTray { get; set; } = true;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -354,7 +366,12 @@ public static void Apply(ViewerAppSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
CsvSeparator = settings.CsvSeparator;
AccuracyRatioDivergenceLimit = settings.AccuracyRatioDivergenceLimit;
}

/// <summary>Current plan-edge accuracy-ratio divergence limit (#4579). Defaults to
/// <see cref="PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit"/>.</summary>
public static double AccuracyRatioDivergenceLimit { get; set; } = PerformanceMonitor.PlanAnalysis.PlanEdgeColour.DefaultDivergenceLimit;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions Lite/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ being handed back the stale in-memory version. The coordinator owns the mutex +
/// </summary>
public static int AutoRefreshIntervalSeconds { get; set; } = 60;

/// <summary>
/// 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 <c>AccuracyRatioDivergenceLimit</c>
/// setting. The viewer floors this at <see cref="PerformanceMonitor.PlanAnalysis.PlanEdgeColour.MinDivergenceLimit"/>
/// when it renders, so a hand-edited settings.json value below that has no effect.
/// </summary>
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;
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Lite/Controls/ServerTab.Plans.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Lite/Windows/PlanViewerWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
4 changes: 4 additions & 0 deletions Lite/config/settings.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
92 changes: 92 additions & 0 deletions PerformanceMonitor.PlanAnalysis/PlanEdgeColour.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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. <see cref="Neutral"/> is the plain
/// default edge color; the other six are the three-tier overestimate/underestimate ramps.
/// </summary>
public enum PlanEdgeColourKey
{
/// <summary>Estimated plan, or actual within the neutral band: the plain default edge color.</summary>
Neutral,

/// <summary>Underestimated (more actual rows than estimated), first tier past the limit.</summary>
LightOrange,

/// <summary>Underestimated, second tier (limit x 10).</summary>
FluoOrange,

/// <summary>Underestimated, third tier (limit x 100).</summary>
FluoRed,

/// <summary>Overestimated (fewer actual rows than estimated), first tier past the limit.</summary>
Blue,

/// <summary>Overestimated, second tier (limit x 10).</summary>
LightBlue,

/// <summary>Overestimated, third tier (limit x 100).</summary>
FluoBlue,
}

/// <summary>
/// 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
/// <c>PlanViewerControl.Rendering.cs</c> <c>GetLinkColorBrush</c> 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 <c>PerformanceMonitor.Ui</c>, which is net10.0-windows-only.
/// </summary>
public static class PlanEdgeColour
{
/// <summary>The floor PerformanceStudio clamps the divergence-limit setting to.</summary>
public const double MinDivergenceLimit = 2.0;

/// <summary>PerformanceStudio's default divergence-limit setting (before clamping).</summary>
public const double DefaultDivergenceLimit = 10.0;

/// <summary>
/// Returns the color key for the edge feeding <paramref name="child"/>. <paramref name="divergenceLimit"/>
/// is the raw setting value; this clamps it to <see cref="MinDivergenceLimit"/> itself, so callers
/// may pass the setting unclamped.
/// </summary>
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;
}
}
23 changes: 22 additions & 1 deletion PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -371,14 +371,35 @@ 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),
SnapsToDevicePixels = true
};
}

/// <summary>
/// Returns the brush for the edge feeding <paramref name="child"/>, colored by how far its actual
/// row count diverged from its estimate. Only actual plans get non-default colors; matches
/// erikdarlingdata/PerformanceStudio's <c>GetLinkColorBrush</c> exactly. The pure ratio-to-tier logic
/// lives in <see cref="PlanEdgeColour"/> so it can be pinned without WPF.
/// </summary>
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 };
Expand Down
Loading
Loading