diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/Dashboard.Tests/RecommendationDeduperTests.cs new file mode 100644 index 00000000..f493605b --- /dev/null +++ b/Dashboard.Tests/RecommendationDeduperTests.cs @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services.Recommendations; +using Xunit; + +namespace PerformanceMonitorDashboard.Tests; + +/// +/// WS1a coverage for the PURE Recommendations data layer (no DB): the cross-store de-dupe +/// (C3) and canonical severity mapping. Every test synthesizes +/// lists (or scalars) and calls the static / the +/// mappers directly. The actual two-store read is NOT +/// unit-tested here (it needs a live DB — see the PR's integration list). +/// +public class RecommendationDeduperTests +{ + // ---- builders --------------------------------------------------------- + + private static RecommendationItem EngineItem( + RecommendationSetting setting, + string? db, + double rawSeverity = 0.3, + string title = "engine") + { + var band = RecommendationDeduper.FromEngineSeverity(rawSeverity); + return new RecommendationItem + { + Source = RecommendationSource.Engine, + CanonicalSeverity = band, + RawSeverity = rawSeverity, + Database = db, + Title = title, + ProblemArea = "db_config", + Setting = setting, + StoryPathHash = "hash-" + title + }; + } + + private static RecommendationItem LegacyItem( + RecommendationSetting setting, + string? db, + CanonicalSeverity band = CanonicalSeverity.Warning, + string title = "legacy") + { + return new RecommendationItem + { + Source = RecommendationSource.Legacy, + CanonicalSeverity = band, + RawSeverity = RecommendationDeduper.LegacyRawSeverity(band), + Database = db, + Title = title, + ProblemArea = "Database Configuration", + Setting = setting + }; + } + + // ---- de-dupe: collisions --------------------------------------------- + + [Fact] + public void Merge_AutoShrink_SameDb_BothStores_KeepsEngineOnly() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.AutoShrink, row.Setting); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + [Fact] + public void Merge_AutoClose_SameDb_BothStores_KeepsEngineOnly() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoClose, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoClose, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.AutoClose, row.Setting); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + [Fact] + public void Merge_QueryStore_SameDb_BothStores_KeepsLegacyOnly() + { + // Synthesize an engine QS row even though prod does not emit one today — the de-dupe + // must still prefer the legacy row for QueryStore (the engine has no QS Apply). + var engine = new[] { EngineItem(RecommendationSetting.QueryStore, "MyDb") }; + var legacy = new[] { LegacyItem(RecommendationSetting.QueryStore, "MyDb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSetting.QueryStore, row.Setting); + Assert.Equal(RecommendationSource.Legacy, row.Source); + } + + [Fact] + public void Merge_SameSetting_DifferentDatabases_KeepsBoth() + { + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, "DbOne") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "DbTwo") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Source == RecommendationSource.Engine && r.Database == "DbOne"); + Assert.Contains(result, r => r.Source == RecommendationSource.Legacy && r.Database == "DbTwo"); + } + + [Fact] + public void Merge_DatabaseNameMatch_IsCaseAndWhitespaceInsensitive() + { + // " MyDb " (engine) vs "mydb" (legacy) must be treated as the SAME database. + var engine = new[] { EngineItem(RecommendationSetting.AutoShrink, " MyDb ") }; + var legacy = new[] { LegacyItem(RecommendationSetting.AutoShrink, "mydb") }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + var row = Assert.Single(result); + Assert.Equal(RecommendationSource.Engine, row.Source); + } + + // ---- de-dupe: pass-through (Setting=None) ---------------------------- + + [Fact] + public void Merge_NoneSettingRows_NeverDedupe() + { + // A memory-pressure legacy row (None) + an engine CPU finding (None) both pass through. + var legacyMemory = LegacyItem(RecommendationSetting.None, "MyDb", + band: CanonicalSeverity.Critical, title: "Memory Pressure"); + var engineCpu = EngineItem(RecommendationSetting.None, "MyDb", + rawSeverity: 1.6, title: "High CPU"); + + var result = RecommendationDeduper.Merge(new[] { engineCpu }, new[] { legacyMemory }); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Title == "Memory Pressure" && r.Source == RecommendationSource.Legacy); + Assert.Contains(result, r => r.Title == "High CPU" && r.Source == RecommendationSource.Engine); + } + + [Fact] + public void Merge_TwoNoneRows_SameDb_AreNotCollapsed() + { + // Even with identical (db) the None setting must never key together. + var a = LegacyItem(RecommendationSetting.None, "MyDb", title: "A"); + var b = LegacyItem(RecommendationSetting.None, "MyDb", title: "B"); + + var result = RecommendationDeduper.Merge(Array.Empty(), new[] { a, b }); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void Merge_NonOverlappingSettings_AllKept() + { + var engine = new[] + { + EngineItem(RecommendationSetting.Rcsi, "MyDb"), + EngineItem(RecommendationSetting.PageVerify, "MyDb"), + EngineItem(RecommendationSetting.AutoShrink, "MyDb") + }; + var legacy = new[] + { + LegacyItem(RecommendationSetting.QueryStore, "MyDb") + }; + + var result = RecommendationDeduper.Merge(engine, legacy); + + Assert.Equal(4, result.Count); + Assert.Contains(result, r => r.Setting == RecommendationSetting.Rcsi); + Assert.Contains(result, r => r.Setting == RecommendationSetting.PageVerify); + Assert.Contains(result, r => r.Setting == RecommendationSetting.AutoShrink && r.Source == RecommendationSource.Engine); + Assert.Contains(result, r => r.Setting == RecommendationSetting.QueryStore && r.Source == RecommendationSource.Legacy); + } + + // ---- de-dupe: ordering ---------------------------------------------- + + [Fact] + public void Merge_SortsBySeverityDescending() + { + var info = EngineItem(RecommendationSetting.None, "DbA", rawSeverity: 0.3, title: "info"); + var critical = EngineItem(RecommendationSetting.None, "DbB", rawSeverity: 1.8, title: "critical"); + var warning = LegacyItem(RecommendationSetting.None, "DbC", + band: CanonicalSeverity.Warning, title: "warning"); + + var result = RecommendationDeduper.Merge(new[] { info, critical }, new[] { warning }); + + Assert.Equal(CanonicalSeverity.Critical, result[0].CanonicalSeverity); + Assert.Equal(CanonicalSeverity.Warning, result[1].CanonicalSeverity); + Assert.Equal(CanonicalSeverity.Info, result[2].CanonicalSeverity); + } + + [Fact] + public void Merge_OrderIndependent_SameWinnerRegardlessOfInputOrder() + { + var engine = EngineItem(RecommendationSetting.AutoShrink, "MyDb"); + var legacy = LegacyItem(RecommendationSetting.AutoShrink, "MyDb"); + + var forward = RecommendationDeduper.Merge(new[] { engine }, new[] { legacy }); + var legacyOnly = RecommendationDeduper.Merge(Array.Empty(), + new[] { legacy }); // legacy alone -> legacy survives (no engine to prefer) + + Assert.Equal(RecommendationSource.Engine, Assert.Single(forward).Source); + // With only the legacy row present, it must still surface (collision resolver falls back). + Assert.Equal(RecommendationSource.Legacy, Assert.Single(legacyOnly).Source); + } + + [Fact] + public void Merge_EmptyInputs_ReturnsEmpty() + { + var result = RecommendationDeduper.Merge( + Array.Empty(), Array.Empty()); + Assert.Empty(result); + } + + // ---- canonical severity: engine boundaries --------------------------- + + [Theory] + [InlineData(0.0, CanonicalSeverity.Info)] + [InlineData(0.74, CanonicalSeverity.Info)] + [InlineData(0.75, CanonicalSeverity.Warning)] + [InlineData(1.49, CanonicalSeverity.Warning)] + [InlineData(1.5, CanonicalSeverity.Critical)] + [InlineData(2.0, CanonicalSeverity.Critical)] + public void FromEngineSeverity_BoundaryCases(double severity, CanonicalSeverity expected) + { + Assert.Equal(expected, RecommendationDeduper.FromEngineSeverity(severity)); + } + + // ---- canonical severity: legacy text --------------------------------- + + [Theory] + [InlineData("CRITICAL", CanonicalSeverity.Critical)] + [InlineData("critical", CanonicalSeverity.Critical)] + [InlineData(" WARNING ", CanonicalSeverity.Warning)] + [InlineData("INFO", CanonicalSeverity.Info)] + [InlineData("", CanonicalSeverity.Info)] + [InlineData(null, CanonicalSeverity.Info)] + [InlineData("something-unknown", CanonicalSeverity.Info)] + public void FromLegacySeverity_TextMapping(string? severity, CanonicalSeverity expected) + { + Assert.Equal(expected, RecommendationDeduper.FromLegacySeverity(severity)); + } + + // ---- mapper-side setting derivation (feeds the de-dupe) -------------- + + [Fact] + public void SettingFromAction_DbConfigAutoShrink_MapsAutoShrink() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + + Assert.Equal(RecommendationSetting.AutoShrink, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_DbConfigAutoClose_MapsAutoClose() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoCloseOff, "ON") }); + + Assert.Equal(RecommendationSetting.AutoClose, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_RcsiFactKey_MapsRcsi() + { + var action = new RemediationAction( + "RCSI", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.ReadCommittedSnapshotOn, "OFF") }); + + Assert.Equal(RecommendationSetting.Rcsi, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_ForcePlan_MapsNone() + { + var action = new RemediationAction( + "PLAN_REGRESSION", "force", + new[] { new ForcePlanTarget("MyDb", 1, 2) }); + + Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(action)); + } + + [Fact] + public void SettingFromAction_Null_MapsNone() + { + Assert.Equal(RecommendationSetting.None, RecommendationsReader.SettingFromAction(null)); + } + + [Theory] + [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_SHRINK OFF;", RecommendationSetting.AutoShrink)] + [InlineData("Database Configuration", "ALTER DATABASE [x] SET AUTO_CLOSE OFF;", RecommendationSetting.AutoClose)] + [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE = ON;", RecommendationSetting.QueryStore)] + [InlineData("Query Store Configuration", "ALTER DATABASE [x] SET QUERY_STORE (OPERATION_MODE = READ_WRITE);", RecommendationSetting.QueryStore)] + public void SettingFromLegacy_ConfigArea_ParsesAlterKeyword( + string problemArea, string investigateQuery, RecommendationSetting expected) + { + Assert.Equal(expected, RecommendationsReader.SettingFromLegacy(problemArea, investigateQuery)); + } + + [Fact] + public void SettingFromLegacy_NonConfigArea_MapsNone() + { + // A memory-pressure row whose SQL incidentally mentions a keyword must NOT de-dupe: + // only the two config problem-areas are eligible. + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Memory", "SELECT 'AUTO_SHRINK';")); + } + + [Fact] + public void SettingFromLegacy_ConfigArea_NoKeyword_MapsNone() + { + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Database Configuration", "SELECT 1;")); + } + + [Fact] + public void SettingFromLegacy_ConfigArea_NullSql_MapsNone() + { + Assert.Equal(RecommendationSetting.None, + RecommendationsReader.SettingFromLegacy("Database Configuration", null)); + } + + // ---- mapper-side copy-paste + full engine mapping -------------------- + + [Fact] + public void BuildCopyPasteFromAction_DbConfig_RendersAlterStatements() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] + { + new DbConfigTarget("My]Db", DbConfigSetting.AutoShrinkOff, "ON"), + new DbConfigTarget("My]Db", DbConfigSetting.AutoCloseOff, "ON") + }); + + var sql = RecommendationsReader.BuildCopyPasteFromAction(action); + + Assert.NotNull(sql); + // Identifier bracket-doubling (QUOTENAME-equivalent) is applied. + Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_SHRINK OFF;", sql); + Assert.Contains("ALTER DATABASE [My]]Db] SET AUTO_CLOSE OFF;", sql); + } + + [Fact] + public void BuildCopyPasteFromAction_ForcePlan_ReturnsNull() + { + var action = new RemediationAction( + "PLAN_REGRESSION", "force", new[] { new ForcePlanTarget("MyDb", 1, 2) }); + + Assert.Null(RecommendationsReader.BuildCopyPasteFromAction(action)); + } + + [Fact] + public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash() + { + var action = new RemediationAction( + "DB_CONFIG", "set", Array.Empty(), + new[] { new DbConfigTarget("MyDb", DbConfigSetting.AutoShrinkOff, "ON") }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = "MyDb", + Severity = 0.3, + Category = "db_config", + StoryText = "AUTO_SHRINK is on", + RootFactKey = "DB_CONFIG", + StoryPathHash = "abc123", + Remediation = action + }; + + var item = RecommendationsReader.MapEngineFinding(finding); + + Assert.Equal(RecommendationSource.Engine, item.Source); + Assert.Equal(RecommendationSetting.AutoShrink, item.Setting); + Assert.Equal(CanonicalSeverity.Info, item.CanonicalSeverity); // 0.3 -> Info + Assert.Same(action, item.Remediation); + Assert.Equal("abc123", item.StoryPathHash); + Assert.NotNull(item.CopyPasteSql); + Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql); + } + + [Fact] + public void MapLegacyIssue_MemoryPressure_IsNoneAndAdviseOnly() + { + var issue = new CriticalIssueItem + { + Severity = "CRITICAL", + ProblemArea = "Memory", + AffectedDatabase = string.Empty, + Message = "Memory pressure detected", + InvestigateQuery = "SELECT * FROM sys.dm_os_memory_clerks;" + }; + + var item = RecommendationsReader.MapLegacyIssue(issue); + + Assert.Equal(RecommendationSource.Legacy, item.Source); + Assert.Equal(RecommendationSetting.None, item.Setting); + Assert.Equal(CanonicalSeverity.Critical, item.CanonicalSeverity); + Assert.Null(item.Remediation); + Assert.Null(item.StoryPathHash); + Assert.Null(item.Database); // empty AffectedDatabase -> null + Assert.Equal("Memory pressure detected", item.AdviceText); + Assert.Equal("SELECT * FROM sys.dm_os_memory_clerks;", item.CopyPasteSql); + } + + [Fact] + public void MapLegacyIssue_DatabaseConfiguration_AutoShrink_DerivesSetting() + { + var issue = new CriticalIssueItem + { + Severity = "WARNING", + ProblemArea = "Database Configuration", + AffectedDatabase = "MyDb", + Message = "Auto shrink is enabled", + InvestigateQuery = "ALTER DATABASE [MyDb] SET AUTO_SHRINK OFF;" + }; + + var item = RecommendationsReader.MapLegacyIssue(issue); + + Assert.Equal(RecommendationSetting.AutoShrink, item.Setting); + Assert.Equal("MyDb", item.Database); + } +} diff --git a/Dashboard/Services/Recommendations/RecommendationDeduper.cs b/Dashboard/Services/Recommendations/RecommendationDeduper.cs new file mode 100644 index 00000000..960d1be4 --- /dev/null +++ b/Dashboard/Services/Recommendations/RecommendationDeduper.cs @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitorDashboard.Services.Recommendations +{ + /// + /// The PURE, database-free core of the Recommendations surface (C3): canonical severity + /// mapping + cross-store de-dupe. Every method here is a static function over already- + /// mapped lists (or scalars), so the merge/collision + /// rules can be unit-tested with synthesized rows — no DB, no UI. The + /// reads the two stores, maps each row to a + /// , then hands the two lists to . + /// + public static class RecommendationDeduper + { + /// + /// Maps an engine finding's double severity (0–~2.0) onto the canonical band. + /// Cutoffs (D1): >= 1.5 → Critical, >= 0.75 → Warning, else Info. + /// + public static CanonicalSeverity FromEngineSeverity(double severity) + { + if (severity >= 1.5) + return CanonicalSeverity.Critical; + if (severity >= 0.75) + return CanonicalSeverity.Warning; + return CanonicalSeverity.Info; + } + + /// + /// Maps the legacy config.critical_issues text severity onto the canonical + /// band. CRITICAL → Critical, WARNING → Warning, anything else (INFO / unknown) → + /// Info. Case-insensitive; tolerates surrounding whitespace. + /// + public static CanonicalSeverity FromLegacySeverity(string? severity) + { + var s = severity?.Trim(); + if (string.Equals(s, "CRITICAL", StringComparison.OrdinalIgnoreCase)) + return CanonicalSeverity.Critical; + if (string.Equals(s, "WARNING", StringComparison.OrdinalIgnoreCase)) + return CanonicalSeverity.Warning; + return CanonicalSeverity.Info; + } + + /// + /// A synthesized raw-severity stand-in for a legacy row (which has no numeric score), + /// so the engine + legacy rows sort together within and across bands: Critical → 2.0, + /// Warning → 1.0, Info → 0.0. Used only for the secondary ordering. + /// + public static double LegacyRawSeverity(CanonicalSeverity band) => band switch + { + CanonicalSeverity.Critical => 2.0, + CanonicalSeverity.Warning => 1.0, + _ => 0.0 + }; + + /// + /// Merges the engine + legacy mapped lists into one de-duped, severity-sorted list + /// (C3). PURE: no DB, no UI, no ordering dependence on the inputs beyond what the + /// collision rule dictates. The de-dupe key is + /// ((), + /// ). Rows whose + /// is + /// NEVER de-dupe (they are not per-DB config-setting recs) — every such row passes + /// through. For a colliding (db, setting) pair the winner is chosen by + /// ; the loser is dropped. The result is sorted by + /// canonical severity desc, then raw severity desc, then database, then title — a + /// total, deterministic order. + /// + public static List Merge( + IEnumerable engineItems, + IEnumerable legacyItems) + { + // Bucket the dedupe-eligible rows (Setting != None) by (db, setting); collect the + // pass-through rows (Setting == None) separately so they are never collapsed. + var byKey = new Dictionary<(string Db, RecommendationSetting Setting), RecommendationItem>(); + var passthrough = new List(); + + // Engine rows first, then legacy: the collision resolver is explicit about which + // source wins per setting, so iteration order does not change the winner — but a + // stable, source-grouped traversal keeps the logic easy to reason about. + foreach (var item in Concat(engineItems, legacyItems)) + { + if (item is null) + continue; + + if (item.Setting == RecommendationSetting.None) + { + passthrough.Add(item); + continue; + } + + var key = (NormalizeDatabase(item.Database), item.Setting); + if (!byKey.TryGetValue(key, out var existing)) + { + byKey[key] = item; + continue; + } + + // Same (db, setting) from both stores: keep the winner for this setting. + byKey[key] = ResolveCollision(existing, item); + } + + var merged = new List(byKey.Count + passthrough.Count); + merged.AddRange(byKey.Values); + merged.AddRange(passthrough); + + merged.Sort(CompareForDisplay); + return merged; + } + + /// + /// Whether the ENGINE row wins a (db, setting) collision. AutoShrink/AutoClose → + /// engine wins (it is Apply-capable; the legacy row is advise-only). QueryStore → + /// legacy wins (the engine has no Query-Store finding/Apply today). PageVerify/Rcsi + /// are engine-only and cannot collide, but are treated as engine-wins for + /// completeness. never reaches here. + /// + public static bool PreferEngineFor(RecommendationSetting setting) => setting switch + { + RecommendationSetting.QueryStore => false, + _ => true + }; + + /// + /// Picks the surviving row for a (db, setting) collision per + /// . When the preferred source is present among the two + /// it wins; otherwise the other row survives (so a collision between two rows of the + /// non-preferred source — which should not occur in practice — still yields a row). + /// + private static RecommendationItem ResolveCollision(RecommendationItem a, RecommendationItem b) + { + var preferEngine = PreferEngineFor(a.Setting); + var wantSource = preferEngine ? RecommendationSource.Engine : RecommendationSource.Legacy; + + if (a.Source == wantSource) + return a; + if (b.Source == wantSource) + return b; + + // Neither row is the preferred source — keep the first-seen one deterministically. + return a; + } + + /// + /// Normalizes a database name for the de-dupe key: trim + lower-invariant. Null/empty + /// collapse to "" so two server-scoped config rows for the same setting would still + /// key together (in practice config-setting recs are always per-database). + /// + public static string NormalizeDatabase(string? database) => + (database ?? string.Empty).Trim().ToLowerInvariant(); + + /// + /// Total display order: canonical severity desc, then raw severity desc, then + /// database (ordinal, case-insensitive), then title — deterministic regardless of + /// input order. + /// + private static int CompareForDisplay(RecommendationItem x, RecommendationItem y) + { + // Higher band first (enum ordinals are Info + /// Concatenates two possibly-null sequences without allocating an intermediate array, + /// guarding either being null (the reader always passes non-null lists, but the pure + /// function tolerates either). + /// + private static IEnumerable Concat( + IEnumerable? first, IEnumerable? second) => + (first ?? Enumerable.Empty()) + .Concat(second ?? Enumerable.Empty()); + } +} diff --git a/Dashboard/Services/Recommendations/RecommendationItem.cs b/Dashboard/Services/Recommendations/RecommendationItem.cs new file mode 100644 index 00000000..39f99046 --- /dev/null +++ b/Dashboard/Services/Recommendations/RecommendationItem.cs @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Analysis; + +namespace PerformanceMonitorDashboard.Services.Recommendations +{ + /// + /// Canonical, three-band severity for the unified Recommendations surface. The two + /// producers score on different scales (engine findings carry a double 0–~2.0; + /// the legacy config.critical_issues store carries the text CRITICAL/WARNING/INFO) + /// — both are mapped onto this single enum so one list can be sorted and rendered + /// consistently. The numeric ordinals are deliberately Critical > Warning > Info + /// so a descending sort on the enum matches a descending sort on severity. + /// + public enum CanonicalSeverity + { + Info = 0, + Warning = 1, + Critical = 2 + } + + /// + /// Which producer a came from. + /// = config.analysis_findings (the fact/story engine; may + /// carry an Apply-capable ). = + /// config.critical_issues (the frozen configuration-issues proc; advise + + /// copy-paste only, never Apply). + /// + public enum RecommendationSource + { + Engine, + Legacy + } + + /// + /// The canonical de-dupe key's setting component (C3). A (normalized database, setting) + /// pair identifies the SAME underlying misconfiguration regardless of which producer + /// surfaced it, so the de-dupe can collapse a duplicate that both stores report. + /// means the row is NOT a per-database config-setting rec (memory + /// pressure, server-level MAXDOP/CTFP, dumps, plan/index advice, …) and therefore NEVER + /// de-dupes — every row passes through untouched. + /// + /// + /// Only , and + /// can collide across the two stores today. and + /// are engine-only (the legacy proc emits no per-DB rec for + /// them), so they get their own values for clarity but never actually collide. + /// + /// + public enum RecommendationSetting + { + None = 0, + AutoShrink, + AutoClose, + QueryStore, + Rcsi, + PageVerify + } + + /// + /// A single unified Recommendations row — the view-model the surface renders. This is a + /// plain DTO with NO WPF dependency so the reader's mapping + the pure de-dupe function + /// over it are unit-testable without a UI or a database. + /// + /// + /// Built by from either an + /// () or a + /// legacy critical-issue row (). The + /// action is present ONLY for engine rows that carry one + /// (it drives Apply + the two-sided consent gate, wired in a later WS); legacy rows + /// are advise/copy-paste only and leave it null. + /// + /// + public sealed class RecommendationItem + { + /// The three-band severity used for sorting and the badge/icon. + public CanonicalSeverity CanonicalSeverity { get; set; } + + /// + /// The raw severity used as the secondary sort within a band. Engine rows carry the + /// finding's double (0–~2.0); legacy rows carry a synthesized stand-in derived + /// from the text band (2.0 / 1.0 / 0.0) so the two scales sort together sensibly. + /// + public double RawSeverity { get; set; } + + /// The affected database, or null/empty for a server-scoped rec. + public string? Database { get; set; } + + /// The one-line title / problem area shown as the row heading. + public string Title { get; set; } = string.Empty; + + /// + /// The problem-area grouping (engine: category; legacy: problem_area). + /// Kept distinct from for grouping/filtering. + /// + public string ProblemArea { get; set; } = string.Empty; + + /// + /// Operator-facing advice prose (engine: for the root fact; + /// legacy: the row's message). Null when no advice is available. + /// + public string? AdviceText { get; set; } + + /// + /// Copy-paste-ready SQL the operator can run themselves (engine: the + /// ALTER DATABASE statements rebuilt from the persisted action's DB-config + /// targets; legacy: the row's investigate_query). Null when none applies. + /// + public string? CopyPasteSql { get; set; } + + /// + /// The built, persisted remediation action for Apply (engine only; null for legacy + /// and for engine rows with no executable shape). Drives the in-app Apply button + + /// the informed-consent gate. Lives in PerformanceMonitor.Analysis. + /// + public RemediationAction? Remediation { get; set; } + + /// Which producer this row came from. + public RecommendationSource Source { get; set; } + + /// + /// The engine finding's story_path_hash, used to mute the pattern. Engine rows + /// only; null for legacy rows (the legacy store has no mute concept). + /// + public string? StoryPathHash { get; set; } + + /// + /// The canonical de-dupe setting (or ). See + /// . Drives the (database, setting) de-dupe key. + /// + public RecommendationSetting Setting { get; set; } + } +} diff --git a/Dashboard/Services/Recommendations/RecommendationsReader.cs b/Dashboard/Services/Recommendations/RecommendationsReader.cs new file mode 100644 index 00000000..539d5c0f --- /dev/null +++ b/Dashboard/Services/Recommendations/RecommendationsReader.cs @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using PerformanceMonitor.Analysis; +using PerformanceMonitorDashboard.Analysis; +using PerformanceMonitorDashboard.Models; + +namespace PerformanceMonitorDashboard.Services.Recommendations +{ + /// + /// The data layer for the unified Recommendations surface (WS1a). Reads BOTH producers + /// for a server — engine findings from config.analysis_findings (via the existing + /// ) and legacy config recs from + /// config.critical_issues (via the existing + /// ) — maps each row to a unified + /// , then de-dupes + sorts via the pure + /// . No new raw SQL is written for the reads; this class + /// reuses the two existing read methods so it inherits their parameterization. + /// + /// + /// Nothing renders here — this is the foundational data layer (the XAML control + tab + /// wiring are a later workstream). The per-row mappers are internal static so the + /// engine/legacy → setting + copy-paste mapping can be unit-tested directly. + /// + /// + public sealed class RecommendationsReader + { + private readonly DatabaseService _databaseService; + private readonly SqlServerFindingStore _findingStore; + + /// The two legacy problem-areas that carry per-database config-setting recs. + internal const string ProblemAreaDatabaseConfiguration = "Database Configuration"; + internal const string ProblemAreaQueryStoreConfiguration = "Query Store Configuration"; + + public RecommendationsReader(DatabaseService databaseService, SqlServerFindingStore findingStore) + { + _databaseService = databaseService ?? throw new ArgumentNullException(nameof(databaseService)); + _findingStore = findingStore ?? throw new ArgumentNullException(nameof(findingStore)); + } + + /// + /// Reads both stores for the server, maps each row, de-dupes (C3), and returns the + /// unified list sorted by severity desc. is carried for + /// display only (the reads key on for findings and on the + /// connection's own server for the legacy store). bounds + /// both reads to the same window. + /// + public async Task> GetRecommendationsAsync( + int serverId, string serverName, int hoursBack = 24, int limit = 100) + { + var findings = await _findingStore.GetRecentFindingsAsync(serverId, hoursBack, limit); + var legacy = await _databaseService.GetCriticalIssuesAsync(hoursBack); + + var engineItems = new List(findings.Count); + foreach (var finding in findings) + engineItems.Add(MapEngineFinding(finding)); + + var legacyItems = new List(legacy.Count); + foreach (var issue in legacy) + legacyItems.Add(MapLegacyIssue(issue)); + + return RecommendationDeduper.Merge(engineItems, legacyItems); + } + + /// + /// Maps one engine to a . + /// Advice comes from for the root fact key; the de-dupe + /// and the copy-paste SQL come from the PERSISTED + /// action — the drill-down that the live + /// builders consume is ephemeral and is NOT returned by the store read, so the built + /// action (which IS persisted) is the authoritative source on read. + /// + internal static RecommendationItem MapEngineFinding(AnalysisFinding finding) + { + var band = RecommendationDeduper.FromEngineSeverity(finding.Severity); + var advice = FactAdvice.GetForFactKey(finding.RootFactKey); + + return new RecommendationItem + { + Source = RecommendationSource.Engine, + CanonicalSeverity = band, + RawSeverity = finding.Severity, + Database = finding.DatabaseName, + Title = !string.IsNullOrEmpty(advice?.Headline) ? advice!.Headline : finding.StoryText, + ProblemArea = finding.Category, + AdviceText = ComposeEngineAdvice(advice), + CopyPasteSql = BuildCopyPasteFromAction(finding.Remediation), + Remediation = finding.Remediation, + StoryPathHash = finding.StoryPathHash, + Setting = SettingFromAction(finding.Remediation) + }; + } + + /// + /// Maps one legacy to a + /// . Advice is the row's message; copy-paste is + /// the row's investigate_query; the de-dupe setting is derived ONLY for the two + /// config problem-areas, from the investigate_query ALTER keyword (never the + /// free-text message). Every other legacy row gets + /// and is non-deduping pass-through. + /// + internal static RecommendationItem MapLegacyIssue(CriticalIssueItem issue) + { + var band = RecommendationDeduper.FromLegacySeverity(issue.Severity); + var database = string.IsNullOrEmpty(issue.AffectedDatabase) ? null : issue.AffectedDatabase; + var sql = string.IsNullOrEmpty(issue.InvestigateQuery) ? null : issue.InvestigateQuery; + + return new RecommendationItem + { + Source = RecommendationSource.Legacy, + CanonicalSeverity = band, + RawSeverity = RecommendationDeduper.LegacyRawSeverity(band), + Database = database, + Title = issue.ProblemArea, + ProblemArea = issue.ProblemArea, + AdviceText = string.IsNullOrEmpty(issue.Message) ? null : issue.Message, + CopyPasteSql = sql, + Remediation = null, // legacy rows are advise/copy-paste only — never Apply + StoryPathHash = null, // the legacy store has no mute concept + Setting = SettingFromLegacy(issue.ProblemArea, sql) + }; + } + + /// + /// Derives the de-dupe for an ENGINE finding from + /// its persisted . RCSI is its own fact-key (and its own + /// setting); DB_CONFIG carries one or more s whose + /// maps to a setting per flagged option. Only the FIRST + /// flagged DB-config option contributes the key (a finding's database is single, and + /// AUTO_SHRINK/AUTO_CLOSE are the only cross-store collisions — distinct per-DB + /// findings keep distinct rows). Returns when + /// there is no action or no recognized config target (so non-config findings — + /// CPU/waits/etc. — never de-dupe). + /// + internal static RecommendationSetting SettingFromAction(RemediationAction? action) + { + if (action is null) + return RecommendationSetting.None; + + // RCSI is routed through a distinct fact key with a single ReadCommittedSnapshotOn + // target; surface it as the Rcsi setting (engine-only — it never collides). + if (string.Equals(action.FactKey, "RCSI", StringComparison.Ordinal)) + return RecommendationSetting.Rcsi; + + if (action.DbConfigTargets is { Count: > 0 } targets) + { + foreach (var target in targets) + { + var setting = SettingFromDbConfig(target.Setting); + if (setting != RecommendationSetting.None) + return setting; + } + } + + return RecommendationSetting.None; + } + + /// + /// Maps a single to its canonical de-dupe + /// . + /// + internal static RecommendationSetting SettingFromDbConfig(DbConfigSetting setting) => setting switch + { + DbConfigSetting.AutoShrinkOff => RecommendationSetting.AutoShrink, + DbConfigSetting.AutoCloseOff => RecommendationSetting.AutoClose, + DbConfigSetting.PageVerifyChecksum => RecommendationSetting.PageVerify, + DbConfigSetting.ReadCommittedSnapshotOn => RecommendationSetting.Rcsi, + _ => RecommendationSetting.None + }; + + /// + /// Derives the de-dupe for a LEGACY row. ONLY the + /// two config problem-areas are eligible; for them the setting is read from the + /// investigate_query ALTER keyword (AUTO_SHRINK → AutoShrink, AUTO_CLOSE → + /// AutoClose, QUERY_STORE → QueryStore). The free-text message is never parsed. + /// Any other problem-area → (pass-through). + /// + internal static RecommendationSetting SettingFromLegacy(string? problemArea, string? investigateQuery) + { + var area = problemArea?.Trim(); + bool isConfigArea = + string.Equals(area, ProblemAreaDatabaseConfiguration, StringComparison.OrdinalIgnoreCase) || + string.Equals(area, ProblemAreaQueryStoreConfiguration, StringComparison.OrdinalIgnoreCase); + + if (!isConfigArea || string.IsNullOrEmpty(investigateQuery)) + return RecommendationSetting.None; + + // Match on the ALTER keyword in the copy-paste SQL only. AUTO_SHRINK / AUTO_CLOSE + // are substrings of distinct keywords; QUERY_STORE covers both SET QUERY_STORE and + // ALTER DATABASE ... SET QUERY_STORE forms. + if (ContainsToken(investigateQuery, "AUTO_SHRINK")) + return RecommendationSetting.AutoShrink; + if (ContainsToken(investigateQuery, "AUTO_CLOSE")) + return RecommendationSetting.AutoClose; + if (ContainsToken(investigateQuery, "QUERY_STORE")) + return RecommendationSetting.QueryStore; + + return RecommendationSetting.None; + } + + /// + /// Rebuilds the copy-paste ALTER DATABASE statements for an engine row from the + /// PERSISTED action's DB-config targets (the live drill-down preview is not persisted). + /// Mirrors the executor's SET-clause literals exactly. Returns null when the action has + /// no DB-config targets (force-plan / clear-plan / null → no DB-config copy-paste here). + /// + internal static string? BuildCopyPasteFromAction(RemediationAction? action) + { + if (action?.DbConfigTargets is not { Count: > 0 } targets) + return null; + + var sb = new StringBuilder(); + foreach (var target in targets) + { + if (sb.Length > 0) + sb.AppendLine(); + sb.Append(AlterStatementFor(target)); + } + + return sb.Length == 0 ? null : sb.ToString(); + } + + /// + /// One ALTER DATABASE [db] SET ...; statement for a persisted + /// . The bracketed identifier doubles any embedded + /// close-bracket (QUOTENAME-equivalent); the SET-clause literal is selected by the + /// hardcoded enum, matching the executor and FactRemediation's renderer. + /// + private static string AlterStatementFor(DbConfigTarget target) + { + var setClause = target.Setting switch + { + DbConfigSetting.AutoShrinkOff => "SET AUTO_SHRINK OFF", + DbConfigSetting.AutoCloseOff => "SET AUTO_CLOSE OFF", + DbConfigSetting.PageVerifyChecksum => "SET PAGE_VERIFY CHECKSUM", + DbConfigSetting.ReadCommittedSnapshotOn => "SET READ_COMMITTED_SNAPSHOT ON", + _ => null + }; + + if (setClause is null) + return string.Empty; + + return $"ALTER DATABASE {QuoteName(target.Database)} {setClause};"; + } + + /// + /// Composes the engine advice prose from a block: the + /// remediation line, with the investigation line appended when present. Null when no + /// advice matched the root fact key. + /// + private static string? ComposeEngineAdvice(AdviceBlock? advice) + { + if (advice is null) + return null; + + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(advice.Remediation)) + sb.Append(advice.Remediation); + if (!string.IsNullOrEmpty(advice.Investigation)) + { + if (sb.Length > 0) + sb.Append(' '); + sb.Append(advice.Investigation); + } + + return sb.Length == 0 ? null : sb.ToString(); + } + + /// + /// Case-insensitive substring test for an ALTER keyword in the copy-paste SQL. + /// + private static bool ContainsToken(string text, string token) => + text.Contains(token, StringComparison.OrdinalIgnoreCase); + + /// + /// QUOTENAME-equivalent: bracket an identifier, doubling any embedded close-bracket. + /// + private static string QuoteName(string identifier) => + "[" + (identifier ?? string.Empty).Replace("]", "]]") + "]"; + } +}