Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dashboard.Tests/RecommendationDeduperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand All @@ -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);
}
Expand All @@ -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);
Expand Down
32 changes: 27 additions & 5 deletions Dashboard.Tests/RecommendationsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
};
}

Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions Dashboard.Tests/RemediationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand Down
18 changes: 12 additions & 6 deletions Dashboard/Controls/RecommendationsContent.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

<!-- One card: severity badge + database + title + advice, then affordances.
Incidents (Setting==None) send the operator to the dashboard / AI; config-fixes
(Setting!=None) offer "Copy fix" (the ALTER). Apply is present-but-disabled until
WS1b-2. Mirrors AlertDetailWindow's per-row visual conventions. -->
(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. -->
<DataTemplate x:Key="RecommendationCardTemplate">
<Border Background="{DynamicResource BackgroundLightBrush}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
Expand Down Expand Up @@ -52,7 +53,8 @@
Foreground="{DynamicResource ForegroundBrush}" Margin="0,0,0,4"/>

<!-- Affordances. Incidents: Open in Active Queries + Ask AI (+ Apply when
a remediation exists). Config-fixes: Apply + Copy fix. -->
a remediation exists). Config-fixes: Apply + Copy fix. Engine rows also
offer Mute. Apply opens the confirm gate (two-sided consent if destructive). -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Open in Active Queries →" Padding="10,3" Margin="0,0,8,0"
Visibility="{Binding ShowOpenInActiveQueries, Converter={StaticResource BoolToVis}}"
Expand All @@ -66,10 +68,14 @@
Visibility="{Binding ShowCopyFix, Converter={StaticResource BoolToVis}}"
Click="CopyFix_Click"
ToolTip="Copy the ALTER statement to the clipboard"/>
<Button Content="Apply…" Padding="10,3"
IsEnabled="False"
<Button Content="Apply…" Padding="10,3" Margin="0,0,8,0"
Visibility="{Binding ShowApply, Converter={StaticResource BoolToVis}}"
ToolTip="{Binding ActionsDisabledReason}"/>
Click="ApplyButton_Click"
ToolTip="Apply this fix — you review and confirm before anything runs"/>
<Button Content="Mute" Padding="10,3"
Visibility="{Binding ShowMute, Converter={StaticResource BoolToVis}}"
Click="MuteButton_Click"
ToolTip="Stop showing this recommendation (mutes the pattern for this server)"/>
</StackPanel>
</StackPanel>
</Border>
Expand Down
220 changes: 220 additions & 0 deletions Dashboard/Controls/RecommendationsContent.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*/

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
Expand All @@ -17,6 +19,7 @@
using PerformanceMonitorDashboard.Models;
using PerformanceMonitorDashboard.Services;
using PerformanceMonitorDashboard.Services.Recommendations;
using PerformanceMonitorDashboard.Services.Remediation;

namespace PerformanceMonitorDashboard.Controls
{
Expand Down Expand Up @@ -51,6 +54,20 @@ public partial class RecommendationsContent : UserControl
private RecommendationsReader? _reader;
private ServerConnection? _serverConnection;
private ICredentialService? _credentialService;
private RemediationApplyService? _remediationApplyService;

/// <summary>
/// The shared, gated remediation entry point (constructed once in <c>MainWindow</c> and
/// threaded MainWindow -> ServerTab -> here, mirroring how <c>AlertsHistoryContent</c>
/// receives it). Drives the Apply button: server resolution, the two-sided informed-
/// consent gate for destructive fixes (RCSI / clear-plan), and the audit write. Null in
/// contexts where no service was supplied, which leaves Apply inert.
/// </summary>
public RemediationApplyService? RemediationApplyService
{
get => _remediationApplyService;
set => _remediationApplyService = value;
}

private int _hoursBack = 24;
private int _utcOffsetMinutes;
Expand Down Expand Up @@ -243,6 +260,209 @@ private void CopyFix_Click(object sender, RoutedEventArgs e)
StatusText.Text = "Fix copied to clipboard.";
}

// ── Apply (gated remediation, mirrors AlertDetailWindow) ───────────────────

/// <summary>
/// Applies the card's built remediation through the gated <see cref="RemediationApplyService"/>.
/// Mirrors <c>AlertDetailWindow</c> exactly: fail-closed server resolution, the handler-for-fact
/// gate, then <c>ApplyAsync</c> with a confirm callback that news up the (two-sided when
/// destructive) <see cref="RemediationConfirmWindow"/>. On a run, refreshes so the action-log
/// outcome is reflected. <c>finding: null</c> is correct — the disclosure renders from the
/// persisted action's carried figures, exactly as on the alert path.
/// </summary>
private async void ApplyButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not RecommendationCardViewModel card)
return;

await RunApplyAsync(card);
}

private async Task RunApplyAsync(RecommendationCardViewModel card)
{
var item = card.Item;
if (_remediationApplyService is null || item.Remediation is null || _serverConnection is null)
return;

if (_isBusy)
return;

// M3 fail-closed resolution: exact GUID match on this tab's connection Id, falling back to a
// unique ServerName match. Ambiguous/unresolved disables Apply (the reason is surfaced).
var resolution = _remediationApplyService.ResolveServer(_serverConnection.Id, _serverConnection.ServerName);
if (!resolution.IsResolved || resolution.Server is null)
{
StatusText.Text = resolution.Reason ?? "Apply is unavailable — the source server could not be resolved.";
return;
}

if (!_remediationApplyService.HasHandlerFor(item.Remediation.FactKey))
{
StatusText.Text = "No remediation handler is registered for this recommendation.";
return;
}

_isBusy = true;
try
{
StatusText.Text = "Applying…";

var operatorIdentity = ResolveOperatorIdentity();
// Audit provenance (RemediationIdentity doc: "the alert's metric name / story hash").
var sourceRef = $"Recommendations: {item.StoryPathHash ?? item.Title}";

// previewSql may be null for RCSI/clear-plan (no DB-config ALTER) — ApplyAsync then
// renders the canonical EXEC/ALTER preview itself, exactly as on the alert path.
var report = await _remediationApplyService.ApplyAsync(
item.Remediation, resolution.Server, item.CopyPasteSql, operatorIdentity, sourceRef,
request => ConfirmAsync(request, resolution), CancellationToken.None, finding: null);

StatusText.Text = FormatApplyStatus(report);
_isBusy = false; // release before re-entering RefreshDataAsync's own guard
await RefreshDataAsync();
return;
}
catch (Exception ex)
{
Logger.Error($"Error applying recommendation: {ex.Message}", ex);
StatusText.Text = $"Apply failed: {ex.Message}";
}
finally
{
_isBusy = false;
}
}

/// <summary>
/// The confirm gate. Invoked by the service from a background continuation, so it marshals to
/// the UI thread to show the modal. Returning true is the only thing that lets the privileged
/// handler run. For a destructive action the request carries the two-sided risks and the dialog
/// renders the acknowledge-each-risk checkboxes automatically (no special-casing here).
/// </summary>
private async Task<bool> ConfirmAsync(RemediationConfirmRequest request, ServerResolution resolution)
{
return await Dispatcher.InvokeAsync(() =>
{
var dialog = new RemediationConfirmWindow(request, resolution.ResolvedByName, resolution.Reason)
{
Owner = Window.GetWindow(this)
};
return dialog.ShowDialog() == true;
});
}

/// <summary>
/// A compact one-line outcome for the status strip (the alert path renders a per-target detail
/// block; the Recommendations surface refreshes the cards, so a summary line suffices).
/// </summary>
private static string FormatApplyStatus(RemediationRunReport report)
{
switch (report.Status)
{
case RemediationRunStatus.NotConfirmed:
return "Cancelled — no change was made.";
case RemediationRunStatus.NoHandler:
return "No remediation handler is registered for this recommendation.";
case RemediationRunStatus.UnapplyNotSupported:
return "This fix type cannot be un-applied — no change was made.";
}

var succeeded = report.Targets.Count(t => t.Status == RemediationStatus.Success);
var failed = report.Targets.Count(t =>
t.Status is RemediationStatus.Error or RemediationStatus.Blocked or RemediationStatus.PermissionDenied);
var skipped = report.Targets.Count(t => t.Status == RemediationStatus.Skipped);

if (report.Targets.Any(t => t.AppliedButUnlogged && t.AuditFailureKind == AuditWriteFailureKind.Permanent))
return $"Applied ({succeeded}), but the audit row could NOT be written — the monitoring login " +
"lacks INSERT on config.remediation_action_log. Fix this grant.";

if (failed > 0)
return $"Apply finished with issues — {succeeded} succeeded, {failed} failed" +
(skipped > 0 ? $", {skipped} skipped." : ".");

if (succeeded > 0)
return $"Applied — {succeeded} target(s) succeeded" + (skipped > 0 ? $", {skipped} skipped." : ".");

if (skipped > 0)
return $"Nothing to apply — {skipped} target(s) skipped.";

return "Apply finished — no targets were processed.";
}

// ── Mute (engine rows only) ────────────────────────────────────────────────

/// <summary>
/// Mutes an engine recommendation's story pattern via the finding store, keyed on
/// (serverId, <c>story_path_hash</c>) — the lower-level story mute, because the card carries a
/// <see cref="RecommendationItem"/> rather than a full <c>AnalysisFinding</c>. After muting,
/// refreshes so the row drops out (the next read filters it). Legacy rows have no mute concept
/// and never reach here (the button is engine-only).
/// </summary>
private async void MuteButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement fe || fe.DataContext is not RecommendationCardViewModel card)
return;

await RunMuteAsync(card);
}

private async Task RunMuteAsync(RecommendationCardViewModel card)
{
var item = card.Item;
if (_findingStore is null || _serverConnection is null
|| item.Source != RecommendationSource.Engine
|| string.IsNullOrEmpty(item.StoryPathHash))
return;

if (_isBusy)
return;

_isBusy = true;
try
{
StatusText.Text = "Muting…";

// Same deterministic server id the reader/scheduler use.
var serverId = ServerIdHelper.GetDeterministicHashCode(_serverConnection.ServerName);
await _findingStore.MuteStoryAsync(
serverId, item.StoryPathHash, item.StoryPath ?? item.StoryPathHash,
reason: "Muted from Recommendations");

StatusText.Text = "Recommendation muted.";
_isBusy = false; // release before re-entering RefreshDataAsync's own guard
await RefreshDataAsync();
return;
}
catch (Exception ex)
{
Logger.Error($"Error muting recommendation: {ex.Message}", ex);
StatusText.Text = $"Mute failed: {ex.Message}";
}
finally
{
_isBusy = false;
}
}

/// <summary>
/// The OS / Windows user running the Dashboard, recorded as the audit <c>operator_identity</c>
/// (mirrors <c>AlertDetailWindow.ResolveOperatorIdentity</c>; not a credential).
/// </summary>
private static string ResolveOperatorIdentity()
{
try
{
var name = System.Security.Principal.WindowsIdentity.GetCurrent()?.Name;
if (!string.IsNullOrEmpty(name))
return name;
}
catch
{
/* WindowsIdentity can throw in constrained contexts — fall back. */
}
return Environment.UserName;
}

/// <summary>
/// Swaps the visible region to match the view-model's state and binds the sections.
/// </summary>
Expand Down
Loading
Loading