From 59830df7867f0f9e594e758c5cef526dcedd49e1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:07:32 +0200
Subject: [PATCH 1/7] Feature: NotificationManager + Ping notifications
---
.../Resources/Strings.Designer.cs | 92 ++++++++-
.../Resources/Strings.resx | 30 +++
.../GlobalStaticConfiguration.cs | 12 ++
.../NETworkManager.Settings/SettingsInfo.cs | 65 ++++++
Source/NETworkManager/NotificationManager.cs | 93 +++++++++
Source/NETworkManager/NotificationWindow.xaml | 119 +++++++++++
.../NETworkManager/NotificationWindow.xaml.cs | 195 ++++++++++++++++++
Source/NETworkManager/StatusWindow.xaml.cs | 2 -
.../PingMonitorSettingsViewModel.cs | 100 +++++++++
.../ViewModels/PingMonitorViewModel.cs | 71 ++++++-
.../Views/PingMonitorSettingsView.xaml | 41 +++-
.../NETworkManager/Views/PingMonitorView.xaml | 2 +-
Website/docs/application/ping-monitor.md | 57 +++++
Website/docs/changelog/next-release.md | 12 ++
14 files changed, 884 insertions(+), 7 deletions(-)
create mode 100644 Source/NETworkManager/NotificationManager.cs
create mode 100644 Source/NETworkManager/NotificationWindow.xaml
create mode 100644 Source/NETworkManager/NotificationWindow.xaml.cs
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index 5379e77ebd..a6e70ad0e0 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -11118,7 +11118,97 @@ public static string StatusChange {
return ResourceManager.GetString("StatusChange", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Notification.
+ ///
+ public static string PingMonitorNotification {
+ get {
+ return ResourceManager.GetString("PingMonitorNotification", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Show notification popup on status change.
+ ///
+ public static string ShowNotificationPopupOnStatusChange {
+ get {
+ return ResourceManager.GetString("ShowNotificationPopupOnStatusChange", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Play sound on status change.
+ ///
+ public static string PlaySoundOnStatusChange {
+ get {
+ return ResourceManager.GetString("PlaySoundOnStatusChange", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Success threshold.
+ ///
+ public static string NotificationSuccessThreshold {
+ get {
+ return ResourceManager.GetString("NotificationSuccessThreshold", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Number of consecutive successful pings required before an "Host is up" notification is shown. Higher values reduce noise from flapping hosts.
+ ///
+ public static string HelpMessage_NotificationSuccessThreshold {
+ get {
+ return ResourceManager.GetString("HelpMessage_NotificationSuccessThreshold", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failure threshold.
+ ///
+ public static string NotificationFailureThreshold {
+ get {
+ return ResourceManager.GetString("NotificationFailureThreshold", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Number of consecutive failed pings (timeouts) required before an "Host is down" notification is shown. Higher values reduce noise from flapping hosts.
+ ///
+ public static string HelpMessage_NotificationFailureThreshold {
+ get {
+ return ResourceManager.GetString("HelpMessage_NotificationFailureThreshold", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Display duration (seconds).
+ ///
+ public static string NotificationDurationInSeconds {
+ get {
+ return ResourceManager.GetString("NotificationDurationInSeconds", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Host is up.
+ ///
+ public static string HostIsUp {
+ get {
+ return ResourceManager.GetString("HostIsUp", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Host is down.
+ ///
+ public static string HostIsDown {
+ get {
+ return ResourceManager.GetString("HostIsDown", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Status window.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 3539a167ac..661cfe4e7e 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -2367,6 +2367,36 @@ $$hostname$$ --> Hostname
Could not resolve ip address for: "{0}"
+
+ Notification
+
+
+ Show notification popup on status change
+
+
+ Play sound on status change
+
+
+ Success threshold
+
+
+ Number of consecutive successful pings required before an "Host is up" notification is shown. Higher values reduce noise from flapping hosts.
+
+
+ Failure threshold
+
+
+ Number of consecutive failed pings (timeouts) required before an "Host is down" notification is shown. Higher values reduce noise from flapping hosts.
+
+
+ Display duration (seconds)
+
+
+ Host is up
+
+
+ Host is down
+
The application will be restarted...
diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
index 0252eb58a4..ee66caad44 100644
--- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
+++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs
@@ -30,6 +30,11 @@ public static class GlobalStaticConfiguration
// Network config
public static int NetworkChangeDetectionDelay => 5000;
+ // Notification config
+ // Minimum interval (ms) between two notification sounds, so a burst of near-simultaneous
+ // status changes collapses into a single sound instead of an overlapping cacophony.
+ public static int NotificationSoundThrottle => 3000;
+
// Profile config
public static bool Profile_TagsMatchAny => true;
public static bool Profile_ExpandProfileView => true;
@@ -147,6 +152,13 @@ public static class GlobalStaticConfiguration
public static int PingMonitor_ChartTime => 120;
public static ExportFileType PingMonitor_ExportFileType => ExportFileType.Csv;
+ // Application: Ping Monitor (notifications)
+ public static bool PingMonitor_ShowNotificationPopup => true;
+ public static bool PingMonitor_NotificationSound => true;
+ public static int PingMonitor_NotificationSuccessThreshold => 1;
+ public static int PingMonitor_NotificationFailureThreshold => 3;
+ public static int PingMonitor_NotificationCloseTime => 10;
+
// Application: Traceroute
public static int Traceroute_MaximumHops => 30;
public static int Traceroute_Timeout => 4000;
diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs
index ffbcdc1f37..1a739e5724 100644
--- a/Source/NETworkManager.Settings/SettingsInfo.cs
+++ b/Source/NETworkManager.Settings/SettingsInfo.cs
@@ -1537,6 +1537,71 @@ public int PingMonitor_ChartTime
}
} = GlobalStaticConfiguration.PingMonitor_ChartTime;
+ public bool PingMonitor_ShowNotificationPopup
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PingMonitor_ShowNotificationPopup;
+
+ public bool PingMonitor_NotificationSound
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PingMonitor_NotificationSound;
+
+ public int PingMonitor_NotificationSuccessThreshold
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PingMonitor_NotificationSuccessThreshold;
+
+ public int PingMonitor_NotificationFailureThreshold
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PingMonitor_NotificationFailureThreshold;
+
+ public int PingMonitor_NotificationCloseTime
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ } = GlobalStaticConfiguration.PingMonitor_NotificationCloseTime;
+
public string PingMonitor_ExportFilePath
{
get;
diff --git a/Source/NETworkManager/NotificationManager.cs b/Source/NETworkManager/NotificationManager.cs
new file mode 100644
index 0000000000..866e4f5e69
--- /dev/null
+++ b/Source/NETworkManager/NotificationManager.cs
@@ -0,0 +1,93 @@
+using MahApps.Metro.IconPacks;
+using NETworkManager.Settings;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Media;
+using System.Threading;
+using System.Windows;
+
+namespace NETworkManager;
+
+///
+/// Shows and manages stackable notification popups. Follows the same pattern as
+/// — callers pass display parameters; internals are hidden.
+///
+public static class NotificationManager
+{
+ private static readonly List ActiveWindows = [];
+
+ ///
+ /// Margin (in DIP) between the screen edges and between stacked notification windows.
+ ///
+ internal const double WindowMargin = 10.0;
+
+ // Throttles the notification sound (see GlobalStaticConfiguration.NotificationSoundThrottle)
+ // so a burst of near-simultaneous status changes collapses into a single sound.
+ private static readonly Lock SoundLock = new();
+ private static DateTime _lastSoundPlayed = DateTime.MinValue;
+
+ ///
+ /// Shows a notification popup in the bottom-right corner of the primary screen.
+ /// Safe to call from any thread.
+ ///
+ /// The Material icon shown on the left of the popup.
+ /// The icon fill color (e.g. "Red" or "#badc58").
+ /// The bold header text (e.g. the host title).
+ /// The gray subtext below the header (e.g. "Host is up").
+ /// Seconds before the popup closes automatically.
+ public static void Show(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds)
+ {
+ Application.Current.Dispatcher.BeginInvoke(() =>
+ {
+ var window = new NotificationWindow(iconKind, iconColor, title, message, closeTimeSeconds);
+
+ window.Closed += (_, _) =>
+ {
+ ActiveWindows.Remove(window);
+ RepositionAll();
+ };
+
+ ActiveWindows.Add(window);
+ window.Show(); // triggers OnSourceInitialized → window positions itself
+ });
+ }
+
+ ///
+ /// Returns the combined height (including margins) of all windows stacked below the given
+ /// window, i.e. the vertical offset from the bottom edge at which it should be placed.
+ ///
+ internal static double GetStackOffset(NotificationWindow window)
+ {
+ return ActiveWindows.TakeWhile(w => !ReferenceEquals(w, window)).Sum(w => w.ActualHeight + WindowMargin);
+ }
+
+ ///
+ /// Plays a notification sound, throttled so that a burst of near-simultaneous status changes
+ /// (e.g. many hosts going down at once) results in a single sound. Safe to call from any thread.
+ ///
+ /// The system sound to play.
+ public static void PlaySound(SystemSound sound)
+ {
+ lock (SoundLock)
+ {
+ var now = DateTime.UtcNow;
+
+ if ((now - _lastSoundPlayed).TotalMilliseconds < GlobalStaticConfiguration.NotificationSoundThrottle)
+ return;
+
+ _lastSoundPlayed = now;
+ }
+
+ sound.Play();
+ }
+
+ ///
+ /// Repositions all active windows after a sibling closes and stack indices shift.
+ ///
+ internal static void RepositionAll()
+ {
+ foreach (var window in ActiveWindows)
+ window.Reposition();
+ }
+}
diff --git a/Source/NETworkManager/NotificationWindow.xaml b/Source/NETworkManager/NotificationWindow.xaml
new file mode 100644
index 0000000000..fba46904b6
--- /dev/null
+++ b/Source/NETworkManager/NotificationWindow.xaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/NETworkManager/NotificationWindow.xaml.cs b/Source/NETworkManager/NotificationWindow.xaml.cs
new file mode 100644
index 0000000000..ca97851569
--- /dev/null
+++ b/Source/NETworkManager/NotificationWindow.xaml.cs
@@ -0,0 +1,195 @@
+using MahApps.Metro.IconPacks;
+using NETworkManager.Utilities;
+using System;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Forms;
+using System.Windows.Input;
+using System.Windows.Threading;
+
+namespace NETworkManager;
+
+///
+/// A generic, stackable notification popup shown in the bottom-right corner of the primary
+/// screen. Contains no feature-specific knowledge — icon, color, title and message are all
+/// supplied by the caller (via ).
+///
+public partial class NotificationWindow : INotifyPropertyChanged
+{
+ #region PropertyChangedEventHandler
+
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ private void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ #endregion
+
+ #region Variables
+
+ private readonly DispatcherTimer _timer = new(DispatcherPriority.Render);
+ private readonly Stopwatch _stopwatch = new();
+
+ // Bound properties — all set once in the constructor, no change notifications needed.
+ // Note: the header property is named NotificationTitle (not Title) to avoid clashing with
+ // the inherited Window.Title property, which a "{Binding Title}" would otherwise resolve to.
+ public PackIconMaterialKind IconKind { get; }
+ public string IconColor { get; }
+ public string NotificationTitle { get; }
+ public string Message { get; }
+
+ // Timestamp shown in the header — captured when the notification is created, which is the
+ // moment the status change occurred.
+ public string Time { get; }
+
+ public double TimeMax { get; }
+
+ public double TimeRemaining
+ {
+ get;
+ private set
+ {
+ if (Math.Abs(value - field) < 0.001)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ // Command — initialized once in the constructor, not recreated on every binding access
+ public ICommand CloseCommand { get; }
+
+ #endregion
+
+ #region Constructor
+
+ public NotificationWindow(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds)
+ {
+ InitializeComponent();
+ DataContext = this;
+
+ IconKind = iconKind;
+ IconColor = iconColor;
+ NotificationTitle = title;
+ Message = message;
+ Time = DateTime.Now.ToString("HH:mm:ss");
+ TimeMax = closeTimeSeconds;
+ TimeRemaining = closeTimeSeconds;
+
+ CloseCommand = new RelayCommand(_ => CloseWindow());
+
+ // The window auto-sizes its height to the content (SizeToContent=Height), so when the
+ // message wraps to more lines the whole stack must re-anchor to the bottom edge.
+ SizeChanged += (_, _) => NotificationManager.RepositionAll();
+
+ _timer.Interval = TimeSpan.FromMilliseconds(16);
+ _timer.Tick += Timer_Tick;
+ }
+
+ #endregion
+
+ #region ICommands & Actions
+
+ private void ShowMainWindow()
+ {
+ CloseWindow();
+
+ if (System.Windows.Application.Current.MainWindow is MainWindow mainWindow &&
+ mainWindow.ShowWindowCommand.CanExecute(null))
+ mainWindow.ShowWindowCommand.Execute(null);
+ }
+
+ #endregion
+
+ #region Methods
+
+ ///
+ /// Called by when the stack changes (a sibling closes or
+ /// resizes) and this window may need to move.
+ ///
+ internal void Reposition()
+ {
+ ApplyPosition();
+ }
+
+ private void ApplyPosition()
+ {
+ if (Screen.PrimaryScreen == null)
+ return;
+
+ var scale = System.Windows.Media.VisualTreeHelper.GetDpi(this).DpiScaleX;
+ var area = Screen.PrimaryScreen.WorkingArea;
+
+ // Offset = total height (incl. margins) of all windows stacked below this one. Using the
+ // actual heights keeps variable-height popups (wrapped messages) bottom-anchored.
+ var offset = NotificationManager.GetStackOffset(this);
+
+ Left = area.Right / scale - Width - NotificationManager.WindowMargin;
+ Top = area.Bottom / scale - ActualHeight - NotificationManager.WindowMargin - offset;
+ }
+
+ private void CloseWindow()
+ {
+ _timer.Stop();
+ _stopwatch.Stop();
+
+ Closing -= MetroWindow_Closing;
+ Close(); // fires Window.Closed → NotificationManager removes from stack and repositions
+ }
+
+ #endregion
+
+ #region Events
+
+ // Lifecycle — the HWND exists here, so DPI is safe to read for positioning
+ protected override void OnSourceInitialized(EventArgs e)
+ {
+ base.OnSourceInitialized(e);
+
+ ApplyPosition();
+
+ _stopwatch.Restart();
+ _timer.Start();
+ }
+
+ // Clicking anywhere on the popup opens the main window. The close button handles its own
+ // mouse events, so clicking [×] does not bubble up here.
+ private void Root_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
+ {
+ ShowMainWindow();
+ }
+
+ // Intercept Alt+F4 / OS close so the stack cleanup always runs through CloseWindow()
+ private void MetroWindow_Closing(object sender, CancelEventArgs e)
+ {
+ e.Cancel = true;
+ CloseWindow();
+ }
+
+ private async void Timer_Tick(object sender, EventArgs e)
+ {
+ // Use a local for the close decision — the TimeRemaining setter ignores sub-0.001 changes
+ // (to throttle the progress bar), so reading the property back could stay stuck at a tiny
+ // positive value and the window would never close.
+ var remaining = Math.Max(0.0, TimeMax - _stopwatch.Elapsed.TotalSeconds);
+ TimeRemaining = remaining;
+
+ if (remaining > 0)
+ return;
+
+ _timer.Stop();
+ _stopwatch.Stop();
+
+ await Task.Delay(250); // let the bar visually reach zero before closing
+
+ CloseWindow();
+ }
+
+ #endregion
+}
diff --git a/Source/NETworkManager/StatusWindow.xaml.cs b/Source/NETworkManager/StatusWindow.xaml.cs
index ade8b9b17c..f767076755 100644
--- a/Source/NETworkManager/StatusWindow.xaml.cs
+++ b/Source/NETworkManager/StatusWindow.xaml.cs
@@ -144,8 +144,6 @@ public void ShowWindow(bool enableCloseTimer = false)
// Check the network connection
Check();
- enableCloseTimer = true;
-
// Close the window after a certain time
if (enableCloseTimer)
{
diff --git a/Source/NETworkManager/ViewModels/PingMonitorSettingsViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorSettingsViewModel.cs
index 462b907090..f3692fbaa7 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorSettingsViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorSettingsViewModel.cs
@@ -144,6 +144,101 @@ public int ChartTime
}
}
+ ///
+ /// Gets or sets a value indicating whether a notification popup is shown on status change.
+ ///
+ public bool ShowNotificationPopup
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PingMonitor_ShowNotificationPopup = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether a sound is played on status change.
+ ///
+ public bool NotificationSound
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PingMonitor_NotificationSound = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the number of consecutive successful pings required before an UP notification fires.
+ ///
+ public int NotificationSuccessThreshold
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PingMonitor_NotificationSuccessThreshold = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the number of consecutive failed pings required before a DOWN notification fires.
+ ///
+ public int NotificationFailureThreshold
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PingMonitor_NotificationFailureThreshold = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the time in seconds the notification popup is shown before it closes automatically.
+ ///
+ public int NotificationCloseTime
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ if (!_isLoading)
+ SettingsManager.Current.PingMonitor_NotificationCloseTime = value;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Contructor, load settings
@@ -169,6 +264,11 @@ private void LoadSettings()
WaitTime = SettingsManager.Current.PingMonitor_WaitTime;
ExpandHostView = SettingsManager.Current.PingMonitor_ExpandHostView;
ChartTime = SettingsManager.Current.PingMonitor_ChartTime;
+ ShowNotificationPopup = SettingsManager.Current.PingMonitor_ShowNotificationPopup;
+ NotificationSound = SettingsManager.Current.PingMonitor_NotificationSound;
+ NotificationSuccessThreshold = SettingsManager.Current.PingMonitor_NotificationSuccessThreshold;
+ NotificationFailureThreshold = SettingsManager.Current.PingMonitor_NotificationFailureThreshold;
+ NotificationCloseTime = SettingsManager.Current.PingMonitor_NotificationCloseTime;
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 2558a171ea..72e8fc5913 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -5,6 +5,7 @@
using LiveChartsCore.SkiaSharpView.Painting;
using LiveChartsCore.SkiaSharpView.Painting.Effects;
using log4net;
+using MahApps.Metro.IconPacks;
using MahApps.Metro.SimpleChildWindow;
using NETworkManager.Localization.Resources;
using NETworkManager.Models.Export;
@@ -76,6 +77,13 @@ public PingMonitorViewModel(Guid hostId, Action removeHostByGuid,
private List _pingInfoList = [];
+ // Notification threshold tracking. The status transition (and notification) only fires once
+ // the configured number of consecutive successes/failures is reached, to avoid noise from
+ // flapping hosts. The initial state is established silently (no notification).
+ private int _consecutiveSuccesses;
+ private int _consecutiveFailures;
+ private bool _initialStateEstablished;
+
///
/// Gets the title of the monitor, typically "Hostname # IP".
///
@@ -92,6 +100,13 @@ private set
}
}
+ ///
+ /// Gets the host display name used as the notification header: the hostname if available,
+ /// otherwise the IP address as a fallback.
+ ///
+ private string NotificationHostDisplay =>
+ string.IsNullOrEmpty(Hostname) ? IPAddress.ToString() : Hostname.TrimEnd('.');
+
///
/// Gets the hostname of the monitored host.
///
@@ -529,6 +544,11 @@ public void Start()
Lost = 0;
PacketLoss = 0;
+ // Reset notification threshold tracking so the initial state is re-established silently
+ _consecutiveSuccesses = 0;
+ _consecutiveFailures = 0;
+ _initialStateEstablished = false;
+
// Reset chart
_sessionStartTime = DateTime.Now;
ResetTimeChart();
@@ -653,12 +673,37 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e)
LvlChartsDefaultInfo timeInfo;
+ var successThreshold = SettingsManager.Current.PingMonitor_NotificationSuccessThreshold;
+ var failureThreshold = SettingsManager.Current.PingMonitor_NotificationFailureThreshold;
+
if (e.Args.Status == IPStatus.Success)
{
- if (!IsReachable)
+ _consecutiveSuccesses++;
+ _consecutiveFailures = 0;
+
+ if (!_initialStateEstablished)
+ {
+ if (_consecutiveSuccesses >= successThreshold)
+ {
+ _initialStateEstablished = true;
+ StatusTime = DateTime.Now;
+ IsReachable = true;
+ // No notification — this is the initial state being established.
+ }
+ }
+ else if (!IsReachable && _consecutiveSuccesses >= successThreshold)
{
StatusTime = DateTime.Now;
IsReachable = true;
+
+ if (SettingsManager.Current.PingMonitor_NotificationSound)
+ NotificationManager.PlaySound(System.Media.SystemSounds.Asterisk); // gentle info sound for UP
+
+ if (SettingsManager.Current.PingMonitor_ShowNotificationPopup)
+ NotificationManager.Show(
+ PackIconMaterialKind.LanConnect, "#badc58",
+ NotificationHostDisplay, Strings.HostIsUp,
+ SettingsManager.Current.PingMonitor_NotificationCloseTime);
}
Received++;
@@ -667,10 +712,32 @@ private void Ping_PingReceived(object sender, PingReceivedArgs e)
}
else
{
- if (IsReachable)
+ _consecutiveFailures++;
+ _consecutiveSuccesses = 0;
+
+ if (!_initialStateEstablished)
+ {
+ if (_consecutiveFailures >= failureThreshold)
+ {
+ _initialStateEstablished = true;
+ StatusTime = DateTime.Now;
+ IsReachable = false;
+ // No notification or sound — initial state.
+ }
+ }
+ else if (IsReachable && _consecutiveFailures >= failureThreshold)
{
StatusTime = DateTime.Now;
IsReachable = false;
+
+ if (SettingsManager.Current.PingMonitor_NotificationSound)
+ NotificationManager.PlaySound(System.Media.SystemSounds.Exclamation); // warning sound for DOWN
+
+ if (SettingsManager.Current.PingMonitor_ShowNotificationPopup)
+ NotificationManager.Show(
+ PackIconMaterialKind.LanDisconnect, "Red",
+ NotificationHostDisplay, Strings.HostIsDown,
+ SettingsManager.Current.PingMonitor_NotificationCloseTime);
}
Lost++;
diff --git a/Source/NETworkManager/Views/PingMonitorSettingsView.xaml b/Source/NETworkManager/Views/PingMonitorSettingsView.xaml
index f14a3e4584..0d3fc125ee 100644
--- a/Source/NETworkManager/Views/PingMonitorSettingsView.xaml
+++ b/Source/NETworkManager/Views/PingMonitorSettingsView.xaml
@@ -27,6 +27,45 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PingMonitorView.xaml b/Source/NETworkManager/Views/PingMonitorView.xaml
index 6639822c1a..d0d6aeefdc 100644
--- a/Source/NETworkManager/Views/PingMonitorView.xaml
+++ b/Source/NETworkManager/Views/PingMonitorView.xaml
@@ -223,7 +223,7 @@
-
+
diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md
index d76aff93a4..09d028c2e5 100644
--- a/Website/docs/application/ping-monitor.md
+++ b/Website/docs/application/ping-monitor.md
@@ -61,6 +61,23 @@ Right-click a monitored host (anywhere except the chart) to open the context men
Right-clicking an individual field (hostname, IP address, ...) instead lets you **Copy** its value to the clipboard.
+### Notifications
+
+When a monitored host changes its reachability state (up → down or down → up), a small notification popup can appear in the bottom-right corner of the primary screen, optionally accompanied by a system sound.
+
+- The popup shows a status icon (green when the host is up, red when it is down), the hostname (or the IP address as a fallback) and the current status (**Host is up** / **Host is down**).
+- Multiple notifications **stack** upwards, so several hosts changing state at once are all visible.
+- **Click anywhere** on a notification to bring the main window to the front. Use the **×** button to dismiss a single notification without opening the main window.
+- Each notification closes automatically after a configurable [duration](#display-duration-seconds); a thin progress bar at the bottom shows the remaining time.
+- To avoid noise from flapping hosts, a state change is only reported after a configurable number of consecutive successes ([Success threshold](#success-threshold)) or failures ([Failure threshold](#failure-threshold)).
+- The **initial** state of a host (established when monitoring starts) never triggers a notification or sound — only later transitions do.
+
+:::note
+
+When many hosts change state at almost the same time (for example, when an uplink goes down and all hosts time out together), every host still shows its own popup, but the sound is played only once within a short interval to avoid an overlapping cacophony.
+
+:::
+
## Profile
### Inherit host from general
@@ -147,3 +164,43 @@ Expand the host view to show more information when the host is added.
**Type:** `Boolean`
**Default:** `Disabled`
+
+### Show notification popup on status change
+
+Show a notification popup in the bottom-right corner of the primary screen when a monitored host changes its reachability state. See [Notifications](#notifications) for details.
+
+**Type:** `Boolean`
+
+**Default:** `Enabled`
+
+### Play sound on status change
+
+Play a system sound when a monitored host changes its reachability state. This is independent of the [popup](#show-notification-popup-on-status-change) — you can enable the sound without the popup, or vice versa.
+
+**Type:** `Boolean`
+
+**Default:** `Enabled`
+
+### Success threshold
+
+Number of consecutive successful pings required before an **Host is up** notification is shown. Higher values reduce noise from flapping hosts.
+
+**Type:** `Integer` [Min `1`, Max `10`]
+
+**Default:** `1`
+
+### Failure threshold
+
+Number of consecutive failed pings (timeouts) required before an **Host is down** notification is shown. Higher values reduce noise from flapping hosts.
+
+**Type:** `Integer` [Min `1`, Max `10`]
+
+**Default:** `3`
+
+### Display duration (seconds)
+
+Time in seconds the notification popup is shown before it closes automatically.
+
+**Type:** `Integer` [Min `3`, Max `60`]
+
+**Default:** `10`
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 7ffa01b136..2068f182a4 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -64,6 +64,10 @@ Release date: **xx.xx.2025**
- Profiles can now be imported from **Active Directory**. Search for computers by name using an AD query, select the results, assign a group, and apply connection settings (RDP, SSH, etc.) before importing. [#3368](https://github.com/BornToBeRoot/NETworkManager/pull/3368)
+**Ping Monitor**
+
+- New **status change notifications**: when a monitored host goes up or down, a stackable popup appears in the bottom-right corner of the primary screen and/or an optional system sound is played. Configurable **success** and **failure thresholds** suppress noise from flapping hosts, the initial state is established silently, and clicking a popup brings the main window to the front. When many hosts change state at once, every host still shows its own popup but the sound is played only once. (See the [documentation](https://borntoberoot.net/NETworkManager/docs/application/ping-monitor#notifications) for more details) [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+
## Improvements
**WiFi**
@@ -127,6 +131,14 @@ Release date: **xx.xx.2025**
- Fixed incorrect initial embedded window size on high-DPI monitors. The `WindowsFormsHost` panel now sets its initial dimensions in physical pixels using the current DPI scale factor, ensuring the PuTTY window fills the panel correctly at startup. [#3352](https://github.com/BornToBeRoot/NETworkManager/pull/3352)
+**Dashboard**
+
+- Fixed the Status Window auto-close timer firing even when the window was opened manually. The `enableCloseTimer` parameter is now respected, so a manually opened Status Window stays open while the one shown automatically on a network change still closes after the configured time. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+
+**Ping Monitor**
+
+- Fixed the **Status change** field showing the time of day formatted as if it were a duration (e.g. `02h 30m 25s` for 14:30:25). It now correctly shows the time of the last status change as `HH:mm:ss`. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+
**Network Interface**
- Fixed `Renew6Action` incorrectly calling `ipconfig /renew` (IPv4) instead of `ipconfig /renew6` (IPv6) when renewing the IPv6 address. [#3441](https://github.com/BornToBeRoot/NETworkManager/pull/3441)
From fd7ba27bc664017b3791a3dc7f58d4c0bf8a1f14 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:20:02 +0200
Subject: [PATCH 2/7] Feature: Notifications
---
Source/NETworkManager/NotificationManager.cs | 7 ++++++-
Source/NETworkManager/ViewModels/PingMonitorViewModel.cs | 5 ++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/Source/NETworkManager/NotificationManager.cs b/Source/NETworkManager/NotificationManager.cs
index 866e4f5e69..409202794d 100644
--- a/Source/NETworkManager/NotificationManager.cs
+++ b/Source/NETworkManager/NotificationManager.cs
@@ -38,6 +38,10 @@ public static class NotificationManager
/// Seconds before the popup closes automatically.
public static void Show(PackIconMaterialKind iconKind, string iconColor, string title, string message, int closeTimeSeconds)
{
+ // A late ping callback during application shutdown may find no Application instance.
+ if (Application.Current is null)
+ return;
+
Application.Current.Dispatcher.BeginInvoke(() =>
{
var window = new NotificationWindow(iconKind, iconColor, title, message, closeTimeSeconds);
@@ -83,7 +87,8 @@ public static void PlaySound(SystemSound sound)
}
///
- /// Repositions all active windows after a sibling closes and stack indices shift.
+ /// Repositions all active windows, e.g. after one closes or its height changes so the stack
+ /// stays bottom-anchored without gaps or overlap.
///
internal static void RepositionAll()
{
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 72e8fc5913..7604960eb5 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -544,10 +544,13 @@ public void Start()
Lost = 0;
PacketLoss = 0;
- // Reset notification threshold tracking so the initial state is re-established silently
+ // Reset notification threshold tracking so the initial state is re-established silently.
+ // IsReachable is cleared too, so a prior run's state can't linger and show a stale (e.g.
+ // green "up") icon during the first few pings before the new initial state is established.
_consecutiveSuccesses = 0;
_consecutiveFailures = 0;
_initialStateEstablished = false;
+ IsReachable = false;
// Reset chart
_sessionStartTime = DateTime.Now;
From e3001cd314202e31de6ac8f41d6320cf51de2185 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:25:46 +0200
Subject: [PATCH 3/7] Docs: #3471
---
Website/docs/changelog/next-release.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index 2068f182a4..bfc7ba6941 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -66,7 +66,7 @@ Release date: **xx.xx.2025**
**Ping Monitor**
-- New **status change notifications**: when a monitored host goes up or down, a stackable popup appears in the bottom-right corner of the primary screen and/or an optional system sound is played. Configurable **success** and **failure thresholds** suppress noise from flapping hosts, the initial state is established silently, and clicking a popup brings the main window to the front. When many hosts change state at once, every host still shows its own popup but the sound is played only once. (See the [documentation](https://borntoberoot.net/NETworkManager/docs/application/ping-monitor#notifications) for more details) [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+- New **status change notifications**: when a monitored host goes up or down, a stackable popup appears in the bottom-right corner of the primary screen and/or an optional system sound is played. Configurable **success** and **failure thresholds** suppress noise from flapping hosts, the initial state is established silently, and clicking a popup brings the main window to the front. When many hosts change state at once, every host still shows its own popup but the sound is played only once. (See the [documentation](https://borntoberoot.net/NETworkManager/docs/application/ping-monitor#notifications) for more details) [#3471](https://github.com/BornToBeRoot/NETworkManager/pull/3471)
## Improvements
@@ -133,11 +133,11 @@ Release date: **xx.xx.2025**
**Dashboard**
-- Fixed the Status Window auto-close timer firing even when the window was opened manually. The `enableCloseTimer` parameter is now respected, so a manually opened Status Window stays open while the one shown automatically on a network change still closes after the configured time. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+- Fixed the Status Window auto-close timer firing even when the window was opened manually. The `enableCloseTimer` parameter is now respected, so a manually opened Status Window stays open while the one shown automatically on a network change still closes after the configured time. [#3471](https://github.com/BornToBeRoot/NETworkManager/pull/3471)
**Ping Monitor**
-- Fixed the **Status change** field showing the time of day formatted as if it were a duration (e.g. `02h 30m 25s` for 14:30:25). It now correctly shows the time of the last status change as `HH:mm:ss`. [#XXXX](https://github.com/BornToBeRoot/NETworkManager/pull/XXXX)
+- Fixed the **Status change** field showing the time of day formatted as if it were a duration (e.g. `02h 30m 25s` for 14:30:25). It now correctly shows the time of the last status change as `HH:mm:ss`. [#3471](https://github.com/BornToBeRoot/NETworkManager/pull/3471)
**Network Interface**
From 052a4efc19189aba132f815b027fc097ad942055 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:26:11 +0200
Subject: [PATCH 4/7] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
Website/docs/application/ping-monitor.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md
index 09d028c2e5..6b3d633d74 100644
--- a/Website/docs/application/ping-monitor.md
+++ b/Website/docs/application/ping-monitor.md
@@ -183,7 +183,7 @@ Play a system sound when a monitored host changes its reachability state. This i
### Success threshold
-Number of consecutive successful pings required before an **Host is up** notification is shown. Higher values reduce noise from flapping hosts.
+Number of consecutive successful pings required before a **Host is up** notification is shown. Higher values reduce noise from flapping hosts.
**Type:** `Integer` [Min `1`, Max `10`]
From cec0977f5e4fe2a74687e28daf6c75919e4a90b8 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:26:21 +0200
Subject: [PATCH 5/7] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
Source/NETworkManager.Localization/Resources/Strings.resx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 661cfe4e7e..f1d9467938 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -2386,7 +2386,7 @@ $$hostname$$ --> Hostname
Failure threshold
- Number of consecutive failed pings (timeouts) required before an "Host is down" notification is shown. Higher values reduce noise from flapping hosts.
+ Number of consecutive failed pings (timeouts) required before a "Host is down" notification is shown. Higher values reduce noise from flapping hosts.Display duration (seconds)
From 056b0c3d84476c4d2183b73f55b44ca2cbb42a72 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:26:45 +0200
Subject: [PATCH 6/7] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
Website/docs/application/ping-monitor.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md
index 6b3d633d74..a1ad0a605d 100644
--- a/Website/docs/application/ping-monitor.md
+++ b/Website/docs/application/ping-monitor.md
@@ -191,7 +191,7 @@ Number of consecutive successful pings required before a **Host is up** notifica
### Failure threshold
-Number of consecutive failed pings (timeouts) required before an **Host is down** notification is shown. Higher values reduce noise from flapping hosts.
+Number of consecutive failed pings (timeouts) required before a **Host is down** notification is shown. Higher values reduce noise from flapping hosts.
**Type:** `Integer` [Min `1`, Max `10`]
From 1e5734b3d4378732f89f3e479b7a436204e299e1 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 7 Jun 2026 00:26:52 +0200
Subject: [PATCH 7/7] Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
Source/NETworkManager.Localization/Resources/Strings.resx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index f1d9467938..28fe9f6281 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -2380,7 +2380,7 @@ $$hostname$$ --> Hostname
Success threshold
- Number of consecutive successful pings required before an "Host is up" notification is shown. Higher values reduce noise from flapping hosts.
+ Number of consecutive successful pings required before a "Host is up" notification is shown. Higher values reduce noise from flapping hosts.Failure threshold