diff --git a/CHANGELOG.md b/CHANGELOG.md index eee63d09..0dbc6413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.8.0] - TBD + +### Important + +- **New nonclustered indexes** on `collect.query_stats`, `collect.procedure_stats`, and `collect.query_store_data` to eliminate Eager Index Spools in Dashboard grid queries. On large installations these indexes may take several minutes to build; the upgrade script uses `ONLINE = ON` on Enterprise/Developer/Azure editions and falls back to offline on Standard/Web ([#835]) + +### Added + +- **Memory Pressure Events in Lite** — the collector, chart, and `get_memory_pressure_events` MCP tool previously only in the Full Edition are now available in Lite ([#865]) +- **Grid auto-scrolling** in Lite and Dashboard ([#843]) — thanks [@ClaudioESSilva](https://github.com/ClaudioESSilva) + +### Changed + +- **PlanAnalyzer and BenefitScorer** synced with PerformanceStudio's Apr 9–16 improvements +- **Query/Procedure/Query Store stats** refactored to a phased DECOMPRESS approach; removed unhelpful `WAITFOR DECOMPRESS` filters +- **Query/Procedure/Query Store grids** capped to TOP 500 to prevent UI freezes on large datasets +- **Server tabs lazy-load** — only the visible server tab loads on startup; remaining tabs load on first visit +- **Webhook URLs (Dashboard)** encrypted with DPAPI via Windows Credential Manager — Lite webhook URLs remain in plaintext settings for now +- **DuckDB queries hardened** — parameterized values, escaped paths, fixed `IsArchiving` race +- **Lite chart axes and sub-tab styling** polished, then ported to Dashboard + +### Fixed + +- **Memory Pressure Events chart filter** was dropping valid rows; added MCP interpretation guidance ([#865]) +- **FinOps recommendation severity sort order** in Lite and Dashboard ([#872]) +- **Overview crosshair** disappearing after tab switches or layout passes +- **Blocked process report plan lookup** returning the wrong plan ([#867]) +- **FinOps TDE recommendation** flagging Standard edition on SQL Server 2019+ where TDE is free ([#854]) +- **Azure SQL DB collector** falls back to single-database mode when `master` is inaccessible ([#857]) +- **Azure SQL DB query snapshots** scoped to the current database ([#857]) +- **Azure SQL DB query snapshot prefilter** — request set is narrowed into `#temp` before joining DMVs to avoid Azure-specific execution plan issues ([#857]) +- **Azure SQL DB live query plans** — now skipped gracefully instead of erroring ([#857]) +- **Azure SQL DB memory_stats collector** — dropped `sys.dm_os_schedulers` which is blocked on elastic-pool contained users regardless of DB-scoped grants ([#857]) +- **Non-transient permission denials** now stop collector retries instead of looping forever ([#857]) + +[#835]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/835 +[#843]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/843 +[#854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/854 +[#857]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/857 +[#865]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/865 +[#867]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/867 +[#872]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/872 + ## [2.7.0] - 2026-04-13 ### Added diff --git a/Dashboard/Analysis/SqlServerBaselineProvider.cs b/Dashboard/Analysis/SqlServerBaselineProvider.cs index 1746028c..3d65ee20 100644 --- a/Dashboard/Analysis/SqlServerBaselineProvider.cs +++ b/Dashboard/Analysis/SqlServerBaselineProvider.cs @@ -463,7 +463,7 @@ private static double PoolVariance(List buckets, double grandMea return totalSumSq / (totalSamples - 1); } - private class CachedBaseline + private sealed class CachedBaseline { public DateTime ComputedAt { get; init; } public DateTime RealTime { get; init; } diff --git a/Dashboard/Controls/ConfigChangesContent.xaml b/Dashboard/Controls/ConfigChangesContent.xaml index a8e1f2d4..4f5d5e7d 100644 --- a/Dashboard/Controls/ConfigChangesContent.xaml +++ b/Dashboard/Controls/ConfigChangesContent.xaml @@ -15,7 +15,7 @@ - + diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 8fe1e734..7beb4696 100644 --- a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -31,7 +31,12 @@ public partial class CorrelatedTimelineLanesControl : UserControl public CorrelatedTimelineLanesControl() { InitializeComponent(); - Unloaded += (_, _) => _crosshairManager?.Dispose(); + /* No Unloaded → Dispose() handler: WPF fires Unloaded for transient + reasons (tab virtualization, layout rebuilds) and Dispose() clears + the crosshair manager's lane list, permanently breaking the crosshair + until the ServerTab is rebuilt. The manager holds only managed state + (a Popup + lane references) — letting GC clean it up with the control + is fine. */ } /// @@ -69,6 +74,9 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.PrepareForRefresh(); + try + { + var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); @@ -225,8 +233,18 @@ public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDa _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); } + /* VLines must be re-attached before SyncXAxes so they're part of + the render set when the chart refreshes. */ _crosshairManager?.ReattachVLines(); SyncXAxes(hoursBack, fromDate, toDate); + } + finally + { + /* Safety net: if something threw between PrepareForRefresh() and the + ReattachVLines() call above, VLines are still null. EnsureVLinesAttached + creates them only for lanes where VLine is null, so it's idempotent. */ + _crosshairManager?.EnsureVLinesAttached(); + } } /// @@ -320,7 +338,7 @@ private void UpdateBlockingLane(List<(double Time, double Value)> blockingData, } } - BlockingChart.Plot.Axes.DateTimeTicksBottom(); + BlockingChart.Plot.Axes.DateTimeTicksBottomDateChange(); BlockingChart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; TabHelpers.ReapplyAxisColors(BlockingChart); @@ -394,7 +412,7 @@ private void UpdateLane(ScottPlot.WPF.WpfPlot chart, string title, _crosshairManager?.SetLaneData(chart, times, values); - chart.Plot.Axes.DateTimeTicksBottom(); + chart.Plot.Axes.DateTimeTicksBottomDateChange(); if (chart != FileIoChart) chart.Plot.Axes.Bottom.TickLabelStyle.IsVisible = false; diff --git a/Dashboard/Controls/CurrentConfigContent.xaml b/Dashboard/Controls/CurrentConfigContent.xaml index ab36dbdf..2cb4fa54 100644 --- a/Dashboard/Controls/CurrentConfigContent.xaml +++ b/Dashboard/Controls/CurrentConfigContent.xaml @@ -15,7 +15,7 @@ - + diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index bfea5d36..43573d47 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -44,7 +44,7 @@ SelectionChanged="ServerSelector_SelectionChanged"/> - + @@ -90,7 +90,7 @@ - + private static void FindMemoryConsumers(PlanNode node, List consumers) + { + // Collect all consumers first, then sort by row count descending + var raw = new List<(string Label, double Rows)>(); + FindMemoryConsumersRecursive(node, raw); + + foreach (var (label, _) in raw.OrderByDescending(c => c.Rows)) + consumers.Add(label); + } + + private static void FindMemoryConsumersRecursive(PlanNode node, List<(string Label, double Rows)> consumers) { if (node.PhysicalOp.Contains("Sort", StringComparison.OrdinalIgnoreCase) && !node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase)) { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; var rows = node.HasActualStats ? $"{node.ActualRows:N0} actual rows" : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add($"Sort (Node {node.NodeId}, {rows})"); + consumers.Add(($"Sort (Node {node.NodeId}, {rows})", rowCount)); } else if (node.PhysicalOp.Contains("Hash", StringComparison.OrdinalIgnoreCase)) { + var rowCount = node.HasActualStats ? node.ActualRows : node.EstimateRows; var rows = node.HasActualStats ? $"{node.ActualRows:N0} actual rows" : $"{node.EstimateRows:N0} estimated rows"; - consumers.Add($"Hash Match (Node {node.NodeId}, {rows})"); + consumers.Add(($"Hash Match (Node {node.NodeId}, {rows})", rowCount)); } foreach (var child in node.Children) - FindMemoryConsumers(child, consumers); + FindMemoryConsumersRecursive(child, consumers); } /// @@ -1298,7 +1385,7 @@ private static void FindMemoryConsumers(PlanNode node, List consumers) /// Exchange operators accumulate downstream wait time (e.g. from spilling /// children) so their self-time is unreliable — see sql.kiwi/2021/03. /// - private static long GetOperatorOwnElapsedMs(PlanNode node) + internal static long GetOperatorOwnElapsedMs(PlanNode node) { if (node.ActualExecutionMode == "Batch") return node.ActualElapsedMs; @@ -1536,6 +1623,37 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + /// + /// Returns a short label describing what a wait type means (e.g., "I/O — reading from disk"). + /// Public for use by UI components that annotate wait stats inline. + /// + public static string GetWaitLabel(string waitType) + { + var wt = waitType.ToUpperInvariant(); + return wt switch + { + _ when wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => "I/O — reading data from disk", + _ when wt.Contains("IO_COMPLETION", StringComparison.Ordinal) => "I/O — spills to TempDB or eager writes", + _ when wt == "SOS_SCHEDULER_YIELD" => "CPU — scheduler yielding", + _ when wt.StartsWith("CXPACKET", StringComparison.Ordinal) || wt.StartsWith("CXCONSUMER", StringComparison.Ordinal) => "parallelism — thread skew", + _ when wt.StartsWith("CXSYNC", StringComparison.Ordinal) => "parallelism — exchange synchronization", + _ when wt == "HTBUILD" => "hash — building hash table", + _ when wt == "HTDELETE" => "hash — cleaning up hash table", + _ when wt == "HTREPARTITION" => "hash — repartitioning", + _ when wt.StartsWith("HT", StringComparison.Ordinal) => "hash operation", + _ when wt == "BPSORT" => "batch sort", + _ when wt == "BMPBUILD" => "bitmap filter build", + _ when wt.Contains("MEMORY_ALLOCATION_EXT", StringComparison.Ordinal) => "memory allocation", + _ when wt.StartsWith("PAGELATCH", StringComparison.Ordinal) => "page latch — in-memory contention", + _ when wt.StartsWith("LATCH_", StringComparison.Ordinal) => "latch contention", + _ when wt.StartsWith("LCK_", StringComparison.Ordinal) => "lock contention", + _ when wt == "LOGBUFFER" => "transaction log writes", + _ when wt == "ASYNC_NETWORK_IO" => "network — client not consuming results", + _ when wt == "SOS_PHYS_PAGE_CACHE" => "physical page cache contention", + _ => "" + }; + } + /// /// Returns targeted advice based on statement-level wait stats, or null if no waits. /// When the dominant wait type is clear, gives specific guidance instead of generic advice. @@ -1552,29 +1670,150 @@ private static string Truncate(string value, int maxLength) var top = waits.OrderByDescending(w => w.WaitTimeMs).First(); var topPct = (double)top.WaitTimeMs / totalMs * 100; - // Only give targeted advice if the dominant wait is >= 80% of total wait time - if (topPct < 80) - return null; + // Single dominant wait — give targeted advice + if (topPct >= 80) + return DescribeWaitType(top.WaitType, topPct); + + // Multiple waits — summarize the top contributors instead of guessing + var topWaits = waits.OrderByDescending(w => w.WaitTimeMs).Take(3) + .Select(w => $"{w.WaitType} ({(double)w.WaitTimeMs / totalMs * 100:N0}%)") + .ToList(); + return $"Top waits: {string.Join(", ", topWaits)}."; + } - var waitType = top.WaitType.ToUpperInvariant(); - var advice = waitType switch + /// + /// Maps a wait type to a human-readable description with percentage context. + /// Covers all wait types observed in real execution plan files. + /// + private static string DescribeWaitType(string rawWaitType, double topPct) + { + var waitType = rawWaitType.ToUpperInvariant(); + return waitType switch { + // I/O: reading/writing data pages from disk _ when waitType.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) => - $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.", + $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Data is being read from disk rather than memory. Consider adding indexes to reduce I/O, or investigate memory pressure.", + _ when waitType.Contains("IO_COMPLETION", StringComparison.Ordinal) => + $"I/O bound — {topPct:N0}% of wait time is {rawWaitType}. Non-buffer I/O such as sort/hash spills to TempDB or eager writes.", + + // CPU: thread yielding its scheduler quantum + _ when waitType == "SOS_SCHEDULER_YIELD" => + $"CPU bound — {topPct:N0}% of wait time is {rawWaitType}. The query is consuming significant CPU. Look for expensive operators (scans, sorts, hash builds) that could be eliminated or reduced.", + + // Parallelism: exchange and synchronization waits + _ when waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) => + $"Parallel thread skew — {topPct:N0}% of wait time is {rawWaitType}. Work is unevenly distributed across parallel threads.", + _ when waitType.StartsWith("CXSYNC", StringComparison.Ordinal) => + $"Parallel synchronization — {topPct:N0}% of wait time is {rawWaitType}. Threads are waiting at exchange operators to synchronize parallel execution.", + + // Hash operations + _ when waitType.StartsWith("HT", StringComparison.Ordinal) => + $"Hash operation — {topPct:N0}% of wait time is {rawWaitType}. Time spent building, repartitioning, or cleaning up hash tables. Large hash builds may indicate missing indexes or bad row estimates.", + + // Sort/bitmap batch operations + _ when waitType == "BPSORT" => + $"Batch sort — {topPct:N0}% of wait time is {rawWaitType}. Time spent in batch-mode sort operations.", + _ when waitType == "BMPBUILD" => + $"Bitmap build — {topPct:N0}% of wait time is {rawWaitType}. Time spent building bitmap filters for hash joins.", + + // Memory allocation + _ when waitType.Contains("MEMORY_ALLOCATION_EXT", StringComparison.Ordinal) => + $"Memory allocation — {topPct:N0}% of wait time is {rawWaitType}. Frequent memory allocations during query execution.", + + // Latch contention (non-I/O) + _ when waitType.StartsWith("PAGELATCH", StringComparison.Ordinal) => + $"Page latch contention — {topPct:N0}% of wait time is {rawWaitType}. In-memory page contention, often on TempDB or hot pages.", _ when waitType.StartsWith("LATCH_", StringComparison.Ordinal) => - $"Latch contention — {topPct:N0}% of wait time is {top.WaitType}.", + $"Latch contention — {topPct:N0}% of wait time is {rawWaitType}.", + + // Lock contention _ when waitType.StartsWith("LCK_", StringComparison.Ordinal) => - $"Lock contention — {topPct:N0}% of wait time is {top.WaitType}. Other sessions are holding locks that this query needs.", - _ when waitType.StartsWith("CXPACKET", StringComparison.Ordinal) || waitType.StartsWith("CXCONSUMER", StringComparison.Ordinal) => - $"Parallel thread skew — {topPct:N0}% of wait time is {top.WaitType}. Work is unevenly distributed across parallel threads.", - _ when waitType.Contains("IO_COMPLETION", StringComparison.Ordinal) => - $"I/O bound — {topPct:N0}% of wait time is {top.WaitType}.", - _ when waitType.StartsWith("RESOURCE_SEMAPHORE", StringComparison.Ordinal) => - $"Memory grant wait — {topPct:N0}% of wait time is {top.WaitType}. The query had to wait for a memory grant.", - _ => $"Dominant wait is {top.WaitType} ({topPct:N0}% of wait time)." + $"Lock contention — {topPct:N0}% of wait time is {rawWaitType}. Other sessions are holding locks that this query needs.", + + // Log writes + _ when waitType == "LOGBUFFER" => + $"Log write — {topPct:N0}% of wait time is {rawWaitType}. Waiting for transaction log buffer flushes, typically from data modifications.", + + // Network + _ when waitType == "ASYNC_NETWORK_IO" => + $"Network bound — {topPct:N0}% of wait time is {rawWaitType}. The client application is not consuming results fast enough.", + + // Physical page cache + _ when waitType == "SOS_PHYS_PAGE_CACHE" => + $"Physical page cache — {topPct:N0}% of wait time is {rawWaitType}. Contention on the physical memory page allocator.", + + _ => $"Dominant wait is {rawWaitType} ({topPct:N0}% of wait time)." }; + } + + /// + /// Returns true if the statement has significant I/O waits (PAGEIOLATCH_*, IO_COMPLETION). + /// Used for severity elevation decisions where I/O specifically indicates disk access. + /// Thresholds: I/O waits >= 20% of total wait time AND >= 100ms absolute. + /// + private static bool HasSignificantIoWaits(List waits) + { + if (waits.Count == 0) + return false; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return false; + + long ioMs = 0; + foreach (var w in waits) + { + var wt = w.WaitType.ToUpperInvariant(); + if (wt.StartsWith("PAGEIOLATCH", StringComparison.Ordinal) || wt.Contains("IO_COMPLETION", StringComparison.Ordinal)) + ioMs += w.WaitTimeMs; + } + + var pct = (double)ioMs / totalMs * 100; + return ioMs >= 100 && pct >= 20; + } - return advice; + /// + /// Formats a node reference for use in warning messages. Includes object name + /// for data access operators where it helps identify which table is involved. + /// + private static string FormatNodeRef(PlanNode node) + { + if (!string.IsNullOrEmpty(node.ObjectName)) + { + var objRef = !string.IsNullOrEmpty(node.DatabaseName) + ? $"{node.DatabaseName}.{node.ObjectName}" + : node.ObjectName; + return $"{node.PhysicalOp} on {objRef} (Node {node.NodeId})"; + } + + return $"{node.PhysicalOp} (Node {node.NodeId})"; + } + + /// + /// Identifies the specific cause of a row goal from the statement text. + /// Returns a specific cause when detectable, or a generic list as fallback. + /// + private static string IdentifyRowGoalCause(string stmtText) + { + if (string.IsNullOrEmpty(stmtText)) + return "TOP, EXISTS, IN, or FAST hint"; + + var text = stmtText.ToUpperInvariant(); + var causes = new List(4); + + if (Regex.IsMatch(text, @"\bTOP\b")) + causes.Add("TOP"); + if (Regex.IsMatch(text, @"\bEXISTS\b")) + causes.Add("EXISTS"); + // IN with subquery — bare "IN (" followed by SELECT, not just "IN (1,2,3)" + if (Regex.IsMatch(text, @"\bIN\s*\(\s*SELECT\b")) + causes.Add("IN (subquery)"); + if (Regex.IsMatch(text, @"\bFAST\b")) + causes.Add("FAST hint"); + + return causes.Count > 0 + ? string.Join(", ", causes) + : "TOP, EXISTS, IN, or FAST hint"; } /// @@ -1589,7 +1828,7 @@ private static bool AllocatesResources(PlanNode node) || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); } - private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); + private sealed record ScanImpact(double CostPct, double ElapsedPct, string? Summary); /// /// Builds impact details for a scan node: what % of plan time/cost it represents, diff --git a/Dashboard/Services/WebhookAlertService.cs b/Dashboard/Services/WebhookAlertService.cs index f9cb68b2..1befa1e2 100644 --- a/Dashboard/Services/WebhookAlertService.cs +++ b/Dashboard/Services/WebhookAlertService.cs @@ -24,7 +24,10 @@ namespace PerformanceMonitorDashboard.Services public class WebhookAlertService { private const string EditionName = "Performance Monitor Dashboard"; + private const string TeamsWebhookCredentialKey = "TeamsWebhook"; + private const string SlackWebhookCredentialKey = "SlackWebhook"; private static readonly JsonSerializerOptions s_jsonOptions = new() { PropertyNamingPolicy = null }; + private static readonly CredentialService s_credentialService = new(); private readonly UserPreferencesService _preferencesService; private readonly ConcurrentDictionary _cooldowns = new(); @@ -42,6 +45,50 @@ public WebhookAlertService(UserPreferencesService preferencesService) Current = this; } + /// + /// Gets a webhook URL from Windows Credential Manager. + /// + public static string GetWebhookUrl(string credentialKey) + { + try + { + var cred = s_credentialService.GetCredential(credentialKey); + return cred?.Password ?? ""; + } + catch (Exception ex) + { + Logger.Error($"Failed to retrieve webhook URL for {credentialKey}: {ex.Message}"); + return ""; + } + } + + /// + /// Saves a webhook URL to Windows Credential Manager. + /// + public static void SaveWebhookUrl(string credentialKey, string url) + { + try + { + if (string.IsNullOrWhiteSpace(url)) + { + s_credentialService.DeleteCredential(credentialKey); + } + else + { + s_credentialService.SaveCredential(credentialKey, "webhook", url); + } + } + catch (Exception ex) + { + Logger.Error($"Failed to save webhook URL for {credentialKey}: {ex.Message}"); + } + } + + public static string GetTeamsWebhookUrl() => GetWebhookUrl(TeamsWebhookCredentialKey); + public static string GetSlackWebhookUrl() => GetWebhookUrl(SlackWebhookCredentialKey); + public static void SaveTeamsWebhookUrl(string url) => SaveWebhookUrl(TeamsWebhookCredentialKey, url); + public static void SaveSlackWebhookUrl(string url) => SaveWebhookUrl(SlackWebhookCredentialKey, url); + /// /// Sends webhook alerts to all configured channels (Teams and/or Slack). /// Respects the email cooldown setting for throttling. Never throws. @@ -67,12 +114,14 @@ public async Task TrySendWebhookAlertsAsync( bool sent = false; - if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl)) + var teamsUrl = GetTeamsWebhookUrl(); + if (prefs.TeamsWebhookEnabled && !string.IsNullOrWhiteSpace(teamsUrl)) { sent |= await TrySendTeamsAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); } - if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl)) + var slackUrl = GetSlackWebhookUrl(); + if (prefs.SlackWebhookEnabled && !string.IsNullOrWhiteSpace(slackUrl)) { sent |= await TrySendSlackAlertAsync(prefs, metricName, serverName, currentValue, thresholdValue, context); } @@ -148,7 +197,7 @@ private async Task TrySendTeamsAlertAsync( try { var payload = BuildTeamsPayload(metricName, serverName, currentValue, thresholdValue, context: context); - var error = await PostWebhookAsync(prefs.TeamsWebhookUrl, payload, prefs.TeamsProxyAddress); + var error = await PostWebhookAsync(GetTeamsWebhookUrl(), payload, prefs.TeamsProxyAddress); if (error != null) { @@ -268,7 +317,7 @@ private async Task TrySendSlackAlertAsync( try { var payload = BuildSlackPayload(metricName, serverName, currentValue, thresholdValue, context: context); - var error = await PostWebhookAsync(prefs.SlackWebhookUrl, payload, prefs.SlackProxyAddress); + var error = await PostWebhookAsync(GetSlackWebhookUrl(), payload, prefs.SlackProxyAddress); if (error != null) { diff --git a/Dashboard/SettingsWindow.xaml.cs b/Dashboard/SettingsWindow.xaml.cs index d4491ac8..bbdbebe1 100644 --- a/Dashboard/SettingsWindow.xaml.cs +++ b/Dashboard/SettingsWindow.xaml.cs @@ -210,11 +210,27 @@ private void LoadSettings() // Webhook settings (Teams / Slack) TeamsWebhookEnabledCheckBox.IsChecked = prefs.TeamsWebhookEnabled; - TeamsWebhookUrlTextBox.Text = prefs.TeamsWebhookUrl; TeamsProxyAddressTextBox.Text = prefs.TeamsProxyAddress; SlackWebhookEnabledCheckBox.IsChecked = prefs.SlackWebhookEnabled; - SlackWebhookUrlTextBox.Text = prefs.SlackWebhookUrl; SlackProxyAddressTextBox.Text = prefs.SlackProxyAddress; + + /* Migrate legacy plaintext webhook URLs to Credential Manager */ + if (!string.IsNullOrWhiteSpace(prefs.TeamsWebhookUrl)) + { + WebhookAlertService.SaveTeamsWebhookUrl(prefs.TeamsWebhookUrl); + prefs.TeamsWebhookUrl = ""; + _preferencesService.SavePreferences(prefs); + } + if (!string.IsNullOrWhiteSpace(prefs.SlackWebhookUrl)) + { + WebhookAlertService.SaveSlackWebhookUrl(prefs.SlackWebhookUrl); + prefs.SlackWebhookUrl = ""; + _preferencesService.SavePreferences(prefs); + } + + /* Load webhook URLs from Credential Manager */ + TeamsWebhookUrlTextBox.Text = WebhookAlertService.GetTeamsWebhookUrl(); + SlackWebhookUrlTextBox.Text = WebhookAlertService.GetSlackWebhookUrl(); UpdateTeamsControlStates(); UpdateSlackControlStates(); @@ -705,12 +721,16 @@ private async void OkButton_Click(object sender, RoutedEventArgs e) // Save webhook settings (Teams / Slack) prefs.TeamsWebhookEnabled = TeamsWebhookEnabledCheckBox.IsChecked == true; - prefs.TeamsWebhookUrl = TeamsWebhookUrlTextBox.Text?.Trim() ?? ""; + prefs.TeamsWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */ prefs.TeamsProxyAddress = TeamsProxyAddressTextBox.Text?.Trim() ?? ""; prefs.SlackWebhookEnabled = SlackWebhookEnabledCheckBox.IsChecked == true; - prefs.SlackWebhookUrl = SlackWebhookUrlTextBox.Text?.Trim() ?? ""; + prefs.SlackWebhookUrl = ""; /* URLs stored in Credential Manager, not preferences */ prefs.SlackProxyAddress = SlackProxyAddressTextBox.Text?.Trim() ?? ""; + /* Save webhook URLs to Credential Manager */ + WebhookAlertService.SaveTeamsWebhookUrl(TeamsWebhookUrlTextBox.Text?.Trim() ?? ""); + WebhookAlertService.SaveSlackWebhookUrl(SlackWebhookUrlTextBox.Text?.Trim() ?? ""); + // Save MCP server settings bool mcpWasEnabled = prefs.McpEnabled; prefs.McpEnabled = McpEnabledCheckBox.IsChecked == true; diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/Dashboard/Themes/CoolBreezeTheme.xaml index 4eb70025..36437cbc 100644 --- a/Dashboard/Themes/CoolBreezeTheme.xaml +++ b/Dashboard/Themes/CoolBreezeTheme.xaml @@ -644,11 +644,46 @@ + + + + + + + + + - + + + + + + + + +