From 98694bae8f27d90f75f89c6a01f0f2a9805d880c Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Thu, 26 Feb 2026 15:05:17 +0000 Subject: [PATCH 01/18] Fixes #319 --- Dashboard/Helpers/ChartHoverHelper.cs | 5 +++-- Lite/Helpers/ChartHoverHelper.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index dd0e712..6318040 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -71,9 +71,10 @@ private void OnMouseMove(object sender, MouseEventArgs e) try { var pos = e.GetPosition(_chart); + var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( - (float)(pos.X * _chart.DisplayScale), - (float)(pos.Y * _chart.DisplayScale)); + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); /* Use X-axis (time) proximity as the primary filter, Y-axis distance diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 71a8fbb..09e6410 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -68,9 +68,10 @@ private void OnMouseMove(object sender, MouseEventArgs e) _lastUpdate = now; var pos = e.GetPosition(_chart); + var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( - (float)(pos.X * _chart.DisplayScale), - (float)(pos.Y * _chart.DisplayScale)); + (float)(pos.X * dpi.DpiScaleX), + (float)(pos.Y * dpi.DpiScaleY)); var mouseCoords = _chart.Plot.GetCoordinates(pixel); double bestDistance = double.MaxValue; From 0979d31905b70bc7914ea295973faebb07c5fb69 Mon Sep 17 00:00:00 2001 From: ClaudioESSilva Date: Thu, 26 Feb 2026 15:40:28 +0000 Subject: [PATCH 02/18] Fixes #321 --- Dashboard/Controls/MemoryContent.xaml.cs | 8 ++++++ .../Controls/ResourceMetricsContent.xaml.cs | 17 +++++++++++++ Lite/Controls/ServerTab.xaml.cs | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index f78b0ab..f381af5 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -82,6 +82,14 @@ public MemoryContent() SetupChartContextMenus(); Loaded += OnLoaded; + // Apply dark theme immediately so charts don't flash white before data loads + TabHelpers.ApplyDarkModeToChart(MemoryStatsOverviewChart); + TabHelpers.ApplyDarkModeToChart(MemoryGrantSizingChart); + TabHelpers.ApplyDarkModeToChart(MemoryGrantActivityChart); + TabHelpers.ApplyDarkModeToChart(MemoryClerksChart); + TabHelpers.ApplyDarkModeToChart(PlanCacheChart); + TabHelpers.ApplyDarkModeToChart(MemoryPressureEventsChart); + _memoryStatsOverviewHover = new Helpers.ChartHoverHelper(MemoryStatsOverviewChart, "MB"); _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, "count"); diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index 9c8c889..aa3def9 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -109,6 +109,23 @@ public ResourceMetricsContent() SetupChartContextMenus(); Loaded += OnLoaded; + // Apply dark theme immediately so charts don't flash white before data loads + TabHelpers.ApplyDarkModeToChart(LatchStatsChart); + TabHelpers.ApplyDarkModeToChart(SpinlockStatsChart); + TabHelpers.ApplyDarkModeToChart(TempdbStatsChart); + TabHelpers.ApplyDarkModeToChart(TempDbLatencyChart); + TabHelpers.ApplyDarkModeToChart(SessionStatsChart); + TabHelpers.ApplyDarkModeToChart(UserDbReadLatencyChart); + TabHelpers.ApplyDarkModeToChart(UserDbWriteLatencyChart); + TabHelpers.ApplyDarkModeToChart(FileIoReadThroughputChart); + TabHelpers.ApplyDarkModeToChart(FileIoWriteThroughputChart); + TabHelpers.ApplyDarkModeToChart(PerfmonCountersChart); + TabHelpers.ApplyDarkModeToChart(WaitStatsDetailChart); + TabHelpers.ApplyDarkModeToChart(ServerUtilTrendsCpuChart); + TabHelpers.ApplyDarkModeToChart(ServerUtilTrendsTempdbChart); + TabHelpers.ApplyDarkModeToChart(ServerUtilTrendsMemoryChart); + TabHelpers.ApplyDarkModeToChart(ServerUtilTrendsPerfmonChart); + _sessionStatsHover = new Helpers.ChartHoverHelper(SessionStatsChart, "sessions"); _latchStatsHover = new Helpers.ChartHoverHelper(LatchStatsChart, "ms/sec"); _spinlockStatsHover = new Helpers.ChartHoverHelper(SpinlockStatsChart, "collisions/sec"); diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index ea70408..800d2a8 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -158,6 +158,31 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe grid.CopyingRowClipboardContent += Helpers.DataGridClipboardBehavior.FixHeaderCopy; } + /* Apply dark theme immediately so charts don't flash white before data loads */ + ApplyDarkTheme(WaitStatsChart); + ApplyDarkTheme(QueryDurationTrendChart); + ApplyDarkTheme(ProcDurationTrendChart); + ApplyDarkTheme(QueryStoreDurationTrendChart); + ApplyDarkTheme(ExecutionCountTrendChart); + ApplyDarkTheme(CpuChart); + ApplyDarkTheme(MemoryChart); + ApplyDarkTheme(MemoryClerksChart); + ApplyDarkTheme(MemoryGrantSizingChart); + ApplyDarkTheme(MemoryGrantActivityChart); + ApplyDarkTheme(FileIoReadChart); + ApplyDarkTheme(FileIoWriteChart); + ApplyDarkTheme(FileIoReadThroughputChart); + ApplyDarkTheme(FileIoWriteThroughputChart); + ApplyDarkTheme(TempDbChart); + ApplyDarkTheme(TempDbFileIoChart); + ApplyDarkTheme(LockWaitTrendChart); + ApplyDarkTheme(BlockingTrendChart); + ApplyDarkTheme(DeadlockTrendChart); + ApplyDarkTheme(CurrentWaitsDurationChart); + ApplyDarkTheme(CurrentWaitsBlockedChart); + ApplyDarkTheme(PerfmonChart); + ApplyDarkTheme(CollectorDurationChart); + /* Chart hover tooltips */ _waitStatsHover = new Helpers.ChartHoverHelper(WaitStatsChart, "ms/sec"); _perfmonHover = new Helpers.ChartHoverHelper(PerfmonChart, ""); From 34a9c54030ef3d4ff4748bb9b9199fa73b231c7a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:58:00 -0500 Subject: [PATCH 03/18] Apply dark theme to all 19 SystemEventsContent charts in constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #322 — SystemEventsContent had the same issue where charts flash white until data loads. Adds upfront ApplyDarkModeToChart calls for all 19 charts in the constructor. Co-Authored-By: Claude Opus 4.6 --- .../Controls/SystemEventsContent.xaml.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index 4778838..d4dd73a 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -130,6 +130,27 @@ public SystemEventsContent() Loaded += OnLoaded; Unloaded += OnUnloaded; + // Apply dark theme immediately so charts don't flash white before data loads + TabHelpers.ApplyDarkModeToChart(BadPagesChart); + TabHelpers.ApplyDarkModeToChart(DumpRequestsChart); + TabHelpers.ApplyDarkModeToChart(AccessViolationsChart); + TabHelpers.ApplyDarkModeToChart(WriteAccessViolationsChart); + TabHelpers.ApplyDarkModeToChart(NonYieldingTasksChart); + TabHelpers.ApplyDarkModeToChart(LatchWarningsChart); + TabHelpers.ApplyDarkModeToChart(SickSpinlocksChart); + TabHelpers.ApplyDarkModeToChart(CpuComparisonChart); + TabHelpers.ApplyDarkModeToChart(SevereErrorsChart); + TabHelpers.ApplyDarkModeToChart(IOIssuesChart); + TabHelpers.ApplyDarkModeToChart(LongestPendingIOChart); + TabHelpers.ApplyDarkModeToChart(SchedulerIssuesChart); + TabHelpers.ApplyDarkModeToChart(MemoryConditionsChart); + TabHelpers.ApplyDarkModeToChart(CPUTasksChart); + TabHelpers.ApplyDarkModeToChart(MemoryBrokerChart); + TabHelpers.ApplyDarkModeToChart(MemoryBrokerRatioChart); + TabHelpers.ApplyDarkModeToChart(MemoryNodeOOMChart); + TabHelpers.ApplyDarkModeToChart(MemoryNodeOOMUtilChart); + TabHelpers.ApplyDarkModeToChart(MemoryNodeOOMMemoryChart); + _badPagesHover = new Helpers.ChartHoverHelper(BadPagesChart, "events"); _dumpRequestsHover = new Helpers.ChartHoverHelper(DumpRequestsChart, "events"); _accessViolationsHover = new Helpers.ChartHoverHelper(AccessViolationsChart, "events"); From bd0569b406efa2b40caa6b58b05ad541d142099a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:50:00 -0500 Subject: [PATCH 04/18] Add --preserve-jobs flag to CLI and GUI installers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing SQL Agent jobs (owner, schedule, notifications) are now preserved during upgrades when --preserve-jobs is specified (CLI) or the checkbox is checked (GUI). Missing jobs are still created. Uses a @preserve_jobs variable in the T-SQL — the C# installer flips it from 0 to 1 via string replace when the flag is set. Fixes #324 Co-Authored-By: Claude Opus 4.6 --- Installer/Program.cs | 13 + InstallerGui/MainWindow.xaml | 6 + InstallerGui/MainWindow.xaml.cs | 2 + InstallerGui/Services/InstallationService.cs | 12 + README.md | 1 + install/45_create_agent_jobs.sql | 247 +++++++++++-------- 6 files changed, 174 insertions(+), 107 deletions(-) diff --git a/Installer/Program.cs b/Installer/Program.cs index a47ed83..f00279b 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -95,6 +95,7 @@ static async Task Main(string[] args) Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(" --reinstall Drop existing database and perform clean install"); Console.WriteLine(" --reset-schedule Reset collection schedule to recommended defaults"); + Console.WriteLine(" --preserve-jobs Keep existing SQL Agent jobs (owner, schedule, notifications)"); Console.WriteLine(" --encrypt= Connection encryption: mandatory (default), optional, strict"); Console.WriteLine(" --trust-cert Trust server certificate without validation"); Console.WriteLine(); @@ -115,6 +116,7 @@ static async Task Main(string[] args) bool automatedMode = args.Length > 0; bool reinstallMode = args.Any(a => a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)); bool resetSchedule = args.Any(a => a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)); + bool preserveJobs = args.Any(a => a.Equals("--preserve-jobs", StringComparison.OrdinalIgnoreCase)); bool trustCert = args.Any(a => a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)); /*Parse encryption option (default: Mandatory)*/ @@ -135,6 +137,7 @@ static async Task Main(string[] args) var filteredArgs = args .Where(a => !a.Equals("--reinstall", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.Equals("--reset-schedule", StringComparison.OrdinalIgnoreCase)) + .Where(a => !a.Equals("--preserve-jobs", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.Equals("--trust-cert", StringComparison.OrdinalIgnoreCase)) .Where(a => !a.StartsWith("--encrypt=", StringComparison.OrdinalIgnoreCase)) .ToArray(); @@ -653,6 +656,16 @@ INSERT...WHERE NOT EXISTS re-populates with current recommended values Console.Write("(resetting schedule) "); } + /* + Preserve existing SQL Agent jobs if requested — flip the T-SQL + variable so existing jobs are left untouched during upgrade + */ + if (preserveJobs && fileName.StartsWith("45_", StringComparison.Ordinal)) + { + sqlContent = sqlContent.Replace("@preserve_jobs bit = 0", "@preserve_jobs bit = 1"); + Console.Write("(preserving existing jobs) "); + } + /* Remove SQLCMD directives (:r includes) as we're executing files directly */ diff --git a/InstallerGui/MainWindow.xaml b/InstallerGui/MainWindow.xaml index 61b4046..76e650d 100644 --- a/InstallerGui/MainWindow.xaml +++ b/InstallerGui/MainWindow.xaml @@ -186,6 +186,12 @@ Margin="0,0,0,10" Foreground="{DynamicResource ForegroundBrush}"/> + + + { diff --git a/InstallerGui/Services/InstallationService.cs b/InstallerGui/Services/InstallationService.cs index 512a6ab..dd6705b 100644 --- a/InstallerGui/Services/InstallationService.cs +++ b/InstallerGui/Services/InstallationService.cs @@ -325,6 +325,7 @@ public static async Task ExecuteInstallationAsync( List sqlFiles, bool cleanInstall, bool resetSchedule = false, + bool preserveJobs = false, IProgress? progress = null, Func? preValidationAction = null, CancellationToken cancellationToken = default) @@ -422,6 +423,17 @@ Execute SQL files }); } + /*Preserve existing SQL Agent jobs if requested*/ + if (preserveJobs && fileName.StartsWith("45_", StringComparison.Ordinal)) + { + sqlContent = sqlContent.Replace("@preserve_jobs bit = 0", "@preserve_jobs bit = 1"); + progress?.Report(new InstallationProgress + { + Message = "Preserving existing SQL Agent jobs...", + Status = "Info" + }); + } + /*Remove SQLCMD directives*/ sqlContent = SqlCmdDirectivePattern.Replace(sqlContent, ""); diff --git a/README.md b/README.md index 9e37523..7318ae9 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ A GUI installer (`PerformanceMonitorInstallerGui.exe`) is also included in the r | `USERNAME PASSWORD` | SQL Authentication credentials (positional, optional) | | `--reinstall` | Drop existing database and perform clean install | | `--reset-schedule` | Reset collection schedule to recommended defaults during upgrade | +| `--preserve-jobs` | Keep existing SQL Agent jobs unchanged (owner, schedule, notifications) | | `--encrypt=optional\|mandatory\|strict` | Connection encryption level (default: mandatory) | | `--trust-cert` | Trust server certificate without validation (default: require valid cert) | diff --git a/install/45_create_agent_jobs.sql b/install/45_create_agent_jobs.sql index e7ce07c..c9fe080 100644 --- a/install/45_create_agent_jobs.sql +++ b/install/45_create_agent_jobs.sql @@ -21,9 +21,21 @@ GO /* Create SQL Server Agent Jobs for Performance Monitor These jobs automate data collection and retention + +When @preserve_jobs = 1, existing jobs are left untouched (owner, +schedule, notifications, etc.) and only missing jobs are created. +The installer sets this to 1 when --preserve-jobs is specified. */ +DECLARE + @preserve_jobs bit = 0; + PRINT 'Creating SQL Server Agent jobs for Performance Monitor'; + +IF @preserve_jobs = 1 +BEGIN + PRINT '(preserve mode — existing jobs will not be modified)'; +END; PRINT ''; /* @@ -31,11 +43,8 @@ Job 1: PerformanceMonitor - Collection Runs scheduled master collector every 1 minute The collector checks config.collection_schedule to determine which collectors should run */ - -/* -Drop existing job if it exists -*/ -IF EXISTS +IF @preserve_jobs = 0 +AND EXISTS ( SELECT 1/0 @@ -72,48 +81,55 @@ BEGIN PRINT 'Dropped existing PerformanceMonitor - Collection job'; END; -/* -Create the collection job -*/ -EXECUTE msdb.dbo.sp_add_job - @job_name = N'PerformanceMonitor - Collection', - @enabled = 1, - @description = N'Runs scheduled master collector to execute collectors based on config.collection_schedule', - @category_name = N'Data Collector'; - -EXECUTE msdb.dbo.sp_add_jobstep - @job_name = N'PerformanceMonitor - Collection', - @step_name = N'Run Scheduled Master Collector', - @subsystem = N'TSQL', - @database_name = N'PerformanceMonitor', - @command = N'EXECUTE collect.scheduled_master_collector @debug = 0;', - @retry_attempts = 0, - @on_success_action = 1; /*Quit with success*/ - -EXECUTE msdb.dbo.sp_add_jobschedule - @job_name = N'PerformanceMonitor - Collection', - @name = N'Every 1 Minute', - @freq_type = 4, /*Daily*/ - @freq_interval = 1, - @freq_subday_type = 4, /*Minutes*/ - @freq_subday_interval = 1; /*Every 1 minute*/ - -EXECUTE msdb.dbo.sp_add_jobserver - @job_name = N'PerformanceMonitor - Collection', - @server_name = N'(local)'; - -PRINT 'Created PerformanceMonitor - Collection job (runs every 1 minute)'; +IF NOT EXISTS +( + SELECT + 1/0 + FROM msdb.dbo.sysjobs AS j + WHERE j.name = N'PerformanceMonitor - Collection' +) +BEGIN + EXECUTE msdb.dbo.sp_add_job + @job_name = N'PerformanceMonitor - Collection', + @enabled = 1, + @description = N'Runs scheduled master collector to execute collectors based on config.collection_schedule', + @category_name = N'Data Collector'; + + EXECUTE msdb.dbo.sp_add_jobstep + @job_name = N'PerformanceMonitor - Collection', + @step_name = N'Run Scheduled Master Collector', + @subsystem = N'TSQL', + @database_name = N'PerformanceMonitor', + @command = N'EXECUTE collect.scheduled_master_collector @debug = 0;', + @retry_attempts = 0, + @on_success_action = 1; /*Quit with success*/ + + EXECUTE msdb.dbo.sp_add_jobschedule + @job_name = N'PerformanceMonitor - Collection', + @name = N'Every 1 Minute', + @freq_type = 4, /*Daily*/ + @freq_interval = 1, + @freq_subday_type = 4, /*Minutes*/ + @freq_subday_interval = 1; /*Every 1 minute*/ + + EXECUTE msdb.dbo.sp_add_jobserver + @job_name = N'PerformanceMonitor - Collection', + @server_name = N'(local)'; + + PRINT 'Created PerformanceMonitor - Collection job (runs every 1 minute)'; +END; +ELSE IF @preserve_jobs = 1 +BEGIN + PRINT 'PerformanceMonitor - Collection job already exists — preserving current settings'; +END; PRINT ''; /* Job 2: PerformanceMonitor - Data Retention Purges old performance monitoring data daily at 2am */ - -/* -Drop existing job if it exists -*/ -IF EXISTS +IF @preserve_jobs = 0 +AND EXISTS ( SELECT 1/0 @@ -150,47 +166,54 @@ BEGIN PRINT 'Dropped existing PerformanceMonitor - Data Retention job'; END; -/* -Create the data retention job -*/ -EXECUTE msdb.dbo.sp_add_job - @job_name = N'PerformanceMonitor - Data Retention', - @enabled = 1, - @description = N'Purges old performance monitoring data', - @category_name = N'Data Collector'; - -EXECUTE msdb.dbo.sp_add_jobstep - @job_name = N'PerformanceMonitor - Data Retention', - @step_name = N'Run Data Retention', - @subsystem = N'TSQL', - @database_name = N'PerformanceMonitor', - @command = N'EXECUTE config.data_retention @debug = 1;', - @retry_attempts = 0, - @on_success_action = 1; /*Quit with success*/ - -EXECUTE msdb.dbo.sp_add_jobschedule - @job_name = N'PerformanceMonitor - Data Retention', - @name = N'Daily at 2am', - @freq_type = 4, /*Daily*/ - @freq_interval = 1, - @active_start_time = 20000; /*2:00 AM*/ - -EXECUTE msdb.dbo.sp_add_jobserver - @job_name = N'PerformanceMonitor - Data Retention', - @server_name = N'(local)'; - -PRINT 'Created PerformanceMonitor - Data Retention job (runs daily at 2:00 AM)'; +IF NOT EXISTS +( + SELECT + 1/0 + FROM msdb.dbo.sysjobs AS j + WHERE j.name = N'PerformanceMonitor - Data Retention' +) +BEGIN + EXECUTE msdb.dbo.sp_add_job + @job_name = N'PerformanceMonitor - Data Retention', + @enabled = 1, + @description = N'Purges old performance monitoring data', + @category_name = N'Data Collector'; + + EXECUTE msdb.dbo.sp_add_jobstep + @job_name = N'PerformanceMonitor - Data Retention', + @step_name = N'Run Data Retention', + @subsystem = N'TSQL', + @database_name = N'PerformanceMonitor', + @command = N'EXECUTE config.data_retention @debug = 1;', + @retry_attempts = 0, + @on_success_action = 1; /*Quit with success*/ + + EXECUTE msdb.dbo.sp_add_jobschedule + @job_name = N'PerformanceMonitor - Data Retention', + @name = N'Daily at 2am', + @freq_type = 4, /*Daily*/ + @freq_interval = 1, + @active_start_time = 20000; /*2:00 AM*/ + + EXECUTE msdb.dbo.sp_add_jobserver + @job_name = N'PerformanceMonitor - Data Retention', + @server_name = N'(local)'; + + PRINT 'Created PerformanceMonitor - Data Retention job (runs daily at 2:00 AM)'; +END; +ELSE IF @preserve_jobs = 1 +BEGIN + PRINT 'PerformanceMonitor - Data Retention job already exists — preserving current settings'; +END; PRINT ''; /* Job 3: PerformanceMonitor - Hung Job Monitor Monitors the collection job for hung state every 5 minutes */ - -/* -Drop existing job if it exists -*/ -IF EXISTS +IF @preserve_jobs = 0 +AND EXISTS ( SELECT 1/0 @@ -227,42 +250,52 @@ BEGIN PRINT 'Dropped existing PerformanceMonitor - Hung Job Monitor job'; END; -/* -Create the hung job monitor job -*/ -EXECUTE msdb.dbo.sp_add_job - @job_name = N'PerformanceMonitor - Hung Job Monitor', - @enabled = 1, - @description = N'Monitors collection job for hung state and stops it if needed', - @category_name = N'Data Collector'; - -EXECUTE msdb.dbo.sp_add_jobstep - @job_name = N'PerformanceMonitor - Hung Job Monitor', - @step_name = N'Check for Hung Collection Job', - @subsystem = N'TSQL', - @database_name = N'PerformanceMonitor', - @command = N'EXECUTE config.check_hung_collector_job +IF NOT EXISTS +( + SELECT + 1/0 + FROM msdb.dbo.sysjobs AS j + WHERE j.name = N'PerformanceMonitor - Hung Job Monitor' +) +BEGIN + EXECUTE msdb.dbo.sp_add_job + @job_name = N'PerformanceMonitor - Hung Job Monitor', + @enabled = 1, + @description = N'Monitors collection job for hung state and stops it if needed', + @category_name = N'Data Collector'; + + EXECUTE msdb.dbo.sp_add_jobstep + @job_name = N'PerformanceMonitor - Hung Job Monitor', + @step_name = N'Check for Hung Collection Job', + @subsystem = N'TSQL', + @database_name = N'PerformanceMonitor', + @command = N'EXECUTE config.check_hung_collector_job @job_name = N''PerformanceMonitor - Collection'', @normal_max_duration_minutes = 5, @first_run_max_duration_minutes = 30, @stop_hung_job = 1, @debug = 0;', - @retry_attempts = 0, - @on_success_action = 1; /*Quit with success*/ - -EXECUTE msdb.dbo.sp_add_jobschedule - @job_name = N'PerformanceMonitor - Hung Job Monitor', - @name = N'Every 5 Minutes', - @freq_type = 4, /*Daily*/ - @freq_interval = 1, - @freq_subday_type = 4, /*Minutes*/ - @freq_subday_interval = 5; /*Every 5 minutes*/ - -EXECUTE msdb.dbo.sp_add_jobserver - @job_name = N'PerformanceMonitor - Hung Job Monitor', - @server_name = N'(local)'; - -PRINT 'Created PerformanceMonitor - Hung Job Monitor job (runs every 5 minutes)'; + @retry_attempts = 0, + @on_success_action = 1; /*Quit with success*/ + + EXECUTE msdb.dbo.sp_add_jobschedule + @job_name = N'PerformanceMonitor - Hung Job Monitor', + @name = N'Every 5 Minutes', + @freq_type = 4, /*Daily*/ + @freq_interval = 1, + @freq_subday_type = 4, /*Minutes*/ + @freq_subday_interval = 5; /*Every 5 minutes*/ + + EXECUTE msdb.dbo.sp_add_jobserver + @job_name = N'PerformanceMonitor - Hung Job Monitor', + @server_name = N'(local)'; + + PRINT 'Created PerformanceMonitor - Hung Job Monitor job (runs every 5 minutes)'; +END; +ELSE IF @preserve_jobs = 1 +BEGIN + PRINT 'PerformanceMonitor - Hung Job Monitor job already exists — preserving current settings'; +END; PRINT ''; /* From 0816ede20a0383ec685db631e816336054ebbf60 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:48:28 -0500 Subject: [PATCH 05/18] Add 13 new plan analysis rules to PlanAnalyzer (#327) * Add 13 new plan analysis rules to PlanAnalyzer Expands the plan analyzer from 11 to 24 rules with domain-specific detection patterns for common SQL Server performance anti-patterns. New rules: - Non-SARGable predicates (CONVERT_IMPLICIT, function calls, ISNULL, leading wildcard LIKE, CASE expressions) - Data type mismatch (GetRangeWithMismatchedTypes) - Lazy spool ineffective rebind/rewind ratio - Join OR clause expansion (Concatenation + Constant Scan) - Nested Loops high inner-side execution count - Many-to-many Merge Join - Large memory grant with sort/hash consumer identification - Compile memory exceeded (early abort) - High compile CPU (>= 1000ms) - Local variables without RECOMPILE - CTE referenced multiple times - Table variable detection - Table-valued function detection - Top above rowstore scan Also fixes existing scan rules to exclude columnstore indexes (designed to be scanned) and extracts IsRowstoreScan() helper. Co-Authored-By: Claude Opus 4.6 * Sync PlanAnalyzer from plan-b: statement-level UDF timing + CASE detection reorder - Add statement-level Rule 4 check for UDF execution timing from QueryTimeStats (some plans report UDF timing only at statement level, not per-node) - Reorder non-SARGable detection: check CASE expression before CONVERT_IMPLICIT since CASE bodies often contain CONVERT_IMPLICIT that isn't the root cause Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Dashboard/Services/PlanAnalyzer.cs | 408 ++++++++++++++++++++++++++++- 1 file changed, 399 insertions(+), 9 deletions(-) diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 89a6568..7fda16d 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using PerformanceMonitorDashboard.Models; namespace PerformanceMonitorDashboard.Services; @@ -11,6 +12,23 @@ namespace PerformanceMonitorDashboard.Services; /// public static class PlanAnalyzer { + private static readonly Regex FunctionInPredicateRegex = new( + @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex LeadingWildcardLikeRegex = new( + @"\blike\b[^'""]*?N?'%", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex CaseInPredicateRegex = new( + @"\bCASE\s+(WHEN\b|$)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // Matches CTE definitions: WITH name AS ( or , name AS ( + private static readonly Regex CteDefinitionRegex = new( + @"(?:\bWITH\s+|\,\s*)(\w+)\s+AS\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static void Analyze(ParsedPlan plan) { foreach (var batch in plan.Batches) @@ -20,7 +38,7 @@ public static void Analyze(ParsedPlan plan) AnalyzeStatement(stmt); if (stmt.RootNode != null) - AnalyzeNodeTree(stmt.RootNode); + AnalyzeNodeTree(stmt.RootNode, stmt); } } } @@ -78,18 +96,102 @@ private static void AnalyzeStatement(PlanStatement stmt) Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } + + // Large memory grant with sort/hash guidance + if (grant.GrantedMemoryKB > 102400 && stmt.RootNode != null) + { + var consumers = new List(); + FindMemoryConsumers(stmt.RootNode, consumers); + + var grantMB = grant.GrantedMemoryKB / 1024.0; + var guidance = consumers.Count > 0 + ? $" Memory consumers: {string.Join(", ", consumers)}. Check whether these operators are processing more rows than necessary." + : ""; + + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Large Memory Grant", + Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", + Severity = grantMB >= 512 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + } + + // Rule 18: Compile memory exceeded (early abort) + if (stmt.StatementOptmEarlyAbortReason == "MemoryLimitExceeded") + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Compile Memory Exceeded", + Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan may be suboptimal. Simplify the query or break it into smaller parts.", + Severity = PlanWarningSeverity.Critical + }); + } + + // Rule 19: High compile CPU + if (stmt.CompileCPUMs >= 1000) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "High Compile CPU", + Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU to compile. Complex queries with many joins or subqueries can cause excessive compile time.", + Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 4 (statement-level): UDF execution timing from QueryTimeStats + // Some plans report UDF timing only at the statement level, not per-node. + if (stmt.QueryUdfCpuTimeMs > 0 || stmt.QueryUdfElapsedTimeMs > 0) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "UDF Execution", + Message = $"Scalar UDF executing in this statement. UDF elapsed: {stmt.QueryUdfElapsedTimeMs:N0}ms, UDF CPU: {stmt.QueryUdfCpuTimeMs:N0}ms", + Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning + }); + } + + // Rule 20: Local variables without RECOMPILE + // Parameters with no CompiledValue are likely local variables — the optimizer + // cannot sniff their values and uses density-based ("unknown") estimates. + if (stmt.Parameters.Count > 0) + { + var unsnifffedParams = stmt.Parameters + .Where(p => string.IsNullOrEmpty(p.CompiledValue)) + .ToList(); + + if (unsnifffedParams.Count > 0) + { + var hasRecompile = stmt.StatementText.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); + if (!hasRecompile) + { + var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Local Variables", + Message = $"Parameters without compiled values detected: {names}. These are likely local variables, which cause the optimizer to use density-based (\"unknown\") estimates. Consider using OPTION (RECOMPILE) or rewriting with parameters.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + // Rule 21: CTE referenced multiple times + if (!string.IsNullOrEmpty(stmt.StatementText)) + { + DetectMultiReferenceCte(stmt); } } - private static void AnalyzeNodeTree(PlanNode node) + private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt) { - AnalyzeNode(node); + AnalyzeNode(node, stmt); foreach (var child in node.Children) - AnalyzeNodeTree(child); + AnalyzeNodeTree(child, stmt); } - private static void AnalyzeNode(PlanNode node) + private static void AnalyzeNode(PlanNode node, PlanStatement stmt) { // Rule 1: Filter operators — rows survived the tree just to be discarded if (node.PhysicalOp == "Filter" && !string.IsNullOrEmpty(node.Predicate)) @@ -198,10 +300,20 @@ private static void AnalyzeNode(PlanNode node) }); } - // Rule 11: Scan with residual predicate (not spools) - if (node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && - !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(node.Predicate)) + // Rule 12: Non-SARGable predicate on scan + var nonSargableReason = DetectNonSargablePredicate(node); + if (nonSargableReason != null) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Non-SARGable Predicate", + Message = $"{nonSargableReason} prevents index seek, forcing a scan. Fix the predicate or add a computed column with an index. Predicate: {Truncate(node.Predicate!, 200)}", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 11: Scan with residual predicate (skip if non-SARGable already flagged) + if (nonSargableReason == null && IsRowstoreScan(node) && !string.IsNullOrEmpty(node.Predicate)) { node.Warnings.Add(new PlanWarning { @@ -210,6 +322,284 @@ private static void AnalyzeNode(PlanNode node) Severity = PlanWarningSeverity.Warning }); } + + // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes) + if (node.PhysicalOp == "Compute Scalar" && + !string.IsNullOrEmpty(node.DefinedValues) && + node.DefinedValues.Contains("GetRangeWithMismatchedTypes", StringComparison.OrdinalIgnoreCase)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Data Type Mismatch", + Message = "Implicit conversion due to mismatched data types. The column type does not match the parameter or literal type, forcing SQL Server to convert values at runtime. Fix the parameter type to match the column.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 14: Lazy Table Spool unfavorable rebind/rewind ratio + if (node.LogicalOp == "Lazy Spool") + { + var rebinds = node.HasActualStats ? (double)node.ActualRebinds : node.EstimateRebinds; + var rewinds = node.HasActualStats ? (double)node.ActualRewinds : node.EstimateRewinds; + var source = node.HasActualStats ? "actual" : "estimated"; + + if (rebinds > 100 && (rewinds == 0 || rebinds * 2 >= rewinds)) + { + var severity = rebinds > rewinds + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning; + + var ratio = rewinds > 0 + ? $"{rewinds / rebinds:F1}x more rewinds (cache hits) than rebinds (cache misses)" + : "no rewinds (cache hits) at all"; + + node.Warnings.Add(new PlanWarning + { + WarningType = "Lazy Spool Ineffective", + Message = $"Lazy spool has unfavorable rebind/rewind ratio ({source}): {rebinds:N0} rebinds, {rewinds:N0} rewinds — {ratio}. The spool cache is not providing significant benefit.", + Severity = severity + }); + } + } + + // Rule 15: Join OR clause (Concatenation + Constant Scan pattern) + // Pattern: Concatenation → Compute Scalar → Constant Scan (one per OR branch) + if (node.PhysicalOp == "Concatenation") + { + var constantScanBranches = node.Children + .Count(c => c.PhysicalOp == "Constant Scan" || + c.Children.Any(gc => gc.PhysicalOp == "Constant Scan")); + + if (constantScanBranches >= 2 && HasJoinAncestor(node)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Join OR Clause", + Message = $"OR clause expansion in a join predicate. SQL Server rewrote the OR as {constantScanBranches} separate branches (Concatenation of Constant Scans), each evaluated independently. This pattern often causes excessive inner-side executions.", + Severity = PlanWarningSeverity.Warning + }); + } + } + + // Rule 16: Nested Loops high inner-side execution count + if (node.PhysicalOp == "Nested Loops" && + node.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase) && + node.Children.Count >= 2) + { + var innerChild = node.Children[1]; + + if (innerChild.HasActualStats && innerChild.ActualExecutions > 1000) + { + var dop = stmt.DegreeOfParallelism > 0 ? stmt.DegreeOfParallelism : 1; + node.Warnings.Add(new PlanWarning + { + WarningType = "Nested Loops High Executions", + Message = $"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop}). A Hash Join or Merge Join may be more efficient for this row count.", + Severity = innerChild.ActualExecutions > 100000 + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning + }); + } + else if (!innerChild.HasActualStats && innerChild.EstimateRebinds > 1000) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Nested Loops High Executions", + Message = $"Nested Loops inner side estimated to execute {innerChild.EstimateRebinds + 1:N0} times. A Hash Join or Merge Join may be more efficient for this row count.", + Severity = innerChild.EstimateRebinds > 100000 + ? PlanWarningSeverity.Critical + : PlanWarningSeverity.Warning + }); + } + } + + // Rule 17: Many-to-many Merge Join + if (node.ManyToMany && node.PhysicalOp.Contains("Merge", StringComparison.OrdinalIgnoreCase)) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Many-to-Many Merge Join", + Message = "Many-to-many Merge Join requires a worktable to handle duplicate values. This can be expensive with large numbers of duplicates.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 22: Table variables (Object name starts with @) + if (!string.IsNullOrEmpty(node.ObjectName) && + node.ObjectName.Contains("@")) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Table Variable", + Message = "Table variable detected. Table variables have no statistics, so the optimizer always estimates 1 row regardless of actual cardinality. Consider using a temp table (#table) for better estimates.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 23: Table-valued functions + if (node.LogicalOp == "Table-valued function") + { + var funcName = node.ObjectName ?? node.PhysicalOp; + node.Warnings.Add(new PlanWarning + { + WarningType = "Table-Valued Function", + Message = $"Table-valued function: {funcName}. Multi-statement TVFs have no statistics and a fixed estimate of 1 row (pre-2017) or 100 rows (2017+). Consider inlining the logic or using an inline TVF.", + Severity = PlanWarningSeverity.Warning + }); + } + + // Rule 24: Top above a scan (linear search pattern) + if (node.PhysicalOp == "Top" && node.Children.Count > 0) + { + // Walk through pass-through operators (Compute Scalar, etc.) + var child = node.Children[0]; + while (child.PhysicalOp == "Compute Scalar" && child.Children.Count > 0) + child = child.Children[0]; + + if (IsRowstoreScan(child)) + { + var predInfo = !string.IsNullOrEmpty(child.Predicate) + ? " The scan has a residual predicate, so it may read many rows before the Top is satisfied." + : ""; + node.Warnings.Add(new PlanWarning + { + WarningType = "Top Above Scan", + Message = $"Top operator reads from {child.PhysicalOp} (Node {child.NodeId}).{predInfo} An index supporting the filter and ordering may convert this to a seek.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + /// + /// Returns true for rowstore scan operators (Index Scan, Clustered Index Scan, + /// Table Scan). Excludes columnstore scans, spools, and constant scans. + /// + private static bool IsRowstoreScan(PlanNode node) + { + return node.PhysicalOp.Contains("Scan", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Constant", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Columnstore", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Detects non-SARGable patterns in scan predicates. + /// Returns a description of the issue, or null if the predicate is fine. + /// + private static string? DetectNonSargablePredicate(PlanNode node) + { + if (string.IsNullOrEmpty(node.Predicate)) + return null; + + // Only check rowstore scan operators — columnstore is designed to be scanned + if (!IsRowstoreScan(node)) + return null; + + var predicate = node.Predicate; + + // CASE expression in predicate — check first because CASE bodies + // often contain CONVERT_IMPLICIT that isn't the root cause + if (CaseInPredicateRegex.IsMatch(predicate)) + return "CASE expression in predicate"; + + // CONVERT_IMPLICIT — most common non-SARGable pattern + if (predicate.Contains("CONVERT_IMPLICIT", StringComparison.OrdinalIgnoreCase)) + return "Implicit conversion (CONVERT_IMPLICIT)"; + + // ISNULL / COALESCE wrapping column + if (Regex.IsMatch(predicate, @"\b(isnull|coalesce)\s*\(", RegexOptions.IgnoreCase)) + return "ISNULL/COALESCE wrapping column"; + + // Common function calls on columns + var funcMatch = FunctionInPredicateRegex.Match(predicate); + if (funcMatch.Success) + { + var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); + if (funcName != "CONVERT_IMPLICIT") + return $"Function call ({funcName}) on column"; + } + + // Leading wildcard LIKE + if (LeadingWildcardLikeRegex.IsMatch(predicate)) + return "Leading wildcard LIKE pattern"; + + return null; + } + + /// + /// Detects CTEs that are referenced more than once in the statement text. + /// Each reference re-executes the CTE since SQL Server does not materialize them. + /// + private static void DetectMultiReferenceCte(PlanStatement stmt) + { + var text = stmt.StatementText; + var cteMatches = CteDefinitionRegex.Matches(text); + if (cteMatches.Count == 0) + return; + + foreach (Match match in cteMatches) + { + var cteName = match.Groups[1].Value; + if (string.IsNullOrEmpty(cteName)) + continue; + + // Count references as FROM/JOIN targets after the CTE definition + var refPattern = new Regex( + $@"\b(FROM|JOIN)\s+{Regex.Escape(cteName)}\b", + RegexOptions.IgnoreCase); + var refCount = refPattern.Matches(text).Count; + + if (refCount > 1) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "CTE Multiple References", + Message = $"CTE \"{cteName}\" is referenced {refCount} times. SQL Server does not materialize CTEs — each reference re-executes the entire CTE query. Consider materializing into a temp table.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + + /// + /// Checks whether a node has a join operator as an ancestor. + /// + private static bool HasJoinAncestor(PlanNode node) + { + var ancestor = node.Parent; + while (ancestor != null) + { + if (ancestor.LogicalOp.Contains("Join", StringComparison.OrdinalIgnoreCase)) + return true; + ancestor = ancestor.Parent; + } + return false; + } + + /// + /// Finds Sort and Hash Match operators in the tree that consume memory. + /// + private static void FindMemoryConsumers(PlanNode node, List consumers) + { + if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && + !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) + { + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add($"Sort (Node {node.NodeId}, {rows})"); + } + else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) + { + var rows = node.HasActualStats + ? $"{node.ActualRows:N0} actual rows" + : $"{node.EstimateRows:N0} estimated rows"; + consumers.Add($"Hash Match (Node {node.NodeId}, {rows})"); + } + + foreach (var child in node.Children) + FindMemoryConsumers(child, consumers); } private static string Truncate(string value, int maxLength) From ef0e29e0089a4185357589fe64105f8b5285f71c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:46:19 -0500 Subject: [PATCH 06/18] Add sortable statement DataGrid and canvas panning to plan viewer Replace the ComboBox statement selector with a sortable DataGrid panel on the left side. Columns: #, Query, CPU, Elapsed, UDF (conditional), Est. Cost (for estimated plans), Critical, Warnings. Uses SortMemberPath for proper numeric sorting on formatted display columns. Add click-and-drag canvas panning via Preview mouse events with hit testing to distinguish node clicks from empty canvas drags. Ported from plan-b Avalonia implementation, adapted for WPF. Co-Authored-By: Claude Opus 4.6 --- Dashboard/Controls/PlanViewerControl.xaml | 81 +++++- Dashboard/Controls/PlanViewerControl.xaml.cs | 275 ++++++++++++++++--- 2 files changed, 311 insertions(+), 45 deletions(-) diff --git a/Dashboard/Controls/PlanViewerControl.xaml b/Dashboard/Controls/PlanViewerControl.xaml index c5e0608..a3610c9 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml +++ b/Dashboard/Controls/PlanViewerControl.xaml @@ -27,13 +27,10 @@