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
60 changes: 60 additions & 0 deletions Lite/Controls/SnoozeBalloon.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<UserControl x:Class="PerformanceMonitorLite.Controls.SnoozeBalloon"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="360"
FontFamily="Segoe UI">
<Border Background="{DynamicResource BackgroundBrush}"
BorderBrush="{DynamicResource BorderBrush}"
BorderThickness="1"
CornerRadius="6"
TextElement.FontFamily="Segoe UI">
<StackPanel Margin="14,12,14,12">
<DockPanel Margin="0,0,0,4">
<TextBlock x:Name="SeverityIcon"
Text="!"
FontSize="16"
FontWeight="Bold"
VerticalAlignment="Center"
Margin="0,0,8,0"
Foreground="{DynamicResource ForegroundBrush}"/>
<TextBlock x:Name="TitleText"
Text="Alert"
FontWeight="SemiBold"
FontSize="13"
Foreground="{DynamicResource ForegroundBrush}"
VerticalAlignment="Center"/>
</DockPanel>

<TextBlock x:Name="MessageText"
Text=""
TextWrapping="Wrap"
FontSize="12"
Foreground="{DynamicResource ForegroundBrush}"
Margin="0,0,0,10"/>

<DockPanel>
<Button x:Name="DismissButton"
Content="Dismiss"
DockPanel.Dock="Right"
Padding="10,4"
Click="DismissButton_Click"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button x:Name="Snooze15Button"
Content="Snooze 15m"
Padding="8,4"
Margin="0,0,4,0"
Click="Snooze15Button_Click"/>
<Button x:Name="Snooze1hButton"
Content="Snooze 1h"
Padding="8,4"
Margin="0,0,4,0"
Click="Snooze1hButton_Click"/>
<Button x:Name="Snooze4hButton"
Content="Snooze 4h"
Padding="8,4"
Click="Snooze4hButton_Click"/>
</StackPanel>
</DockPanel>
</StackPanel>
</Border>
</UserControl>
106 changes: 106 additions & 0 deletions Lite/Controls/SnoozeBalloon.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor Lite.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Hardcodet.Wpf.TaskbarNotification;
using PerformanceMonitorLite.Models;
using PerformanceMonitorLite.Services;

namespace PerformanceMonitorLite.Controls;

public partial class SnoozeBalloon : UserControl
{
private readonly MuteRuleService _muteRuleService;
private readonly string _serverName;
private readonly string _metricName;
private bool _closed;

public SnoozeBalloon(
string title,
string message,
BalloonIcon icon,
string serverName,
string metricName,
MuteRuleService muteRuleService)
{
InitializeComponent();

_muteRuleService = muteRuleService;
_serverName = serverName;
_metricName = metricName;

TitleText.Text = title;
MessageText.Text = message;
ApplySeverity(icon);
}

private void ApplySeverity(BalloonIcon icon)
{
switch (icon)
{
case BalloonIcon.Error:
SeverityIcon.Text = "⚠"; /* warning sign */
SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
break;
case BalloonIcon.Warning:
SeverityIcon.Text = "⚠";
SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B));
break;
default:
SeverityIcon.Text = "ℹ"; /* info */
SeverityIcon.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
break;
}
}

private void Snooze15Button_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromMinutes(15));
private void Snooze1hButton_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromHours(1));
private void Snooze4hButton_Click(object sender, RoutedEventArgs e) => Snooze(TimeSpan.FromHours(4));

private async void Snooze(TimeSpan duration)
{
if (_closed) return;
_closed = true;

var rule = new MuteRule
{
ServerName = _serverName,
MetricName = _metricName,
ExpiresAtUtc = DateTime.UtcNow + duration,
Reason = $"Snoozed from popup ({FormatDuration(duration)})"
};

try
{
await _muteRuleService.AddRuleAsync(rule);
}
catch (Exception ex)
{
AppLogger.Warn("SnoozeBalloon", $"Failed to add snooze rule: {ex.Message}");
}

CloseBalloon();
}

private void DismissButton_Click(object sender, RoutedEventArgs e)
{
if (_closed) return;
_closed = true;
CloseBalloon();
}

private void CloseBalloon()
{
RaiseEvent(new RoutedEventArgs(TaskbarIcon.BalloonClosingEvent));
}

private static string FormatDuration(TimeSpan d) =>
d.TotalHours >= 1 ? $"{(int)d.TotalHours}h" : $"{(int)d.TotalMinutes}m";
}
49 changes: 35 additions & 14 deletions Lite/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,10 +1306,13 @@ private async void CheckPerformanceAlerts(ServerSummaryItem summary)

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"High CPU",
$"{summary.DisplayName}: {cpuMetricLabel} at {alertCpuValue:F0}% (threshold: {App.AlertCpuThreshold}%)",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning,
summary.DisplayName,
"High CPU",
_muteRuleService);
}

var cpuDetailText = $" {cpuMetricLabel}: {alertCpuValue:F0}%\n Threshold: {App.AlertCpuThreshold}%";
Expand Down Expand Up @@ -1366,10 +1369,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"Blocking Detected",
$"{summary.DisplayName}: {effectiveBlockingCount} blocking session(s)",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning,
summary.DisplayName,
"Blocking Detected",
_muteRuleService);
}

var blockingContext = await BuildBlockingContextAsync(summary.ServerId);
Expand Down Expand Up @@ -1426,10 +1432,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"Deadlocks Detected",
$"{summary.DisplayName}: {effectiveDeadlockCount} deadlock(s) in the last hour",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error,
summary.DisplayName,
"Deadlocks Detected",
_muteRuleService);
}

var deadlockContext = await BuildDeadlockContextAsync(summary.ServerId);
Expand Down Expand Up @@ -1481,10 +1490,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"Poison Wait",
$"{summary.DisplayName}: {worst.WaitType} avg {worst.AvgMsPerWait:F0}ms/wait",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Error,
summary.DisplayName,
"Poison Wait",
_muteRuleService);
}

var poisonContext = BuildPoisonWaitContext(triggered);
Expand Down Expand Up @@ -1556,10 +1568,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"Long-Running Query",
$"{summary.DisplayName}: Session #{worst.SessionId} running {elapsedMinutes}m{previewSuffix}",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning,
summary.DisplayName,
"Long-Running Query",
_muteRuleService);
}

var lrqContext = BuildLongRunningQueryContext(longRunning);
Expand Down Expand Up @@ -1611,10 +1626,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"TempDB Space",
$"{summary.DisplayName}: TempDB {tempDb.UsedPercent:F0}% used",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning,
summary.DisplayName,
"TempDB Space",
_muteRuleService);
}

var tempDbContext = BuildTempDbSpaceContext(tempDb);
Expand Down Expand Up @@ -1672,10 +1690,13 @@ await _emailAlertService.TrySendAlertEmailAsync(

if (!isMuted)
{
_trayService.ShowNotification(
_trayService.ShowSnoozableNotification(
"Long-Running Job",
$"{summary.DisplayName}: {worst.JobName} at {worst.PercentOfAverage:F0}% of avg ({currentMinutes}m)",
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning);
Hardcodet.Wpf.TaskbarNotification.BalloonIcon.Warning,
summary.DisplayName,
"Long-Running Job",
_muteRuleService);
}

var jobContext = BuildAnomalousJobContext(anomalousJobs);
Expand Down
21 changes: 21 additions & 0 deletions Lite/Services/SystemTrayService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Hardcodet.Wpf.TaskbarNotification;
using PerformanceMonitorLite.Controls;

namespace PerformanceMonitorLite.Services;

Expand Down Expand Up @@ -159,6 +160,26 @@ public void ShowNotification(string title, string message, BalloonIcon icon)
_trayIcon?.ShowBalloonTip(title, message, icon);
}

/// <summary>
/// Shows a custom interactive balloon with snooze buttons that create a temporary
/// mute rule scoped to <paramref name="serverName"/> + <paramref name="metricName"/>.
/// Falls back to a plain balloon if the tray icon hasn't been initialized.
/// </summary>
public void ShowSnoozableNotification(
string title,
string message,
BalloonIcon icon,
string serverName,
string metricName,
MuteRuleService muteRuleService)
{
if (_trayIcon == null)
return;

var balloon = new SnoozeBalloon(title, message, icon, serverName, metricName, muteRuleService);
_trayIcon.ShowCustomBalloon(balloon, System.Windows.Controls.Primitives.PopupAnimation.Slide, 15000);
}

public void Dispose()
{
Dispose(true);
Expand Down
Loading