From 9bac6a4001fc0aa7cff793ecde442a8aa6e45509 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:14:48 -0400 Subject: [PATCH] Recs rebuild WS1b-2: wire Apply (consent) + Mute actions Wire the Recommendations tab's Apply and Mute buttons live (Dashboard-only), replacing the WS1b-1 disabled "Available in the next update" affordances. Apply mirrors the proven AlertDetailWindow path exactly: fail-closed server resolution, the handler-for-fact gate, then RemediationApplyService.ApplyAsync with a confirm callback that news up RemediationConfirmWindow. Destructive fixes (RCSI / clear-plan) set RequiresInformedConsent, so the two-sided acknowledge-each-risk gate renders automatically (no special-casing); the disclosure draws on the persisted action's carried figures (finding: null). The shared RemediationApplyService is threaded MainWindow -> ServerTab -> RecommendationsContent, the same instance the Alerts tab uses. Mute is engine-only: the lower-level story mute keyed on (serverId, story_path_hash) via SqlServerFindingStore.MuteStoryAsync, since the card holds a RecommendationItem rather than a full AnalysisFinding. Both Apply and Mute refresh on success so the action-log outcome / dropped row is reflected. RecommendationItem gains a StoryPath (populated from finding.StoryPath in the reader) for the mute record's operator-facing label. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/RecommendationDeduperTests.cs | 3 + .../RecommendationsViewModelTests.cs | 32 ++- Dashboard.Tests/RemediationTests.cs | 2 + .../Controls/RecommendationsContent.xaml | 18 +- .../Controls/RecommendationsContent.xaml.cs | 220 ++++++++++++++++++ .../Controls/RecommendationsViewModel.cs | 22 +- Dashboard/MainWindow.xaml.cs | 5 + Dashboard/ServerTab.xaml.cs | 12 + .../Recommendations/RecommendationItem.cs | 8 + .../Recommendations/RecommendationsReader.cs | 1 + 10 files changed, 296 insertions(+), 27 deletions(-) diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/Dashboard.Tests/RecommendationDeduperTests.cs index f493605..47764da 100644 --- a/Dashboard.Tests/RecommendationDeduperTests.cs +++ b/Dashboard.Tests/RecommendationDeduperTests.cs @@ -385,6 +385,7 @@ public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash() StoryText = "AUTO_SHRINK is on", RootFactKey = "DB_CONFIG", StoryPathHash = "abc123", + StoryPath = "config>db_config>MyDb", Remediation = action }; @@ -395,6 +396,7 @@ public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash() Assert.Equal(CanonicalSeverity.Info, item.CanonicalSeverity); // 0.3 -> Info Assert.Same(action, item.Remediation); Assert.Equal("abc123", item.StoryPathHash); + Assert.Equal("config>db_config>MyDb", item.StoryPath); // carried for the mute record Assert.NotNull(item.CopyPasteSql); Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql); } @@ -418,6 +420,7 @@ public void MapLegacyIssue_MemoryPressure_IsNoneAndAdviseOnly() Assert.Equal(CanonicalSeverity.Critical, item.CanonicalSeverity); Assert.Null(item.Remediation); Assert.Null(item.StoryPathHash); + Assert.Null(item.StoryPath); // legacy rows have no mute concept 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); diff --git a/Dashboard.Tests/RecommendationsViewModelTests.cs b/Dashboard.Tests/RecommendationsViewModelTests.cs index 4d31700..ddc17b0 100644 --- a/Dashboard.Tests/RecommendationsViewModelTests.cs +++ b/Dashboard.Tests/RecommendationsViewModelTests.cs @@ -49,7 +49,8 @@ private static RecommendationItem Item( string? advice = null, DateTime? windowStartUtc = null, DateTime? windowEndUtc = null, - string? storyHash = "hash") + string? storyHash = "hash", + string? storyPath = "root>leaf") { return new RecommendationItem { @@ -64,7 +65,8 @@ private static RecommendationItem Item( AdviceText = advice, WindowStartUtc = windowStartUtc, WindowEndUtc = windowEndUtc, - StoryPathHash = source == RecommendationSource.Engine ? storyHash : null + StoryPathHash = source == RecommendationSource.Engine ? storyHash : null, + StoryPath = source == RecommendationSource.Engine ? storyPath : null }; } @@ -430,9 +432,29 @@ public void Card_SeverityLabel_MatchesBand(CanonicalSeverity severity, string ex } [Fact] - public void Card_ActionsDisabledReason_IsTheNextUpdateTooltip() + public void EngineCard_ExposesMuteKeyInputs_HashAndPath() { - var card = Card(Item(CanonicalSeverity.Warning)); - Assert.Equal(RecommendationsViewModel.ActionsDisabledTooltip, card.ActionsDisabledReason); + // The Mute handler keys on the underlying item's StoryPathHash (the mute key) and carries + // StoryPath (the operator-facing label). Both must round-trip through the card for engine rows. + var card = Card(Item( + CanonicalSeverity.Warning, + source: RecommendationSource.Engine, + storyHash: "h-42", + storyPath: "blocking>rcsi>Sales")); + + Assert.True(card.ShowMute); + Assert.Equal("h-42", card.Item.StoryPathHash); + Assert.Equal("blocking>rcsi>Sales", card.Item.StoryPath); + } + + [Fact] + public void LegacyCard_HasNoMuteKeyInputs() + { + // Legacy rows never mute: no hash, no path, no Mute button. + var card = Card(Item(CanonicalSeverity.Warning, source: RecommendationSource.Legacy)); + + Assert.False(card.ShowMute); + Assert.Null(card.Item.StoryPathHash); + Assert.Null(card.Item.StoryPath); } } diff --git a/Dashboard.Tests/RemediationTests.cs b/Dashboard.Tests/RemediationTests.cs index 617cad1..7060619 100644 --- a/Dashboard.Tests/RemediationTests.cs +++ b/Dashboard.Tests/RemediationTests.cs @@ -1832,6 +1832,8 @@ public void GatedEntry_ReferencedOnlyBySanctionedUiPath() "Controls/AlertsHistoryContent.xaml.cs", // threads it into the alert detail dialog "AlertDetailWindow.xaml.cs", // invokes Apply/Un-apply via the service "RemediationConfirmWindow.xaml.cs", // the confirm modal (gate UI) + "ServerTab.xaml.cs", // forwards it to the Recommendations sub-tab (WS1b-2) + "Controls/RecommendationsContent.xaml.cs", // invokes Apply via the service (WS1b-2) }; var offenders = new List(); diff --git a/Dashboard/Controls/RecommendationsContent.xaml b/Dashboard/Controls/RecommendationsContent.xaml index f75d8de..ab572cf 100644 --- a/Dashboard/Controls/RecommendationsContent.xaml +++ b/Dashboard/Controls/RecommendationsContent.xaml @@ -12,8 +12,9 @@ + (Setting!=None) offer "Copy fix" (the ALTER). Apply (live: confirm gate + + two-sided informed consent for destructive fixes) and Mute (engine rows) are wired + in WS1b-2. Mirrors AlertDetailWindow's per-row visual conventions. --> + a remediation exists). Config-fixes: Apply + Copy fix. Engine rows also + offer Mute. Apply opens the confirm gate (two-sided consent if destructive). -->