diff --git a/.github/workflows/check-pr-branch.yml b/.github/workflows/check-pr-branch.yml
new file mode 100644
index 00000000..88fa6fae
--- /dev/null
+++ b/.github/workflows/check-pr-branch.yml
@@ -0,0 +1,21 @@
+name: Check pull request target branch
+on:
+ pull_request_target:
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - edited
+jobs:
+ check-branches:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check branches
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then
+ echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead."
+ exit 1
+ fi
diff --git a/Dashboard/AboutWindow.xaml b/Dashboard/AboutWindow.xaml
index 432be912..b8760fa4 100644
--- a/Dashboard/AboutWindow.xaml
+++ b/Dashboard/AboutWindow.xaml
@@ -43,13 +43,12 @@
Check for Updates
+
-
+
-
-
-
+
www.erikdarling.com
diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs
index e661dfd5..0e61086e 100644
--- a/Dashboard/AboutWindow.xaml.cs
+++ b/Dashboard/AboutWindow.xaml.cs
@@ -7,6 +7,7 @@
using System.Diagnostics;
using System.Reflection;
using System.Windows;
+using PerformanceMonitorDashboard.Services;
namespace PerformanceMonitorDashboard
{
@@ -17,6 +18,8 @@ public partial class AboutWindow : Window
private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases";
private const string DarlingDataUrl = "https://www.erikdarling.com";
+ private string? _updateReleaseUrl;
+
public AboutWindow()
{
InitializeComponent();
@@ -39,9 +42,37 @@ private void ReportIssueLink_Click(object sender, RoutedEventArgs e)
OpenUrl(IssuesUrl);
}
- private void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
+ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
+ {
+ UpdateStatusText.Text = "Checking for updates...";
+ UpdateStatusText.Visibility = Visibility.Visible;
+
+ var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true);
+
+ if (result == null)
+ {
+ UpdateStatusText.Text = "Unable to check for updates. Please try again later.";
+ }
+ else if (result.IsUpdateAvailable)
+ {
+ _updateReleaseUrl = result.ReleaseUrl;
+ UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
+ ?? System.Windows.Media.Brushes.DodgerBlue;
+ }
+ else
+ {
+ UpdateStatusText.Text = $"You're up to date ({result.CurrentVersion})";
+ }
+ }
+
+ private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
- OpenUrl(ReleasesUrl);
+ if (!string.IsNullOrEmpty(_updateReleaseUrl))
+ OpenUrl(_updateReleaseUrl);
}
private void DarlingDataLink_Click(object sender, RoutedEventArgs e)
diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs
index 3e257435..1a17661f 100644
--- a/Dashboard/App.xaml.cs
+++ b/Dashboard/App.xaml.cs
@@ -65,6 +65,7 @@ protected override void OnExit(ExitEventArgs e)
base.OnExit(e);
}
+
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
var exception = e.ExceptionObject as Exception;
diff --git a/Dashboard/Controls/LandingPage.xaml b/Dashboard/Controls/LandingPage.xaml
index 250f2a10..7e99b049 100644
--- a/Dashboard/Controls/LandingPage.xaml
+++ b/Dashboard/Controls/LandingPage.xaml
@@ -113,15 +113,15 @@
Padding="16,8">
-
+
-
+
-
+
diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs
index 0b7e662d..56ffdec8 100644
--- a/Dashboard/Controls/MemoryContent.xaml.cs
+++ b/Dashboard/Controls/MemoryContent.xaml.cs
@@ -213,26 +213,26 @@ private void LoadMemoryStatsOverviewChart(List memoryData, int
var totalScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(totalXs, totalYs);
totalScatter.LineWidth = 2;
- totalScatter.MarkerSize = 0;
- totalScatter.Color = ScottPlot.Colors.Gray;
+ totalScatter.MarkerSize = 5;
+ totalScatter.Color = TabHelpers.ChartColors[9];
totalScatter.LegendText = "Total Memory";
var bufferScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(bufferXs, bufferYs);
bufferScatter.LineWidth = 2;
- bufferScatter.MarkerSize = 0;
- bufferScatter.Color = ScottPlot.Colors.Blue;
+ bufferScatter.MarkerSize = 5;
+ bufferScatter.Color = TabHelpers.ChartColors[0];
bufferScatter.LegendText = "Buffer Pool";
var cacheScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(cacheXs, cacheYs);
cacheScatter.LineWidth = 2;
- cacheScatter.MarkerSize = 0;
- cacheScatter.Color = ScottPlot.Colors.Green;
+ cacheScatter.MarkerSize = 5;
+ cacheScatter.Color = TabHelpers.ChartColors[1];
cacheScatter.LegendText = "Plan Cache";
var availScatter = MemoryStatsOverviewChart.Plot.Add.Scatter(availXs, availYs);
availScatter.LineWidth = 2;
- availScatter.MarkerSize = 0;
- availScatter.Color = ScottPlot.Colors.Orange;
+ availScatter.MarkerSize = 5;
+ availScatter.Color = TabHelpers.ChartColors[2];
availScatter.LegendText = "Available Physical";
_legendPanels[MemoryStatsOverviewChart] = MemoryStatsOverviewChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -282,7 +282,7 @@ private void AddPressureWarningSpans(List dataList)
if (item.BufferPoolPressureWarning && item.PlanCachePressureWarning)
{
- vline.Color = ScottPlot.Colors.Red.WithAlpha(0.5);
+ vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.5);
// Add legend entry for BP pressure (covers "both" case too)
if (!bpLegendAdded)
{
@@ -292,7 +292,7 @@ private void AddPressureWarningSpans(List dataList)
}
else if (item.BufferPoolPressureWarning)
{
- vline.Color = ScottPlot.Colors.Red.WithAlpha(0.3);
+ vline.Color = TabHelpers.ChartColors[3].WithAlpha(0.3);
if (!bpLegendAdded)
{
vline.LegendText = "BP Pressure";
@@ -301,7 +301,7 @@ private void AddPressureWarningSpans(List dataList)
}
else
{
- vline.Color = ScottPlot.Colors.Orange.WithAlpha(0.3);
+ vline.Color = TabHelpers.ChartColors[2].WithAlpha(0.3);
if (!pcLegendAdded)
{
vline.LegendText = "PC Pressure";
@@ -419,14 +419,14 @@ private void LoadMemoryGrantsChart(IEnumerable data, int h
{
var grantedScatter = MemoryGrantsChart.Plot.Add.Scatter(grantedXs, grantedYs);
grantedScatter.LineWidth = 2;
- grantedScatter.MarkerSize = 0;
- grantedScatter.Color = ScottPlot.Colors.Blue;
+ grantedScatter.MarkerSize = 5;
+ grantedScatter.Color = TabHelpers.ChartColors[0];
grantedScatter.LegendText = "Granted MB";
var targetScatter = MemoryGrantsChart.Plot.Add.Scatter(targetXs, targetYs);
targetScatter.LineWidth = 2;
- targetScatter.MarkerSize = 0;
- targetScatter.Color = ScottPlot.Colors.Orange;
+ targetScatter.MarkerSize = 5;
+ targetScatter.Color = TabHelpers.ChartColors[2];
targetScatter.LegendText = "Target MB";
_legendPanels[MemoryGrantsChart] = MemoryGrantsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -502,7 +502,7 @@ private void LoadMemoryClerksChart(List data, int hoursBack, D
.Select(x => x.ClerkType)
.ToList();
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red, ScottPlot.Colors.Purple };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var clerkType in topClerks)
@@ -519,7 +519,7 @@ private void LoadMemoryClerksChart(List data, int hoursBack, D
var scatter = MemoryClerksChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = clerkType.Length > 20 ? clerkType.Substring(0, 20) + "..." : clerkType;
colorIndex++;
@@ -647,8 +647,8 @@ private void LoadPlanCacheChart(IEnumerable data, int hoursB
var singleScatter = PlanCacheChart.Plot.Add.Scatter(singleXs, singleYs);
singleScatter.LineWidth = 2;
- singleScatter.MarkerSize = 0;
- singleScatter.Color = ScottPlot.Colors.Red;
+ singleScatter.MarkerSize = 5;
+ singleScatter.Color = TabHelpers.ChartColors[3];
singleScatter.LegendText = "Single-Use";
// Multi-Use series with gap filling
@@ -658,8 +658,8 @@ private void LoadPlanCacheChart(IEnumerable data, int hoursB
var multiScatter = PlanCacheChart.Plot.Add.Scatter(multiXs, multiYs);
multiScatter.LineWidth = 2;
- multiScatter.MarkerSize = 0;
- multiScatter.Color = ScottPlot.Colors.Green;
+ multiScatter.MarkerSize = 5;
+ multiScatter.Color = TabHelpers.ChartColors[1];
multiScatter.LegendText = "Multi-Use";
_legendPanels[PlanCacheChart] = PlanCacheChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -786,7 +786,7 @@ private void LoadMemoryPressureEventsChart(IEnumerable
var highScatter = MemoryPressureEventsChart.Plot.Add.Scatter(xs, ys);
highScatter.LineWidth = 2;
highScatter.MarkerSize = 5;
- highScatter.Color = ScottPlot.Colors.Red;
+ highScatter.Color = TabHelpers.ChartColors[3];
highScatter.LegendText = "High Pressure Events";
_legendPanels[MemoryPressureEventsChart] = MemoryPressureEventsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs
index c4396979..34509c4e 100644
--- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs
+++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs
@@ -243,9 +243,9 @@ await Task.WhenAll(
QueryStoreNoDataMessage.Visibility = queryStore.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
// Populate charts from time-series data
- LoadDurationChart(QueryPerfTrendsQueryChart, await queryDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", ScottPlot.Colors.Blue);
- LoadDurationChart(QueryPerfTrendsProcChart, await procDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", ScottPlot.Colors.Green);
- LoadDurationChart(QueryPerfTrendsQsChart, await qsDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", ScottPlot.Colors.Purple);
+ LoadDurationChart(QueryPerfTrendsQueryChart, await queryDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[0]);
+ LoadDurationChart(QueryPerfTrendsProcChart, await procDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[1]);
+ LoadDurationChart(QueryPerfTrendsQsChart, await qsDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[4]);
LoadExecChart(await execTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate);
}
catch (Exception ex)
@@ -915,18 +915,13 @@ private void LoadDurationChart(WpfPlot chart, IEnumerable tre
dataList.Select(d => d.CollectionTime),
dataList.Select(d => d.AvgDurationMs));
- if (xs.Length > 0)
- {
- var scatter = chart.Plot.Add.Scatter(xs, ys);
- scatter.LineWidth = 2;
- scatter.MarkerSize = 5;
- scatter.Color = color;
- scatter.LegendText = legendText;
-
- _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
- chart.Plot.Legend.FontSize = 12;
- }
- else
+ var scatter = chart.Plot.Add.Scatter(xs, ys);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 5;
+ scatter.Color = color;
+ scatter.LegendText = legendText;
+
+ if (xs.Length == 0)
{
double xCenter = xMin + (xMax - xMin) / 2;
var noDataText = chart.Plot.Add.Text("No data for selected time range", xCenter, 0.5);
@@ -935,6 +930,9 @@ private void LoadDurationChart(WpfPlot chart, IEnumerable tre
noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter;
}
+ _legendPanels[chart] = chart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
+ chart.Plot.Legend.FontSize = 12;
+
chart.Plot.Axes.DateTimeTicksBottom();
chart.Plot.Axes.SetLimitsX(xMin, xMax);
chart.Plot.YLabel("Duration (ms/sec)");
@@ -971,18 +969,13 @@ private void LoadExecChart(IEnumerable execTrends, int hours
dataList.Select(d => d.CollectionTime),
dataList.Select(d => (double)d.ExecutionsPerSecond));
- if (xs.Length > 0)
- {
- var scatter = QueryPerfTrendsExecChart.Plot.Add.Scatter(xs, ys);
- scatter.LineWidth = 2;
- scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Blue;
- scatter.LegendText = "Executions/sec";
+ var scatter = QueryPerfTrendsExecChart.Plot.Add.Scatter(xs, ys);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[0];
+ scatter.LegendText = "Executions/sec";
- _legendPanels[QueryPerfTrendsExecChart] = QueryPerfTrendsExecChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
- QueryPerfTrendsExecChart.Plot.Legend.FontSize = 12;
- }
- else
+ if (xs.Length == 0)
{
double xCenter = xMin + (xMax - xMin) / 2;
var noDataText = QueryPerfTrendsExecChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5);
@@ -991,6 +984,9 @@ private void LoadExecChart(IEnumerable execTrends, int hours
noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter;
}
+ _legendPanels[QueryPerfTrendsExecChart] = QueryPerfTrendsExecChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
+ QueryPerfTrendsExecChart.Plot.Legend.FontSize = 12;
+
QueryPerfTrendsExecChart.Plot.Axes.DateTimeTicksBottom();
QueryPerfTrendsExecChart.Plot.Axes.SetLimitsX(xMin, xMax);
QueryPerfTrendsExecChart.Plot.YLabel("Executions/sec");
diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/Dashboard/Controls/ResourceMetricsContent.xaml
index 52b06e2c..9ea43c3e 100644
--- a/Dashboard/Controls/ResourceMetricsContent.xaml
+++ b/Dashboard/Controls/ResourceMetricsContent.xaml
@@ -110,8 +110,9 @@
-
+
+
? _allWaitStatsDetailData;
private List? _waitTypeItems;
private bool _isUpdatingWaitTypeSelection = false;
+ private Helpers.ChartHoverHelper? _sessionStatsHover;
+ private Helpers.ChartHoverHelper? _latchStatsHover;
+ private Helpers.ChartHoverHelper? _spinlockStatsHover;
+ private Helpers.ChartHoverHelper? _fileIoReadHover;
+ private Helpers.ChartHoverHelper? _fileIoWriteHover;
+ private Helpers.ChartHoverHelper? _perfmonHover;
+ private Helpers.ChartHoverHelper? _waitStatsHover;
// Column filter popup and state
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
@@ -115,6 +122,14 @@ public ResourceMetricsContent()
InitializeComponent();
SetupChartContextMenus();
Loaded += OnLoaded;
+
+ _sessionStatsHover = new Helpers.ChartHoverHelper(SessionStatsChart, "sessions");
+ _latchStatsHover = new Helpers.ChartHoverHelper(LatchStatsChart, "ms/sec");
+ _spinlockStatsHover = new Helpers.ChartHoverHelper(SpinlockStatsChart, "collisions/sec");
+ _fileIoReadHover = new Helpers.ChartHoverHelper(UserDbReadLatencyChart, "ms");
+ _fileIoWriteHover = new Helpers.ChartHoverHelper(UserDbWriteLatencyChart, "ms");
+ _perfmonHover = new Helpers.ChartHoverHelper(PerfmonCountersChart, "");
+ _waitStatsHover = new Helpers.ChartHoverHelper(WaitStatsDetailChart, "ms/sec");
}
private void OnLoaded(object sender, RoutedEventArgs e)
@@ -277,6 +292,7 @@ private void LoadLatchStatsChart(IEnumerable data, int hoursBack
}
LatchStatsChart.Plot.Clear();
TabHelpers.ApplyDarkModeToChart(LatchStatsChart);
+ _latchStatsHover?.Clear();
var dataList = data?.ToList() ?? new List();
if (dataList.Count > 0)
@@ -289,7 +305,7 @@ private void LoadLatchStatsChart(IEnumerable data, int hoursBack
.Select(x => x.LatchClass)
.ToList();
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red, ScottPlot.Colors.Purple };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var latchClass in topLatches)
@@ -306,9 +322,10 @@ private void LoadLatchStatsChart(IEnumerable data, int hoursBack
var scatter = LatchStatsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = latchClass?.Length > 20 ? latchClass.Substring(0, 20) + "..." : latchClass ?? "";
+ _latchStatsHover?.Add(scatter, latchClass ?? "");
colorIndex++;
}
}
@@ -367,6 +384,7 @@ private void LoadSpinlockStatsChart(IEnumerable data, int hou
}
SpinlockStatsChart.Plot.Clear();
TabHelpers.ApplyDarkModeToChart(SpinlockStatsChart);
+ _spinlockStatsHover?.Clear();
var dataList = data?.ToList() ?? new List();
if (dataList.Count > 0)
@@ -379,7 +397,7 @@ private void LoadSpinlockStatsChart(IEnumerable data, int hou
.Select(x => x.SpinlockName)
.ToList();
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red, ScottPlot.Colors.Purple };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var spinlock in topSpinlocks)
@@ -396,9 +414,10 @@ private void LoadSpinlockStatsChart(IEnumerable data, int hou
var scatter = SpinlockStatsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = spinlock?.Length > 20 ? spinlock.Substring(0, 20) + "..." : spinlock ?? "";
+ _spinlockStatsHover?.Add(scatter, spinlock ?? "");
colorIndex++;
}
}
@@ -494,8 +513,8 @@ private void LoadCombinedTempDbLatencyChart(List da
aggregated.Select(d => d.AvgReadLatency));
var readScatter = TempDbLatencyChart.Plot.Add.Scatter(readXs, readYs);
readScatter.LineWidth = 2;
- readScatter.MarkerSize = 0;
- readScatter.Color = ScottPlot.Colors.Blue;
+ readScatter.MarkerSize = 5;
+ readScatter.Color = TabHelpers.ChartColors[0];
readScatter.LegendText = "Read Latency";
// Write Latency series
@@ -504,8 +523,8 @@ private void LoadCombinedTempDbLatencyChart(List da
aggregated.Select(d => d.AvgWriteLatency));
var writeScatter = TempDbLatencyChart.Plot.Add.Scatter(writeXs, writeYs);
writeScatter.LineWidth = 2;
- writeScatter.MarkerSize = 0;
- writeScatter.Color = ScottPlot.Colors.Orange;
+ writeScatter.MarkerSize = 5;
+ writeScatter.Color = TabHelpers.ChartColors[2];
writeScatter.LegendText = "Write Latency";
// Store legend panel reference for removal on refresh (ScottPlot issue #4717)
@@ -554,8 +573,8 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa
dataList.Select(d => (double)d.UserObjectReservedMb));
var userScatter = TempdbStatsChart.Plot.Add.Scatter(userXs, userYs);
userScatter.LineWidth = 2;
- userScatter.MarkerSize = 0;
- userScatter.Color = ScottPlot.Colors.Blue;
+ userScatter.MarkerSize = 5;
+ userScatter.Color = TabHelpers.ChartColors[0];
userScatter.LegendText = "User Objects";
// Version Store series
@@ -564,8 +583,8 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa
dataList.Select(d => (double)d.VersionStoreReservedMb));
var versionScatter = TempdbStatsChart.Plot.Add.Scatter(versionXs, versionYs);
versionScatter.LineWidth = 2;
- versionScatter.MarkerSize = 0;
- versionScatter.Color = ScottPlot.Colors.Green;
+ versionScatter.MarkerSize = 5;
+ versionScatter.Color = TabHelpers.ChartColors[1];
versionScatter.LegendText = "Version Store";
// Internal Objects series
@@ -574,8 +593,8 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa
dataList.Select(d => (double)d.InternalObjectReservedMb));
var internalScatter = TempdbStatsChart.Plot.Add.Scatter(internalXs, internalYs);
internalScatter.LineWidth = 2;
- internalScatter.MarkerSize = 0;
- internalScatter.Color = ScottPlot.Colors.Orange;
+ internalScatter.MarkerSize = 5;
+ internalScatter.Color = TabHelpers.ChartColors[2];
internalScatter.LegendText = "Internal Objects";
// Unallocated (free space) series
@@ -586,8 +605,8 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa
{
var unallocScatter = TempdbStatsChart.Plot.Add.Scatter(unallocXs, unallocYs);
unallocScatter.LineWidth = 2;
- unallocScatter.MarkerSize = 0;
- unallocScatter.Color = ScottPlot.Colors.Gray;
+ unallocScatter.MarkerSize = 5;
+ unallocScatter.Color = TabHelpers.ChartColors[9];
unallocScatter.LegendText = "Unallocated";
}
@@ -600,8 +619,8 @@ private void LoadTempdbStatsChart(IEnumerable data, int hoursBa
topTaskValues);
var topTaskScatter = TempdbStatsChart.Plot.Add.Scatter(topTaskXs, topTaskYs);
topTaskScatter.LineWidth = 2;
- topTaskScatter.MarkerSize = 0;
- topTaskScatter.Color = ScottPlot.Colors.Red;
+ topTaskScatter.MarkerSize = 5;
+ topTaskScatter.Color = TabHelpers.ChartColors[3];
topTaskScatter.LegendText = "Top Task";
}
@@ -686,6 +705,7 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
}
SessionStatsChart.Plot.Clear();
TabHelpers.ApplyDarkModeToChart(SessionStatsChart);
+ _sessionStatsHover?.Clear();
var dataList = data?.OrderBy(d => d.CollectionTime).ToList() ?? new List();
if (dataList.Count > 0)
@@ -700,9 +720,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, totalCounts.Select(c => c));
var totalScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
totalScatter.LineWidth = 2;
- totalScatter.MarkerSize = 0;
- totalScatter.Color = ScottPlot.Colors.Blue;
+ totalScatter.MarkerSize = 5;
+ totalScatter.Color = TabHelpers.ChartColors[0];
totalScatter.LegendText = "Total";
+ _sessionStatsHover?.Add(totalScatter, "Total");
}
if (runningCounts.Any(c => c > 0))
@@ -710,9 +731,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, runningCounts.Select(c => c));
var runningScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
runningScatter.LineWidth = 2;
- runningScatter.MarkerSize = 0;
- runningScatter.Color = ScottPlot.Colors.Green;
+ runningScatter.MarkerSize = 5;
+ runningScatter.Color = TabHelpers.ChartColors[1];
runningScatter.LegendText = "Running";
+ _sessionStatsHover?.Add(runningScatter, "Running");
}
if (sleepingCounts.Any(c => c > 0))
@@ -720,9 +742,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, sleepingCounts.Select(c => c));
var sleepingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
sleepingScatter.LineWidth = 2;
- sleepingScatter.MarkerSize = 0;
- sleepingScatter.Color = ScottPlot.Colors.Orange;
+ sleepingScatter.MarkerSize = 5;
+ sleepingScatter.Color = TabHelpers.ChartColors[2];
sleepingScatter.LegendText = "Sleeping";
+ _sessionStatsHover?.Add(sleepingScatter, "Sleeping");
}
double[] backgroundCounts = dataList.Select(d => (double)d.BackgroundSessions).ToArray();
@@ -731,9 +754,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, backgroundCounts.Select(c => c));
var backgroundScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
backgroundScatter.LineWidth = 2;
- backgroundScatter.MarkerSize = 0;
- backgroundScatter.Color = ScottPlot.Colors.Purple;
+ backgroundScatter.MarkerSize = 5;
+ backgroundScatter.Color = TabHelpers.ChartColors[4];
backgroundScatter.LegendText = "Background";
+ _sessionStatsHover?.Add(backgroundScatter, "Background");
}
double[] dormantCounts = dataList.Select(d => (double)d.DormantSessions).ToArray();
@@ -742,9 +766,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, dormantCounts.Select(c => c));
var dormantScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
dormantScatter.LineWidth = 2;
- dormantScatter.MarkerSize = 0;
- dormantScatter.Color = ScottPlot.Colors.Cyan;
+ dormantScatter.MarkerSize = 5;
+ dormantScatter.Color = TabHelpers.ChartColors[5];
dormantScatter.LegendText = "Dormant";
+ _sessionStatsHover?.Add(dormantScatter, "Dormant");
}
double[] idleOver30MinCounts = dataList.Select(d => (double)d.IdleSessionsOver30Min).ToArray();
@@ -753,9 +778,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, idleOver30MinCounts.Select(c => c));
var idleScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
idleScatter.LineWidth = 2;
- idleScatter.MarkerSize = 0;
- idleScatter.Color = ScottPlot.Colors.Gray;
+ idleScatter.MarkerSize = 5;
+ idleScatter.Color = TabHelpers.ChartColors[9];
idleScatter.LegendText = "Idle >30m";
+ _sessionStatsHover?.Add(idleScatter, "Idle >30m");
}
double[] waitingForMemoryCounts = dataList.Select(d => (double)d.SessionsWaitingForMemory).ToArray();
@@ -764,9 +790,10 @@ private void LoadSessionStatsChart(IEnumerable data, int hours
var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, waitingForMemoryCounts.Select(c => c));
var waitingScatter = SessionStatsChart.Plot.Add.Scatter(xs, ys);
waitingScatter.LineWidth = 2;
- waitingScatter.MarkerSize = 0;
- waitingScatter.Color = ScottPlot.Colors.Red;
+ waitingScatter.MarkerSize = 5;
+ waitingScatter.Color = TabHelpers.ChartColors[3];
waitingScatter.LegendText = "Waiting for Memory";
+ _sessionStatsHover?.Add(waitingScatter, "Waiting for Memory");
}
// Update summary panel with latest data point
@@ -828,15 +855,15 @@ private async Task LoadFileIoLatencyChartsAsync()
double xMin = rangeStart.ToOADate();
double xMax = rangeEnd.ToOADate();
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red, ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta, ScottPlot.Colors.DarkGreen, ScottPlot.Colors.Navy, ScottPlot.Colors.Brown };
+ var colors = TabHelpers.ChartColors;
// Load User DB data only - TempDB latency moved to TempDB Stats tab
var userDbData = await _databaseService.GetFileIoLatencyTimeSeriesAsync(isTempDb: false, _fileIoHoursBack, _fileIoFromDate, _fileIoToDate);
- LoadFileIoChart(UserDbReadLatencyChart, userDbData, d => d.ReadLatencyMs, "Read Latency (ms)", colors, xMin, xMax);
- LoadFileIoChart(UserDbWriteLatencyChart, userDbData, d => d.WriteLatencyMs, "Write Latency (ms)", colors, xMin, xMax);
+ LoadFileIoChart(UserDbReadLatencyChart, userDbData, d => d.ReadLatencyMs, "Read Latency (ms)", colors, xMin, xMax, _fileIoReadHover);
+ LoadFileIoChart(UserDbWriteLatencyChart, userDbData, d => d.WriteLatencyMs, "Write Latency (ms)", colors, xMin, xMax, _fileIoWriteHover);
}
- private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List data, Func latencySelector, string yLabel, ScottPlot.Color[] colors, double xMin, double xMax)
+ private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List data, Func latencySelector, string yLabel, ScottPlot.Color[] colors, double xMin, double xMax, Helpers.ChartHoverHelper? hover = null)
{
DateTime rangeStart = DateTime.FromOADate(xMin);
DateTime rangeEnd = DateTime.FromOADate(xMax);
@@ -849,6 +876,7 @@ private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List 0)
{
@@ -872,11 +900,13 @@ private void LoadFileIoChart(ScottPlot.WPF.WpfPlot chart, List cpuData, int hou
dataList.Select(d => (double)d.SqlServerCpu));
var sqlScatter = ServerUtilTrendsCpuChart.Plot.Add.Scatter(sqlXs, sqlYs);
sqlScatter.LineWidth = 2;
- sqlScatter.MarkerSize = 0;
- sqlScatter.Color = ScottPlot.Colors.Blue;
+ sqlScatter.MarkerSize = 5;
+ sqlScatter.Color = TabHelpers.ChartColors[0];
sqlScatter.LegendText = "SQL CPU";
// Other CPU series
@@ -970,8 +1000,8 @@ private void LoadServerTrendsCpuChart(IEnumerable cpuData, int hou
dataList.Select(d => (double)d.OtherProcessCpu));
var otherScatter = ServerUtilTrendsCpuChart.Plot.Add.Scatter(otherXs, otherYs);
otherScatter.LineWidth = 2;
- otherScatter.MarkerSize = 0;
- otherScatter.Color = ScottPlot.Colors.Orange;
+ otherScatter.MarkerSize = 5;
+ otherScatter.Color = TabHelpers.ChartColors[2];
otherScatter.LegendText = "Other CPU";
_legendPanels[ServerUtilTrendsCpuChart] = ServerUtilTrendsCpuChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -1023,14 +1053,14 @@ private void LoadServerTrendsTempdbChart(IEnumerable tempdbData
var userScatter = ServerUtilTrendsTempdbChart.Plot.Add.Scatter(userXs, userYs);
userScatter.LineWidth = 2;
- userScatter.MarkerSize = 0;
- userScatter.Color = ScottPlot.Colors.Green;
+ userScatter.MarkerSize = 5;
+ userScatter.Color = TabHelpers.ChartColors[1];
userScatter.LegendText = "User Objects";
var versionScatter = ServerUtilTrendsTempdbChart.Plot.Add.Scatter(versionXs, versionYs);
versionScatter.LineWidth = 2;
- versionScatter.MarkerSize = 0;
- versionScatter.Color = ScottPlot.Colors.Orange;
+ versionScatter.MarkerSize = 5;
+ versionScatter.Color = TabHelpers.ChartColors[2];
versionScatter.LegendText = "Version Store";
_legendPanels[ServerUtilTrendsTempdbChart] = ServerUtilTrendsTempdbChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -1118,14 +1148,14 @@ private void LoadServerTrendsMemoryChart(IEnumerable memoryData
var bufferScatter = ServerUtilTrendsMemoryChart.Plot.Add.Scatter(bufferXs, bufferYs);
bufferScatter.LineWidth = 2;
- bufferScatter.MarkerSize = 0;
- bufferScatter.Color = ScottPlot.Colors.Purple;
+ bufferScatter.MarkerSize = 5;
+ bufferScatter.Color = TabHelpers.ChartColors[4];
bufferScatter.LegendText = "Buffer Pool";
var cacheScatter = ServerUtilTrendsMemoryChart.Plot.Add.Scatter(cacheXs, cacheYs);
cacheScatter.LineWidth = 2;
- cacheScatter.MarkerSize = 0;
- cacheScatter.Color = ScottPlot.Colors.Cyan;
+ cacheScatter.MarkerSize = 5;
+ cacheScatter.Color = TabHelpers.ChartColors[5];
cacheScatter.LegendText = "Plan Cache";
_legendPanels[ServerUtilTrendsMemoryChart] = ServerUtilTrendsMemoryChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -1176,10 +1206,10 @@ private void LoadServerTrendsPerfmonChart(IEnumerable perfmonD
// Counters to display
var countersToShow = new[] {
- ("Batch Requests/sec", ScottPlot.Colors.Blue),
- ("SQL Compilations/sec", ScottPlot.Colors.Orange),
- ("SQL Re-Compilations/sec", ScottPlot.Colors.Red),
- ("Optimizer Statistics", ScottPlot.Colors.Green)
+ ("Batch Requests/sec", TabHelpers.ChartColors[0]),
+ ("SQL Compilations/sec", TabHelpers.ChartColors[2]),
+ ("SQL Re-Compilations/sec", TabHelpers.ChartColors[3]),
+ ("Optimizer Statistics", TabHelpers.ChartColors[1])
};
// Get all time points across all counters for gap filling
@@ -1201,7 +1231,7 @@ private void LoadServerTrendsPerfmonChart(IEnumerable perfmonD
var scatter = ServerUtilTrendsPerfmonChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = color;
scatter.LegendText = counterName.Replace("/sec", "", StringComparison.Ordinal);
linesAdded++;
@@ -1662,6 +1692,7 @@ private void LoadPerfmonCountersChart(List? data, int hoursBac
}
PerfmonCountersChart.Plot.Clear();
TabHelpers.ApplyDarkModeToChart(PerfmonCountersChart);
+ _perfmonHover?.Clear();
if (data == null || data.Count == 0 || _perfmonCounterItems == null)
{
@@ -1677,11 +1708,7 @@ private void LoadPerfmonCountersChart(List? data, int hoursBac
return;
}
- var colors = new[] {
- ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red,
- ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta, ScottPlot.Colors.DarkGreen,
- ScottPlot.Colors.Navy, ScottPlot.Colors.Brown, ScottPlot.Colors.Teal, ScottPlot.Colors.Olive
- };
+ var colors = TabHelpers.ChartColors;
// Get all time points across all counters for gap filling
int colorIndex = 0;
@@ -1706,9 +1733,10 @@ private void LoadPerfmonCountersChart(List? data, int hoursBac
var scatter = PerfmonCountersChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 3; // Show small markers to ensure visibility
+ scatter.MarkerSize = 5; // Show small markers to ensure visibility
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = counter.CounterName;
+ _perfmonHover?.Add(scatter, counter.CounterName);
colorIndex++;
}
@@ -1862,6 +1890,17 @@ private void RefreshWaitTypeListOrder()
.ToList();
_waitTypeItems = sorted;
ApplyWaitTypeSearchFilter();
+ UpdateWaitTypeCount();
+ }
+
+ private void UpdateWaitTypeCount()
+ {
+ if (_waitTypeItems == null || WaitTypeCountText == null) return;
+ int count = _waitTypeItems.Count(x => x.IsSelected);
+ WaitTypeCountText.Text = $"{count} / 20 selected";
+ WaitTypeCountText.Foreground = count >= 20
+ ? new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#E57373")!)
+ : (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush");
}
private void WaitTypeSearch_TextChanged(object sender, TextChangedEventArgs e)
@@ -1897,19 +1936,10 @@ private async void WaitTypes_SelectAll_Click(object sender, RoutedEventArgs e)
{
if (_waitTypeItems == null) return;
_isUpdatingWaitTypeSelection = true;
- // Only select first 12 (color limit)
- int count = 0;
+ var topWaits = TabHelpers.GetDefaultWaitTypes(_waitTypeItems.Select(x => x.WaitType).ToList());
foreach (var item in _waitTypeItems)
{
- if (count < 12)
- {
- item.IsSelected = true;
- count++;
- }
- else
- {
- item.IsSelected = false;
- }
+ item.IsSelected = topWaits.Contains(item.WaitType);
}
_isUpdatingWaitTypeSelection = false;
RefreshWaitTypeListOrder();
@@ -1925,6 +1955,7 @@ private async void WaitTypes_ClearAll_Click(object sender, RoutedEventArgs e)
item.IsSelected = false;
}
_isUpdatingWaitTypeSelection = false;
+ RefreshWaitTypeListOrder();
await UpdateWaitStatsDetailChartAsync();
}
@@ -1961,22 +1992,14 @@ private async Task RefreshWaitStatsDetailTabAsync()
})
.ToList();
- // If nothing was previously selected, default select some common wait types
+ // If nothing was previously selected, apply poison waits + usual suspects + top 10
if (!waitTypes.Any(w => w.IsSelected))
{
- var defaultWaitTypes = new[] { "CXPACKET", "SOS_SCHEDULER_YIELD", "PAGEIOLATCH_SH", "LCK_M_X", "ASYNC_NETWORK_IO", "WRITELOG" };
- foreach (var item in waitTypes.Where(w => defaultWaitTypes.Contains(w.WaitType)))
+ var topWaits = TabHelpers.GetDefaultWaitTypes(waitTypes.Select(w => w.WaitType).ToList());
+ foreach (var item in waitTypes.Where(w => topWaits.Contains(w.WaitType)))
{
item.IsSelected = true;
}
- // If none of the defaults exist, select the top 5
- if (!waitTypes.Any(w => w.IsSelected) && waitTypes.Count > 0)
- {
- foreach (var item in waitTypes.Take(5))
- {
- item.IsSelected = true;
- }
- }
}
_waitTypeItems = waitTypes;
@@ -2029,6 +2052,7 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB
}
WaitStatsDetailChart.Plot.Clear();
TabHelpers.ApplyDarkModeToChart(WaitStatsDetailChart);
+ _waitStatsHover?.Clear();
if (data == null || data.Count == 0 || _waitTypeItems == null)
{
@@ -2044,15 +2068,11 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB
return;
}
- var colors = new[] {
- ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red,
- ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta, ScottPlot.Colors.DarkGreen,
- ScottPlot.Colors.Navy, ScottPlot.Colors.Brown, ScottPlot.Colors.Teal, ScottPlot.Colors.Olive
- };
+ var colors = TabHelpers.ChartColors;
// Get all time points across all wait types for gap filling
int colorIndex = 0;
- foreach (var waitType in selectedWaitTypes.Take(12)) // Limit to 12 wait types
+ foreach (var waitType in selectedWaitTypes.Take(20)) // Limit to 20 wait types
{
// Get data for this wait type
var waitTypeData = data
@@ -2073,7 +2093,7 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB
var scatter = WaitStatsDetailChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 3;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
// Truncate legend text if too long
@@ -2081,6 +2101,7 @@ private void LoadWaitStatsDetailChart(List? data, int hoursB
if (legendText.Length > 25)
legendText = legendText.Substring(0, 22) + "...";
scatter.LegendText = legendText;
+ _waitStatsHover?.Add(scatter, waitType.WaitType);
colorIndex++;
}
diff --git a/Dashboard/Controls/ServerHealthCard.xaml b/Dashboard/Controls/ServerHealthCard.xaml
index 3c07ea3c..b599da5a 100644
--- a/Dashboard/Controls/ServerHealthCard.xaml
+++ b/Dashboard/Controls/ServerHealthCard.xaml
@@ -214,11 +214,11 @@
diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs
index 4294939c..f9070bd9 100644
--- a/Dashboard/Controls/SystemEventsContent.xaml.cs
+++ b/Dashboard/Controls/SystemEventsContent.xaml.cs
@@ -417,8 +417,8 @@ private void LoadCorruptionEventsCharts(List data,
orderedData.Select(d => (double)(d.BadPagesDetected ?? 0)));
var scatter = BadPagesChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[3];
}
else
{
@@ -445,8 +445,8 @@ private void LoadCorruptionEventsCharts(List data,
orderedData.Select(d => (double)(d.IntervalDumpRequests ?? 0)));
var scatter = DumpRequestsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[2];
}
else
{
@@ -473,8 +473,8 @@ private void LoadCorruptionEventsCharts(List data,
orderedData.Select(d => (double)(d.IsAccessViolationOccurred ?? 0)));
var scatter = AccessViolationsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Purple;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[4];
}
else
{
@@ -501,8 +501,8 @@ private void LoadCorruptionEventsCharts(List data,
orderedData.Select(d => (double)(d.WriteAccessViolationCount ?? 0)));
var scatter = WriteAccessViolationsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Blue;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[0];
}
else
{
@@ -541,8 +541,8 @@ private void LoadContentionEventsCharts(List data,
orderedData.Select(d => (double)(d.NonYieldingTasksReported ?? 0)));
var scatter = NonYieldingTasksChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[3];
}
else
{
@@ -569,8 +569,8 @@ private void LoadContentionEventsCharts(List data,
orderedData.Select(d => (double)(d.LatchWarnings ?? 0)));
var scatter = LatchWarningsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[2];
}
else
{
@@ -605,7 +605,7 @@ private void LoadContentionEventsCharts(List data,
.Take(5) // Limit to top 5 types to avoid chart clutter
.ToList();
- var colors = new[] { ScottPlot.Colors.Purple, ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var spinlockType in spinlockTypes)
@@ -621,7 +621,7 @@ private void LoadContentionEventsCharts(List data,
typeData.Select(d => (double)(d.SpinlockBackoffs ?? 1))); // Use backoffs count or 1 if null
var scatter = SickSpinlocksChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = spinlockType ?? "Unknown";
colorIndex++;
@@ -665,8 +665,8 @@ private void LoadContentionEventsCharts(List data,
orderedData.Select(d => (double)(d.SystemCpuUtilization ?? 0)));
var sysScatter = CpuComparisonChart.Plot.Add.Scatter(sysXs, sysYs);
sysScatter.LineWidth = 2;
- sysScatter.MarkerSize = 4;
- sysScatter.Color = ScottPlot.Colors.Blue;
+ sysScatter.MarkerSize = 5;
+ sysScatter.Color = TabHelpers.ChartColors[0];
sysScatter.LegendText = "System CPU %";
// SQL CPU series
@@ -675,8 +675,8 @@ private void LoadContentionEventsCharts(List data,
orderedData.Select(d => (double)(d.SqlCpuUtilization ?? 0)));
var sqlScatter = CpuComparisonChart.Plot.Add.Scatter(sqlXs, sqlYs);
sqlScatter.LineWidth = 2;
- sqlScatter.MarkerSize = 4;
- sqlScatter.Color = ScottPlot.Colors.Green;
+ sqlScatter.MarkerSize = 5;
+ sqlScatter.Color = TabHelpers.ChartColors[1];
sqlScatter.LegendText = "SQL CPU %";
_legendPanels[CpuComparisonChart] = CpuComparisonChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -773,7 +773,7 @@ private void LoadSevereErrorsChart(IEnumerable data
var scatter = SevereErrorsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.Color = TabHelpers.ChartColors[3];
scatter.LegendText = "Error Count";
_legendPanels[SevereErrorsChart] = SevereErrorsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -919,7 +919,7 @@ private void LoadIOIssuesChart(IEnumerable data, int ho
var scatter = IOIssuesChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.Color = TabHelpers.ChartColors[3];
scatter.LegendText = "Latch Timeouts";
}
@@ -929,7 +929,7 @@ private void LoadIOIssuesChart(IEnumerable data, int ho
var scatter = IOIssuesChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.Color = TabHelpers.ChartColors[2];
scatter.LegendText = "Long IOs";
}
@@ -992,7 +992,7 @@ private void LoadLongestPendingIOChart(IEnumerable data
if (filePathGroups.Count > 0)
{
hasData = true;
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var group in filePathGroups)
@@ -1121,7 +1121,7 @@ long ParseNonYield(string? value)
var scatter = SchedulerIssuesChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.Color = TabHelpers.ChartColors[2];
scatter.LegendText = "Total Non-Yield Time";
_legendPanels[SchedulerIssuesChart] = SchedulerIssuesChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -1268,7 +1268,7 @@ private void LoadMemoryConditionsChart(IEnumerable data, int h
var scatter = CPUTasksChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Blue;
+ scatter.Color = TabHelpers.ChartColors[0];
scatter.LegendText = "Workers Created";
// Max Workers threshold line (horizontal)
@@ -1374,7 +1374,7 @@ private void LoadCPUTasksChart(IEnumerable data, int h
if (maxWorkersValue > 0)
{
var hLine = CPUTasksChart.Plot.Add.HorizontalLine(maxWorkersValue);
- hLine.Color = ScottPlot.Colors.Orange;
+ hLine.Color = TabHelpers.ChartColors[2];
hLine.LineWidth = 2;
hLine.LinePattern = ScottPlot.LinePattern.Dashed;
hLine.LegendText = $"Max Workers ({maxWorkersValue})";
@@ -1394,7 +1394,7 @@ private void LoadCPUTasksChart(IEnumerable data, int h
var dlYs = unresolvableDLByHour.Select(b => 0.0).ToArray();
var dlScatter = CPUTasksChart.Plot.Add.Scatter(dlXs, dlYs);
dlScatter.LineWidth = 0;
- dlScatter.Color = ScottPlot.Colors.Red;
+ dlScatter.Color = TabHelpers.ChartColors[3];
dlScatter.LegendText = "Unresolvable DL";
dlScatter.MarkerSize = 10;
dlScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle;
@@ -1413,7 +1413,7 @@ private void LoadCPUTasksChart(IEnumerable data, int h
var schedYs = schedDLByHour.Select(b => 0.0).ToArray();
var schedScatter = CPUTasksChart.Plot.Add.Scatter(schedXs, schedYs);
schedScatter.LineWidth = 0;
- schedScatter.Color = ScottPlot.Colors.Orange;
+ schedScatter.Color = TabHelpers.ChartColors[2];
schedScatter.LegendText = "Sched Deadlock";
schedScatter.MarkerSize = 10;
schedScatter.MarkerShape = ScottPlot.MarkerShape.FilledCircle;
@@ -1433,7 +1433,7 @@ private void LoadCPUTasksChart(IEnumerable data, int h
var blockingYs = blockingByHour.Select(b => 0.0).ToArray(); // At bottom
var blockingScatter = CPUTasksChart.Plot.Add.Scatter(blockingXs, blockingYs);
blockingScatter.LineWidth = 0; // No connecting line
- blockingScatter.Color = ScottPlot.Colors.Yellow;
+ blockingScatter.Color = TabHelpers.ChartColors[6];
blockingScatter.LegendText = "Blocking";
// Size points based on count - min 8, max 20, scaled by count
var maxCount = blockingByHour.Max(b => b.Count);
@@ -1566,7 +1566,7 @@ private void LoadMemoryBrokerChart(IEnumerable dat
if (dataList.Count > 0)
{
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Red, ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan, ScottPlot.Colors.Magenta, ScottPlot.Colors.DarkGreen };
+ var colors = TabHelpers.ChartColors;
/* Chart 1: Currently Allocated by Broker */
var brokerGroups = dataList
@@ -1587,7 +1587,7 @@ private void LoadMemoryBrokerChart(IEnumerable dat
var scatter = MemoryBrokerChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = brokerGroup.Key.Length > 25 ? brokerGroup.Key.Substring(0, 25) + "..." : brokerGroup.Key;
colorIndex++;
@@ -1613,8 +1613,8 @@ private void LoadMemoryBrokerChart(IEnumerable dat
var scatter = MemoryBrokerRatioChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
- scatter.Color = ScottPlot.Colors.Blue;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[0];
scatter.LegendText = "Memory Ratio";
}
@@ -1627,8 +1627,8 @@ private void LoadMemoryBrokerChart(IEnumerable dat
var scatter = MemoryBrokerRatioChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[2];
scatter.LegendText = "Overall";
}
@@ -1795,7 +1795,7 @@ private void LoadMemoryNodeOOMChart(IEnumerable d
var scatter = MemoryNodeOOMChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
scatter.MarkerSize = 5;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.Color = TabHelpers.ChartColors[3];
scatter.LegendText = "OOM Event Count";
_legendPanels[MemoryNodeOOMChart] = MemoryNodeOOMChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -1847,8 +1847,8 @@ private void LoadMemoryNodeOOMUtilChart(IEnumerable (double)d.TargetKb!.Value / 1024.0).ToArray();
var scatter = MemoryNodeOOMMemoryChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Green;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[1];
scatter.LegendText = "Target";
}
@@ -1911,8 +1911,8 @@ private void LoadMemoryNodeOOMMemoryChart(IEnumerable (double)d.CommittedKb!.Value / 1024.0).ToArray();
var scatter = MemoryNodeOOMMemoryChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[2];
scatter.LegendText = "Committed";
}
@@ -1925,8 +1925,8 @@ private void LoadMemoryNodeOOMMemoryChart(IEnumerable (double)d.TotalPageFileKb!.Value / 1024.0).ToArray();
var scatter = MemoryNodeOOMMemoryChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Purple;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[4];
scatter.LegendText = "Total Page File";
}
@@ -1939,8 +1939,8 @@ private void LoadMemoryNodeOOMMemoryChart(IEnumerable (double)d.AvailablePageFileKb!.Value / 1024.0).ToArray();
var scatter = MemoryNodeOOMMemoryChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Cyan;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[5];
scatter.LegendText = "Avail Page File";
}
@@ -1970,9 +1970,9 @@ private void LoadMemoryNodeOOMMemoryChart(IEnumerable data)
{
// Indicator colors
- var healthyBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#22C55E"));
- var warningBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#F59E0B"));
- var criticalBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#EF4444"));
+ var healthyBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#81C784"));
+ var warningBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#FFD54F"));
+ var criticalBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#E57373"));
var unknownBrush = new System.Windows.Media.SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString("#888888"));
var dataList = data?.ToList() ?? new List();
diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj
index e1ecd936..9c3d2a7f 100644
--- a/Dashboard/Dashboard.csproj
+++ b/Dashboard/Dashboard.csproj
@@ -10,6 +10,7 @@
Darling Data, LLC
Copyright © 2026 Darling Data, LLC
EDD.ico
+ app.manifest
true
latest-recommended
diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs
new file mode 100644
index 00000000..747c5d55
--- /dev/null
+++ b/Dashboard/Helpers/ChartHoverHelper.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace PerformanceMonitorDashboard.Helpers;
+
+///
+/// Adds mouse-hover tooltips to a ScottPlot chart with multiple scatter series.
+/// Shows the series name, value, and timestamp in a popup that follows the mouse.
+///
+internal sealed class ChartHoverHelper
+{
+ private readonly ScottPlot.WPF.WpfPlot _chart;
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
+ private readonly Popup _popup;
+ private readonly TextBlock _text;
+ private readonly string _unit;
+ private DateTime _lastUpdate;
+
+ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
+ {
+ _chart = chart;
+ _unit = unit;
+
+ _text = new TextBlock
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 13
+ };
+
+ _popup = new Popup
+ {
+ PlacementTarget = chart,
+ Placement = PlacementMode.Relative,
+ IsHitTestVisible = false,
+ AllowsTransparency = true,
+ Child = new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(3),
+ Padding = new Thickness(8, 4, 8, 4),
+ Child = _text
+ }
+ };
+
+ chart.MouseMove += OnMouseMove;
+ chart.MouseLeave += OnMouseLeave;
+ }
+
+ public void Clear() => _scatters.Clear();
+
+ public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
+ _scatters.Add((scatter, label));
+
+ private void OnMouseMove(object sender, MouseEventArgs e)
+ {
+ if (_scatters.Count == 0) return;
+ var now = DateTime.UtcNow;
+ if ((now - _lastUpdate).TotalMilliseconds < 50) return;
+ _lastUpdate = now;
+
+ var pos = e.GetPosition(_chart);
+ var pixel = new ScottPlot.Pixel(
+ (float)(pos.X * _chart.DisplayScale),
+ (float)(pos.Y * _chart.DisplayScale));
+ var mouseCoords = _chart.Plot.GetCoordinates(pixel);
+
+ double bestDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestLabel = "";
+
+ foreach (var (scatter, label) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
+ if (nearest.IsReal)
+ {
+ var nearestPixel = _chart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = nearestPixel.X - pixel.X;
+ double dy = nearestPixel.Y - pixel.Y;
+ double dist = dx * dx + dy * dy;
+ if (dist < bestDistance)
+ {
+ bestDistance = dist;
+ bestPoint = nearest;
+ bestLabel = label;
+ }
+ }
+ }
+
+ if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius
+ {
+ var time = DateTime.FromOADate(bestPoint.X);
+ _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}";
+ _popup.HorizontalOffset = pos.X + 15;
+ _popup.VerticalOffset = pos.Y + 15;
+ _popup.IsOpen = true;
+ }
+ else
+ {
+ _popup.IsOpen = false;
+ }
+ }
+
+ private void OnMouseLeave(object sender, MouseEventArgs e)
+ {
+ _popup.IsOpen = false;
+ }
+}
diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/Dashboard/Helpers/DateFilterHelper.cs
index a49873c5..b9b5e4f4 100644
--- a/Dashboard/Helpers/DateFilterHelper.cs
+++ b/Dashboard/Helpers/DateFilterHelper.cs
@@ -138,13 +138,13 @@ private static bool TryConvertToDateTime(object value, out DateTime result)
switch (expressionLower)
{
case "today":
- return DateTime.Today;
+ return ServerTimeHelper.ServerNow.Date;
case "yesterday":
- return DateTime.Today.AddDays(-1);
+ return ServerTimeHelper.ServerNow.Date.AddDays(-1);
case "tomorrow":
- return DateTime.Today.AddDays(1);
+ return ServerTimeHelper.ServerNow.Date.AddDays(1);
case "now":
- return DateTime.Now;
+ return ServerTimeHelper.ServerNow;
}
// "last N hours/days/weeks" expressions
@@ -155,13 +155,13 @@ private static bool TryConvertToDateTime(object value, out DateTime result)
string unit = lastMatch.Groups[2].Value;
if (unit.StartsWith("hour", StringComparison.Ordinal))
- return DateTime.Now.AddHours(-count);
+ return ServerTimeHelper.ServerNow.AddHours(-count);
else if (unit.StartsWith("day", StringComparison.Ordinal))
- return DateTime.Now.AddDays(-count);
+ return ServerTimeHelper.ServerNow.AddDays(-count);
else if (unit.StartsWith("week", StringComparison.Ordinal))
- return DateTime.Now.AddDays(-count * 7);
+ return ServerTimeHelper.ServerNow.AddDays(-count * 7);
else if (unit.StartsWith("month", StringComparison.Ordinal))
- return DateTime.Now.AddMonths(-count);
+ return ServerTimeHelper.ServerNow.AddMonths(-count);
}
// Try to parse as absolute date (use original case for proper parsing)
diff --git a/Dashboard/Helpers/TabHelpers.cs b/Dashboard/Helpers/TabHelpers.cs
index e73a438b..c7556a9c 100644
--- a/Dashboard/Helpers/TabHelpers.cs
+++ b/Dashboard/Helpers/TabHelpers.cs
@@ -28,24 +28,119 @@ namespace PerformanceMonitorDashboard.Helpers
///
public static class TabHelpers
{
+ ///
+ /// Material Design 300-level color palette for chart data series.
+ /// Soft pastels optimized for dark backgrounds, ordered to map 1:1
+ /// with common ScottPlot stock colors (Blue→[0], Green→[1], etc.).
+ ///
+ public static readonly ScottPlot.Color[] ChartColors = new[]
+ {
+ ScottPlot.Color.FromHex("#4FC3F7"), // [0] Light Blue 300
+ ScottPlot.Color.FromHex("#81C784"), // [1] Green 300
+ ScottPlot.Color.FromHex("#FFB74D"), // [2] Orange 300
+ ScottPlot.Color.FromHex("#E57373"), // [3] Red 300
+ ScottPlot.Color.FromHex("#BA68C8"), // [4] Purple 300
+ ScottPlot.Color.FromHex("#4DD0E1"), // [5] Cyan 300
+ ScottPlot.Color.FromHex("#FFF176"), // [6] Yellow 300
+ ScottPlot.Color.FromHex("#F06292"), // [7] Pink 300
+ ScottPlot.Color.FromHex("#AED581"), // [8] Light Green 300
+ ScottPlot.Color.FromHex("#90A4AE"), // [9] Blue Grey 300
+ ScottPlot.Color.FromHex("#A1887F"), // [10] Brown 300
+ ScottPlot.Color.FromHex("#7986CB"), // [11] Indigo 300
+ ScottPlot.Color.FromHex("#FF7043"), // [12] Deep Orange 300
+ ScottPlot.Color.FromHex("#80DEEA"), // [13] Cyan 200
+ ScottPlot.Color.FromHex("#FFE082"), // [14] Amber 200
+ ScottPlot.Color.FromHex("#CE93D8"), // [15] Purple 200
+ ScottPlot.Color.FromHex("#EF9A9A"), // [16] Red 200
+ ScottPlot.Color.FromHex("#C5E1A5"), // [17] Light Green 200
+ ScottPlot.Color.FromHex("#FFCC80"), // [18] Orange 200
+ ScottPlot.Color.FromHex("#B0BEC5"), // [19] Blue Grey 200
+ };
+
+ ///
+ /// Poison waits — always selected by default. These indicate critical resource exhaustion.
+ ///
+ public static readonly string[] PoisonWaits = new[]
+ {
+ "THREADPOOL",
+ "RESOURCE_SEMAPHORE",
+ "RESOURCE_SEMAPHORE_QUERY_COMPILE"
+ };
+
+ ///
+ /// Usual suspect waits — always selected by default. Common performance-relevant wait types.
+ ///
+ public static readonly string[] UsualSuspectWaits = new[]
+ {
+ "SOS_SCHEDULER_YIELD",
+ "CXPACKET",
+ "CXCONSUMER",
+ "PAGEIOLATCH_SH",
+ "PAGEIOLATCH_EX",
+ "WRITELOG"
+ };
+
+ ///
+ /// Prefix patterns for usual suspect waits (e.g. PAGELATCH_EX, PAGELATCH_SH, etc.)
+ ///
+ public static readonly string[] UsualSuspectPrefixes = new[] { "PAGELATCH_" };
+
+ ///
+ /// Returns the set of wait types that should be selected by default:
+ /// poison waits + usual suspects + top 10 by total wait time (deduped), capped at 20.
+ /// The availableWaitTypes list must be sorted by total wait time descending.
+ ///
+ public static HashSet GetDefaultWaitTypes(IList availableWaitTypes)
+ {
+ var defaults = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // 1. Poison waits that exist in data
+ foreach (var w in PoisonWaits)
+ if (availableWaitTypes.Contains(w)) defaults.Add(w);
+
+ // 2. Usual suspects — exact matches
+ foreach (var w in UsualSuspectWaits)
+ if (availableWaitTypes.Contains(w)) defaults.Add(w);
+
+ // 3. Usual suspects — prefix matches
+ foreach (var prefix in UsualSuspectPrefixes)
+ foreach (var w in availableWaitTypes)
+ if (w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ defaults.Add(w);
+
+ // 4. Top 10 by total wait time (items not already in the set), hard cap at 20 total
+ int added = 0;
+ foreach (var w in availableWaitTypes)
+ {
+ if (defaults.Count >= 20) break;
+ if (added >= 10) break;
+ if (defaults.Add(w))
+ {
+ added++;
+ }
+ }
+
+ return defaults;
+ }
+
///
/// Applies the Darling Data dark theme to a ScottPlot chart.
///
public static void ApplyDarkModeToChart(WpfPlot chart)
{
- // Dark theme colors matching Darling Data brand
- var darkBackground = ScottPlot.Color.FromHex("#333333");
- var darkerBackground = ScottPlot.Color.FromHex("#252525");
- var textColor = ScottPlot.Color.FromHex("#E0E0E0");
- var gridColor = ScottPlot.Color.FromHex("#444444");
+ // Grafana-inspired dark theme colors
+ var darkBackground = ScottPlot.Color.FromHex("#22252b");
+ var darkerBackground = ScottPlot.Color.FromHex("#111217");
+ var textColor = ScottPlot.Color.FromHex("#9DA5B4");
+ var gridColor = ScottPlot.Colors.White.WithAlpha(20);
chart.Plot.FigureBackground.Color = darkBackground;
chart.Plot.DataBackground.Color = darkerBackground;
chart.Plot.Axes.Color(textColor);
chart.Plot.Grid.MajorLineColor = gridColor;
chart.Plot.Legend.BackgroundColor = darkBackground;
- chart.Plot.Legend.FontColor = textColor;
- chart.Plot.Legend.OutlineColor = gridColor;
+ chart.Plot.Legend.FontColor = ScottPlot.Color.FromHex("#E4E6EB");
+ chart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#2a2d35");
chart.Plot.Legend.Alignment = ScottPlot.Alignment.LowerCenter;
chart.Plot.Legend.Orientation = ScottPlot.Orientation.Horizontal;
chart.Plot.Axes.Margins(bottom: 0); // No bottom margin - SetChartYLimitsWithLegendPadding handles Y-axis
@@ -63,7 +158,7 @@ public static void ApplyDarkModeToChart(WpfPlot chart)
///
public static void ReapplyAxisColors(WpfPlot chart)
{
- var textColor = ScottPlot.Color.FromHex("#E0E0E0");
+ var textColor = ScottPlot.Color.FromHex("#9DA5B4");
chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor;
chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor;
chart.Plot.Axes.Bottom.Label.ForeColor = textColor;
@@ -124,15 +219,15 @@ public static void SetChartYLimitsWithLegendPadding(WpfPlot chart, double dataYM
///
public static void ApplyDarkThemeToCalendar(System.Windows.Controls.Calendar calendar)
{
- var darkBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#252525"));
- var lightBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#404040"));
- var whiteFg = new SolidColorBrush(Colors.White);
- var mutedFg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#858585"));
+ var darkBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#111217"));
+ var lightBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#22252b"));
+ var whiteFg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB"));
+ var mutedFg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
var accentBg = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2eaef1"));
calendar.Background = darkBg;
calendar.Foreground = whiteFg;
- calendar.BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#555555"));
+ calendar.BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2a2d35"));
// Apply to all child controls recursively
ApplyDarkThemeRecursively(calendar, darkBg, lightBg, whiteFg, mutedFg);
diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs
index 0a031764..f665f82c 100644
--- a/Dashboard/MainWindow.xaml.cs
+++ b/Dashboard/MainWindow.xaml.cs
@@ -137,6 +137,30 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
_displayRefreshTimer.Start();
await CheckAllConnectionsAsync();
+
+ _ = CheckForUpdatesOnStartupAsync();
+ }
+
+ private async Task CheckForUpdatesOnStartupAsync()
+ {
+ try
+ {
+ var prefs = _preferencesService.GetPreferences();
+ if (!prefs.CheckForUpdatesOnStartup) return;
+
+ var result = await UpdateCheckService.CheckForUpdateAsync();
+ if (result?.IsUpdateAvailable == true)
+ {
+ _notificationService?.ShowNotification(
+ "Update Available",
+ $"Performance Monitor {result.LatestVersion} is available (you have {result.CurrentVersion}). Check About for details.",
+ NotificationType.Info);
+ }
+ }
+ catch
+ {
+ // Never crash on update check failure
+ }
}
private void StartMcpServerIfEnabled()
diff --git a/Dashboard/Models/ServerListItem.cs b/Dashboard/Models/ServerListItem.cs
index aeb9e0fe..eb31fd1a 100644
--- a/Dashboard/Models/ServerListItem.cs
+++ b/Dashboard/Models/ServerListItem.cs
@@ -79,7 +79,7 @@ public string StatusColor
if (!_status.LastChecked.HasValue)
return "#888888"; // Gray for not checked
- return _status.IsOnline == true ? "#22C55E" : "#EF4444"; // Green or Red
+ return _status.IsOnline == true ? "#81C784" : "#E57373"; // Green or Red
}
}
diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs
index a915900e..58f8723d 100644
--- a/Dashboard/Models/UserPreferences.cs
+++ b/Dashboard/Models/UserPreferences.cs
@@ -92,6 +92,9 @@ public class UserPreferences
public bool McpEnabled { get; set; } = false;
public int McpPort { get; set; } = 5150;
+ // Update check settings
+ public bool CheckForUpdatesOnStartup { get; set; } = true;
+
// Alert suppression (persisted)
public List SilencedServers { get; set; } = new();
public List SilencedServerTabs { get; set; } = new();
diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs
index 0a00c6b1..1d54bc03 100644
--- a/Dashboard/ServerTab.xaml.cs
+++ b/Dashboard/ServerTab.xaml.cs
@@ -246,7 +246,7 @@ private void SetupAutoRefresh()
{
AutoRefreshToggle.IsChecked = false;
AutoRefreshToggle.Content = "Auto-Refresh: Off";
- AutoRefreshToggle.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#555555")); // Gray when inactive
+ AutoRefreshToggle.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2a2d35")); // Gray when inactive
}
}
@@ -358,7 +358,7 @@ private void AutoRefreshToggle_Click(object sender, RoutedEventArgs e)
_autoRefreshTimer?.Stop();
_autoRefreshTimer = null;
AutoRefreshToggle.Content = "Auto-Refresh: Off";
- AutoRefreshToggle.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#555555")); // Gray when inactive
+ AutoRefreshToggle.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2a2d35")); // Gray when inactive
}
}
@@ -1650,8 +1650,8 @@ private void LoadBlockingStatsCharts(List data, int h
{
var scatter = BlockingStatsBlockingEventsChart.Plot.Add.Scatter(blockingXs, blockingYs);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Blue;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[0];
}
else
{
@@ -1678,8 +1678,8 @@ private void LoadBlockingStatsCharts(List data, int h
{
var scatter = BlockingStatsDurationChart.Plot.Add.Scatter(durationXs, durationYs);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Orange;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[2];
}
else
{
@@ -1706,8 +1706,8 @@ private void LoadBlockingStatsCharts(List data, int h
{
var scatter = BlockingStatsDeadlocksChart.Plot.Add.Scatter(deadlockXs, deadlockYs);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Red;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[3];
}
else
{
@@ -1734,8 +1734,8 @@ private void LoadBlockingStatsCharts(List data, int h
{
var scatter = BlockingStatsDeadlockWaitTimeChart.Plot.Add.Scatter(deadlockWaitXs, deadlockWaitYs);
scatter.LineWidth = 2;
- scatter.MarkerSize = 4;
- scatter.Color = ScottPlot.Colors.Purple;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[4];
}
else
{
@@ -1772,9 +1772,7 @@ private void LoadLockWaitStatsChart(List data, int hoursBack,
// Get all unique time points across all wait types for gap filling
// Group by wait type and plot each as a separate series
var waitTypes = data.Select(d => d.WaitType).Distinct().OrderBy(w => w).ToList();
- var colors = new[] { ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange,
- ScottPlot.Colors.Red, ScottPlot.Colors.Purple, ScottPlot.Colors.Cyan,
- ScottPlot.Colors.Magenta, ScottPlot.Colors.Yellow };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var waitType in waitTypes)
@@ -1789,7 +1787,7 @@ private void LoadLockWaitStatsChart(List data, int hoursBack,
var scatter = LockWaitStatsChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = waitType.Replace("LCK_M_", "").Replace("LCK_", "");
colorIndex++;
@@ -2216,8 +2214,8 @@ private void LoadResourceOverviewCpuChart(IEnumerable cpuData, int
{
var scatter = ResourceOverviewCpuChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
- scatter.Color = ScottPlot.Colors.Blue;
+ scatter.MarkerSize = 5;
+ scatter.Color = TabHelpers.ChartColors[0];
scatter.LegendText = "SQL CPU %";
_legendPanels[ResourceOverviewCpuChart] = ResourceOverviewCpuChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -2271,14 +2269,14 @@ private void LoadResourceOverviewMemoryChart(IEnumerable memory
{
var bufferScatter = ResourceOverviewMemoryChart.Plot.Add.Scatter(bufferXs, bufferYs);
bufferScatter.LineWidth = 2;
- bufferScatter.MarkerSize = 0;
- bufferScatter.Color = ScottPlot.Colors.Purple;
+ bufferScatter.MarkerSize = 5;
+ bufferScatter.Color = TabHelpers.ChartColors[4];
bufferScatter.LegendText = "Buffer Pool";
var grantsScatter = ResourceOverviewMemoryChart.Plot.Add.Scatter(grantsXs, grantsYs);
grantsScatter.LineWidth = 2;
- grantsScatter.MarkerSize = 0;
- grantsScatter.Color = ScottPlot.Colors.Orange;
+ grantsScatter.MarkerSize = 5;
+ grantsScatter.Color = TabHelpers.ChartColors[2];
grantsScatter.LegendText = "Memory Grants";
_legendPanels[ResourceOverviewMemoryChart] = ResourceOverviewMemoryChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -2347,14 +2345,14 @@ private void LoadResourceOverviewIoChart(IEnumerable ioData, in
{
var readScatter = ResourceOverviewIoChart.Plot.Add.Scatter(readXs, readYs);
readScatter.LineWidth = 2;
- readScatter.MarkerSize = 0;
- readScatter.Color = ScottPlot.Colors.Green;
+ readScatter.MarkerSize = 5;
+ readScatter.Color = TabHelpers.ChartColors[1];
readScatter.LegendText = "Read ms";
var writeScatter = ResourceOverviewIoChart.Plot.Add.Scatter(writeXs, writeYs);
writeScatter.LineWidth = 2;
- writeScatter.MarkerSize = 0;
- writeScatter.Color = ScottPlot.Colors.Orange;
+ writeScatter.MarkerSize = 5;
+ writeScatter.Color = TabHelpers.ChartColors[2];
writeScatter.LegendText = "Write ms";
_legendPanels[ResourceOverviewIoChart] = ResourceOverviewIoChart.Plot.ShowLegend(ScottPlot.Edge.Bottom);
@@ -2407,7 +2405,7 @@ private void LoadResourceOverviewWaitChart(IEnumerable waitD
.Select(x => x.WaitType)
.ToList();
- var colors = new[] { ScottPlot.Colors.Red, ScottPlot.Colors.Blue, ScottPlot.Colors.Green, ScottPlot.Colors.Orange, ScottPlot.Colors.Purple };
+ var colors = TabHelpers.ChartColors;
int colorIndex = 0;
foreach (var waitType in topWaitTypes)
@@ -2422,7 +2420,7 @@ private void LoadResourceOverviewWaitChart(IEnumerable waitD
var scatter = ResourceOverviewWaitChart.Plot.Add.Scatter(xs, ys);
scatter.LineWidth = 2;
- scatter.MarkerSize = 0;
+ scatter.MarkerSize = 5;
scatter.Color = colors[colorIndex % colors.Length];
scatter.LegendText = waitType.Length > 15 ? waitType.Substring(0, 15) + "..." : waitType;
colorIndex++;
diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs
index c1b3f6e6..26cd0d17 100644
--- a/Dashboard/Services/DatabaseService.QueryPerformance.cs
+++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs
@@ -568,6 +568,7 @@ SELECT TOP (500)
FROM report.query_snapshots AS qs
WHERE qs.collection_time >= @from_date
AND qs.collection_time <= @to_date
+ AND CONVERT(nvarchar(max), qs.sql_text) NOT LIKE N'WAITFOR%'
ORDER BY
qs.collection_time DESC,
qs.session_id;";
@@ -609,11 +610,12 @@ SELECT TOP (500)
/* query_plan fetched on-demand via GetQuerySnapshotPlanAsync */
FROM report.query_snapshots AS qs
WHERE qs.collection_time >= DATEADD(HOUR, @hours_back, SYSDATETIME())
+ AND CONVERT(nvarchar(max), qs.sql_text) NOT LIKE N'WAITFOR%'
ORDER BY
qs.collection_time DESC,
qs.session_id;";
}
-
+
using var command = new SqlCommand(query, connection);
command.CommandTimeout = 120;
command.Parameters.Add(new SqlParameter("@hours_back", SqlDbType.Int) { Value = -hoursBack });
@@ -748,6 +750,7 @@ FROM report.query_stats_summary AS qs
OR (qs.last_execution_time >= @fromDate AND qs.last_execution_time <= @toDate)
OR (qs.first_execution_time <= @fromDate AND qs.last_execution_time >= @toDate)))
)
+ AND qs.query_text NOT LIKE N'WAITFOR%'
ORDER BY
qs.avg_worker_time_ms DESC;";
@@ -989,6 +992,7 @@ FROM report.query_store_summary AS qss
OR (qss.last_execution_time >= @fromDate AND qss.last_execution_time <= @toDate)
OR (qss.first_execution_time <= @fromDate AND qss.last_execution_time >= @toDate)))
)
+ AND qss.query_sql_text NOT LIKE N'WAITFOR%'
ORDER BY
qss.avg_cpu_time_ms DESC
OPTION
@@ -1204,8 +1208,8 @@ ORDER BY
}
else
{
- startDate = DateTime.Now.AddHours(-hoursBack);
- endDate = DateTime.Now;
+ startDate = Helpers.ServerTimeHelper.ServerNow.AddHours(-hoursBack);
+ endDate = Helpers.ServerTimeHelper.ServerNow;
}
command.Parameters.Add(new SqlParameter("@start_date", SqlDbType.DateTime2) { Value = startDate });
diff --git a/Dashboard/Services/NotificationService.cs b/Dashboard/Services/NotificationService.cs
index 5dcc55f9..e04199a2 100644
--- a/Dashboard/Services/NotificationService.cs
+++ b/Dashboard/Services/NotificationService.cs
@@ -9,6 +9,7 @@
using System;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Media;
using System.Windows.Media.Imaging;
using Hardcodet.Wpf.TaskbarNotification;
using PerformanceMonitorDashboard.Interfaces;
@@ -33,9 +34,22 @@ public void Initialize()
// Dispose any existing icon first
_trayIcon?.Dispose();
- _trayIcon = new TaskbarIcon
+ _trayIcon = new TaskbarIcon();
+
+ /* Custom dark tooltip (native ToolTipText uses Windows light theme) */
+ _trayIcon.TrayToolTip = new Border
{
- ToolTipText = "SQL Server Performance Monitor"
+ Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#22252b")),
+ BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#33363e")),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 8, 10, 8),
+ CornerRadius = new CornerRadius(4),
+ Child = new TextBlock
+ {
+ Text = "SQL Server Performance Monitor",
+ Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E4E6EB")),
+ FontSize = 12
+ }
};
// Load dark theme for context menu styling
@@ -62,14 +76,14 @@ public void Initialize()
var showItem = new MenuItem
{
Header = "Show Dashboard",
- Icon = new TextBlock { Text = "📊" }
+ Icon = new TextBlock { Text = "📊", Background = Brushes.Transparent }
};
showItem.Click += (s, e) => ShowMainWindow();
var settingsItem = new MenuItem
{
Header = "Settings...",
- Icon = new TextBlock { Text = "⚙" }
+ Icon = new TextBlock { Text = "⚙", Background = Brushes.Transparent }
};
settingsItem.Click += (s, e) => OpenSettings();
@@ -78,7 +92,7 @@ public void Initialize()
var exitItem = new MenuItem
{
Header = "Exit",
- Icon = new TextBlock { Text = "✕" }
+ Icon = new TextBlock { Text = "✕", Background = Brushes.Transparent }
};
exitItem.Click += (s, e) => ExitApplication();
diff --git a/Dashboard/Services/UpdateCheckService.cs b/Dashboard/Services/UpdateCheckService.cs
new file mode 100644
index 00000000..13c5edc2
--- /dev/null
+++ b/Dashboard/Services/UpdateCheckService.cs
@@ -0,0 +1,101 @@
+/*
+ * Performance Monitor Dashboard
+ * Copyright (c) 2026 Darling Data, LLC
+ * Licensed under the MIT License - see LICENSE file for details
+ */
+
+using System;
+using System.Net.Http;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace PerformanceMonitorDashboard.Services
+{
+ public record UpdateInfo(
+ bool IsUpdateAvailable,
+ string CurrentVersion,
+ string LatestVersion,
+ string ReleaseUrl,
+ string ReleaseNotes);
+
+ public static class UpdateCheckService
+ {
+ private const string ReleasesApiUrl =
+ "https://api.github.com/repos/erikdarlingdata/PerformanceMonitor/releases/latest";
+
+ private static readonly HttpClient HttpClient = CreateHttpClient();
+ private static UpdateInfo? _cachedResult;
+ private static DateTime _cacheExpiry = DateTime.MinValue;
+
+ private static HttpClient CreateHttpClient()
+ {
+ var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
+ client.DefaultRequestHeaders.Add("User-Agent", "PerformanceMonitor");
+ client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
+ return client;
+ }
+
+ public static async Task CheckForUpdateAsync(bool bypassCache = false)
+ {
+ try
+ {
+ if (!bypassCache && _cachedResult != null && DateTime.UtcNow < _cacheExpiry)
+ return _cachedResult;
+
+ var response = await HttpClient.GetAsync(ReleasesApiUrl).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ return null;
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var tagName = root.GetProperty("tag_name").GetString() ?? "";
+ var releaseUrl = root.GetProperty("html_url").GetString() ?? "";
+ var releaseNotes = root.TryGetProperty("body", out var bodyProp)
+ ? bodyProp.GetString() ?? ""
+ : "";
+
+ var currentVersion = GetCurrentVersion();
+ var latestVersion = ParseVersion(tagName);
+ var isUpdateAvailable = latestVersion != null
+ && currentVersion != null
+ && latestVersion > currentVersion;
+
+ var result = new UpdateInfo(
+ isUpdateAvailable,
+ FormatVersion(currentVersion),
+ tagName,
+ releaseUrl,
+ releaseNotes);
+
+ _cachedResult = result;
+ _cacheExpiry = DateTime.UtcNow.AddHours(24);
+
+ return result;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static Version? GetCurrentVersion()
+ {
+ return Assembly.GetExecutingAssembly().GetName().Version;
+ }
+
+ private static Version? ParseVersion(string tagName)
+ {
+ var versionString = tagName.TrimStart('v', 'V');
+ return Version.TryParse(versionString, out var version) ? version : null;
+ }
+
+ private static string FormatVersion(Version? version)
+ {
+ if (version == null) return "unknown";
+ return $"{version.Major}.{version.Minor}.{version.Build}";
+ }
+ }
+}
diff --git a/Dashboard/Themes/DarkTheme.xaml b/Dashboard/Themes/DarkTheme.xaml
index 60f791b8..686dcef6 100644
--- a/Dashboard/Themes/DarkTheme.xaml
+++ b/Dashboard/Themes/DarkTheme.xaml
@@ -11,26 +11,26 @@
#5bc4f5
#1a9ae0
-
- #252525
- #2d2d2d
- #333333
- #404040
- #4a4a4a
+
+ #111217
+ #1c1f25
+ #181b1f
+ #22252b
+ #2a2d35
-
- #FFFFFF
- #D0D0D0
- #B0B0B0
+
+ #E4E6EB
+ #9DA5B4
+ #6B7280
-
- #555555
- #666666
+
+ #2a2d35
+ #33363e
-
- #4CAF50
- #FFC107
- #F44336
+
+ #81C784
+ #FFD54F
+ #E57373
#2eaef1
@@ -83,7 +83,7 @@
@@ -514,7 +514,7 @@
-
+
@@ -540,6 +540,8 @@
+
+
@@ -571,7 +573,7 @@
@@ -999,9 +1001,22 @@
diff --git a/Dashboard/app.manifest b/Dashboard/app.manifest
new file mode 100644
index 00000000..c70b1558
--- /dev/null
+++ b/Dashboard/app.manifest
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ PerMonitorV2
+ true
+
+
+
diff --git a/Lite/App.xaml.cs b/Lite/App.xaml.cs
index 21dc418c..00bbb874 100644
--- a/Lite/App.xaml.cs
+++ b/Lite/App.xaml.cs
@@ -56,6 +56,9 @@ public partial class App : Application
public static bool AlertDeadlockEnabled { get; set; } = true;
public static int AlertDeadlockThreshold { get; set; } = 1;
+ /* Update check settings */
+ public static bool CheckForUpdatesOnStartup { get; set; } = true;
+
/* SMTP email alert settings */
public static bool SmtpEnabled { get; set; } = false;
public static string SmtpServer { get; set; } = "";
@@ -103,6 +106,7 @@ public static void SaveSmtpPassword(string password)
protected override void OnStartup(StartupEventArgs e)
{
+
// Check for existing instance
_singleInstanceMutex = new Mutex(true, MutexName, out bool isNewInstance);
@@ -195,6 +199,9 @@ public static void LoadAlertSettings()
if (root.TryGetProperty("alert_deadlock_enabled", out v)) AlertDeadlockEnabled = v.GetBoolean();
if (root.TryGetProperty("alert_deadlock_threshold", out v)) AlertDeadlockThreshold = v.GetInt32();
+ /* Update check settings */
+ if (root.TryGetProperty("check_for_updates_on_startup", out v)) CheckForUpdatesOnStartup = v.GetBoolean();
+
/* SMTP settings */
if (root.TryGetProperty("smtp_enabled", out v)) SmtpEnabled = v.GetBoolean();
if (root.TryGetProperty("smtp_server", out v)) SmtpServer = v.GetString() ?? "";
diff --git a/Lite/Controls/ColumnFilterPopup.xaml b/Lite/Controls/ColumnFilterPopup.xaml
new file mode 100644
index 00000000..2ca878fd
--- /dev/null
+++ b/Lite/Controls/ColumnFilterPopup.xaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/ColumnFilterPopup.xaml.cs b/Lite/Controls/ColumnFilterPopup.xaml.cs
new file mode 100644
index 00000000..b431eb21
--- /dev/null
+++ b/Lite/Controls/ColumnFilterPopup.xaml.cs
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Controls;
+
+public partial class ColumnFilterPopup : UserControl
+{
+ private string _columnName = string.Empty;
+ private bool _suppressEvents = false;
+
+ public event EventHandler? FilterApplied;
+ public event EventHandler? FilterCleared;
+
+ public ColumnFilterPopup()
+ {
+ InitializeComponent();
+ PopulateOperatorComboBox();
+ }
+
+ private void PopulateOperatorComboBox()
+ {
+ OperatorComboBox.Items.Clear();
+
+ foreach (FilterOperator op in Enum.GetValues(typeof(FilterOperator)))
+ {
+ OperatorComboBox.Items.Add(new ComboBoxItem
+ {
+ Content = ColumnFilterState.GetOperatorDisplayName(op),
+ Tag = op
+ });
+ }
+
+ OperatorComboBox.SelectedIndex = 0;
+ }
+
+ public void Initialize(string columnName, ColumnFilterState? existingFilter)
+ {
+ _suppressEvents = true;
+ _columnName = columnName;
+ HeaderText.Text = $"Filter: {columnName}";
+
+ if (existingFilter != null && existingFilter.IsActive)
+ {
+ for (int i = 0; i < OperatorComboBox.Items.Count; i++)
+ {
+ if (OperatorComboBox.Items[i] is ComboBoxItem item && item.Tag is FilterOperator op)
+ {
+ if (op == existingFilter.Operator)
+ {
+ OperatorComboBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+
+ ValueTextBox.Text = existingFilter.Value;
+ }
+ else
+ {
+ OperatorComboBox.SelectedIndex = 0;
+ ValueTextBox.Text = string.Empty;
+ }
+
+ UpdateValueVisibility();
+ _suppressEvents = false;
+
+ ValueTextBox.Focus();
+ ValueTextBox.SelectAll();
+ }
+
+ private void OperatorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressEvents) return;
+ UpdateValueVisibility();
+ }
+
+ private void UpdateValueVisibility()
+ {
+ var selectedOp = GetSelectedOperator();
+
+ bool showValue = selectedOp != FilterOperator.IsEmpty &&
+ selectedOp != FilterOperator.IsNotEmpty;
+
+ ValueLabel.Visibility = showValue ? Visibility.Visible : Visibility.Collapsed;
+ ValueTextBox.Visibility = showValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private FilterOperator GetSelectedOperator()
+ {
+ if (OperatorComboBox.SelectedItem is ComboBoxItem item && item.Tag is FilterOperator op)
+ {
+ return op;
+ }
+ return FilterOperator.Contains;
+ }
+
+ private void ValueTextBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ ApplyFilter();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ }
+ }
+
+ private void ApplyButton_Click(object sender, RoutedEventArgs e)
+ {
+ ApplyFilter();
+ }
+
+ private void ApplyFilter()
+ {
+ var filterState = new ColumnFilterState
+ {
+ ColumnName = _columnName,
+ Operator = GetSelectedOperator(),
+ Value = ValueTextBox.Text.Trim()
+ };
+
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs { FilterState = filterState });
+ }
+
+ private void ClearButton_Click(object sender, RoutedEventArgs e)
+ {
+ ValueTextBox.Text = string.Empty;
+ OperatorComboBox.SelectedIndex = 0;
+
+ var filterState = new ColumnFilterState
+ {
+ ColumnName = _columnName,
+ Operator = FilterOperator.Contains,
+ Value = string.Empty
+ };
+
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs { FilterState = filterState });
+ }
+}
+
+public class FilterAppliedEventArgs : EventArgs
+{
+ public ColumnFilterState FilterState { get; set; } = new ColumnFilterState();
+}
diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index f5cf4ea7..144f662a 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -1,703 +1,976 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index b09a3661..6c0c7cdd 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -16,6 +16,7 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
+using System.Windows.Controls.Primitives;
using System.Windows.Threading;
using Microsoft.Win32;
using PerformanceMonitorLite.Database;
@@ -30,11 +31,33 @@ public partial class ServerTab : UserControl
private readonly ServerConnection _server;
private readonly LocalDataService _dataService;
private readonly int _serverId;
+ public int ServerId => _serverId;
private readonly CredentialService _credentialService;
private readonly DispatcherTimer _refreshTimer;
private readonly Dictionary _legendPanels = new();
private List _waitTypeItems = new();
private List _perfmonCounterItems = new();
+ private Helpers.ChartHoverHelper? _waitStatsHover;
+ private Helpers.ChartHoverHelper? _perfmonHover;
+ private Helpers.ChartHoverHelper? _tempDbFileIoHover;
+ private Helpers.ChartHoverHelper? _fileIoReadHover;
+ private Helpers.ChartHoverHelper? _fileIoWriteHover;
+
+ /* Column filtering */
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+ private readonly Dictionary _filterManagers = new();
+ private DataGridFilterManager? _querySnapshotsFilterMgr;
+ private DataGridFilterManager? _queryStatsFilterMgr;
+ private DataGridFilterManager? _procStatsFilterMgr;
+ private DataGridFilterManager? _queryStoreFilterMgr;
+ private DataGridFilterManager? _blockedProcessFilterMgr;
+ private DataGridFilterManager? _deadlockFilterMgr;
+ private DataGridFilterManager? _runningJobsFilterMgr;
+ private DataGridFilterManager? _serverConfigFilterMgr;
+ private DataGridFilterManager? _databaseConfigFilterMgr;
+ private DataGridFilterManager? _dbScopedConfigFilterMgr;
+ private DataGridFilterManager? _traceFlagsFilterMgr;
private static readonly HashSet _defaultPerfmonCounters = new(StringComparer.OrdinalIgnoreCase)
{
@@ -47,9 +70,10 @@ public partial class ServerTab : UserControl
private static readonly string[] SeriesColors = new[]
{
- "#2eaef1", "#F44336", "#4CAF50", "#FFC107", "#9C27B0",
- "#FF9800", "#00BCD4", "#E91E63", "#8BC34A", "#3F51B5",
- "#CDDC39", "#795548"
+ "#4FC3F7", "#E57373", "#81C784", "#FFD54F", "#BA68C8",
+ "#FFB74D", "#4DD0E1", "#F06292", "#AED581", "#7986CB",
+ "#FFF176", "#A1887F", "#FF7043", "#80DEEA", "#FFE082",
+ "#CE93D8", "#EF9A9A", "#C5E1A5", "#FFCC80", "#B0BEC5"
};
public int UtcOffsetMinutes { get; }
@@ -97,6 +121,16 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe
/* Initialize time picker ComboBoxes */
InitializeTimeComboBoxes();
+ /* Initialize column filter managers */
+ InitializeFilterManagers();
+
+ /* Chart hover tooltips */
+ _waitStatsHover = new Helpers.ChartHoverHelper(WaitStatsChart, "ms/sec");
+ _perfmonHover = new Helpers.ChartHoverHelper(PerfmonChart, "");
+ _tempDbFileIoHover = new Helpers.ChartHoverHelper(TempDbFileIoChart, "ms");
+ _fileIoReadHover = new Helpers.ChartHoverHelper(FileIoReadChart, "ms");
+ _fileIoWriteHover = new Helpers.ChartHoverHelper(FileIoWriteChart, "ms");
+
/* Initial load is triggered by MainWindow.ConnectToServer calling RefreshData()
after collectors finish - no Loaded handler needed */
}
@@ -295,13 +329,13 @@ private void DatePicker_CalendarOpened(object sender, RoutedEventArgs e)
private void ApplyDarkThemeToCalendar(System.Windows.Controls.Calendar calendar)
{
- var darkBg = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#252525")!);
- var whiteFg = new SolidColorBrush(System.Windows.Media.Colors.White);
- var mutedFg = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#858585")!);
+ var darkBg = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#111217")!);
+ var whiteFg = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#E4E6EB")!);
+ var mutedFg = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#6B7280")!);
calendar.Background = darkBg;
calendar.Foreground = whiteFg;
- calendar.BorderBrush = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#555555")!);
+ calendar.BorderBrush = new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#2a2d35")!);
ApplyDarkThemeRecursively(calendar, darkBg, whiteFg, mutedFg);
}
@@ -442,18 +476,18 @@ await System.Threading.Tasks.Task.WhenAll(
AppLogger.DataDiag("ServerTab", $" TempDb: {tempDbTask.Result.Count}, BlockedProcessReports: {blockedProcessTask.Result.Count}, Deadlocks: {deadlockTask.Result.Count}");
AppLogger.DataDiag("ServerTab", $" WaitTypes: {waitTypesTask.Result.Count}, PerfmonCounters: {perfmonCountersTask.Result.Count}, QueryStore: {queryStoreTask.Result.Count}");
- /* Update grids */
- QuerySnapshotsGrid.ItemsSource = snapshotsTask.Result;
- QueryStatsGrid.ItemsSource = queryStatsTask.Result;
- ProcedureStatsGrid.ItemsSource = procStatsTask.Result;
- BlockedProcessReportGrid.ItemsSource = blockedProcessTask.Result;
- DeadlockGrid.ItemsSource = DeadlockProcessDetail.ParseFromRows(deadlockTask.Result);
- QueryStoreGrid.ItemsSource = queryStoreTask.Result;
- ServerConfigGrid.ItemsSource = serverConfigTask.Result;
- DatabaseConfigGrid.ItemsSource = databaseConfigTask.Result;
- DatabaseScopedConfigGrid.ItemsSource = databaseScopedConfigTask.Result;
- TraceFlagsGrid.ItemsSource = traceFlagsTask.Result;
- RunningJobsGrid.ItemsSource = runningJobsTask.Result;
+ /* Update grids (via filter managers to preserve active filters) */
+ _querySnapshotsFilterMgr!.UpdateData(snapshotsTask.Result);
+ _queryStatsFilterMgr!.UpdateData(queryStatsTask.Result);
+ _procStatsFilterMgr!.UpdateData(procStatsTask.Result);
+ _blockedProcessFilterMgr!.UpdateData(blockedProcessTask.Result);
+ _deadlockFilterMgr!.UpdateData(DeadlockProcessDetail.ParseFromRows(deadlockTask.Result));
+ _queryStoreFilterMgr!.UpdateData(queryStoreTask.Result);
+ _serverConfigFilterMgr!.UpdateData(serverConfigTask.Result);
+ _databaseConfigFilterMgr!.UpdateData(databaseConfigTask.Result);
+ _dbScopedConfigFilterMgr!.UpdateData(databaseScopedConfigTask.Result);
+ _traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
+ _runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
/* Update memory summary */
UpdateMemorySummary(memoryTask.Result);
@@ -549,11 +583,11 @@ private void UpdateCpuChart(List data)
var sqlPlot = CpuChart.Plot.Add.Scatter(times, sqlCpu);
sqlPlot.LegendText = "SQL Server";
- sqlPlot.Color = ScottPlot.Color.FromHex("#2eaef1");
+ sqlPlot.Color = ScottPlot.Color.FromHex("#4FC3F7");
var otherPlot = CpuChart.Plot.Add.Scatter(times, otherCpu);
otherPlot.LegendText = "Other";
- otherPlot.Color = ScottPlot.Color.FromHex("#F44336");
+ otherPlot.Color = ScottPlot.Color.FromHex("#E57373");
CpuChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(CpuChart);
@@ -578,7 +612,7 @@ private void UpdateMemoryChart(List data, List data, List data, List data)
var userPlot = TempDbChart.Plot.Add.Scatter(times, userObj);
userPlot.LegendText = "User Objects";
- userPlot.Color = ScottPlot.Color.FromHex("#2eaef1");
+ userPlot.Color = ScottPlot.Color.FromHex("#4FC3F7");
var internalPlot = TempDbChart.Plot.Add.Scatter(times, internalObj);
internalPlot.LegendText = "Internal Objects";
- internalPlot.Color = ScottPlot.Color.FromHex("#FFC107");
+ internalPlot.Color = ScottPlot.Color.FromHex("#FFD54F");
var vsPlot = TempDbChart.Plot.Add.Scatter(times, versionStore);
vsPlot.LegendText = "Version Store";
- vsPlot.Color = ScottPlot.Color.FromHex("#4CAF50");
+ vsPlot.Color = ScottPlot.Color.FromHex("#81C784");
TempDbChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(TempDbChart);
@@ -655,6 +689,7 @@ private void UpdateTempDbChart(List data)
private void UpdateTempDbFileIoChart(List data)
{
ClearChart(TempDbFileIoChart);
+ _tempDbFileIoHover?.Clear();
ApplyDarkTheme(TempDbFileIoChart);
if (data.Count == 0) { TempDbFileIoChart.Refresh(); return; }
@@ -681,6 +716,7 @@ private void UpdateTempDbFileIoChart(List data)
var plot = TempDbFileIoChart.Plot.Add.Scatter(times, latency);
plot.LegendText = fileGroup.Key;
plot.Color = color;
+ _tempDbFileIoHover?.Add(plot, fileGroup.Key);
maxLatency = Math.Max(maxLatency, latency.Max());
}
}
@@ -697,6 +733,8 @@ private void UpdateFileIoCharts(List data)
{
ClearChart(FileIoReadChart);
ClearChart(FileIoWriteChart);
+ _fileIoReadHover?.Clear();
+ _fileIoWriteHover?.Clear();
ApplyDarkTheme(FileIoReadChart);
ApplyDarkTheme(FileIoWriteChart);
@@ -726,6 +764,7 @@ private void UpdateFileIoCharts(List data)
var readPlot = FileIoReadChart.Plot.Add.Scatter(times, readLatency);
readPlot.LegendText = dbGroup.Key;
readPlot.Color = color;
+ _fileIoReadHover?.Add(readPlot, dbGroup.Key);
readMax = Math.Max(readMax, readLatency.Max());
}
@@ -734,6 +773,7 @@ private void UpdateFileIoCharts(List data)
var writePlot = FileIoWriteChart.Plot.Add.Scatter(times, writeLatency);
writePlot.LegendText = dbGroup.Key;
writePlot.Color = color;
+ _fileIoWriteHover?.Add(writePlot, dbGroup.Key);
writeMax = Math.Max(writeMax, writeLatency.Max());
}
}
@@ -813,7 +853,7 @@ private void UpdateBlockingTrendChart(List data, int hoursBack, Date
var plot = BlockingTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray());
plot.LegendText = "Blocking Incidents";
- plot.Color = ScottPlot.Color.FromHex("#F44336");
+ plot.Color = ScottPlot.Color.FromHex("#E57373");
plot.MarkerSize = 0; /* No markers, just lines */
BlockingTrendChart.Plot.Axes.DateTimeTicksBottom();
@@ -883,7 +923,7 @@ private void UpdateDeadlockTrendChart(List data, int hoursBack, Date
var plot = DeadlockTrendChart.Plot.Add.Scatter(expandedTimes.ToArray(), expandedCounts.ToArray());
plot.LegendText = "Deadlocks";
- plot.Color = ScottPlot.Color.FromHex("#FF9800");
+ plot.Color = ScottPlot.Color.FromHex("#FFB74D");
plot.MarkerSize = 0; /* No markers, just lines */
DeadlockTrendChart.Plot.Axes.DateTimeTicksBottom();
@@ -909,7 +949,7 @@ private void UpdateQueryDurationTrendChart(List data)
var plot = QueryDurationTrendChart.Plot.Add.Scatter(times, values);
plot.LegendText = "Query Duration";
- plot.Color = ScottPlot.Color.FromHex("#2eaef1");
+ plot.Color = ScottPlot.Color.FromHex("#4FC3F7");
QueryDurationTrendChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(QueryDurationTrendChart);
@@ -931,7 +971,7 @@ private void UpdateProcDurationTrendChart(List data)
var plot = ProcDurationTrendChart.Plot.Add.Scatter(times, values);
plot.LegendText = "Procedure Duration";
- plot.Color = ScottPlot.Color.FromHex("#4CAF50");
+ plot.Color = ScottPlot.Color.FromHex("#81C784");
ProcDurationTrendChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(ProcDurationTrendChart);
@@ -953,7 +993,7 @@ private void UpdateQueryStoreDurationTrendChart(List data)
var plot = QueryStoreDurationTrendChart.Plot.Add.Scatter(times, values);
plot.LegendText = "Query Store Duration";
- plot.Color = ScottPlot.Color.FromHex("#FF9800");
+ plot.Color = ScottPlot.Color.FromHex("#FFB74D");
QueryStoreDurationTrendChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(QueryStoreDurationTrendChart);
@@ -975,7 +1015,7 @@ private void UpdateExecutionCountTrendChart(List data)
var plot = ExecutionCountTrendChart.Plot.Add.Scatter(times, values);
plot.LegendText = "Executions";
- plot.Color = ScottPlot.Color.FromHex("#9C27B0");
+ plot.Color = ScottPlot.Color.FromHex("#BA68C8");
ExecutionCountTrendChart.Plot.Axes.DateTimeTicksBottom();
ReapplyAxisColors(ExecutionCountTrendChart);
@@ -987,15 +1027,41 @@ private void UpdateExecutionCountTrendChart(List data)
/* ========== Wait Stats Picker ========== */
+ private static readonly string[] PoisonWaits = { "THREADPOOL", "RESOURCE_SEMAPHORE", "RESOURCE_SEMAPHORE_QUERY_COMPILE" };
+ private static readonly string[] UsualSuspectWaits = { "SOS_SCHEDULER_YIELD", "CXPACKET", "CXCONSUMER", "PAGEIOLATCH_SH", "PAGEIOLATCH_EX", "WRITELOG" };
+ private static readonly string[] UsualSuspectPrefixes = { "PAGELATCH_" };
+
+ private static HashSet GetDefaultWaitTypes(List availableWaitTypes)
+ {
+ var defaults = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var w in PoisonWaits)
+ if (availableWaitTypes.Contains(w)) defaults.Add(w);
+ foreach (var w in UsualSuspectWaits)
+ if (availableWaitTypes.Contains(w)) defaults.Add(w);
+ foreach (var prefix in UsualSuspectPrefixes)
+ foreach (var w in availableWaitTypes)
+ if (w.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ defaults.Add(w);
+ int added = 0;
+ foreach (var w in availableWaitTypes)
+ {
+ if (defaults.Count >= 20) break;
+ if (added >= 10) break;
+ if (defaults.Add(w)) { added++; }
+ }
+ return defaults;
+ }
+
private bool _isUpdatingWaitTypeSelection;
private void PopulateWaitTypePicker(List waitTypes)
{
var previouslySelected = new HashSet(_waitTypeItems.Where(i => i.IsSelected).Select(i => i.DisplayName));
+ var topWaits = previouslySelected.Count == 0 ? GetDefaultWaitTypes(waitTypes) : null;
_waitTypeItems = waitTypes.Select(w => new SelectableItem
{
DisplayName = w,
- IsSelected = previouslySelected.Contains(w) || (previouslySelected.Count == 0 && waitTypes.IndexOf(w) < 10)
+ IsSelected = previouslySelected.Contains(w) || (topWaits != null && topWaits.Contains(w))
}).ToList();
/* Sort checked items to top, then preserve original order (by total wait time desc) */
RefreshWaitTypeListOrder();
@@ -1006,9 +1072,20 @@ private void RefreshWaitTypeListOrder()
if (_waitTypeItems == null) return;
_waitTypeItems = _waitTypeItems
.OrderByDescending(x => x.IsSelected)
- .ThenBy(x => _waitTypeItems.IndexOf(x))
+ .ThenBy(x => x.DisplayName)
.ToList();
ApplyWaitTypeFilter();
+ UpdateWaitTypeCount();
+ }
+
+ private void UpdateWaitTypeCount()
+ {
+ if (_waitTypeItems == null || WaitTypeCountText == null) return;
+ int count = _waitTypeItems.Count(x => x.IsSelected);
+ WaitTypeCountText.Text = $"{count} / 20 selected";
+ WaitTypeCountText.Foreground = count >= 20
+ ? new SolidColorBrush((System.Windows.Media.Color)ColorConverter.ConvertFromString("#E57373")!)
+ : (System.Windows.Media.Brush)FindResource("ForegroundMutedBrush");
}
private void ApplyWaitTypeFilter()
@@ -1026,15 +1103,10 @@ private void ApplyWaitTypeFilter()
private void WaitTypeSelectAll_Click(object sender, RoutedEventArgs e)
{
_isUpdatingWaitTypeSelection = true;
- var visible = (WaitTypesList.ItemsSource as IEnumerable)?.ToList() ?? _waitTypeItems;
- int count = visible.Count(i => i.IsSelected);
- foreach (var item in visible)
+ var topWaits = GetDefaultWaitTypes(_waitTypeItems.Select(x => x.DisplayName).ToList());
+ foreach (var item in _waitTypeItems)
{
- if (!item.IsSelected && count < 12)
- {
- item.IsSelected = true;
- count++;
- }
+ item.IsSelected = topWaits.Contains(item.DisplayName);
}
_isUpdatingWaitTypeSelection = false;
RefreshWaitTypeListOrder();
@@ -1062,10 +1134,11 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
{
try
{
- var selected = _waitTypeItems.Where(i => i.IsSelected).Take(12).ToList();
+ var selected = _waitTypeItems.Where(i => i.IsSelected).Take(20).ToList();
ClearChart(WaitStatsChart);
ApplyDarkTheme(WaitStatsChart);
+ _waitStatsHover?.Clear();
if (selected.Count == 0) { WaitStatsChart.Refresh(); return; }
@@ -1095,6 +1168,7 @@ private async System.Threading.Tasks.Task UpdateWaitStatsChartFromPickerAsync()
var plot = WaitStatsChart.Plot.Add.Scatter(times, waitTime);
plot.LegendText = selected[i].DisplayName;
plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]);
+ _waitStatsHover?.Add(plot, selected[i].DisplayName);
if (waitTime.Length > 0) globalMax = Math.Max(globalMax, waitTime.Max());
}
@@ -1204,6 +1278,7 @@ private async System.Threading.Tasks.Task UpdatePerfmonChartFromPickerAsync()
var selected = _perfmonCounterItems.Where(i => i.IsSelected).Take(12).ToList();
ClearChart(PerfmonChart);
+ _perfmonHover?.Clear();
ApplyDarkTheme(PerfmonChart);
if (selected.Count == 0) { PerfmonChart.Refresh(); return; }
@@ -1234,6 +1309,7 @@ private async System.Threading.Tasks.Task UpdatePerfmonChartFromPickerAsync()
var plot = PerfmonChart.Plot.Add.Scatter(times, values);
plot.LegendText = selected[i].DisplayName;
plot.Color = ScottPlot.Color.FromHex(SeriesColors[i % SeriesColors.Length]);
+ _perfmonHover?.Add(plot, selected[i].DisplayName);
if (values.Length > 0) globalMax = Math.Max(globalMax, values.Max());
}
@@ -1330,18 +1406,18 @@ private void ShowChartLegend(ScottPlot.WPF.WpfPlot chart)
///
private static void ApplyDarkTheme(ScottPlot.WPF.WpfPlot chart)
{
- var darkBackground = ScottPlot.Color.FromHex("#333333");
- var darkerBackground = ScottPlot.Color.FromHex("#252525");
- var textColor = ScottPlot.Color.FromHex("#E0E0E0");
- var gridColor = ScottPlot.Color.FromHex("#444444");
+ var darkBackground = ScottPlot.Color.FromHex("#22252b");
+ var darkerBackground = ScottPlot.Color.FromHex("#111217");
+ var textColor = ScottPlot.Color.FromHex("#9DA5B4");
+ var gridColor = ScottPlot.Colors.White.WithAlpha(20);
chart.Plot.FigureBackground.Color = darkBackground;
chart.Plot.DataBackground.Color = darkerBackground;
chart.Plot.Axes.Color(textColor);
chart.Plot.Grid.MajorLineColor = gridColor;
chart.Plot.Legend.BackgroundColor = darkBackground;
- chart.Plot.Legend.FontColor = textColor;
- chart.Plot.Legend.OutlineColor = gridColor;
+ chart.Plot.Legend.FontColor = ScottPlot.Color.FromHex("#E4E6EB");
+ chart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#2a2d35");
chart.Plot.Legend.Alignment = ScottPlot.Alignment.LowerCenter;
chart.Plot.Legend.Orientation = ScottPlot.Orientation.Horizontal;
chart.Plot.Axes.Margins(bottom: 0); /* No bottom margin - SetChartYLimitsWithLegendPadding handles Y-axis */
@@ -1357,7 +1433,7 @@ private static void ApplyDarkTheme(ScottPlot.WPF.WpfPlot chart)
///
private static void ReapplyAxisColors(ScottPlot.WPF.WpfPlot chart)
{
- var textColor = ScottPlot.Color.FromHex("#E0E0E0");
+ var textColor = ScottPlot.Color.FromHex("#9DA5B4");
chart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = textColor;
chart.Plot.Axes.Left.TickLabelStyle.ForeColor = textColor;
chart.Plot.Axes.Bottom.Label.ForeColor = textColor;
@@ -1772,4 +1848,105 @@ public void StopRefresh()
{
_refreshTimer.Stop();
}
+
+ /* ========== Column Filtering ========== */
+
+ private void InitializeFilterManagers()
+ {
+ _querySnapshotsFilterMgr = new DataGridFilterManager(QuerySnapshotsGrid);
+ _queryStatsFilterMgr = new DataGridFilterManager(QueryStatsGrid);
+ _procStatsFilterMgr = new DataGridFilterManager(ProcedureStatsGrid);
+ _queryStoreFilterMgr = new DataGridFilterManager(QueryStoreGrid);
+ _blockedProcessFilterMgr = new DataGridFilterManager(BlockedProcessReportGrid);
+ _deadlockFilterMgr = new DataGridFilterManager(DeadlockGrid);
+ _runningJobsFilterMgr = new DataGridFilterManager(RunningJobsGrid);
+ _serverConfigFilterMgr = new DataGridFilterManager(ServerConfigGrid);
+ _databaseConfigFilterMgr = new DataGridFilterManager(DatabaseConfigGrid);
+ _dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid);
+ _traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid);
+
+ _filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr;
+ _filterManagers[QueryStatsGrid] = _queryStatsFilterMgr;
+ _filterManagers[ProcedureStatsGrid] = _procStatsFilterMgr;
+ _filterManagers[QueryStoreGrid] = _queryStoreFilterMgr;
+ _filterManagers[BlockedProcessReportGrid] = _blockedProcessFilterMgr;
+ _filterManagers[DeadlockGrid] = _deadlockFilterMgr;
+ _filterManagers[RunningJobsGrid] = _runningJobsFilterMgr;
+ _filterManagers[ServerConfigGrid] = _serverConfigFilterMgr;
+ _filterManagers[DatabaseConfigGrid] = _databaseConfigFilterMgr;
+ _filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr;
+ _filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr;
+ }
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+ }
+
+ private DataGrid? _currentFilterGrid;
+
+ private void FilterButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnName) return;
+
+ /* Walk up visual tree to find the parent DataGrid */
+ var dataGrid = FindParentDataGridFromElement(button);
+ if (dataGrid == null || !_filterManagers.TryGetValue(dataGrid, out var manager)) return;
+
+ _currentFilterGrid = dataGrid;
+
+ EnsureFilterPopup();
+
+ /* Rewire events to the current grid */
+ _filterPopupContent!.FilterApplied -= FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared;
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ /* Initialize with existing filter state */
+ manager.Filters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent.Initialize(columnName, existingFilter);
+
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+
+ if (_currentFilterGrid != null && _filterManagers.TryGetValue(_currentFilterGrid, out var manager))
+ {
+ manager.SetFilter(e.FilterState);
+ }
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+ }
+
+ private static DataGrid? FindParentDataGridFromElement(DependencyObject element)
+ {
+ var current = element;
+ while (current != null)
+ {
+ if (current is DataGrid dg)
+ return dg;
+ current = VisualTreeHelper.GetParent(current);
+ }
+ return null;
+ }
}
diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs
new file mode 100644
index 00000000..5c1e41a0
--- /dev/null
+++ b/Lite/Helpers/ChartHoverHelper.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace PerformanceMonitorLite.Helpers;
+
+///
+/// Adds mouse-hover tooltips to a ScottPlot chart with multiple scatter series.
+/// Shows the series name, value, and timestamp in a popup that follows the mouse.
+///
+internal sealed class ChartHoverHelper
+{
+ private readonly ScottPlot.WPF.WpfPlot _chart;
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
+ private readonly Popup _popup;
+ private readonly TextBlock _text;
+ private readonly string _unit;
+ private DateTime _lastUpdate;
+
+ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
+ {
+ _chart = chart;
+ _unit = unit;
+
+ _text = new TextBlock
+ {
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 13
+ };
+
+ _popup = new Popup
+ {
+ PlacementTarget = chart,
+ Placement = PlacementMode.Relative,
+ IsHitTestVisible = false,
+ AllowsTransparency = true,
+ Child = new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(3),
+ Padding = new Thickness(8, 4, 8, 4),
+ Child = _text
+ }
+ };
+
+ chart.MouseMove += OnMouseMove;
+ chart.MouseLeave += OnMouseLeave;
+ }
+
+ public void Clear() => _scatters.Clear();
+
+ public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
+ _scatters.Add((scatter, label));
+
+ private void OnMouseMove(object sender, MouseEventArgs e)
+ {
+ if (_scatters.Count == 0) return;
+ var now = DateTime.UtcNow;
+ if ((now - _lastUpdate).TotalMilliseconds < 50) return;
+ _lastUpdate = now;
+
+ var pos = e.GetPosition(_chart);
+ var pixel = new ScottPlot.Pixel(
+ (float)(pos.X * _chart.DisplayScale),
+ (float)(pos.Y * _chart.DisplayScale));
+ var mouseCoords = _chart.Plot.GetCoordinates(pixel);
+
+ double bestDistance = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestLabel = "";
+
+ foreach (var (scatter, label) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
+ if (nearest.IsReal)
+ {
+ var nearestPixel = _chart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ double dx = nearestPixel.X - pixel.X;
+ double dy = nearestPixel.Y - pixel.Y;
+ double dist = dx * dx + dy * dy;
+ if (dist < bestDistance)
+ {
+ bestDistance = dist;
+ bestPoint = nearest;
+ bestLabel = label;
+ }
+ }
+ }
+
+ if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius
+ {
+ var time = DateTime.FromOADate(bestPoint.X);
+ _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}";
+ _popup.HorizontalOffset = pos.X + 15;
+ _popup.VerticalOffset = pos.Y + 15;
+ _popup.IsOpen = true;
+ }
+ else
+ {
+ _popup.IsOpen = false;
+ }
+ }
+
+ private void OnMouseLeave(object sender, MouseEventArgs e)
+ {
+ _popup.IsOpen = false;
+ }
+}
diff --git a/Lite/Helpers/MfaAuthenticationHelper.cs b/Lite/Helpers/MfaAuthenticationHelper.cs
new file mode 100644
index 00000000..dfedf948
--- /dev/null
+++ b/Lite/Helpers/MfaAuthenticationHelper.cs
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+
+namespace PerformanceMonitorLite.Helpers;
+
+///
+/// Helper utilities for Microsoft Entra MFA authentication.
+///
+public static class MfaAuthenticationHelper
+{
+ ///
+ /// Checks if an exception indicates that the user cancelled MFA authentication.
+ ///
+ /// The exception to check.
+ /// True if the exception represents user cancellation, false otherwise.
+ public static bool IsMfaCancelledException(Exception ex)
+ {
+ var message = ex.Message?.ToLowerInvariant() ?? string.Empty;
+
+ // Only treat explicit user cancellation messages as cancellation
+ // Do NOT treat authentication errors (wrong password, account selection, etc.) as cancellation
+ return message.Contains("user canceled") ||
+ message.Contains("user cancelled") ||
+ message.Contains("authentication was cancelled") ||
+ message.Contains("authentication was canceled");
+ }
+}
diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml
index adea8549..975a265f 100644
--- a/Lite/MainWindow.xaml
+++ b/Lite/MainWindow.xaml
@@ -12,6 +12,7 @@
+
@@ -190,37 +191,55 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs
index cfae29a2..0707ce4e 100644
--- a/Lite/MainWindow.xaml.cs
+++ b/Lite/MainWindow.xaml.cs
@@ -121,6 +121,8 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
await RefreshOverviewAsync();
StatusText.Text = "Ready - Collection active";
+
+ _ = CheckForUpdatesOnStartupAsync();
}
catch (Exception ex)
{
@@ -133,6 +135,37 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
}
}
+ private async Task CheckForUpdatesOnStartupAsync()
+ {
+ try
+ {
+ if (!App.CheckForUpdatesOnStartup) return;
+
+ var result = await UpdateCheckService.CheckForUpdateAsync();
+ if (result?.IsUpdateAvailable == true)
+ {
+ var answer = MessageBox.Show(
+ $"Performance Monitor {result.LatestVersion} is available (you have {result.CurrentVersion}).\n\nWould you like to open the download page?",
+ "Update Available",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Information);
+
+ if (answer == MessageBoxResult.Yes)
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = result.ReleaseUrl,
+ UseShellExecute = true
+ });
+ }
+ }
+ }
+ catch
+ {
+ // Never crash on update check failure
+ }
+ }
+
private async void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// Dispose system tray
@@ -186,6 +219,8 @@ private void ServerTabControl_SelectionChanged(object sender, SelectionChangedEv
{
ServerTimeHelper.UtcOffsetMinutes = serverTab.UtcOffsetMinutes;
}
+
+ UpdateCollectorHealth();
}
private void RefreshServerList()
@@ -257,7 +292,13 @@ private void UpdateCollectorHealth()
return;
}
- var health = _collectorService.GetHealthSummary();
+ int? selectedServerId = null;
+ if (ServerTabControl.SelectedItem is TabItem { Content: ServerTab serverTab })
+ {
+ selectedServerId = serverTab.ServerId;
+ }
+
+ var health = _collectorService.GetHealthSummary(selectedServerId);
if (health.TotalCollectors == 0)
{
@@ -307,6 +348,8 @@ private async Task RefreshOverviewAsync()
if (summary != null)
{
summary.ServerName = server.ServerName;
+ var connStatus = _serverManager.GetConnectionStatus(server.Id);
+ summary.IsOnline = connStatus.IsOnline;
summaries.Add(summary);
}
}
@@ -359,13 +402,23 @@ private async void ConnectToServer(ServerConnection server)
return;
}
+ // Clear MFA cancellation flag when user explicitly connects
+ // This gives them a fresh attempt at authentication
+ var currentStatus = _serverManager.GetConnectionStatus(server.Id);
+ if (server.AuthenticationType == AuthenticationTypes.EntraMFA && currentStatus.UserCancelledMfa)
+ {
+ currentStatus.UserCancelledMfa = false;
+ StatusText.Text = "Retrying MFA authentication...";
+ }
+
// Ensure connection status is populated with UTC offset before opening tab
// This is critical for timezone-correct chart display
var status = _serverManager.GetConnectionStatus(server.Id);
if (!status.UtcOffsetMinutes.HasValue)
{
StatusText.Text = "Checking server connection...";
- status = await _serverManager.CheckConnectionAsync(server.Id);
+ // Allow interactive auth (MFA) when user explicitly opens a server
+ status = await _serverManager.CheckConnectionAsync(server.Id, allowInteractiveAuth: true);
}
var utcOffset = status.UtcOffsetMinutes ?? 0;
diff --git a/Lite/Models/AuthenticationTypes.cs b/Lite/Models/AuthenticationTypes.cs
new file mode 100644
index 00000000..f2842e1a
--- /dev/null
+++ b/Lite/Models/AuthenticationTypes.cs
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// Constants for server authentication types.
+///
+public static class AuthenticationTypes
+{
+ ///
+ /// Windows integrated authentication.
+ ///
+ public const string Windows = "Windows";
+
+ ///
+ /// SQL Server username/password authentication.
+ ///
+ public const string SqlServer = "SqlServer";
+
+ ///
+ /// Microsoft Entra MFA (Azure AD) interactive authentication.
+ ///
+ public const string EntraMFA = "EntraMFA";
+}
diff --git a/Lite/Models/ColumnFilterState.cs b/Lite/Models/ColumnFilterState.cs
new file mode 100644
index 00000000..33574ff7
--- /dev/null
+++ b/Lite/Models/ColumnFilterState.cs
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// Represents the filter state for a single DataGrid column.
+///
+public class ColumnFilterState
+{
+ public string ColumnName { get; set; } = string.Empty;
+ public FilterOperator Operator { get; set; } = FilterOperator.Contains;
+ public string Value { get; set; } = string.Empty;
+
+ public bool IsActive => !string.IsNullOrEmpty(Value) ||
+ Operator == FilterOperator.IsEmpty ||
+ Operator == FilterOperator.IsNotEmpty;
+
+ public string DisplayText
+ {
+ get
+ {
+ if (!IsActive) return string.Empty;
+
+ return Operator switch
+ {
+ FilterOperator.Contains => $"Contains '{Value}'",
+ FilterOperator.Equals => $"= '{Value}'",
+ FilterOperator.NotEquals => $"!= '{Value}'",
+ FilterOperator.GreaterThan => $"> {Value}",
+ FilterOperator.GreaterThanOrEqual => $">= {Value}",
+ FilterOperator.LessThan => $"< {Value}",
+ FilterOperator.LessThanOrEqual => $"<= {Value}",
+ FilterOperator.StartsWith => $"Starts with '{Value}'",
+ FilterOperator.EndsWith => $"Ends with '{Value}'",
+ FilterOperator.IsEmpty => "Is Empty",
+ FilterOperator.IsNotEmpty => "Is Not Empty",
+ _ => Value
+ };
+ }
+ }
+
+ public static string GetOperatorDisplayName(FilterOperator op)
+ {
+ return op switch
+ {
+ FilterOperator.Contains => "Contains",
+ FilterOperator.Equals => "Equals (=)",
+ FilterOperator.NotEquals => "Not Equals (!=)",
+ FilterOperator.GreaterThan => "Greater Than (>)",
+ FilterOperator.GreaterThanOrEqual => "Greater or Equal (>=)",
+ FilterOperator.LessThan => "Less Than (<)",
+ FilterOperator.LessThanOrEqual => "Less or Equal (<=)",
+ FilterOperator.StartsWith => "Starts With",
+ FilterOperator.EndsWith => "Ends With",
+ FilterOperator.IsEmpty => "Is Empty",
+ FilterOperator.IsNotEmpty => "Is Not Empty",
+ _ => op.ToString()
+ };
+ }
+}
diff --git a/Lite/Models/FilterOperator.cs b/Lite/Models/FilterOperator.cs
new file mode 100644
index 00000000..3e823132
--- /dev/null
+++ b/Lite/Models/FilterOperator.cs
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+namespace PerformanceMonitorLite.Models;
+
+///
+/// Filter operators for column filtering in DataGrids.
+///
+public enum FilterOperator
+{
+ Contains,
+ Equals,
+ NotEquals,
+ GreaterThan,
+ GreaterThanOrEqual,
+ LessThan,
+ LessThanOrEqual,
+ StartsWith,
+ EndsWith,
+ IsEmpty,
+ IsNotEmpty
+}
diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs
index c3a8a3fa..34bd442e 100644
--- a/Lite/Models/ServerConnection.cs
+++ b/Lite/Models/ServerConnection.cs
@@ -18,7 +18,33 @@ public class ServerConnection
public string Id { get; set; } = Guid.NewGuid().ToString();
public string ServerName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
- public bool UseWindowsAuth { get; set; } = true;
+
+ ///
+ /// Backward compatibility property for old servers.json files.
+ /// Returns true if authentication type is Windows.
+ /// Setter updates AuthenticationType for migration from old configs.
+ ///
+ public bool UseWindowsAuth
+ {
+ get => AuthenticationType == AuthenticationTypes.Windows;
+ set
+ {
+ // During JSON deserialization of old configs, update AuthenticationType based on UseWindowsAuth
+ // Only apply this if AuthenticationType is still at default (indicating old JSON without that field)
+ if (AuthenticationType == AuthenticationTypes.Windows && !value)
+ {
+ // Old config with UseWindowsAuth=false -> SQL Server auth
+ AuthenticationType = AuthenticationTypes.SqlServer;
+ }
+ // If value is true, keep Windows (already the default)
+ }
+ }
+
+ ///
+ /// Authentication type: Windows, SqlServer, or EntraMFA
+ ///
+ public string AuthenticationType { get; set; } = AuthenticationTypes.Windows;
+
public string? Description { get; set; }
public DateTime CreatedDate { get; set; } = DateTime.Now;
public DateTime LastConnected { get; set; } = DateTime.Now;
@@ -48,7 +74,12 @@ public class ServerConnection
/// Display-only property for showing authentication type in UI.
///
[JsonIgnore]
- public string AuthenticationDisplay => UseWindowsAuth ? "Windows" : "SQL Server";
+ public string AuthenticationDisplay => AuthenticationType switch
+ {
+ AuthenticationTypes.EntraMFA => "Microsoft Entra MFA",
+ AuthenticationTypes.SqlServer => "SQL Server",
+ _ => "Windows"
+ };
///
/// Display-only property for showing status in UI.
@@ -65,7 +96,7 @@ public string GetConnectionString(CredentialService credentialService)
string? username = null;
string? password = null;
- if (!UseWindowsAuth)
+ if (AuthenticationType == AuthenticationTypes.SqlServer)
{
var cred = credentialService.GetCredential(Id);
if (cred.HasValue)
@@ -102,16 +133,27 @@ private string BuildConnectionString(string? username, string? password)
_ => SqlConnectionEncryptOption.Optional
};
- if (UseWindowsAuth)
+ if (AuthenticationType == AuthenticationTypes.Windows)
{
builder.IntegratedSecurity = true;
}
- else
+ else if (AuthenticationType == AuthenticationTypes.SqlServer)
{
builder.IntegratedSecurity = false;
builder.UserID = username ?? string.Empty;
builder.Password = password ?? string.Empty;
}
+ else if (AuthenticationType == AuthenticationTypes.EntraMFA)
+ {
+ // Microsoft Entra MFA (Azure AD Interactive)
+ builder.IntegratedSecurity = false;
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+ // Optionally set UserID (email/UPN)
+ if (!string.IsNullOrWhiteSpace(username))
+ {
+ builder.UserID = username;
+ }
+ }
return builder.ConnectionString;
}
@@ -121,7 +163,7 @@ private string BuildConnectionString(string? username, string? password)
///
public bool HasStoredCredentials(CredentialService credentialService)
{
- if (UseWindowsAuth)
+ if (AuthenticationType == AuthenticationTypes.Windows || AuthenticationType == AuthenticationTypes.EntraMFA)
{
return true;
}
diff --git a/Lite/Models/ServerConnectionStatus.cs b/Lite/Models/ServerConnectionStatus.cs
index 6aa365c3..16977173 100644
--- a/Lite/Models/ServerConnectionStatus.cs
+++ b/Lite/Models/ServerConnectionStatus.cs
@@ -88,6 +88,12 @@ public class ServerConnectionStatus
///
public int? UtcOffsetMinutes { get; set; }
+ ///
+ /// Indicates whether the user has cancelled MFA authentication for this server.
+ /// When true, MFA popups will not be shown until the user explicitly tries to connect again.
+ ///
+ public bool UserCancelledMfa { get; set; }
+
///
/// Gets the status display text for the UI.
///
diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj
index e91aa06f..a058adb3 100644
--- a/Lite/PerformanceMonitorLite.csproj
+++ b/Lite/PerformanceMonitorLite.csproj
@@ -12,6 +12,7 @@
Copyright © 2026 Darling Data, LLC
Lightweight SQL Server performance monitoring - no installation required on target servers
EDD.ico
+ app.manifest
true
latest-recommended
diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs
index ad329e03..bfcf995b 100644
--- a/Lite/Services/ArchiveService.cs
+++ b/Lite/Services/ArchiveService.cs
@@ -41,6 +41,7 @@ private static readonly (string Table, string TimeColumn)[] ArchivableTables =
("tempdb_stats", "collection_time"),
("perfmon_stats", "collection_time"),
("deadlocks", "collection_time"),
+ ("blocked_process_reports", "collection_time"),
("collection_log", "collection_time")
];
diff --git a/Lite/Services/DataGridFilterManager.cs b/Lite/Services/DataGridFilterManager.cs
new file mode 100644
index 00000000..37026a3e
--- /dev/null
+++ b/Lite/Services/DataGridFilterManager.cs
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using PerformanceMonitorLite.Models;
+
+namespace PerformanceMonitorLite.Services;
+
+///
+/// Non-generic interface for looking up filter state from a shared dictionary.
+///
+public interface IDataGridFilterManager
+{
+ Dictionary Filters { get; }
+ void SetFilter(ColumnFilterState filterState);
+ void UpdateFilterButtonStyles();
+}
+
+///
+/// Manages column filter state, unfiltered data capture, and filter application
+/// for a single DataGrid. Eliminates per-grid boilerplate code.
+///
+public class DataGridFilterManager : IDataGridFilterManager
+{
+ private readonly DataGrid _dataGrid;
+ private readonly Dictionary _filters = new();
+ private List? _unfilteredData;
+
+ public DataGridFilterManager(DataGrid dataGrid)
+ {
+ _dataGrid = dataGrid;
+ }
+
+ public Dictionary Filters => _filters;
+
+ ///
+ /// Called when new data arrives (refresh cycle). Captures unfiltered data,
+ /// then re-applies any active filters.
+ ///
+ public void UpdateData(List newData)
+ {
+ _unfilteredData = newData;
+
+ if (!HasActiveFilters())
+ {
+ _dataGrid.ItemsSource = newData;
+ return;
+ }
+
+ ApplyFilters();
+ }
+
+ ///
+ /// Applies or removes a filter and re-filters the data.
+ ///
+ public void SetFilter(ColumnFilterState filterState)
+ {
+ if (filterState.IsActive)
+ _filters[filterState.ColumnName] = filterState;
+ else
+ _filters.Remove(filterState.ColumnName);
+
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private bool HasActiveFilters()
+ {
+ return _filters.Count > 0 && _filters.Values.Any(f => f.IsActive);
+ }
+
+ private void ApplyFilters()
+ {
+ if (_unfilteredData == null) return;
+
+ if (!HasActiveFilters())
+ {
+ _dataGrid.ItemsSource = _unfilteredData;
+ return;
+ }
+
+ var filteredData = _unfilteredData.Where(item =>
+ {
+ foreach (var filter in _filters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item!, filter))
+ return false;
+ }
+ return true;
+ }).ToList();
+
+ _dataGrid.ItemsSource = filteredData;
+ }
+
+ ///
+ /// Updates filter icon colors (gold when active, dim when inactive).
+ ///
+ public void UpdateFilterButtonStyles()
+ {
+ foreach (var column in _dataGrid.Columns)
+ {
+ if (column.Header is StackPanel headerPanel)
+ {
+ var filterButton = headerPanel.Children.OfType
+
www.erikdarling.com
diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs
index 496b5a79..05a388a4 100644
--- a/Lite/Windows/AboutWindow.xaml.cs
+++ b/Lite/Windows/AboutWindow.xaml.cs
@@ -9,6 +9,7 @@
using System.Diagnostics;
using System.Reflection;
using System.Windows;
+using PerformanceMonitorLite.Services;
namespace PerformanceMonitorLite.Windows;
@@ -19,6 +20,8 @@ public partial class AboutWindow : Window
private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases";
private const string DarlingDataUrl = "https://www.erikdarling.com";
+ private string? _updateReleaseUrl;
+
public AboutWindow()
{
InitializeComponent();
@@ -37,9 +40,37 @@ private void ReportIssueLink_Click(object sender, RoutedEventArgs e)
OpenUrl(IssuesUrl);
}
- private void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
+ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e)
+ {
+ UpdateStatusText.Text = "Checking for updates...";
+ UpdateStatusText.Visibility = Visibility.Visible;
+
+ var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true);
+
+ if (result == null)
+ {
+ UpdateStatusText.Text = "Unable to check for updates. Please try again later.";
+ }
+ else if (result.IsUpdateAvailable)
+ {
+ _updateReleaseUrl = result.ReleaseUrl;
+ UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})";
+ UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand;
+ UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click;
+ UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline;
+ UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush
+ ?? System.Windows.Media.Brushes.DodgerBlue;
+ }
+ else
+ {
+ UpdateStatusText.Text = $"You're up to date ({result.CurrentVersion})";
+ }
+ }
+
+ private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
- OpenUrl(ReleasesUrl);
+ if (!string.IsNullOrEmpty(_updateReleaseUrl))
+ OpenUrl(_updateReleaseUrl);
}
private void DarlingDataLink_Click(object sender, RoutedEventArgs e)
diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml
index 730a8fe3..376d6f4a 100644
--- a/Lite/Windows/AddServerDialog.xaml
+++ b/Lite/Windows/AddServerDialog.xaml
@@ -43,7 +43,10 @@
+ Checked="AuthMode_Changed" Margin="0,0,0,4"/>
+
@@ -56,6 +59,13 @@
Padding="6,4"/>
+
+
+
+
+
+
diff --git a/Lite/Windows/AddServerDialog.xaml.cs b/Lite/Windows/AddServerDialog.xaml.cs
index 861452d8..085c8960 100644
--- a/Lite/Windows/AddServerDialog.xaml.cs
+++ b/Lite/Windows/AddServerDialog.xaml.cs
@@ -9,6 +9,7 @@
using System;
using System.Windows;
using Microsoft.Data.SqlClient;
+using PerformanceMonitorLite.Helpers;
using PerformanceMonitorLite.Models;
using PerformanceMonitorLite.Services;
@@ -17,6 +18,12 @@ namespace PerformanceMonitorLite.Windows;
public partial class AddServerDialog : Window
{
private readonly ServerManager _serverManager;
+ private static bool _isDialogOpen = false;
+
+ ///
+ /// Indicates if any AddServerDialog is currently open. Used to prevent background connection checks.
+ ///
+ public static bool IsDialogOpen => _isDialogOpen;
///
/// The server that was added, or null if the dialog was cancelled.
@@ -27,6 +34,8 @@ public AddServerDialog(ServerManager serverManager)
{
InitializeComponent();
_serverManager = serverManager;
+ _isDialogOpen = true;
+ Closed += (s, e) => _isDialogOpen = false;
}
///
@@ -51,13 +60,35 @@ public AddServerDialog(ServerManager serverManager, ServerConnection existing) :
DescriptionTextBox.Text = existing.Description ?? "";
DatabaseNameBox.Text = existing.DatabaseName ?? "";
- if (existing.UseWindowsAuth)
+ // Set authentication mode
+ if (existing.AuthenticationType == AuthenticationTypes.EntraMFA)
{
- WindowsAuthRadio.IsChecked = true;
+ EntraMfaAuthRadio.IsChecked = true;
+
+ // Load username if stored
+ var credentialService = new CredentialService();
+ var cred = credentialService.GetCredential(existing.Id);
+ if (cred.HasValue)
+ {
+ EntraMfaUsernameBox.Text = cred.Value.Username;
+ }
}
- else
+ else if (existing.AuthenticationType == AuthenticationTypes.SqlServer)
{
SqlAuthRadio.IsChecked = true;
+
+ // Load credentials if stored
+ var credentialService = new CredentialService();
+ var cred = credentialService.GetCredential(existing.Id);
+ if (cred.HasValue)
+ {
+ UsernameBox.Text = cred.Value.Username;
+ PasswordBox.Password = cred.Value.Password;
+ }
+ }
+ else
+ {
+ WindowsAuthRadio.IsChecked = true;
}
AddedServer = existing;
@@ -65,11 +96,17 @@ public AddServerDialog(ServerManager serverManager, ServerConnection existing) :
private void AuthMode_Changed(object sender, RoutedEventArgs e)
{
- if (SqlCredentialsPanel != null)
+ if (SqlCredentialsPanel != null && EntraMfaPanel != null)
{
+ // Show credentials panel for SQL Server authentication
SqlCredentialsPanel.Visibility = SqlAuthRadio.IsChecked == true
? Visibility.Visible
: Visibility.Collapsed;
+
+ // Show MFA panel for Microsoft Entra MFA
+ EntraMfaPanel.Visibility = EntraMfaAuthRadio.IsChecked == true
+ ? Visibility.Visible
+ : Visibility.Collapsed;
}
}
@@ -122,12 +159,27 @@ private async void TestButton_Click(object sender, RoutedEventArgs e)
{
builder.IntegratedSecurity = true;
}
- else
+ else if (SqlAuthRadio.IsChecked == true)
{
builder.IntegratedSecurity = false;
builder.UserID = UsernameBox.Text.Trim();
builder.Password = PasswordBox.Password;
}
+ else if (EntraMfaAuthRadio.IsChecked == true)
+ {
+ // Microsoft Entra MFA (Azure AD Interactive)
+ builder.IntegratedSecurity = false;
+ builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
+
+ // Optional: Use username if provided
+ var username = EntraMfaUsernameBox.Text.Trim();
+ if (!string.IsNullOrEmpty(username))
+ {
+ builder.UserID = username;
+ }
+
+ StatusText.Text = "Please complete authentication in the popup window...";
+ }
using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();
@@ -137,10 +189,25 @@ private async void TestButton_Click(object sender, RoutedEventArgs e)
var shortVersion = version?.Split('\n')[0] ?? "Connected";
StatusText.Text = $"Success: {shortVersion}";
+
+ // Clear any previous MFA cancellation flag on successful connection
+ if (AddedServer != null && EntraMfaAuthRadio.IsChecked == true)
+ {
+ var status = _serverManager.GetConnectionStatus(AddedServer.Id);
+ status.UserCancelledMfa = false;
+ }
}
catch (Exception ex)
{
StatusText.Text = $"Failed: {ex.Message}";
+
+ // Mark MFA as cancelled if user cancelled the authentication popup
+ if (AddedServer != null && EntraMfaAuthRadio.IsChecked == true && MfaAuthenticationHelper.IsMfaCancelledException(ex))
+ {
+ var status = _serverManager.GetConnectionStatus(AddedServer.Id);
+ status.UserCancelledMfa = true;
+ StatusText.Text = "Authentication cancelled by user. Click Test to try again.";
+ }
}
finally
{
@@ -163,12 +230,24 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
displayName = serverName;
}
- var useWindowsAuth = WindowsAuthRadio.IsChecked == true;
+ // Determine authentication type
+ string authenticationType;
string? username = null;
string? password = null;
- if (!useWindowsAuth)
+ if (WindowsAuthRadio.IsChecked == true)
+ {
+ authenticationType = AuthenticationTypes.Windows;
+ }
+ else if (EntraMfaAuthRadio.IsChecked == true)
+ {
+ authenticationType = AuthenticationTypes.EntraMFA;
+ // Optionally store username for MFA
+ username = EntraMfaUsernameBox.Text.Trim();
+ }
+ else // SQL Server Authentication
{
+ authenticationType = AuthenticationTypes.SqlServer;
username = UsernameBox.Text.Trim();
password = PasswordBox.Password;
@@ -186,7 +265,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
/* Editing existing server */
AddedServer.ServerName = serverName;
AddedServer.DisplayName = displayName;
- AddedServer.UseWindowsAuth = useWindowsAuth;
+ AddedServer.AuthenticationType = authenticationType;
AddedServer.IsEnabled = EnabledCheckBox.IsChecked == true;
AddedServer.TrustServerCertificate = TrustCertCheckBox.IsChecked == true;
AddedServer.EncryptMode = GetSelectedEncryptMode();
@@ -203,7 +282,7 @@ private void SaveButton_Click(object sender, RoutedEventArgs e)
{
ServerName = serverName,
DisplayName = displayName,
- UseWindowsAuth = useWindowsAuth,
+ AuthenticationType = authenticationType,
IsEnabled = EnabledCheckBox.IsChecked == true,
TrustServerCertificate = TrustCertCheckBox.IsChecked == true,
EncryptMode = GetSelectedEncryptMode(),
diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml.cs b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
index 4e059dea..1d9547b2 100644
--- a/Lite/Windows/ProcedureHistoryWindow.xaml.cs
+++ b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
@@ -120,10 +120,10 @@ private void UpdateChart()
private static void ApplyDarkTheme(ScottPlot.WPF.WpfPlot chart)
{
- var darkBg = ScottPlot.Color.FromHex("#333333");
- var darkerBg = ScottPlot.Color.FromHex("#252525");
- var text = ScottPlot.Color.FromHex("#E0E0E0");
- var grid = ScottPlot.Color.FromHex("#444444");
+ var darkBg = ScottPlot.Color.FromHex("#22252b");
+ var darkerBg = ScottPlot.Color.FromHex("#111217");
+ var text = ScottPlot.Color.FromHex("#9DA5B4");
+ var grid = ScottPlot.Colors.White.WithAlpha(20);
chart.Plot.FigureBackground.Color = darkBg;
chart.Plot.DataBackground.Color = darkerBg;
diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
index 5a9db62f..76a51bfd 100644
--- a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
@@ -155,10 +155,10 @@ private async void DownloadPlan_Click(object sender, RoutedEventArgs e)
private static void ApplyDarkTheme(ScottPlot.WPF.WpfPlot chart)
{
- var darkBg = ScottPlot.Color.FromHex("#333333");
- var darkerBg = ScottPlot.Color.FromHex("#252525");
- var text = ScottPlot.Color.FromHex("#E0E0E0");
- var grid = ScottPlot.Color.FromHex("#444444");
+ var darkBg = ScottPlot.Color.FromHex("#22252b");
+ var darkerBg = ScottPlot.Color.FromHex("#111217");
+ var text = ScottPlot.Color.FromHex("#9DA5B4");
+ var grid = ScottPlot.Colors.White.WithAlpha(20);
chart.Plot.FigureBackground.Color = darkBg;
chart.Plot.DataBackground.Color = darkerBg;
diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
index df036ee6..8699d208 100644
--- a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
@@ -158,10 +158,10 @@ private async void DownloadPlan_Click(object sender, RoutedEventArgs e)
private static void ApplyDarkTheme(ScottPlot.WPF.WpfPlot chart)
{
- var darkBg = ScottPlot.Color.FromHex("#333333");
- var darkerBg = ScottPlot.Color.FromHex("#252525");
- var text = ScottPlot.Color.FromHex("#E0E0E0");
- var grid = ScottPlot.Color.FromHex("#444444");
+ var darkBg = ScottPlot.Color.FromHex("#22252b");
+ var darkerBg = ScottPlot.Color.FromHex("#111217");
+ var text = ScottPlot.Color.FromHex("#9DA5B4");
+ var grid = ScottPlot.Colors.White.WithAlpha(20);
chart.Plot.FigureBackground.Color = darkBg;
chart.Plot.DataBackground.Color = darkerBg;
diff --git a/Lite/app.manifest b/Lite/app.manifest
new file mode 100644
index 00000000..a3dc0922
--- /dev/null
+++ b/Lite/app.manifest
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ PerMonitorV2
+ true
+
+
+
diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql
index 6d9b9c58..e25923e3 100644
--- a/install/02_create_tables.sql
+++ b/install/02_create_tables.sql
@@ -295,6 +295,7 @@ BEGIN
DEFAULT SYSDATETIME(),
event_time datetime2(7) NULL,
deadlock_xml xml NOT NULL,
+ is_processed bit NOT NULL DEFAULT 0,
CONSTRAINT
PK_deadlock_xml
PRIMARY KEY CLUSTERED
@@ -307,7 +308,7 @@ BEGIN
END;
/*
-8b. Blocked Process XML Storage
+8b. Blocked Process XML Storage
Raw blocked process XML for later analysis with sp_HumanEventsBlockViewer
*/
IF OBJECT_ID(N'collect.blocked_process_xml', N'U') IS NULL
@@ -316,15 +317,16 @@ BEGIN
collect.blocked_process_xml
(
id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
+ collection_time datetime2(7) NOT NULL
DEFAULT SYSDATETIME(),
event_time datetime2(7) NULL,
- blocked_process_xml xml NOT NULL,
- CONSTRAINT
- PK_blocked_process_xml
- PRIMARY KEY CLUSTERED
- (collection_time, id)
- WITH
+ blocked_process_xml xml NOT NULL,
+ is_processed bit NOT NULL DEFAULT 0,
+ CONSTRAINT
+ PK_blocked_process_xml
+ PRIMARY KEY CLUSTERED
+ (collection_time, id)
+ WITH
(DATA_COMPRESSION = PAGE)
);
diff --git a/install/06_ensure_collection_table.sql b/install/06_ensure_collection_table.sql
index 39773807..f5e23e73 100644
--- a/install/06_ensure_collection_table.sql
+++ b/install/06_ensure_collection_table.sql
@@ -57,7 +57,54 @@ BEGIN
BEGIN
IF @debug = 1
BEGIN
- RAISERROR(N'Table %s already exists', 0, 1, @full_table_name) WITH NOWAIT;
+ RAISERROR(N'Table %s already exists, checking for schema upgrades', 0, 1, @full_table_name) WITH NOWAIT;
+ END;
+
+ /*
+ Schema upgrade: Add is_processed column to raw XML tables if missing
+ This handles upgrades from older versions that did not track processing state
+ */
+ IF @table_name IN (N'blocked_process_xml', N'deadlock_xml')
+ BEGIN
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.columns AS c
+ JOIN sys.tables AS t
+ ON t.object_id = c.object_id
+ JOIN sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ WHERE s.name = N'collect'
+ AND t.name = @table_name
+ AND c.name = N'is_processed'
+ )
+ BEGIN
+ DECLARE
+ @alter_sql nvarchar(500) = N'ALTER TABLE ' + @full_table_name + N' ADD is_processed bit NOT NULL DEFAULT 0;';
+
+ EXECUTE sys.sp_executesql
+ @alter_sql;
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Added is_processed column to %s', 0, 1, @full_table_name) WITH NOWAIT;
+ END;
+
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ error_message
+ )
+ VALUES
+ (
+ N'ensure_collection_table',
+ N'SCHEMA_UPGRADE',
+ N'Added is_processed column to ' + @full_table_name
+ );
+ END;
END;
RETURN;
@@ -296,15 +343,16 @@ BEGIN
collect.deadlock_xml
(
id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
+ collection_time datetime2(7) NOT NULL
DEFAULT SYSDATETIME(),
event_time datetime2(7) NULL,
deadlock_xml xml NOT NULL,
- CONSTRAINT
- PK_deadlock_xml
- PRIMARY KEY CLUSTERED
- (collection_time, id)
- WITH
+ is_processed bit NOT NULL DEFAULT 0,
+ CONSTRAINT
+ PK_deadlock_xml
+ PRIMARY KEY CLUSTERED
+ (collection_time, id)
+ WITH
(DATA_COMPRESSION = PAGE)
);
END;
@@ -314,15 +362,16 @@ BEGIN
collect.blocked_process_xml
(
id bigint IDENTITY NOT NULL,
- collection_time datetime2(7) NOT NULL
+ collection_time datetime2(7) NOT NULL
DEFAULT SYSDATETIME(),
event_time datetime2(7) NULL,
- blocked_process_xml xml NOT NULL,
- CONSTRAINT
- PK_blocked_process_xml
- PRIMARY KEY CLUSTERED
- (collection_time, id)
- WITH
+ blocked_process_xml xml NOT NULL,
+ is_processed bit NOT NULL DEFAULT 0,
+ CONSTRAINT
+ PK_blocked_process_xml
+ PRIMARY KEY CLUSTERED
+ (collection_time, id)
+ WITH
(DATA_COMPRESSION = PAGE)
);
END;
@@ -1137,3 +1186,49 @@ GO
PRINT 'Ensure collection table procedure created successfully';
GO
+
+/*
+Schema upgrade: Add is_processed column to raw XML tables if missing
+This runs during installation to ensure the column exists before
+processors (files 23/25) reference it in their procedure definitions.
+The ensure_collection_table procedure also handles this for runtime upgrades.
+*/
+IF OBJECT_ID(N'collect.blocked_process_xml', N'U') IS NOT NULL
+AND NOT EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.columns AS c
+ JOIN sys.tables AS t
+ ON t.object_id = c.object_id
+ JOIN sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ WHERE s.name = N'collect'
+ AND t.name = N'blocked_process_xml'
+ AND c.name = N'is_processed'
+)
+BEGIN
+ ALTER TABLE collect.blocked_process_xml ADD is_processed bit NOT NULL DEFAULT 0;
+ PRINT 'Added is_processed column to collect.blocked_process_xml';
+END;
+GO
+
+IF OBJECT_ID(N'collect.deadlock_xml', N'U') IS NOT NULL
+AND NOT EXISTS
+(
+ SELECT
+ 1/0
+ FROM sys.columns AS c
+ JOIN sys.tables AS t
+ ON t.object_id = c.object_id
+ JOIN sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ WHERE s.name = N'collect'
+ AND t.name = N'deadlock_xml'
+ AND c.name = N'is_processed'
+)
+BEGIN
+ ALTER TABLE collect.deadlock_xml ADD is_processed bit NOT NULL DEFAULT 0;
+ PRINT 'Added is_processed column to collect.deadlock_xml';
+END;
+GO
diff --git a/install/08_collect_query_stats.sql b/install/08_collect_query_stats.sql
index dd4a4df9..df0163cd 100644
--- a/install/08_collect_query_stats.sql
+++ b/install/08_collect_query_stats.sql
@@ -269,7 +269,7 @@ BEGIN
THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset
- ) / 2
+ ) / 2 + 1
)
END,
query_plan_text = tqp.query_plan
diff --git a/install/23_process_blocked_process_xml.sql b/install/23_process_blocked_process_xml.sql
index d9b8e833..dff182d8 100644
--- a/install/23_process_blocked_process_xml.sql
+++ b/install/23_process_blocked_process_xml.sql
@@ -48,11 +48,14 @@ BEGIN
DECLARE
@rows_available integer = 0,
+ @rows_deleted bigint = 0,
+ @rows_marked bigint = 0,
@start_time datetime2(7) = SYSDATETIME(),
@error_message nvarchar(4000),
@error_number integer,
@blockviewer_database sysname = NULL,
- @sql nvarchar(max) = N'';
+ @sql nvarchar(max) = N'',
+ @debug_msg nvarchar(500) = N'';
BEGIN TRY
BEGIN TRANSACTION;
@@ -100,11 +103,14 @@ BEGIN
/*
Count unprocessed events
+ When no date range specified, only count unprocessed rows
+ When date range IS specified (manual re-processing), count all rows in range
*/
SELECT
@rows_available = COUNT_BIG(*)
FROM collect.blocked_process_xml AS bx
- WHERE (@start_date IS NULL OR bx.collection_time >= @start_date)
+ WHERE (@start_date IS NOT NULL OR bx.is_processed = 0)
+ AND (@start_date IS NULL OR bx.collection_time >= @start_date)
AND (@end_date IS NULL OR bx.collection_time <= @end_date)
OPTION(RECOMPILE);
@@ -115,19 +121,37 @@ BEGIN
IF @rows_available > 0
BEGIN
+ /*
+ Derive date range from unprocessed rows when not explicitly provided
+ This ensures we only parse new data and pass proper bounds to sp_HumanEventsBlockViewer
+ */
+ IF @start_date IS NULL AND @end_date IS NULL
+ BEGIN
+ SELECT
+ @start_date = MIN(bx.event_time),
+ @end_date = MAX(bx.event_time)
+ FROM collect.blocked_process_xml AS bx
+ WHERE bx.is_processed = 0
+ AND bx.event_time IS NOT NULL
+ OPTION(RECOMPILE);
+
+ IF @debug = 1
+ BEGIN
+ SET @debug_msg = N'Derived date range from unprocessed rows: ' + ISNULL(CONVERT(nvarchar(30), @start_date, 121), N'NULL') + N' to ' + ISNULL(CONVERT(nvarchar(30), @end_date, 121), N'NULL');
+ RAISERROR(@debug_msg, 0, 1) WITH NOWAIT;
+ END;
+ END;
+
/*
Delete existing parsed blocking events for the time range to prevent duplicates
sp_HumanEventsBlockViewer will re-insert fresh parsed data
*/
- DECLARE
- @rows_deleted bigint = 0;
-
- IF @start_date IS NOT NULL OR @end_date IS NOT NULL
+ IF @start_date IS NOT NULL AND @end_date IS NOT NULL
BEGIN
DELETE b
FROM collect.blocking_BlockedProcessReport AS b
- WHERE (@start_date IS NULL OR b.event_time >= @start_date)
- AND (@end_date IS NULL OR b.event_time <= @end_date);
+ WHERE b.event_time >= @start_date
+ AND b.event_time <= @end_date;
SELECT
@rows_deleted = ROWCOUNT_BIG();
@@ -137,16 +161,6 @@ BEGIN
RAISERROR(N'Deleted %I64d existing parsed blocking events for time range', 0, 1, @rows_deleted) WITH NOWAIT;
END;
END;
- ELSE
- BEGIN
- /*No date range specified - delete all and re-parse*/
- TRUNCATE TABLE collect.blocking_BlockedProcessReport;
-
- IF @debug = 1
- BEGIN
- RAISERROR(N'Truncated collect.blocking_BlockedProcessReport table (no date range specified)', 0, 1) WITH NOWAIT;
- END;
- END;
/*
Call sp_HumanEventsBlockViewer to parse the XML
@@ -180,6 +194,25 @@ BEGIN
@start_date = @start_date,
@end_date = @end_date,
@debug = @debug;
+
+ /*
+ Mark raw XML rows as processed
+ Only mark the rows in the date range we just processed
+ */
+ UPDATE bx
+ SET bx.is_processed = 1
+ FROM collect.blocked_process_xml AS bx
+ WHERE bx.is_processed = 0
+ AND (@start_date IS NULL OR bx.event_time >= @start_date)
+ AND (@end_date IS NULL OR bx.event_time <= @end_date);
+
+ SELECT
+ @rows_marked = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Marked %I64d raw XML rows as processed', 0, 1, @rows_marked) WITH NOWAIT;
+ END;
END;
/*
diff --git a/install/25_process_deadlock_xml.sql b/install/25_process_deadlock_xml.sql
index b34e00e9..91fb1974 100644
--- a/install/25_process_deadlock_xml.sql
+++ b/install/25_process_deadlock_xml.sql
@@ -47,11 +47,14 @@ BEGIN
DECLARE
@rows_available integer = 0,
+ @rows_deleted bigint = 0,
+ @rows_marked bigint = 0,
@start_time datetime2(7) = SYSDATETIME(),
@error_message nvarchar(4000),
@error_number integer,
@blitzlock_database sysname = NULL,
- @sql nvarchar(max) = N'';
+ @sql nvarchar(max) = N'',
+ @debug_msg nvarchar(500) = N'';
BEGIN TRY
BEGIN TRANSACTION;
@@ -99,11 +102,14 @@ BEGIN
/*
Count unprocessed events
+ When no date range specified, only count unprocessed rows
+ When date range IS specified (manual re-processing), count all rows in range
*/
SELECT
@rows_available = COUNT_BIG(*)
FROM collect.deadlock_xml AS dx
- WHERE (@start_date IS NULL OR dx.collection_time >= @start_date)
+ WHERE (@start_date IS NOT NULL OR dx.is_processed = 0)
+ AND (@start_date IS NULL OR dx.collection_time >= @start_date)
AND (@end_date IS NULL OR dx.collection_time <= @end_date)
OPTION(RECOMPILE);
@@ -114,19 +120,37 @@ BEGIN
IF @rows_available > 0
BEGIN
+ /*
+ Derive date range from unprocessed rows when not explicitly provided
+ This ensures we only parse new data and pass proper bounds to sp_BlitzLock
+ */
+ IF @start_date IS NULL AND @end_date IS NULL
+ BEGIN
+ SELECT
+ @start_date = MIN(dx.event_time),
+ @end_date = MAX(dx.event_time)
+ FROM collect.deadlock_xml AS dx
+ WHERE dx.is_processed = 0
+ AND dx.event_time IS NOT NULL
+ OPTION(RECOMPILE);
+
+ IF @debug = 1
+ BEGIN
+ SET @debug_msg = N'Derived date range from unprocessed rows: ' + ISNULL(CONVERT(nvarchar(30), @start_date, 121), N'NULL') + N' to ' + ISNULL(CONVERT(nvarchar(30), @end_date, 121), N'NULL');
+ RAISERROR(@debug_msg, 0, 1) WITH NOWAIT;
+ END;
+ END;
+
/*
Delete existing parsed deadlocks for the time range to prevent duplicates
sp_BlitzLock will re-insert fresh parsed data
*/
- DECLARE
- @rows_deleted bigint = 0;
-
- IF @start_date IS NOT NULL OR @end_date IS NOT NULL
+ IF @start_date IS NOT NULL AND @end_date IS NOT NULL
BEGIN
DELETE d
FROM collect.deadlocks AS d
- WHERE (@start_date IS NULL OR d.event_date >= @start_date)
- AND (@end_date IS NULL OR d.event_date <= @end_date);
+ WHERE d.event_date >= @start_date
+ AND d.event_date <= @end_date;
SELECT
@rows_deleted = ROWCOUNT_BIG();
@@ -136,16 +160,6 @@ BEGIN
RAISERROR(N'Deleted %I64d existing parsed deadlocks for time range', 0, 1, @rows_deleted) WITH NOWAIT;
END;
END;
- ELSE
- BEGIN
- /*No date range specified - delete all and re-parse*/
- TRUNCATE TABLE collect.deadlocks;
-
- IF @debug = 1
- BEGIN
- RAISERROR(N'Truncated collect.deadlocks table (no date range specified)', 0, 1) WITH NOWAIT;
- END;
- END;
/*
Call sp_BlitzLock to parse the deadlock XML
@@ -173,6 +187,25 @@ BEGIN
@start_date = @start_date,
@end_date = @end_date,
@debug = @debug;
+
+ /*
+ Mark raw XML rows as processed
+ Only mark the rows in the date range we just processed
+ */
+ UPDATE dx
+ SET dx.is_processed = 1
+ FROM collect.deadlock_xml AS dx
+ WHERE dx.is_processed = 0
+ AND (@start_date IS NULL OR dx.event_time >= @start_date)
+ AND (@end_date IS NULL OR dx.event_time <= @end_date);
+
+ SELECT
+ @rows_marked = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Marked %I64d raw XML rows as processed', 0, 1, @rows_marked) WITH NOWAIT;
+ END;
END;
/*