From 7acd20bef41aec5b60fce547de16daa0fc6eaba5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:58:14 -0400 Subject: [PATCH 001/547] Lite #1251: tolerate "XE session already exists/started" on Azure SQL DB On Azure SQL DB the database-scoped XE existence catalogs are visibility-scoped per principal (sys.database_event_sessions needs VIEW DATABASE PERFORMANCE STATE, sys.dm_xe_database_sessions needs VIEW DATABASE STATE -- different perms). A monitoring login missing one sees the pre-check come back empty even when PerformanceMonitor_BlockedProcess / _Deadlock already exist and run, so EnsureXeSession falls into CREATE/START and the engine returns 25631 ("already exists") / 25705 ("already started"). Since #1086 the ensure throws on any SqlException, so those benign errors marked the blocked_process + deadlock collectors unhealthy and spammed the collection log every cycle (#1251) -- even though collection was fine. Both ensure paths now treat 25631/25705 as success (shared IsBenignXeSessionAlreadyPresent helper) instead of throwing; genuine failures still surface as before. Build clean, 618 Lite.Tests pass. Azure SQL DB only; pre-existing (ensure path unchanged since v1.0.0 -- not a 3.1.0 regression). To be live-validated in the next release's Azure SQL DB cloud testing (no live Azure instance up now). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...teCollectorService.BlockedProcessReport.cs | 38 +++++++++++++++++++ .../RemoteCollectorService.Deadlocks.cs | 9 +++++ 2 files changed, 47 insertions(+) diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index 7d5e24b9f..2d712d02d 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -55,6 +55,17 @@ public async Task EnsureBlockedProcessXeSessionAsync(ServerConnection server, in await EnsureBlockedProcessXeSessionOnPremAsync(connection, server, cancellationToken); } } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* The session is already present + running. On Azure SQL DB the XE existence catalogs are + visibility-scoped per principal (sys.database_event_sessions needs VIEW DATABASE PERFORMANCE + STATE, sys.dm_xe_database_sessions needs VIEW DATABASE STATE), so a pre-check can come back + empty even when the session exists -- the CREATE/START path then reports "already exists" + (25631) / "already started" (25705). That confirms the session is up; it is success, not a + failure to surface as an unhealthy collector or log every cycle (#1251). */ + _logger?.LogDebug("Blocked process XE session already present on '{Server}' (benign)", server.DisplayName); + AppLogger.Info("XeSession", $"[{server.DisplayName}] Blocked process XE session already present (benign, #1251)"); + } catch (SqlException ex) { _logger?.LogWarning("Failed to ensure blocked process XE session on '{Server}': {Message}", @@ -263,6 +274,33 @@ ADD TARGET package0.ring_buffer AppLogger.Info("XeSession", $"[Azure SQL DB] Created and started blocked process XE session (database-scoped)"); } + /// + /// True when every error is a benign "the session is already there" extended-events error: + /// 25631 (event session already exists) or 25705 (already started). On Azure SQL DB the XE + /// existence catalogs (sys.database_event_sessions / sys.dm_xe_database_sessions) are visibility- + /// scoped per principal and can come back empty even when the session is present + running, so the + /// CREATE/START path reports these -- they confirm the session is up, not a failure to surface (#1251). + /// Shared by the blocked-process and deadlock ensure paths (same partial class). + /// + private static bool IsBenignXeSessionAlreadyPresent(SqlException ex) + { + if (ex.Errors.Count == 0) + { + return false; + } + + foreach (Microsoft.Data.SqlClient.SqlError error in ex.Errors) + { + /* 25631 = event session already exists; 25705 = already started. */ + if (error.Number != 25631 && error.Number != 25705) + { + return false; + } + } + + return true; + } + /// /// Collects blocked process reports from the XE session ring_buffer. /// For on-prem/MI: reads from server-scoped session. diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs index 11fcefbe9..4403bac20 100644 --- a/Lite/Services/RemoteCollectorService.Deadlocks.cs +++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs @@ -55,6 +55,15 @@ public async Task EnsureDeadlockXeSessionAsync(ServerConnection server, int engi await EnsureDeadlockXeSessionOnPremAsync(connection, server, cancellationToken); } } + catch (SqlException ex) when (IsBenignXeSessionAlreadyPresent(ex)) + { + /* Session already present + running -- see IsBenignXeSessionAlreadyPresent. On Azure SQL DB the + XE existence catalogs are visibility-scoped per principal, so the pre-check can miss an + existing session and CREATE/START then reports "already exists" (25631) / "already started" + (25705). That's success, not a failure to surface as unhealthy or log every cycle (#1251). */ + _logger?.LogDebug("Deadlock XE session already present on '{Server}' (benign)", server.DisplayName); + AppLogger.Info("XeSession", $"[{server.DisplayName}] Deadlock XE session already present (benign, #1251)"); + } catch (SqlException ex) { _logger?.LogWarning("Failed to ensure deadlock XE session on '{Server}': {Message}", From c8ac47e6996926132ea92e4f0162e5425e87acfe Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:31:39 -0400 Subject: [PATCH 002/547] Remove dead code in Dashboard (SystemEvents scaffolding, MCP registry, misc) Dead-code cleanup from a code-simplification audit. Dashboard-only, verified by build + 691 Dashboard.Tests (no behavior change, +4/-159): - SystemEvents: remove the dead SystemHealthFilter_Click handler and 6 write-only/unreferenced filter+cache fields (SystemHealth, IOIssues, MemoryNodeOOM -- those grids are chart-only now), plus ~56 "grid removed per todo.md #N" tombstone comments across 8 partials. The audit named only the 2 SystemHealth fields; the IOIssues/MemoryNodeOOM siblings are the same dead-scaffolding class and went with them. - MCP DatabaseServiceRegistry: drop the unused serverManager ctor param and the always-false `is IAsyncDisposable` branch in DisposeAsync (DatabaseService is not IAsyncDisposable, so it was unreachable) -- the method now just clears the cache. - ServerTab.CopyExport: remove the unused GetColumnHeader forwarder (EscapeCsvField/GetRowValues forwarders stay -- they are called). - DatabaseService.TestConnectionAsync: drop the dead `var connection` local. - SqlServerFactCollector: remove two stranded/duplicate XML doc-comments. - Delete the empty orphan partial DatabaseService.FinOps.cs. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Analysis/SqlServerFactCollector.cs | 9 ------- .../Controls/SystemEventsContent.CPUTasks.cs | 15 ------------ .../SystemEventsContent.FilterPopup.cs | 6 ----- .../Controls/SystemEventsContent.IOIssues.cs | 12 ---------- .../SystemEventsContent.MemoryConditions.cs | 15 ------------ .../SystemEventsContent.MemoryNodeOOM.cs | 3 --- .../SystemEventsContent.SchedulerIssues.cs | 15 ------------ .../SystemEventsContent.SystemHealth.cs | 18 -------------- .../Controls/SystemEventsContent.xaml.cs | 24 ------------------- Dashboard/Mcp/DatabaseServiceRegistry.cs | 14 +++-------- Dashboard/Mcp/McpHostService.cs | 2 +- Dashboard/ServerTab.CopyExport.cs | 5 ---- Dashboard/Services/DatabaseService.FinOps.cs | 24 ------------------- Dashboard/Services/DatabaseService.cs | 1 - 14 files changed, 4 insertions(+), 159 deletions(-) delete mode 100644 Dashboard/Services/DatabaseService.FinOps.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs index f9bd9af59..f25aeae4a 100644 --- a/Dashboard/Analysis/SqlServerFactCollector.cs +++ b/Dashboard/Analysis/SqlServerFactCollector.cs @@ -61,15 +61,6 @@ public async Task> CollectFactsAsync(AnalysisContext context) return facts; } - /// - /// Collects server hardware properties: CPU count, cores, sockets, memory. - /// Critical context for MAXDOP and memory recommendations. - /// - /// - /// Builds the latest-row server_properties SELECT, including the WS5 server-health columns only - /// when the DB has them. Extracted for testability and so a not-yet-upgraded server (no WS5 - /// columns) reads the core hardware columns without a bind error. - /// // The WS5 server-health column names (compile-time literals — never built from data, so the // probe + SELECT-list construction below are injection-safe). Each is referenced only when it // actually exists on the target DB. diff --git a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs index 8ed5c1097..5dc0a2386 100644 --- a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs +++ b/Dashboard/Controls/SystemEventsContent.CPUTasks.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshCPUTasksAsync() try { var data = await _databaseService.GetHealthParserCPUTasksAsync(_cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); - // Grid removed per todo.md #15 - chart + summary only LoadCPUTasksChart(data, _cpuTasksHoursBack, _cpuTasksFromDate, _cpuTasksToDate); UpdateCPUTasksSummaryPanel(data); } @@ -223,20 +222,6 @@ private void UpdateCPUTasksSummaryPanel(List dataList) CPUTasksPendingNoBlockText.Text = pendingNoBlockCount.ToString("N0", CultureInfo.CurrentCulture); } - // CPUTasksFilter_Click removed - grid removed per todo.md #15 - - // ApplyCPUTasksFilters removed - grid removed per todo.md #15 - - // UpdateCPUTasksFilterButtonStyles removed - grid removed per todo.md #15 - - // CPUTasksFilterTextBox_TextChanged removed - grid removed per todo.md #15 - - // CPUTasksNumericFilterTextBox_TextChanged removed - grid removed per todo.md #15 - - // CPUTasksBoolFilter_Changed removed - grid removed per todo.md #15 - - // ApplyCPUTasksFilter removed - grid removed per todo.md #15 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs index ca9aa62f6..810c350d6 100644 --- a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs +++ b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs @@ -73,22 +73,16 @@ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) switch (_currentFilterTarget) { - // SystemHealth case removed - grid removed per todo.md #18 case "SevereErrors": UpdateFilterState(_severeErrorsFilters, e.FilterState); ApplySevereErrorsFilters(); UpdateSevereErrorsFilterButtonStyles(); break; - // IOIssues case removed - grid removed per todo.md #19 - // SchedulerIssues case removed - grid removed per todo.md #13 - // MemoryConditions case removed - grid removed per todo.md #14 - // CPUTasks case removed - grid removed per todo.md #15 case "MemoryBroker": UpdateFilterState(_memoryBrokerFilters, e.FilterState); ApplyMemoryBrokerFilters(); UpdateMemoryBrokerFilterButtonStyles(); break; - // MemoryNodeOOM case removed - DataGrid removed per GitHub issue #13 } } diff --git a/Dashboard/Controls/SystemEventsContent.IOIssues.cs b/Dashboard/Controls/SystemEventsContent.IOIssues.cs index fa2a01340..83b02fbea 100644 --- a/Dashboard/Controls/SystemEventsContent.IOIssues.cs +++ b/Dashboard/Controls/SystemEventsContent.IOIssues.cs @@ -38,8 +38,6 @@ private async System.Threading.Tasks.Task RefreshIOIssuesAsync() try { var data = await _databaseService.GetHealthParserIOIssuesAsync(_ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); - _ioIssuesUnfilteredData = data; - // IOIssuesDataGrid removed - chart only per todo.md #19 LoadIOIssuesChart(data, _ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); LoadLongestPendingIOChart(data, _ioIssuesHoursBack, _ioIssuesFromDate, _ioIssuesToDate); } @@ -123,7 +121,6 @@ private void LoadIOIssuesChart(IEnumerable data, int ho IOIssuesChart.Refresh(); } - // IOIssuesFilter_Click removed - grid removed per todo.md #19 private void LoadLongestPendingIOChart(IEnumerable data, int hoursBack, DateTime? fromDate, DateTime? toDate) { DateTime rangeEnd = toDate ?? Helpers.ServerTimeHelper.ServerNow; @@ -216,15 +213,6 @@ private void LoadLongestPendingIOChart(IEnumerable data LongestPendingIOChart.Refresh(); } - - - // ApplyIOIssuesFilters removed - grid removed per todo.md #19 - - // UpdateIOIssuesFilterButtonStyles removed - grid removed per todo.md #19 - - // IOIssuesFilterTextBox_TextChanged removed - grid removed per todo.md #19 - // IOIssuesNumericFilterTextBox_TextChanged removed - grid removed per todo.md #19 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs b/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs index 5af006791..eee0be8bc 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshMemoryConditionsAsync() try { var data = await _databaseService.GetHealthParserMemoryConditionsAsync(_memoryConditionsHoursBack, _memoryConditionsFromDate, _memoryConditionsToDate); - // Grid removed per todo.md #14 - chart only LoadMemoryConditionsChart(data, _memoryConditionsHoursBack, _memoryConditionsFromDate, _memoryConditionsToDate); } catch (Exception ex) @@ -111,20 +110,6 @@ private void LoadMemoryConditionsChart(IEnumerable 0 ? warningBrush : unknownBrush; } - // MemoryNodeOOM filter methods removed - DataGrid removed per GitHub issue #13 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs b/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs index 06dbff794..e9a17a4e9 100644 --- a/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs +++ b/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshSchedulerIssuesAsync() try { var data = await _databaseService.GetHealthParserSchedulerIssuesAsync(_schedulerIssuesHoursBack, _schedulerIssuesFromDate, _schedulerIssuesToDate); - // Grid removed per todo.md #13 - chart + summary only LoadSchedulerIssuesChart(data, _schedulerIssuesHoursBack, _schedulerIssuesFromDate, _schedulerIssuesToDate); UpdateSchedulerIssuesSummaryPanel(data); } @@ -167,20 +166,6 @@ long ParseNonYieldTime(string? value) } } - // SchedulerIssuesFilter_Click removed - grid removed per todo.md #13 - - // ApplySchedulerIssuesFilters removed - grid removed per todo.md #13 - - // UpdateSchedulerIssuesFilterButtonStyles removed - grid removed per todo.md #13 - - // SchedulerIssuesFilterTextBox_TextChanged removed - grid removed per todo.md #13 - - // SchedulerIssuesNumericFilterTextBox_TextChanged removed - grid removed per todo.md #13 - - // SchedulerIssuesBoolFilter_Changed removed - grid removed per todo.md #13 - - // ApplySchedulerIssuesFilter removed - grid removed per todo.md #13 - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SystemHealth.cs b/Dashboard/Controls/SystemEventsContent.SystemHealth.cs index 8bc1e0a5e..47ab4cd9a 100644 --- a/Dashboard/Controls/SystemEventsContent.SystemHealth.cs +++ b/Dashboard/Controls/SystemEventsContent.SystemHealth.cs @@ -38,8 +38,6 @@ private async System.Threading.Tasks.Task RefreshSystemHealthAsync() try { var data = await _databaseService.GetHealthParserSystemHealthAsync(_systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); - _systemHealthUnfilteredData = data; - // SystemHealthDataGrid removed - chart only per todo.md #18 LoadCorruptionEventsCharts(data, _systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); LoadContentionEventsCharts(data, _systemHealthHoursBack, _systemHealthFromDate, _systemHealthToDate); } @@ -350,22 +348,6 @@ private void LoadContentionEventsCharts(List data, TabHelpers.LockChartVerticalAxis(CpuComparisonChart); CpuComparisonChart.Refresh(); } - private void SystemHealthFilter_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - ShowFilterPopup(button, columnName, "SystemHealth", _systemHealthFilters, - args => { }, // Handled in FilterPopup_FilterApplied - () => { }); // Handled in FilterPopup_FilterCleared - } - - // ApplySystemHealthFilters removed - grid removed per todo.md #18 - - // UpdateSystemHealthFilterButtonStyles removed - grid removed per todo.md #18 - - // SystemHealthFilterTextBox_TextChanged removed - grid removed per todo.md #18 - - // SystemHealthNumericFilterTextBox_TextChanged removed - grid removed per todo.md #18 #endregion } diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index 2798e1bc2..e8ba168a7 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -75,24 +75,12 @@ public partial class SystemEventsContent : UserControl private DateTime? _memoryNodeOOMToDate; // Filter state dictionaries for each DataGrid - private Dictionary _systemHealthFilters = new(); private Dictionary _severeErrorsFilters = new(); - private Dictionary _ioIssuesFilters = new(); - // Scheduler Issues filter removed - grid removed per todo.md #13 - // Memory Conditions filter removed - grid removed per todo.md #14 - // CPU Tasks filter removed - grid removed per todo.md #15 private Dictionary _memoryBrokerFilters = new(); - private Dictionary _memoryNodeOOMFilters = new(); // Unfiltered data caches - private List? _systemHealthUnfilteredData; private List? _severeErrorsUnfilteredData; - private List? _ioIssuesUnfilteredData; - // Scheduler Issues unfiltered data cache removed - grid removed per todo.md #13 - // Memory Conditions unfiltered data cache removed - grid removed per todo.md #14 - // CPU Tasks unfiltered data cache removed - grid removed per todo.md #15 private List? _memoryBrokerUnfilteredData; - private List? _memoryNodeOOMUnfilteredData; // Shared popup controls private Popup? _filterPopup; @@ -230,24 +218,12 @@ private void OnThemeChanged(string _) private void OnLoaded(object sender, RoutedEventArgs e) { // Apply minimum column widths based on header text - // SystemHealthDataGrid removed - chart only per todo.md #18 TabHelpers.AutoSizeColumnMinWidths(SevereErrorsDataGrid); - // IOIssuesDataGrid removed - chart only per todo.md #19 - // SchedulerIssuesDataGrid removed - chart + summary only per todo.md #13 - // MemoryConditionsDataGrid removed - chart only per todo.md #14 - // CPUTasksDataGrid AutoSizeColumnMinWidths removed - chart + summary only per todo.md #15 TabHelpers.AutoSizeColumnMinWidths(MemoryBrokerDataGrid); - // MemoryNodeOOMDataGrid removed - chart only per GitHub issue #13 // Freeze time column for easier horizontal scrolling - // SystemHealthDataGrid FreezeColumns removed - chart only per todo.md #18 TabHelpers.FreezeColumns(SevereErrorsDataGrid, 1); - // IOIssuesDataGrid FreezeColumns removed - chart only per todo.md #19 - // SchedulerIssuesDataGrid FreezeColumns removed - chart + summary only per todo.md #13 - - // CPUTasksDataGrid FreezeColumns removed - chart + summary only per todo.md #15 TabHelpers.FreezeColumns(MemoryBrokerDataGrid, 1); - // MemoryNodeOOMDataGrid FreezeColumns removed - chart only per GitHub issue #13 } private void SetupChartContextMenus() diff --git a/Dashboard/Mcp/DatabaseServiceRegistry.cs b/Dashboard/Mcp/DatabaseServiceRegistry.cs index 1df8b7287..835c3441a 100644 --- a/Dashboard/Mcp/DatabaseServiceRegistry.cs +++ b/Dashboard/Mcp/DatabaseServiceRegistry.cs @@ -16,10 +16,8 @@ public sealed class DatabaseServiceRegistry : IAsyncDisposable private readonly ConcurrentDictionary _services = new(StringComparer.OrdinalIgnoreCase); private readonly ICredentialService _credentialService; - public DatabaseServiceRegistry(ServerManager serverManager, ICredentialService credentialService) + public DatabaseServiceRegistry(ICredentialService credentialService) { - /* serverManager passed for future use but not currently needed */ - _ = serverManager; _credentialService = credentialService; } @@ -35,15 +33,9 @@ public DatabaseService GetOrCreate(ServerConnection server) }); } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - foreach (var kvp in _services) - { - if (kvp.Value is IAsyncDisposable disposable) - { - await disposable.DisposeAsync(); - } - } _services.Clear(); + return ValueTask.CompletedTask; } } diff --git a/Dashboard/Mcp/McpHostService.cs b/Dashboard/Mcp/McpHostService.cs index ac8150cd7..b97b9c356 100644 --- a/Dashboard/Mcp/McpHostService.cs +++ b/Dashboard/Mcp/McpHostService.cs @@ -53,7 +53,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) builder.Logging.SetMinimumLevel(LogLevel.Warning); /* Register services that MCP tools need via dependency injection */ - _registry = new DatabaseServiceRegistry(_serverManager, _credentialService); + _registry = new DatabaseServiceRegistry(_credentialService); builder.Services.AddSingleton(_serverManager); builder.Services.AddSingleton(_registry); builder.Services.AddSingleton(_muteRuleService); diff --git a/Dashboard/ServerTab.CopyExport.cs b/Dashboard/ServerTab.CopyExport.cs index 765ae784c..05b55a59c 100644 --- a/Dashboard/ServerTab.CopyExport.cs +++ b/Dashboard/ServerTab.CopyExport.cs @@ -194,11 +194,6 @@ private List GetRowValues(DataGrid dataGrid, object item) return values; } - private string GetColumnHeader(DataGridColumn column) - { - return TabHelpers.GetColumnHeader(column); - } - private string EscapeCsvField(string field) { return TabHelpers.EscapeCsvField(field); diff --git a/Dashboard/Services/DatabaseService.FinOps.cs b/Dashboard/Services/DatabaseService.FinOps.cs deleted file mode 100644 index 3cb3ccdbe..000000000 --- a/Dashboard/Services/DatabaseService.FinOps.cs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -namespace PerformanceMonitorDashboard.Services -{ - /// - /// FinOps Tab data access — see sub-partials: - /// - DatabaseService.FinOps.Inventory.cs (server-level utilization, properties, metrics) - /// - DatabaseService.FinOps.Workload.cs (database/application resource usage, top consumers) - /// - DatabaseService.FinOps.Storage.cs (database sizes, growth, idle DBs, tempdb) - /// - DatabaseService.FinOps.Queries.cs (waits, expensive/high-impact queries, memory grants, provisioning trend) - /// - DatabaseService.FinOps.IndexAnalysis.cs (sp_IndexCleanup integration) - /// - DatabaseService.FinOps.Recommendations.cs (FinOps recommendation engine) - /// - DatabaseService.FinOps.Models.cs (FinOps DTO/model types) - /// - public partial class DatabaseService - { - } -} diff --git a/Dashboard/Services/DatabaseService.cs b/Dashboard/Services/DatabaseService.cs index d7c2825da..b1f60154e 100644 --- a/Dashboard/Services/DatabaseService.cs +++ b/Dashboard/Services/DatabaseService.cs @@ -128,7 +128,6 @@ public async Task TestConnectionAsync() try { await using var tc = await OpenThrottledConnectionAsync(); - var connection = tc.Connection; return true; } catch (Exception ex) From 6e280eced6afc223d61b463e0ae85aa3f7086dd0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:41:59 -0400 Subject: [PATCH 003/547] Remove second dead filter chain (MemoryBroker) + MCP no-op + stale comment Folds in pass-2 audit findings. Still Dashboard-only, no behavior change, verified by build + 691 Dashboard.Tests: - MemoryBroker filter chain was unreachable: MemoryBrokerFilter_Click has no XAML binding (contrast SevereErrorsFilter_Click, 7 bindings), so the whole popup chain downstream was dead -- removed MemoryBrokerFilter_Click, ApplyMemoryBrokerFilters, UpdateMemoryBrokerFilterButtonStyles, the "MemoryBroker" FilterPopup branch, and the _memoryBrokerFilters / _memoryBrokerUnfilteredData fields (+ the now-dead write). The grid still binds ItemsSource directly; only the never-wired popup filtering is gone. (Corrects the first commit, which kept these as "live.") - McpHealthParserTools: delete the no-op SerializeHealthItem (returned its arg unchanged; doc claimed "duck typing via dynamic" but did none) and inline its 8 call sites -- JSON output identical (STJ serializes by runtime type). - RemediationHandlerRegistry: fix the stale "v1 registers exactly one handler" doc comment (the set is injected; the default is now six). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SystemEventsContent.FilterPopup.cs | 5 -- .../SystemEventsContent.MemoryBroker.cs | 53 ------------------- .../Controls/SystemEventsContent.xaml.cs | 6 +-- Dashboard/Mcp/McpHealthParserTools.cs | 27 +++------- .../Remediation/RemediationHandlerRegistry.cs | 4 +- 5 files changed, 12 insertions(+), 83 deletions(-) diff --git a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs index 810c350d6..02e2e3d89 100644 --- a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs +++ b/Dashboard/Controls/SystemEventsContent.FilterPopup.cs @@ -78,11 +78,6 @@ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e) ApplySevereErrorsFilters(); UpdateSevereErrorsFilterButtonStyles(); break; - case "MemoryBroker": - UpdateFilterState(_memoryBrokerFilters, e.FilterState); - ApplyMemoryBrokerFilters(); - UpdateMemoryBrokerFilterButtonStyles(); - break; } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs index a612bf899..e9e7d3448 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs @@ -38,7 +38,6 @@ private async System.Threading.Tasks.Task RefreshMemoryBrokerAsync() try { var data = await _databaseService.GetHealthParserMemoryBrokerAsync(_memoryBrokerHoursBack, _memoryBrokerFromDate, _memoryBrokerToDate); - _memoryBrokerUnfilteredData = data; MemoryBrokerDataGrid.ItemsSource = data; MemoryBrokerNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; LoadMemoryBrokerChart(data, _memoryBrokerHoursBack, _memoryBrokerFromDate, _memoryBrokerToDate); @@ -181,58 +180,6 @@ private void LoadMemoryBrokerChart(IEnumerable dat MemoryBrokerRatioChart.Plot.YLabel("Value"); } - private void MemoryBrokerFilter_Click(object sender, RoutedEventArgs e) - { - if (sender is not Button button || button.Tag is not string columnName) return; - - ShowFilterPopup(button, columnName, "MemoryBroker", _memoryBrokerFilters, - args => { }, - () => { }); - } - - private void ApplyMemoryBrokerFilters() - { - if (_memoryBrokerUnfilteredData == null) - { - _memoryBrokerUnfilteredData = MemoryBrokerDataGrid.ItemsSource as List; - if (_memoryBrokerUnfilteredData == null && MemoryBrokerDataGrid.ItemsSource != null) - { - _memoryBrokerUnfilteredData = (MemoryBrokerDataGrid.ItemsSource as IEnumerable)?.ToList(); - } - } - - if (_memoryBrokerUnfilteredData == null) return; - - if (_memoryBrokerFilters.Count == 0) - { - MemoryBrokerDataGrid.ItemsSource = _memoryBrokerUnfilteredData; - return; - } - - var filteredData = _memoryBrokerUnfilteredData.Where(item => - { - foreach (var filter in _memoryBrokerFilters.Values) - { - if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter)) - { - return false; - } - } - return true; - }).ToList(); - - MemoryBrokerDataGrid.ItemsSource = filteredData; - } - - private void UpdateMemoryBrokerFilterButtonStyles() - { - foreach (var columnName in new[] { "CollectionTime", "Broker", "Notification", "MemoryRatio", - "CurrentlyAllocated", "PreviouslyAllocated", "NewTarget", "Overall", "Rate", "DeltaTime", "BrokerId" }) - { - UpdateFilterButtonStyle(MemoryBrokerDataGrid, columnName, _memoryBrokerFilters); - } - } - private void MemoryBrokerFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) { DataGridFilterService.ApplyFilter(MemoryBrokerDataGrid, sender as TextBox); diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index e8ba168a7..58bf73c16 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -74,13 +74,11 @@ public partial class SystemEventsContent : UserControl private DateTime? _memoryNodeOOMFromDate; private DateTime? _memoryNodeOOMToDate; - // Filter state dictionaries for each DataGrid + // Filter state dictionary for the SevereErrors DataGrid private Dictionary _severeErrorsFilters = new(); - private Dictionary _memoryBrokerFilters = new(); - // Unfiltered data caches + // Unfiltered data cache private List? _severeErrorsUnfilteredData; - private List? _memoryBrokerUnfilteredData; // Shared popup controls private Popup? _filterPopup; diff --git a/Dashboard/Mcp/McpHealthParserTools.cs b/Dashboard/Mcp/McpHealthParserTools.cs index dfab7cfba..1676388b5 100644 --- a/Dashboard/Mcp/McpHealthParserTools.cs +++ b/Dashboard/Mcp/McpHealthParserTools.cs @@ -35,7 +35,7 @@ public static async Task GetSystemHealth( hours_back, total_entries = rows.Count, shown = Math.Min(rows.Count, limit), - entries = rows.Take(limit).Select(r => SerializeHealthItem(r)) + entries = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_system_health", ex); } @@ -64,7 +64,7 @@ public static async Task GetSevereErrors( hours_back, error_count = rows.Count, shown = Math.Min(rows.Count, limit), - errors = rows.Take(limit).Select(r => SerializeHealthItem(r)) + errors = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_severe_errors", ex); } @@ -93,7 +93,7 @@ public static async Task GetIOIssues( hours_back, issue_count = rows.Count, shown = Math.Min(rows.Count, limit), - issues = rows.Take(limit).Select(r => SerializeHealthItem(r)) + issues = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_io_issues", ex); } @@ -122,7 +122,7 @@ public static async Task GetSchedulerIssues( hours_back, issue_count = rows.Count, shown = Math.Min(rows.Count, limit), - issues = rows.Take(limit).Select(r => SerializeHealthItem(r)) + issues = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_scheduler_issues", ex); } @@ -151,7 +151,7 @@ public static async Task GetMemoryConditions( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_conditions", ex); } @@ -180,7 +180,7 @@ public static async Task GetCPUTasks( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_cpu_tasks", ex); } @@ -209,7 +209,7 @@ public static async Task GetMemoryBroker( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_broker", ex); } @@ -238,20 +238,9 @@ public static async Task GetMemoryNodeOOM( hours_back, event_count = rows.Count, shown = Math.Min(rows.Count, limit), - events = rows.Take(limit).Select(r => SerializeHealthItem(r)) + events = rows.Take(limit) }, McpHelpers.JsonOptions); } catch (Exception ex) { return McpHelpers.FormatError("get_health_parser_memory_node_oom", ex); } } - - /// - /// Generic serializer for HealthParser items. All HealthParser models share - /// similar structure — uses reflection-free duck typing via dynamic. - /// - private static object SerializeHealthItem(object item) - { - // All HealthParser items share a CollectionTime property and varying detail columns. - // Serialize the full object and let JSON handle the properties. - return item; - } } diff --git a/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs b/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs index 98033c569..7c736f362 100644 --- a/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs +++ b/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs @@ -14,8 +14,8 @@ namespace PerformanceMonitorDashboard.Services.Remediation /// /// Maps a finding's fact key to its remediation handler. A fact key with no /// registered handler yields no Apply affordance (mirrors - /// FactRemediation.BuildAction returning null). v1 registers exactly - /// one handler: for PLAN_REGRESSION. + /// FactRemediation.BuildAction returning null). The handler set is + /// supplied at construction (see RemediationApplyService.CreateDefaultHandlers). /// public sealed class RemediationHandlerRegistry { From cf03210152ed928ed2bb935daed48e5f07ea2631 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:07:17 -0400 Subject: [PATCH 004/547] Remove dead inline-textbox-filter mechanism (Dashboard) Follow-up (d) from the code-simplification audit. Dashboard-only, no behavior change, verified by build + 691 Dashboard.Tests. The pre-popup inline-textbox column filter was abandoned when the popup-button filter UI landed, but its plumbing was left behind fully unwired (no XAML TextChanged bindings -- confirmed in both apps): - Removed 6 dead handlers: {MemoryBroker,SevereErrors,DailySummary} {Filter,NumericFilter}TextBox_TextChanged -- each a one-liner calling DataGridFilterService.ApplyFilter, none bound anywhere. - Collapsed DataGridFilterService to its one live member, the ColumnFilterState forwarder used by the popup filter UI (~25 callers). ApplyFilter (the textbox mechanism), the 3-arg string MatchesFilter overload (no callers), and their reflection cache + numeric/date type sets were all dead. Dashboard's service now matches Lite's minimal version. - Dropped the stale "Filtering logic moved to ApplyFilter()" comment. Lite needed no change: its DataGridFilterService was already the minimal forwarder and it has no textbox-filter handlers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controls/DailySummaryContent.xaml.cs | 12 -- .../SystemEventsContent.MemoryBroker.cs | 10 -- .../SystemEventsContent.SevereErrors.cs | 10 -- Dashboard/Services/DataGridFilterService.cs | 166 +----------------- 4 files changed, 3 insertions(+), 195 deletions(-) diff --git a/Dashboard/Controls/DailySummaryContent.xaml.cs b/Dashboard/Controls/DailySummaryContent.xaml.cs index c394b4059..e6dc2d4a8 100644 --- a/Dashboard/Controls/DailySummaryContent.xaml.cs +++ b/Dashboard/Controls/DailySummaryContent.xaml.cs @@ -178,16 +178,6 @@ private void DatePicker_CalendarOpened(object sender, RoutedEventArgs e) return null; } - private void DailySummaryFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(DailySummaryDataGrid, sender as TextBox); - } - - private void DailySummaryNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(DailySummaryDataGrid, sender as TextBox); - } - private async void DailySummary_Refresh_Click(object sender, RoutedEventArgs e) { try @@ -457,7 +447,5 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) } #endregion - - // Filtering logic moved to DataGridFilterService.ApplyFilter() } } diff --git a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs index e9e7d3448..8e30a2fef 100644 --- a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs +++ b/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs @@ -180,16 +180,6 @@ private void LoadMemoryBrokerChart(IEnumerable dat MemoryBrokerRatioChart.Plot.YLabel("Value"); } - private void MemoryBrokerFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(MemoryBrokerDataGrid, sender as TextBox); - } - - private void MemoryBrokerNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(MemoryBrokerDataGrid, sender as TextBox); - } - #endregion } } diff --git a/Dashboard/Controls/SystemEventsContent.SevereErrors.cs b/Dashboard/Controls/SystemEventsContent.SevereErrors.cs index 0e4151348..6bde5de23 100644 --- a/Dashboard/Controls/SystemEventsContent.SevereErrors.cs +++ b/Dashboard/Controls/SystemEventsContent.SevereErrors.cs @@ -161,16 +161,6 @@ private void UpdateSevereErrorsFilterButtonStyles() } } - private void SevereErrorsFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(SevereErrorsDataGrid, sender as TextBox); - } - - private void SevereErrorsNumericFilterTextBox_TextChanged(object sender, TextChangedEventArgs e) - { - DataGridFilterService.ApplyFilter(SevereErrorsDataGrid, sender as TextBox); - } - #endregion } } diff --git a/Dashboard/Services/DataGridFilterService.cs b/Dashboard/Services/DataGridFilterService.cs index c441f8a13..118b59eae 100644 --- a/Dashboard/Services/DataGridFilterService.cs +++ b/Dashboard/Services/DataGridFilterService.cs @@ -6,181 +6,21 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Windows.Controls; -using System.Windows.Data; -using PerformanceMonitorDashboard.Helpers; -using PerformanceMonitorDashboard.Models; using PerformanceMonitor.Common; namespace PerformanceMonitorDashboard.Services { /// - /// Provides shared filtering functionality for DataGrid controls. + /// Provides operator-based filtering for DataGrid column filters. Forwards to the shared + /// so Lite and Dashboard share one matching implementation. /// public static class DataGridFilterService { - /* Numeric types that should use NumericFilterHelper */ - private static readonly HashSet NumericTypes = new HashSet - { - typeof(int), typeof(int?), - typeof(long), typeof(long?), - typeof(short), typeof(short?), - typeof(decimal), typeof(decimal?), - typeof(double), typeof(double?), - typeof(float), typeof(float?) - }; - - /* Date types that should use DateFilterHelper */ - private static readonly HashSet DateTypes = new HashSet - { - typeof(DateTime), typeof(DateTime?) - }; - - /* Cache PropertyInfo lookups to avoid repeated reflection */ - private static readonly ConcurrentDictionary<(Type, string), PropertyInfo?> s_propertyCache = new(); - - /// - /// Gets a cached PropertyInfo for the given type and property name. - /// - private static PropertyInfo? GetCachedProperty(Type type, string propertyName) - { - return s_propertyCache.GetOrAdd((type, propertyName), key => key.Item1.GetProperty(key.Item2)); - } - - /// - /// Checks if a single property value matches a filter string using type-aware filtering. - /// Use this in custom filter implementations that need additional logic (e.g., ComboBox filters). - /// - /// The data item to check - /// The property name to filter on - /// The filter text (supports operators for numeric/date types) - /// True if the value matches the filter, false otherwise - public static bool MatchesFilter(object item, string propertyName, string filterText) - { - if (item == null || string.IsNullOrWhiteSpace(filterText)) - return true; - - var property = GetCachedProperty(item.GetType(), propertyName); - if (property == null) - return true; - - var propertyType = property.PropertyType; - var rawValue = property.GetValue(item); - - // Use appropriate filter helper based on property type - if (NumericTypes.Contains(propertyType)) - { - return NumericFilterHelper.MatchesFilter(rawValue, filterText); - } - else if (DateTypes.Contains(propertyType)) - { - return DateFilterHelper.MatchesFilter(rawValue, filterText); - } - else - { - // String filtering: case-insensitive contains with comma-separated OR - var value = rawValue?.ToString()?.ToLowerInvariant() ?? string.Empty; - var filterLower = filterText.ToLowerInvariant(); - var filterTerms = filterLower.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)); - return filterTerms.Any(term => value.Contains(term, StringComparison.Ordinal)); - } - } - /// /// Checks if an item matches a ColumnFilterState using operator-based filtering. - /// Used by the new popup-based column filter UI. + /// Used by the popup-based column filter UI. /// - /// The data item to check - /// The filter state containing operator and value - /// True if the value matches the filter, false otherwise public static bool MatchesFilter(object item, ColumnFilterState filter) => ColumnFilterMatcher.MatchesFilter(item, filter); - - /// - /// Applies a column-based filter to a DataGrid. - /// Filter TextBoxes in column headers (inside StackPanels) with Tag set to property names - /// are used to filter the data. Multiple terms separated by commas are OR-combined, - /// while multiple columns are AND-combined. - /// Numeric columns support operators: >, <, >=, <=, and ranges (10-20 or 10..20). - /// Date columns support operators, relative dates (today, yesterday), and ranges. - /// - /// The DataGrid to filter - /// The TextBox that triggered the filter (can be null) - public static void ApplyFilter(DataGrid? dataGrid, TextBox? filterTextBox) - { - if (dataGrid?.ItemsSource == null || filterTextBox == null) - return; - - var view = CollectionViewSource.GetDefaultView(dataGrid.ItemsSource); - if (view == null) - return; - - view.Filter = item => - { - if (item == null) - return false; - - // Get all filter TextBoxes for this DataGrid - var filterBoxes = new List<(string PropertyName, string FilterText)>(); - foreach (var column in dataGrid.Columns) - { - if (column.Header is StackPanel stackPanel) - { - var textBox = stackPanel.Children.OfType().FirstOrDefault(); - if (textBox != null && !string.IsNullOrWhiteSpace(textBox.Text) && textBox.Tag is string propName) - { - filterBoxes.Add((propName, textBox.Text)); - } - } - } - - // If no filters, show all items - if (filterBoxes.Count == 0) - return true; - - // Check all filters (AND logic) - foreach (var (propertyName, filterText) in filterBoxes) - { - var property = GetCachedProperty(item.GetType(), propertyName); - if (property != null) - { - var propertyType = property.PropertyType; - var rawValue = property.GetValue(item); - - // Use appropriate filter helper based on property type - if (NumericTypes.Contains(propertyType)) - { - // Numeric filtering: supports >, <, >=, <=, ranges - if (!NumericFilterHelper.MatchesFilter(rawValue, filterText)) - return false; - } - else if (DateTypes.Contains(propertyType)) - { - // Date filtering: supports operators, relative dates, ranges - if (!DateFilterHelper.MatchesFilter(rawValue, filterText)) - return false; - } - else - { - // String filtering: case-insensitive contains with comma-separated OR - var value = rawValue?.ToString()?.ToLowerInvariant() ?? string.Empty; - var filterLower = filterText.ToLowerInvariant(); - var filterTerms = filterLower.Split(',').Select(t => t.Trim()).Where(t => !string.IsNullOrEmpty(t)); - var matchesAnyTerm = filterTerms.Any(term => value.Contains(term, StringComparison.Ordinal)); - - if (!matchesAnyTerm) - return false; - } - } - } - - return true; - }; - } } } From 797d179309d173d0afd1f2854990ebcbcf8234f5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:28:55 -0400 Subject: [PATCH 005/547] Bring Dashboard MCP tool recommendations to parity with Lite Follow-up (c) from the code-simplification audit. The audit flagged ToolRecommendations as a copy-paste duplicate to move to Common, but it is NOT: the two apps expose different MCP tools, so each map correctly references its own app's tools. The real defects were Dashboard's stale "shared -- same recommendations" comment (a trap that nearly became a dedup-into-bug) and Dashboard's map being materially thinner than Lite's. - Fixed the comment to state the maps are app-specific (different tool sets), not shared/duplicated. - Enriched 35 -> 38 fact keys (added LCK_M_IS, LATCH_SH, RUNNING_JOBS to match Lite's coverage) and fleshed out every entry to Lite's depth, using ONLY Dashboard's tool inventory. - Translated Lite-only tool names to Dashboard equivalents: get_blocked_process_reports -> get_blocking; get_blocking_trend / get_deadlock_trend -> get_blocking_deadlock_stats; get_memory_grants -> get_resource_semaphore; get_query_duration_trend -> get_query_trend; get_waiting_tasks -> get_cpu_scheduler_pressure / get_active_queries. - Used Dashboard's richer tools where they beat Lite's: get_latch_stats for LATCH_EX/LATCH_SH, get_database_sizes (volume free space) for DISK_SPACE. Dashboard-only. Build + 691 Dashboard.Tests green. The recommendation text is domain advice -- worth an expert eyeball before merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Mcp/McpAnalysisTools.cs | 262 ++++++++++++++++++++++++++---- 1 file changed, 226 insertions(+), 36 deletions(-) diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs index 098820fbb..db21cf74e 100644 --- a/Dashboard/Mcp/McpAnalysisTools.cs +++ b/Dashboard/Mcp/McpAnalysisTools.cs @@ -445,47 +445,237 @@ public static async Task MuteAnalysisFinding( /// /// Maps fact keys to recommended MCP tools for further investigation. -/// Shared between Lite and Dashboard — same recommendations. +/// Used by analyze_server to tell the AI client what to call next. Lite keeps its own +/// copy (PerformanceMonitorLite.Mcp) because the two apps expose different MCP tool +/// sets, so the recommendations are app-specific -- NOT a shared/duplicated list. /// internal static class ToolRecommendations { private static readonly System.Collections.Generic.Dictionary> ByFactKey = new() { - ["SOS_SCHEDULER_YIELD"] = [new("get_cpu_utilization", "Check CPU usage over time"), new("get_top_queries_by_cpu", "Find CPU-expensive queries")], - ["CXPACKET"] = [new("get_top_queries_by_cpu", "Find parallel queries", new() { ["parallel_only"] = "true" }), new("audit_config", "Check CTFP and MAXDOP")], - ["THREADPOOL"] = [new("get_top_queries_by_cpu", "Find resource-consuming queries"), new("get_blocking", "Check if blocking is holding threads")], - ["PAGEIOLATCH_SH"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool")], - ["PAGEIOLATCH_EX"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool"), new("get_tempdb_trend", "Check whether tempdb I/O is driving EX-mode waits")], - ["RESOURCE_SEMAPHORE"] = [new("get_resource_semaphore", "Check memory grants")], - ["WRITELOG"] = [new("get_file_io_stats", "Check log file latency"), new("get_perfmon_trend", "Check Transactions/sec commit rate driving log flush pressure", new() { ["counter_name"] = "Transactions/sec" })], - ["LCK"] = [new("get_blocking", "Get blocking details"), new("get_deadlocks", "Check for deadlocks")], - ["LCK_M_S"] = [new("get_blocking", "Get reader/writer blocking details")], - ["SCH_M"] = [new("get_blocking", "Check if DDL is causing blocking"), new("get_running_jobs", "See if maintenance jobs are taking schema-modification locks")], - ["BLOCKING_EVENTS"] = [new("get_blocking", "Get detailed blocking reports"), new("get_deadlocks", "Check for deadlocks")], - ["DEADLOCKS"] = [new("get_deadlocks", "Get deadlock events"), new("get_deadlock_detail", "Get full deadlock XML")], - ["CPU_SQL_PERCENT"] = [new("get_cpu_utilization", "See CPU trend"), new("get_top_queries_by_cpu", "Find CPU queries")], - ["CPU_SPIKE"] = [new("get_cpu_utilization", "See when spike occurred"), new("get_top_queries_by_cpu", "Find queries that drove the spike")], - ["IO_READ_LATENCY_MS"] = [new("get_file_io_stats", "Check per-file latency"), new("get_memory_stats", "Check buffer pool")], - ["IO_WRITE_LATENCY_MS"] = [new("get_file_io_stats", "Check per-file latency")], - ["TEMPDB_USAGE"] = [new("get_tempdb_trend", "Track TempDB usage")], - ["MEMORY_GRANT_PENDING"] = [new("get_resource_semaphore", "Check memory grants")], - ["QUERY_SPILLS"] = [new("get_top_queries_by_cpu", "Find queries with spills")], - ["QUERY_HIGH_DOP"] = [new("get_top_queries_by_cpu", "Find high-DOP queries", new() { ["parallel_only"] = "true" })], - ["PARAMETER_SENSITIVITY"] = [new("get_top_queries_by_cpu", "Find the sensitive query and see its cached parameters"), new("analyze_query_plan", "Examine the plan for operators driving the runtime variance"), new("get_query_trend", "Confirm the bimodal duration pattern over time"), new("get_resource_semaphore", "Check whether bad-parameter executions blow up memory grants")], - ["PLAN_REGRESSION"] = [new("analyze_query_store_plan", "Compare the regressed plan against the prior plan"), new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), new("get_query_store_top", "Pull the full Query Store entry and forced-plan history before forcing")], - ["DB_CONFIG"] = [new("audit_config", "Check configuration")], - ["FILE_AUTOGROWTH_PERCENT"] = [new("get_database_sizes", "See per-file sizes and autogrowth settings"), new("get_file_io_stats", "Check per-file growth/latency")], - ["DISK_SPACE"] = [new("get_file_io_stats", "Check per-file sizes")], - ["LATCH_EX"] = [new("get_latch_stats", "Check latch contention"), new("get_tempdb_trend", "Check TempDB")], - ["BAD_ACTOR"] = [new("get_top_queries_by_cpu", "See full query stats"), new("analyze_query_plan", "Analyze the execution plan")], - ["ANOMALY_CPU"] = [new("get_cpu_utilization", "See CPU trend"), new("get_active_queries", "Find what ran during spike")], - ["ANOMALY_WAIT"] = [new("get_wait_stats", "See wait breakdown"), new("compare_analysis", "Compare current vs baseline")], - ["ANOMALY_BLOCKING"] = [new("get_blocking", "Get blocking details"), new("get_deadlocks", "Get deadlock events")], - ["ANOMALY_IO"] = [new("get_file_io_stats", "Check I/O latency"), new("get_memory_stats", "Check buffer pool")], - ["ANOMALY_SESSION_SPIKE"] = [new("get_session_stats", "See which application is driving the session-count spike"), new("get_active_queries", "Find what those sessions were doing at the spike")], - ["ANOMALY_QUERY_DURATION"] = [new("get_top_queries_by_cpu", "Find the queries whose runtime moved the average"), new("analyze_query_plan", "Examine the plan for the queries that slowed down"), new("get_query_trend", "Track the regressed query across executions")], - ["ANOMALY_MEMORY_PRESSURE"] = [new("get_memory_stats", "See current memory allocation"), new("get_memory_clerks", "Find which clerks are growing"), new("get_memory_pressure_events", "Pull the RING_BUFFER_RESOURCE_MONITOR notifications driving the anomaly"), new("get_resource_semaphore", "Check whether query grants are competing with buffer pool")], - ["ANOMALY_BATCH_REQUESTS"] = [new("get_perfmon_trend", "Confirm the batch-rate change across the window", new() { ["counter_name"] = "Batch Requests/sec" }), new("get_top_queries_by_cpu", "Find which queries account for the new batch volume"), new("get_active_queries", "See what's actually running at the elevated rate")] + ["SOS_SCHEDULER_YIELD"] = + [ + new("get_cpu_utilization", "Check SQL Server vs other-process CPU usage over time"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries"), + new("get_perfmon_trend", "Check batch requests/sec for throughput context", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CXPACKET"] = + [ + new("get_top_queries_by_cpu", "Find parallel queries consuming CPU", new() { ["parallel_only"] = "true" }), + new("get_wait_trend", "Track the parallelism wait trend over time", new() { ["wait_type"] = "CXPACKET" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["THREADPOOL"] = + [ + new("get_cpu_scheduler_pressure", "Check runnable-queue depth and worker-thread exhaustion"), + new("get_top_queries_by_cpu", "Find queries consuming the most resources"), + new("get_blocking", "Check whether blocking is holding worker threads") + ], + ["PAGEIOLATCH_SH"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_resource_semaphore", "Check for memory grant pressure competing with the buffer pool") + ], + ["PAGEIOLATCH_EX"] = + [ + new("get_file_io_stats", "Check I/O latency per database file"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check buffer pool and memory pressure"), + new("get_tempdb_trend", "Check whether tempdb I/O is driving the EX-mode waits") + ], + ["RESOURCE_SEMAPHORE"] = + [ + new("get_resource_semaphore", "Check granted vs available workspace memory and waiter counts"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large memory grants") + ], + ["WRITELOG"] = + [ + new("get_file_io_stats", "Check transaction log file latency"), + new("get_file_io_trend", "Track log I/O latency over time"), + new("get_perfmon_trend", "Check Transactions/sec to see the commit rate driving log flush pressure", new() { ["counter_name"] = "Transactions/sec" }) + ], + ["LCK"] = + [ + new("get_blocking", "Get detailed blocking event reports"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time"), + new("get_active_queries", "See currently running sessions with their lock/wait detail") + ], + ["LCK_M_S"] = + [ + new("get_blocking", "Get reader/writer blocking details"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["LCK_M_IS"] = + [ + new("get_blocking", "Get reader/writer blocking details"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["BLOCKING_EVENTS"] = + [ + new("get_blocking", "Get detailed blocking reports with full query text"), + new("get_blocking_deadlock_stats", "Track blocking event frequency over time"), + new("get_deadlocks", "Check whether blocking is escalating to deadlocks") + ], + ["DEADLOCKS"] = + [ + new("get_deadlocks", "Get recent deadlock events with victim info"), + new("get_deadlock_detail", "Get the full deadlock graph XML for deep analysis"), + new("get_blocking_deadlock_stats", "Track deadlock frequency over time") + ], + ["SCH_M"] = + [ + new("get_active_queries", "See what's waiting on schema-modification locks"), + new("get_blocking", "Check whether DDL operations are causing blocking"), + new("get_running_jobs", "See whether maintenance jobs (index rebuilds, stats updates) are taking schema-modification locks") + ], + ["CPU_SQL_PERCENT"] = + [ + new("get_cpu_utilization", "See the CPU trend over time"), + new("get_top_queries_by_cpu", "Find queries consuming the most CPU"), + new("get_perfmon_trend", "Check batch requests/sec for throughput context", new() { ["counter_name"] = "Batch Requests/sec" }) + ], + ["CPU_SPIKE"] = + [ + new("get_cpu_utilization", "See the CPU trend to identify when the spike occurred"), + new("get_top_queries_by_cpu", "Find the queries that drove the CPU spike"), + new("get_active_queries", "Find what was actually running during the spike") + ], + ["IO_READ_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file read latency"), + new("get_file_io_trend", "Track read latency over time"), + new("get_memory_stats", "Check whether the buffer pool is undersized") + ], + ["IO_WRITE_LATENCY_MS"] = + [ + new("get_file_io_stats", "Check per-file write latency"), + new("get_file_io_trend", "Track write latency over time") + ], + ["TEMPDB_USAGE"] = + [ + new("get_tempdb_trend", "Track TempDB usage over time"), + new("get_top_queries_by_cpu", "Find queries that may be spilling to TempDB") + ], + ["MEMORY_GRANT_PENDING"] = + [ + new("get_resource_semaphore", "Check active vs pending memory grants and waiter counts"), + new("get_memory_stats", "Check overall memory allocation"), + new("get_top_queries_by_cpu", "Find queries requesting large grants") + ], + ["QUERY_SPILLS"] = + [ + new("get_top_queries_by_cpu", "Find queries with spills"), + new("get_resource_semaphore", "Check memory grant pressure"), + new("get_tempdb_trend", "Check the TempDB impact from spills") + ], + ["QUERY_HIGH_DOP"] = + [ + new("get_top_queries_by_cpu", "Find high-DOP queries", new() { ["parallel_only"] = "true" }), + new("audit_config", "Check CTFP and MAXDOP settings") + ], + ["PARAMETER_SENSITIVITY"] = + [ + new("get_top_queries_by_cpu", "Find the sensitive query in the plan cache and see its current cached parameters"), + new("analyze_query_plan", "Examine the plan for the operators driving the runtime variance (seek vs scan, grant size, join type)"), + new("get_query_trend", "Confirm the bimodal duration pattern across executions over time"), + new("get_resource_semaphore", "Check whether the bad-parameter executions are also blowing up memory grants") + ], + ["PLAN_REGRESSION"] = + [ + new("analyze_query_store_plan", "Compare the regressed plan against the prior plan to see what the optimizer changed"), + new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), + new("get_query_store_top", "Pull the full Query Store entry including plan_id and forced-plan history before considering a force") + ], + ["LATCH_EX"] = + [ + new("get_latch_stats", "Check latch contention by class (ACCESS_METHODS_DATASET_PARENT / FGCB_ADD_REMOVE point at TempDB allocation contention)"), + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_EX" }) + ], + ["LATCH_SH"] = + [ + new("get_latch_stats", "Check latch contention by class"), + new("get_tempdb_trend", "Check TempDB for allocation contention"), + new("get_wait_trend", "Track the latch contention trend", new() { ["wait_type"] = "LATCH_SH" }) + ], + ["DB_CONFIG"] = + [ + new("audit_config", "Check server-level configuration against best practices"), + new("get_blocking", "Check whether RCSI-off databases are seeing blocking") + ], + ["FILE_AUTOGROWTH_PERCENT"] = + [ + new("get_database_sizes", "See per-file sizes and autogrowth settings"), + new("get_file_io_stats", "Check per-file growth and latency") + ], + ["RUNNING_JOBS"] = + [ + new("get_running_jobs", "See currently running jobs with duration vs historical average and p95"), + new("get_cpu_utilization", "Check whether long-running jobs are consuming CPU") + ], + ["DISK_SPACE"] = + [ + new("get_database_sizes", "See per-file sizes and underlying volume free space"), + new("get_tempdb_trend", "Check TempDB growth on the volume") + ], + ["BAD_ACTOR"] = + [ + new("get_top_queries_by_cpu", "See full query stats for this query"), + new("analyze_query_plan", "Analyze the execution plan for optimization opportunities"), + new("get_query_trend", "Track this query's performance over time") + ], + ["ANOMALY_CPU"] = + [ + new("get_cpu_utilization", "See the CPU trend to identify when the spike occurred"), + new("get_active_queries", "Find what queries were running during the spike"), + new("get_top_queries_by_cpu", "Find the most CPU-expensive queries in the period") + ], + ["ANOMALY_WAIT"] = + [ + new("get_wait_stats", "See the full wait stats breakdown"), + new("get_wait_trend", "Track the anomalous wait type over time"), + new("compare_analysis", "Compare current vs baseline to see what changed") + ], + ["ANOMALY_BLOCKING"] = + [ + new("get_blocking", "Get detailed blocking event reports"), + new("get_deadlocks", "Get recent deadlock events"), + new("get_blocking_deadlock_stats", "Track blocking frequency over time") + ], + ["ANOMALY_IO"] = + [ + new("get_file_io_stats", "Check per-file I/O latency"), + new("get_file_io_trend", "Track I/O latency over time"), + new("get_memory_stats", "Check whether the buffer pool is undersized") + ], + ["ANOMALY_SESSION_SPIKE"] = + [ + new("get_session_stats", "See which application is driving the session-count spike"), + new("get_active_queries", "Find what those sessions were doing at the spike"), + new("get_cpu_scheduler_pressure", "Check whether the new sessions are exhausting worker threads") + ], + ["ANOMALY_QUERY_DURATION"] = + [ + new("get_query_trend", "Confirm the duration shift across the analysis window"), + new("get_top_queries_by_cpu", "Find the queries whose runtime moved the average"), + new("analyze_query_plan", "Examine the plan for the queries that slowed down") + ], + ["ANOMALY_MEMORY_PRESSURE"] = + [ + new("get_memory_stats", "See current memory allocation and target vs total"), + new("get_memory_clerks", "Find which clerks are growing"), + new("get_memory_pressure_events", "Pull the RING_BUFFER_RESOURCE_MONITOR notifications driving the anomaly"), + new("get_resource_semaphore", "Check whether query grants are competing with the buffer pool") + ], + ["ANOMALY_BATCH_REQUESTS"] = + [ + new("get_perfmon_trend", "Confirm the batch-rate change across the window", new() { ["counter_name"] = "Batch Requests/sec" }), + new("get_top_queries_by_cpu", "Find which queries account for the new batch volume"), + new("get_active_queries", "See what's actually running at the elevated rate") + ] }; public static System.Collections.Generic.List GetForStoryPath(string storyPath) From a75bb472025a5be3519f14f1fc14f4eeb1db49fd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:11:45 -0400 Subject: [PATCH 006/547] Dedup MCP resolve-and-bail into ServerResolver.ResolveOrError (both apps) Follow-up (b) from the code-simplification audit. Every MCP tool method opened with the identical resolve-and-bail block -- 61 copies in Dashboard, 50 in Lite, each repeating the same "Could not resolve server..." error string verbatim. Added ServerResolver.ResolveOrError(...) per app (the Resolve signatures differ: Dashboard takes the registry, Lite doesn't) returning (resolved, error), and collapsed all 111 sites to: var (resolved, error) = ServerResolver.ResolveOrError(...); if (error != null) return error; The error string is now single-sourced instead of copy-pasted 111 times. Downstream code is unchanged (it already used resolved.Value.X). Handled both the braced and brace-less if-forms plus one nested optional-resolve site. Both apps: build + Dashboard.Tests (691) + Lite.Tests (618) green. Net -212 lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/Mcp/McpActiveQueryTools.cs | 5 ++- Dashboard/Mcp/McpAnalysisTools.cs | 30 +++++++---------- Dashboard/Mcp/McpBlockingTools.cs | 35 ++++++-------------- Dashboard/Mcp/McpConfigHistoryTools.cs | 15 ++++----- Dashboard/Mcp/McpCpuTools.cs | 7 ++-- Dashboard/Mcp/McpDiagnosticTools.cs | 15 ++++----- Dashboard/Mcp/McpHealthParserTools.cs | 40 +++++++++------------- Dashboard/Mcp/McpHealthTools.cs | 14 +++----- Dashboard/Mcp/McpIoTools.cs | 14 +++----- Dashboard/Mcp/McpJobTools.cs | 7 ++-- Dashboard/Mcp/McpLatchSpinlockTools.cs | 10 +++--- Dashboard/Mcp/McpMemoryTools.cs | 28 +++++----------- Dashboard/Mcp/McpObjectStatsTools.cs | 21 ++++-------- Dashboard/Mcp/McpPerfmonTools.cs | 14 +++----- Dashboard/Mcp/McpPlanTools.cs | 20 +++++------ Dashboard/Mcp/McpQueryTools.cs | 35 ++++++-------------- Dashboard/Mcp/McpSchedulerTools.cs | 5 ++- Dashboard/Mcp/McpServerInventoryTools.cs | 10 +++--- Dashboard/Mcp/McpSystemEventTools.cs | 15 ++++----- Dashboard/Mcp/McpTempDbTools.cs | 7 ++-- Dashboard/Mcp/McpWaitTools.cs | 14 +++----- Dashboard/Mcp/ServerResolver.cs | 16 +++++++++ Lite/Mcp/McpAnalysisTools.cs | 42 +++++++----------------- Lite/Mcp/McpBlockingTools.cs | 42 +++++++----------------- Lite/Mcp/McpConfigTools.cs | 20 +++++------ Lite/Mcp/McpCpuTools.cs | 7 ++-- Lite/Mcp/McpHealthTools.cs | 14 +++----- Lite/Mcp/McpIoTools.cs | 14 +++----- Lite/Mcp/McpJobTools.cs | 7 ++-- Lite/Mcp/McpMemoryTools.cs | 35 ++++++-------------- Lite/Mcp/McpObjectStatsTools.cs | 21 ++++-------- Lite/Mcp/McpPerfmonTools.cs | 14 +++----- Lite/Mcp/McpPlanTools.cs | 20 +++++------ Lite/Mcp/McpQueryTools.cs | 35 ++++++-------------- Lite/Mcp/McpServerInfoTools.cs | 10 +++--- Lite/Mcp/McpSessionTools.cs | 10 +++--- Lite/Mcp/McpTempDbTools.cs | 7 ++-- Lite/Mcp/McpWaitTools.cs | 28 +++++----------- Lite/Mcp/ServerResolver.cs | 15 +++++++++ 39 files changed, 253 insertions(+), 465 deletions(-) diff --git a/Dashboard/Mcp/McpActiveQueryTools.cs b/Dashboard/Mcp/McpActiveQueryTools.cs index 371102442..79ee87e8d 100644 --- a/Dashboard/Mcp/McpActiveQueryTools.cs +++ b/Dashboard/Mcp/McpActiveQueryTools.cs @@ -20,9 +20,8 @@ public static async Task GetActiveQueries( [Description("Hours of data to retrieve. Default 1.")] int hours_back = 1, [Description("Maximum number of rows to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs index 098820fbb..7589541a0 100644 --- a/Dashboard/Mcp/McpAnalysisTools.cs +++ b/Dashboard/Mcp/McpAnalysisTools.cs @@ -32,9 +32,8 @@ public static async Task AnalyzeServer( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -134,9 +133,8 @@ public static async Task GetAnalysisFacts( [Description("Filter to a specific source category. Omit for all.")] string? source = null, [Description("Minimum severity to include. Default 0.")] double min_severity = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -206,9 +204,8 @@ public static async Task CompareAnalysis( [Description("Hours back for the comparison period. Default 4.")] int hours_back = 4, [Description("Hours back for the baseline period start. Default 28.")] int baseline_hours_back = 28) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -281,9 +278,8 @@ public static async Task AuditConfig( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -352,9 +348,8 @@ public static async Task GetAnalysisFindings( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of finding history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -423,9 +418,8 @@ public static async Task MuteAnalysisFinding( [Description("Server name.")] string? server_name = null, [Description("Optional reason for muting.")] string? reason = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpBlockingTools.cs b/Dashboard/Mcp/McpBlockingTools.cs index 9f7f747aa..ebc92fbc0 100644 --- a/Dashboard/Mcp/McpBlockingTools.cs +++ b/Dashboard/Mcp/McpBlockingTools.cs @@ -20,11 +20,8 @@ public static async Task GetBlocking( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -86,11 +83,8 @@ public static async Task GetDeadlocks( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -154,11 +148,8 @@ public static async Task GetDeadlockDetail( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -204,11 +195,8 @@ public static async Task GetBlockedProcessXml( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum reports to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -254,11 +242,8 @@ public static async Task GetBlockingDeadlockStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpConfigHistoryTools.cs b/Dashboard/Mcp/McpConfigHistoryTools.cs index 3c8816ac8..28a35ec6e 100644 --- a/Dashboard/Mcp/McpConfigHistoryTools.cs +++ b/Dashboard/Mcp/McpConfigHistoryTools.cs @@ -19,9 +19,8 @@ public static async Task GetServerConfigChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -64,9 +63,8 @@ public static async Task GetDatabaseConfigChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -107,9 +105,8 @@ public static async Task GetTraceFlagChanges( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 168 (7 days).")] int hours_back = 168) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpCpuTools.cs b/Dashboard/Mcp/McpCpuTools.cs index 344fadaf2..661836f33 100644 --- a/Dashboard/Mcp/McpCpuTools.cs +++ b/Dashboard/Mcp/McpCpuTools.cs @@ -19,11 +19,8 @@ public static async Task GetCpuUtilization( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpDiagnosticTools.cs b/Dashboard/Mcp/McpDiagnosticTools.cs index 84495dee5..690368a3a 100644 --- a/Dashboard/Mcp/McpDiagnosticTools.cs +++ b/Dashboard/Mcp/McpDiagnosticTools.cs @@ -19,9 +19,8 @@ public static async Task GetPlanCacheBloat( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -82,9 +81,8 @@ public static async Task GetCriticalIssues( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -130,9 +128,8 @@ public static async Task GetSessionStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpHealthParserTools.cs b/Dashboard/Mcp/McpHealthParserTools.cs index 1676388b5..1fef49270 100644 --- a/Dashboard/Mcp/McpHealthParserTools.cs +++ b/Dashboard/Mcp/McpHealthParserTools.cs @@ -20,9 +20,8 @@ public static async Task GetSystemHealth( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -49,9 +48,8 @@ public static async Task GetSevereErrors( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -78,9 +76,8 @@ public static async Task GetIOIssues( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -107,9 +104,8 @@ public static async Task GetSchedulerIssues( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -136,9 +132,8 @@ public static async Task GetMemoryConditions( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -165,9 +160,8 @@ public static async Task GetCPUTasks( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -194,9 +188,8 @@ public static async Task GetMemoryBroker( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -223,9 +216,8 @@ public static async Task GetMemoryNodeOOM( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpHealthTools.cs b/Dashboard/Mcp/McpHealthTools.cs index b68fdd899..4b7b72d80 100644 --- a/Dashboard/Mcp/McpHealthTools.cs +++ b/Dashboard/Mcp/McpHealthTools.cs @@ -18,11 +18,8 @@ public static async Task GetCollectionHealth( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -64,11 +61,8 @@ public static async Task GetDailySummary( [Description("Server name or display name.")] string? server_name = null, [Description("Summary date (yyyy-MM-dd). Default is today.")] string? summary_date = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpIoTools.cs b/Dashboard/Mcp/McpIoTools.cs index 779b3fea6..7334c25dd 100644 --- a/Dashboard/Mcp/McpIoTools.cs +++ b/Dashboard/Mcp/McpIoTools.cs @@ -18,11 +18,8 @@ public static async Task GetFileIoStats( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task GetFileIoTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpJobTools.cs b/Dashboard/Mcp/McpJobTools.cs index 273fc0c08..5611e16d3 100644 --- a/Dashboard/Mcp/McpJobTools.cs +++ b/Dashboard/Mcp/McpJobTools.cs @@ -26,11 +26,8 @@ public static async Task GetRunningJobs( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpLatchSpinlockTools.cs b/Dashboard/Mcp/McpLatchSpinlockTools.cs index 544f8f46a..16f6defcd 100644 --- a/Dashboard/Mcp/McpLatchSpinlockTools.cs +++ b/Dashboard/Mcp/McpLatchSpinlockTools.cs @@ -20,9 +20,8 @@ public static async Task GetLatchStats( [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, [Description("Number of top latch classes to return. Default 10.")] int top = 10) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -83,9 +82,8 @@ public static async Task GetSpinlockStats( [Description("Hours of data to analyze. Default 24.")] int hours_back = 24, [Description("Number of top spinlocks to return. Default 10.")] int top = 10) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpMemoryTools.cs b/Dashboard/Mcp/McpMemoryTools.cs index 0434344b3..e70805a39 100644 --- a/Dashboard/Mcp/McpMemoryTools.cs +++ b/Dashboard/Mcp/McpMemoryTools.cs @@ -19,11 +19,8 @@ public static async Task GetMemoryStats( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -71,11 +68,8 @@ public static async Task GetMemoryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -117,11 +111,8 @@ public static async Task GetMemoryClerks( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -166,11 +157,8 @@ public static async Task GetMemoryGrants( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpObjectStatsTools.cs b/Dashboard/Mcp/McpObjectStatsTools.cs index db5ca5320..47a361bfb 100644 --- a/Dashboard/Mcp/McpObjectStatsTools.cs +++ b/Dashboard/Mcp/McpObjectStatsTools.cs @@ -18,11 +18,8 @@ public static async Task GetTableIndexSizes( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task GetIndexUsage( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -115,11 +109,8 @@ public static async Task GetObjectLocking( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpPerfmonTools.cs b/Dashboard/Mcp/McpPerfmonTools.cs index 01eb5f88a..5e45f90d1 100644 --- a/Dashboard/Mcp/McpPerfmonTools.cs +++ b/Dashboard/Mcp/McpPerfmonTools.cs @@ -23,11 +23,8 @@ public static async Task GetPerfmonStats( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'. Partial match.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name. Partial match.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -80,11 +77,8 @@ public static async Task GetPerfmonTrend( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'. Partial match.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name. Partial match.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpPlanTools.cs b/Dashboard/Mcp/McpPlanTools.cs index 850985e1e..f7d6b8332 100644 --- a/Dashboard/Mcp/McpPlanTools.cs +++ b/Dashboard/Mcp/McpPlanTools.cs @@ -24,9 +24,8 @@ public static async Task AnalyzeQueryPlan( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -54,9 +53,8 @@ public static async Task AnalyzeProcedurePlan( [Description("The sql_handle value from get_top_procedures_by_cpu.")] string sql_handle, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -85,9 +83,8 @@ public static async Task AnalyzeQueryStorePlan( [Description("The query_id from get_query_store_top.")] long query_id, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -135,9 +132,8 @@ public static async Task GetPlanXml( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpQueryTools.cs b/Dashboard/Mcp/McpQueryTools.cs index 65b53ecc2..d76f687a8 100644 --- a/Dashboard/Mcp/McpQueryTools.cs +++ b/Dashboard/Mcp/McpQueryTools.cs @@ -25,11 +25,8 @@ public static async Task GetTopQueriesByCpu( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -97,11 +94,8 @@ public static async Task GetTopProceduresByCpu( [Description("Number of top procedures. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -164,11 +158,8 @@ public static async Task GetQueryStoreTop( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -228,11 +219,8 @@ public static async Task GetExpensiveQueries( [Description("Number of top queries. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -293,11 +281,8 @@ public static async Task GetQueryTrend( [Description("The database name the query belongs to.")] string database_name, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpSchedulerTools.cs b/Dashboard/Mcp/McpSchedulerTools.cs index ffefa38b1..9c19bd11c 100644 --- a/Dashboard/Mcp/McpSchedulerTools.cs +++ b/Dashboard/Mcp/McpSchedulerTools.cs @@ -17,9 +17,8 @@ public static async Task GetCpuSchedulerPressure( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpServerInventoryTools.cs b/Dashboard/Mcp/McpServerInventoryTools.cs index 38b1b84e6..96af6189d 100644 --- a/Dashboard/Mcp/McpServerInventoryTools.cs +++ b/Dashboard/Mcp/McpServerInventoryTools.cs @@ -18,9 +18,8 @@ public static async Task GetDatabaseSizes( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { @@ -69,9 +68,8 @@ public static async Task GetServerProperties( DatabaseServiceRegistry registry, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpSystemEventTools.cs b/Dashboard/Mcp/McpSystemEventTools.cs index bb28242fb..ff807900b 100644 --- a/Dashboard/Mcp/McpSystemEventTools.cs +++ b/Dashboard/Mcp/McpSystemEventTools.cs @@ -20,9 +20,8 @@ public static async Task GetDefaultTraceEvents( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of events to return. Default 100.")] int limit = 100) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -72,9 +71,8 @@ public static async Task GetTraceAnalysis( [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24, [Description("Maximum number of entries to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -135,9 +133,8 @@ public static async Task GetMemoryPressureEvents( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; diff --git a/Dashboard/Mcp/McpTempDbTools.cs b/Dashboard/Mcp/McpTempDbTools.cs index a624d6e68..9b97d35dd 100644 --- a/Dashboard/Mcp/McpTempDbTools.cs +++ b/Dashboard/Mcp/McpTempDbTools.cs @@ -19,11 +19,8 @@ public static async Task GetTempDbTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; try { diff --git a/Dashboard/Mcp/McpWaitTools.cs b/Dashboard/Mcp/McpWaitTools.cs index c810ec7b0..ffcc6a93e 100644 --- a/Dashboard/Mcp/McpWaitTools.cs +++ b/Dashboard/Mcp/McpWaitTools.cs @@ -19,11 +19,8 @@ public static async Task GetWaitStats( [Description("Server name or display name.")] string? server_name = null, [Description("Maximum rows to return. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var limitError = McpHelpers.ValidateTop(limit); if (limitError != null) return limitError; @@ -68,11 +65,8 @@ public static async Task GetWaitTrend( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Top N wait types to include. Default 5.")] int top_wait_types = 5) { - var resolved = ServerResolver.Resolve(serverManager, registry, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, registry, server_name); + if (error != null) return error; var hoursError = McpHelpers.ValidateHoursBack(hours_back); if (hoursError != null) return hoursError; diff --git a/Dashboard/Mcp/ServerResolver.cs b/Dashboard/Mcp/ServerResolver.cs index c236fb1c3..ffa9dcde5 100644 --- a/Dashboard/Mcp/ServerResolver.cs +++ b/Dashboard/Mcp/ServerResolver.cs @@ -62,6 +62,22 @@ internal static class ServerResolver return new ResolvedServer(displayName, match, service); } + /// + /// Resolves a server name, returning either the resolved server or a ready-to-return error + /// string listing the available servers. Lets MCP tools collapse the repeated resolve-and-bail + /// block to: var (resolved, error) = ResolveOrError(...); if (error != null) return error; + /// + public static (ResolvedServer? resolved, string? error) ResolveOrError( + ServerManager serverManager, + DatabaseServiceRegistry registry, + string? serverName) + { + var resolved = Resolve(serverManager, registry, serverName); + return resolved is null + ? (null, $"Could not resolve server. Available servers:\n{ListAvailableServers(serverManager)}") + : (resolved, null); + } + public static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetAllServers(); diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index d232c0930..eb6302dfb 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -18,11 +18,8 @@ public static async Task AnalyzeServer( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of data to analyze. Default 4. Longer windows give more stable results but may miss recent spikes.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -121,11 +118,8 @@ public static async Task GetAnalysisFacts( [Description("Filter to a specific source category: waits, blocking, config, memory. Omit for all.")] string? source = null, [Description("Minimum severity to include. Default 0 (all facts). Use 0.5 to see only significant facts.")] double min_severity = 0) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -196,11 +190,8 @@ public static async Task CompareAnalysis( [Description("Hours back for the comparison (recent) period. Default 4.")] int hours_back = 4, [Description("Hours back for the baseline period start, measured from now. Default 28 (yesterday same time). The baseline period will be the same duration as the comparison period.")] int baseline_hours_back = 28) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -287,11 +278,8 @@ public static async Task AuditConfig( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -510,11 +498,8 @@ public static async Task GetAnalysisFindings( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of finding history to retrieve. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -606,11 +591,8 @@ public static async Task MuteAnalysisFinding( int? serverId = null; if (server_name != null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; serverId = resolved.Value.ServerId; } diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index 86db7a95c..b92b9903c 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -17,11 +17,8 @@ public static async Task GetDeadlocks( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -69,11 +66,8 @@ public static async Task GetDeadlockDetail( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum deadlocks to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -119,11 +113,8 @@ public static async Task GetBlockedProcessReports( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -197,11 +188,8 @@ public static async Task GetBlockedProcessXml( [Description("Hours of history. Default 24.")] int hours_back = 24, [Description("Maximum reports to return. Default 5.")] int limit = 5) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -248,11 +236,8 @@ public static async Task GetBlockingTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -282,11 +267,8 @@ public static async Task GetDeadlockTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpConfigTools.cs b/Lite/Mcp/McpConfigTools.cs index 1becdaa4e..f25c0c73e 100644 --- a/Lite/Mcp/McpConfigTools.cs +++ b/Lite/Mcp/McpConfigTools.cs @@ -15,9 +15,8 @@ public static async Task GetServerConfig( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -55,9 +54,8 @@ public static async Task GetDatabaseConfig( [Description("Server name or display name.")] string? server_name = null, [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -115,9 +113,8 @@ public static async Task GetDatabaseScopedConfig( [Description("Server name or display name.")] string? server_name = null, [Description("Filter to a specific database. Omit for all databases.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -163,9 +160,8 @@ public static async Task GetTraceFlags( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpCpuTools.cs b/Lite/Mcp/McpCpuTools.cs index f6462ec90..f5366b4a9 100644 --- a/Lite/Mcp/McpCpuTools.cs +++ b/Lite/Mcp/McpCpuTools.cs @@ -16,11 +16,8 @@ public static async Task GetCpuUtilization( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 4.")] int hours_back = 4) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpHealthTools.cs b/Lite/Mcp/McpHealthTools.cs index 0283a2392..65e882e0a 100644 --- a/Lite/Mcp/McpHealthTools.cs +++ b/Lite/Mcp/McpHealthTools.cs @@ -15,11 +15,8 @@ public static async Task GetServerSummary( ServerManager serverManager, [Description("Server name or display name. Optional if only one server is configured.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -53,11 +50,8 @@ public static async Task GetCollectionHealth( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpIoTools.cs b/Lite/Mcp/McpIoTools.cs index 95606f2cd..94fca1b32 100644 --- a/Lite/Mcp/McpIoTools.cs +++ b/Lite/Mcp/McpIoTools.cs @@ -15,11 +15,8 @@ public static async Task GetFileIoStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task GetFileIoTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpJobTools.cs b/Lite/Mcp/McpJobTools.cs index 208f59cac..e96f98eb1 100644 --- a/Lite/Mcp/McpJobTools.cs +++ b/Lite/Mcp/McpJobTools.cs @@ -15,11 +15,8 @@ public static async Task GetRunningJobs( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index b2032a13b..efef82101 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -15,11 +15,8 @@ public static async Task GetMemoryStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -57,11 +54,8 @@ public static async Task GetMemoryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -98,11 +92,8 @@ public static async Task GetMemoryClerks( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -142,11 +133,8 @@ public static async Task GetMemoryPressureEvents( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -185,11 +173,8 @@ public static async Task GetMemoryGrants( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 1.")] int hours_back = 1) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpObjectStatsTools.cs b/Lite/Mcp/McpObjectStatsTools.cs index 66f33e26b..4a64293f5 100644 --- a/Lite/Mcp/McpObjectStatsTools.cs +++ b/Lite/Mcp/McpObjectStatsTools.cs @@ -15,11 +15,8 @@ public static async Task GetTableIndexSizes( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -62,11 +59,8 @@ public static async Task GetIndexUsage( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -112,11 +106,8 @@ public static async Task GetObjectLocking( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs index cbda33694..61e0319da 100644 --- a/Lite/Mcp/McpPerfmonTools.cs +++ b/Lite/Mcp/McpPerfmonTools.cs @@ -17,11 +17,8 @@ public static async Task GetPerfmonStats( [Description("Filter to a specific counter name, e.g. 'Batch Requests/sec'.")] string? counter_name = null, [Description("Filter to a specific instance name, e.g. a database name.")] string? instance_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -65,11 +62,8 @@ public static async Task GetPerfmonTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs index 992f6abe4..5505bbb8a 100644 --- a/Lite/Mcp/McpPlanTools.cs +++ b/Lite/Mcp/McpPlanTools.cs @@ -22,9 +22,8 @@ public static async Task AnalyzeQueryPlan( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -52,9 +51,8 @@ public static async Task AnalyzeProcedurePlan( [Description("The plan_handle value from get_top_procedures_by_cpu.")] string plan_handle, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -82,9 +80,8 @@ public static async Task AnalyzeQueryStorePlan( [Description("The plan_id from get_query_store_top.")] long plan_id, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -144,9 +141,8 @@ public static async Task GetPlanXml( [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index 4a210277d..cbee080c6 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -20,11 +20,8 @@ public static async Task GetTopQueriesByCpu( [Description("If true, only return queries that used parallelism (max_dop > 1).")] bool parallel_only = false, [Description("Minimum DOP to filter on. Implies parallel filtering.")] int min_dop = 0) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -94,11 +91,8 @@ public static async Task GetTopProceduresByCpu( [Description("Number of top procedures. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -161,11 +155,8 @@ public static async Task GetQueryStoreTop( [Description("Number of top queries. Default 20.")] int top = 20, [Description("Filter to a specific database.")] string? database_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -219,11 +210,8 @@ public static async Task GetQueryDurationTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -260,11 +248,8 @@ public static async Task GetQueryTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpServerInfoTools.cs b/Lite/Mcp/McpServerInfoTools.cs index 7ca38b264..5bb299b99 100644 --- a/Lite/Mcp/McpServerInfoTools.cs +++ b/Lite/Mcp/McpServerInfoTools.cs @@ -15,9 +15,8 @@ public static async Task GetServerProperties( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -57,9 +56,8 @@ public static async Task GetDatabaseSizes( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpSessionTools.cs b/Lite/Mcp/McpSessionTools.cs index 8beda61cb..f565303a6 100644 --- a/Lite/Mcp/McpSessionTools.cs +++ b/Lite/Mcp/McpSessionTools.cs @@ -19,9 +19,8 @@ public static async Task GetActiveQueries( [Description("Show only queries involved in blocking (blocking_session_id > 0 or is a head blocker).")] bool blocking_only = false, [Description("Maximum number of rows to return. Default 50.")] int limit = 50) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; var validation = McpHelpers.ValidateHoursBack(hours_back); if (validation != null) return validation; @@ -88,9 +87,8 @@ public static async Task GetSessionStats( ServerManager serverManager, [Description("Server name or display name.")] string? server_name = null) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpTempDbTools.cs b/Lite/Mcp/McpTempDbTools.cs index d75fa50f0..da3d1309b 100644 --- a/Lite/Mcp/McpTempDbTools.cs +++ b/Lite/Mcp/McpTempDbTools.cs @@ -16,11 +16,8 @@ public static async Task GetTempDbTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/McpWaitTools.cs b/Lite/Mcp/McpWaitTools.cs index 9bf0d6e8a..c30d7074b 100644 --- a/Lite/Mcp/McpWaitTools.cs +++ b/Lite/Mcp/McpWaitTools.cs @@ -17,11 +17,8 @@ public static async Task GetWaitStats( [Description("Hours of history to analyze. Default 24.")] int hours_back = 24, [Description("Maximum rows to return. Default 20.")] int limit = 20) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -67,11 +64,8 @@ public static async Task GetWaitTypes( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -100,11 +94,8 @@ public static async Task GetWaitTrend( [Description("Server name or display name.")] string? server_name = null, [Description("Hours of history. Default 24.")] int hours_back = 24) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { @@ -158,11 +149,8 @@ public static async Task GetWaitingTasks( [Description("Hours of history. Default 1.")] int hours_back = 1, [Description("Maximum rows. Default 30.")] int limit = 30) { - var resolved = ServerResolver.Resolve(serverManager, server_name); - if (resolved == null) - { - return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; - } + var (resolved, error) = ServerResolver.ResolveOrError(serverManager, server_name); + if (error != null) return error; try { diff --git a/Lite/Mcp/ServerResolver.cs b/Lite/Mcp/ServerResolver.cs index 8cdf9ec8c..06bb20d6c 100644 --- a/Lite/Mcp/ServerResolver.cs +++ b/Lite/Mcp/ServerResolver.cs @@ -56,6 +56,21 @@ public static (int ServerId, string ServerName)? Resolve( return null; } + /// + /// Resolves a server name, returning either the resolved (server_id, name) or a ready-to-return + /// error string listing the available servers. Lets MCP tools collapse the repeated resolve-and-bail + /// block to: var (resolved, error) = ResolveOrError(...); if (error != null) return error; + /// + public static ((int ServerId, string ServerName)? resolved, string? error) ResolveOrError( + ServerManager serverManager, + string? serverName) + { + var resolved = Resolve(serverManager, serverName); + return resolved is null + ? (null, $"Could not resolve server. Available servers:\n{ListAvailableServers(serverManager)}") + : (resolved, null); + } + public static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetEnabledServers(); From cb66a12ba9f57b636236019c91fdbebc3d94334e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:09:22 -0400 Subject: [PATCH 007/547] Add shared DataGridExport keystone + migrate 2 proof sites Phase 1 of the copy/export lift (a). Adds PerformanceMonitor.Ui/DataGridExport with the four context-menu operations (Copy Cell / Row / All, Export to CSV) plus their generic leaves, shared by both apps. Canonical behavior fixes the latent Dashboard bug where CopyAllRows/ExportToCsv filtered headers to bound columns but emitted all-column rows (misaligned on any grid with a template column): headers and rows now iterate all columns in lock-step. Each op is split into a pure builder (BuildCsv / BuildClipboardText / GetRowValues / ...) and a thin clipboard/SaveFileDialog/file wrapper, so the logic is unit-testable headlessly. - 12 DataGridExport tests (Dashboard.Tests), including an alignment regression test built on a real DataGrid with a template column (STA test harness). - Migrated 2 proof sites to delegate: Dashboard QueryPerformanceContent and Lite ServerTab (its inline leaves removed; FindParentDataGrid kept for CopyReproScript). Both apps green: Dashboard.Tests 703, Lite.Tests 618. The remaining ~27 sites are mechanical delegations and follow next. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/DataGridExportTests.cs | 157 +++++++++++++ .../QueryPerformanceContent.CopyExport.cs | 107 +-------- Lite/Controls/ServerTab.CopyExport.cs | 152 +------------ PerformanceMonitor.Ui/DataGridExport.cs | 214 ++++++++++++++++++ 4 files changed, 381 insertions(+), 249 deletions(-) create mode 100644 Dashboard.Tests/DataGridExportTests.cs create mode 100644 PerformanceMonitor.Ui/DataGridExport.cs diff --git a/Dashboard.Tests/DataGridExportTests.cs b/Dashboard.Tests/DataGridExportTests.cs new file mode 100644 index 000000000..6cc38d7cc --- /dev/null +++ b/Dashboard.Tests/DataGridExportTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Windows.Controls; +using System.Windows.Data; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Dashboard.Tests; + +/// +/// Tests for the shared DataGridExport (PerformanceMonitor.Ui) used by every copy/export +/// context menu in both apps. Because all ~29 sites delegate here, these cover their behavior. +/// The pure formatter/escaper tests run anywhere; the grid tests run on an STA thread because +/// WPF objects require it. +/// +public class DataGridExportTests +{ + private sealed class Row + { + public string Name { get; set; } = ""; + public int Value { get; set; } + public string Note { get; set; } = ""; + } + + private static T RunSta(Func body) + { + T result = default!; + Exception? error = null; + var thread = new Thread(() => + { + try { result = body(); } + catch (Exception ex) { error = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + if (error != null) throw error; + return result; + } + + private static DataGrid BuildGrid() + { + var grid = new DataGrid(); + grid.Columns.Add(new DataGridTextColumn { Header = "Name", Binding = new Binding(nameof(Row.Name)) }); + grid.Columns.Add(new DataGridTextColumn { Header = "Value", Binding = new Binding(nameof(Row.Value)) }); + + // A template column with a TextBlock bound to Note -- the case the old Dashboard + // handlers mishandled (header filtered out, value kept). + var template = new System.Windows.DataTemplate(); + var tb = new System.Windows.FrameworkElementFactory(typeof(TextBlock)); + tb.SetBinding(TextBlock.TextProperty, new Binding(nameof(Row.Note))); + template.VisualTree = tb; + template.Seal(); + grid.Columns.Add(new DataGridTemplateColumn { Header = "Note", CellTemplate = template }); + + grid.ItemsSource = new[] + { + new Row { Name = "a,b", Value = 42, Note = "hi" }, + new Row { Name = "plain", Value = 7, Note = "there" } + }; + return grid; + } + + [Theory] + [InlineData(null, "")] + [InlineData("text", "text")] + public void FormatForExport_HandlesStringsAndNull(string? input, string expected) + { + Assert.Equal(expected, DataGridExport.FormatForExport(input)); + } + + [Fact] + public void FormatForExport_UsesInvariantCultureForNumbers() + { + var prior = CultureInfo.CurrentCulture; + try + { + // A culture whose decimal separator is a comma -- proves we don't emit "1,5". + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal("1.5", DataGridExport.FormatForExport(1.5)); + Assert.Equal("42", DataGridExport.FormatForExport(42)); + } + finally + { + CultureInfo.CurrentCulture = prior; + } + } + + [Theory] + [InlineData("plain", "plain")] + [InlineData("", "")] + [InlineData("a,b", "\"a,b\"")] + [InlineData("a\"b", "\"a\"\"b\"")] + [InlineData("a\nb", "\"a\nb\"")] + public void CsvEscape_QuotesWhenNeeded(string input, string expected) + { + Assert.Equal(expected, DataGridExport.CsvEscape(input, ",")); + } + + [Fact] + public void CsvEscape_QuotesWhenValueContainsSeparator() + { + // With a semicolon separator a comma is NOT special, but a semicolon is. + Assert.Equal("a,b", DataGridExport.CsvEscape("a,b", ";")); + Assert.Equal("\"a;b\"", DataGridExport.CsvEscape("a;b", ";")); + } + + [Fact] + public void GetRowValues_ReturnsOneValuePerColumn_IncludingTemplate() + { + var values = RunSta(() => + { + var grid = BuildGrid(); + var item = grid.Items.Cast().First(); + return DataGridExport.GetRowValues(grid, item); + }); + + // Bound, bound, template -> three values, in column order. + Assert.Equal(new[] { "a,b", "42", "hi" }, values); + } + + [Fact] + public void BuildClipboardText_KeepsHeadersAndRowsAligned() + { + var (headerFields, rowFieldCounts, columnCount) = RunSta(() => + { + var grid = BuildGrid(); + var text = DataGridExport.BuildClipboardText(grid); + var lines = text.Replace("\r\n", "\n").TrimEnd('\n').Split('\n'); + var headers = lines[0].Split('\t'); + var counts = lines.Skip(1).Select(l => l.Split('\t').Length).ToArray(); + return (headers, counts, grid.Columns.Count); + }); + + Assert.Equal(new[] { "Name", "Value", "Note" }, headerFields); + Assert.Equal(3, columnCount); + // The regression guard: every row has exactly as many fields as there are columns/headers. + Assert.All(rowFieldCounts, count => Assert.Equal(columnCount, count)); + } + + [Fact] + public void BuildCsv_EscapesAndIncludesAllColumns() + { + var csv = RunSta(() => + { + var grid = BuildGrid(); + return DataGridExport.BuildCsv(grid, ",").Replace("\r\n", "\n"); + }); + + var lines = csv.TrimEnd('\n').Split('\n'); + Assert.Equal("Name,Value,Note", lines[0]); + Assert.Equal("\"a,b\",42,hi", lines[1]); // comma value quoted, number invariant, template value present + Assert.Equal("plain,7,there", lines[2]); + } +} diff --git a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs index f640d488c..5a5de3f04 100644 --- a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs +++ b/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs @@ -24,63 +24,11 @@ namespace PerformanceMonitorDashboard.Controls { public partial class QueryPerformanceContent : UserControl { - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private void CopyReproScript_Click(object sender, RoutedEventArgs e) { @@ -145,52 +93,7 @@ private void CopyReproScript_Click(object sender, RoutedEventArgs e) } } - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_performance_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_performance", TabHelpers.CsvSeparator); } } diff --git a/Lite/Controls/ServerTab.CopyExport.cs b/Lite/Controls/ServerTab.CopyExport.cs index 7bf931945..fc018e075 100644 --- a/Lite/Controls/ServerTab.CopyExport.cs +++ b/Lite/Controls/ServerTab.CopyExport.cs @@ -38,100 +38,11 @@ public partial class ServerTab : UserControl return target as DataGrid; } - /// - /// Gets a cell value from a row item for any column type (bound or template). - /// Template columns are inspected for a TextBlock binding in their CellTemplate. - /// - private static string GetCellValue(DataGridColumn col, object item) - { - /* DataGridBoundColumn — binding is directly accessible */ - if (col is DataGridBoundColumn boundCol - && boundCol.Binding is System.Windows.Data.Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - /* DataGridTemplateColumn — instantiate the template and find a TextBlock binding */ - if (col is DataGridTemplateColumn templateCol && templateCol.CellTemplate != null) - { - var content = templateCol.CellTemplate.LoadContent(); - if (content is TextBlock textBlock) - { - var textBinding = System.Windows.Data.BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); - if (textBinding != null) - { - var prop = item.GetType().GetProperty(textBinding.Path.Path); - return FormatForExport(prop?.GetValue(item)); - } - } - } - - return ""; - } - - private static string FormatForExport(object? value) - { - if (value == null) return ""; - if (value is IFormattable formattable) - return formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture); - return value.ToString() ?? ""; - } - - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - var sb = new StringBuilder(); - - /* Header */ - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - /* Rows */ - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private async void CopyReproScript_Click(object sender, RoutedEventArgs e) { @@ -212,59 +123,6 @@ private async void CopyReproScript_Click(object sender, RoutedEventArgs e) Clipboard.SetDataObject(script, false); } - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; - - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"{_server.DisplayName}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - var sep = App.CsvSeparator; - - /* Header */ - var headers = new List(); - foreach (var col in grid.Columns) - { - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col), sep)); - } - sb.AppendLine(string.Join(sep, headers)); - - /* Rows */ - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var col in grid.Columns) - { - values.Add(CsvEscape(GetCellValue(col, item), sep)); - } - sb.AppendLine(string.Join(sep, values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - private static string CsvEscape(string value, string separator) - { - if (value.Contains(separator, StringComparison.Ordinal) || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - { - return "\"" + value.Replace("\"", "\"\"") + "\""; - } - return value; - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, _server.DisplayName, App.CsvSeparator); } diff --git a/PerformanceMonitor.Ui/DataGridExport.cs b/PerformanceMonitor.Ui/DataGridExport.cs new file mode 100644 index 000000000..eda18afdd --- /dev/null +++ b/PerformanceMonitor.Ui/DataGridExport.cs @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Media; +using Microsoft.Win32; + +namespace PerformanceMonitor.Ui; + +/// +/// Shared copy/export operations for DataGrid context menus, used by both Dashboard and Lite. +/// The four context-menu handlers (Copy Cell / Copy Row / Copy All Rows / Export to CSV) are +/// identical across ~29 sites apart from the CSV file-name prefix and the user's separator +/// preference, so they live here once. +/// +/// Headers and rows both iterate in lock-step (bound AND template +/// columns), so the two never misalign -- a bug the older Dashboard handlers had, where headers +/// were filtered to bound columns but rows emitted every column. +/// +/// Each operation is split into a pure builder (returns the string -- unit-testable headlessly) +/// and a thin wrapper that performs the clipboard / SaveFileDialog / file side effect. +/// +public static class DataGridExport +{ + /* ---- side-effecting wrappers (wired to the context-menu MenuItem handlers) ---- */ + + /// Copies the current cell's value to the clipboard. + public static void CopyCell(object sender) + { + var grid = FindDataGrid(sender); + if (grid == null) return; + + var cell = grid.CurrentCell; + if (cell.Column == null || cell.Item == null) return; + + var value = GetCellValue(cell.Column, cell.Item); + if (value.Length > 0) Clipboard.SetDataObject(value, false); + } + + /// Copies the current row (all columns, tab-delimited) to the clipboard. + public static void CopyRow(object sender) + { + var grid = FindDataGrid(sender); + if (grid?.CurrentItem == null) return; + + Clipboard.SetDataObject(string.Join("\t", GetRowValues(grid, grid.CurrentItem)), false); + } + + /// Copies the whole grid (header + all rows, tab-delimited) to the clipboard. + public static void CopyAllRows(object sender) + { + var grid = FindDataGrid(sender); + if (grid == null || grid.Items.Count == 0) return; + + Clipboard.SetDataObject(BuildClipboardText(grid), false); + } + + /// + /// Prompts for a file and exports the whole grid to CSV. + /// seeds the suggested file name; is the user's CSV separator + /// preference (each app passes its own). + /// + public static void ExportToCsv(object sender, string fileNamePrefix, string separator) + { + var grid = FindDataGrid(sender); + if (grid == null || grid.Items.Count == 0) return; + + var dialog = new SaveFileDialog + { + FileName = $"{fileNamePrefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*" + }; + + if (dialog.ShowDialog() != true) return; + + try + { + File.WriteAllText(dialog.FileName, BuildCsv(grid, separator), Encoding.UTF8); + MessageBox.Show($"Data exported successfully to:\n{dialog.FileName}", "Export Complete", + MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + /* ---- pure builders (unit-testable) ---- */ + + /// Builds the tab-delimited header+rows text used by Copy All Rows. + public static string BuildClipboardText(DataGrid grid) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join("\t", grid.Columns.Select(DataGridClipboardBehavior.GetHeaderText))); + foreach (var item in grid.Items) + { + sb.AppendLine(string.Join("\t", GetRowValues(grid, item))); + } + return sb.ToString(); + } + + /// Builds the CSV text (header + rows, escaped) for the given separator. + public static string BuildCsv(DataGrid grid, string separator) + { + var sb = new StringBuilder(); + sb.AppendLine(string.Join(separator, + grid.Columns.Select(c => CsvEscape(DataGridClipboardBehavior.GetHeaderText(c), separator)))); + foreach (var item in grid.Items) + { + sb.AppendLine(string.Join(separator, + GetRowValues(grid, item).Select(v => CsvEscape(v, separator)))); + } + return sb.ToString(); + } + + /// Gets one string per column for a row, in column order (empty for columns with no extractable value). + public static List GetRowValues(DataGrid grid, object item) + { + var values = new List(grid.Columns.Count); + foreach (var column in grid.Columns) + { + values.Add(GetCellValue(column, item)); + } + return values; + } + + /// + /// Extracts a cell value for any column type. Bound columns read their binding path directly; + /// template columns are instantiated and inspected for a TextBlock binding. Unhandled column + /// types yield an empty string so the value count always matches the column count. + /// + public static string GetCellValue(DataGridColumn column, object item) + { + if (column is DataGridBoundColumn boundColumn + && boundColumn.Binding is Binding binding + && binding.Path != null) + { + var property = item.GetType().GetProperty(binding.Path.Path); + return FormatForExport(property?.GetValue(item)); + } + + if (column is DataGridTemplateColumn templateColumn && templateColumn.CellTemplate != null) + { + var content = templateColumn.CellTemplate.LoadContent(); + if (content is TextBlock textBlock) + { + var textBinding = BindingOperations.GetBinding(textBlock, TextBlock.TextProperty); + if (textBinding != null && textBinding.Path != null) + { + var property = item.GetType().GetProperty(textBinding.Path.Path); + return FormatForExport(property?.GetValue(item)); + } + } + } + + return string.Empty; + } + + /// Formats a value for export: invariant culture for IFormattable, else ToString. + public static string FormatForExport(object? value) + { + if (value == null) return string.Empty; + if (value is IFormattable formattable) return formattable.ToString(null, CultureInfo.InvariantCulture); + return value.ToString() ?? string.Empty; + } + + /// Escapes a value for CSV: quote when it contains the separator, a quote, or a newline. + public static string CsvEscape(string value, string separator) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + if (value.Contains(separator, StringComparison.Ordinal) + || value.Contains('"') + || value.Contains('\n') + || value.Contains('\r')) + { + return "\"" + value.Replace("\"", "\"\"") + "\""; + } + return value; + } + + /* ---- internals ---- */ + + /// Resolves the DataGrid that owns the context menu the handler fired from. + private static DataGrid? FindDataGrid(object sender) + { + if (sender is not MenuItem menuItem || menuItem.Parent is not ContextMenu contextMenu) + return null; + + if (contextMenu.PlacementTarget is DataGrid grid) + return grid; + + DependencyObject? target = contextMenu.PlacementTarget; + while (target != null && target is not DataGrid) + { + target = VisualTreeHelper.GetParent(target); + } + return target as DataGrid; + } +} From 950639cb06aa63c1f467d2ba1d4e8fdf8aa80f8a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:55:01 -0400 Subject: [PATCH 008/547] Migrate remaining 27 copy/export sites to DataGridExport Phase 2 of the copy/export lift (a). Every DataGrid copy/export context menu in both apps now delegates its four handlers to the shared PerformanceMonitor.Ui DataGridExport -- 116 delegations across 29 files (the 2 Phase-1 proofs + these 27), fanned out one agent per site. Inline leaf helpers that became unused were removed; ones still used by other handlers (CopyReproScript, cross-partial FindDataGridFromContextMenu) were kept. The older dialog windows that previously hardcoded the separator, skipped the success dialog, or filtered headers to bound columns now inherit the canonical behavior: culture-aware separator, success toast, and all-columns-aligned, template-aware output. Both apps green: Dashboard.Tests 703, Lite.Tests 618. Builds clean, 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/CollectionLogWindow.xaml.cs | 75 +------- Dashboard/CollectorScheduleWindow.xaml.cs | 75 +------- .../Controls/AlertsHistoryContent.xaml.cs | 93 +--------- .../Controls/ConfigChangesContent.xaml.cs | 75 +------- .../Controls/CriticalIssuesContent.xaml.cs | 114 +------------ .../Controls/CurrentConfigContent.xaml.cs | 75 +------- .../Controls/DailySummaryContent.xaml.cs | 114 +------------ .../Controls/FinOpsContent.CopyExport.cs | 137 +-------------- .../Controls/MemoryContent.CopyExport.cs | 117 +------------ .../ResourceMetricsContent.CopyExport.cs | 119 +------------ .../SystemEventsContent.CopyExport.cs | 123 +------------- Dashboard/ManageServersWindow.xaml.cs | 75 +------- Dashboard/ProcedureHistoryWindow.xaml.cs | 90 +--------- Dashboard/QueryExecutionHistoryWindow.xaml.cs | 90 +--------- Dashboard/QueryStatsHistoryWindow.xaml.cs | 90 +--------- Dashboard/ServerTab.CopyExport.cs | 160 +----------------- Dashboard/TracePatternHistoryWindow.xaml.cs | 90 +--------- Dashboard/WaitDrillDownWindow.xaml.cs | 89 +--------- Lite/Controls/AlertsHistoryTab.xaml.cs | 112 +----------- Lite/Controls/FinOpsTab.xaml.cs | 114 +------------ Lite/Windows/CollectionLogWindow.xaml.cs | 9 +- Lite/Windows/ManageServersWindow.xaml.cs | 9 +- Lite/Windows/ProcedureHistoryWindow.xaml.cs | 8 +- Lite/Windows/QueryStatsHistoryWindow.xaml.cs | 8 +- Lite/Windows/QueryStoreHistoryWindow.xaml.cs | 8 +- Lite/Windows/SettingsWindow.xaml.cs | 8 +- Lite/Windows/WaitDrillDownWindow.xaml.cs | 8 +- 27 files changed, 133 insertions(+), 1952 deletions(-) diff --git a/Dashboard/CollectionLogWindow.xaml.cs b/Dashboard/CollectionLogWindow.xaml.cs index 659036cdf..f6be3fc79 100644 --- a/Dashboard/CollectionLogWindow.xaml.cs +++ b/Dashboard/CollectionLogWindow.xaml.cs @@ -191,79 +191,14 @@ private void UpdateLogFilterButtonStyles() } } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"collection_log_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "collection_log", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/Dashboard/CollectorScheduleWindow.xaml.cs index 0dda90b50..ea44a5771 100644 --- a/Dashboard/CollectorScheduleWindow.xaml.cs +++ b/Dashboard/CollectorScheduleWindow.xaml.cs @@ -325,79 +325,14 @@ private async void Refresh_Click(object sender, RoutedEventArgs e) await LoadSchedulesAsync(); } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is System.Windows.Controls.MenuItem menuItem && menuItem.Parent is System.Windows.Controls.ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"collector_schedules_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "collector_schedules", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs index aec46c8d0..0fd923aeb 100644 --- a/Dashboard/Controls/AlertsHistoryContent.xaml.cs +++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs @@ -400,97 +400,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = dataGrid.Columns - .OfType() - .Select(c => DataGridClipboardBehavior.GetHeaderText(c)) - .ToList(); - sb.AppendLine(string.Join("\t", headers)); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"alert_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var sep = TabHelpers.CsvSeparator; - var headers = dataGrid.Columns - .OfType() - .Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)) - .ToList(); - sb.AppendLine(string.Join(sep, headers)); - - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(sep, values.Select(v => TabHelpers.EscapeCsvField(v, sep)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", - "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", - "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "alert_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/Controls/ConfigChangesContent.xaml.cs b/Dashboard/Controls/ConfigChangesContent.xaml.cs index dc4b82c7a..a9f7acc4a 100644 --- a/Dashboard/Controls/ConfigChangesContent.xaml.cs +++ b/Dashboard/Controls/ConfigChangesContent.xaml.cs @@ -385,79 +385,14 @@ private void UpdateFilterButtonStyles(DataGrid grid, Dictionary DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"config_changes_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "config_changes", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/CriticalIssuesContent.xaml.cs b/Dashboard/Controls/CriticalIssuesContent.xaml.cs index 32eb57e13..90f387c78 100644 --- a/Dashboard/Controls/CriticalIssuesContent.xaml.cs +++ b/Dashboard/Controls/CriticalIssuesContent.xaml.cs @@ -280,118 +280,14 @@ private void UpdateFilterButtonStyle(Button? button, string columnName, Style? a #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"critical_issues_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "critical_issues", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/CurrentConfigContent.xaml.cs b/Dashboard/Controls/CurrentConfigContent.xaml.cs index 1ab56b60a..f5102a92b 100644 --- a/Dashboard/Controls/CurrentConfigContent.xaml.cs +++ b/Dashboard/Controls/CurrentConfigContent.xaml.cs @@ -283,79 +283,14 @@ private void UpdateFilterButtonStyles(DataGrid grid, Dictionary DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"config_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "config", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/DailySummaryContent.xaml.cs b/Dashboard/Controls/DailySummaryContent.xaml.cs index e6dc2d4a8..4a4906c18 100644 --- a/Dashboard/Controls/DailySummaryContent.xaml.cs +++ b/Dashboard/Controls/DailySummaryContent.xaml.cs @@ -333,118 +333,14 @@ private void UpdateFilterButtonStyle(Button? button, string columnName, Style? a #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"daily_summary_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "daily_summary", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/FinOpsContent.CopyExport.cs b/Dashboard/Controls/FinOpsContent.CopyExport.cs index 14b05a871..065f2b7c8 100644 --- a/Dashboard/Controls/FinOpsContent.CopyExport.cs +++ b/Dashboard/Controls/FinOpsContent.CopyExport.cs @@ -33,141 +33,14 @@ public partial class FinOpsContent : UserControl // Copy / Export Context Menu Handlers // ============================================ - private static DataGrid? FindParentDataGrid(DependencyObject? element) - { - while (element != null) - { - if (element is DataGrid dg) return dg; - element = VisualTreeHelper.GetParent(element); - } - return null; - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null) - { - var sb = new StringBuilder(); - - // Header row - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var grid = FindParentDataGrid(contextMenu.PlacementTarget); - if (grid != null) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"FinOps_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Header row - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "FinOps_Export", TabHelpers.CsvSeparator); } } diff --git a/Dashboard/Controls/MemoryContent.CopyExport.cs b/Dashboard/Controls/MemoryContent.CopyExport.cs index 7f940a7d8..c9f7e61c0 100644 --- a/Dashboard/Controls/MemoryContent.CopyExport.cs +++ b/Dashboard/Controls/MemoryContent.CopyExport.cs @@ -29,121 +29,14 @@ public partial class MemoryContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join("\t", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - string prefix = "memory"; - - - var saveFileDialog = new SaveFileDialog - { - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column), TabHelpers.CsvSeparator)); - } - } - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(TabHelpers.CsvSeparator, values.Select(v => TabHelpers.EscapeCsvField(v, TabHelpers.CsvSeparator)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "memory", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs index 55b190dc7..f5718c76c 100644 --- a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs +++ b/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs @@ -32,123 +32,14 @@ public partial class ResourceMetricsContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"ResourceMetrics_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "ResourceMetrics_Export", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/Controls/SystemEventsContent.CopyExport.cs b/Dashboard/Controls/SystemEventsContent.CopyExport.cs index 093897ca5..7b61a0183 100644 --- a/Dashboard/Controls/SystemEventsContent.CopyExport.cs +++ b/Dashboard/Controls/SystemEventsContent.CopyExport.cs @@ -30,127 +30,14 @@ public partial class SystemEventsContent : UserControl { #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.CurrentCell.Column != null) - { - var cellContent = TabHelpers.GetCellContent(grid, grid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid && grid.SelectedItem != null) - { - var rowText = TabHelpers.GetRowAsText(grid, grid.SelectedItem); - if (!string.IsNullOrEmpty(rowText)) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var sb = new StringBuilder(); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Header row - var headers = grid.Columns.Select(c => DataGridClipboardBehavior.GetHeaderText(c)); - sb.AppendLine(string.Join("\t", headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - var value = prop?.GetValue(item)?.ToString() ?? string.Empty; - values.Add(value); - } - } - sb.AppendLine(string.Join("\t", values)); - } - - if (sb.Length > 0) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - if (contextMenu.PlacementTarget is DataGrid grid) - { - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"SystemEvents_Export_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - - // Header row - var sep = TabHelpers.CsvSeparator; - var headers = grid.Columns.Select(c => TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(c), sep)); - sb.AppendLine(string.Join(sep, headers)); - - // Data rows - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var column in grid.Columns) - { - var binding = (column as DataGridBoundColumn)?.Binding as System.Windows.Data.Binding; - if (binding != null) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - values.Add(TabHelpers.EscapeCsvField(TabHelpers.FormatForExport(prop?.GetValue(item)), sep)); - } - } - sb.AppendLine(string.Join(sep, values)); - } - - File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - Logger.Error($"Error exporting to CSV: {ex.Message}", ex); - MessageBox.Show($"Error exporting to CSV: {ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "SystemEvents_Export", TabHelpers.CsvSeparator); #endregion } diff --git a/Dashboard/ManageServersWindow.xaml.cs b/Dashboard/ManageServersWindow.xaml.cs index a58f4fe66..449e09281 100644 --- a/Dashboard/ManageServersWindow.xaml.cs +++ b/Dashboard/ManageServersWindow.xaml.cs @@ -419,79 +419,14 @@ private async void PurgeNow_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = Helpers.TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(Helpers.TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new System.Text.StringBuilder(); - var headers = new System.Collections.Generic.List(); - foreach (var column in dataGrid.Columns) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(Helpers.TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = Helpers.TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new Microsoft.Win32.SaveFileDialog - { - FileName = $"servers_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; - if (dialog.ShowDialog() == true) - { - var sb = new System.Text.StringBuilder(); - var headers = new System.Collections.Generic.List(); - foreach (var column in dataGrid.Columns) - headers.Add(Helpers.TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = Helpers.TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => Helpers.TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "servers", Helpers.TabHelpers.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Dashboard/ProcedureHistoryWindow.xaml.cs b/Dashboard/ProcedureHistoryWindow.xaml.cs index efed59eff..93b05f689 100644 --- a/Dashboard/ProcedureHistoryWindow.xaml.cs +++ b/Dashboard/ProcedureHistoryWindow.xaml.cs @@ -391,94 +391,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"procedure_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "procedure_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml.cs b/Dashboard/QueryExecutionHistoryWindow.xaml.cs index d1583f2da..02ecae711 100644 --- a/Dashboard/QueryExecutionHistoryWindow.xaml.cs +++ b/Dashboard/QueryExecutionHistoryWindow.xaml.cs @@ -407,94 +407,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_execution_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_execution_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/QueryStatsHistoryWindow.xaml.cs b/Dashboard/QueryStatsHistoryWindow.xaml.cs index 56606911e..c3eee9e82 100644 --- a/Dashboard/QueryStatsHistoryWindow.xaml.cs +++ b/Dashboard/QueryStatsHistoryWindow.xaml.cs @@ -386,94 +386,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"query_stats_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "query_stats_history", TabHelpers.CsvSeparator); #endregion diff --git a/Dashboard/ServerTab.CopyExport.cs b/Dashboard/ServerTab.CopyExport.cs index 05b55a59c..9583802bd 100644 --- a/Dashboard/ServerTab.CopyExport.cs +++ b/Dashboard/ServerTab.CopyExport.cs @@ -26,118 +26,14 @@ public partial class ServerTab : UserControl // Context Menu Event Handlers // ==================================================================== - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = GetCellContent(dataGrid, dataGrid.CurrentCell); - if (cellContent != null) - { - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(cellContent, false); - } - } - } - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.SelectedItem != null) - { - var rowText = GetRowAsText(dataGrid, dataGrid.SelectedItem); - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(rowText, false); - } - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn boundColumn) - { - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - } - sb.AppendLine(string.Join(" ", headers)); - - // Add all rows - foreach (var item in dataGrid.Items) - { - sb.AppendLine(GetRowAsText(dataGrid, item)); - } - - /* Use SetDataObject with copy=false to avoid WPF's problematic Clipboard.Flush() */ - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } - - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"export_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - // Add headers - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - { - headers.Add(EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - } - sb.AppendLine(string.Join(",", headers)); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - // Add all rows - foreach (var item in dataGrid.Items) - { - var values = GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => EscapeCsvField(v)))); - } - - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "export", TabHelpers.CsvSeparator); private DataGrid? FindDataGridFromContextMenu(ContextMenu contextMenu) { @@ -152,51 +48,5 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) { return TabHelpers.FindParent(child); } - - private string GetCellContent(DataGrid dataGrid, DataGridCellInfo cellInfo) - { - var column = cellInfo.Column as DataGridBoundColumn; - if (column?.Binding is Binding binding && binding.Path != null) - { - var propertyName = binding.Path.Path; - var property = cellInfo.Item.GetType().GetProperty(propertyName); - if (property != null) - { - var value = property.GetValue(cellInfo.Item); - return value?.ToString() ?? string.Empty; - } - } - return string.Empty; - } - - private string GetRowAsText(DataGrid dataGrid, object item) - { - var values = GetRowValues(dataGrid, item); - return string.Join(" ", values); - } - - private List GetRowValues(DataGrid dataGrid, object item) - { - var values = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn boundColumn && boundColumn.Binding is Binding binding) - { - var propertyName = binding.Path.Path; - var property = item.GetType().GetProperty(propertyName); - if (property != null) - { - var value = property.GetValue(item); - values.Add(value?.ToString() ?? string.Empty); - } - } - } - return values; - } - - private string EscapeCsvField(string field) - { - return TabHelpers.EscapeCsvField(field); - } } } diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/Dashboard/TracePatternHistoryWindow.xaml.cs index 20d6cf22c..8201b7991 100644 --- a/Dashboard/TracePatternHistoryWindow.xaml.cs +++ b/Dashboard/TracePatternHistoryWindow.xaml.cs @@ -316,94 +316,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var saveFileDialog = new SaveFileDialog - { - FileName = $"trace_pattern_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (saveFileDialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - File.WriteAllText(saveFileDialog.FileName, sb.ToString()); - MessageBox.Show($"Data exported successfully to:\n{saveFileDialog.FileName}", "Export Complete", MessageBoxButton.OK, MessageBoxImage.Information); - } - catch (Exception ex) - { - MessageBox.Show($"Error exporting data:\n\n{ex.Message}", "Export Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "trace_pattern_history", TabHelpers.CsvSeparator); [System.Text.RegularExpressions.GeneratedRegex(@"\s+")] private static partial System.Text.RegularExpressions.Regex MultipleSpacesRegExp(); diff --git a/Dashboard/WaitDrillDownWindow.xaml.cs b/Dashboard/WaitDrillDownWindow.xaml.cs index ea380c81d..3b281a544 100644 --- a/Dashboard/WaitDrillDownWindow.xaml.cs +++ b/Dashboard/WaitDrillDownWindow.xaml.cs @@ -437,93 +437,14 @@ private void UpdateFilterButtonStyles() #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.CurrentCell.Item != null) - { - var cellContent = TabHelpers.GetCellContent(dataGrid, dataGrid.CurrentCell); - if (!string.IsNullOrEmpty(cellContent)) - Clipboard.SetDataObject(cellContent, false); - } - } - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid?.SelectedItem != null) - Clipboard.SetDataObject(TabHelpers.GetRowAsText(dataGrid, dataGrid.SelectedItem), false); - } - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(DataGridClipboardBehavior.GetHeaderText(column)); - } - sb.AppendLine(string.Join("\t", headers)); - foreach (var item in dataGrid.Items) - sb.AppendLine(TabHelpers.GetRowAsText(dataGrid, item)); - Clipboard.SetDataObject(sb.ToString(), false); - } - } - } + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is MenuItem menuItem && menuItem.Parent is ContextMenu contextMenu) - { - var dataGrid = TabHelpers.FindDataGridFromContextMenu(contextMenu); - if (dataGrid != null && dataGrid.Items.Count > 0) - { - var dialog = new SaveFileDialog - { - FileName = $"wait_drill_down_{_waitType}_{DateTime.Now:yyyyMMdd_HHmmss}.csv", - DefaultExt = ".csv", - Filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (dialog.ShowDialog() == true) - { - try - { - var sb = new StringBuilder(); - var headers = new List(); - foreach (var column in dataGrid.Columns) - { - if (column is DataGridBoundColumn) - headers.Add(TabHelpers.EscapeCsvField(DataGridClipboardBehavior.GetHeaderText(column))); - } - sb.AppendLine(string.Join(",", headers)); - foreach (var item in dataGrid.Items) - { - var values = TabHelpers.GetRowValues(dataGrid, item); - sb.AppendLine(string.Join(",", values.Select(v => TabHelpers.EscapeCsvField(v)))); - } - System.IO.File.WriteAllText(dialog.FileName, sb.ToString()); - } - catch (Exception ex) - { - MessageBox.Show($"Export failed: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - } - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, $"wait_drill_down_{_waitType}", TabHelpers.CsvSeparator); #endregion diff --git a/Lite/Controls/AlertsHistoryTab.xaml.cs b/Lite/Controls/AlertsHistoryTab.xaml.cs index e0526695b..2187897e6 100644 --- a/Lite/Controls/AlertsHistoryTab.xaml.cs +++ b/Lite/Controls/AlertsHistoryTab.xaml.cs @@ -404,99 +404,14 @@ private async void DismissAll_Click(object sender, RoutedEventArgs e) #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } - - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; - - var sb = new StringBuilder(); - - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"alert_history_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - - var headers = new List(); - foreach (var col in grid.Columns) - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col))); - sb.AppendLine(string.Join(",", headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var col in grid.Columns) - values.Add(CsvEscape(GetCellValue(col, item))); - sb.AppendLine(string.Join(",", values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } - } + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => + DataGridExport.ExportToCsv(sender, "alert_history", App.CsvSeparator); #endregion @@ -511,23 +426,6 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) return target as DataGrid; } - private static string GetCellValue(DataGridColumn col, object item) - { - if (col is DataGridBoundColumn boundCol && boundCol.Binding is Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return prop?.GetValue(item)?.ToString() ?? ""; - } - return ""; - } - - private static string CsvEscape(string value) - { - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - return "\"" + value.Replace("\"", "\"\"") + "\""; - return value; - } - #endregion #region Mute Handlers diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 322e59306..6abf49960 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -948,66 +948,18 @@ private async void RunIndexAnalysis_Click(object sender, RoutedEventArgs e) #region Context Menu Handlers - private void CopyCell_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentCell.Column == null || grid.CurrentItem == null) return; - - var value = GetCellValue(grid.CurrentCell.Column, grid.CurrentItem); - if (value.Length > 0) Clipboard.SetDataObject(value, false); - } - - private void CopyRow_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.CurrentItem == null) return; - - var sb = new StringBuilder(); - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, grid.CurrentItem)); - sb.Append('\t'); - } - Clipboard.SetDataObject(sb.ToString().TrimEnd('\t'), false); - } + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) - { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null) return; + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); - var sb = new StringBuilder(); - - foreach (var col in grid.Columns) - { - sb.Append(DataGridClipboardBehavior.GetHeaderText(col)); - sb.Append('\t'); - } - sb.AppendLine(); - - foreach (var item in grid.Items) - { - foreach (var col in grid.Columns) - { - sb.Append(GetCellValue(col, item)); - sb.Append('\t'); - } - sb.AppendLine(); - } - - Clipboard.SetDataObject(sb.ToString(), false); - } + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); private void ExportToCsv_Click(object sender, RoutedEventArgs e) { - if (sender is not MenuItem menuItem) return; - var grid = FindParentDataGrid(menuItem); - if (grid?.Items == null || grid.Items.Count == 0) return; - - var prefix = grid.Name switch + // FinOps shares one handler across several grids, so the file-name prefix is chosen + // from the originating grid's name (resolved the same way the shared exporter does). + var grid = sender is MenuItem menuItem ? FindParentDataGrid(menuItem) : null; + var prefix = grid?.Name switch { nameof(DatabaseSizesDataGrid) => "database_sizes", nameof(ServerInventoryDataGrid) => "server_inventory", @@ -1015,40 +967,7 @@ private void ExportToCsv_Click(object sender, RoutedEventArgs e) nameof(ApplicationConnectionsDataGrid) => "application_connections", _ => "finops_export" }; - - var dialog = new SaveFileDialog - { - Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - DefaultExt = ".csv", - FileName = $"{prefix}_{DateTime.Now:yyyyMMdd_HHmmss}.csv" - }; - - if (dialog.ShowDialog() != true) return; - - var sb = new StringBuilder(); - - var headers = new List(); - foreach (var col in grid.Columns) - headers.Add(CsvEscape(DataGridClipboardBehavior.GetHeaderText(col))); - sb.AppendLine(string.Join(",", headers)); - - foreach (var item in grid.Items) - { - var values = new List(); - foreach (var col in grid.Columns) - values.Add(CsvEscape(GetCellValue(col, item))); - sb.AppendLine(string.Join(",", values)); - } - - try - { - File.WriteAllText(dialog.FileName, sb.ToString(), Encoding.UTF8); - } - catch (Exception ex) - { - MessageBox.Show($"Failed to export: {ex.Message}", "Export Error", - MessageBoxButton.OK, MessageBoxImage.Error); - } + DataGridExport.ExportToCsv(sender, prefix, App.CsvSeparator); } #endregion @@ -1168,22 +1087,5 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) return target as DataGrid; } - private static string GetCellValue(DataGridColumn col, object item) - { - if (col is DataGridBoundColumn boundCol && boundCol.Binding is Binding binding) - { - var prop = item.GetType().GetProperty(binding.Path.Path); - return prop?.GetValue(item)?.ToString() ?? ""; - } - return ""; - } - - private static string CsvEscape(string value) - { - if (value.Contains(',') || value.Contains('"') || value.Contains('\n') || value.Contains('\r')) - return "\"" + value.Replace("\"", "\"\"") + "\""; - return value; - } - #endregion } diff --git a/Lite/Windows/CollectionLogWindow.xaml.cs b/Lite/Windows/CollectionLogWindow.xaml.cs index 631302833..56dc819f9 100644 --- a/Lite/Windows/CollectionLogWindow.xaml.cs +++ b/Lite/Windows/CollectionLogWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Windows; using PerformanceMonitorLite.Services; +using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Windows { @@ -72,10 +73,10 @@ private async Task LoadCollectionLogAsync() } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "collection_log"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "collection_log", App.CsvSeparator); private void Close_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/ManageServersWindow.xaml.cs b/Lite/Windows/ManageServersWindow.xaml.cs index 15ca19a7b..9aabe57fc 100644 --- a/Lite/Windows/ManageServersWindow.xaml.cs +++ b/Lite/Windows/ManageServersWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Windows.Input; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; +using PerformanceMonitor.Ui; namespace PerformanceMonitorLite.Windows; @@ -144,10 +145,10 @@ private void DeleteButton_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "servers"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "servers", App.CsvSeparator); private void CloseButton_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml.cs b/Lite/Windows/ProcedureHistoryWindow.xaml.cs index 04e09a425..9d3c201ab 100644 --- a/Lite/Windows/ProcedureHistoryWindow.xaml.cs +++ b/Lite/Windows/ProcedureHistoryWindow.xaml.cs @@ -174,10 +174,10 @@ private async void DownloadPlan_Click(object sender, RoutedEventArgs e) } } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "procedure_history"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "procedure_history", App.CsvSeparator); // Procedures are View Plan only — re-executing a proc without parameter values is unsafe, // matching the main Procedure grid (no Get Actual case in its switch). diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs index e7d5d1c58..81b6b37b8 100644 --- a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs +++ b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs @@ -196,10 +196,10 @@ private void OnThemeChanged(string _) HistoryChart.Refresh(); } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "query_stats_history"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_stats_history", App.CsvSeparator); private async System.Threading.Tasks.Task FetchPlanAsync() { diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs index 567cfc263..04cada97c 100644 --- a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs +++ b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs @@ -177,10 +177,10 @@ private void OnThemeChanged(string _) HistoryChart.Refresh(); } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "query_store_history"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "query_store_history", App.CsvSeparator); private async System.Threading.Tasks.Task FetchPlanAsync() { diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs index 734efd911..96d511677 100644 --- a/Lite/Windows/SettingsWindow.xaml.cs +++ b/Lite/Windows/SettingsWindow.xaml.cs @@ -1198,10 +1198,10 @@ private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e e.Handled = true; } - private void CopyCell_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => Helpers.ContextMenuHelper.ExportToCsv(sender, "schedules"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "schedules", App.CsvSeparator); private void CloseButton_Click(object sender, RoutedEventArgs e) { diff --git a/Lite/Windows/WaitDrillDownWindow.xaml.cs b/Lite/Windows/WaitDrillDownWindow.xaml.cs index 6eb38ee12..613c61a46 100644 --- a/Lite/Windows/WaitDrillDownWindow.xaml.cs +++ b/Lite/Windows/WaitDrillDownWindow.xaml.cs @@ -419,10 +419,10 @@ private void FilterPopup_FilterCleared(object? sender, EventArgs e) #endregion - private void CopyCell_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyCell(sender); - private void CopyRow_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyRow(sender); - private void CopyAllRows_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.CopyAllRows(sender); - private void ExportToCsv_Click(object sender, RoutedEventArgs e) => ContextMenuHelper.ExportToCsv(sender, "wait_drill_down"); + private void CopyCell_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyCell(sender); + private void CopyRow_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyRow(sender); + private void CopyAllRows_Click(object sender, RoutedEventArgs e) => DataGridExport.CopyAllRows(sender); + private void ExportToCsv_Click(object sender, RoutedEventArgs e) => DataGridExport.ExportToCsv(sender, "wait_drill_down", App.CsvSeparator); private async void ViewPlan_Click(object sender, RoutedEventArgs e) { From dccdf7907a639cd523a44dea452f54187c4740e2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:52:19 -0400 Subject: [PATCH 009/547] Fix right-click Copy Cell/Copy Row across all DataGrids The copy/export consolidation (#1257) routes every grid's Copy Cell through grid.CurrentCell and Copy Row through grid.CurrentItem, but a bare right-click sets neither -- WPF only updates them on left-click or keyboard navigation. The app-wide DataGridRowSelectionBehavior selected the row (SelectedItem) on right-click, keeping View Plan working, but never set the current cell or item, so Copy Cell did nothing on every grid and Copy Row did nothing wherever no prior selection existed (e.g. after an auto-refresh re-bound ItemsSource). Enhance DataGridRowSelectionBehavior to also make the right-clicked cell current (which sets CurrentItem too), so both context-menu attachment patterns -- row-level (PlacementTarget = DataGridRow) and grid-level (PlacementTarget = DataGrid) -- feed Copy Cell and Copy Row uniformly. CopyRow reads the clicked row directly via FindRowItem for the row-attached case, falling back to CurrentItem then SelectedItem. Verified live on Collection Health (row-level menu) and Current Configuration (grid-level menu): Copy Cell, Copy Row, and Copy All Rows all correct. Both apps build; 12/12 DataGridExport tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- PerformanceMonitor.Ui/DataGridExport.cs | 30 +++++++++++++-- .../DataGridRowSelectionBehavior.cs | 37 ++++++++++++++----- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/PerformanceMonitor.Ui/DataGridExport.cs b/PerformanceMonitor.Ui/DataGridExport.cs index eda18afdd..acbfefb0c 100644 --- a/PerformanceMonitor.Ui/DataGridExport.cs +++ b/PerformanceMonitor.Ui/DataGridExport.cs @@ -50,13 +50,20 @@ public static void CopyCell(object sender) if (value.Length > 0) Clipboard.SetDataObject(value, false); } - /// Copies the current row (all columns, tab-delimited) to the clipboard. + /// + /// Copies a row (all columns, tab-delimited) to the clipboard -- the row the context menu was + /// opened on, falling back to the current or selected row. Row-attached context menus don't + /// select the row on right-click, so CurrentItem alone is often null here. + /// public static void CopyRow(object sender) { var grid = FindDataGrid(sender); - if (grid?.CurrentItem == null) return; + if (grid == null) return; + + var item = FindRowItem(sender) ?? grid.CurrentItem ?? grid.SelectedItem; + if (item == null) return; - Clipboard.SetDataObject(string.Join("\t", GetRowValues(grid, grid.CurrentItem)), false); + Clipboard.SetDataObject(string.Join("\t", GetRowValues(grid, item)), false); } /// Copies the whole grid (header + all rows, tab-delimited) to the clipboard. @@ -211,4 +218,21 @@ public static string CsvEscape(string value, string separator) } return target as DataGrid; } + + /// + /// The data item of the DataGridRow the context menu was opened on (for row- or cell-attached + /// menus), or null when the menu is attached directly to the DataGrid. + /// + private static object? FindRowItem(object sender) + { + if (sender is not MenuItem menuItem || menuItem.Parent is not ContextMenu contextMenu) + return null; + + DependencyObject? target = contextMenu.PlacementTarget; + while (target != null && target is not DataGridRow) + { + target = VisualTreeHelper.GetParent(target); + } + return (target as DataGridRow)?.Item; + } } diff --git a/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs b/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs index aafff8053..5ec44aefa 100644 --- a/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs +++ b/PerformanceMonitor.Ui/DataGridRowSelectionBehavior.cs @@ -14,20 +14,22 @@ namespace PerformanceMonitor.Ui; /// -/// Makes right-clicking a DataGrid row select it. WPF's DataGrid does not select a row on -/// right-click by default, so context-menu actions that read SelectedItem (e.g. "View Plan") -/// silently do nothing whenever the prior left-click selection was cleared — most commonly by a -/// background auto-refresh re-binding ItemsSource, which also drops the row's highlight. -/// Selecting the row under the cursor on right-click makes the menu always act on the row the user -/// actually clicked. Call once at application startup to apply this app-wide. +/// Makes right-clicking a DataGrid select the row AND make the cell under the cursor current. WPF's +/// DataGrid does neither on right-click by default, so context-menu actions that read +/// SelectedItem/CurrentItem (e.g. "View Plan", "Copy Row") or CurrentCell +/// (e.g. "Copy Cell") silently do nothing on a bare right-click — and any prior left-click selection +/// is most commonly cleared by a background auto-refresh re-binding ItemsSource. Setting both +/// the selected row and the current cell under the cursor makes every context-menu item act on what +/// the user actually clicked, regardless of how the grid attaches its menu (row-level or grid-level). +/// Call once at application startup to apply this app-wide. /// public static class DataGridRowSelectionBehavior { private static bool _enabled; /// - /// Registers a class handler so every in the app selects the row under - /// the cursor on right-click. Idempotent. + /// Registers a class handler so every in the app selects the row and makes + /// the cell under the cursor current on right-click. Idempotent. /// public static void Enable() { @@ -42,15 +44,30 @@ public static void Enable() private static void OnPreviewRightButtonDown(object sender, MouseButtonEventArgs e) { - // Walk up from the actual clicked element (a cell's content) to its DataGridRow. + if (sender is not DataGrid grid) return; + + // Walk up from the actual clicked element (a cell's content), capturing the DataGridCell on + // the way to its DataGridRow. The cell is a descendant of the row, so it is seen first. + DataGridCell? cell = null; var dep = e.OriginalSource as DependencyObject; while (dep != null && dep is not DataGridRow) + { + if (cell == null && dep is DataGridCell c) cell = c; dep = VisualTreeHelper.GetParent(dep); + } - if (dep is DataGridRow row && sender is DataGrid grid) + if (dep is DataGridRow row) { row.IsSelected = true; grid.SelectedItem = row.Item; } + + // Also make the clicked cell current so cell-scoped items (Copy Cell) act on it. This sets + // CurrentItem too, which row-scoped items (Copy Row) fall back to. We do NOT mark the event + // handled — the context menu must still open. + if (cell?.Column != null) + { + grid.CurrentCell = new DataGridCellInfo(cell); + } } } From 3152f129b3214fa59984ee5f2630f3a6f3a40abf Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:18:35 -0400 Subject: [PATCH 010/547] Lite code-simplification audit: remove dead code, collapse duplication Result of an 8-area read-only audit of the Lite app; every finding was re-verified before applying. Net ~860 lines removed. Both apps build; 1321 tests pass (Lite.Tests 618, Dashboard.Tests 703). Track A -- dead code (verified zero-caller): - Move TestDataSeeder.cs (~2400 lines) out of the shipping Lite binary into Lite.Tests (test-only, but was compiled into the app). - ScheduleManager: delete the unused v1 global-schedule API (10 methods + PresetNames + orphaned ApplyPresetToList); only the per-server variants are used. - LocalDataService: GetServerPropertiesLatestAsync (~150 lines), GetDatabaseSizeTrendAsync + DatabaseSizeTrendPoint DTO, GetMemoryClerkTrendAsync (superseded by the batched plural), GetQueryStoreDatabasesAsync, IsHighWait. - Analysis: GetAllBaselinesAsync, the MemoryBufferPoolMb baseline metric, UnmuteStoryAsync, LiteRecommendationItem.ProblemArea/RootFactKey. - RemoteCollectorService: dead CheckpointAsync (wrapper + DuckDb impl), SafeToDecimal, the LPIM-advisory const, write-only health fields. - DataGridFilterService.cs (whole file; Lite uses DataGridFilterManager), AppLogger.DataDiag, and the unused analysis_exclusions/analysis_thresholds DuckDB tables + their shared AnalysisExclusion/AnalysisThreshold models and AnalysisContext.Exclusions. - Dashboard parity: dead AboutWindow.ReleasesUrl const + LoadingOverlay SpinnerText x:Name (dead in both apps). Track B -- collapse duplication: - 3 identical chart drill-down methods -> the shared one; 4 identical FinOpsTab Get*HoursBack switches -> one helper; SilenceServer_Click -> the existing HideServerBadge; an identical if/else in the schedule editor. - DeltaCalculator: drop the always-true baselineOnly param (~30 collector call sites). - SettingsWindow: extract a WriteSetting(...) helper for the 5 single-value Save* methods (the 4 multi-field/validation methods -- Mcp/Alert/Smtp/ Webhook -- left for a focused follow-up). - Encapsulation: DuckDbInitializer.DatabaseExists, ServerManager.MigrateServerAuthentication (vestigial), ServerTimeHelper.LocalToServerTime/ToLocalTime made private. Track C -- MCP instruction drift: - Lite's server-instructions + one tool description told the LLM to call get_blocking, a tool Lite does not register (stale Dashboard copy); point them at get_blocked_process_reports and document get_mute_rules. Track D -- dead XAML + cosmetic: - WaitStatsRowStyle (dead) + its IsHighWait binding; unused x:Names; fluent LINQ; ServerResolver.Resolve/ListAvailableServers -> private. Track E -- restore copy/export on the Settings server-schedule grid: the handlers + context menu existed but the menu was never attached to the grid. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard/AboutWindow.xaml.cs | 1 - Dashboard/Controls/LoadingOverlay.xaml | 3 +- .../Analysis => Lite.Tests}/TestDataSeeder.cs | 0 Lite/Analysis/BaselineProvider.cs | 23 --- Lite/Analysis/FindingStore.cs | 15 -- .../Recommendations/LiteRecommendationItem.cs | 6 - .../LiteRecommendationsReader.cs | 1 - Lite/Controls/FinOpsTab.xaml.cs | 60 +----- Lite/Controls/LoadingOverlay.xaml | 3 +- Lite/Controls/ServerTab.DrillDown.cs | 45 +---- Lite/Controls/ServerTab.xaml | 9 - Lite/Controls/ServerTab.xaml.cs | 6 +- Lite/Database/AnalysisSchema.cs | 32 ---- Lite/Database/DuckDbInitializer.cs | 23 +-- Lite/MainWindow.xaml.cs | 14 +- Lite/Mcp/McpAnalysisTools.cs | 4 +- Lite/Mcp/McpBlockingTools.cs | 2 +- Lite/Mcp/McpInstructions.cs | 10 +- Lite/Mcp/ServerResolver.cs | 4 +- Lite/Services/AppLogger.cs | 5 - Lite/Services/DataGridFilterService.cs | 24 --- Lite/Services/DeltaCalculator.cs | 11 +- ...ocalDataService.FinOps.ServerProperties.cs | 155 --------------- .../LocalDataService.FinOps.StorageGrowth.cs | 39 ---- Lite/Services/LocalDataService.FinOps.cs | 7 - Lite/Services/LocalDataService.Memory.cs | 44 +---- Lite/Services/LocalDataService.QueryStore.cs | 26 --- Lite/Services/LocalDataService.WaitStats.cs | 1 - .../Services/RemoteCollectorService.FileIo.cs | 16 +- .../RemoteCollectorService.MemoryGrants.cs | 4 +- .../RemoteCollectorService.Perfmon.cs | 2 +- .../RemoteCollectorService.ProcedureStats.cs | 12 +- .../RemoteCollectorService.QueryStats.cs | 16 +- ...RemoteCollectorService.ServerProperties.cs | 5 - .../RemoteCollectorService.WaitStats.cs | 6 +- Lite/Services/RemoteCollectorService.cs | 41 ---- Lite/Services/ScheduleManager.cs | 181 +----------------- Lite/Services/ServerManager.cs | 23 --- Lite/Services/ServerTimeHelper.cs | 4 +- Lite/Windows/AboutWindow.xaml | 6 +- Lite/Windows/AboutWindow.xaml.cs | 1 - Lite/Windows/AddServerDialog.xaml | 2 +- .../CollectorScheduleEditorWindow.xaml.cs | 15 +- Lite/Windows/EditCredentialProfileDialog.xaml | 2 +- Lite/Windows/ManageMuteRulesWindow.xaml | 6 +- Lite/Windows/SettingsWindow.xaml | 1 + Lite/Windows/SettingsWindow.xaml.cs | 142 +++----------- .../AnalysisContext.cs | 3 - PerformanceMonitor.Analysis/AnalysisModels.cs | 31 --- 49 files changed, 101 insertions(+), 991 deletions(-) rename {Lite/Analysis => Lite.Tests}/TestDataSeeder.cs (100%) delete mode 100644 Lite/Services/DataGridFilterService.cs diff --git a/Dashboard/AboutWindow.xaml.cs b/Dashboard/AboutWindow.xaml.cs index 7cdc6c9c4..b77f845a7 100644 --- a/Dashboard/AboutWindow.xaml.cs +++ b/Dashboard/AboutWindow.xaml.cs @@ -18,7 +18,6 @@ public partial class AboutWindow : Window { private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceMonitor"; private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/issues"; - private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; diff --git a/Dashboard/Controls/LoadingOverlay.xaml b/Dashboard/Controls/LoadingOverlay.xaml index eced2acbd..34acf9728 100644 --- a/Dashboard/Controls/LoadingOverlay.xaml +++ b/Dashboard/Controls/LoadingOverlay.xaml @@ -15,8 +15,7 @@ - GetBaselineAsync( return BaselineBucket.Empty; } - /// - /// Gets all baseline buckets for a metric/server. Used by UI for rendering - /// expected-range bands across all time slots. - /// - public async Task?> GetAllBaselinesAsync( - int serverId, string metricName, DateTime analysisTime) - { - return await GetOrComputeBaselinesAsync(serverId, metricName, analysisTime); - } - /// Forces cache eviction for a server — used during testing. public void InvalidateCache(int serverId) { @@ -347,18 +337,6 @@ AND target_server_memory_mb > 0 // ── Chart-unit baselines (for UI bands — units match what the chart displays) ── - // Buffer pool MB (chart shows this, not pressure %) - MetricNames.MemoryBufferPoolMb => @" -SELECT EXTRACT(HOUR FROM collection_time)::INT AS hour_of_day, - EXTRACT(DOW FROM collection_time)::INT AS day_of_week, - AVG(buffer_pool_mb::DOUBLE) AS mean_val, - STDDEV_SAMP(buffer_pool_mb::DOUBLE) AS stddev_val, - COUNT(*) AS sample_count -FROM v_memory_stats -WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3 -AND buffer_pool_mb > 0 -GROUP BY hour_of_day, day_of_week", - // Wait ms per second (chart shows this, not total ms per collection) MetricNames.WaitMsPerSec => @" WITH per_collection AS ( @@ -541,7 +519,6 @@ public static class MetricNames public const string Memory = "memory"; // Chart-unit metrics (for UI bands — units match what the chart displays) - public const string MemoryBufferPoolMb = "memory_buffer_pool_mb"; public const string WaitMsPerSec = "wait_ms_per_sec"; public const string BlockingPerMinute = "blocking_per_minute"; } diff --git a/Lite/Analysis/FindingStore.cs b/Lite/Analysis/FindingStore.cs index 7698033ce..1d2cd2567 100644 --- a/Lite/Analysis/FindingStore.cs +++ b/Lite/Analysis/FindingStore.cs @@ -221,21 +221,6 @@ INSERT INTO analysis_muted (mute_id, server_id, story_path_hash, story_path, mut await cmd.ExecuteNonQueryAsync(); } - /// - /// Unmutes a story pattern. - /// - public async Task UnmuteStoryAsync(long muteId) - { - using var readLock = _duckDb.AcquireReadLock(); - using var connection = _duckDb.CreateConnection(); - await connection.OpenAsync(); - - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM analysis_muted WHERE mute_id = $1"; - cmd.Parameters.Add(new DuckDBParameter { Value = muteId }); - await cmd.ExecuteNonQueryAsync(); - } - /// /// Cleans up old findings beyond the retention period. /// diff --git a/Lite/Analysis/Recommendations/LiteRecommendationItem.cs b/Lite/Analysis/Recommendations/LiteRecommendationItem.cs index dfcffb490..df467ce30 100644 --- a/Lite/Analysis/Recommendations/LiteRecommendationItem.cs +++ b/Lite/Analysis/Recommendations/LiteRecommendationItem.cs @@ -54,12 +54,6 @@ public sealed class LiteRecommendationItem /// The one-line title shown as the card heading. public string Title { get; set; } = string.Empty; - /// - /// The problem-area / category grouping (the finding's Category). Kept distinct - /// from for any future grouping/filtering. - /// - public string ProblemArea { get; set; } = string.Empty; - /// /// Operator-facing advice prose, composed from /// for the finding's root fact key (remediation line + investigation line). Falls back to the diff --git a/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs b/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs index b2d95c861..dbeea1594 100644 --- a/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs +++ b/Lite/Analysis/Recommendations/LiteRecommendationsReader.cs @@ -148,7 +148,6 @@ internal static LiteRecommendationItem MapFinding(AnalysisFinding finding, strin RawSeverity = finding.Severity, Database = string.IsNullOrEmpty(finding.DatabaseName) ? null : finding.DatabaseName, Title = !string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.RootFactKey, - ProblemArea = finding.Category, AdviceText = ComposeAdvice(advice), CopyPasteSql = NullIfEmpty(FactRemediation.GenerateForFinding(finding)), RootFactKey = finding.RootFactKey, diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs index 6abf49960..620409dcf 100644 --- a/Lite/Controls/FinOpsTab.xaml.cs +++ b/Lite/Controls/FinOpsTab.xaml.cs @@ -417,18 +417,7 @@ private static void SetBar(Border bar, ColumnDefinition filled, ColumnDefinition empty.Width = new GridLength(Math.Max(100 - clamped, 0.1), GridUnitType.Star); } - private int GetResourceUsageHoursBack() - { - return ResourceUsageTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } + private int HoursBackFromIndex(System.Windows.Controls.ComboBox combo) => combo.SelectedIndex switch { 0 => 1, 1 => 4, 2 => 12, 3 => 24, 4 => 168, _ => 24 }; private async void ResourceUsageTimeRange_Changed(object sender, System.Windows.Controls.SelectionChangedEventArgs e) { @@ -444,7 +433,7 @@ private async System.Threading.Tasks.Task LoadDatabaseResourcesAsync(int serverI try { - var hoursBack = GetResourceUsageHoursBack(); + var hoursBack = HoursBackFromIndex(ResourceUsageTimeRangeCombo); var data = await Task.Run(() => _dataService.GetDatabaseResourceUsageAsync(serverId, hoursBack)); _dbResourcesFilterMgr!.UpdateData(data); NoDatabaseResourcesMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; @@ -647,26 +636,13 @@ private async System.Threading.Tasks.Task LoadTempdbSummaryAsync(int serverId) } } - private int GetHighImpactHoursBack() - { - return HighImpactTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - private async System.Threading.Tasks.Task LoadHighImpactQueriesAsync(int serverId) { if (_dataService == null) return; try { - var hoursBack = GetHighImpactHoursBack(); + var hoursBack = HoursBackFromIndex(HighImpactTimeRangeCombo); var data = await Task.Run(() => _dataService.GetHighImpactQueriesAsync(serverId, hoursBack)); _highImpactFilterMgr!.UpdateData(data); HighImpactNoDataMessage.Visibility = data.Count == 0 ? Visibility.Visible : Visibility.Collapsed; @@ -678,39 +654,13 @@ private async System.Threading.Tasks.Task LoadHighImpactQueriesAsync(int serverI } } - private int GetWaitStatsHoursBack() - { - return WaitStatsTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - - private int GetExpensiveQueriesHoursBack() - { - return ExpensiveQueriesTimeRangeCombo.SelectedIndex switch - { - 0 => 1, - 1 => 4, - 2 => 12, - 3 => 24, - 4 => 168, - _ => 24 - }; - } - private async System.Threading.Tasks.Task LoadWaitCategorySummaryAsync(int serverId) { if (_dataService == null) return; try { - var hoursBack = GetWaitStatsHoursBack(); + var hoursBack = HoursBackFromIndex(WaitStatsTimeRangeCombo); var data = await Task.Run(() => _dataService.GetWaitCategorySummaryAsync(serverId, hoursBack)); // Compute proportional cost shares — scaled to time window @@ -740,7 +690,7 @@ private async System.Threading.Tasks.Task LoadExpensiveQueriesAsync(int serverId try { - var hoursBack = GetExpensiveQueriesHoursBack(); + var hoursBack = HoursBackFromIndex(ExpensiveQueriesTimeRangeCombo); var data = await Task.Run(() => _dataService.GetExpensiveQueriesAsync(serverId, hoursBack)); // Compute proportional cost shares — scaled to time window diff --git a/Lite/Controls/LoadingOverlay.xaml b/Lite/Controls/LoadingOverlay.xaml index 73960ea96..d278b6574 100644 --- a/Lite/Controls/LoadingOverlay.xaml +++ b/Lite/Controls/LoadingOverlay.xaml @@ -8,8 +8,7 @@ d:DesignHeight="200" d:DesignWidth="400"> - /// Generic "Show Active Queries at This Time" drill-down for resource charts that have no /// more specific target (memory clerks/grants/pressure, tempdb size + file I/O, file I/O - /// latency + throughput, current waits, perfmon). Same behavior as . + /// latency + throughput, current waits, perfmon). /// private async void OnActiveQueriesDrillDown(DateTime time) { @@ -131,49 +131,6 @@ private async void OnActiveQueriesDrillDown(DateTime time) _ = LoadActiveQueriesSlicerAsync(); } - private async void OnCpuDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - - // Populate custom date pickers so user can explore other tabs - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Queries > Active Queries with ±15 min window - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnMemoryDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - - private async void OnTempDbDrillDown(DateTime time) - { - var fromDate = time.AddMinutes(-30); - var toDate = time.AddMinutes(30); - SetDrillDownTimeRange(fromDate, toDate); - - // Navigate to Active Queries — TempDB spills are visible there - SelectActiveQueriesForDrillDown(); - var snapshots = await System.Threading.Tasks.Task.Run(() => _dataService.GetLatestQuerySnapshotsAsync(_serverId, 0, fromDate, toDate)); - _querySnapshotsFilterMgr!.UpdateData(snapshots); - LiveSnapshotIndicator.Text = $"Drill-down: {ServerTimeHelper.FormatServerTime(fromDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")} → {ServerTimeHelper.FormatServerTime(toDate.AddMinutes(-UtcOffsetMinutes), "HH:mm")}"; - _ = LoadActiveQueriesSlicerAsync(); - } - private async void OnBlockingDrillDown(DateTime time) { var fromDate = time.AddMinutes(-30); diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index 1234b5e3c..042896d56 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -106,15 +106,6 @@ - - - diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 46c47f7ea..b63658ef5 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -322,9 +322,9 @@ refresh once when it becomes visible again so it isn't showing stale data on ret Helpers.ContextMenuHelper.SetupChartContextMenu(QueryStoreDurationTrendChart, "QueryStore_Duration_Trends"); Helpers.ContextMenuHelper.SetupChartContextMenu(ExecutionCountTrendChart, "Execution_Count_Trends"); var cpuMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(CpuChart, "CPU_Usage"); - AddChartDrillDownMenuItem(CpuChart, cpuMenu, _cpuHover, "Show Active Queries at This Time", OnCpuDrillDown); + AddChartDrillDownMenuItem(CpuChart, cpuMenu, _cpuHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var memoryMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryChart, "Memory_Usage"); - AddChartDrillDownMenuItem(MemoryChart, memoryMenu, _memoryHover, "Show Active Queries at This Time", OnMemoryDrillDown); + AddChartDrillDownMenuItem(MemoryChart, memoryMenu, _memoryHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var memoryClerksMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryClerksChart, "Memory_Clerks"); AddChartDrillDownMenuItem(MemoryClerksChart, memoryClerksMenu, _memoryClerksHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var memoryGrantSizingMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(MemoryGrantSizingChart, "Memory_Grant_Sizing"); @@ -342,7 +342,7 @@ refresh once when it becomes visible again so it isn't showing stale data on ret var fileIoWriteThroughputMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(FileIoWriteThroughputChart, "File_IO_Write_Throughput"); AddChartDrillDownMenuItem(FileIoWriteThroughputChart, fileIoWriteThroughputMenu, _fileIoWriteThroughputHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var tempDbMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbChart, "TempDB_Stats"); - AddChartDrillDownMenuItem(TempDbChart, tempDbMenu, _tempDbHover, "Show Active Queries at This Time", OnTempDbDrillDown); + AddChartDrillDownMenuItem(TempDbChart, tempDbMenu, _tempDbHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var tempDbSizeMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbSizeChart, "TempDB_Allocated_Size"); AddChartDrillDownMenuItem(TempDbSizeChart, tempDbSizeMenu, _tempDbSizeHover, "Show Active Queries at This Time", OnActiveQueriesDrillDown); var tempDbFileIoMenu = Helpers.ContextMenuHelper.SetupChartContextMenu(TempDbFileIoChart, "TempDB_File_IO"); diff --git a/Lite/Database/AnalysisSchema.cs b/Lite/Database/AnalysisSchema.cs index 3e188b20c..e0d12da6b 100644 --- a/Lite/Database/AnalysisSchema.cs +++ b/Lite/Database/AnalysisSchema.cs @@ -47,31 +47,6 @@ CREATE TABLE IF NOT EXISTS analysis_muted ( reason VARCHAR )"; - public const string CreateAnalysisExclusionsTable = @" -CREATE TABLE IF NOT EXISTS analysis_exclusions ( - exclusion_id BIGINT PRIMARY KEY, - exclusion_type VARCHAR NOT NULL, - exclusion_value VARCHAR NOT NULL, - server_id INTEGER, - database_name VARCHAR, - is_enabled BOOLEAN NOT NULL DEFAULT true, - created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - description VARCHAR -)"; - - public const string CreateAnalysisThresholdsTable = @" -CREATE TABLE IF NOT EXISTS analysis_thresholds ( - threshold_id BIGINT PRIMARY KEY, - category VARCHAR NOT NULL, - fact_key VARCHAR NOT NULL, - threshold_type VARCHAR NOT NULL, - threshold_value DOUBLE NOT NULL, - server_id INTEGER, - database_name VARCHAR, - is_enabled BOOLEAN NOT NULL DEFAULT true, - modified_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -)"; - public const string CreateAnalysisFindingsTimeIndex = @" CREATE INDEX IF NOT EXISTS idx_analysis_findings_time ON analysis_findings(server_id, analysis_time)"; @@ -84,10 +59,6 @@ CREATE INDEX IF NOT EXISTS idx_analysis_findings_hash CREATE INDEX IF NOT EXISTS idx_analysis_muted_hash ON analysis_muted(story_path_hash)"; - public const string CreateAnalysisThresholdsLookupIndex = @" -CREATE INDEX IF NOT EXISTS idx_analysis_thresholds_lookup - ON analysis_thresholds(category, fact_key)"; - /// /// Returns all analysis table creation statements. /// @@ -95,8 +66,6 @@ public static IEnumerable GetAllTableStatements() { yield return CreateAnalysisFindingsTable; yield return CreateAnalysisMutedTable; - yield return CreateAnalysisExclusionsTable; - yield return CreateAnalysisThresholdsTable; } /// @@ -127,6 +96,5 @@ public static IEnumerable GetAllIndexStatements() yield return CreateAnalysisFindingsTimeIndex; yield return CreateAnalysisFindingsHashIndex; yield return CreateAnalysisMutedHashIndex; - yield return CreateAnalysisThresholdsLookupIndex; } } diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index 27ffbb86c..17ffedad3 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -999,27 +999,6 @@ await ExecuteNonQueryAsync(connection, } } - /// - /// Runs a manual WAL checkpoint. Call this between collection cycles - /// to flush the WAL during idle time instead of during collector writes. - /// - public async Task CheckpointAsync() - { - using var writeLock = AcquireWriteLock(); - try - { - using var connection = CreateConnection(); - await connection.OpenAsync(); - using var command = connection.CreateCommand(); - command.CommandText = "CHECKPOINT"; - await command.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - _logger?.LogDebug(ex, "Manual checkpoint failed (non-critical)"); - } - } - /// /// Executes a non-query SQL statement. /// @@ -1041,7 +1020,7 @@ private async Task ExecuteNonQueryAsync(DuckDBConnection connection, string sql) /// /// Checks if the database file exists. /// - public bool DatabaseExists() + private bool DatabaseExists() { return File.Exists(_databasePath); } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index 972300b45..83b8d1386 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -998,18 +998,8 @@ private void SilenceServer_Click(object sender, RoutedEventArgs e) { _alertStateService.SilenceServer(serverId); - /* Find and hide the badge for this server */ - if (_openServerTabs.TryGetValue(serverId, out var tab) && tab.Header is StackPanel panel) - { - foreach (var child in panel.Children) - { - if (child is System.Windows.Controls.Border border && border.Tag as string == "AlertBadge") - { - border.Visibility = Visibility.Collapsed; - break; - } - } - } + /* Hide the badge for this server (same loop as the acknowledge path). */ + HideServerBadge(serverId); } } diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index eb6302dfb..2053b219c 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -96,8 +96,8 @@ surfaced with the shared miss vocabulary so callers branch on it uniformly. */ // are null/0 so the inaction side shows the weak-case baseline). destructive_risk_disclosure = advice?.Risks is null ? null : new { - risks_of_changing = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(advice.Risks.RisksOfChanging, r => r.Text)), - risks_of_not_changing = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(advice.Risks.RisksOfNotChanging, r => r.Text)) + risks_of_changing = advice.Risks.RisksOfChanging.Select(r => r.Text).ToArray(), + risks_of_not_changing = advice.Risks.RisksOfNotChanging.Select(r => r.Text).ToArray() } }; }) diff --git a/Lite/Mcp/McpBlockingTools.cs b/Lite/Mcp/McpBlockingTools.cs index b92b9903c..c4b7f5c5d 100644 --- a/Lite/Mcp/McpBlockingTools.cs +++ b/Lite/Mcp/McpBlockingTools.cs @@ -105,7 +105,7 @@ public static async Task GetDeadlockDetail( } } - [McpServerTool(Name = "get_blocked_process_reports"), Description("Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer). Provides richer detail than get_blocking: isolation levels, transaction names, full query text for both blocker and blocked. Use for deep analysis of prolonged blocking.")] + [McpServerTool(Name = "get_blocked_process_reports"), Description("Gets detailed blocked process reports from extended events (parsed via sp_HumanEventsBlockViewer). Provides detailed blocked/blocking session info: isolation levels, transaction names, full query text for both sessions. Use for deep analysis of prolonged blocking.")] public static async Task GetBlockedProcessReports( LocalDataService dataService, ServerManager serverManager, diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index e67a5845b..fd6bf45ee 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -109,6 +109,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo |------|---------|----------------| | `get_alert_history` | Recent alert history: what fired, when, email status | `hours_back` (default 24), `limit` (default 50) | | `get_alert_settings` | Current alert thresholds and SMTP configuration | none | + | `get_mute_rules` | Configured mute rules that suppress specific recurring alerts (still logged) | `enabled_only` (default true) | ### Job Tools | Tool | Purpose | Key Parameters | @@ -193,16 +194,15 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `CXPACKET` / `CXCONSUMER` | Parallelism | `get_top_queries_by_cpu` with `parallel_only=true` | | `PAGEIOLATCH_*` | Disk I/O | `get_file_io_stats`, `get_file_io_trend` | | `WRITELOG` | Transaction log I/O | `get_file_io_stats` (check log file latency) | - | `LCK_M_*` | Lock contention | `get_blocking`, `get_blocked_process_reports` | + | `LCK_M_*` | Lock contention | `get_blocked_process_reports` | | `RESOURCE_SEMAPHORE` | Memory grant pressure | `get_memory_grants` | | `LATCH_*` | Internal contention | `get_tempdb_trend` | - ## Blocking vs Blocked Process Reports + ## Blocked Process Reports - - **`get_blocking`**: Captures blocking chains from `sys.dm_exec_requests` at each collection snapshot. Shows who is blocking whom. - - **`get_blocked_process_reports`**: Captures events from SQL Server's Blocked Process Report extended event (via sp_HumanEventsBlockViewer). Fires when a session has been blocked longer than the configured threshold. Includes richer detail: isolation levels, transaction names, full query text for both blocker and blocked. + - **`get_blocked_process_reports`**: Captures events from SQL Server's Blocked Process Report extended event (via sp_HumanEventsBlockViewer). Fires when a session has been blocked longer than the configured threshold. Includes rich detail: isolation levels, transaction names, and full query text for both the blocker and the blocked session. - **Use `get_blocking` first** for a quick overview. **Use `get_blocked_process_reports`** when you need detailed analysis of prolonged blocking events. + Use it for detailed analysis of prolonged blocking events; pair it with `get_blocking_trend` to see whether blocking frequency is new, worsening, or resolved. ## Interpreting Memory Pressure Events diff --git a/Lite/Mcp/ServerResolver.cs b/Lite/Mcp/ServerResolver.cs index 06bb20d6c..3698682b4 100644 --- a/Lite/Mcp/ServerResolver.cs +++ b/Lite/Mcp/ServerResolver.cs @@ -8,7 +8,7 @@ namespace PerformanceMonitorLite.Mcp; /// internal static class ServerResolver { - public static (int ServerId, string ServerName)? Resolve( + private static (int ServerId, string ServerName)? Resolve( ServerManager serverManager, string? serverName) { @@ -71,7 +71,7 @@ public static ((int ServerId, string ServerName)? resolved, string? error) Resol : (resolved, null); } - public static string ListAvailableServers(ServerManager serverManager) + private static string ListAvailableServers(ServerManager serverManager) { var servers = serverManager.GetEnabledServers(); if (servers.Count == 0) diff --git a/Lite/Services/AppLogger.cs b/Lite/Services/AppLogger.cs index 0b385991c..18c7a84f8 100644 --- a/Lite/Services/AppLogger.cs +++ b/Lite/Services/AppLogger.cs @@ -97,11 +97,6 @@ public static void Debug(string source, string message) #endif } - public static void DataDiag(string source, string message) - { - Log("DATA", source, message); - } - private static void Log(string level, string source, string message) { var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level,-5}] [{source}] {message}"; diff --git a/Lite/Services/DataGridFilterService.cs b/Lite/Services/DataGridFilterService.cs deleted file mode 100644 index 4bd9f5140..000000000 --- a/Lite/Services/DataGridFilterService.cs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2026 Erik Darling, Darling Data LLC - * - * This file is part of the SQL Server Performance Monitor Lite. - * - * Licensed under the MIT License. See LICENSE file in the project root for full license information. - */ - -using PerformanceMonitor.Common; - -namespace PerformanceMonitorLite.Services; - -/// -/// Provides operator-based filtering for DataGrid column filters. Forwards to the shared -/// so Lite and Dashboard share one matching implementation. -/// -public static class DataGridFilterService -{ - /// - /// Checks if an item matches a ColumnFilterState using operator-based filtering. - /// - public static bool MatchesFilter(object item, ColumnFilterState filter) => - ColumnFilterMatcher.MatchesFilter(item, filter); -} diff --git a/Lite/Services/DeltaCalculator.cs b/Lite/Services/DeltaCalculator.cs index 990a507cd..ea70416b5 100644 --- a/Lite/Services/DeltaCalculator.cs +++ b/Lite/Services/DeltaCalculator.cs @@ -70,14 +70,14 @@ public void ClearServer(int serverId) /// /// Calculates the delta between the current value and the previous cached value. - /// First-ever sighting (no baseline): returns currentValue so single-execution queries appear. + /// First-ever sighting (no baseline): returns 0 and stores the value as the new baseline. /// Counter reset (value decreased): returns 0 to avoid inflated deltas from plan cache churn. /// Gap detection: if collectionTime and maxGapSeconds are provided and the gap since the /// last cached value exceeds maxGapSeconds, returns 0 to avoid inflated deltas after restarts. /// Thread-safe via atomic AddOrUpdate. /// public long CalculateDelta(int serverId, string collectorName, string key, long currentValue, - bool baselineOnly = false, DateTime? collectionTime = null, int maxGapSeconds = 0) + DateTime? collectionTime = null, int maxGapSeconds = 0) { var serverCache = _cache.GetOrAdd(serverId, _ => new ConcurrentDictionary>()); var collectorCache = serverCache.GetOrAdd(collectorName, _ => new ConcurrentDictionary()); @@ -86,12 +86,11 @@ public long CalculateDelta(int serverId, string collectorName, string key, long collectorCache.AddOrUpdate( key, - /* Add: first time seeing this key. - baselineOnly = true: store baseline only, return 0 (for cumulative counters like perfmon). - baselineOnly = false: use current value as delta so single-execution queries surface. */ + /* Add: first time seeing this key — store the baseline only and return 0. + All callers track cumulative counters (perfmon, wait stats, file IO, etc.). */ _ => { - delta = baselineOnly ? 0 : currentValue; + delta = 0; return (currentValue, collectionTime); }, /* Update: compute delta atomically */ diff --git a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs index e4279b306..d9a2c8765 100644 --- a/Lite/Services/LocalDataService.FinOps.ServerProperties.cs +++ b/Lite/Services/LocalDataService.FinOps.ServerProperties.cs @@ -235,159 +235,4 @@ LEFT JOIN storage_totals st ON true return (null, null, null, null); } - - /// - /// Gets the latest server properties snapshot per server (cross-server) from DuckDB. - /// Fallback for when live query is not available. - /// - public async Task> GetServerPropertiesLatestAsync(IEnumerable? activeServerIds = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var cpuCutoff = DateTime.UtcNow.AddHours(-24); - var idleCutoff = DateTime.UtcNow.AddDays(-7); - var recentCutoff = DateTime.UtcNow.AddHours(-24); - - // Build server ID filter — integers only, safe to inline - var serverFilter = ""; - if (activeServerIds != null) - { - var idList = string.Join(",", activeServerIds); - if (!string.IsNullOrEmpty(idList)) - serverFilter = $"AND server_id IN ({idList})"; - } - - command.CommandText = $@" -WITH active_servers AS ( - SELECT DISTINCT server_id, server_name - FROM v_cpu_utilization_stats - WHERE collection_time >= $3 - {serverFilter} -), -latest_props AS ( - SELECT * - FROM v_server_properties - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_server_properties - GROUP BY server_id - ) -), -cpu_24h AS ( - SELECT - server_id, - AVG(CAST(sqlserver_cpu_utilization AS DECIMAL(5,2))) AS avg_cpu_pct, - MAX(sqlserver_cpu_utilization) AS max_cpu_pct, - PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY sqlserver_cpu_utilization) AS p95_cpu_pct - FROM v_cpu_utilization_stats - WHERE collection_time >= $1 - GROUP BY server_id -), -mem_latest AS ( - SELECT - server_id, - CAST(total_server_memory_mb AS DECIMAL(10,2)) / NULLIF(target_server_memory_mb, 0) AS memory_ratio - FROM v_memory_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_memory_stats - GROUP BY server_id - ) -), -storage_totals AS ( - SELECT - server_id, - SUM(total_size_mb) / 1024.0 AS total_storage_gb - FROM v_database_size_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_database_size_stats - GROUP BY server_id - ) - GROUP BY server_id -), -idle_dbs AS ( - SELECT - server_id, - COUNT(DISTINCT database_name) AS idle_db_count - FROM ( - SELECT server_id, database_name - FROM v_database_size_stats - WHERE (server_id, collection_time) IN ( - SELECT server_id, MAX(collection_time) - FROM v_database_size_stats - GROUP BY server_id - ) - AND database_name NOT IN ('master', 'model', 'msdb', 'tempdb', 'PerformanceMonitor') - EXCEPT - SELECT DISTINCT server_id, database_name - FROM v_query_stats - WHERE collection_time >= $2 - AND delta_execution_count > 0 - ) AS idle - GROUP BY server_id -) -SELECT - a.server_name, - sp.edition, - sp.product_version, - sp.product_level, - sp.product_update_level, - sp.engine_edition, - COALESCE(sp.vcore_count, sp.cpu_count) AS cpu_count, - sp.physical_memory_mb, - sp.socket_count, - sp.cores_per_socket, - sp.is_hadr_enabled, - sp.is_clustered, - c.avg_cpu_pct, - st.total_storage_gb, - id.idle_db_count, - CASE - WHEN c.avg_cpu_pct < 15 AND c.max_cpu_pct < 40 AND COALESCE(m.memory_ratio, 0) < 0.5 - THEN 'OVER_PROVISIONED' - WHEN c.p95_cpu_pct > 85 OR COALESCE(m.memory_ratio, 0) > 0.95 - THEN 'UNDER_PROVISIONED' - ELSE 'RIGHT_SIZED' - END AS provisioning_status -FROM active_servers a -LEFT JOIN latest_props sp ON sp.server_id = a.server_id -LEFT JOIN cpu_24h c ON c.server_id = a.server_id -LEFT JOIN mem_latest m ON m.server_id = a.server_id -LEFT JOIN storage_totals st ON st.server_id = a.server_id -LEFT JOIN idle_dbs id ON id.server_id = a.server_id -ORDER BY a.server_name"; - - command.Parameters.Add(new DuckDBParameter { Value = cpuCutoff }); - command.Parameters.Add(new DuckDBParameter { Value = idleCutoff }); - command.Parameters.Add(new DuckDBParameter { Value = recentCutoff }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new ServerPropertyRow - { - ServerName = reader.IsDBNull(0) ? "" : reader.GetString(0), - Edition = reader.IsDBNull(1) ? "" : reader.GetString(1), - ProductVersion = reader.IsDBNull(2) ? "" : reader.GetString(2), - ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3), - ProductUpdateLevel = reader.IsDBNull(4) ? null : reader.GetString(4), - EngineEdition = reader.IsDBNull(5) ? 0 : Convert.ToInt32(reader.GetValue(5)), - CpuCount = reader.IsDBNull(6) ? 0 : Convert.ToInt32(reader.GetValue(6)), - PhysicalMemoryMb = reader.IsDBNull(7) ? 0L : ToInt64(reader.GetValue(7)), - SocketCount = reader.IsDBNull(8) ? null : Convert.ToInt32(reader.GetValue(8)), - CoresPerSocket = reader.IsDBNull(9) ? null : Convert.ToInt32(reader.GetValue(9)), - IsHadrEnabled = reader.IsDBNull(10) ? null : reader.GetBoolean(10), - IsClustered = reader.IsDBNull(11) ? null : reader.GetBoolean(11), - AvgCpuPct = reader.IsDBNull(12) ? null : Convert.ToDecimal(reader.GetValue(12)), - StorageTotalGb = reader.IsDBNull(13) ? null : Convert.ToDecimal(reader.GetValue(13)), - IdleDbCount = reader.IsDBNull(14) ? null : Convert.ToInt32(reader.GetValue(14)), - ProvisioningStatus = reader.IsDBNull(15) ? null : reader.GetString(15) - }); - } - - return items; - } } diff --git a/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs b/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs index 88bad70c6..a9266c9e2 100644 --- a/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs +++ b/Lite/Services/LocalDataService.FinOps.StorageGrowth.cs @@ -15,45 +15,6 @@ namespace PerformanceMonitorLite.Services; public partial class LocalDataService { - /// - /// Gets database size trend (total_size_mb per database per collection) for a specific server. - /// - public async Task> GetDatabaseSizeTrendAsync(int serverId, int daysBack = 30) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var cutoff = DateTime.UtcNow.AddDays(-daysBack); - - command.CommandText = @" -SELECT - collection_time, - database_name, - SUM(total_size_mb) AS total_size_mb -FROM v_database_size_stats -WHERE server_id = $1 -AND collection_time >= $2 -GROUP BY collection_time, database_name -ORDER BY collection_time, database_name"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = cutoff }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new DatabaseSizeTrendPoint - { - CollectionTime = reader.GetDateTime(0), - DatabaseName = reader.IsDBNull(1) ? "" : reader.GetString(1), - TotalSizeMb = reader.IsDBNull(2) ? 0m : Convert.ToDecimal(reader.GetValue(2)) - }); - } - - return items; - } - /// /// Gets per-database storage growth trends comparing current size to 7d and 30d ago. /// diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index b1031f2e2..c966dfcea 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -221,13 +221,6 @@ public string? LicenseWarning public string HealthScoreColor => FinOpsHealthCalculator.ScoreColor(HealthScore); } -public class DatabaseSizeTrendPoint -{ - public DateTime CollectionTime { get; set; } - public string DatabaseName { get; set; } = ""; - public decimal TotalSizeMb { get; set; } -} - public class StorageGrowthRow { public string DatabaseName { get; set; } = ""; diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index fb9275cd1..17362fcfc 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -142,48 +142,8 @@ GROUP BY clerk_type } /// - /// Gets memory clerk trend data for a single clerk type for charting. - /// - public async Task> GetMemoryClerkTrendAsync(int serverId, string clerkType, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - - var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); - - command.CommandText = @" -SELECT - collection_time, - memory_mb -FROM v_memory_clerks -WHERE server_id = $1 -AND clerk_type = $2 -AND collection_time >= $3 -AND collection_time <= $4 -ORDER BY collection_time"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = clerkType }); - command.Parameters.Add(new DuckDBParameter { Value = startTime }); - command.Parameters.Add(new DuckDBParameter { Value = endTime }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(new MemoryClerkTrendPoint - { - CollectionTime = reader.GetDateTime(0), - MemoryMb = reader.IsDBNull(1) ? 0 : ToDouble(reader.GetValue(1)) - }); - } - - return items; - } - - /// - /// Batched sibling of : fetches the trend for ALL selected - /// clerk types in ONE query (replacing an N+1 query-per-clerk loop), grouped by clerk type. + /// Fetches the memory clerk trend for ALL selected clerk types in ONE query + /// (replacing an N+1 query-per-clerk loop), grouped by clerk type. /// public async Task>> GetMemoryClerkTrendsByTypesAsync(int serverId, List clerkTypes, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) { diff --git a/Lite/Services/LocalDataService.QueryStore.cs b/Lite/Services/LocalDataService.QueryStore.cs index ab360f4bc..70e62fc55 100644 --- a/Lite/Services/LocalDataService.QueryStore.cs +++ b/Lite/Services/LocalDataService.QueryStore.cs @@ -384,32 +384,6 @@ FROM v_query_store_stats return items; } - /// - /// Gets distinct databases that have Query Store data collected. - /// - public async Task> GetQueryStoreDatabasesAsync(int serverId, int hoursBack = 24) - { - using var connection = await OpenConnectionAsync(); - using var command = connection.CreateCommand(); - command.CommandText = @" -SELECT DISTINCT database_name -FROM v_query_store_stats -WHERE server_id = $1 -AND collection_time >= $2 -ORDER BY database_name"; - - command.Parameters.Add(new DuckDBParameter { Value = serverId }); - command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) }); - - var items = new List(); - using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - items.Add(reader.GetString(0)); - } - return items; - } - /// /// Fetches a query plan on-demand from Query Store by plan_id. /// Uses three-part naming with sp_executesql for Azure SQL DB compatibility. diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 7556a9f47..6e9fd7855 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -628,7 +628,6 @@ public class WaitStatsRow public string SignalWaitTimeFormatted => FormatMs(TotalSignalWaitTimeMs); public string ResourceWaitTimeFormatted => FormatMs(ResourceWaitTimeMs); public double SignalWaitPercent => TotalWaitTimeMs > 0 ? (double)TotalSignalWaitTimeMs / TotalWaitTimeMs * 100 : 0; - public bool IsHighWait => TotalWaitTimeMs > 60000; private static string FormatMs(long ms) { diff --git a/Lite/Services/RemoteCollectorService.FileIo.cs b/Lite/Services/RemoteCollectorService.FileIo.cs index a1ac8a8e6..29d4a04e2 100644 --- a/Lite/Services/RemoteCollectorService.FileIo.cs +++ b/Lite/Services/RemoteCollectorService.FileIo.cs @@ -150,14 +150,14 @@ AND vfs.database_id < 32761 foreach (var stat in fileStats) { var deltaKey = $"{stat.DatabaseName}|{stat.FileName}"; - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaReads = _deltaCalculator.CalculateDelta(serverId, "file_io_reads", deltaKey, stat.NumOfReads, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "file_io_writes", deltaKey, stat.NumOfWrites, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaReadBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_read_bytes", deltaKey, stat.ReadBytes, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWriteBytes = _deltaCalculator.CalculateDelta(serverId, "file_io_write_bytes", deltaKey, stat.WriteBytes, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaStallReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_read", deltaKey, stat.IoStallReadMs, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaStallWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_write", deltaKey, stat.IoStallWriteMs, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaStallQueuedReadMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_read", deltaKey, stat.IoStallQueuedReadMs, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaStallQueuedWriteMs = _deltaCalculator.CalculateDelta(serverId, "file_io_stall_queued_write", deltaKey, stat.IoStallQueuedWriteMs, collectionTime: collectionTime, maxGapSeconds: 300); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) diff --git a/Lite/Services/RemoteCollectorService.MemoryGrants.cs b/Lite/Services/RemoteCollectorService.MemoryGrants.cs index e2bf31d49..00e593b45 100644 --- a/Lite/Services/RemoteCollectorService.MemoryGrants.cs +++ b/Lite/Services/RemoteCollectorService.MemoryGrants.cs @@ -92,8 +92,8 @@ WHERE deqrs.max_target_memory_kb IS NOT NULL foreach (var r in rows) { var deltaKey = $"{r.PoolId}_{r.ResourceSemaphoreId}"; - var deltaTimeouts = _deltaCalculator.CalculateDelta(serverId, "memory_grants_timeouts", deltaKey, r.TimeoutErrorCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaTimeouts = _deltaCalculator.CalculateDelta(serverId, "memory_grants_timeouts", deltaKey, r.TimeoutErrorCount, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaForced = _deltaCalculator.CalculateDelta(serverId, "memory_grants_forced", deltaKey, r.ForcedGrantCount, collectionTime: collectionTime, maxGapSeconds: 300); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) diff --git a/Lite/Services/RemoteCollectorService.Perfmon.cs b/Lite/Services/RemoteCollectorService.Perfmon.cs index 145b77bd2..65ec1b370 100644 --- a/Lite/Services/RemoteCollectorService.Perfmon.cs +++ b/Lite/Services/RemoteCollectorService.Perfmon.cs @@ -182,7 +182,7 @@ WHERE pc.counter_name IN ( prevents inflated deltas after app restarts */ var deltaKey = $"{objectName}|{counterName}|{instanceName}"; var deltaCntrValue = _deltaCalculator.CalculateDelta(serverId, "perfmon", deltaKey, cntrValue, - baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + collectionTime: collectionTime, maxGapSeconds: 300); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) diff --git a/Lite/Services/RemoteCollectorService.ProcedureStats.cs b/Lite/Services/RemoteCollectorService.ProcedureStats.cs index 3bd37c31a..8c5d5bd49 100644 --- a/Lite/Services/RemoteCollectorService.ProcedureStats.cs +++ b/Lite/Services/RemoteCollectorService.ProcedureStats.cs @@ -291,12 +291,12 @@ exclusion filter is interpolated as literal N'...' values rather than parameter /* Delta key: plan_handle to prevent cross-contamination when multiple plans exist for the same object */ var deltaKey = planHandle ?? $"{dbName}.{schemaName}.{objectName}"; - var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaExec = _deltaCalculator.CalculateDelta(serverId, "proc_stats_exec", deltaKey, execCount, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWorker = _deltaCalculator.CalculateDelta(serverId, "proc_stats_worker", deltaKey, workerTime, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaElapsed = _deltaCalculator.CalculateDelta(serverId, "proc_stats_elapsed", deltaKey, elapsedTime, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_reads", deltaKey, logicalReads, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWrites = _deltaCalculator.CalculateDelta(serverId, "proc_stats_writes", deltaKey, logicalWrites, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaPhysReads = _deltaCalculator.CalculateDelta(serverId, "proc_stats_phys_reads", deltaKey, physicalReads, collectionTime: collectionTime, maxGapSeconds: 300); /* Appender column order must match DuckDB table definition exactly */ var row = appender.CreateRow(); diff --git a/Lite/Services/RemoteCollectorService.QueryStats.cs b/Lite/Services/RemoteCollectorService.QueryStats.cs index 4a139c3d8..95c36a705 100644 --- a/Lite/Services/RemoteCollectorService.QueryStats.cs +++ b/Lite/Services/RemoteCollectorService.QueryStats.cs @@ -275,14 +275,14 @@ qs.total_elapsed_time DESC /* Delta calculations keyed by plan_handle to prevent cross-contamination when multiple plans exist for the same query_hash */ var deltaKey = planHandle ?? queryHash; - var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", deltaKey, executionCount, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaExecCount = _deltaCalculator.CalculateDelta(serverId, "query_stats_exec", deltaKey, executionCount, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWorkerTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_worker", deltaKey, totalWorkerTime, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaElapsedTime = _deltaCalculator.CalculateDelta(serverId, "query_stats_elapsed", deltaKey, totalElapsedTime, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaLogicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_reads", deltaKey, totalLogicalReads, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaLogicalWrites = _deltaCalculator.CalculateDelta(serverId, "query_stats_writes", deltaKey, totalLogicalWrites, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaPhysicalReads = _deltaCalculator.CalculateDelta(serverId, "query_stats_phys_reads", deltaKey, totalPhysicalReads, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaRows = _deltaCalculator.CalculateDelta(serverId, "query_stats_rows", deltaKey, totalRows, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaSpills = _deltaCalculator.CalculateDelta(serverId, "query_stats_spills", deltaKey, totalSpills, collectionTime: collectionTime, maxGapSeconds: 300); /* Appender column order must match DuckDB table definition exactly */ var row = appender.CreateRow(); diff --git a/Lite/Services/RemoteCollectorService.ServerProperties.cs b/Lite/Services/RemoteCollectorService.ServerProperties.cs index 78bf195e9..71c136689 100644 --- a/Lite/Services/RemoteCollectorService.ServerProperties.cs +++ b/Lite/Services/RemoteCollectorService.ServerProperties.cs @@ -23,11 +23,6 @@ public partial class RemoteCollectorService /// Collects server edition, version, CPU/memory hardware metadata for /// license audit and FinOps cost attribution. On-load only collector. /// - // RAM floor below which LPIM-off is not worth flagging (shared rule with the Dashboard - // SqlServerFactCollector). On a small buffer pool the OS paging SQL out is not the practical - // risk it is on a large dedicated host. - private const long LpimAdvisoryMinPhysicalMemoryMb = 32 * 1024; - private async Task CollectServerPropertiesAsync(ServerConnection server, CancellationToken cancellationToken) { var serverStatus = _serverManager.GetConnectionStatus(server.Id); diff --git a/Lite/Services/RemoteCollectorService.WaitStats.cs b/Lite/Services/RemoteCollectorService.WaitStats.cs index 0e03def4c..edee6915f 100644 --- a/Lite/Services/RemoteCollectorService.WaitStats.cs +++ b/Lite/Services/RemoteCollectorService.WaitStats.cs @@ -105,9 +105,9 @@ WHERE ws.wait_time_ms > 0 foreach (var stat in waitStats) { var deltaKey = stat.WaitType; - var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); - var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs, baselineOnly: true, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWaitingTasks = _deltaCalculator.CalculateDelta(serverId, "wait_stats_tasks", deltaKey, stat.WaitingTasks, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_time", deltaKey, stat.WaitTimeMs, collectionTime: collectionTime, maxGapSeconds: 300); + var deltaSignalWaitTimeMs = _deltaCalculator.CalculateDelta(serverId, "wait_stats_signal", deltaKey, stat.SignalWaitTimeMs, collectionTime: collectionTime, maxGapSeconds: 300); var row = appender.CreateRow(); row.AppendValue(GenerateCollectionId()) /* collection_id BIGINT */ diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 124a8f604..80ddaad03 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -35,11 +35,8 @@ public class CollectorHealthEntry public int ServerId { get; set; } public string CollectorName { get; set; } = ""; public DateTime? LastSuccessTime { get; set; } - public DateTime? LastErrorTime { get; set; } public string? LastErrorMessage { get; set; } public int ConsecutiveErrors { get; set; } - public int TotalErrors { get; set; } - public int TotalSuccesses { get; set; } /* * Set when a collector hits a non-transient permission error @@ -148,7 +145,6 @@ public partial class RemoteCollectorService /// Tracks consecutive failures of the collection_log INSERT itself. /// private int _logInsertFailures; - private string? _lastLogInsertError; /// /// Per-server flag indicating that master DB enumeration has failed with an access-denied @@ -178,11 +174,6 @@ public RemoteCollectorService( /// public Task SeedDeltaCacheAsync() => _deltaCalculator.SeedFromDatabaseAsync(_duckDb); - /// - /// Runs a manual DuckDB WAL checkpoint during idle time between collection cycles. - /// - public Task CheckpointAsync() => _duckDb.CheckpointAsync(); - /// /// Gets a summary of collector health for a specific server connection. /// @@ -275,7 +266,6 @@ internal void RecordCollectorResult(int serverId, string collectorName, string s { entry.LastSuccessTime = DateTime.UtcNow; entry.ConsecutiveErrors = 0; - entry.TotalSuccesses++; } else if (status == "PERMISSIONS") { @@ -284,16 +274,13 @@ internal void RecordCollectorResult(int serverId, string collectorName, string s Record the error message so the user can see what's wrong, and flag the collector so the scheduler stops retrying for the rest of the app session. */ - entry.LastErrorTime = DateTime.UtcNow; entry.LastErrorMessage = errorMessage; entry.IsPermissionRestricted = true; } else { - entry.LastErrorTime = DateTime.UtcNow; entry.LastErrorMessage = errorMessage; entry.ConsecutiveErrors++; - entry.TotalErrors++; } } } @@ -655,13 +642,11 @@ INSERT INTO collection_log (log_id, server_id, server_name, collector_name, coll { AppLogger.Info("Collector", $"Collection logging recovered after {_logInsertFailures} failure(s)"); _logInsertFailures = 0; - _lastLogInsertError = null; } } catch (Exception ex) { _logInsertFailures++; - _lastLogInsertError = ex.Message; if (_logInsertFailures <= 3) { @@ -974,32 +959,6 @@ protected static int GetServerId(ServerConnection server) return null; } - /// - /// Safely converts a SQL Server float/real value to decimal. - /// Returns 0 for Infinity, NaN, or values outside decimal range. - /// - protected static decimal SafeToDecimal(object value) - { - try - { - if (value is double d) - { - if (double.IsInfinity(d) || double.IsNaN(d)) - return 0m; - } - else if (value is float f) - { - if (float.IsInfinity(f) || float.IsNaN(f)) - return 0m; - } - return Convert.ToDecimal(value); - } - catch (OverflowException) - { - return 0m; - } - } - /// /// Deterministic hash code for a string. Forwards to the shared /// so Lite, diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs index 67f96b29d..29f8b920a 100644 --- a/Lite/Services/ScheduleManager.cs +++ b/Lite/Services/ScheduleManager.cs @@ -29,8 +29,6 @@ public class ScheduleManager DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - public static readonly string[] PresetNames = ["Low-Impact", "Balanced", "Aggressive"]; - private static readonly Dictionary> s_presets = new(StringComparer.OrdinalIgnoreCase) { ["Aggressive"] = new(StringComparer.OrdinalIgnoreCase) @@ -92,91 +90,9 @@ public ScheduleManager(string configDirectory, ILogger? logger } // ────────────────────────────────────────────────────────────────── - // Existing public API — operates on the default schedule. - // These methods are unchanged from v1 so existing callers keep working. + // Default-schedule public API. // ────────────────────────────────────────────────────────────────── - /// - /// Gets all configured collector schedules (default schedule). - /// - public IReadOnlyList GetAllSchedules() - { - lock (_lock) - { - return _defaultSchedule.ToList(); - } - } - - /// - /// Gets only enabled and scheduled collectors (default schedule). - /// - public IReadOnlyList GetEnabledSchedules() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.Enabled && s.IsScheduled).ToList(); - } - } - - /// - /// Gets collectors that are due to run (default schedule, global run state). - /// - public IReadOnlyList GetDueCollectors() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.IsDue).ToList(); - } - } - - /// - /// Gets on-load only collectors (frequency = 0) from the default schedule. - /// - public IReadOnlyList GetOnLoadCollectors() - { - lock (_lock) - { - return _defaultSchedule.Where(s => s.Enabled && !s.IsScheduled).ToList(); - } - } - - /// - /// Gets a specific collector schedule by name (default schedule). - /// - public CollectorSchedule? GetSchedule(string collectorName) - { - lock (_lock) - { - return _defaultSchedule.FirstOrDefault(s => - s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); - } - } - - /// - /// Marks a collector as having been run (default schedule, global run state). - /// - public void MarkCollectorRun(string collectorName, DateTime runTime) - { - lock (_lock) - { - var schedule = _defaultSchedule.FirstOrDefault(s => - s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); - - if (schedule != null) - { - schedule.LastRunTime = runTime; - - if (schedule.IsScheduled) - { - schedule.NextRunTime = runTime.AddMinutes(schedule.FrequencyMinutes); - } - - _logger?.LogDebug("Marked collector '{Name}' as run at {Time}, next run at {NextTime}", - collectorName, runTime, schedule.NextRunTime); - } - } - } - /// /// Updates a collector's schedule settings (default schedule). /// @@ -225,26 +141,6 @@ public string GetActivePreset() } } - /// - /// Applies a named preset to the default schedule. - /// Does not modify enabled/disabled state or on-load (frequency=0) collectors. - /// - public void ApplyPreset(string presetName) - { - if (!s_presets.TryGetValue(presetName, out var intervals)) - { - throw new ArgumentException($"Unknown preset: {presetName}"); - } - - lock (_lock) - { - ApplyPresetToList(_defaultSchedule, intervals); - SaveSchedules(); - - _logger?.LogInformation("Applied collection preset '{Preset}' to default schedule", presetName); - } - } - // ────────────────────────────────────────────────────────────────── // New per-server API (v2) // ────────────────────────────────────────────────────────────────── @@ -405,40 +301,6 @@ public bool HasServerOverride(string serverId) } } - /// - /// Applies a preset to a single server's schedule. - /// Creates an override if one doesn't exist (copies default first). - /// - public void ApplyPresetForServer(string serverId, string presetName) - { - if (!s_presets.TryGetValue(presetName, out var intervals)) - { - throw new ArgumentException($"Unknown preset: {presetName}"); - } - - lock (_lock) - { - if (!_serverOverrides.TryGetValue(serverId, out var over)) - { - over = new ServerScheduleOverride { Collectors = CloneScheduleList(_defaultSchedule) }; - _serverOverrides[serverId] = over; - } - - ApplyPresetToList(over.Collectors, intervals); - SaveSchedules(); - - _logger?.LogInformation("Applied preset '{Preset}' to server {ServerId}", presetName, serverId); - } - } - - /// - /// Applies a preset to the default schedule (alias for ApplyPreset). - /// - public void ApplyPresetToDefault(string presetName) - { - ApplyPreset(presetName); - } - /// /// Detects which preset matches a server's active schedule. /// @@ -454,31 +316,6 @@ public string GetActivePresetForServer(string serverId) } } - /// - /// Removes orphaned overrides for servers that no longer exist. - /// - public void CleanupRemovedServers(IEnumerable activeServerIds) - { - lock (_lock) - { - var activeSet = new HashSet(activeServerIds); - var orphaned = _serverOverrides.Keys.Where(id => !activeSet.Contains(id)).ToList(); - - if (orphaned.Count == 0) - return; - - foreach (var id in orphaned) - { - _serverOverrides.Remove(id); - _serverRunState.Remove(id); - } - - SaveSchedules(); - - _logger?.LogInformation("Cleaned up {Count} orphaned server schedule override(s)", orphaned.Count); - } - } - // ────────────────────────────────────────────────────────────────── // Persistence // ────────────────────────────────────────────────────────────────── @@ -690,22 +527,6 @@ private static string DetectPreset(List schedules) return "Custom"; } - /// - /// Applies preset intervals to a collector list. Does not touch enabled/disabled or on-load collectors. - /// - private static void ApplyPresetToList(List schedules, Dictionary intervals) - { - foreach (var (collector, freq) in intervals) - { - var schedule = schedules.FirstOrDefault(s => - s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); - if (schedule != null) - { - schedule.FrequencyMinutes = freq; - } - } - } - /// /// Deep-clones a schedule list (for creating overrides from defaults). /// diff --git a/Lite/Services/ServerManager.cs b/Lite/Services/ServerManager.cs index 08239b849..19b7c259a 100644 --- a/Lite/Services/ServerManager.cs +++ b/Lite/Services/ServerManager.cs @@ -525,10 +525,6 @@ private void LoadServers() try { File.Copy(_configFilePath, _configFilePath + ".bak", overwrite: true); } catch { /* best effort */ } - // MIGRATION: Backward compatibility for existing servers.json files - // Migration from old UseWindowsAuth property happens automatically during deserialization - MigrateServerAuthentication(_servers); - // Initialize status tracking for all loaded servers foreach (var server in _servers) { @@ -551,9 +547,6 @@ private void LoadServers() var bakConfig = JsonSerializer.Deserialize(bakJson); _servers = bakConfig?.Servers ?? new List(); - // MIGRATION: Backward compatibility - MigrateServerAuthentication(_servers); - foreach (var server in _servers) { _connectionStatuses[server.Id] = new ServerConnectionStatus { ServerId = server.Id }; @@ -644,22 +637,6 @@ private void SaveServers() } } - /// - /// Migrates server authentication configuration for backward compatibility. - /// Note: Migration from old UseWindowsAuth property happens automatically via - /// the UseWindowsAuth setter during JSON deserialization. - /// - private void MigrateServerAuthentication(List servers) - { - // Migration is now automatic via UseWindowsAuth property setter - // Just log the loaded servers for debugging - foreach (var server in servers) - { - _logger?.LogDebug("Server '{DisplayName}' loaded with AuthenticationType={AuthType}", - server.DisplayName, server.AuthenticationType); - } - } - /// /// JSON wrapper for servers list. /// diff --git a/Lite/Services/ServerTimeHelper.cs b/Lite/Services/ServerTimeHelper.cs index 6a4417e40..a94753851 100644 --- a/Lite/Services/ServerTimeHelper.cs +++ b/Lite/Services/ServerTimeHelper.cs @@ -32,7 +32,7 @@ public static int UtcOffsetMinutes /// Converts a local DateTime (from date picker) to server time. /// Use when the user picks dates in their local timezone but the database stores server time. /// - public static DateTime LocalToServerTime(DateTime localTime) + private static DateTime LocalToServerTime(DateTime localTime) { var utcTime = localTime.ToUniversalTime(); return utcTime.AddMinutes(_utcOffsetMinutes); @@ -42,7 +42,7 @@ public static DateTime LocalToServerTime(DateTime localTime) /// Converts a server DateTime to local time. /// Use this when displaying server timestamps to the user in the UI. /// - public static DateTime ToLocalTime(DateTime serverTime) + private static DateTime ToLocalTime(DateTime serverTime) { /* Convert server time to UTC, then to local */ var utcTime = serverTime.AddMinutes(-_utcOffsetMinutes); diff --git a/Lite/Windows/AboutWindow.xaml b/Lite/Windows/AboutWindow.xaml index d8b77ebf0..c50bb3fe5 100644 --- a/Lite/Windows/AboutWindow.xaml +++ b/Lite/Windows/AboutWindow.xaml @@ -37,12 +37,12 @@ - + GitHub Repository - + Report an Issue @@ -52,7 +52,7 @@ Foreground="{DynamicResource ForegroundMutedBrush}" Visibility="Collapsed"/> - + www.erikdarling.com diff --git a/Lite/Windows/AboutWindow.xaml.cs b/Lite/Windows/AboutWindow.xaml.cs index 793354df2..d8e0b27fb 100644 --- a/Lite/Windows/AboutWindow.xaml.cs +++ b/Lite/Windows/AboutWindow.xaml.cs @@ -20,7 +20,6 @@ public partial class AboutWindow : Window { private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceMonitor"; private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/issues"; - private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceMonitor/releases"; private const string DarlingDataUrl = "https://www.erikdarling.com"; private string? _updateReleaseUrl; diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml index ecaea7ec2..a94cf5f88 100644 --- a/Lite/Windows/AddServerDialog.xaml +++ b/Lite/Windows/AddServerDialog.xaml @@ -39,7 +39,7 @@ - -