diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 67c7ecc2..00000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,67 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json - -language: "en-US" -early_access: false -enable_free_tier: true - -reviews: - profile: "chill" - high_level_summary: true - review_status: true - commit_status: true - collapse_walkthrough: true - sequence_diagrams: false - poem: false - - path_filters: - - "!**/*.Designer.cs" - - "!**/bin/**" - - "!**/obj/**" - - "!**/publish/**" - - "!**/*.user" - - "!**/*.suo" - - path_instructions: - - path: "Dashboard/**/*.cs" - instructions: > - This is a WPF .NET 8 desktop app (Dashboard) that reads from SQL Server. - Uses data binding, async/await patterns, and INotifyPropertyChanged. - Watch for: null reference risks, disposal of SQL connections, - thread safety with UI dispatch, and proper async patterns. - - path: "Lite/**/*.cs" - instructions: > - This is a WPF .NET 8 desktop app (Lite) that collects SQL Server DMV data - into a local DuckDB database. Uses ReaderWriterLockSlim for DB coordination. - Watch for: connection disposal, thread safety, DuckDB access patterns, - and proper async/await usage. - - path: "**/*.sql" - instructions: > - T-SQL stored procedures and scripts for SQL Server. - Watch for: SQL injection risks, missing error handling (TRY/CATCH), - proper use of SET NOCOUNT ON, and parameter sniffing concerns. - - path: "Installers/**" - instructions: > - WiX-based MSI installer projects. Be cautious about upgrade paths - and file versioning. Schema upgrades go in upgrades/ folder, not install scripts. - - auto_review: - enabled: true - drafts: false - base_branches: - - "dev" - - "main" - - tools: - gitleaks: - enabled: true - github-checks: - enabled: true - -chat: - auto_reply: true - -knowledge_base: - learnings: - scope: "local" - pull_requests: - scope: "local" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 408089fe..92d06a69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,9 +42,17 @@ jobs: - name: Publish Dashboard run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard + - name: Publish Dashboard (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack + - name: Publish Lite run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite + - name: Publish Lite (self-contained for Velopack) + if: github.event_name == 'release' + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack + - name: Publish CLI Installer run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release @@ -156,6 +164,25 @@ jobs: Remove-Item "releases/PerformanceMonitorInstaller-$version.zip" -ErrorAction SilentlyContinue Compress-Archive -Path 'signed/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force + - name: Create Velopack release (Dashboard) + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + dotnet tool install -g vpk + New-Item -ItemType Directory -Force -Path releases/velopack-dashboard + New-Item -ItemType Directory -Force -Path releases/velopack-lite + + # Dashboard: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorDashboard -v $env:VERSION -p publish/Dashboard-velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard + + # Lite: download previous + pack + vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN + vpk pack -u PerformanceMonitorLite -v $env:VERSION -p publish/Lite-velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite + - name: Generate checksums if: github.event_name == 'release' shell: pwsh @@ -174,3 +201,13 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber + + - name: Upload Velopack artifacts + if: github.event_name == 'release' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN + vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d7bf2c..1f8b28fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0] - 2026-03-23 + +### Important + +- **Lite data directory moved**: Lite now stores all data (config, DuckDB, archives, logs) in `%LOCALAPPDATA%\PerformanceMonitorLite\` instead of alongside the executable. This enables auto-update support. Existing users upgrading from the zip should use **Import Settings** and **Import Data** to bring over their configuration and historical data from the old install folder. +- **Auto-update (Windows)**: Both Dashboard and Lite now include Velopack auto-update. Users who install via the new Setup.exe will receive update notifications and can download + apply updates from within the app. Existing zip distribution continues to work as before. + +### Added + +- **Velopack auto-update** for Dashboard and Lite — check on startup, download + apply from About window with confirmation dialog before restart ([#635]) +- **Per-tab time range slicers** on Dashboard and Lite query tabs — filter data directly on each tab without changing global time range ([#655], [#662]) +- **Time display picker** (Local/UTC/Server) in Dashboard and Lite toolbars ([#646]) +- **Import Settings** — renamed from "Import Connections", now also copies `settings.json`, `collection_schedule.json`, `ignored_wait_types.json`, and `alert_state.json` from a previous install +- **Alert muting improvements** — pre-fill context fields (database, query, wait type, job name) from alert detail text, configurable default expiration for new mute rules, tooltip on query text field ([#642]) +- **Missing date columns** on Query Stats and Procedure Stats tabs (`creation_time`, `last_execution_time`) ([#649], [#651], [#654]) +- **Trace pattern drill-down** now includes `CollectionTime` and `NtUserName` columns ([#663]) +- **DataGrid sort preservation** across auto-refresh — sort order no longer resets when data refreshes ([#659]) +- **CLI installer**: colored output (green/red/yellow) and version check on startup ([#639]) +- **GUI installer**: version check on startup +- **Growth rate and VLF count** columns in Database Sizes (from v2.3.0 nightly, now in upgrade path) ([#567]) +- `llms.txt` and `CITATION.cff` for project discoverability ([#630]) + +### Changed + +- **Lite data directory** moved to `%LOCALAPPDATA%\PerformanceMonitorLite\` for Velopack compatibility +- **Delta gap detection** added to all cumulative-counter collectors (file I/O, wait stats, query stats, procedure stats, memory grants) — prevents inflated spikes after app restart ([#633]) +- **File I/O NULL fallbacks** improved when `sys.master_files` is inaccessible — falls back to `DB_NAME()` and `File_{id}` instead of generic "Unknown" ([#633]) +- **Running jobs collector** skipped gracefully when login lacks msdb access ([#656]) +- NuGet packages updated to latest minor versions ([#653]) + +### Fixed + +- **Installer writing SUCCESS when files fail** — CLI tolerated 1 failure in automated mode, GUI had a similar workaround. Now any failure = not success. +- **Query stats collector causing SQL dumps** on passive mirror servers — removed `dm_exec_plan_attributes` CROSS APPLY, uses temp table of ONLINE database IDs instead ([#632]) +- **Trigger name extraction** fails when comment before `CREATE TRIGGER` contains " ON " ([#666]) +- **FinOps expensive queries** DuckDB error — query referenced `statement_start_offset` column that doesn't exist in schema +- **Imported parquet files** not recognized by archive compaction — added regex patterns for `imported_` prefix +- **Auto-refresh after Import Data** — views now refresh immediately after import completes + +[#630]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/630 +[#632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/632 +[#633]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/633 +[#635]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/635 +[#639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/639 +[#642]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/642 +[#646]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/646 +[#649]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/649 +[#651]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/651 +[#653]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/653 +[#654]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/654 +[#655]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/655 +[#656]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/656 +[#659]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/659 +[#662]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/662 +[#663]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/663 +[#666]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/666 +[#567]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/567 + ## [2.3.0] - 2026-03-18 ### Important diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..53f532fd --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,22 @@ +cff-version: 1.2.0 +title: "SQL Server Performance Monitor" +message: "If you use this software, please cite it as below." +type: software +authors: + - given-names: Erik + family-names: Darling + affiliation: "Darling Data, LLC" + website: "https://erikdarling.com" +repository-code: "https://github.com/erikdarlingdata/PerformanceMonitor" +url: "https://erikdarling.com/free-sql-server-performance-monitoring/" +license: MIT +version: "2.3.0" +date-released: "2026-03-18" +keywords: + - sql-server + - performance-monitoring + - database-monitoring + - dba-tools + - mcp-server + - wait-stats + - execution-plans diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs index 0e61086e..19cd7a8b 100644 --- a/Dashboard/AboutWindow.xaml.cs +++ b/Dashboard/AboutWindow.xaml.cs @@ -8,6 +8,8 @@ using System.Reflection; using System.Windows; using PerformanceMonitorDashboard.Services; +using Velopack; +using System; namespace PerformanceMonitorDashboard { @@ -19,6 +21,8 @@ public partial class AboutWindow : Window private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; + private UpdateManager? _velopackMgr; + private Velopack.UpdateInfo? _pendingUpdate; public AboutWindow() { @@ -47,6 +51,32 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) UpdateStatusText.Text = "Checking for updates..."; UpdateStatusText.Visibility = Visibility.Visible; + // Try Velopack first (supports download + apply) + try + { + _velopackMgr = new UpdateManager( + new Velopack.Sources.GithubSource("https://github.com/erikdarlingdata/PerformanceMonitor", null, false)); + + var updateInfo = await _velopackMgr.CheckForUpdatesAsync(); + if (updateInfo != null) + { + _pendingUpdate = updateInfo; + UpdateStatusText.Text = $"Update available: v{updateInfo.TargetFullRelease.Version} — click to install"; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= UpdateStatusText_Click; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + return; + } + } + catch + { + // Velopack packages may not exist yet — fall through to legacy check + } + + // Fallback: GitHub Releases API check (opens browser) var result = await UpdateCheckService.CheckForUpdateAsync(bypassCache: true); if (result == null) @@ -56,8 +86,9 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) else if (result.IsUpdateAvailable) { _updateReleaseUrl = result.ReleaseUrl; - UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion})"; + UpdateStatusText.Text = $"Update available: {result.LatestVersion} (you have {result.CurrentVersion}) — click to open releases"; UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; UpdateStatusText.MouseLeftButtonUp += UpdateStatusText_Click; UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; UpdateStatusText.Foreground = FindResource("AccentBrush") as System.Windows.Media.Brush @@ -69,6 +100,50 @@ private async void CheckUpdatesLink_Click(object sender, RoutedEventArgs e) } } + private bool _updateDownloaded; + + private async void VelopackDownload_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (_velopackMgr == null || _pendingUpdate == null) return; + + // Step 3: restart with confirmation + if (_updateDownloaded) + { + var result = MessageBox.Show(this, + "The application will close and restart with the new version. Continue?", + "Update Ready", + MessageBoxButton.OKCancel, + MessageBoxImage.Question); + + if (result == MessageBoxResult.OK) + { + _velopackMgr.ApplyUpdatesAndRestart(_pendingUpdate.TargetFullRelease); + } + return; + } + + // Step 2: download + try + { + UpdateStatusText.MouseLeftButtonUp -= VelopackDownload_Click; + UpdateStatusText.TextDecorations = null; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Arrow; + UpdateStatusText.Text = "Downloading update..."; + + await _velopackMgr.DownloadUpdatesAsync(_pendingUpdate); + + _updateDownloaded = true; + UpdateStatusText.Text = "Update downloaded."; + UpdateStatusText.Cursor = System.Windows.Input.Cursors.Hand; + UpdateStatusText.TextDecorations = System.Windows.TextDecorations.Underline; + UpdateStatusText.MouseLeftButtonUp += VelopackDownload_Click; + } + catch (Exception ex) + { + UpdateStatusText.Text = $"Download failed: {ex.Message}"; + } + } + private void UpdateStatusText_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) { if (!string.IsNullOrEmpty(_updateReleaseUrl)) diff --git a/Dashboard/Analysis/FactScorer.cs b/Dashboard/Analysis/FactScorer.cs index 82382989..78347953 100644 --- a/Dashboard/Analysis/FactScorer.cs +++ b/Dashboard/Analysis/FactScorer.cs @@ -308,8 +308,10 @@ private static double ScoreBadActorFact(Fact fact) /// private static double ScoreAnomalyFact(Fact fact) { - if (fact.Key.StartsWith("ANOMALY_CPU_SPIKE") || fact.Key.StartsWith("ANOMALY_READ_LATENCY") - || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY")) + if ( fact.Key.StartsWith("ANOMALY_CPU_SPIKE" , StringComparison.OrdinalIgnoreCase) + || fact.Key.StartsWith("ANOMALY_READ_LATENCY" , StringComparison.OrdinalIgnoreCase) + || fact.Key.StartsWith("ANOMALY_WRITE_LATENCY", StringComparison.OrdinalIgnoreCase) + ) { // Deviation-based scoring: 2σ = 0.5, 4σ = 1.0 var deviation = fact.Metadata.GetValueOrDefault("deviation_sigma"); @@ -319,7 +321,7 @@ private static double ScoreAnomalyFact(Fact fact) return base_score * confidence; } - if (fact.Key.StartsWith("ANOMALY_WAIT_")) + if (fact.Key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase)) { // Ratio-based scoring: 5x = 0.5, 20x = 1.0 var ratio = fact.Metadata.GetValueOrDefault("ratio"); @@ -327,7 +329,9 @@ private static double ScoreAnomalyFact(Fact fact) return 0.5 + 0.5 * Math.Min((ratio - 5.0) / 15.0, 1.0); } - if (fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE") || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE")) + if ( fact.Key.StartsWith("ANOMALY_BLOCKING_SPIKE", StringComparison.OrdinalIgnoreCase) + || fact.Key.StartsWith("ANOMALY_DEADLOCK_SPIKE", StringComparison.OrdinalIgnoreCase) + ) { // Ratio-based: 3x = 0.5, 10x = 1.0 var ratio = fact.Metadata.GetValueOrDefault("ratio"); @@ -859,7 +863,7 @@ private static (double concerning, double? critical)? GetWaitThresholds(string w /// /// An amplifier definition: a named predicate that boosts severity when matched. /// -internal class AmplifierDefinition +internal sealed class AmplifierDefinition { public string Description { get; set; } = string.Empty; public double Boost { get; set; } diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.cs b/Dashboard/Analysis/SqlServerDrillDownCollector.cs index 050ffe30..cf435978 100644 --- a/Dashboard/Analysis/SqlServerDrillDownCollector.cs +++ b/Dashboard/Analysis/SqlServerDrillDownCollector.cs @@ -62,10 +62,15 @@ public async Task EnrichFindingsAsync(List findings, AnalysisCo if (pathKeys.Contains("QUERY_SPILLS")) await CollectTopSpillingQueries(finding, context); - if (pathKeys.Contains("IO_READ_LATENCY_MS") || pathKeys.Contains("IO_WRITE_LATENCY_MS")) + if ( pathKeys.Contains("IO_READ_LATENCY_MS") + || pathKeys.Contains("IO_WRITE_LATENCY_MS") + ) await CollectFileLatencyBreakdown(finding, context); - if (pathKeys.Contains("LCK") || pathKeys.Contains("LCK_M_S") || pathKeys.Contains("LCK_M_IS")) + if ( pathKeys.Contains("LCK") + || pathKeys.Contains("LCK_M_S") + || pathKeys.Contains("LCK_M_IS") + ) await CollectLockModeBreakdown(finding, context); if (pathKeys.Contains("DB_CONFIG")) @@ -77,7 +82,7 @@ public async Task EnrichFindingsAsync(List findings, AnalysisCo if (pathKeys.Contains("MEMORY_GRANT_PENDING")) await CollectPendingGrants(finding, context); - if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_"))) + if (pathKeys.Any(k => k.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase))) await CollectBadActorDetail(finding, context); // Plan analysis: for findings with top queries, analyze their cached plans @@ -618,7 +623,7 @@ private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext // Only analyze plans for bad actor findings (1 plan each). // Skip top_cpu_queries (5 plans would be too heavy). - if (!finding.RootFactKey.StartsWith("BAD_ACTOR_")) return; + if (!finding.RootFactKey.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) return; var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", ""); if (string.IsNullOrEmpty(queryHash)) return; diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs index a99d9aa1..446e4277 100644 --- a/Dashboard/Analysis/SqlServerFactCollector.cs +++ b/Dashboard/Analysis/SqlServerFactCollector.cs @@ -1671,15 +1671,15 @@ private static void GroupParallelismWaits(List facts, AnalysisContext cont /// private static bool IsGeneralLockWait(string waitType) { - if (!waitType.StartsWith("LCK_M_")) return false; + if (!waitType.StartsWith("LCK_M_", StringComparison.OrdinalIgnoreCase)) return false; // Keep individual: reader/writer locks if (waitType is "LCK_M_S" or "LCK_M_IS") return false; // Keep individual: range locks (serializable/repeatable read) - if (waitType.StartsWith("LCK_M_RS_") || - waitType.StartsWith("LCK_M_RIn_") || - waitType.StartsWith("LCK_M_RX_")) return false; + if (waitType.StartsWith("LCK_M_RS_", StringComparison.OrdinalIgnoreCase) || + waitType.StartsWith("LCK_M_RIn_", StringComparison.OrdinalIgnoreCase) || + waitType.StartsWith("LCK_M_RX_", StringComparison.OrdinalIgnoreCase)) return false; // Everything else (X, U, IX, SIX, BU, IU, UIX, etc.) -> group return true; diff --git a/Dashboard/App.xaml b/Dashboard/App.xaml index c8b1ea46..f3576c29 100644 --- a/Dashboard/App.xaml +++ b/Dashboard/App.xaml @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:helpers="clr-namespace:PerformanceMonitorDashboard.Helpers" - StartupUri="MainWindow.xaml"> + > diff --git a/Dashboard/App.xaml.cs b/Dashboard/App.xaml.cs index dfc546d8..738cd06c 100644 --- a/Dashboard/App.xaml.cs +++ b/Dashboard/App.xaml.cs @@ -14,6 +14,7 @@ using System.Windows.Markup; using System.Windows.Threading; using PerformanceMonitorDashboard.Helpers; +using Velopack; namespace PerformanceMonitorDashboard { @@ -59,6 +60,10 @@ protected override void OnStartup(StartupEventArgs e) Logger.Info($"OS: {Environment.OSVersion}"); Logger.Info($".NET Runtime: {Environment.Version}"); Logger.Info($"Log Directory: {Logger.GetLogDirectory()}"); + + // Create and show main window (StartupUri removed for Velopack custom Main) + var mainWindow = new MainWindow(); + mainWindow.Show(); } protected override void OnExit(ExitEventArgs e) diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs index 2911fa83..a85d2f6e 100644 --- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs +++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs @@ -484,6 +484,7 @@ private void MuteThisAlert_Click(object sender, RoutedEventArgs e) ServerName = item.ServerName, MetricName = item.MetricName }; + context.PopulateFromDetailText(item.DetailText); var dialog = new MuteRuleDialog(context) { Owner = Window.GetWindow(this) }; if (dialog.ShowDialog() == true) diff --git a/Dashboard/Controls/FinOpsContent.xaml.cs b/Dashboard/Controls/FinOpsContent.xaml.cs index 3533b833..e2a3a6c8 100644 --- a/Dashboard/Controls/FinOpsContent.xaml.cs +++ b/Dashboard/Controls/FinOpsContent.xaml.cs @@ -16,7 +16,6 @@ using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Data; -using System.Windows.Controls.Primitives; using System.Windows.Media; using Microsoft.Win32; using PerformanceMonitorDashboard.Helpers; diff --git a/Dashboard/Controls/PlanViewerControl.xaml.cs b/Dashboard/Controls/PlanViewerControl.xaml.cs index 43590b72..3ec40d49 100644 --- a/Dashboard/Controls/PlanViewerControl.xaml.cs +++ b/Dashboard/Controls/PlanViewerControl.xaml.cs @@ -454,7 +454,7 @@ private WpfPath CreateElbowConnector(PlanNode parent, PlanNode child) }; } - private object BuildEdgeTooltipContent(PlanNode child) + private Border BuildEdgeTooltipContent(PlanNode child) { var grid = new Grid { MinWidth = 240 }; grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/Dashboard/Controls/QueryPerformanceContent.xaml index 5236029c..2126752a 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml +++ b/Dashboard/Controls/QueryPerformanceContent.xaml @@ -80,7 +80,12 @@ - + + + + + @@ -565,10 +570,16 @@ - + + + + + @@ -859,10 +870,16 @@ - + + + + + @@ -1122,10 +1139,16 @@ - + + + + + diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index 48fe2a57..cd6e293b 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -225,6 +225,274 @@ public void Initialize(DatabaseService databaseService, Action? statusCa { _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); _statusCallback = statusCallback; + ActiveQueriesSlicer.RangeChanged += OnActiveQueriesSlicerChanged; + QueryStatsSlicer.RangeChanged += OnQueryStatsSlicerChanged; + ProcStatsSlicer.RangeChanged += OnProcStatsSlicerChanged; + QueryStoreSlicer.RangeChanged += OnQueryStoreSlicerChanged; + } + + // ── Active Queries Slicer ── + + private async Task LoadActiveQueriesSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetActiveQuerySlicerDataAsync( + _activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); + if (data.Count > 0) + ActiveQueriesSlicer.LoadData(data, "Sessions"); + } + catch { } + } + + private async void OnActiveQueriesSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + // Dashboard data is in server time; slicer sends server time directly + var data = await _databaseService.GetQuerySnapshotsAsync(0, e.Start, e.End); + _activeQueriesUnfilteredData = data; + ActiveQueriesDataGrid.ItemsSource = data; + ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + catch { } + } + + // ── Query Stats Slicer ── + + private List? _queryStatsSlicerData; + private string _queryStatsSlicerMetric = "TotalCpu"; + + private async Task LoadQueryStatsSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStatsSlicerDataAsync( + _queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); + _queryStatsSlicerData = data; + _queryStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStatsSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnQueryStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStatsAsync(0, e.Start, e.End, fromSlicer: true); + PopulateQueryStatsGrid(data); + } + catch { } + } + + private void QueryStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStatsSlicerData == null || _queryStatsSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc && bc.Binding is System.Windows.Data.Binding b) + col = b.Path.Path; + + var (metric, label) = col switch + { + "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" or "AvgLogicalReads" => ("TotalReads", "Total Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"), + "IntervalExecutions" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStatsSlicerMetric) return; + _queryStatsSlicerMetric = metric; + + foreach (var bucket in _queryStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + QueryStatsSlicer.UpdateMetric(label); + } + + // ── Procedure Stats Slicer ── + + private List? _procStatsSlicerData; + private string _procStatsSlicerMetric = "TotalCpu"; + + private async Task LoadProcStatsSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetProcStatsSlicerDataAsync( + _procStatsHoursBack, _procStatsFromDate, _procStatsToDate); + _procStatsSlicerData = data; + _procStatsSlicerMetric = "TotalCpu"; + if (data.Count > 0) + ProcStatsSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnProcStatsSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetProcedureStatsAsync(0, e.Start, e.End, fromSlicer: true); + PopulateProcStatsGrid(data); + } + catch { } + } + + private void ProcStatsDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_procStatsSlicerData == null || _procStatsSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc2 && bc2.Binding is System.Windows.Data.Binding b2) + col = b2.Path.Path; + + var (metric, label) = col switch + { + "TotalWorkerTimeMs" => ("TotalCpu", "Total CPU (ms)"), + "AvgWorkerTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "TotalElapsedTimeMs" => ("TotalElapsed", "Total Duration (ms)"), + "AvgElapsedTimeMs" => ("AvgElapsed", "Avg Duration (ms)"), + "TotalLogicalReads" => ("TotalReads", "Total Reads"), + "TotalLogicalWrites" => ("TotalWrites", "Total Writes"), + "TotalPhysicalReads" => ("TotalReads", "Total Physical Reads"), + "IntervalExecutions" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _procStatsSlicerMetric) return; + _procStatsSlicerMetric = metric; + + foreach (var bucket in _procStatsSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + ProcStatsSlicer.UpdateMetric(label); + } + + // ── Query Store Slicer ── + + private List? _queryStoreSlicerData; + private string _queryStoreSlicerMetric = "TotalCpu"; + + private async Task LoadQueryStoreSlicerAsync() + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStoreSlicerDataAsync( + _queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); + _queryStoreSlicerData = data; + _queryStoreSlicerMetric = "TotalCpu"; + if (data.Count > 0) + QueryStoreSlicer.LoadData(data, "Total CPU (ms)"); + } + catch { } + } + + private async void OnQueryStoreSlicerChanged(object? sender, Controls.SlicerRangeEventArgs e) + { + if (_databaseService == null) return; + try + { + var data = await _databaseService.GetQueryStoreDataAsync(0, e.Start, e.End, fromSlicer: true); + PopulateQueryStoreGrid(data); + } + catch { } + } + + private void QueryStoreDataGrid_Sorting(object sender, DataGridSortingEventArgs e) + { + if (_queryStoreSlicerData == null || _queryStoreSlicerData.Count == 0) return; + + var col = e.Column.SortMemberPath ?? ""; + if (string.IsNullOrEmpty(col) && e.Column is DataGridBoundColumn bc3 && bc3.Binding is System.Windows.Data.Binding b3) + col = b3.Path.Path; + + var (metric, label) = col switch + { + "AvgCpuTimeMs" => ("AvgCpu", "Avg CPU (ms)"), + "AvgDurationMs" => ("AvgElapsed", "Avg Duration (ms)"), + "AvgLogicalReads" => ("TotalReads", "Avg Reads"), + "AvgLogicalWrites" => ("TotalWrites", "Avg Writes"), + "AvgPhysicalReads" => ("TotalReads", "Avg Physical Reads"), + "ExecutionCount" => ("Sessions", "Executions"), + _ => ("TotalCpu", "Total CPU (ms)"), + }; + + if (metric == _queryStoreSlicerMetric) return; + _queryStoreSlicerMetric = metric; + + foreach (var bucket in _queryStoreSlicerData) + { + var n = bucket.SessionCount > 0 ? bucket.SessionCount : 1; + bucket.Value = metric switch + { + "TotalCpu" => bucket.TotalCpu, + "AvgCpu" => bucket.TotalCpu / n, + "TotalElapsed" => bucket.TotalElapsed, + "AvgElapsed" => bucket.TotalElapsed / n, + "TotalReads" => bucket.TotalReads, + "TotalWrites" => bucket.TotalWrites, + "Sessions" => bucket.SessionCount, + _ => bucket.TotalCpu, + }; + } + + QueryStoreSlicer.UpdateMetric(label); + } + + public void RefreshGridBindings() + { + QueryStatsDataGrid.Items.Refresh(); + ProcStatsDataGrid.Items.Refresh(); + QueryStoreDataGrid.Items.Refresh(); + QueryStoreRegressionsDataGrid.Items.Refresh(); + ActiveQueriesDataGrid.Items.Refresh(); + CurrentActiveQueriesDataGrid.Items.Refresh(); + LongRunningQueryPatternsDataGrid.Items.Refresh(); + ActiveQueriesSlicer.Redraw(); + QueryStatsSlicer.Redraw(); + ProcStatsSlicer.Redraw(); + QueryStoreSlicer.Redraw(); } /// @@ -322,9 +590,37 @@ await Task.WhenAll( ); // Populate grids from summary data - PopulateQueryStatsGrid(await queryStatsTask); - PopulateProcStatsGrid(await procStatsTask); - PopulateQueryStoreGrid(await queryStoreTask); + // If slicer is narrowed, re-query with slicer dates instead of global range + if (QueryStatsSlicer.HasNarrowedSelection) + { + var slicerData = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true); + PopulateQueryStatsGrid(slicerData); + } + else + { + PopulateQueryStatsGrid(await queryStatsTask); + } + LoadQueryStatsSlicerAsync().ConfigureAwait(false); + if (ProcStatsSlicer.HasNarrowedSelection) + { + var slicerProcData = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true); + PopulateProcStatsGrid(slicerProcData); + } + else + { + PopulateProcStatsGrid(await procStatsTask); + } + LoadProcStatsSlicerAsync().ConfigureAwait(false); + if (QueryStoreSlicer.HasNarrowedSelection) + { + var slicerQsData = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true); + PopulateQueryStoreGrid(slicerQsData); + } + else + { + PopulateQueryStoreGrid(await queryStoreTask); + } + LoadQueryStoreSlicerAsync().ConfigureAwait(false); // Populate charts from time-series data LoadDurationChart(QueryPerfTrendsQueryChart, await queryDurationTrendsTask, _perfTrendsHoursBack, _perfTrendsFromDate, _perfTrendsToDate, "Duration (ms/sec)", TabHelpers.ChartColors[0], _queryDurationHover); @@ -362,43 +658,55 @@ private async Task RefreshPerformanceTrendsAsync() private async Task RefreshQueryStatsGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); + List data; + if (QueryStatsSlicer.HasNarrowedSelection) + data = await _databaseService.GetQueryStatsAsync(0, QueryStatsSlicer.SelectionStart, QueryStatsSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetQueryStatsAsync(_queryStatsHoursBack, _queryStatsFromDate, _queryStatsToDate); PopulateQueryStatsGrid(data); + LoadQueryStatsSlicerAsync().ConfigureAwait(false); } private async Task RefreshProcStatsGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate); + List data; + if (ProcStatsSlicer.HasNarrowedSelection) + data = await _databaseService.GetProcedureStatsAsync(0, ProcStatsSlicer.SelectionStart, ProcStatsSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetProcedureStatsAsync(_procStatsHoursBack, _procStatsFromDate, _procStatsToDate); PopulateProcStatsGrid(data); + LoadProcStatsSlicerAsync().ConfigureAwait(false); } private async Task RefreshQueryStoreGridAsync() { if (_databaseService == null) return; - var data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); + List data; + if (QueryStoreSlicer.HasNarrowedSelection) + data = await _databaseService.GetQueryStoreDataAsync(0, QueryStoreSlicer.SelectionStart, QueryStoreSlicer.SelectionEnd, fromSlicer: true); + else + data = await _databaseService.GetQueryStoreDataAsync(_queryStoreHoursBack, _queryStoreFromDate, _queryStoreToDate); PopulateQueryStoreGrid(data); + LoadQueryStoreSlicerAsync().ConfigureAwait(false); } private void PopulateQueryStatsGrid(List data) { - QueryStatsDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(QueryStatsDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending); QueryStatsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - SetInitialSort(QueryStatsDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending); } private void PopulateProcStatsGrid(List data) { - ProcStatsDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(ProcStatsDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending); ProcStatsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - SetInitialSort(ProcStatsDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending); } private void PopulateQueryStoreGrid(List data) { - QueryStoreDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(QueryStoreDataGrid, data, "AvgCpuTimeMs", ListSortDirection.Descending); QueryStoreNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - SetInitialSort(QueryStoreDataGrid, "AvgCpuTimeMs", ListSortDirection.Descending); } private void SetStatus(string message) @@ -406,16 +714,42 @@ private void SetStatus(string message) _statusCallback?.Invoke(message); } - private static void SetInitialSort(DataGrid grid, string bindingPath, ListSortDirection direction) + private static void SetItemsSourcePreservingSort( + DataGrid grid, System.Collections.IEnumerable? newSource, + string? defaultSortProperty = null, + ListSortDirection defaultDirection = ListSortDirection.Descending) { - foreach (var column in grid.Columns) + var savedSorts = grid.Items.SortDescriptions.ToList(); + + grid.ItemsSource = newSource; + + if (savedSorts.Count > 0) { - if (column is DataGridBoundColumn bc && - bc.Binding is Binding b && - b.Path.Path == bindingPath) + foreach (var sort in savedSorts) + grid.Items.SortDescriptions.Add(sort); + + foreach (var column in grid.Columns) { - column.SortDirection = direction; - return; + if (column is DataGridBoundColumn bc && + bc.Binding is Binding b) + { + var match = savedSorts.FirstOrDefault(s => s.PropertyName == b.Path.Path); + column.SortDirection = match.PropertyName != null ? match.Direction : null; + } + } + } + else if (defaultSortProperty != null) + { + grid.Items.SortDescriptions.Add(new SortDescription(defaultSortProperty, defaultDirection)); + foreach (var column in grid.Columns) + { + if (column is DataGridBoundColumn bc && + bc.Binding is Binding b && + b.Path.Path == defaultSortProperty) + { + column.SortDirection = defaultDirection; + return; + } } } } @@ -529,10 +863,21 @@ private async Task RefreshActiveQueriesAsync() } SetStatus("Loading active queries..."); - var data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); - ActiveQueriesDataGrid.ItemsSource = data; + // If user has narrowed the slicer, use slicer dates for the grid + List data; + if (ActiveQueriesSlicer.HasNarrowedSelection) + { + data = await _databaseService.GetQuerySnapshotsAsync(0, ActiveQueriesSlicer.SelectionStart, ActiveQueriesSlicer.SelectionEnd); + } + else + { + data = await _databaseService.GetQuerySnapshotsAsync(_activeQueriesHoursBack, _activeQueriesFromDate, _activeQueriesToDate); + } + + SetItemsSourcePreservingSort(ActiveQueriesDataGrid, data); ActiveQueriesNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; SetStatus($"Loaded {data.Count} query snapshots"); + LoadActiveQueriesSlicerAsync().ConfigureAwait(false); } catch (Exception ex) { @@ -693,7 +1038,7 @@ private async Task RefreshCurrentActiveQueriesAsync() var data = await _databaseService.GetCurrentActiveQueriesAsync(); _currentActiveUnfilteredData = data; - CurrentActiveQueriesDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(CurrentActiveQueriesDataGrid, data); CurrentActiveNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; CurrentActiveTimestamp.Text = $"Last refreshed: {DateTime.Now:HH:mm:ss} — {data.Count} queries"; @@ -1435,9 +1780,8 @@ private async Task RefreshQueryStoreRegressionsAsync() { SetStatus("Loading query store regressions..."); var data = await _databaseService.GetQueryStoreRegressionsAsync(_qsRegressionsHoursBack, _qsRegressionsFromDate, _qsRegressionsToDate); - QueryStoreRegressionsDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(QueryStoreRegressionsDataGrid, data, "DurationRegressionPercent", ListSortDirection.Descending); QueryStoreRegressionsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - SetInitialSort(QueryStoreRegressionsDataGrid, "DurationRegressionPercent", ListSortDirection.Descending); SetStatus($"Loaded {data.Count} query store regression records"); } catch (Exception ex) @@ -1536,9 +1880,8 @@ private async Task RefreshLongRunningPatternsAsync() { SetStatus("Loading long running query patterns..."); var data = await _databaseService.GetLongRunningQueryPatternsAsync(_lrqPatternsHoursBack, _lrqPatternsFromDate, _lrqPatternsToDate); - LongRunningQueryPatternsDataGrid.ItemsSource = data; + SetItemsSourcePreservingSort(LongRunningQueryPatternsDataGrid, data, "AvgDurationSec", ListSortDirection.Descending); LongRunningQueryPatternsNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - SetInitialSort(LongRunningQueryPatternsDataGrid, "AvgDurationSec", ListSortDirection.Descending); SetStatus($"Loaded {data.Count} long running query pattern records"); } catch (Exception ex) diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml b/Dashboard/Controls/TimeRangeSlicerControl.xaml new file mode 100644 index 00000000..d67c1ef3 --- /dev/null +++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs new file mode 100644 index 00000000..b90e09f9 --- /dev/null +++ b/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Shapes; +using PerformanceMonitorDashboard.Helpers; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Controls; + +/// +/// Time range slicer for Dashboard. Timestamps are in SERVER LOCAL TIME +/// (matching Dashboard's collect.* tables which use SYSDATETIME()). +/// +public partial class TimeRangeSlicerControl : UserControl +{ + private List _data = new(); + private string _metricLabel = "Sessions"; + private bool _isExpanded = true; + + private double _rangeStart; + private double _rangeEnd = 1.0; + + private const double HandleWidthPx = 8; + private const double HandleGripWidthPx = 20; + private const double MinRangeNorm = 0.02; + private const double ChartPaddingTop = 16; + private const double ChartPaddingBottom = 20; + + private enum DragMode { None, MoveRange, DragStart, DragEnd } + private DragMode _dragMode = DragMode.None; + private double _dragOriginX; + private double _dragOriginRangeStart; + private double _dragOriginRangeEnd; + + /// + /// Fired when the user finishes adjusting the slicer handles. + /// Start/End are in server local time (matching Dashboard data). + /// + public event EventHandler? RangeChanged; + + public TimeRangeSlicerControl() + { + InitializeComponent(); + SlicerBorder.SizeChanged += (_, _) => Redraw(); + IsVisibleChanged += (_, _) => { if (IsVisible) Redraw(); }; + } + + public bool IsExpanded + { + get => _isExpanded; + set + { + _isExpanded = value; + SlicerBorder.Visibility = _isExpanded ? Visibility.Visible : Visibility.Collapsed; + ToggleIcon.Text = _isExpanded ? "▾" : "▸"; + } + } + + public void LoadData(List data, string metricLabel) + { + // Preserve selection if we already have data (auto-refresh) + DateTime? prevStart = null, prevEnd = null; + if (_data.Count > 0 && (_rangeStart > 0 || _rangeEnd < 1.0)) + { + prevStart = TimeAtNorm(_rangeStart); + prevEnd = TimeAtNorm(_rangeEnd); + } + + _data = data; + _metricLabel = metricLabel; + + if (prevStart.HasValue && prevEnd.HasValue && _data.Count >= 2) + { + _rangeStart = NormAtTime(prevStart.Value); + _rangeEnd = NormAtTime(prevEnd.Value); + } + else + { + _rangeStart = 0; + _rangeEnd = 1.0; + } + + UpdateRangeLabel(); + Redraw(); + } + + public void UpdateMetric(string metricLabel) + { + _metricLabel = metricLabel; + Redraw(); + } + + public DateTime? SelectionStart => _data.Count > 0 ? TimeAtNorm(_rangeStart) : null; + public DateTime? SelectionEnd => _data.Count > 0 ? TimeAtNorm(_rangeEnd) : null; + public bool HasNarrowedSelection => _data.Count > 0 && (_rangeStart > 0.01 || _rangeEnd < 0.99); + + private DateTime DataStart => _data[0].BucketTime; + private DateTime DataEnd => _data[^1].BucketTime.AddHours(1); + + private DateTime TimeAtNorm(double norm) + { + var ticks = DataStart.Ticks + (long)((DataEnd.Ticks - DataStart.Ticks) * norm); + return new DateTime(Math.Clamp(ticks, DataStart.Ticks, DataEnd.Ticks)); + } + + private double NormAtTime(DateTime dt) + { + var span = DataEnd.Ticks - DataStart.Ticks; + if (span <= 0) return 0; + return Math.Clamp((double)(dt.Ticks - DataStart.Ticks) / span, 0, 1); + } + + // ── Drawing ── + + public void Redraw() + { + SlicerCanvas.Children.Clear(); + if (_data.Count < 2) return; + + var w = SlicerBorder.ActualWidth; + var h = SlicerBorder.ActualHeight; + if (w <= 0 || h <= 0) return; + + var values = _data.Select(d => d.Value).ToArray(); + var max = values.Max(); + if (max <= 0) max = 1; + + var chartTop = ChartPaddingTop; + var chartBottom = h - ChartPaddingBottom; + var chartHeight = chartBottom - chartTop; + if (chartHeight <= 0) return; + + var n = values.Length; + + var linePoints = new List(n); + for (int i = 0; i < n; i++) + { + var x = NormAtTime(_data[i].BucketTime) * w; + var y = chartBottom - (values[i] / max) * chartHeight; + linePoints.Add(new Point(x, y)); + } + + var fillBrush = FindBrush("SlicerChartFillBrush", "#332EAEF1"); + var areaGeo = new StreamGeometry(); + using (var ctx = areaGeo.Open()) + { + ctx.BeginFigure(new Point(linePoints[0].X, chartBottom), true, true); + foreach (var pt in linePoints) ctx.LineTo(pt, true, false); + ctx.LineTo(new Point(linePoints[^1].X, chartBottom), true, false); + } + SlicerCanvas.Children.Add(new Path { Data = areaGeo, Fill = fillBrush }); + + var lineBrush = FindBrush("SlicerChartLineBrush", "#2EAEF1"); + var lineGeo = new StreamGeometry(); + using (var ctx = lineGeo.Open()) + { + ctx.BeginFigure(linePoints[0], false, false); + for (int i = 1; i < linePoints.Count; i++) ctx.LineTo(linePoints[i], true, false); + } + SlicerCanvas.Children.Add(new Path { Data = lineGeo, Stroke = lineBrush, StrokeThickness = 1.5 }); + + // X-axis labels — evenly spaced by TIME across the full range, skip if too close + var labelBrush = FindBrush("SlicerLabelBrush", "#99E4E6EB"); + const double minLabelSpacingPx = 90; + double lastLabelX = -minLabelSpacingPx; + int targetLabels = Math.Max(2, (int)(w / minLabelSpacingPx)); + var timeStep = (DataEnd - DataStart).TotalHours / targetLabels; + for (int tick = 0; tick <= targetLabels; tick++) + { + var tickTime = DataStart.AddHours(tick * timeStep); + var x = NormAtTime(tickTime) * w; + if (x - lastLabelX < minLabelSpacingPx) continue; + if (x < 10 || x > w - 40) continue; // avoid edge clipping + var dt = ServerTimeHelper.ConvertForDisplay(tickTime, ServerTimeHelper.CurrentDisplayMode); + var tb = new TextBlock { Text = dt.ToString("MM/dd HH:mm"), FontSize = 9, Foreground = labelBrush }; + Canvas.SetLeft(tb, x - 25); + Canvas.SetTop(tb, chartBottom + 2); + SlicerCanvas.Children.Add(tb); + lastLabelX = x; + } + + var metricBrush = FindBrush("SlicerToggleBrush", "#E4E6EB"); + var metricTb = new TextBlock { Text = _metricLabel, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = metricBrush }; + Canvas.SetLeft(metricTb, w - 120); + Canvas.SetTop(metricTb, 2); + SlicerCanvas.Children.Add(metricTb); + + var overlayBrush = FindBrush("SlicerOverlayBrush", "#99000000"); + var selectedBrush = FindBrush("SlicerSelectedBrush", "#22FFFFFF"); + var handleBrush = FindBrush("SlicerHandleBrush", "#E4E6EB"); + + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + if (selLeft > 0) AddRect(0, 0, selLeft, h, overlayBrush); + if (selRight < w) AddRect(selRight, 0, w - selRight, h, overlayBrush); + AddRect(selLeft, 0, Math.Max(0, selRight - selLeft), h, selectedBrush); + + DrawHandle(selLeft, h, handleBrush); + DrawHandle(selRight - HandleWidthPx, h, handleBrush); + AddLine(selLeft, 0, selRight, 0, handleBrush, 0.5); + AddLine(selLeft, h, selRight, h, handleBrush, 0.5); + } + + private void AddRect(double x, double y, double width, double height, Brush fill) + { + var rect = new Rectangle { Width = width, Height = height, Fill = fill }; + Canvas.SetLeft(rect, x); Canvas.SetTop(rect, y); + SlicerCanvas.Children.Add(rect); + } + + private void AddLine(double x1, double y1, double x2, double y2, Brush stroke, double opacity) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x1, Y1 = y1, X2 = x2, Y2 = y2, + Stroke = stroke, StrokeThickness = 1, Opacity = opacity + }); + } + + private void DrawHandle(double x, double canvasHeight, Brush brush) + { + AddRect(x, 0, HandleWidthPx, canvasHeight, brush); + ((Rectangle)SlicerCanvas.Children[^1]).Opacity = 0.7; + var midY = canvasHeight / 2; + for (int i = -1; i <= 1; i++) + { + SlicerCanvas.Children.Add(new Line + { + X1 = x + 2, Y1 = midY + i * 5, X2 = x + HandleWidthPx - 2, Y2 = midY + i * 5, + Stroke = Brushes.Black, StrokeThickness = 1, Opacity = 0.6 + }); + } + } + + private Brush FindBrush(string key, string fallbackHex) + { + if (TryFindResource(key) is Brush b) return b; + return new SolidColorBrush((Color)ColorConverter.ConvertFromString(fallbackHex)); + } + + // ── Range label ── + + private void UpdateRangeLabel() + { + if (_data.Count == 0) { RangeLabel.Text = ""; return; } + var start = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeStart), ServerTimeHelper.CurrentDisplayMode); + var end = ServerTimeHelper.ConvertForDisplay(TimeAtNorm(_rangeEnd), ServerTimeHelper.CurrentDisplayMode); + var span = end - start; + RangeLabel.Text = $"{start:yyyy-MM-dd HH:mm} \u2192 {end:yyyy-MM-dd HH:mm} ({span.TotalHours:F0}h)"; + } + + // ── Mouse interaction ── + + private void Toggle_Click(object sender, RoutedEventArgs e) => IsExpanded = !IsExpanded; + + private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + + _dragOriginX = pos.X; + _dragOriginRangeStart = _rangeStart; + _dragOriginRangeEnd = _rangeEnd; + + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx) + { _dragMode = DragMode.DragStart; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + { _dragMode = DragMode.DragEnd; SlicerCanvas.CaptureMouse(); e.Handled = true; return; } + if (pos.X >= selLeft && pos.X <= selRight) + { _dragMode = DragMode.MoveRange; SlicerCanvas.CaptureMouse(); e.Handled = true; } + } + + private void Canvas_MouseMove(object sender, MouseEventArgs e) + { + if (_data.Count < 2) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + var pos = e.GetPosition(SlicerCanvas); + + if (_dragMode == DragMode.None) + { + var selLeft = _rangeStart * w; + var selRight = _rangeEnd * w; + if (Math.Abs(pos.X - selLeft) <= HandleGripWidthPx || Math.Abs(pos.X - selRight) <= HandleGripWidthPx) + SlicerCanvas.Cursor = Cursors.SizeWE; + else if (pos.X >= selLeft && pos.X <= selRight) + SlicerCanvas.Cursor = Cursors.SizeAll; + else + SlicerCanvas.Cursor = Cursors.Arrow; + return; + } + + var deltaNorm = (pos.X - _dragOriginX) / w; + switch (_dragMode) + { + case DragMode.DragStart: + _rangeStart = Math.Clamp(_dragOriginRangeStart + deltaNorm, 0, _rangeEnd - MinRangeNorm); + break; + case DragMode.DragEnd: + _rangeEnd = Math.Clamp(_dragOriginRangeEnd + deltaNorm, _rangeStart + MinRangeNorm, 1); + break; + case DragMode.MoveRange: + var span = _dragOriginRangeEnd - _dragOriginRangeStart; + var newStart = _dragOriginRangeStart + deltaNorm; + if (newStart < 0) newStart = 0; + if (newStart + span > 1) newStart = 1 - span; + _rangeStart = newStart; + _rangeEnd = newStart + span; + break; + } + UpdateRangeLabel(); + Redraw(); + e.Handled = true; + } + + private void Canvas_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (_dragMode != DragMode.None) + { + _dragMode = DragMode.None; + SlicerCanvas.ReleaseMouseCapture(); + FireRangeChanged(); + e.Handled = true; + } + } + + private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e) + { + if (_data.Count < 2) return; + if (!Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) return; + var w = SlicerBorder.ActualWidth; + if (w <= 0) return; + + var pos = e.GetPosition(SlicerCanvas); + var pivot = Math.Clamp(pos.X / w, 0, 1); + var span = _rangeEnd - _rangeStart; + + var zoomFactor = e.Delta > 0 ? 0.85 : 1.0 / 0.85; + var newSpan = Math.Clamp(span * zoomFactor, MinRangeNorm, 1.0); + + var pivotInRange = (pivot - _rangeStart) / span; + var newStart = pivot - pivotInRange * newSpan; + var newEnd = newStart + newSpan; + + if (newStart < 0) { newStart = 0; newEnd = newSpan; } + if (newEnd > 1) { newEnd = 1; newStart = 1 - newSpan; } + + _rangeStart = Math.Max(0, newStart); + _rangeEnd = Math.Min(1, newEnd); + + UpdateRangeLabel(); + Redraw(); + FireRangeChanged(); + e.Handled = true; + } + + private void FireRangeChanged() + { + if (_data.Count == 0) return; + // Snap to hour boundaries so slider positions align with hourly buckets + var start = FloorToHour(TimeAtNorm(_rangeStart)); + var end = CeilToHour(TimeAtNorm(_rangeEnd)); + RangeChanged?.Invoke(this, new SlicerRangeEventArgs(start, end)); + } + + private static DateTime FloorToHour(DateTime dt) => + new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind); + + private static DateTime CeilToHour(DateTime dt) + { + var floored = FloorToHour(dt); + return floored == dt ? dt : floored.AddHours(1); + } +} + +public class SlicerRangeEventArgs : EventArgs +{ + public DateTime Start { get; } + public DateTime End { get; } + public SlicerRangeEventArgs(DateTime start, DateTime end) { Start = start; End = end; } +} diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index aa54e6f7..73b591ef 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -4,12 +4,13 @@ net8.0-windows enable true + PerformanceMonitorDashboard.Program PerformanceMonitorDashboard SQL Server Performance Monitor Dashboard - 2.3.0 - 2.3.0.0 - 2.3.0.0 - 2.3.0 + 2.4.0 + 2.4.0.0 + 2.4.0.0 + 2.4.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC EDD.ico @@ -35,12 +36,13 @@ - - + + - + + diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 8d67436b..1eddb357 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -151,6 +151,7 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) // Sync preferences var startupPrefs = _preferencesService.GetPreferences(); TabHelpers.CsvSeparator = startupPrefs.CsvSeparator; + MuteRuleDialog.DefaultExpiration = startupPrefs.MuteRuleDefaultExpiration; if (Enum.TryParse(startupPrefs.TimeDisplayMode, out var tdm)) Helpers.ServerTimeHelper.CurrentDisplayMode = tdm; @@ -176,9 +177,34 @@ private async Task CheckForUpdatesOnStartupAsync() { try { + await Task.Delay(5000); // Don't slow down startup + var prefs = _preferencesService.GetPreferences(); if (!prefs.CheckForUpdatesOnStartup) return; + // Try Velopack first (supports download + apply) + try + { + var mgr = new Velopack.UpdateManager( + new Velopack.Sources.GithubSource( + "https://github.com/erikdarlingdata/PerformanceMonitor", null, false)); + + var newVersion = await mgr.CheckForUpdatesAsync(); + if (newVersion != null) + { + _notificationService?.ShowNotification( + "Update Available", + $"Performance Monitor {newVersion.TargetFullRelease.Version} is available. Use Help > About to download and install.", + NotificationType.Info); + return; + } + } + catch + { + // Velopack packages may not exist yet — fall through to legacy check + } + + // Fallback: GitHub Releases API check (notification only) var result = await UpdateCheckService.CheckForUpdateAsync(); if (result?.IsUpdateAvailable == true) { @@ -1629,7 +1655,7 @@ await _emailAlertService.TrySendAlertEmailAsync( private static string Truncate(string text, int maxLength = 300) { if (string.IsNullOrEmpty(text)) return ""; - text = text.Trim(); + text = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "..."; } diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs index 72ffa00b..c20db796 100644 --- a/Dashboard/Mcp/McpAnalysisTools.cs +++ b/Dashboard/Mcp/McpAnalysisTools.cs @@ -433,15 +433,17 @@ public static System.Collections.Generic.List GetForStoryPath(string sto { if (!ByFactKey.TryGetValue(key, out var recommendations)) { - if (key.StartsWith("BAD_ACTOR_")) + if (key.StartsWith("BAD_ACTOR_", StringComparison.OrdinalIgnoreCase)) ByFactKey.TryGetValue("BAD_ACTOR", out recommendations); - else if (key.StartsWith("ANOMALY_CPU")) + else if (key.StartsWith("ANOMALY_CPU", StringComparison.OrdinalIgnoreCase)) ByFactKey.TryGetValue("ANOMALY_CPU", out recommendations); - else if (key.StartsWith("ANOMALY_WAIT_")) + else if (key.StartsWith("ANOMALY_WAIT_", StringComparison.OrdinalIgnoreCase)) ByFactKey.TryGetValue("ANOMALY_WAIT", out recommendations); - else if (key.StartsWith("ANOMALY_BLOCKING") || key.StartsWith("ANOMALY_DEADLOCK")) + else if (key.StartsWith("ANOMALY_BLOCKING", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("ANOMALY_DEADLOCK", StringComparison.OrdinalIgnoreCase)) ByFactKey.TryGetValue("ANOMALY_BLOCKING", out recommendations); - else if (key.StartsWith("ANOMALY_READ") || key.StartsWith("ANOMALY_WRITE")) + else if (key.StartsWith("ANOMALY_READ", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("ANOMALY_WRITE", StringComparison.OrdinalIgnoreCase)) ByFactKey.TryGetValue("ANOMALY_IO", out recommendations); if (recommendations == null) continue; } @@ -459,7 +461,7 @@ public static System.Collections.Generic.List GetForStoryPath(string sto } } -internal record ToolRecommendation( +internal sealed record ToolRecommendation( string Tool, string Reason, System.Collections.Generic.Dictionary? SuggestedParams = null); diff --git a/Dashboard/Models/MuteRule.cs b/Dashboard/Models/MuteRule.cs index b5b8e40d..f103a14e 100644 --- a/Dashboard/Models/MuteRule.cs +++ b/Dashboard/Models/MuteRule.cs @@ -1,101 +1,177 @@ -using System; - -namespace PerformanceMonitorDashboard.Models -{ - public class MuteRule - { - public string Id { get; set; } = Guid.NewGuid().ToString(); - public bool Enabled { get; set; } = true; - public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; - public DateTime? ExpiresAtUtc { get; set; } - public string? Reason { get; set; } - - public string? ServerName { get; set; } - public string? MetricName { get; set; } - public string? DatabasePattern { get; set; } - public string? QueryTextPattern { get; set; } - public string? WaitTypePattern { get; set; } - public string? JobNamePattern { get; set; } - - public bool IsExpired => ExpiresAtUtc.HasValue && DateTime.UtcNow >= ExpiresAtUtc.Value; - - public MuteRule Clone() => new() - { - Id = Id, - Enabled = Enabled, - CreatedAtUtc = CreatedAtUtc, - ExpiresAtUtc = ExpiresAtUtc, - Reason = Reason, - ServerName = ServerName, - MetricName = MetricName, - DatabasePattern = DatabasePattern, - QueryTextPattern = QueryTextPattern, - WaitTypePattern = WaitTypePattern, - JobNamePattern = JobNamePattern - }; - - public string ExpiresDisplay => ExpiresAtUtc.HasValue - ? (IsExpired ? "Expired" : ExpiresAtUtc.Value.ToLocalTime().ToString("g")) - : "Never"; - - public string Summary - { - get - { - var parts = new System.Collections.Generic.List(); - if (MetricName != null) parts.Add(MetricName); - if (ServerName != null) parts.Add($"on {ServerName}"); - if (DatabasePattern != null) parts.Add($"db≈{DatabasePattern}"); - if (QueryTextPattern != null) parts.Add($"query≈{QueryTextPattern}"); - if (WaitTypePattern != null) parts.Add($"wait≈{WaitTypePattern}"); - if (JobNamePattern != null) parts.Add($"job≈{JobNamePattern}"); - return parts.Count > 0 ? string.Join(", ", parts) : "(matches all alerts)"; - } - } - - public bool Matches(AlertMuteContext context) - { - if (!Enabled || IsExpired) return false; - - if (ServerName != null && - !string.Equals(ServerName, context.ServerName, StringComparison.OrdinalIgnoreCase)) - return false; - - if (MetricName != null && - !string.Equals(MetricName, context.MetricName, StringComparison.OrdinalIgnoreCase)) - return false; - - if (DatabasePattern != null && - (context.DatabaseName == null || - !context.DatabaseName.Contains(DatabasePattern, StringComparison.OrdinalIgnoreCase))) - return false; - - if (QueryTextPattern != null && - (context.QueryText == null || - !context.QueryText.Contains(QueryTextPattern, StringComparison.OrdinalIgnoreCase))) - return false; - - if (WaitTypePattern != null && - (context.WaitType == null || - !context.WaitType.Contains(WaitTypePattern, StringComparison.OrdinalIgnoreCase))) - return false; - - if (JobNamePattern != null && - (context.JobName == null || - !context.JobName.Contains(JobNamePattern, StringComparison.OrdinalIgnoreCase))) - return false; - - return true; - } - } - - public class AlertMuteContext - { - public string ServerName { get; set; } = ""; - public string MetricName { get; set; } = ""; - public string? DatabaseName { get; set; } - public string? QueryText { get; set; } - public string? WaitType { get; set; } - public string? JobName { get; set; } - } -} +using System; + +namespace PerformanceMonitorDashboard.Models +{ + public class MuteRule + { + public string Id { get; set; } = Guid.NewGuid().ToString(); + public bool Enabled { get; set; } = true; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime? ExpiresAtUtc { get; set; } + public string? Reason { get; set; } + + public string? ServerName { get; set; } + public string? MetricName { get; set; } + public string? DatabasePattern { get; set; } + public string? QueryTextPattern { get; set; } + public string? WaitTypePattern { get; set; } + public string? JobNamePattern { get; set; } + + public bool IsExpired => ExpiresAtUtc.HasValue && DateTime.UtcNow >= ExpiresAtUtc.Value; + + public MuteRule Clone() => new() + { + Id = Id, + Enabled = Enabled, + CreatedAtUtc = CreatedAtUtc, + ExpiresAtUtc = ExpiresAtUtc, + Reason = Reason, + ServerName = ServerName, + MetricName = MetricName, + DatabasePattern = DatabasePattern, + QueryTextPattern = QueryTextPattern, + WaitTypePattern = WaitTypePattern, + JobNamePattern = JobNamePattern + }; + + public string ExpiresDisplay => ExpiresAtUtc.HasValue + ? (IsExpired ? "Expired" : ExpiresAtUtc.Value.ToLocalTime().ToString("g")) + : "Never"; + + public string Summary + { + get + { + var parts = new System.Collections.Generic.List(); + if (MetricName != null) parts.Add(MetricName); + if (ServerName != null) parts.Add($"on {ServerName}"); + if (DatabasePattern != null) parts.Add($"db≈{DatabasePattern}"); + if (QueryTextPattern != null) parts.Add($"query≈{QueryTextPattern}"); + if (WaitTypePattern != null) parts.Add($"wait≈{WaitTypePattern}"); + if (JobNamePattern != null) parts.Add($"job≈{JobNamePattern}"); + return parts.Count > 0 ? string.Join(", ", parts) : "(matches all alerts)"; + } + } + + public bool Matches(AlertMuteContext context) + { + if (!Enabled || IsExpired) return false; + + if (ServerName != null && + !string.Equals(ServerName, context.ServerName, StringComparison.OrdinalIgnoreCase)) + return false; + + if (MetricName != null && + !string.Equals(MetricName, context.MetricName, StringComparison.OrdinalIgnoreCase)) + return false; + + if (DatabasePattern != null && + (context.DatabaseName == null || + !context.DatabaseName.Contains(DatabasePattern, StringComparison.OrdinalIgnoreCase))) + return false; + + if (QueryTextPattern != null && + (context.QueryText == null || + !context.QueryText.Contains(QueryTextPattern, StringComparison.OrdinalIgnoreCase))) + return false; + + if (WaitTypePattern != null && + (context.WaitType == null || + !context.WaitType.Contains(WaitTypePattern, StringComparison.OrdinalIgnoreCase))) + return false; + + if (JobNamePattern != null && + (context.JobName == null || + !context.JobName.Contains(JobNamePattern, StringComparison.OrdinalIgnoreCase))) + return false; + + return true; + } + } + + public class AlertMuteContext + { + public string ServerName { get; set; } = ""; + public string MetricName { get; set; } = ""; + public string? DatabaseName { get; set; } + public string? QueryText { get; set; } + public string? WaitType { get; set; } + public string? JobName { get; set; } + + /// + /// Extracts context fields (Database, Query, Wait Type, Job Name) from the + /// structured detail_text stored with each alert. The format is label/value + /// pairs indented with two spaces, e.g. " Database: MyDB". + /// Query values may span multiple lines and use variant labels + /// (Blocked Query, Blocking Query, Victim SQL). + /// + public void PopulateFromDetailText(string? detailText) + { + if (string.IsNullOrEmpty(detailText)) return; + + System.Text.StringBuilder? queryBuilder = null; + var lines = detailText.Split('\n'); + + foreach (var line in lines) + { + var trimmed = line.TrimStart(); + + if (DatabaseName == null && trimmed.StartsWith("Database: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + DatabaseName = trimmed.Substring("Database: ".Length).Trim(); + } + else if (WaitType == null && trimmed.StartsWith("Wait Type: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + WaitType = trimmed.Substring("Wait Type: ".Length).Trim(); + } + else if (JobName == null && trimmed.StartsWith("Job Name: ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + JobName = trimmed.Substring("Job Name: ".Length).Trim(); + } + else if (QueryText == null && queryBuilder == null && TryExtractQueryValue(trimmed, out var qv)) + { + queryBuilder = new System.Text.StringBuilder(qv); + } + else if (queryBuilder != null) + { + // Continuation lines from multi-line query values don't start + // with the two-space indent used by ContextToDetailText fields. + if (string.IsNullOrWhiteSpace(trimmed) || line.StartsWith(" ", StringComparison.Ordinal)) + { + FlushQuery(ref queryBuilder); + } + else + { + queryBuilder.Append(' ').Append(trimmed.Trim()); + } + } + } + + FlushQuery(ref queryBuilder); + } + + private void FlushQuery(ref System.Text.StringBuilder? builder) + { + if (builder != null && QueryText == null) + QueryText = builder.ToString(); + builder = null; + } + + private static bool TryExtractQueryValue(string trimmed, out string value) + { + foreach (var prefix in new[] { "Query: ", "Blocked Query: ", "Blocking Query: ", "Victim SQL: " }) + { + if (trimmed.StartsWith(prefix, StringComparison.Ordinal)) + { + value = trimmed.Substring(prefix.Length).Trim(); + return true; + } + } + value = ""; + return false; + } + } +} diff --git a/Dashboard/Models/TimeSliceBucket.cs b/Dashboard/Models/TimeSliceBucket.cs new file mode 100644 index 00000000..69e35a6f --- /dev/null +++ b/Dashboard/Models/TimeSliceBucket.cs @@ -0,0 +1,21 @@ +using System; + +namespace PerformanceMonitorDashboard.Models; + +/// +/// One hourly bucket of aggregated metrics for a time-range slicer. +/// In Dashboard, timestamps are in server local time (matching collect.* tables). +/// +public class TimeSliceBucket +{ + public DateTime BucketTime { get; set; } + public long SessionCount { get; set; } + public double TotalCpu { get; set; } + public double TotalElapsed { get; set; } + public double TotalReads { get; set; } + public double TotalLogicalReads { get; set; } + public double TotalWrites { get; set; } + + /// The display value used by the slicer chart. Set by the caller based on sort column. + public double Value { get; set; } +} diff --git a/Dashboard/Models/TracePatternDetailItem.cs b/Dashboard/Models/TracePatternDetailItem.cs index 9d81c0d1..6da95cc4 100644 --- a/Dashboard/Models/TracePatternDetailItem.cs +++ b/Dashboard/Models/TracePatternDetailItem.cs @@ -12,6 +12,7 @@ public class TracePatternDetailItem public string EventName { get; set; } = string.Empty; public string? DatabaseName { get; set; } public string? LoginName { get; set; } + public string? NtUserName { get; set; } public string? ApplicationName { get; set; } public string? HostName { get; set; } public int? Spid { get; set; } diff --git a/Dashboard/Models/UserPreferences.cs b/Dashboard/Models/UserPreferences.cs index aa2a3cd3..9276408c 100644 --- a/Dashboard/Models/UserPreferences.cs +++ b/Dashboard/Models/UserPreferences.cs @@ -141,6 +141,9 @@ private static string GetDefaultCsvSeparator() // Alert database exclusions public List AlertExcludedDatabases { get; set; } = new(); + // Default mute rule expiration ("1 hour", "24 hours", "7 days", "Never") + public string MuteRuleDefaultExpiration { get; set; } = "24 hours"; + // Alert suppression (persisted) public List SilencedServers { get; set; } = new(); public List SilencedServerTabs { get; set; } = new(); diff --git a/Dashboard/MuteRuleDialog.xaml b/Dashboard/MuteRuleDialog.xaml index cdae393e..45eb031f 100644 --- a/Dashboard/MuteRuleDialog.xaml +++ b/Dashboard/MuteRuleDialog.xaml @@ -1,112 +1,113 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -