Skip to content

Commit 564bab0

Browse files
erikdarlingdataClaudioESSilvaclaudeHannahVernonMisterZeus
authored
Fix poison wait false positives and alert log parsing (#445) (#447)
* Fix #410 (#411) * Fix #412 (#413) * GUI installer: log installation history to config.installation_history (#414) The GUI installer read from installation_history for version detection but never wrote to it, causing subsequent upgrades to fail to detect the prior install. Adds LogInstallationHistoryAsync mirroring the CLI installer's existing LogInstallationHistory method. Fixes #409 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Feature/long running queries config settings (#415) * Added exclusions in GetLongRunningQueriesAsync() method for SP_SERVER_DIAGNOSTIC wait types, and WAITFOR wait types. * Added TOP parameter for query in GetLongRunningQueriesAsync() method to allow future configurability on the number of long-running queries returned. Added optional parameter to control display of WAITFOR types in future. * Replaced waitForFilter string constructor with C# string interpolation instead of janky string addition. * Added exclusion for backup-related waits to GetLongRunningQueriesAsync() method. Removed System.Collections.Generic using statement as it is unnecessary. * Reverted Controls/LandingPage.xaml.cs * Added BROKER_RECEIVE_WAITFOR wait type to waitforFilter exclusions. Added miscWaitsFilter to exclude XE_LIVE_TARGET_TVF waits. Removed unused parameters. Corrected minimum value for maxLongRunningQueryCount (minimum 1 instead of 5). * Added filtering for SP_SERVER_DIAGNOSTICS, WAITFOR, BROKER_RECEIVE_WAITFOR, BACKUPTHREAD, BACKUPIO, and XE_LIVE_TARGET_TVF wait types in Lite/Services/LocalDataService.WaitStats.cs * Add configurable max results setting for long-running queries Adds LongRunningQueryMaxResults to UserPreferences (default 5) and exposes it in the Settings UI alongside the existing duration threshold. Threads the value through GetAlertHealthAsync and GetLongRunningQueriesAsync to replace the hardcoded TOP(5) in the DMV query. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Apply max results validation and Lite parity for long-running queries Adds Math.Clamp(1, int.MaxValue) guard to GetLongRunningQueriesAsync in both Dashboard and Lite, and updates the Dashboard settings validation to use an explicit range check with a descriptive error message. Mirrors the LongRunningQueryMaxResults setting across the Lite project: adds the App property, loads/saves it to settings.json, exposes it in the Lite Settings UI, and passes it through to GetLongRunningQueriesAsync to replace the hardcoded LIMIT 5. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Use GetInt64() when loading long-running query max results from JSON Prevents an OverflowException if the value in settings.json is outside the int32 range. The value is read as long, clamped to [1, int.MaxValue], then cast back to int. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add configurable long-running query filter toggles Replaces hardcoded wait type exclusions in GetLongRunningQueriesAsync with user-configurable booleans for SP_SERVER_DIAGNOSTICS, WAITFOR / BROKER_RECEIVE_WAITFOR, backup waits, and miscellaneous waits. All four filters default to true (existing behavior preserved). Settings are exposed in the Notifications section of both Dashboard and Lite Settings UIs and persisted to UserPreferences / settings.json. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * merged with incoming dev branch * Parameterized TOP/LIMIT value in Dashboard/Services/DatabaseService.NocHealth.cs and Lite/Services/LocalDataService.WaitStats.cs, and clamped the upper bound of the value to 1000 to avoid foot shooting. Removed blank lines as per Erik's request. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * Sync plan viewer fixes from plan-b: spool labels, unmatched index detail (#416) - Spool operators now show Eager/Lazy prefix (e.g., "Eager Index Spool" instead of just "Index Spool") by prepending from LogicalOp - PlanIconMapper entries added for all Eager/Lazy spool variants - UnmatchedIndexes warning now parses child Parameterization elements to show specific database.schema.table.index names Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fixes many warnings, and pre-calculates the RegEx patterns at compile time instead of at runtime: warning CA1847: Use 'string.Contains(char)' instead of 'string.Contains(string)' when searching for a single character (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1847) warning CA1875: Use 'Regex.Count' instead of 'Regex.Matches(...).Count' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1875) warning CA1863: Cache a 'CompositeFormat' for repeated use in this formatting operation (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1863) (#346) Co-authored-by: Orestes Zoupanos <orestes.zoupanos@tpr.gov.uk> * Complete GeneratedRegex conversion and remove Compiled flags (#420) PR #346 left Lite PlanAnalyzer with 3 old-style regex fields and inline Regex.IsMatch calls, and added unnecessary RegexOptions.Compiled to all GeneratedRegex attributes (source generator always compiles, flag is ignored). - Convert remaining Lite regex fields to use GeneratedRegex source generator - Convert all inline Regex.IsMatch calls to GeneratedRegex in both apps - Remove RegexOptions.Compiled from all [GeneratedRegex] attributes - Keep both PlanAnalyzer copies in sync with identical regex methods Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add permissions section to README with least-privilege setup (#421) Documents required permissions for all platforms: on-prem Full/Lite, Azure SQL Database (contained user), Azure SQL MI, and AWS RDS. Includes copy-paste SQL scripts. Updates comparison table and troubleshooting to link to the new section. Prompted by user question in issue #418. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Replace custom TrayToolTip with plain ToolTipText to fix crash The custom visual TrayToolTip (Border + TextBlock) triggers a known race condition in Hardcodet.NotifyIcon.Wpf where Popup.CreateWindow throws "The root Visual of a VisualTarget cannot have a parent." This crash poisons the ReaderWriterLockSlim on the UI thread, breaking Overview queries permanently until restart. Plain string ToolTipText avoids the WPF Popup infrastructure entirely. Fixes #422 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add resilience to DuckDB read lock acquisition If an unhandled exception leaks a read lock on the UI thread, subsequent EnterReadLock() calls throw LockRecursionException permanently. Catch this and return a no-op disposable instead, since the thread already has read protection in place. This makes the lock self-healing: even if a crash leaks the lock, subsequent operations recover gracefully rather than failing forever. Fixes #423 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore custom TrayToolTip and silently handle Hardcodet crash (issue #422) Reverts the ToolTipText approach which caused context menu positioning and theme regressions. Instead, keeps the original custom TrayToolTip for proper dark theme styling and popup anchoring, and adds IsTrayToolTipCrash() detection in both apps' DispatcherUnhandledException handlers to silently swallow the rare Hardcodet VisualTarget race condition without showing error dialogs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix incorrect table name in Data Retention section README referenced config.retention_settings which doesn't exist. Retention is configured via the retention_days column in config.collection_schedule. Same bug reported in #223 but the fix never actually landed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix RID Lookup analyzer rule to match new PhysicalOp label (#429) The Key Lookup parser fix (PR #413) changes PhysicalOp from "RID Lookup" to "RID Lookup (Heap)". The analyzer rule used exact equality which no longer matched. Changed to StartsWith. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add uninstall option to CLI and GUI installers (#431) Adds complete uninstall capability that removes all server-level objects: - 3 SQL Agent jobs (Collection, Data Retention, Hung Job Monitor) - 2 Extended Events sessions (BlockedProcess, Deadlock) - Server-side traces - PerformanceMonitor database Changes: - New install/00_uninstall.sql standalone script for SSMS users - CLI: --uninstall flag with interactive confirmation - GUI: Uninstall button (red, enabled when installed version detected) - Fix: existing clean install now removes all 3 jobs + XE sessions (previously missed Hung Job Monitor and both XE sessions) blocked process threshold (s) is intentionally NOT reset during uninstall as other monitoring tools may depend on it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * LOB compression + deduplication for query stats tables (#419) Databases were growing to 150-200 GB in under a week on busy servers. LOB columns (query_text, query_plan_text, query_sql_text, compilation_metrics) were 92-94% of storage. COMPRESS()/DECOMPRESS() achieves 90-91% reduction. Schema changes: - query_stats, query_store_data, procedure_stats: LOB columns changed from nvarchar(max)/xml to varbinary(max) with COMPRESS() on write - Dropped unused query_plan xml columns - Added row_hash binary(32) for deduplication - Added tracking tables (query_stats_latest_hash, procedure_stats_latest_hash, query_store_data_latest_hash) for efficient hash lookups Collector changes (08, 09, 10): - COMPRESS() on all text/plan INSERT expressions - collect_query/collect_plan flag support added to query_stats and procedure_stats - HASHBYTES('SHA2_256', binary_concat) dedup: only INSERT rows where metrics changed - MERGE source deduped with ROW_NUMBER() to prevent duplicate key errors Read-side changes: - All reporting views (46, 47) wrap compressed columns in DECOMPRESS() - Dashboard C# queries updated with DECOMPRESS() in SQL strings Upgrade path: - Batched DELETE WITH OUTPUT migration compresses existing data in place - IDENTITY reseed preserves ID continuity - Old tables renamed to _old for safety (manual DROP later) Version bump: 2.1.0 → 2.2.0 across all four projects. Tested: clean install (sql2016), CLI upgrade (sql2017, sql2019, sql2022), GUI upgrade (sql2025). All collectors healthy, dedup validated, all views and MCP tools return readable decompressed data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add RESTORING database filter to waiting_tasks collector (#430) The waiting_tasks collector joins sys.databases without filtering d.state = 0 (ONLINE), which means sessions running in the context of a RESTORING database (mirroring passive/AG secondary) can pass through to sys.dm_exec_sql_text and sys.dm_exec_text_query_plan. While the dumps in #430 turned out to be an internal SQL Server 2016 background thread crash (not our collector), this hardens the waiting_tasks collector to match the pattern already used in query_stats, procedure_stats, query_store, and file_io_stats collectors (PR #385). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add CI check to require version bump on PRs to main Ensures the Dashboard.csproj version has been bumped before merging dev into main. Only runs on PRs from dev to main. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore commercial support tiers to README The procurement/compliance support tiers were accidentally removed during the README restructure in PR #377. Restores the Supported ($500/yr) and Priority ($2,500/yr) tiers alongside the existing sponsorship and consulting sections. Reported in #436. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add wait stats query drill-down (#372) Right-click any wait type in the chart legend to see queries causing it. Classifies waits into categories (correlated, chain, uncapturable, filtered) and shows the appropriate data: correlated metrics for brief waits like SOS_SCHEDULER_YIELD, head blockers for lock waits (LCK_M_*), and direct wait-type filtering for everything else. Chain mode shows the same full column set with blocking path prepended — no jarring layout changes. Both Dashboard and Lite. No new collectors needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix poison wait false positives and alert log parsing (#445) The Lite poison wait query had no time filter, so stale data from days/weeks ago with high avg waits kept triggering alerts indefinitely. Added a 10-minute window matching Dashboard's existing filter. Also fixed alert history logging: non-numeric display strings (poison wait, LRQ, TempDB, job alerts) failed double.TryParse and logged as 0/0. Added optional numeric parameters to TrySendAlertEmailAsync so call sites can pass actual values for the DuckDB alert log while keeping display strings for emails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Cláudio Silva <claudiosil100@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Hannah Vernon <hannah@mvct.com> Co-authored-by: Orestes <MisterZeus@users.noreply.github.com> Co-authored-by: Orestes Zoupanos <orestes.zoupanos@tpr.gov.uk>
1 parent ae208b3 commit 564bab0

70 files changed

Lines changed: 5657 additions & 307 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Check version bump
2+
on:
3+
pull_request:
4+
branches: [main]
5+
6+
jobs:
7+
check-version:
8+
if: github.head_ref == 'dev'
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Checkout PR branch
13+
uses: actions/checkout@v4
14+
15+
- name: Get PR version
16+
id: pr
17+
shell: pwsh
18+
run: |
19+
$version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
20+
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
21+
Write-Host "PR version: $version"
22+
23+
- name: Checkout main
24+
uses: actions/checkout@v4
25+
with:
26+
ref: main
27+
path: main-branch
28+
29+
- name: Get main version
30+
id: main
31+
shell: pwsh
32+
run: |
33+
$version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
34+
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
35+
Write-Host "Main version: $version"
36+
37+
- name: Compare versions
38+
env:
39+
PR_VERSION: ${{ steps.pr.outputs.VERSION }}
40+
MAIN_VERSION: ${{ steps.main.outputs.VERSION }}
41+
run: |
42+
echo "Main version: $MAIN_VERSION"
43+
echo "PR version: $PR_VERSION"
44+
if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then
45+
echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main."
46+
exit 1
47+
fi
48+
echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION"

Dashboard/App.xaml.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
9595

9696
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
9797
{
98+
/* Silently swallow Hardcodet TrayToolTip race condition (issue #422).
99+
The crash occurs in Popup.CreateWindow when showing the custom visual tooltip
100+
and is harmless — the tooltip simply doesn't show that one time. */
101+
if (IsTrayToolTipCrash(e.Exception))
102+
{
103+
Logger.Warning("Suppressed Hardcodet TrayToolTip crash (issue #422)");
104+
e.Handled = true;
105+
return;
106+
}
107+
98108
Logger.Error("Unhandled Dispatcher Exception", e.Exception);
99109

100110
MessageBox.Show(
@@ -114,6 +124,16 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv
114124
e.SetObserved(); // Prevent process termination
115125
}
116126

127+
/// <summary>
128+
/// Detects the Hardcodet TrayToolTip race condition crash (issue #422).
129+
/// </summary>
130+
private static bool IsTrayToolTipCrash(Exception ex)
131+
{
132+
return ex is ArgumentException
133+
&& ex.Message.Contains("VisualTarget")
134+
&& ex.StackTrace?.Contains("TaskbarIcon") == true;
135+
}
136+
117137
private void CreateCrashDump(Exception? exception)
118138
{
119139
try

Dashboard/Controls/PlanViewerControl.xaml.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,8 @@ private void ShowPropertiesPanel(PlanNode node)
534534

535535
// Header
536536
var headerText = node.PhysicalOp;
537-
if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
537+
if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
538+
&& !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
538539
headerText += $" ({node.LogicalOp})";
539540
PropertiesHeader.Text = headerText;
540541
PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
@@ -1481,7 +1482,8 @@ private ToolTip BuildNodeTooltip(PlanNode node)
14811482

14821483
// Header
14831484
var headerText = node.PhysicalOp;
1484-
if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp))
1485+
if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
1486+
&& !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
14851487
headerText += $" ({node.LogicalOp})";
14861488
stack.Children.Add(new TextBlock
14871489
{

Dashboard/Controls/ResourceMetricsContent.xaml.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ private void SetupChartContextMenus()
199199
TabHelpers.SetupChartContextMenu(PerfmonCountersChart, "Perfmon_Counters", "collect.perfmon_stats");
200200

201201
// Wait Stats Detail chart
202-
TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
202+
var waitStatsMenu = TabHelpers.SetupChartContextMenu(WaitStatsDetailChart, "Wait_Stats_Detail", "collect.wait_stats");
203+
AddWaitDrillDownMenuItem(WaitStatsDetailChart, waitStatsMenu);
203204
}
204205

205206
/// <summary>
@@ -1813,6 +1814,48 @@ private async void WaitType_CheckChanged(object sender, RoutedEventArgs e)
18131814
await UpdateWaitStatsDetailChartAsync();
18141815
}
18151816

1817+
private void AddWaitDrillDownMenuItem(ScottPlot.WPF.WpfPlot chart, ContextMenu contextMenu)
1818+
{
1819+
contextMenu.Items.Insert(0, new Separator());
1820+
var drillDownItem = new MenuItem { Header = "Show Queries With This Wait" };
1821+
drillDownItem.Click += ShowQueriesForWaitType_Click;
1822+
contextMenu.Items.Insert(0, drillDownItem);
1823+
1824+
contextMenu.Opened += (s, _) =>
1825+
{
1826+
var pos = System.Windows.Input.Mouse.GetPosition(chart);
1827+
var nearest = _waitStatsHover?.GetNearestSeries(pos);
1828+
if (nearest.HasValue)
1829+
{
1830+
drillDownItem.Tag = (nearest.Value.Label, nearest.Value.Time);
1831+
drillDownItem.Header = $"Show Queries With {nearest.Value.Label.Replace("_", "__")}";
1832+
drillDownItem.IsEnabled = true;
1833+
}
1834+
else
1835+
{
1836+
drillDownItem.Tag = null;
1837+
drillDownItem.Header = "Show Queries With This Wait";
1838+
drillDownItem.IsEnabled = false;
1839+
}
1840+
};
1841+
}
1842+
1843+
private void ShowQueriesForWaitType_Click(object sender, RoutedEventArgs e)
1844+
{
1845+
if (sender is not MenuItem menuItem) return;
1846+
if (menuItem.Tag is not ValueTuple<string, DateTime> tag) return;
1847+
if (_databaseService == null) return;
1848+
1849+
// ±15 minute window around the clicked point
1850+
var fromDate = tag.Item2.AddMinutes(-15);
1851+
var toDate = tag.Item2.AddMinutes(15);
1852+
1853+
var window = new WaitDrillDownWindow(
1854+
_databaseService, tag.Item1, 1, fromDate, toDate);
1855+
window.Owner = Window.GetWindow(this);
1856+
window.ShowDialog();
1857+
}
1858+
18161859
private void WaitStatsMetric_SelectionChanged(object sender, SelectionChangedEventArgs e)
18171860
{
18181861
if (_allWaitStatsDetailData != null)

Dashboard/Converters/QueryTextCleanupConverter.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
namespace PerformanceMonitorDashboard.Converters
1414
{
15-
public class QueryTextCleanupConverter : IValueConverter
15+
public partial class QueryTextCleanupConverter : IValueConverter
1616
{
1717
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
1818
{
@@ -28,7 +28,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn
2828
text = text.Replace("\t", " ", StringComparison.Ordinal);
2929

3030
// Replace multiple spaces with single space
31-
text = Regex.Replace(text, @"\s+", " ");
31+
text = MultipleSpacesRegExp().Replace(text, " ");
3232

3333
// Trim leading/trailing whitespace
3434
text = text.Trim();
@@ -40,5 +40,8 @@ public object ConvertBack(object value, Type targetType, object parameter, Cultu
4040
{
4141
throw new NotImplementedException();
4242
}
43+
44+
[GeneratedRegex(@"\s+")]
45+
private static partial Regex MultipleSpacesRegExp();
4346
}
4447
}

Dashboard/Dashboard.csproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
<UseWPF>true</UseWPF>
77
<AssemblyName>PerformanceMonitorDashboard</AssemblyName>
88
<Product>SQL Server Performance Monitor Dashboard</Product>
9-
<Version>2.1.0</Version>
10-
<AssemblyVersion>2.1.0.0</AssemblyVersion>
11-
<FileVersion>2.1.0.0</FileVersion>
12-
<InformationalVersion>2.1.0</InformationalVersion>
9+
<Version>2.2.0</Version>
10+
<AssemblyVersion>2.2.0.0</AssemblyVersion>
11+
<FileVersion>2.2.0.0</FileVersion>
12+
<InformationalVersion>2.2.0</InformationalVersion>
1313
<Company>Darling Data, LLC</Company>
1414
<Copyright>Copyright © 2026 Darling Data, LLC</Copyright>
1515
<ApplicationIcon>EDD.ico</ApplicationIcon>

Dashboard/Helpers/ChartHoverHelper.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,50 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit)
6161
public void Add(ScottPlot.Plottables.Scatter scatter, string label) =>
6262
_scatters.Add((scatter, label));
6363

64+
/// <summary>
65+
/// Returns the nearest series label and data-point time for the given mouse position,
66+
/// or null if no series is close enough.
67+
/// </summary>
68+
public (string Label, DateTime Time)? GetNearestSeries(Point mousePos)
69+
{
70+
if (_scatters.Count == 0) return null;
71+
try
72+
{
73+
var dpi = VisualTreeHelper.GetDpi(_chart);
74+
var pixel = new ScottPlot.Pixel(
75+
(float)(mousePos.X * dpi.DpiScaleX),
76+
(float)(mousePos.Y * dpi.DpiScaleY));
77+
var mouseCoords = _chart.Plot.GetCoordinates(pixel);
78+
79+
double bestYDistance = double.MaxValue;
80+
ScottPlot.DataPoint bestPoint = default;
81+
string bestLabel = "";
82+
bool found = false;
83+
84+
foreach (var (scatter, label) in _scatters)
85+
{
86+
var nearest = scatter.Data.GetNearest(mouseCoords, _chart.Plot.LastRender);
87+
if (!nearest.IsReal) continue;
88+
var nearestPixel = _chart.Plot.GetPixel(
89+
new ScottPlot.Coordinates(nearest.X, nearest.Y));
90+
double dx = Math.Abs(nearestPixel.X - pixel.X);
91+
double dy = Math.Abs(nearestPixel.Y - pixel.Y);
92+
if (dx < 80 && dy < bestYDistance)
93+
{
94+
bestYDistance = dy;
95+
bestPoint = nearest;
96+
bestLabel = label;
97+
found = true;
98+
}
99+
}
100+
101+
if (found)
102+
return (bestLabel, DateTime.FromOADate(bestPoint.X));
103+
}
104+
catch { }
105+
return null;
106+
}
107+
64108
private void OnMouseMove(object sender, MouseEventArgs e)
65109
{
66110
if (_scatters.Count == 0) return;
@@ -71,9 +115,10 @@ private void OnMouseMove(object sender, MouseEventArgs e)
71115
try
72116
{
73117
var pos = e.GetPosition(_chart);
118+
var dpi = VisualTreeHelper.GetDpi(_chart);
74119
var pixel = new ScottPlot.Pixel(
75-
(float)(pos.X * _chart.DisplayScale),
76-
(float)(pos.Y * _chart.DisplayScale));
120+
(float)(pos.X * dpi.DpiScaleX),
121+
(float)(pos.Y * dpi.DpiScaleY));
77122
var mouseCoords = _chart.Plot.GetCoordinates(pixel);
78123

79124
/* Use X-axis (time) proximity as the primary filter, Y-axis distance

Dashboard/Helpers/DateFilterHelper.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace PerformanceMonitorDashboard.Helpers
1313
{
14-
public static class DateFilterHelper
14+
public static partial class DateFilterHelper
1515
{
1616
public static bool MatchesFilter(object? value, string? filterText)
1717
{
@@ -148,7 +148,7 @@ private static bool TryConvertToDateTime(object value, out DateTime result)
148148
}
149149

150150
// "last N hours/days/weeks" expressions
151-
var lastMatch = Regex.Match(expressionLower, @"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)");
151+
var lastMatch = LastNHoursDaysWeeksMonthsRegExp().Match(expressionLower);
152152
if (lastMatch.Success)
153153
{
154154
int count = int.Parse(lastMatch.Groups[1].Value, CultureInfo.InvariantCulture);
@@ -231,5 +231,8 @@ private static bool IsRelativeExpression(string expression)
231231
expression == "tomorrow" ||
232232
Regex.IsMatch(expression, @"last\s+\d+\s+(hour|hours|day|days|week|weeks|month|months)");
233233
}
234+
235+
[GeneratedRegex(@"last\s+(\d+)\s+(hour|hours|day|days|week|weeks|month|months)")]
236+
private static partial Regex LastNHoursDaysWeeksMonthsRegExp();
234237
}
235238
}

Dashboard/Helpers/TabHelpers.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ public static string FormatForExport(object? value)
603603
/// <param name="chart">The WpfPlot chart control</param>
604604
/// <param name="chartName">A descriptive name for the chart (used in filenames)</param>
605605
/// <param name="dataSource">Optional SQL view/table name that populates this chart</param>
606-
public static void SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
606+
public static ContextMenu SetupChartContextMenu(WpfPlot chart, string chartName, string? dataSource = null)
607607
{
608608
var contextMenu = new ContextMenu();
609609

@@ -786,6 +786,8 @@ public static void SetupChartContextMenu(WpfPlot chart, string chartName, string
786786
chart.Plot.Axes.AutoScale();
787787
chart.Refresh();
788788
};
789+
790+
return contextMenu;
789791
}
790792

791793
/// <summary>

0 commit comments

Comments
 (0)