From 91b656b4ed1661fb633fedeec5bafe1316cbcad8 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:26:26 -0400 Subject: [PATCH 01/19] Fix duplicate release builds: trigger on published only gh release create fires both created and published events, causing two identical builds that conflict on asset uploads. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a235f907..a2e88c68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [main, dev] release: - types: [created, published] + types: [published] permissions: contents: write From 19430a03edc28e15ac6b2563c31b4857e694af13 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:29:24 -0400 Subject: [PATCH 02/19] Sync PlanAnalyzer + ShowPlanParser from PerformanceStudio (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanAnalyzer (Dashboard + Lite): - Rules 3 & 20: SubTreeCost threshold 0.01 → 1.0 (CTFP is integer) - Rule 3: Smarter MaxDOPSetToOne — 3-branch logic with query text check - Rule 5: AllocatesResources gate on zero-rows warning (Hash/Sort/Spool) - Rule 11: Enriched scan-with-predicate (cost %, elapsed %, selectivity) - Rule 12: IsFunctionOnColumnSide — fix non-SARGable false positive - Rules 25/31: GetWaitStatsAdvice for targeted parallelism advice - New Rule 32: Scan cardinality misestimate (actual plans) - New Rule 33: CE guess detection (estimated plans) ShowPlanParser (Dashboard + Lite): - Fix multi-statement batch parsing (iterate all children) - Add synthetic root nodes for DECLARE/ASSIGN statements Closes #816. Syncs from PerformanceStudio PR #213. Co-authored-by: Claude Opus 4.6 (1M context) --- Dashboard/Services/PlanAnalyzer.cs | 289 +++++++++++++++++++++++++-- Dashboard/Services/ShowPlanParser.cs | 27 ++- Lite/Services/PlanAnalyzer.cs | 289 +++++++++++++++++++++++++-- Lite/Services/ShowPlanParser.cs | 27 ++- 4 files changed, 592 insertions(+), 40 deletions(-) diff --git a/Dashboard/Services/PlanAnalyzer.cs b/Dashboard/Services/PlanAnalyzer.cs index 6965bbb3..befa192c 100644 --- a/Dashboard/Services/PlanAnalyzer.cs +++ b/Dashboard/Services/PlanAnalyzer.cs @@ -38,10 +38,11 @@ public static void Analyze(ParsedPlan plan) private static void AnalyzeStatement(PlanStatement stmt) { // Rule 3: Serial plan with reason - // Skip: trivial cost (< 0.01), TRIVIAL optimization (can't go parallel anyway), + // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), + // TRIVIAL optimization (can't go parallel anyway), // and 0ms actual elapsed time (not worth flagging). if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason) - && stmt.StatementSubTreeCost >= 0.01 + && stmt.StatementSubTreeCost >= 1.0 && stmt.StatementOptmLevel != "TRIVIAL" && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) { @@ -105,12 +106,44 @@ private static void AnalyzeStatement(PlanStatement stmt) or "NoParallelWithRemoteQuery" or "NoRemoteParallelismForMatrix"; - stmt.PlanWarnings.Add(new PlanWarning + // MaxDOPSetToOne needs special handling: check whether the user explicitly + // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. + // SQL Server truncates StatementText at ~4,000 characters in plan XML. + if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}.", - Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info - }); + var text = stmt.StatementText ?? ""; + var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); + var isTruncated = text.Length >= 3990; + + if (hasMaxdop1InText) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = PlanWarningSeverity.Warning + }); + } + else if (isTruncated) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", + Severity = PlanWarningSeverity.Info + }); + } + // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely + } + else + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + }); + } } // Rule 9: Memory grant issues (statement-level) @@ -203,8 +236,8 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 20: Local variables without RECOMPILE // Parameters with no CompiledValue are likely local variables — the optimizer // cannot sniff their values and uses density-based ("unknown") estimates. - // Skip trivial statements (simple variable assignments) where estimate quality doesn't matter. - if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01) + // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). + if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) { var unsnifffedParams = stmt.Parameters .Where(p => string.IsNullOrEmpty(p.CompiledValue)) @@ -259,28 +292,33 @@ private static void AnalyzeStatement(PlanStatement stmt) var speedup = (double)cpu / elapsed; var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0)); + // Build targeted advice from wait stats if available + var waitAdvice = GetWaitStatsAdvice(stmt.WaitStats); + if (speedup < 0.5) { // CPU well below Elapsed: threads are waiting, not doing CPU work var waitPct = (1.0 - speedup) * 100; + var advice = waitAdvice ?? "Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits."; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Parallel Wait Bottleneck", Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " + $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + - $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", + advice, Severity = PlanWarningSeverity.Warning }); } else if (efficiency < 40) { // CPU >= Elapsed but well below DOP potential — parallelism is ineffective + var advice = waitAdvice ?? "Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution."; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Ineffective Parallelism", Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " + $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " + - $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + advice, Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } @@ -483,8 +521,11 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) { if (node.ActualRows == 0) { - // Zero rows is always worth noting — resources were allocated for nothing - if (node.EstimateRows >= 100) + // Zero rows with a significant estimate — only warn on operators that + // actually allocate meaningful resources (memory grants for hash/sort/spool). + // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. + // where 0 rows is just a consequence of upstream filtering. + if (node.EstimateRows >= 100 && AllocatesResources(node)) { node.Warnings.Add(new PlanWarning { @@ -670,14 +711,76 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn !IsProbeOnly(node.Predicate)) { var displayPredicate = StripProbeExpressions(node.Predicate); + var details = BuildScanImpactDetails(node, stmt); + var severity = PlanWarningSeverity.Warning; + if (details.CostPct >= 90 || details.ElapsedPct >= 90) + severity = PlanWarningSeverity.Critical; + var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; + if (!string.IsNullOrEmpty(details.Summary)) + message += $" {details.Summary}"; + message += " Check that you have appropriate indexes."; + message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; node.Warnings.Add(new PlanWarning { WarningType = "Scan With Predicate", - Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: {Truncate(displayPredicate, 200)}", - Severity = PlanWarningSeverity.Warning + Message = message, + Severity = severity }); } + // Rule 32: Cardinality misestimate on expensive scan — likely preventing index usage + // When a scan dominates the plan AND the estimate is vastly higher than actual rows, + // the optimizer chose a scan because it thought it needed most of the table. + // With accurate estimates, it would likely seek instead. + if (node.HasActualStats && IsRowstoreScan(node) + && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) + { + var impact = BuildScanImpactDetails(node, stmt); + var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); + var selectivity = (double)node.ActualRows / node.ActualRowsRead; + + // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity + if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) + && overestimateRatio >= 10.0 + && selectivity < 0.10) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan Cardinality Misestimate", + Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + + $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + + $"An index on the predicate columns could dramatically reduce I/O.", + Severity = PlanWarningSeverity.Critical + }); + } + } + + // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity + // When the optimizer uses a local variable or can't sniff, it falls back to density-based + // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), + // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. + if (!node.HasActualStats && IsRowstoreScan(node) + && node.TableCardinality >= 100_000 && node.EstimateRows > 0 + && !string.IsNullOrEmpty(node.Predicate)) + { + var impact = BuildScanImpactDetails(node, stmt); + if (impact.CostPct >= 50) + { + var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); + if (guessDesc != null) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Estimated Plan CE Guess", + Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + + $"The optimizer may be using a default guess instead of accurate statistics. " + + $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) if (node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) { @@ -1073,12 +1176,14 @@ private static bool IsScanOperator(PlanNode node) if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; - // Common function calls on columns + // Common function calls on columns — but only if the function wraps a column, + // not a parameter/variable. Split on comparison operators to check which side + // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) var funcMatch = FunctionInPredicateRegex.Match(predicate); if (funcMatch.Success) { var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); - if (funcName != "CONVERT_IMPLICIT") + if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) return $"Function call ({funcName}) on column"; } @@ -1431,6 +1536,156 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + /// + /// 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. + /// + private static string? GetWaitStatsAdvice(List waits) + { + if (waits.Count == 0) + return null; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return null; + + 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; + + var waitType = top.WaitType.ToUpperInvariant(); + var advice = waitType switch + { + _ 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.", + _ when waitType.StartsWith("LATCH_", StringComparison.Ordinal) => + $"Latch contention — {topPct:N0}% of wait time is {top.WaitType}.", + _ 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)." + }; + + return advice; + } + + /// + /// Returns true for operators that allocate meaningful resources based on row estimates. + /// Hash Match (hash table), Sort (sort buffer), Spool (worktable). + /// + private static bool AllocatesResources(PlanNode node) + { + var op = node.PhysicalOp; + return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) + || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) + || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); + } + + private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); + + /// + /// Builds impact details for a scan node: what % of plan time/cost it represents, + /// and what fraction of rows survived filtering. + /// + private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) + { + var parts = new List(); + + // % of plan cost + double costPct = 0; + if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) + { + costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; + if (costPct >= 50) + parts.Add($"This scan is {costPct:N0}% of the plan cost."); + } + + // % of elapsed time (actual plans) + double elapsedPct = 0; + if (node.HasActualStats && node.ActualElapsedMs > 0 && + stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (elapsedPct >= 50) + parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); + } + + // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) + if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) + { + var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); + } + else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) + { + var selectivity = node.EstimateRows / node.TableCardinality * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); + } + + return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); + } + + /// + /// Checks whether a function call in a predicate is on the column side of the comparison. + /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) + /// If the function is only on the parameter/literal side, it's still SARGable. + /// + private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) + { + // Find the comparison operator that splits the predicate into left/right sides. + // Operators in ScalarString: >=, <=, <>, >, <, = + var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); + if (!compMatch.Success) + return true; // No comparison found — can't determine side, assume worst case + + var compPos = compMatch.Index; + var funcPos = funcMatch.Index; + + // Determine which side the function is on + var funcSide = funcPos < compPos ? "left" : "right"; + + // Check if that side also contains a column reference [...].[...].[...] + string side = funcSide == "left" + ? predicate[..compPos] + : predicate[(compPos + compMatch.Length)..]; + + // Column references are multi-part bracket-qualified: [schema].[table].[column] + // Variables are [@var] or [@var] — single bracket pair with @ prefix. + // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. + return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); + } + + /// + /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. + /// Returns a description of the guess pattern, or null if no known pattern matches. + /// + private static string? DetectCeGuess(double estimateRows, double tableCardinality) + { + if (tableCardinality <= 0) return null; + var selectivity = estimateRows / tableCardinality; + + // Known CE guess selectivities with a 2% tolerance band + return selectivity switch + { + >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", + >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", + >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", + >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", + >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", + _ => null + }; + } + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] private static partial Regex FunctionInPredicateRegExp(); [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] diff --git a/Dashboard/Services/ShowPlanParser.cs b/Dashboard/Services/ShowPlanParser.cs index f441db9a..d99793ec 100644 --- a/Dashboard/Services/ShowPlanParser.cs +++ b/Dashboard/Services/ShowPlanParser.cs @@ -37,8 +37,9 @@ public static ParsedPlan Parse(string xml) foreach (var batchEl in batches) { var batch = new PlanBatch(); - var statementsEl = batchEl.Element(Ns + "Statements"); - if (statementsEl != null) + // A Batch can contain multiple elements (e.g., DECLARE + SELECT). + // Use Elements() to iterate all of them, not just the first. + foreach (var statementsEl in batchEl.Elements(Ns + "Statements")) { foreach (var stmtEl in statementsEl.Elements()) { @@ -204,7 +205,27 @@ private static List ParseStatementAndChildren(XElement stmtEl) } } - if (queryPlanEl == null) return stmt; + if (queryPlanEl == null) + { + // Statements with no QueryPlan (e.g., DECLARE/ASSIGN) still get a synthetic + // root node so they appear in the statement tab list. + var stmtType = stmt.StatementType.Length > 0 + ? stmt.StatementType.ToUpperInvariant() + : "STATEMENT"; + stmt.RootNode = new PlanNode + { + NodeId = -1, + PhysicalOp = stmtType, + LogicalOp = stmtType, + IconName = stmtType switch + { + "ASSIGN" => "assign", + "DECLARE" => "declare", + _ => "language_construct_catch_all" + } + }; + return stmt; + } ParseStmtAttributes(stmt, stmtEl); ParseQueryPlanElements(stmt, stmtEl, queryPlanEl); diff --git a/Lite/Services/PlanAnalyzer.cs b/Lite/Services/PlanAnalyzer.cs index 0ead490a..866def3b 100644 --- a/Lite/Services/PlanAnalyzer.cs +++ b/Lite/Services/PlanAnalyzer.cs @@ -38,10 +38,11 @@ public static void Analyze(ParsedPlan plan) private static void AnalyzeStatement(PlanStatement stmt) { // Rule 3: Serial plan with reason - // Skip: trivial cost (< 0.01), TRIVIAL optimization (can't go parallel anyway), + // Skip: cost < 1 (CTFP is an integer so cost < 1 can never go parallel), + // TRIVIAL optimization (can't go parallel anyway), // and 0ms actual elapsed time (not worth flagging). if (!string.IsNullOrEmpty(stmt.NonParallelPlanReason) - && stmt.StatementSubTreeCost >= 0.01 + && stmt.StatementSubTreeCost >= 1.0 && stmt.StatementOptmLevel != "TRIVIAL" && !(stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs == 0)) { @@ -105,12 +106,44 @@ private static void AnalyzeStatement(PlanStatement stmt) or "NoParallelWithRemoteQuery" or "NoRemoteParallelismForMatrix"; - stmt.PlanWarnings.Add(new PlanWarning + // MaxDOPSetToOne needs special handling: check whether the user explicitly + // set MAXDOP 1 in the query text, or if it's a server/db/RG setting. + // SQL Server truncates StatementText at ~4,000 characters in plan XML. + if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") { - WarningType = "Serial Plan", - Message = $"Query running serially: {reason}.", - Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info - }); + var text = stmt.StatementText ?? ""; + var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); + var isTruncated = text.Length >= 3990; + + if (hasMaxdop1InText) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = PlanWarningSeverity.Warning + }); + } + else if (isTruncated) + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", + Severity = PlanWarningSeverity.Info + }); + } + // else: not truncated, no MAXDOP 1 in text — server/db/RG setting, suppress entirely + } + else + { + stmt.PlanWarnings.Add(new PlanWarning + { + WarningType = "Serial Plan", + Message = $"Query running serially: {reason}.", + Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info + }); + } } // Rule 9: Memory grant issues (statement-level) @@ -203,8 +236,8 @@ private static void AnalyzeStatement(PlanStatement stmt) // Rule 20: Local variables without RECOMPILE // Parameters with no CompiledValue are likely local variables — the optimizer // cannot sniff their values and uses density-based ("unknown") estimates. - // Skip trivial statements (simple variable assignments) where estimate quality doesn't matter. - if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 0.01) + // Skip statements with cost < 1 (can't go parallel, estimate quality rarely matters). + if (stmt.Parameters.Count > 0 && stmt.StatementSubTreeCost >= 1.0) { var unsnifffedParams = stmt.Parameters .Where(p => string.IsNullOrEmpty(p.CompiledValue)) @@ -259,28 +292,33 @@ private static void AnalyzeStatement(PlanStatement stmt) var speedup = (double)cpu / elapsed; var efficiency = Math.Max(0.0, Math.Min(100.0, (speedup - 1.0) / (dop - 1.0) * 100.0)); + // Build targeted advice from wait stats if available + var waitAdvice = GetWaitStatsAdvice(stmt.WaitStats); + if (speedup < 0.5) { // CPU well below Elapsed: threads are waiting, not doing CPU work var waitPct = (1.0 - speedup) * 100; + var advice = waitAdvice ?? "Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits."; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Parallel Wait Bottleneck", Message = $"Parallel plan (DOP {dop}, {efficiency:N0}% efficient) with elapsed time ({elapsed:N0}ms) exceeding CPU time ({cpu:N0}ms). " + $"Approximately {waitPct:N0}% of elapsed time was spent waiting rather than on CPU. " + - $"Common causes include spills to tempdb, physical I/O reads, lock or latch contention, and memory grant waits.", + advice, Severity = PlanWarningSeverity.Warning }); } else if (efficiency < 40) { // CPU >= Elapsed but well below DOP potential — parallelism is ineffective + var advice = waitAdvice ?? "Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution."; stmt.PlanWarnings.Add(new PlanWarning { WarningType = "Ineffective Parallelism", Message = $"Parallel plan (DOP {dop}) is only {efficiency:N0}% efficient — CPU time ({cpu:N0}ms) vs elapsed time ({elapsed:N0}ms). " + $"At DOP {dop}, ideal CPU time would be ~{elapsed * dop:N0}ms. " + - $"Look for parallel thread skew, blocking exchanges, or serial zones in the plan that prevent effective parallel execution.", + advice, Severity = efficiency < 20 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } @@ -483,8 +521,11 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt) { if (node.ActualRows == 0) { - // Zero rows is always worth noting — resources were allocated for nothing - if (node.EstimateRows >= 100) + // Zero rows with a significant estimate — only warn on operators that + // actually allocate meaningful resources (memory grants for hash/sort/spool). + // Skip Parallelism, Bitmap, Compute Scalar, Filter, Concatenation, etc. + // where 0 rows is just a consequence of upstream filtering. + if (node.EstimateRows >= 100 && AllocatesResources(node)) { node.Warnings.Add(new PlanWarning { @@ -670,14 +711,76 @@ _ when nonSargableReason.StartsWith("Function call", StringComparison.OrdinalIgn !IsProbeOnly(node.Predicate)) { var displayPredicate = StripProbeExpressions(node.Predicate); + var details = BuildScanImpactDetails(node, stmt); + var severity = PlanWarningSeverity.Warning; + if (details.CostPct >= 90 || details.ElapsedPct >= 90) + severity = PlanWarningSeverity.Critical; + var message = "Scan with residual predicate — SQL Server is reading every row and filtering after the fact."; + if (!string.IsNullOrEmpty(details.Summary)) + message += $" {details.Summary}"; + message += " Check that you have appropriate indexes."; + message += $"\nPredicate: {Truncate(displayPredicate, 200)}"; node.Warnings.Add(new PlanWarning { WarningType = "Scan With Predicate", - Message = $"Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: {Truncate(displayPredicate, 200)}", - Severity = PlanWarningSeverity.Warning + Message = message, + Severity = severity }); } + // Rule 32: Cardinality misestimate on expensive scan — likely preventing index usage + // When a scan dominates the plan AND the estimate is vastly higher than actual rows, + // the optimizer chose a scan because it thought it needed most of the table. + // With accurate estimates, it would likely seek instead. + if (node.HasActualStats && IsRowstoreScan(node) + && node.EstimateRows > 0 && node.ActualRows >= 0 && node.ActualRowsRead > 0) + { + var impact = BuildScanImpactDetails(node, stmt); + var overestimateRatio = node.EstimateRows / Math.Max(1.0, node.ActualRows); + var selectivity = (double)node.ActualRows / node.ActualRowsRead; + + // Fire when: scan is >= 50% of plan, estimate is >= 10x actual, and < 10% selectivity + if ((impact.CostPct >= 50 || impact.ElapsedPct >= 50) + && overestimateRatio >= 10.0 + && selectivity < 0.10) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Scan Cardinality Misestimate", + Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + + $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + + $"An index on the predicate columns could dramatically reduce I/O.", + Severity = PlanWarningSeverity.Critical + }); + } + } + + // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity + // When the optimizer uses a local variable or can't sniff, it falls back to density-based + // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), + // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. + if (!node.HasActualStats && IsRowstoreScan(node) + && node.TableCardinality >= 100_000 && node.EstimateRows > 0 + && !string.IsNullOrEmpty(node.Predicate)) + { + var impact = BuildScanImpactDetails(node, stmt); + if (impact.CostPct >= 50) + { + var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); + if (guessDesc != null) + { + node.Warnings.Add(new PlanWarning + { + WarningType = "Estimated Plan CE Guess", + Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + + $"The optimizer may be using a default guess instead of accurate statistics. " + + $"If actual selectivity is much lower, an index on the predicate columns could help significantly.", + Severity = PlanWarningSeverity.Warning + }); + } + } + } + // Rule 13: Mismatched data types (GetRangeWithMismatchedTypes / GetRangeThroughConvert) if (node.PhysicalOp == "Compute Scalar" && !string.IsNullOrEmpty(node.DefinedValues)) { @@ -1072,12 +1175,14 @@ private static bool IsScanOperator(PlanNode node) if (IsNullCoalesceRegExp().IsMatch(predicate)) return "ISNULL/COALESCE wrapping column"; - // Common function calls on columns + // Common function calls on columns — but only if the function wraps a column, + // not a parameter/variable. Split on comparison operators to check which side + // the function is on. Predicate format: [db].[schema].[table].[col]>func(...) var funcMatch = FunctionInPredicateRegex.Match(predicate); if (funcMatch.Success) { var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); - if (funcName != "CONVERT_IMPLICIT") + if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch)) return $"Function call ({funcName}) on column"; } @@ -1430,6 +1535,156 @@ private static string Truncate(string value, int maxLength) return value.Length <= maxLength ? value : value[..maxLength] + "..."; } + /// + /// 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. + /// + private static string? GetWaitStatsAdvice(List waits) + { + if (waits.Count == 0) + return null; + + var totalMs = waits.Sum(w => w.WaitTimeMs); + if (totalMs == 0) + return null; + + 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; + + var waitType = top.WaitType.ToUpperInvariant(); + var advice = waitType switch + { + _ 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.", + _ when waitType.StartsWith("LATCH_", StringComparison.Ordinal) => + $"Latch contention — {topPct:N0}% of wait time is {top.WaitType}.", + _ 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)." + }; + + return advice; + } + + /// + /// Returns true for operators that allocate meaningful resources based on row estimates. + /// Hash Match (hash table), Sort (sort buffer), Spool (worktable). + /// + private static bool AllocatesResources(PlanNode node) + { + var op = node.PhysicalOp; + return op.StartsWith("Hash", StringComparison.OrdinalIgnoreCase) + || op.StartsWith("Sort", StringComparison.OrdinalIgnoreCase) + || op.EndsWith("Spool", StringComparison.OrdinalIgnoreCase); + } + + private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); + + /// + /// Builds impact details for a scan node: what % of plan time/cost it represents, + /// and what fraction of rows survived filtering. + /// + private static ScanImpact BuildScanImpactDetails(PlanNode node, PlanStatement stmt) + { + var parts = new List(); + + // % of plan cost + double costPct = 0; + if (stmt.StatementSubTreeCost > 0 && node.EstimatedTotalSubtreeCost > 0) + { + costPct = node.EstimatedTotalSubtreeCost / stmt.StatementSubTreeCost * 100; + if (costPct >= 50) + parts.Add($"This scan is {costPct:N0}% of the plan cost."); + } + + // % of elapsed time (actual plans) + double elapsedPct = 0; + if (node.HasActualStats && node.ActualElapsedMs > 0 && + stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs > 0) + { + elapsedPct = (double)node.ActualElapsedMs / stmt.QueryTimeStats.ElapsedTimeMs * 100; + if (elapsedPct >= 50) + parts.Add($"This scan took {elapsedPct:N0}% of elapsed time."); + } + + // Row selectivity: rows returned vs rows read (actual) or vs table cardinality (estimated) + if (node.HasActualStats && node.ActualRowsRead > 0 && node.ActualRows < node.ActualRowsRead) + { + var selectivity = (double)node.ActualRows / node.ActualRowsRead * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N3}% of rows survived filtering ({node.ActualRows:N0} of {node.ActualRowsRead:N0})."); + } + else if (!node.HasActualStats && node.TableCardinality > 0 && node.EstimateRows < node.TableCardinality) + { + var selectivity = node.EstimateRows / node.TableCardinality * 100; + if (selectivity < 10) + parts.Add($"Only {selectivity:N1}% of rows estimated to survive filtering."); + } + + return new ScanImpact(costPct, elapsedPct, parts.Count > 0 ? string.Join(" ", parts) : null); + } + + /// + /// Checks whether a function call in a predicate is on the column side of the comparison. + /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) + /// If the function is only on the parameter/literal side, it's still SARGable. + /// + private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch) + { + // Find the comparison operator that splits the predicate into left/right sides. + // Operators in ScalarString: >=, <=, <>, >, <, = + var compMatch = Regex.Match(predicate, @"(?])([<>=!]{1,2})(?![<>=])"); + if (!compMatch.Success) + return true; // No comparison found — can't determine side, assume worst case + + var compPos = compMatch.Index; + var funcPos = funcMatch.Index; + + // Determine which side the function is on + var funcSide = funcPos < compPos ? "left" : "right"; + + // Check if that side also contains a column reference [...].[...].[...] + string side = funcSide == "left" + ? predicate[..compPos] + : predicate[(compPos + compMatch.Length)..]; + + // Column references are multi-part bracket-qualified: [schema].[table].[column] + // Variables are [@var] or [@var] — single bracket pair with @ prefix. + // Match [identifier].[identifier] (at least two dotted parts) to distinguish columns. + return Regex.IsMatch(side, @"\[[^\]@]+\]\.\["); + } + + /// + /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. + /// Returns a description of the guess pattern, or null if no known pattern matches. + /// + private static string? DetectCeGuess(double estimateRows, double tableCardinality) + { + if (tableCardinality <= 0) return null; + var selectivity = estimateRows / tableCardinality; + + // Known CE guess selectivities with a 2% tolerance band + return selectivity switch + { + >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", + >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", + >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", + >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", + >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", + _ => null + }; + } + [GeneratedRegex(@"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase)] private static partial Regex FunctionInPredicateRegExp(); [GeneratedRegex(@"\blike\b[^'""]*?N?'%", RegexOptions.IgnoreCase)] diff --git a/Lite/Services/ShowPlanParser.cs b/Lite/Services/ShowPlanParser.cs index 1e825e94..c9060e5d 100644 --- a/Lite/Services/ShowPlanParser.cs +++ b/Lite/Services/ShowPlanParser.cs @@ -37,8 +37,9 @@ public static ParsedPlan Parse(string xml) foreach (var batchEl in batches) { var batch = new PlanBatch(); - var statementsEl = batchEl.Element(Ns + "Statements"); - if (statementsEl != null) + // A Batch can contain multiple elements (e.g., DECLARE + SELECT). + // Use Elements() to iterate all of them, not just the first. + foreach (var statementsEl in batchEl.Elements(Ns + "Statements")) { foreach (var stmtEl in statementsEl.Elements()) { @@ -204,7 +205,27 @@ private static List ParseStatementAndChildren(XElement stmtEl) } } - if (queryPlanEl == null) return stmt; + if (queryPlanEl == null) + { + // Statements with no QueryPlan (e.g., DECLARE/ASSIGN) still get a synthetic + // root node so they appear in the statement tab list. + var stmtType = stmt.StatementType.Length > 0 + ? stmt.StatementType.ToUpperInvariant() + : "STATEMENT"; + stmt.RootNode = new PlanNode + { + NodeId = -1, + PhysicalOp = stmtType, + LogicalOp = stmtType, + IconName = stmtType switch + { + "ASSIGN" => "assign", + "DECLARE" => "declare", + _ => "language_construct_catch_all" + } + }; + return stmt; + } ParseStmtAttributes(stmt, stmtEl); ParseQueryPlanElements(stmt, stmtEl, queryPlanEl); From a25c90bc70f284082de0fce7417414301e1cf7a6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:31:58 -0400 Subject: [PATCH 03/19] Fix upgrade filter skipping patch versions (#817) (#819) FilterUpgrades used `FromVersion >= current` which excluded upgrades when the user was on a patch release (e.g., 2.4.1 skipped the 2.4.0-to-2.5.0 upgrade because 2.4.0 < 2.4.1). Changed to `ToVersion > current` so any upgrade the user hasn't reached is included. Adds regression test for the patch version scenario. Fixes #817 Co-authored-by: Claude Opus 4.6 (1M context) --- Installer.Core/ScriptProvider.cs | 2 +- Installer.Tests/UpgradeOrderingTests.cs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Installer.Core/ScriptProvider.cs b/Installer.Core/ScriptProvider.cs index b136226a..40f1823e 100644 --- a/Installer.Core/ScriptProvider.cs +++ b/Installer.Core/ScriptProvider.cs @@ -152,7 +152,7 @@ protected static List FilterUpgrades( return candidates .Where(x => x.FromVersion != null && x.ToVersion != null) - .Where(x => x.FromVersion >= current) + .Where(x => x.ToVersion > current) .Where(x => x.ToVersion <= target) .OrderBy(x => x.FromVersion) .ToList(); diff --git a/Installer.Tests/UpgradeOrderingTests.cs b/Installer.Tests/UpgradeOrderingTests.cs index 20400707..1c5aef21 100644 --- a/Installer.Tests/UpgradeOrderingTests.cs +++ b/Installer.Tests/UpgradeOrderingTests.cs @@ -148,6 +148,23 @@ public void DoesNotIncludeFutureUpgrades() Assert.DoesNotContain(upgrades, u => u.FolderName == "2.2.0-to-2.3.0"); } + [Fact] + public void PatchVersion_GetsUpgradeFromPriorMinor() + { + // Regression test for #817: user on v2.4.1 should still get the + // 2.4.0-to-2.5.0 upgrade applied (patch version within range) + using var dir = new TempDirectoryBuilder() + .WithUpgrade("2.3.0", "2.4.0", "01_a.sql") + .WithUpgrade("2.4.0", "2.5.0", "01_b.sql") + .WithUpgrade("2.5.0", "2.6.0", "01_c.sql"); + + var upgrades = ScriptProvider.FromDirectory(dir.RootPath).GetApplicableUpgrades("2.4.1", "2.6.0"); + + Assert.Equal(2, upgrades.Count); + Assert.Equal("2.4.0-to-2.5.0", upgrades[0].FolderName); + Assert.Equal("2.5.0-to-2.6.0", upgrades[1].FolderName); + } + [Fact] public void EmbeddedResources_FindsUpgradeFolders() { From b4fe86600c31bad80b5967e54d7d0a35f6358eb5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:13:20 -0400 Subject: [PATCH 04/19] Fix deadlock count not resetting between collections (#803) (#820) When no new deadlocks occurred for a database, the MERGE source was empty and no row was created, leaving stale delta values in the last row. Expanded the MERGE source with a combined_source CTE that carries forward databases from the previous collection with zero counts, so the delta calculation properly resets to 0. Fixes #803 Co-authored-by: Claude Opus 4.6 (1M context) --- install/26_blocking_deadlock_analyzer.sql | 36 ++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/install/26_blocking_deadlock_analyzer.sql b/install/26_blocking_deadlock_analyzer.sql index d8dfe243..3db34051 100644 --- a/install/26_blocking_deadlock_analyzer.sql +++ b/install/26_blocking_deadlock_analyzer.sql @@ -178,6 +178,8 @@ BEGIN Aggregate deadlock data by database Update rows if database already exists from blocking aggregation Otherwise insert new rows + Include databases from previous collection with zero counts so + deltas reset to 0 when no new events occur (#803) */ WITH deadlock_aggregates AS @@ -192,9 +194,41 @@ BEGIN AND bl.collection_time < @start_time GROUP BY bl.database_name + ), + combined_source AS + ( + SELECT + da.database_name, + da.deadlock_count, + da.total_deadlock_wait_time_ms, + da.victim_count + FROM deadlock_aggregates AS da + + UNION ALL + + /* + Carry forward databases from previous collection with zero + counts so delta calculation can reset them to 0 + */ + SELECT DISTINCT + bds.database_name, + 0, + 0, + 0 + FROM collect.blocking_deadlock_stats AS bds + WHERE bds.collection_time >= @last_deadlock_collection + AND bds.collection_time < @start_time + AND bds.database_name <> N'(none)' + AND NOT EXISTS + ( + SELECT + 1/0 + FROM deadlock_aggregates AS da + WHERE da.database_name = bds.database_name + ) ) MERGE collect.blocking_deadlock_stats WITH (SERIALIZABLE) AS target - USING deadlock_aggregates AS source + USING combined_source AS source ON target.database_name = source.database_name AND target.collection_time >= @start_time WHEN MATCHED From f302205f3170671096fbf1dd7323b53d8580e92a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:40:35 -0400 Subject: [PATCH 05/19] Add MultiSubnetFailover connection option to Dashboard and Lite (#813) (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in checkbox in both Dashboard and Lite Add/Edit Server dialogs that sets MultiSubnetFailover=true on the connection string. Recommended for AG listeners and FCIs spanning multiple subnets — connects to all IPs in parallel during failover instead of sequentially. Defaults to off; persists to servers.json; backward compatible with existing configs. Fixes #813 Co-authored-by: Claude Opus 4.6 (1M context) --- Dashboard/AddServerDialog.xaml | 6 ++++++ Dashboard/AddServerDialog.xaml.cs | 7 ++++++- Dashboard/Models/ServerConnection.cs | 10 +++++++++- Dashboard/Services/DatabaseService.cs | 6 ++++-- Lite/Models/ServerConnection.cs | 9 ++++++++- Lite/Windows/AddServerDialog.xaml | 3 +++ Lite/Windows/AddServerDialog.xaml.cs | 6 +++++- 7 files changed, 41 insertions(+), 6 deletions(-) diff --git a/Dashboard/AddServerDialog.xaml b/Dashboard/AddServerDialog.xaml index a4c788f1..9b220483 100644 --- a/Dashboard/AddServerDialog.xaml +++ b/Dashboard/AddServerDialog.xaml @@ -111,6 +111,12 @@ Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,6" ToolTip="Sets ApplicationIntent=ReadOnly. Required when connecting via an AG listener or Azure failover group endpoint to route to a readable secondary."/> + + + diff --git a/Dashboard/AddServerDialog.xaml.cs b/Dashboard/AddServerDialog.xaml.cs index 7595d755..1e626ea2 100644 --- a/Dashboard/AddServerDialog.xaml.cs +++ b/Dashboard/AddServerDialog.xaml.cs @@ -78,6 +78,7 @@ public AddServerDialog(ServerConnection existingServer) }; TrustServerCertificateCheckBox.IsChecked = existingServer.TrustServerCertificate; ReadOnlyIntentCheckBox.IsChecked = existingServer.ReadOnlyIntent; + MultiSubnetFailoverCheckBox.IsChecked = existingServer.MultiSubnetFailover; if (existingServer.AuthenticationType == AuthenticationTypes.EntraMFA) { @@ -154,7 +155,8 @@ private SqlConnectionStringBuilder BuildConnectionBuilder() Encrypt = ParseEncryptOption(GetSelectedEncryptMode()), ApplicationIntent = ReadOnlyIntentCheckBox.IsChecked == true ? ApplicationIntent.ReadOnly - : ApplicationIntent.ReadWrite + : ApplicationIntent.ReadWrite, + MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true }; if (WindowsAuthRadio.IsChecked == true) @@ -821,6 +823,7 @@ private void SetFormEnabled(bool enabled) EncryptModeComboBox.IsEnabled = enabled; TrustServerCertificateCheckBox.IsEnabled = enabled; ReadOnlyIntentCheckBox.IsEnabled = enabled; + MultiSubnetFailoverCheckBox.IsEnabled = enabled; IsFavoriteCheckBox.IsEnabled = enabled; MonthlyCostTextBox.IsEnabled = enabled; DescriptionTextBox.IsEnabled = enabled; @@ -915,6 +918,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) ServerConnection.EncryptMode = GetSelectedEncryptMode(); ServerConnection.TrustServerCertificate = TrustServerCertificateCheckBox.IsChecked == true; ServerConnection.ReadOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true; + ServerConnection.MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true; if (decimal.TryParse(MonthlyCostTextBox.Text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var editCost) && editCost >= 0) ServerConnection.MonthlyCostUsd = editCost; } @@ -936,6 +940,7 @@ private async void Save_Click(object sender, RoutedEventArgs e) EncryptMode = GetSelectedEncryptMode(), TrustServerCertificate = TrustServerCertificateCheckBox.IsChecked == true, ReadOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true, + MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true, MonthlyCostUsd = monthlyCost }; } diff --git a/Dashboard/Models/ServerConnection.cs b/Dashboard/Models/ServerConnection.cs index 15ca76c0..ba80d9fa 100644 --- a/Dashboard/Models/ServerConnection.cs +++ b/Dashboard/Models/ServerConnection.cs @@ -69,6 +69,12 @@ public bool UseWindowsAuth /// public bool ReadOnlyIntent { get; set; } = false; + /// + /// When true, sets MultiSubnetFailover=true on the connection string. + /// Recommended for AG listeners and FCIs spanning multiple subnets. + /// + public bool MultiSubnetFailover { get; set; } = false; + /// /// Monthly cost of this server in USD, used for FinOps cost attribution. /// Set to 0 to hide cost columns. All FinOps costs are proportional to this budget. @@ -120,6 +126,7 @@ public string GetConnectionString(ICredentialService credentialService) _ => SqlConnectionEncryptOption.Mandatory }, ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, + MultiSubnetFailover = MultiSubnetFailover, Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive }; @@ -151,7 +158,8 @@ public string GetConnectionString(ICredentialService credentialService) password, EncryptMode, TrustServerCertificate, - ReadOnlyIntent + ReadOnlyIntent, + MultiSubnetFailover ).ConnectionString; } diff --git a/Dashboard/Services/DatabaseService.cs b/Dashboard/Services/DatabaseService.cs index afdd2e8d..5b403852 100644 --- a/Dashboard/Services/DatabaseService.cs +++ b/Dashboard/Services/DatabaseService.cs @@ -92,7 +92,8 @@ public static SqlConnectionStringBuilder BuildConnectionString( string? password = null, string encryptMode = "Mandatory", bool trustServerCertificate = false, - bool readOnlyIntent = false) + bool readOnlyIntent = false, + bool multiSubnetFailover = false) { var builder = new SqlConnectionStringBuilder { @@ -101,7 +102,8 @@ public static SqlConnectionStringBuilder BuildConnectionString( TrustServerCertificate = trustServerCertificate, IntegratedSecurity = useWindowsAuth, MultipleActiveResultSets = true, - ApplicationIntent = readOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite + ApplicationIntent = readOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, + MultiSubnetFailover = multiSubnetFailover }; // Set encryption mode diff --git a/Lite/Models/ServerConnection.cs b/Lite/Models/ServerConnection.cs index f89ea109..5f347a92 100644 --- a/Lite/Models/ServerConnection.cs +++ b/Lite/Models/ServerConnection.cs @@ -89,6 +89,12 @@ public bool UseWindowsAuth /// public bool ReadOnlyIntent { get; set; } = false; + /// + /// When true, sets MultiSubnetFailover=true on the connection string. + /// Recommended for AG listeners and FCIs spanning multiple subnets. + /// + public bool MultiSubnetFailover { get; set; } = false; + /// /// Server name with "(Read-Only)" suffix when ReadOnlyIntent is enabled. /// Used for sidebar subtitle and status text. @@ -205,7 +211,8 @@ private string BuildConnectionString(string? username, string? password) CommandTimeout = 60, TrustServerCertificate = TrustServerCertificate, MultipleActiveResultSets = true, - ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite + ApplicationIntent = ReadOnlyIntent ? ApplicationIntent.ReadOnly : ApplicationIntent.ReadWrite, + MultiSubnetFailover = MultiSubnetFailover }; // Set encryption mode diff --git a/Lite/Windows/AddServerDialog.xaml b/Lite/Windows/AddServerDialog.xaml index 33d9b8e9..0b63af4b 100644 --- a/Lite/Windows/AddServerDialog.xaml +++ b/Lite/Windows/AddServerDialog.xaml @@ -96,6 +96,9 @@ + = 0) AddedServer.MonthlyCostUsd = editCost; @@ -375,6 +378,7 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e) DatabaseName = string.IsNullOrWhiteSpace(DatabaseNameBox.Text) ? null : DatabaseNameBox.Text.Trim(), UtilityDatabase = string.IsNullOrWhiteSpace(UtilityDatabaseBox.Text) ? null : UtilityDatabaseBox.Text.Trim(), ReadOnlyIntent = ReadOnlyIntentCheckBox.IsChecked == true, + MultiSubnetFailover = MultiSubnetFailoverCheckBox.IsChecked == true, MonthlyCostUsd = monthlyCost }; From f554d3bd9e542bdffafed4f5118c77b182f1d102 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:35:07 -0400 Subject: [PATCH 06/19] Add offline community script support via community/ directory (#814) (#822) The installer now checks for pre-downloaded SQL files in a community/ directory before downloading from GitHub. Users in air-gapped environments can place the files there manually. If a file is missing, it falls back to the normal GitHub download. Expected files: - community/sp_WhoIsActive.sql - community/DarlingData.sql - community/Install-All-Scripts.sql Fixes #814 Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++ Dashboard/AddServerDialog.xaml.cs | 3 +- Installer.Core/DependencyInstaller.cs | 70 +++++++++++++++++++++++---- Installer/Program.cs | 3 +- InstallerGui/MainWindow.xaml.cs | 3 +- community/README.md | 14 ++++++ 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 community/README.md diff --git a/.gitignore b/.gitignore index e5507e8d..5af2ea72 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ Lite/collection_schedule.json # Plans directory plans/ + +# Community scripts (user-provided, not bundled) +community/*.sql diff --git a/Dashboard/AddServerDialog.xaml.cs b/Dashboard/AddServerDialog.xaml.cs index 1e626ea2..b3f262ba 100644 --- a/Dashboard/AddServerDialog.xaml.cs +++ b/Dashboard/AddServerDialog.xaml.cs @@ -618,7 +618,8 @@ private async void InstallOrUpgrade_Click(object sender, RoutedEventArgs e) preValidationAction = async () => { AppendInstallLog("Installing community dependencies...", "Info"); - using var depInstaller = new DependencyInstaller(); + string communityDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "community"); + using var depInstaller = new DependencyInstaller(communityDir); await depInstaller.InstallDependenciesAsync(installerConnStr, progress, cancellationToken); }; } diff --git a/Installer.Core/DependencyInstaller.cs b/Installer.Core/DependencyInstaller.cs index 13aad3cb..b2ac49e8 100644 --- a/Installer.Core/DependencyInstaller.cs +++ b/Installer.Core/DependencyInstaller.cs @@ -14,23 +14,31 @@ namespace Installer.Core; /// /// Installs community dependencies (sp_WhoIsActive, DarlingData, First Responder Kit) -/// from GitHub. Requires an HttpClient — create one instance and dispose when done. +/// from a local community/ directory or GitHub. Local files are checked first — if +/// present, the network is not used. This supports air-gapped installations. /// public sealed class DependencyInstaller : IDisposable { private readonly HttpClient _httpClient; + private readonly string? _communityDirectory; private bool _disposed; - public DependencyInstaller() + /// + /// Optional path to a community/ directory containing pre-downloaded SQL files. + /// When provided and files exist, they are used instead of downloading from GitHub. + /// + public DependencyInstaller(string? communityDirectory = null) { _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + _communityDirectory = communityDirectory; } /// - /// Install community dependencies from GitHub into the PerformanceMonitor database. + /// Install community dependencies into the PerformanceMonitor database. + /// Checks the community/ directory first, falls back to GitHub download. /// Returns the number of successfully installed dependencies. /// public async Task InstallDependenciesAsync( @@ -38,21 +46,24 @@ public async Task InstallDependenciesAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { - var dependencies = new List<(string Name, string Url, string Description)> + var dependencies = new List<(string Name, string Url, string LocalFile, string Description)> { ( "sp_WhoIsActive", "https://raw.githubusercontent.com/amachanic/sp_whoisactive/refs/heads/master/sp_WhoIsActive.sql", + "sp_WhoIsActive.sql", "Query activity monitoring by Adam Machanic (GPLv3)" ), ( "DarlingData", "https://raw.githubusercontent.com/erikdarlingdata/DarlingData/main/Install-All/DarlingData.sql", + "DarlingData.sql", "sp_HealthParser, sp_HumanEventsBlockViewer by Erik Darling (MIT)" ), ( "First Responder Kit", "https://raw.githubusercontent.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/refs/heads/main/Install-All-Scripts.sql", + "Install-All-Scripts.sql", "sp_BlitzLock and diagnostic tools by Brent Ozar Unlimited (MIT)" ) }; @@ -65,7 +76,7 @@ public async Task InstallDependenciesAsync( int successCount = 0; - foreach (var (name, url, description) in dependencies) + foreach (var (name, url, localFile, description) in dependencies) { cancellationToken.ThrowIfCancellationRequested(); @@ -78,15 +89,40 @@ public async Task InstallDependenciesAsync( try { var depSw = Stopwatch.StartNew(); - progress?.Report(new InstallationProgress { Message = $"[DEBUG] Downloading {name} from {url}", Status = "Debug" }); - string sql = await DownloadWithRetryAsync(url, progress, cancellationToken: cancellationToken).ConfigureAwait(false); - progress?.Report(new InstallationProgress { Message = $"[DEBUG] {name}: downloaded {sql.Length} chars in {depSw.ElapsedMilliseconds}ms", Status = "Debug" }); + string sql; + + /* Check community/ directory first */ + string? localPath = ResolveLocalFile(localFile); + if (localPath != null) + { + progress?.Report(new InstallationProgress + { + Message = $"[DEBUG] {name}: loading from {localPath}", + Status = "Debug" + }); + sql = await File.ReadAllTextAsync(localPath, cancellationToken).ConfigureAwait(false); + } + else + { + progress?.Report(new InstallationProgress + { + Message = $"[DEBUG] Downloading {name} from {url}", + Status = "Debug" + }); + sql = await DownloadWithRetryAsync(url, progress, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + progress?.Report(new InstallationProgress + { + Message = $"[DEBUG] {name}: {(localPath != null ? "loaded" : "downloaded")} {sql.Length} chars in {depSw.ElapsedMilliseconds}ms", + Status = "Debug" + }); if (string.IsNullOrWhiteSpace(sql)) { progress?.Report(new InstallationProgress { - Message = $"{name} - FAILED (empty response)", + Message = $"{name} - FAILED (empty {(localPath != null ? "file" : "response")})", Status = "Error" }); continue; @@ -115,9 +151,10 @@ public async Task InstallDependenciesAsync( await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + string source = localPath != null ? "local" : "GitHub"; progress?.Report(new InstallationProgress { - Message = $"{name} - Success ({description})", + Message = $"{name} - Success ({description}) [{source}]", Status = "Success" }); @@ -158,6 +195,19 @@ public async Task InstallDependenciesAsync( return successCount; } + /// + /// Checks the community directory for a local copy of the dependency file. + /// Returns the full path if found, null otherwise. + /// + private string? ResolveLocalFile(string fileName) + { + if (string.IsNullOrEmpty(_communityDirectory) || !Directory.Exists(_communityDirectory)) + return null; + + string path = Path.Combine(_communityDirectory, fileName); + return File.Exists(path) ? path : null; + } + private async Task DownloadWithRetryAsync( string url, IProgress? progress = null, diff --git a/Installer/Program.cs b/Installer/Program.cs index bbb487a5..dd89646b 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -636,7 +636,8 @@ Execute SQL files in order Execute installation using Installer.Core Use DependencyInstaller for community dependencies before validation */ - using var dependencyInstaller = new DependencyInstaller(); + string communityDir = Path.Combine(monitorRootDirectory, "community"); + using var dependencyInstaller = new DependencyInstaller(communityDir); var installResult = await InstallationService.ExecuteInstallationAsync( connectionString, diff --git a/InstallerGui/MainWindow.xaml.cs b/InstallerGui/MainWindow.xaml.cs index 6103e949..1f532c89 100644 --- a/InstallerGui/MainWindow.xaml.cs +++ b/InstallerGui/MainWindow.xaml.cs @@ -60,7 +60,8 @@ public MainWindow() try { InitializeComponent(); - _dependencyInstaller = new DependencyInstaller(); + string communityDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "community"); + _dependencyInstaller = new DependencyInstaller(communityDir); /*Set window title with version*/ Title = $"Performance Monitor Installer v{AppVersion}"; diff --git a/community/README.md b/community/README.md new file mode 100644 index 00000000..9bc2b264 --- /dev/null +++ b/community/README.md @@ -0,0 +1,14 @@ +# Community Scripts (Offline Installation) + +Place pre-downloaded community SQL scripts in this directory for offline/air-gapped installations. +When files are present here, the installer uses them instead of downloading from GitHub. + +## Expected files + +| File | Source | License | +|------|--------|---------| +| `sp_WhoIsActive.sql` | [amachanic/sp_whoisactive](https://github.com/amachanic/sp_whoisactive) | GPLv3 | +| `DarlingData.sql` | [erikdarlingdata/DarlingData](https://github.com/erikdarlingdata/DarlingData/tree/main/Install-All) | MIT | +| `Install-All-Scripts.sql` | [BrentOzarULTD/SQL-Server-First-Responder-Kit](https://github.com/BrentOzarULTD/SQL-Server-First-Responder-Kit) | MIT | + +Any file not found here will be downloaded from GitHub as usual. From 31f97ee76dfec47737ce5bda64faa5bf31e5122c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:35:05 -0400 Subject: [PATCH 07/19] Add Host OS column to Server Inventory in Dashboard and Lite (#748) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries sys.dm_os_host_info (SQL 2017+) for the host_distribution value. Falls back to parsing @@VERSION for SQL 2016 and Azure SQL DB. No schema changes — the data is queried live alongside existing server properties. Fixes #748 Co-authored-by: Claude Opus 4.6 (1M context) --- Dashboard/Controls/FinOpsContent.xaml | 8 ++++++++ Dashboard/Services/DatabaseService.FinOps.cs | 20 +++++++++++++++++++- Lite/Controls/FinOpsTab.xaml | 8 ++++++++ Lite/Services/LocalDataService.FinOps.cs | 20 ++++++++++++++++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Dashboard/Controls/FinOpsContent.xaml b/Dashboard/Controls/FinOpsContent.xaml index c18bc1b5..c400c41a 100644 --- a/Dashboard/Controls/FinOpsContent.xaml +++ b/Dashboard/Controls/FinOpsContent.xaml @@ -2447,6 +2447,14 @@ + + + + private async Task LoadDataAsync(bool fullRefresh = true) { + if (_isRefreshing) return; + _isRefreshing = true; + using var _ = Helpers.MethodProfiler.StartTiming("ServerTab"); try { @@ -1139,12 +1097,19 @@ private async Task LoadDataAsync(bool fullRefresh = true) if (!connected) { StatusText.Text = $"Failed to connect to {_serverConnection.DisplayName}"; - MessageBox.Show( - $"Could not connect to SQL Server: {_serverConnection.ServerName}\n\nCheck connection settings", - "Connection Error", - MessageBoxButton.OK, - MessageBoxImage.Error - ); + if (fullRefresh) + { + MessageBox.Show( + $"Could not connect to SQL Server: {_serverConnection.ServerName}\n\nCheck connection settings", + "Connection Error", + MessageBoxButton.OK, + MessageBoxImage.Error + ); + } + else + { + Logger.Error($"Auto-refresh connection failed for {_serverConnection.DisplayName}"); + } return; } @@ -1167,16 +1132,24 @@ private async Task LoadDataAsync(bool fullRefresh = true) catch (Exception ex) { StatusText.Text = "Error loading data"; - MessageBox.Show( - $"Error loading data:\n\n{ex.Message}", - "Error", - MessageBoxButton.OK, - MessageBoxImage.Error - ); + if (fullRefresh) + { + MessageBox.Show( + $"Error loading data:\n\n{ex.Message}", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error + ); + } + else + { + Logger.Error($"Auto-refresh error for {_serverConnection.DisplayName}: {ex.Message}", ex); + } } finally { RefreshButton.IsEnabled = true; + _isRefreshing = false; } } From 64a62472b1fbea613af72d48bffe0cf460cc0673 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:04:42 -0400 Subject: [PATCH 15/19] Fix Dashboard auto-refresh stalling by using stop/start timer pattern (#834) The _isRefreshing guard in LoadDataAsync could get permanently stuck if any SQL query hung (connection drop, slow timeout), silently killing all subsequent auto-refresh ticks. Replaced with a stop/start pattern: the timer stops before each tick and restarts in finally, bypassing LoadDataAsync entirely for auto-refresh. This guarantees the timer always restarts regardless of what happens during the refresh. Also removed the redundant _isRefreshing guard from CorrelatedTimelineLanesControl.RefreshAsync which was independently causing Server Trends charts to silently skip updates. Co-authored-by: Claude Opus 4.6 (1M context) --- .../CorrelatedTimelineLanesControl.xaml.cs | 299 +++++++++--------- Dashboard/ServerTab.xaml.cs | 64 +++- 2 files changed, 204 insertions(+), 159 deletions(-) diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs index 9af24f3f..8fe1e734 100644 --- a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs +++ b/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs @@ -27,7 +27,6 @@ public partial class CorrelatedTimelineLanesControl : UserControl private DatabaseService? _dataService; private SqlServerBaselineProvider? _baselineProvider; private CorrelatedCrosshairManager? _crosshairManager; - private bool _isRefreshing; public CorrelatedTimelineLanesControl() { @@ -66,176 +65,168 @@ public void Initialize(DatabaseService dataService, SqlServerBaselineProvider? b public async Task RefreshAsync(int hoursBack, DateTime? fromDate, DateTime? toDate, (DateTime From, DateTime To)? comparisonRange = null) { - if (_dataService == null || _isRefreshing) return; - _isRefreshing = true; + if (_dataService == null) return; + + _crosshairManager?.PrepareForRefresh(); + + var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); + var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); + var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); + var deadlockTask = _dataService.GetDeadlockTrendAsync(hoursBack, fromDate, toDate); + var memoryTask = _dataService.GetMemoryStatsAsync(hoursBack, fromDate, toDate); + var fileIoTask = _dataService.GetFileIoLatencyTimeSeriesAsync(false, hoursBack, fromDate, toDate); + + // Fetch baselines for band rendering if provider is available + var referenceTime = fromDate ?? DateTime.UtcNow.AddHours(-hoursBack); + Task? cpuBaselineTask = null; + Task? waitBaselineTask = null; + Task? ioBaselineTask = null; + Task? blockingBaselineTask = null; + Task? deadlockBaselineTask = null; + + if (_baselineProvider != null) + { + cpuBaselineTask = GetBaselineAsync(SqlServerMetricNames.Cpu, referenceTime); + waitBaselineTask = GetBaselineAsync(SqlServerMetricNames.WaitStats, referenceTime); + ioBaselineTask = GetBaselineAsync(SqlServerMetricNames.IoLatency, referenceTime); + blockingBaselineTask = GetBaselineAsync(SqlServerMetricNames.Blocking, referenceTime); + deadlockBaselineTask = GetBaselineAsync(SqlServerMetricNames.Deadlock, referenceTime); + } try { - _crosshairManager?.PrepareForRefresh(); - - var cpuTask = _dataService.GetCpuUtilizationAsync(hoursBack, fromDate, toDate); - var waitTask = _dataService.GetTotalWaitStatsTrendAsync(hoursBack, fromDate, toDate); - var blockingTask = _dataService.GetBlockedSessionTrendAsync(hoursBack, fromDate, toDate); - var deadlockTask = _dataService.GetDeadlockTrendAsync(hoursBack, fromDate, toDate); - var memoryTask = _dataService.GetMemoryStatsAsync(hoursBack, fromDate, toDate); - var fileIoTask = _dataService.GetFileIoLatencyTimeSeriesAsync(false, hoursBack, fromDate, toDate); - - // Fetch baselines for band rendering if provider is available - var referenceTime = fromDate ?? DateTime.UtcNow.AddHours(-hoursBack); - Task? cpuBaselineTask = null; - Task? waitBaselineTask = null; - Task? ioBaselineTask = null; - Task? blockingBaselineTask = null; - Task? deadlockBaselineTask = null; - - if (_baselineProvider != null) - { - cpuBaselineTask = GetBaselineAsync(SqlServerMetricNames.Cpu, referenceTime); - waitBaselineTask = GetBaselineAsync(SqlServerMetricNames.WaitStats, referenceTime); - ioBaselineTask = GetBaselineAsync(SqlServerMetricNames.IoLatency, referenceTime); - blockingBaselineTask = GetBaselineAsync(SqlServerMetricNames.Blocking, referenceTime); - deadlockBaselineTask = GetBaselineAsync(SqlServerMetricNames.Deadlock, referenceTime); - } + var tasks = new List { cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fileIoTask }; + if (cpuBaselineTask != null) tasks.Add(cpuBaselineTask); + if (waitBaselineTask != null) tasks.Add(waitBaselineTask); + if (ioBaselineTask != null) tasks.Add(ioBaselineTask); + if (blockingBaselineTask != null) tasks.Add(blockingBaselineTask); + if (deadlockBaselineTask != null) tasks.Add(deadlockBaselineTask); + await Task.WhenAll(tasks); + } + catch (Exception ex) + { + Debug.WriteLine($"CorrelatedLanes: Data fetch failed: {ex.Message}"); + } - try - { - var tasks = new List { cpuTask, waitTask, blockingTask, deadlockTask, memoryTask, fileIoTask }; - if (cpuBaselineTask != null) tasks.Add(cpuBaselineTask); - if (waitBaselineTask != null) tasks.Add(waitBaselineTask); - if (ioBaselineTask != null) tasks.Add(ioBaselineTask); - if (blockingBaselineTask != null) tasks.Add(blockingBaselineTask); - if (deadlockBaselineTask != null) tasks.Add(deadlockBaselineTask); - await Task.WhenAll(tasks); - } - catch (Exception ex) - { - Debug.WriteLine($"CorrelatedLanes: Data fetch failed: {ex.Message}"); - } + var cpuBaseline = cpuBaselineTask is { IsCompletedSuccessfully: true } ? cpuBaselineTask.Result : null; + var waitBaseline = waitBaselineTask is { IsCompletedSuccessfully: true } ? waitBaselineTask.Result : null; + var ioBaseline = ioBaselineTask is { IsCompletedSuccessfully: true } ? ioBaselineTask.Result : null; + var blockingBaseline = blockingBaselineTask is { IsCompletedSuccessfully: true } ? blockingBaselineTask.Result : null; + var deadlockBaseline = deadlockBaselineTask is { IsCompletedSuccessfully: true } ? deadlockBaselineTask.Result : null; + var blockingLaneBaseline = blockingBaseline ?? deadlockBaseline; + + // minAnomalyValue: absolute floor below which dots/arrows are suppressed even if outside band. + // Prevents "1% CPU above 0.5% baseline" false alarms on idle servers. + if (cpuTask.IsCompletedSuccessfully) + UpdateLane(CpuChart, "CPU %", + cpuTask.Result.OrderBy(d => d.SampleTime) + .Select(d => (d.SampleTime.ToOADate(), (double)d.SqlServerCpuUtilization)).ToList(), + "#4FC3F7", 0, 105, cpuBaseline, minAnomalyValue: 10); + else + ShowEmpty(CpuChart, "CPU %"); - var cpuBaseline = cpuBaselineTask is { IsCompletedSuccessfully: true } ? cpuBaselineTask.Result : null; - var waitBaseline = waitBaselineTask is { IsCompletedSuccessfully: true } ? waitBaselineTask.Result : null; - var ioBaseline = ioBaselineTask is { IsCompletedSuccessfully: true } ? ioBaselineTask.Result : null; - var blockingBaseline = blockingBaselineTask is { IsCompletedSuccessfully: true } ? blockingBaselineTask.Result : null; - var deadlockBaseline = deadlockBaselineTask is { IsCompletedSuccessfully: true } ? deadlockBaselineTask.Result : null; - var blockingLaneBaseline = blockingBaseline ?? deadlockBaseline; - - // minAnomalyValue: absolute floor below which dots/arrows are suppressed even if outside band. - // Prevents "1% CPU above 0.5% baseline" false alarms on idle servers. - if (cpuTask.IsCompletedSuccessfully) - UpdateLane(CpuChart, "CPU %", - cpuTask.Result.OrderBy(d => d.SampleTime) - .Select(d => (d.SampleTime.ToOADate(), (double)d.SqlServerCpuUtilization)).ToList(), - "#4FC3F7", 0, 105, cpuBaseline, minAnomalyValue: 10); - else - ShowEmpty(CpuChart, "CPU %"); - - if (waitTask.IsCompletedSuccessfully) - UpdateLane(WaitStatsChart, "Wait ms/sec", - waitTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.WaitTimeMsPerSecond)).ToList(), - "#FFB74D", baseline: waitBaseline, minAnomalyValue: 100); - else - ShowEmpty(WaitStatsChart, "Wait ms/sec"); - - try - { - var blockingData = blockingTask.IsCompletedSuccessfully - ? blockingTask.Result - .GroupBy(d => d.CollectionTime) - .OrderBy(g => g.Key) - .Select(g => (g.Key.ToOADate(), (double)g.Sum(x => x.BlockedCount))) - .ToList() - : new List<(double, double)>(); - var deadlockData = deadlockTask.IsCompletedSuccessfully - ? deadlockTask.Result - .Select(d => (d.CollectionTime.ToOADate(), (double)d.BlockedCount)) - .ToList() - : new List<(double, double)>(); - UpdateBlockingLane(blockingData, deadlockData, blockingLaneBaseline); - } - catch (Exception ex) - { - Debug.WriteLine($"CorrelatedLanes: Blocking lane failed: {ex}"); - ShowEmpty(BlockingChart, "Blocking & Deadlocking"); - } + if (waitTask.IsCompletedSuccessfully) + UpdateLane(WaitStatsChart, "Wait ms/sec", + waitTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.WaitTimeMsPerSecond)).ToList(), + "#FFB74D", baseline: waitBaseline, minAnomalyValue: 100); + else + ShowEmpty(WaitStatsChart, "Wait ms/sec"); - if (memoryTask.IsCompletedSuccessfully) - UpdateLane(MemoryChart, "Buffer Pool MB", - memoryTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.TotalMemoryMb)).ToList(), - "#CE93D8"); - else - ShowEmpty(MemoryChart, "Buffer Pool MB"); + try + { + var blockingData = blockingTask.IsCompletedSuccessfully + ? blockingTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.ToOADate(), (double)g.Sum(x => x.BlockedCount))) + .ToList() + : new List<(double, double)>(); + var deadlockData = deadlockTask.IsCompletedSuccessfully + ? deadlockTask.Result + .Select(d => (d.CollectionTime.ToOADate(), (double)d.BlockedCount)) + .ToList() + : new List<(double, double)>(); + UpdateBlockingLane(blockingData, deadlockData, blockingLaneBaseline); + } + catch (Exception ex) + { + Debug.WriteLine($"CorrelatedLanes: Blocking lane failed: {ex}"); + ShowEmpty(BlockingChart, "Blocking & Deadlocking"); + } + + if (memoryTask.IsCompletedSuccessfully) + UpdateLane(MemoryChart, "Buffer Pool MB", + memoryTask.Result.Select(d => (d.CollectionTime.ToOADate(), (double)d.TotalMemoryMb)).ToList(), + "#CE93D8"); + else + ShowEmpty(MemoryChart, "Buffer Pool MB"); + + if (fileIoTask.IsCompletedSuccessfully) + { + var ioGrouped = fileIoTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.ToOADate(), (double)g.Average(x => x.ReadLatencyMs))) + .ToList(); + UpdateLane(FileIoChart, "I/O ms", ioGrouped, "#81C784", baseline: ioBaseline, minAnomalyValue: 2); + } + else + ShowEmpty(FileIoChart, "I/O ms"); + + // Comparison overlay — fetch reference period data and render as ghost lines + if (comparisonRange.HasValue) + { + var refFrom = comparisonRange.Value.From; + var refTo = comparisonRange.Value.To; + var timeShift = (fromDate ?? DateTime.UtcNow.AddHours(-hoursBack)) - refFrom; + + var refCpuTask = _dataService.GetCpuUtilizationAsync(0, refFrom, refTo); + var refWaitTask = _dataService.GetTotalWaitStatsTrendAsync(0, refFrom, refTo); + var refBlockingTask = _dataService.GetBlockedSessionTrendAsync(0, refFrom, refTo); + var refMemoryTask = _dataService.GetMemoryStatsAsync(0, refFrom, refTo); + var refIoTask = _dataService.GetFileIoLatencyTimeSeriesAsync(false, 0, refFrom, refTo); - if (fileIoTask.IsCompletedSuccessfully) + try { await Task.WhenAll(refCpuTask, refWaitTask, refBlockingTask, refMemoryTask, refIoTask); } + catch (Exception ex) { Debug.WriteLine($"CorrelatedLanes: Comparison fetch failed: {ex.Message}"); } + + if (refCpuTask.IsCompletedSuccessfully) + AddGhostLine(CpuChart, refCpuTask.Result + .Select(d => (d.SampleTime.Add(timeShift).ToOADate(), (double)d.SqlServerCpuUtilization)).ToList(), "#4FC3F7"); + + if (refWaitTask.IsCompletedSuccessfully) + AddGhostLine(WaitStatsChart, refWaitTask.Result + .Select(d => (d.CollectionTime.Add(timeShift).ToOADate(), (double)d.WaitTimeMsPerSecond)).ToList(), "#FFB74D"); + + if (refBlockingTask.IsCompletedSuccessfully) { - var ioGrouped = fileIoTask.Result + var refBlocking = refBlockingTask.Result .GroupBy(d => d.CollectionTime) .OrderBy(g => g.Key) - .Select(g => (g.Key.ToOADate(), (double)g.Average(x => x.ReadLatencyMs))) + .Select(g => (g.Key.Add(timeShift).ToOADate(), (double)g.Sum(x => x.BlockedCount))) .ToList(); - UpdateLane(FileIoChart, "I/O ms", ioGrouped, "#81C784", baseline: ioBaseline, minAnomalyValue: 2); + if (refBlocking.Count > 0) + AddGhostLine(BlockingChart, refBlocking, "#E57373"); } - else - ShowEmpty(FileIoChart, "I/O ms"); - // Comparison overlay — fetch reference period data and render as ghost lines - if (comparisonRange.HasValue) + if (refMemoryTask.IsCompletedSuccessfully) + AddGhostLine(MemoryChart, refMemoryTask.Result + .Select(d => (d.CollectionTime.Add(timeShift).ToOADate(), (double)d.TotalMemoryMb)).ToList(), "#CE93D8"); + + if (refIoTask.IsCompletedSuccessfully) { - var refFrom = comparisonRange.Value.From; - var refTo = comparisonRange.Value.To; - var timeShift = (fromDate ?? DateTime.UtcNow.AddHours(-hoursBack)) - refFrom; - - var refCpuTask = _dataService.GetCpuUtilizationAsync(0, refFrom, refTo); - var refWaitTask = _dataService.GetTotalWaitStatsTrendAsync(0, refFrom, refTo); - var refBlockingTask = _dataService.GetBlockedSessionTrendAsync(0, refFrom, refTo); - var refMemoryTask = _dataService.GetMemoryStatsAsync(0, refFrom, refTo); - var refIoTask = _dataService.GetFileIoLatencyTimeSeriesAsync(false, 0, refFrom, refTo); - - try { await Task.WhenAll(refCpuTask, refWaitTask, refBlockingTask, refMemoryTask, refIoTask); } - catch (Exception ex) { Debug.WriteLine($"CorrelatedLanes: Comparison fetch failed: {ex.Message}"); } - - if (refCpuTask.IsCompletedSuccessfully) - AddGhostLine(CpuChart, refCpuTask.Result - .Select(d => (d.SampleTime.Add(timeShift).ToOADate(), (double)d.SqlServerCpuUtilization)).ToList(), "#4FC3F7"); - - if (refWaitTask.IsCompletedSuccessfully) - AddGhostLine(WaitStatsChart, refWaitTask.Result - .Select(d => (d.CollectionTime.Add(timeShift).ToOADate(), (double)d.WaitTimeMsPerSecond)).ToList(), "#FFB74D"); - - if (refBlockingTask.IsCompletedSuccessfully) - { - var refBlocking = refBlockingTask.Result - .GroupBy(d => d.CollectionTime) - .OrderBy(g => g.Key) - .Select(g => (g.Key.Add(timeShift).ToOADate(), (double)g.Sum(x => x.BlockedCount))) - .ToList(); - if (refBlocking.Count > 0) - AddGhostLine(BlockingChart, refBlocking, "#E57373"); - } - - if (refMemoryTask.IsCompletedSuccessfully) - AddGhostLine(MemoryChart, refMemoryTask.Result - .Select(d => (d.CollectionTime.Add(timeShift).ToOADate(), (double)d.TotalMemoryMb)).ToList(), "#CE93D8"); - - if (refIoTask.IsCompletedSuccessfully) - { - var refIo = refIoTask.Result - .GroupBy(d => d.CollectionTime) - .OrderBy(g => g.Key) - .Select(g => (g.Key.Add(timeShift).ToOADate(), (double)g.Average(x => x.ReadLatencyMs))) - .ToList(); - AddGhostLine(FileIoChart, refIo, "#81C784"); - } - - _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); + var refIo = refIoTask.Result + .GroupBy(d => d.CollectionTime) + .OrderBy(g => g.Key) + .Select(g => (g.Key.Add(timeShift).ToOADate(), (double)g.Average(x => x.ReadLatencyMs))) + .ToList(); + AddGhostLine(FileIoChart, refIo, "#81C784"); } - _crosshairManager?.ReattachVLines(); - SyncXAxes(hoursBack, fromDate, toDate); - } - finally - { - _isRefreshing = false; + _crosshairManager?.SetComparisonLabel(ComparisonLabel(comparisonRange.Value, fromDate, hoursBack)); } + + _crosshairManager?.ReattachVLines(); + SyncXAxes(hoursBack, fromDate, toDate); } /// diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index e858f38a..2bfb3c33 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -47,6 +47,7 @@ public partial class ServerTab : UserControl private readonly UserPreferencesService _preferencesService; private DispatcherTimer? _autoRefreshTimer; private bool _isRefreshing; + private DateTime _refreshStartedUtc; private bool _suppressPickerUpdates; // Filter state dictionaries for each DataGrid @@ -344,7 +345,22 @@ private void SetupAutoRefresh() }; _autoRefreshTimer.Tick += async (s, e) => { - await LoadDataAsync(fullRefresh: false); + _autoRefreshTimer?.Stop(); + try + { + await RefreshVisibleTabAsync(); + StatusText.Text = "Ready"; + FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; + } + catch (Exception ex) + { + Logger.Error($"Auto-refresh error: {ex.Message}", ex); + StatusText.Text = "Auto-refresh error"; + } + finally + { + _autoRefreshTimer?.Start(); + } }; _autoRefreshTimer.Start(); AutoRefreshToggle.IsChecked = true; @@ -400,7 +416,22 @@ public void RefreshAutoRefreshSettings() }; _autoRefreshTimer.Tick += async (s, e) => { - await LoadDataAsync(fullRefresh: false); + _autoRefreshTimer?.Stop(); + try + { + await RefreshVisibleTabAsync(); + StatusText.Text = "Ready"; + FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; + } + catch (Exception ex) + { + Logger.Error($"Auto-refresh error: {ex.Message}", ex); + StatusText.Text = "Auto-refresh error"; + } + finally + { + _autoRefreshTimer?.Start(); + } }; _autoRefreshTimer.Start(); AutoRefreshToggle.IsChecked = true; @@ -434,7 +465,22 @@ private void AutoRefreshToggle_Click(object sender, RoutedEventArgs e) }; _autoRefreshTimer.Tick += async (s, args) => { - await LoadDataAsync(fullRefresh: false); + _autoRefreshTimer?.Stop(); + try + { + await RefreshVisibleTabAsync(); + StatusText.Text = "Ready"; + FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; + } + catch (Exception ex) + { + Logger.Error($"Auto-refresh error: {ex.Message}", ex); + StatusText.Text = "Auto-refresh error"; + } + finally + { + _autoRefreshTimer?.Start(); + } }; _autoRefreshTimer.Start(); AutoRefreshToggle.Content = $"Auto-Refresh: {prefs.AutoRefreshIntervalSeconds}s"; @@ -1084,8 +1130,14 @@ private async Task ApplyAndRefreshCurrentTabAsync() /// private async Task LoadDataAsync(bool fullRefresh = true) { - if (_isRefreshing) return; + if (_isRefreshing) + { + // If a previous refresh has been running for over 2 minutes, it's stuck — allow a new one + if ((DateTime.UtcNow - _refreshStartedUtc).TotalMinutes < 2) return; + Logger.Error($"Previous refresh appears stuck (started {_refreshStartedUtc:HH:mm:ss}), allowing new refresh"); + } _isRefreshing = true; + _refreshStartedUtc = DateTime.UtcNow; using var _ = Helpers.MethodProfiler.StartTiming("ServerTab"); try @@ -1616,9 +1668,11 @@ private async void DataTabControl_SelectionChanged(object sender, SelectionChang UpdateCompareDropdownState(); // Don't refresh during initial load or if already refreshing - if (_isRefreshing || !IsLoaded) return; + if (!IsLoaded) return; + if (_isRefreshing && (DateTime.UtcNow - _refreshStartedUtc).TotalMinutes < 2) return; _isRefreshing = true; + _refreshStartedUtc = DateTime.UtcNow; try { await RefreshVisibleTabAsync(); From e41992acd930351d527bbb84a9cddf53a6bf93fb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:37:05 -0400 Subject: [PATCH 16/19] Fix Dashboard memory leaks: dispose chart helpers, unsubscribe events on tab close (#835) Dashboard had the same memory leak patterns fixed in Lite (commit 2b61592) but never received those fixes. When server tabs were closed, 53+ event handlers and hover helpers remained subscribed, preventing GC from collecting the tab and its child controls. - Add Dispose() to Dashboard ChartHoverHelper (matches Lite) - Add DisposeChartHelpers() to ServerTab, MemoryContent, ResourceMetricsContent, and QueryPerformanceContent - Store lambda event delegates as named fields so they can be unsubscribed - Unsubscribe all child control events in ServerTab_Unloaded (slicers, CriticalIssuesTab, MemoryTab, PerformanceTab, ResourceMetricsContent) - Store and unsubscribe AlertAcknowledged handler in MainWindow.CloseTab_Click - Clear unfiltered data collections, filter dictionaries, legend panels, and baseline provider cache on tab close Co-Authored-By: Claude Opus 4.6 (1M context) --- Dashboard/Controls/MemoryContent.xaml.cs | 16 +++- .../Controls/QueryPerformanceContent.xaml.cs | 9 ++ .../Controls/ResourceMetricsContent.xaml.cs | 24 +++++- Dashboard/Helpers/ChartHoverHelper.cs | 8 ++ Dashboard/MainWindow.xaml.cs | 14 +++- Dashboard/ServerTab.xaml.cs | 83 +++++++++++++++---- 6 files changed, 130 insertions(+), 24 deletions(-) diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index 678ff5a3..522a6005 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -106,7 +106,11 @@ public MemoryContent() SetupChartContextMenus(); Loaded += OnLoaded; Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + Unloaded += (_, _) => + { + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + DisposeChartHelpers(); + }; // Apply dark theme immediately so charts don't flash white before data loads TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); @@ -124,6 +128,16 @@ public MemoryContent() _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); } + public void DisposeChartHelpers() + { + _memoryStatsOverviewHover?.Dispose(); + _memoryGrantSizingHover?.Dispose(); + _memoryGrantActivityHover?.Dispose(); + _memoryClerksHover?.Dispose(); + _planCacheHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); + } + private void OnLoaded(object sender, RoutedEventArgs e) { // No grids to configure - all tabs are chart-only now diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index f2147f83..8417afa8 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -276,9 +276,18 @@ private void OnUnloaded(object sender, RoutedEventArgs e) _qsRegressionsUnfilteredData = null; _lrqPatternsUnfilteredData = null; + DisposeChartHelpers(); Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; } + public void DisposeChartHelpers() + { + _queryDurationHover?.Dispose(); + _procDurationHover?.Dispose(); + _qsDurationHover?.Dispose(); + _execTrendsHover?.Dispose(); + } + private void OnThemeChanged(string _) { foreach (var field in GetType().GetFields( diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index e66c6e92..74329671 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -130,7 +130,11 @@ public ResourceMetricsContent() SetupChartContextMenus(); Loaded += OnLoaded; Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + Unloaded += (_, _) => + { + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + DisposeChartHelpers(); + }; // Apply dark theme immediately so charts don't flash white before data loads TabHelpers.ApplyThemeToChart(LatchStatsChart); @@ -158,11 +162,23 @@ public ResourceMetricsContent() _tempDbLatencyHover = new Helpers.ChartHoverHelper(TempDbLatencyChart, "ms"); } - private void OnLoaded(object sender, RoutedEventArgs e) + public void DisposeChartHelpers() { - // Apply minimum column widths based on header text + _sessionStatsHover?.Dispose(); + _latchStatsHover?.Dispose(); + _spinlockStatsHover?.Dispose(); + _fileIoReadHover?.Dispose(); + _fileIoWriteHover?.Dispose(); + _fileIoReadThroughputHover?.Dispose(); + _fileIoWriteThroughputHover?.Dispose(); + _perfmonHover?.Dispose(); + _waitStatsHover?.Dispose(); + _tempdbStatsHover?.Dispose(); + _tempDbLatencyHover?.Dispose(); + } - // Freeze identifier columns + private void OnLoaded(object sender, RoutedEventArgs e) + { } private void OnThemeChanged(string _) diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index 1fb73cc2..b1ec6f11 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -56,6 +56,14 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) public string Unit { get => _unit; set => _unit = value; } + public void Dispose() + { + _chart.MouseMove -= OnMouseMove; + _chart.MouseLeave -= OnMouseLeave; + _popup.IsOpen = false; + _scatters.Clear(); + } + public void Clear() => _scatters.Clear(); public void Add(ScottPlot.Plottables.Scatter scatter, string label) => diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 396e8ebb..94aa06d6 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -56,6 +56,8 @@ public partial class MainWindow : Window private Controls.FinOpsContent? _finOpsContent; private AlertsHistoryContent? _alertsHistoryContent; + private readonly Dictionary _alertAcknowledgedHandlers = new(); + private McpHostService? _mcpHostService; private CancellationTokenSource? _mcpCts; @@ -571,12 +573,14 @@ private async Task OpenServerTabAsync(ServerConnection server) System.Windows.MessageBoxImage.Error); return; } - serverTab.AlertAcknowledged += (_, _) => + EventHandler alertHandler = (_, _) => { _emailAlertService.HideAllAlerts(8760, server.DisplayNameWithIntent); UpdateAlertBadge(); _alertsHistoryContent?.RefreshAlerts(); }; + serverTab.AlertAcknowledged += alertHandler; + _alertAcknowledgedHandlers[server.Id] = alertHandler; var headerPanel = new StackPanel { Orientation = Orientation.Horizontal }; var headerText = new TextBlock @@ -875,6 +879,14 @@ private void CloseTab_Click(object sender, RoutedEventArgs e) } else if (_openTabs.TryGetValue(tabId, out var tabToClose)) { + if (tabToClose.Content is ServerTab serverTab) + { + if (_alertAcknowledgedHandlers.TryGetValue(tabId, out var handler)) + { + serverTab.AlertAcknowledged -= handler; + _alertAcknowledgedHandlers.Remove(tabId); + } + } _openTabs.Remove(tabId); _tabBadges.Remove(tabId); ServerTabControl.Items.Remove(tabToClose); diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 2bfb3c33..42ab6a1d 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -70,6 +70,14 @@ public partial class ServerTab : UserControl // Legend panel references for edge-based legends (ScottPlot issue #4717 workaround) private Dictionary _legendPanels = new(); + // Stored event handler delegates for cleanup + private Action? _viewPlanHandler; + private Action? _actualPlanStartedHandler; + private Action? _actualPlanFinishedHandler; + private Action? _drillDownTimeRangeHandler; + private Action? _subTabChangedHandler; + private Analysis.SqlServerBaselineProvider? _baselineProvider; + // Chart hover tooltips private Helpers.ChartHoverHelper? _resourceOverviewCpuHover; private Helpers.ChartHoverHelper? _resourceOverviewMemoryHover; @@ -141,27 +149,23 @@ public ServerTab(ServerConnection serverConnection, int utcOffsetMinutes = 0) MemoryTab.Initialize(_databaseService); MemoryTab.ChartDrillDownRequested += OnChildChartDrillDown; PerformanceTab.Initialize(_databaseService, s => StatusText.Text = s); - PerformanceTab.ViewPlanRequested += (planXml, label, queryText) => + _viewPlanHandler = (planXml, label, queryText) => { OpenPlanTab(planXml, label, queryText); PlanViewerTabItem.IsSelected = true; }; - PerformanceTab.ActualPlanStarted += (label) => - { - ShowPlanLoading(label); - }; - PerformanceTab.ActualPlanFinished += () => - { - HidePlanLoading(); - }; - PerformanceTab.DrillDownTimeRangeRequested += (from, to) => - { - SetDrillDownGlobalRange(from, to); - }; - PerformanceTab.SubTabChanged += () => UpdateCompareDropdownState(); + _actualPlanStartedHandler = (label) => ShowPlanLoading(label); + _actualPlanFinishedHandler = () => HidePlanLoading(); + _drillDownTimeRangeHandler = (from, to) => SetDrillDownGlobalRange(from, to); + _subTabChangedHandler = () => UpdateCompareDropdownState(); + PerformanceTab.ViewPlanRequested += _viewPlanHandler; + PerformanceTab.ActualPlanStarted += _actualPlanStartedHandler; + PerformanceTab.ActualPlanFinished += _actualPlanFinishedHandler; + PerformanceTab.DrillDownTimeRangeRequested += _drillDownTimeRangeHandler; + PerformanceTab.SubTabChanged += _subTabChangedHandler; SystemEventsContent.Initialize(_databaseService); - var baselineProvider = new Analysis.SqlServerBaselineProvider(_databaseService.ConnectionString); - ResourceMetricsContent.Initialize(_databaseService, baselineProvider); + _baselineProvider = new Analysis.SqlServerBaselineProvider(_databaseService.ConnectionString); + ResourceMetricsContent.Initialize(_databaseService, _baselineProvider); ResourceMetricsContent.ChartDrillDownRequested += OnChildChartDrillDown; // Set default time range on UserControls based on user preferences @@ -375,15 +379,58 @@ private void SetupAutoRefresh() private void ServerTab_Unloaded(object sender, RoutedEventArgs e) { - // Stop the timer when the tab is closed _autoRefreshTimer?.Stop(); _autoRefreshTimer = null; - // Unsubscribe event handlers to prevent memory leaks Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; Loaded -= ServerTab_Loaded; Unloaded -= ServerTab_Unloaded; KeyDown -= ServerTab_KeyDown; + + BlockingSlicer.RangeChanged -= OnBlockingSlicerChanged; + DeadlockSlicer.RangeChanged -= OnDeadlockSlicerChanged; + + CriticalIssuesTab.InvestigateRequested -= OnInvestigateCriticalIssue; + MemoryTab.ChartDrillDownRequested -= OnChildChartDrillDown; + ResourceMetricsContent.ChartDrillDownRequested -= OnChildChartDrillDown; + + if (_viewPlanHandler != null) PerformanceTab.ViewPlanRequested -= _viewPlanHandler; + if (_actualPlanStartedHandler != null) PerformanceTab.ActualPlanStarted -= _actualPlanStartedHandler; + if (_actualPlanFinishedHandler != null) PerformanceTab.ActualPlanFinished -= _actualPlanFinishedHandler; + if (_drillDownTimeRangeHandler != null) PerformanceTab.DrillDownTimeRangeRequested -= _drillDownTimeRangeHandler; + if (_subTabChangedHandler != null) PerformanceTab.SubTabChanged -= _subTabChangedHandler; + + DisposeChartHelpers(); + + _collectionHealthUnfilteredData = null; + _blockingEventsUnfilteredData = null; + _deadlocksUnfilteredData = null; + _collectionHealthFilters.Clear(); + _blockingEventsFilters.Clear(); + _deadlocksFilters.Clear(); + _legendPanels.Clear(); + + _baselineProvider?.ClearCache(); + } + + public void DisposeChartHelpers() + { + _resourceOverviewCpuHover?.Dispose(); + _resourceOverviewMemoryHover?.Dispose(); + _resourceOverviewIoHover?.Dispose(); + _resourceOverviewWaitHover?.Dispose(); + _lockWaitStatsHover?.Dispose(); + _blockingEventsHover?.Dispose(); + _blockingDurationHover?.Dispose(); + _deadlocksHover?.Dispose(); + _deadlockWaitTimeHover?.Dispose(); + _collectorDurationHover?.Dispose(); + _currentWaitsDurationHover?.Dispose(); + _currentWaitsBlockedHover?.Dispose(); + + MemoryTab.DisposeChartHelpers(); + ResourceMetricsContent.DisposeChartHelpers(); + PerformanceTab.DisposeChartHelpers(); } private void OnThemeChanged(string _) From 87cdbafa9439b60ed92a18b6469daec84f83d5be Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:22:13 -0400 Subject: [PATCH 17/19] Release v2.7.0: version bumps, changelog, auto-refresh fix - Bump version to 2.7.0 in all 4 csproj files - Add CHANGELOG entry for v2.7.0 (features, changes, fixes) - Update README: MultiSubnetFailover AG note, air-gapped community scripts - Replace DispatcherTimer auto-refresh with async Task.Delay loop to prevent priority starvation under heavy UI load (chart rendering) Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 42 +++++++ Dashboard/Dashboard.csproj | 8 +- Dashboard/ServerTab.xaml.cs | 117 ++++++++----------- Installer.Core/Installer.Core.csproj | 8 +- Installer/PerformanceMonitorInstaller.csproj | 8 +- Lite/PerformanceMonitorLite.csproj | 8 +- README.md | 4 +- 7 files changed, 108 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ebd159..eee63d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ 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.7.0] - 2026-04-13 + +### Added + +- **Host OS column** in Server Inventory for both Dashboard and Lite ([#748], [#823]) +- **Offline community script support** via `community/` directory for user-contributed scripts ([#814], [#822]) +- **MultiSubnetFailover connection option** in Dashboard and Lite for Always On availability groups ([#813], [#821]) + +### Changed + +- **PlanAnalyzer and ShowPlanParser** synced from PerformanceStudio with latest improvements ([#816]) +- **MCP query tools** optimized for large databases ([#826]) +- **Add Server dialog UX** improved with inline connection status and full-height window +- **"CPUs" renamed to "Logical CPUs"** for clarity in Lite ([#825]) + +### Fixed + +- **Dashboard auto-refresh stalling under load** — replaced DispatcherTimer with async Task.Delay loop to prevent priority starvation during heavy chart rendering ([#833], [#834]) +- **Lite auto-refresh silently skipping** every tick ([#824]) +- **Deadlock count not resetting** between collections ([#803], [#820]) +- **Upgrade filter skipping patch versions** during version comparison ([#817], [#819]) +- **Upgrade script executing against master** instead of PerformanceMonitor database ([#828]) +- **Duplicate release builds** triggering on both created and published events + +[#748]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/748 +[#803]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/803 +[#813]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/813 +[#814]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/814 +[#816]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/816 +[#817]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/817 +[#819]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/819 +[#820]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/820 +[#821]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/821 +[#822]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/822 +[#823]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/823 +[#824]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/824 +[#825]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/825 +[#826]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/826 +[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 +[#833]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/833 +[#834]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/834 + ## [2.6.0] - 2026-04-08 ### Added diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index 8fc70396..b7a0103f 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -7,10 +7,10 @@ PerformanceMonitorDashboard.Program PerformanceMonitorDashboard SQL Server Performance Monitor Dashboard - 2.6.0 - 2.6.0.0 - 2.6.0.0 - 2.6.0 + 2.7.0 + 2.7.0.0 + 2.7.0.0 + 2.7.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC EDD.ico diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 42ab6a1d..a67daf89 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -5,6 +5,7 @@ using System.Windows.Data; using System.Text; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -46,6 +47,7 @@ public partial class ServerTab : UserControl private readonly UserPreferencesService _preferencesService; private DispatcherTimer? _autoRefreshTimer; + private CancellationTokenSource? _autoRefreshCts; private bool _isRefreshing; private DateTime _refreshStartedUtc; private bool _suppressPickerUpdates; @@ -127,7 +129,6 @@ public ServerTab(ServerConnection serverConnection, int utcOffsetMinutes = 0) InitializeDefaultTimeRanges(); SetupChartContextMenus(); - SetupAutoRefresh(); SetupSubTabContextMenus(); BlockingSlicer.RangeChanged += OnBlockingSlicerChanged; @@ -343,42 +344,62 @@ private void SetupAutoRefresh() if (prefs.AutoRefreshEnabled) { - _autoRefreshTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(prefs.AutoRefreshIntervalSeconds) - }; - _autoRefreshTimer.Tick += async (s, e) => + StartAutoRefreshLoop(prefs.AutoRefreshIntervalSeconds); + AutoRefreshToggle.IsChecked = true; + AutoRefreshToggle.Content = $"Auto-Refresh: {prefs.AutoRefreshIntervalSeconds}s"; + } + else + { + AutoRefreshToggle.IsChecked = false; + AutoRefreshToggle.Content = "Auto-Refresh: Off"; + } + } + + /// + /// Async loop that replaces DispatcherTimer for auto-refresh. Task.Delay is not + /// subject to Dispatcher priority starvation under heavy UI load (chart rendering, + /// data binding) that can indefinitely defer Background-priority DispatcherTimer ticks. + /// + private async void StartAutoRefreshLoop(int intervalSeconds) + { + if (_autoRefreshCts != null && !_autoRefreshCts.IsCancellationRequested) + return; + + _autoRefreshCts?.Cancel(); + var cts = new CancellationTokenSource(); + _autoRefreshCts = cts; + + try + { + while (!cts.Token.IsCancellationRequested) { - _autoRefreshTimer?.Stop(); + await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cts.Token); + if (cts.Token.IsCancellationRequested) break; + try { + var sw = System.Diagnostics.Stopwatch.StartNew(); await RefreshVisibleTabAsync(); StatusText.Text = "Ready"; FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; + Logger.Info($"Auto-refresh completed in {sw.ElapsedMilliseconds}ms for {_serverConnection.DisplayName}"); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { Logger.Error($"Auto-refresh error: {ex.Message}", ex); StatusText.Text = "Auto-refresh error"; } - finally - { - _autoRefreshTimer?.Start(); - } - }; - _autoRefreshTimer.Start(); - AutoRefreshToggle.IsChecked = true; - AutoRefreshToggle.Content = $"Auto-Refresh: {prefs.AutoRefreshIntervalSeconds}s"; + } } - else + catch (OperationCanceledException) { - AutoRefreshToggle.IsChecked = false; - AutoRefreshToggle.Content = "Auto-Refresh: Off"; + // Normal shutdown } } private void ServerTab_Unloaded(object sender, RoutedEventArgs e) { + _autoRefreshCts?.Cancel(); _autoRefreshTimer?.Stop(); _autoRefreshTimer = null; @@ -448,7 +469,9 @@ private void OnThemeChanged(string _) public void RefreshAutoRefreshSettings() { - // Stop existing timer + // Stop existing loop and timer + _autoRefreshCts?.Cancel(); + _autoRefreshCts = null; _autoRefreshTimer?.Stop(); _autoRefreshTimer = null; @@ -457,30 +480,7 @@ public void RefreshAutoRefreshSettings() if (prefs.AutoRefreshEnabled) { - _autoRefreshTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(prefs.AutoRefreshIntervalSeconds) - }; - _autoRefreshTimer.Tick += async (s, e) => - { - _autoRefreshTimer?.Stop(); - try - { - await RefreshVisibleTabAsync(); - StatusText.Text = "Ready"; - FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; - } - catch (Exception ex) - { - Logger.Error($"Auto-refresh error: {ex.Message}", ex); - StatusText.Text = "Auto-refresh error"; - } - finally - { - _autoRefreshTimer?.Start(); - } - }; - _autoRefreshTimer.Start(); + StartAutoRefreshLoop(prefs.AutoRefreshIntervalSeconds); AutoRefreshToggle.IsChecked = true; AutoRefreshToggle.Content = $"Auto-Refresh: {prefs.AutoRefreshIntervalSeconds}s"; } @@ -506,30 +506,7 @@ private void AutoRefreshToggle_Click(object sender, RoutedEventArgs e) prefs.AutoRefreshEnabled = true; _preferencesService.SavePreferences(prefs); - _autoRefreshTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(prefs.AutoRefreshIntervalSeconds) - }; - _autoRefreshTimer.Tick += async (s, args) => - { - _autoRefreshTimer?.Stop(); - try - { - await RefreshVisibleTabAsync(); - StatusText.Text = "Ready"; - FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; - } - catch (Exception ex) - { - Logger.Error($"Auto-refresh error: {ex.Message}", ex); - StatusText.Text = "Auto-refresh error"; - } - finally - { - _autoRefreshTimer?.Start(); - } - }; - _autoRefreshTimer.Start(); + StartAutoRefreshLoop(prefs.AutoRefreshIntervalSeconds); AutoRefreshToggle.Content = $"Auto-Refresh: {prefs.AutoRefreshIntervalSeconds}s"; } else @@ -538,8 +515,7 @@ private void AutoRefreshToggle_Click(object sender, RoutedEventArgs e) prefs.AutoRefreshEnabled = false; _preferencesService.SavePreferences(prefs); - _autoRefreshTimer?.Stop(); - _autoRefreshTimer = null; + _autoRefreshCts?.Cancel(); AutoRefreshToggle.Content = "Auto-Refresh: Off"; } } @@ -643,6 +619,7 @@ private async void ServerTab_Loaded(object sender, RoutedEventArgs e) DefaultTraceTab.SetTimeRange(_globalHoursBack, _globalFromDate, _globalToDate); await LoadDataAsync(); + SetupAutoRefresh(); } catch (Exception ex) { diff --git a/Installer.Core/Installer.Core.csproj b/Installer.Core/Installer.Core.csproj index fbf0387b..bb5f1f32 100644 --- a/Installer.Core/Installer.Core.csproj +++ b/Installer.Core/Installer.Core.csproj @@ -7,10 +7,10 @@ Installer.Core Installer.Core SQL Server Performance Monitor Installer Core - 2.6.0 - 2.6.0.0 - 2.6.0.0 - 2.6.0 + 2.7.0 + 2.7.0.0 + 2.7.0.0 + 2.7.0 Darling Data, LLC Copyright (c) 2026 Darling Data, LLC true diff --git a/Installer/PerformanceMonitorInstaller.csproj b/Installer/PerformanceMonitorInstaller.csproj index 7c3fa1a9..a8293d15 100644 --- a/Installer/PerformanceMonitorInstaller.csproj +++ b/Installer/PerformanceMonitorInstaller.csproj @@ -20,10 +20,10 @@ PerformanceMonitorInstaller SQL Server Performance Monitor Installer - 2.6.0 - 2.6.0.0 - 2.6.0.0 - 2.6.0 + 2.7.0 + 2.7.0.0 + 2.7.0.0 + 2.7.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025 diff --git a/Lite/PerformanceMonitorLite.csproj b/Lite/PerformanceMonitorLite.csproj index d87dde0a..784cd81b 100644 --- a/Lite/PerformanceMonitorLite.csproj +++ b/Lite/PerformanceMonitorLite.csproj @@ -8,10 +8,10 @@ PerformanceMonitorLite PerformanceMonitorLite SQL Server Performance Monitor Lite - 2.6.0 - 2.6.0.0 - 2.6.0.0 - 2.6.0 + 2.7.0 + 2.7.0.0 + 2.7.0.0 + 2.7.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Lightweight SQL Server performance monitoring - no installation required on target servers diff --git a/README.md b/README.md index 9390c014..19402312 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser **Upgrading from zip?** Click **Import Settings** then **Import Data** in the sidebar and point both at your old Lite folder. Settings imports server connections, alert thresholds, SMTP config, and schedules. Data imports historical DuckDB + Parquet archives. **Auto-update users** (installed via Setup.exe) get updates automatically — no manual import needed. -**Always On AG?** Enable **ReadOnlyIntent** in the connection settings to route Lite's monitoring queries to a readable secondary, keeping the primary clear. +**Always On AG?** Enable **ReadOnlyIntent** in the connection settings to route Lite's monitoring queries to a readable secondary, keeping the primary clear. Enable **MultiSubnetFailover** for multi-subnet failover scenarios. ### Lite Collectors @@ -191,6 +191,8 @@ PerformanceMonitorInstaller.exe YourServerName sa YourPassword --uninstall The installer automatically tests the connection, checks the SQL Server version (2016+ required), executes SQL scripts, downloads community dependencies, creates SQL Agent jobs, and runs initial data collection. You can also install directly from the Dashboard's Add Server dialog. +**Air-gapped environments?** Place pre-downloaded community scripts (`sp_WhoIsActive.sql`, `DarlingData.sql`, `Install-All-Scripts.sql`) in a `community/` directory next to the installer. The installer uses local files when present and falls back to GitHub downloads otherwise. + ### CLI Installer Options | Option | Description | From cb5bc9ac00ebcb50ad8ae2edd642dbafa8fa698a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:14:47 -0400 Subject: [PATCH 18/19] Fix Dashboard auto-refresh: async loop, survive tab close, prevent legend duplication Replace DispatcherTimer with async Task.Delay loop to prevent priority starvation under heavy UI load. Don't cancel the loop on Unloaded (WPF fires Unloaded on tab switch/close, not just control destruction). Catch OperationCanceledException from SQL queries without killing the loop. Skip auto-refresh ticks while a full refresh is in progress to prevent concurrent chart rendering that duplicates legends. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dashboard/ServerTab.xaml.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index a67daf89..4f990bfc 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -375,6 +375,7 @@ private async void StartAutoRefreshLoop(int intervalSeconds) { await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cts.Token); if (cts.Token.IsCancellationRequested) break; + if (_isRefreshing) continue; try { @@ -384,6 +385,11 @@ private async void StartAutoRefreshLoop(int intervalSeconds) FooterText.Text = $"Last refresh: {DateTime.Now:yyyy-MM-dd HH:mm:ss} | Server: {_serverConnection.DisplayName}"; Logger.Info($"Auto-refresh completed in {sw.ElapsedMilliseconds}ms for {_serverConnection.DisplayName}"); } + catch (OperationCanceledException) when (!cts.Token.IsCancellationRequested) + { + // SQL query cancelled or timed out, but our loop CTS is still alive — keep going + Logger.Error($"Auto-refresh query cancelled for {_serverConnection.DisplayName}, continuing loop"); + } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.Error($"Auto-refresh error: {ex.Message}", ex); @@ -393,13 +399,15 @@ private async void StartAutoRefreshLoop(int intervalSeconds) } catch (OperationCanceledException) { - // Normal shutdown + Logger.Info($"Auto-refresh loop stopped for {_serverConnection.DisplayName}"); } } private void ServerTab_Unloaded(object sender, RoutedEventArgs e) { - _autoRefreshCts?.Cancel(); + // Don't cancel auto-refresh on tab switch — WPF fires Unloaded when + // a TabItem is deselected, not just when the control is destroyed. + // The loop is lightweight and should keep ticking in the background. _autoRefreshTimer?.Stop(); _autoRefreshTimer = null; From 1e40ef32e18a8cab0e4b5b7d2b46c101388527c5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:23:08 -0400 Subject: [PATCH 19/19] Fix legend duplication on tab switch: move cleanup from Unloaded to CleanupOnClose WPF fires Unloaded on tab switch, not just destruction. The old handler tore down legend tracking, chart helpers, and event subscriptions on every tab switch, causing the auto-refresh loop to lose track of legend panels and create duplicates. Now Unloaded is a no-op; full cleanup only runs via CleanupOnClose when a tab is actually closed/removed. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dashboard/MainWindow.xaml.cs | 2 ++ Dashboard/ServerTab.xaml.cs | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Dashboard/MainWindow.xaml.cs b/Dashboard/MainWindow.xaml.cs index 94aa06d6..dfb032d2 100644 --- a/Dashboard/MainWindow.xaml.cs +++ b/Dashboard/MainWindow.xaml.cs @@ -886,6 +886,7 @@ private void CloseTab_Click(object sender, RoutedEventArgs e) serverTab.AlertAcknowledged -= handler; _alertAcknowledgedHandlers.Remove(tabId); } + serverTab.CleanupOnClose(); } _openTabs.Remove(tabId); _tabBadges.Remove(tabId); @@ -1092,6 +1093,7 @@ private async void RemoveServer_Click(object sender, RoutedEventArgs e) if (_openTabs.TryGetValue(server.Id, out var tabItem)) { + if (tabItem.Content is ServerTab st) st.CleanupOnClose(); _openTabs.Remove(server.Id); ServerTabControl.Items.Remove(tabItem); } diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index 4f990bfc..4ba7629e 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -377,6 +377,8 @@ private async void StartAutoRefreshLoop(int intervalSeconds) if (cts.Token.IsCancellationRequested) break; if (_isRefreshing) continue; + _isRefreshing = true; + _refreshStartedUtc = DateTime.UtcNow; try { var sw = System.Diagnostics.Stopwatch.StartNew(); @@ -387,7 +389,6 @@ private async void StartAutoRefreshLoop(int intervalSeconds) } catch (OperationCanceledException) when (!cts.Token.IsCancellationRequested) { - // SQL query cancelled or timed out, but our loop CTS is still alive — keep going Logger.Error($"Auto-refresh query cancelled for {_serverConnection.DisplayName}, continuing loop"); } catch (Exception ex) when (ex is not OperationCanceledException) @@ -395,6 +396,10 @@ private async void StartAutoRefreshLoop(int intervalSeconds) Logger.Error($"Auto-refresh error: {ex.Message}", ex); StatusText.Text = "Auto-refresh error"; } + finally + { + _isRefreshing = false; + } } } catch (OperationCanceledException) @@ -405,9 +410,18 @@ private async void StartAutoRefreshLoop(int intervalSeconds) private void ServerTab_Unloaded(object sender, RoutedEventArgs e) { - // Don't cancel auto-refresh on tab switch — WPF fires Unloaded when - // a TabItem is deselected, not just when the control is destroyed. - // The loop is lightweight and should keep ticking in the background. + // WPF fires Unloaded on tab switch, not just destruction. + // Don't tear down state here — the auto-refresh loop and chart + // state must survive tab switches. Cleanup happens when the tab + // is actually removed from the TabControl (via CleanupOnClose). + } + + /// + /// Full cleanup — call when the server tab is permanently removed, not on tab switch. + /// + public void CleanupOnClose() + { + _autoRefreshCts?.Cancel(); _autoRefreshTimer?.Stop(); _autoRefreshTimer = null;