From eac9e846883f47cfa82edff1e0830594557da5b5 Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:30:34 +0200
Subject: [PATCH 1/4] Feature: Add count up/down, open/closed and re-work ping
monitor view
---
.../Resources/Strings.Designer.cs | 35 +++-
.../Resources/Strings.resx | 11 +-
.../Network/IPScanner.cs | 21 +++
.../Network/PortScanner.cs | 21 +++
.../ViewModels/IPScannerViewModel.cs | 53 +++++-
.../PingMonitorGroupSummaryConverter.cs | 52 ++++++
.../ViewModels/PingMonitorHostViewModel.cs | 49 +++++
.../ViewModels/PingMonitorViewModel.cs | 6 +
.../ViewModels/PortScannerViewModel.cs | 51 ++++-
.../NETworkManager/Views/IPScannerView.xaml | 28 +++
.../Views/PingMonitorHostView.xaml | 70 ++++++-
.../NETworkManager/Views/PingMonitorView.xaml | 176 +++++++-----------
.../Views/PingMonitorView.xaml.cs | 7 +
.../NETworkManager/Views/PortScannerView.xaml | 29 +++
Website/docs/changelog/next-release.md | 3 +
15 files changed, 490 insertions(+), 122 deletions(-)
create mode 100644 Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs
diff --git a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
index b1362fdc88..3cf04e06e4 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
+++ b/Source/NETworkManager.Localization/Resources/Strings.Designer.cs
@@ -3432,7 +3432,16 @@ public static string DontFragment {
return ResourceManager.GetString("DontFragment", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Down.
+ ///
+ public static string Down {
+ get {
+ return ResourceManager.GetString("Down", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Download.
///
@@ -8091,7 +8100,16 @@ public static string Pause {
return ResourceManager.GetString("Pause", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Paused.
+ ///
+ public static string Paused {
+ get {
+ return ResourceManager.GetString("Paused", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Performance.
///
@@ -11497,7 +11515,7 @@ public static string Status {
}
///
- /// Looks up a localized string similar to Status change.
+ /// Looks up a localized string similar to Last status change.
///
public static string StatusChange {
get {
@@ -12233,7 +12251,16 @@ public static string UntrayBringWindowToForeground {
return ResourceManager.GetString("UntrayBringWindowToForeground", resourceCulture);
}
}
-
+
+ ///
+ /// Looks up a localized string similar to Up.
+ ///
+ public static string Up {
+ get {
+ return ResourceManager.GetString("Up", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Update.
///
diff --git a/Source/NETworkManager.Localization/Resources/Strings.resx b/Source/NETworkManager.Localization/Resources/Strings.resx
index 0c2584cd75..9c69a5b26d 100644
--- a/Source/NETworkManager.Localization/Resources/Strings.resx
+++ b/Source/NETworkManager.Localization/Resources/Strings.resx
@@ -228,6 +228,9 @@
Don't fragment
+
+ Down
+
Edit credentials
@@ -1252,6 +1255,9 @@ Profile files are not affected!
Untray / Bring window to foreground
+
+ Up
+
URL
@@ -2442,6 +2448,9 @@ is disabled!
Pause
+
+ Paused
+
Resume
@@ -2836,7 +2845,7 @@ is disabled!
Received
- Status change
+ Last status changeUpdate available!
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 039fc66482..9662e85211 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -21,6 +21,20 @@ public sealed class IPScanner(IPScannerOptions options)
#region Variables
private int _progressValue;
+ private int _hostsUp;
+ private int _hostsDown;
+
+ ///
+ /// Gets the number of hosts found to be reachable so far. Thread-safe; may be read from
+ /// any thread while the scan is running.
+ ///
+ public int HostsUp => Volatile.Read(ref _hostsUp);
+
+ ///
+ /// Gets the number of hosts found to be unreachable so far. Thread-safe; may be read from
+ /// any thread while the scan is running.
+ ///
+ public int HostsDown => Volatile.Read(ref _hostsDown);
#endregion
@@ -129,6 +143,13 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
isAnyPortOpen || // Any port is open
netBIOSInfo.IsReachable; // NetBIOS response
+ // Count reachable/unreachable hosts unconditionally, since ShowAllResults
+ // (below) may prevent unreachable hosts from ever reaching HostScanned
+ if (isReachable)
+ Interlocked.Increment(ref _hostsUp);
+ else
+ Interlocked.Increment(ref _hostsDown);
+
// DNS & ARP
if (isReachable || options.ShowAllResults)
{
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index fff1c51681..f9ec4d4159 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -22,9 +22,23 @@ public PortScanner(PortScannerOptions options)
#region Variables
private int _progressValue;
+ private int _portsOpen;
+ private int _portsClosed;
private readonly PortScannerOptions _options;
+ ///
+ /// Gets the number of ports found to be open so far. Thread-safe; may be read from any
+ /// thread while the scan is running.
+ ///
+ public int PortsOpen => Volatile.Read(ref _portsOpen);
+
+ ///
+ /// Gets the number of ports found to be closed (or timed out) so far. Thread-safe; may be
+ /// read from any thread while the scan is running.
+ ///
+ public int PortsClosed => Volatile.Read(ref _portsClosed);
+
#endregion
#region Events
@@ -102,6 +116,13 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, portCt)
.ConfigureAwait(false);
+ // Count open/closed ports unconditionally, since ShowAllResults (below)
+ // may prevent closed ports from ever reaching PortScanned
+ if (portState == PortState.Open)
+ Interlocked.Increment(ref _portsOpen);
+ else
+ Interlocked.Increment(ref _portsClosed);
+
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
new PortScannerPortInfo(host.ipAddress, hostname, port,
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 388ceb5a64..d39ff1c35f 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -54,6 +54,8 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal
// (unconditionally, unlike HostScanned), so it's flushed to the bound property on the same
// timer instead of updating it directly from the background thread on every event.
private int _latestHostsScanned;
+ private int _latestHostsUp;
+ private int _latestHostsDown;
///
/// Gets or sets the host or IP range to scan.
@@ -209,6 +211,38 @@ public int HostsScanned
}
}
+ ///
+ /// Gets or sets the number of hosts found to be reachable so far.
+ ///
+ public int HostsUp
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the number of hosts found to be unreachable so far.
+ ///
+ public int HostsDown
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets a value indicating whether the scan is being prepared.
///
@@ -485,7 +519,11 @@ private async Task Start()
HostsToScan = hosts.hosts.Count;
HostsScanned = 0;
+ HostsUp = 0;
+ HostsDown = 0;
Volatile.Write(ref _latestHostsScanned, 0);
+ Volatile.Write(ref _latestHostsUp, 0);
+ Volatile.Write(ref _latestHostsDown, 0);
PreparingScan = false;
@@ -768,20 +806,29 @@ private void FlushResultsBuffer()
/// pick up on the next timer tick, instead of updating the bound property directly from a
/// background thread on every single host.
///
- /// The source of the event.
+ /// The instance raising the event.
/// The instance containing the event data.
private void ProgressChanged(object sender, ProgressChangedArgs e)
{
Volatile.Write(ref _latestHostsScanned, e.Value);
+
+ if (sender is IPScanner ipScanner)
+ {
+ Volatile.Write(ref _latestHostsUp, ipScanner.HostsUp);
+ Volatile.Write(ref _latestHostsDown, ipScanner.HostsDown);
+ }
}
///
- /// Pushes the latest buffered progress value into . Always called
- /// on the UI thread - same calling contexts as .
+ /// Pushes the latest buffered progress value into ,
+ /// and . Always called on the UI thread - same
+ /// calling contexts as .
///
private void FlushProgress()
{
HostsScanned = Volatile.Read(ref _latestHostsScanned);
+ HostsUp = Volatile.Read(ref _latestHostsUp);
+ HostsDown = Volatile.Read(ref _latestHostsDown);
}
///
diff --git a/Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs b/Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs
new file mode 100644
index 0000000000..e8c9d80d0a
--- /dev/null
+++ b/Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Data;
+using NETworkManager.Views;
+
+namespace NETworkManager.ViewModels;
+
+///
+/// Formats the count of hosts up (reachable), down (unreachable) or paused (not running) within
+/// a Ping Monitor group, selected via ConverterParameter ("Up", "Down", "Paused" - formats
+/// "{count} {label}" using the label bound as the third value - or "PausedVisibility", which
+/// instead returns a so the paused count can be hidden while zero).
+///
+///
+/// Bound as a with the as the first
+/// value and as the second. The second
+/// value isn't used directly, it only forces re-evaluation whenever a host's
+/// /
+/// changes, since itself only raises change notifications for
+/// item add/remove, not for property changes on its items.
+///
+public sealed class PingMonitorGroupSummaryConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length == 0 || values[0] is not CollectionViewGroup group)
+ return parameter as string == "PausedVisibility" ? Visibility.Collapsed : string.Empty;
+
+ var hosts = group.Items.OfType().Select(host => host.ViewModel).ToList();
+
+ switch (parameter as string)
+ {
+ case "Up":
+ return $"{hosts.Count(host => host.IsRunning && host.IsReachable)} {values[2]}";
+ case "Down":
+ return $"{hosts.Count(host => host.IsRunning && !host.IsReachable)} {values[2]}";
+ case "Paused":
+ return $"{hosts.Count(host => !host.IsRunning)} {values[2]}";
+ case "PausedVisibility":
+ return hosts.Any(host => !host.IsRunning) ? Visibility.Visible : Visibility.Collapsed;
+ default:
+ return string.Empty;
+ }
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 5244f1e1b3..2d96266dfc 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
@@ -136,6 +137,25 @@ public ObservableCollection Hosts
///
public ICollectionView HostsView { get; }
+ ///
+ /// Bumped whenever a host is added/removed, or a host's reachability/running state
+ /// changes. Used only as a change-notification trigger for the per-group up/down summary
+ /// (see ) - the value itself carries no
+ /// meaning.
+ ///
+ public int HostsChangeVersion
+ {
+ get;
+ private set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
#endregion
#region Constructor, load settings
@@ -152,6 +172,7 @@ public PingMonitorHostViewModel()
HostsView = CollectionViewSource.GetDefaultView(Hosts);
HostsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(PingMonitorView.Group)));
HostsView.SortDescriptions.Add(new SortDescription(nameof(PingMonitorView.Group), ListSortDirection.Ascending));
+ Hosts.CollectionChanged += Hosts_CollectionChanged;
InitializeProfileHost();
}
@@ -372,5 +393,33 @@ private void UserHasCanceled()
IsRunning = false;
}
+ ///
+ /// Keeps each host's subscription in
+ /// sync with , and bumps so the
+ /// per-group up/down summary () re-evaluates.
+ ///
+ private void Hosts_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.NewItems != null)
+ foreach (PingMonitorView host in e.NewItems)
+ host.ViewModel.PropertyChanged += HostViewModel_PropertyChanged;
+
+ if (e.OldItems != null)
+ foreach (PingMonitorView host in e.OldItems)
+ host.ViewModel.PropertyChanged -= HostViewModel_PropertyChanged;
+
+ HostsChangeVersion++;
+ }
+
+ ///
+ /// Bumps whenever a host's reachability or running state
+ /// changes, so the per-group up/down summary re-evaluates.
+ ///
+ private void HostViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(PingMonitorViewModel.IsReachable) or nameof(PingMonitorViewModel.IsRunning))
+ HostsChangeVersion++;
+ }
+
#endregion
}
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 7604960eb5..91d7ab52ff 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -200,9 +200,15 @@ private set
field = value;
OnPropertyChanged();
+ OnPropertyChanged(nameof(StatusTimeToolTip));
}
}
+ ///
+ /// Gets the tooltip text for the connectivity icon, e.g. "Last status change: 14:32:05".
+ ///
+ public string StatusTimeToolTip => $"{Strings.StatusChange}: {StatusTime:HH:mm:ss}";
+
///
/// Gets or sets the total number of ping packets transmitted.
///
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index 44f9f26e8e..a46578cf5f 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -49,6 +49,8 @@ public class PortScannerViewModel : ViewModelBase
// (unconditionally, unlike PortScanned), so it's flushed to the bound property on the same
// timer instead of updating it directly from the background thread on every event.
private int _latestPortsScanned;
+ private int _latestPortsOpen;
+ private int _latestPortsClosed;
///
/// Gets or sets the host to scan.
@@ -209,6 +211,38 @@ public int PortsScanned
}
}
+ ///
+ /// Gets or sets the number of ports found to be open so far.
+ ///
+ public int PortsOpen
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
+ ///
+ /// Gets or sets the number of ports found to be closed so far.
+ ///
+ public int PortsClosed
+ {
+ get;
+ set
+ {
+ if (value == field)
+ return;
+
+ field = value;
+ OnPropertyChanged();
+ }
+ }
+
///
/// Gets or sets a value indicating whether the scan is being prepared.
///
@@ -439,7 +473,11 @@ private async Task Start()
PortsToScan = ports.Length * hosts.hosts.Count;
PortsScanned = 0;
+ PortsOpen = 0;
+ PortsClosed = 0;
Volatile.Write(ref _latestPortsScanned, 0);
+ Volatile.Write(ref _latestPortsOpen, 0);
+ Volatile.Write(ref _latestPortsClosed, 0);
PreparingScan = false;
@@ -593,15 +631,24 @@ private void FlushResultsBuffer()
private void ProgressChanged(object sender, ProgressChangedArgs e)
{
Volatile.Write(ref _latestPortsScanned, e.Value);
+
+ if (sender is PortScanner portScanner)
+ {
+ Volatile.Write(ref _latestPortsOpen, portScanner.PortsOpen);
+ Volatile.Write(ref _latestPortsClosed, portScanner.PortsClosed);
+ }
}
///
- /// Pushes the latest buffered progress value into . Always called
- /// on the UI thread - same calling contexts as .
+ /// Pushes the latest buffered progress value into ,
+ /// and . Always called on the UI thread -
+ /// same calling contexts as .
///
private void FlushProgress()
{
PortsScanned = Volatile.Read(ref _latestPortsScanned);
+ PortsOpen = Volatile.Read(ref _latestPortsOpen);
+ PortsClosed = Volatile.Read(ref _latestPortsClosed);
}
private void ScanComplete(object sender, EventArgs e)
diff --git a/Source/NETworkManager/Views/IPScannerView.xaml b/Source/NETworkManager/Views/IPScannerView.xaml
index 0a85dce0ba..d328b7eb8f 100644
--- a/Source/NETworkManager/Views/IPScannerView.xaml
+++ b/Source/NETworkManager/Views/IPScannerView.xaml
@@ -219,6 +219,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -212,11 +213,78 @@
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PingMonitorView.xaml.cs b/Source/NETworkManager/Views/PingMonitorView.xaml.cs
index 004d4e1749..8fdfebfafd 100644
--- a/Source/NETworkManager/Views/PingMonitorView.xaml.cs
+++ b/Source/NETworkManager/Views/PingMonitorView.xaml.cs
@@ -25,6 +25,13 @@ public PingMonitorView(Guid hostId, Action removeHostByGuid, (IPAddress ip
public string Group => _viewModel.Group;
+ ///
+ /// The underlying view model, exposed so can
+ /// observe per-host status (,
+ /// ) for the group up/down summary.
+ ///
+ public PingMonitorViewModel ViewModel => _viewModel;
+
public void Start()
{
_viewModel.Start();
diff --git a/Source/NETworkManager/Views/PortScannerView.xaml b/Source/NETworkManager/Views/PortScannerView.xaml
index 0d8bae5e3f..089b88c7a4 100644
--- a/Source/NETworkManager/Views/PortScannerView.xaml
+++ b/Source/NETworkManager/Views/PortScannerView.xaml
@@ -23,6 +23,7 @@
+
@@ -227,6 +228,34 @@
Style="{DynamicResource StatusMessageTextBlock}" Margin="0,10,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Date: Sat, 15 Aug 2026 23:36:22 +0200
Subject: [PATCH 2/4] Update next-release.md
---
Website/docs/changelog/next-release.md | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/Website/docs/changelog/next-release.md b/Website/docs/changelog/next-release.md
index e132f7f97c..beeb7c68ab 100644
--- a/Website/docs/changelog/next-release.md
+++ b/Website/docs/changelog/next-release.md
@@ -35,29 +35,30 @@ Release date: **xx.xx.2026**
**IP Scanner**
-- Added a live count of hosts up/down next to the **Result** header. Stays visible after the scan finishes. [#3565](https://github.com/BornToBeRoot/NETworkManager/issues/3565)
+- Added a live count of hosts up/down next to the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572)
- Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. Thanks to [@dearmb](https://github.com/dearmb) [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
- Added `135` (RPC) and `9100` (raw printing) to the default **Ports** list used to detect if a host is reachable. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- Reduced the default **Max. concurrent port threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- Reduced the default **Max. concurrent host threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned network. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
-**Ping Monitor**
-
-- Added a live count of hosts up/down (and paused, if any) per group, next to the group's close button. [#3565](https://github.com/BornToBeRoot/NETworkManager/issues/3565)
-- Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
-- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
-
**Port Scanner**
- Added a port status icon column to the results, matching the port icon used in the IP Scanner's extended port info, to indicate at a glance whether a port is open (green) or closed (red). [#3558](https://github.com/BornToBeRoot/NETworkManager/issues/3558)
-- Added a live count of ports open/closed next to the **Result** header. Stays visible after the scan finishes. [#3565](https://github.com/BornToBeRoot/NETworkManager/issues/3565)
+- Added a live count of ports open/closed next to the **Result** header. Stays visible after the scan finishes. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572)
- Host and Ports input fields now accept newline-separated entries (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
- Reduced the default **Max. concurrent host threads** from `5` to `4`. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- Reduced the default **Max. concurrent port threads** from `256` to `64`, a more conservative default that puts less simultaneous load on the scanned host. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
- Added a new **Well-known ports** (`1-1024`) default port profile. [#3564](https://github.com/BornToBeRoot/NETworkManager/pull/3564)
+**Ping Monitor**
+
+- Added a live count of hosts up/down (and paused, if any) per group, next to the group's close button. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572)
+- Reworked each monitored host's card: the collapsed quick info now shows labeled values (`Received: X · Lost: Y · Packet loss: Z%`) instead of unlabeled numbers, the expanded view shows only the latency chart (with more room, since Hostname/IP address are already visible in the header) and the last status change time moved to a tooltip on the connectivity icon. The **Status change** field was also renamed to **Last status change**. [#3572](https://github.com/BornToBeRoot/NETworkManager/pull/3572)
+- Host input now accepts newline-separated hosts (one per line, e.g. a column pasted from Excel), converted automatically to the semicolon-separated form. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
+- Host input now supports shorthand IPv4 ranges like `192.168.0.1-100`, in addition to the existing `192.168.0.0-192.168.0.100` and `192.168.[0-100].1` range formats. [#3568](https://github.com/BornToBeRoot/NETworkManager/pull/3568)
+
## Bug Fixes
**Dashboard**
From 8a2cc594427c9790eac4eda8c156d032602399bd Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sat, 15 Aug 2026 23:54:52 +0200
Subject: [PATCH 3/4] Fix: Copilot feedback
---
.../ViewModels/IPScannerViewModel.cs | 17 +++++++++++------
.../ViewModels/PingMonitorHostViewModel.cs | 14 ++++++++++++--
.../ViewModels/PortScannerViewModel.cs | 17 +++++++++++------
3 files changed, 34 insertions(+), 14 deletions(-)
diff --git a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index d39ff1c35f..3d00a2ee17 100644
--- a/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
@@ -490,6 +490,17 @@ private async Task Start()
Results.Clear();
+ // Reset before hostname resolution too (not just after), so a cancellation during
+ // resolution can't flush the previous scan's stale totals - HostsToScan = 0 also hides
+ // the up/down summary until the new scan's host count is known.
+ HostsToScan = 0;
+ HostsScanned = 0;
+ HostsUp = 0;
+ HostsDown = 0;
+ Volatile.Write(ref _latestHostsScanned, 0);
+ Volatile.Write(ref _latestHostsUp, 0);
+ Volatile.Write(ref _latestHostsDown, 0);
+
DragablzTabItem.SetTabHeader(_tabId, Host);
_cancellationTokenSource?.Dispose();
@@ -518,12 +529,6 @@ private async Task Start()
}
HostsToScan = hosts.hosts.Count;
- HostsScanned = 0;
- HostsUp = 0;
- HostsDown = 0;
- Volatile.Write(ref _latestHostsScanned, 0);
- Volatile.Write(ref _latestHostsUp, 0);
- Volatile.Write(ref _latestHostsDown, 0);
PreparingScan = false;
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 2d96266dfc..5ace84ef0d 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -18,6 +18,7 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
+using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -415,10 +416,19 @@ private void Hosts_CollectionChanged(object sender, NotifyCollectionChangedEvent
/// Bumps whenever a host's reachability or running state
/// changes, so the per-group up/down summary re-evaluates.
///
+ ///
+ /// /
+ /// are updated directly from each host's background ping loop (not marshaled to the UI
+ /// thread), so this handler can be invoked concurrently for multiple hosts. The increment
+ /// is marshaled to the UI thread so it's never lost - and never silently suppressed by the
+ /// property setter's equality check - to a concurrent write from another host.
+ ///
private void HostViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
- if (e.PropertyName is nameof(PingMonitorViewModel.IsReachable) or nameof(PingMonitorViewModel.IsRunning))
- HostsChangeVersion++;
+ if (e.PropertyName is not (nameof(PingMonitorViewModel.IsReachable) or nameof(PingMonitorViewModel.IsRunning)))
+ return;
+
+ Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.Normal, () => HostsChangeVersion++);
}
#endregion
diff --git a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
index a46578cf5f..926037f288 100644
--- a/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PortScannerViewModel.cs
@@ -441,6 +441,17 @@ private async Task Start()
Results.Clear();
+ // Reset before hostname resolution too (not just after), so a cancellation during
+ // resolution can't flush the previous scan's stale totals - PortsToScan = 0 also hides
+ // the open/closed summary until the new scan's port count is known.
+ PortsToScan = 0;
+ PortsScanned = 0;
+ PortsOpen = 0;
+ PortsClosed = 0;
+ Volatile.Write(ref _latestPortsScanned, 0);
+ Volatile.Write(ref _latestPortsOpen, 0);
+ Volatile.Write(ref _latestPortsClosed, 0);
+
DragablzTabItem.SetTabHeader(_tabId, Host);
_cancellationTokenSource?.Dispose();
@@ -472,12 +483,6 @@ private async Task Start()
var ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports);
PortsToScan = ports.Length * hosts.hosts.Count;
- PortsScanned = 0;
- PortsOpen = 0;
- PortsClosed = 0;
- Volatile.Write(ref _latestPortsScanned, 0);
- Volatile.Write(ref _latestPortsOpen, 0);
- Volatile.Write(ref _latestPortsClosed, 0);
PreparingScan = false;
From ed2aaa4aabf64227fffb33956a3f8e4f0c2a88dc Mon Sep 17 00:00:00 2001
From: BornToBeRoot <16019165+BornToBeRoot@users.noreply.github.com>
Date: Sun, 16 Aug 2026 00:42:52 +0200
Subject: [PATCH 4/4] Fix: Update docs, add context menu, code review fixes
---
.../PingMonitorGroupSummaryConverter.cs | 40 +++++++++++--------
.../Network/IPScanner.cs | 12 +++---
.../Network/IPingMonitorHostStatus.cs | 19 +++++++++
.../Network/PortScanner.cs | 12 +++---
.../InterlockedCounter.cs | 25 ++++++++++++
.../ViewModels/PingMonitorHostViewModel.cs | 1 +
.../ViewModels/PingMonitorViewModel.cs | 2 +-
.../Views/PingMonitorHostView.xaml | 17 +++-----
.../NETworkManager/Views/PingMonitorView.xaml | 33 +++++++++++++++
.../Views/PingMonitorView.xaml.cs | 14 +++++--
Website/docs/application/ping-monitor.md | 9 ++---
11 files changed, 134 insertions(+), 50 deletions(-)
rename Source/{NETworkManager/ViewModels => NETworkManager.Converters}/PingMonitorGroupSummaryConverter.cs (57%)
create mode 100644 Source/NETworkManager.Models/Network/IPingMonitorHostStatus.cs
create mode 100644 Source/NETworkManager.Utilities/InterlockedCounter.cs
diff --git a/Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
similarity index 57%
rename from Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs
rename to Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
index e8c9d80d0a..97161a0c4d 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorGroupSummaryConverter.cs
+++ b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
@@ -3,9 +3,9 @@
using System.Linq;
using System.Windows;
using System.Windows.Data;
-using NETworkManager.Views;
+using NETworkManager.Models.Network;
-namespace NETworkManager.ViewModels;
+namespace NETworkManager.Converters;
///
/// Formats the count of hosts up (reachable), down (unreachable) or paused (not running) within
@@ -15,9 +15,8 @@ namespace NETworkManager.ViewModels;
///
///
/// Bound as a with the as the first
-/// value and as the second. The second
-/// value isn't used directly, it only forces re-evaluation whenever a host's
-/// /
+/// value and a per-group change-notification trigger as the second. The second value isn't used
+/// directly, it only forces re-evaluation whenever a host's
/// changes, since itself only raises change notifications for
/// item add/remove, not for property changes on its items.
///
@@ -28,21 +27,28 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur
if (values.Length == 0 || values[0] is not CollectionViewGroup group)
return parameter as string == "PausedVisibility" ? Visibility.Collapsed : string.Empty;
- var hosts = group.Items.OfType().Select(host => host.ViewModel).ToList();
+ var up = 0;
+ var down = 0;
+ var paused = 0;
- switch (parameter as string)
+ foreach (var host in group.Items.OfType())
{
- case "Up":
- return $"{hosts.Count(host => host.IsRunning && host.IsReachable)} {values[2]}";
- case "Down":
- return $"{hosts.Count(host => host.IsRunning && !host.IsReachable)} {values[2]}";
- case "Paused":
- return $"{hosts.Count(host => !host.IsRunning)} {values[2]}";
- case "PausedVisibility":
- return hosts.Any(host => !host.IsRunning) ? Visibility.Visible : Visibility.Collapsed;
- default:
- return string.Empty;
+ if (!host.IsRunning)
+ paused++;
+ else if (host.IsReachable)
+ up++;
+ else
+ down++;
}
+
+ return parameter as string switch
+ {
+ "Up" => $"{up} {values[2]}",
+ "Down" => $"{down} {values[2]}",
+ "Paused" => $"{paused} {values[2]}",
+ "PausedVisibility" => paused > 0 ? Visibility.Visible : Visibility.Collapsed,
+ _ => string.Empty
+ };
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
diff --git a/Source/NETworkManager.Models/Network/IPScanner.cs b/Source/NETworkManager.Models/Network/IPScanner.cs
index 9662e85211..986ba87a82 100644
--- a/Source/NETworkManager.Models/Network/IPScanner.cs
+++ b/Source/NETworkManager.Models/Network/IPScanner.cs
@@ -21,20 +21,20 @@ public sealed class IPScanner(IPScannerOptions options)
#region Variables
private int _progressValue;
- private int _hostsUp;
- private int _hostsDown;
+ private readonly InterlockedCounter _hostsUp = new();
+ private readonly InterlockedCounter _hostsDown = new();
///
/// Gets the number of hosts found to be reachable so far. Thread-safe; may be read from
/// any thread while the scan is running.
///
- public int HostsUp => Volatile.Read(ref _hostsUp);
+ public int HostsUp => _hostsUp.Value;
///
/// Gets the number of hosts found to be unreachable so far. Thread-safe; may be read from
/// any thread while the scan is running.
///
- public int HostsDown => Volatile.Read(ref _hostsDown);
+ public int HostsDown => _hostsDown.Value;
#endregion
@@ -146,9 +146,9 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
// Count reachable/unreachable hosts unconditionally, since ShowAllResults
// (below) may prevent unreachable hosts from ever reaching HostScanned
if (isReachable)
- Interlocked.Increment(ref _hostsUp);
+ _hostsUp.Increment();
else
- Interlocked.Increment(ref _hostsDown);
+ _hostsDown.Increment();
// DNS & ARP
if (isReachable || options.ShowAllResults)
diff --git a/Source/NETworkManager.Models/Network/IPingMonitorHostStatus.cs b/Source/NETworkManager.Models/Network/IPingMonitorHostStatus.cs
new file mode 100644
index 0000000000..0ec902e2f0
--- /dev/null
+++ b/Source/NETworkManager.Models/Network/IPingMonitorHostStatus.cs
@@ -0,0 +1,19 @@
+namespace NETworkManager.Models.Network;
+
+///
+/// Minimal reachability/running status of a Ping Monitor host, exposed so lower-level
+/// projects (e.g. converters) can read a host's status without depending on the concrete
+/// View/ViewModel types that implement it.
+///
+public interface IPingMonitorHostStatus
+{
+ ///
+ /// Gets a value indicating whether the host is reachable (responds to ping).
+ ///
+ bool IsReachable { get; }
+
+ ///
+ /// Gets a value indicating whether the ping monitoring is currently running.
+ ///
+ bool IsRunning { get; }
+}
diff --git a/Source/NETworkManager.Models/Network/PortScanner.cs b/Source/NETworkManager.Models/Network/PortScanner.cs
index f9ec4d4159..64b87762bf 100644
--- a/Source/NETworkManager.Models/Network/PortScanner.cs
+++ b/Source/NETworkManager.Models/Network/PortScanner.cs
@@ -22,8 +22,8 @@ public PortScanner(PortScannerOptions options)
#region Variables
private int _progressValue;
- private int _portsOpen;
- private int _portsClosed;
+ private readonly InterlockedCounter _portsOpen = new();
+ private readonly InterlockedCounter _portsClosed = new();
private readonly PortScannerOptions _options;
@@ -31,13 +31,13 @@ public PortScanner(PortScannerOptions options)
/// Gets the number of ports found to be open so far. Thread-safe; may be read from any
/// thread while the scan is running.
///
- public int PortsOpen => Volatile.Read(ref _portsOpen);
+ public int PortsOpen => _portsOpen.Value;
///
/// Gets the number of ports found to be closed (or timed out) so far. Thread-safe; may be
/// read from any thread while the scan is running.
///
- public int PortsClosed => Volatile.Read(ref _portsClosed);
+ public int PortsClosed => _portsClosed.Value;
#endregion
@@ -119,9 +119,9 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
// Count open/closed ports unconditionally, since ShowAllResults (below)
// may prevent closed ports from ever reaching PortScanned
if (portState == PortState.Open)
- Interlocked.Increment(ref _portsOpen);
+ _portsOpen.Increment();
else
- Interlocked.Increment(ref _portsClosed);
+ _portsClosed.Increment();
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
diff --git a/Source/NETworkManager.Utilities/InterlockedCounter.cs b/Source/NETworkManager.Utilities/InterlockedCounter.cs
new file mode 100644
index 0000000000..08ff0b5e94
--- /dev/null
+++ b/Source/NETworkManager.Utilities/InterlockedCounter.cs
@@ -0,0 +1,25 @@
+using System.Threading;
+
+namespace NETworkManager.Utilities;
+
+///
+/// A simple counter that can be incremented from any thread and read from any other thread
+/// without locking.
+///
+public sealed class InterlockedCounter
+{
+ private int _value;
+
+ ///
+ /// Gets the current value.
+ ///
+ public int Value => Volatile.Read(ref _value);
+
+ ///
+ /// Increments the value by one.
+ ///
+ public void Increment()
+ {
+ Interlocked.Increment(ref _value);
+ }
+}
diff --git a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 5ace84ef0d..f50cf2cafe 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
@@ -1,4 +1,5 @@
using MahApps.Metro.Controls;
+using NETworkManager.Converters;
using NETworkManager.Localization.Resources;
using NETworkManager.Models;
using NETworkManager.Models.Network;
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 91d7ab52ff..264808f225 100644
--- a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
+++ b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
@@ -32,7 +32,7 @@ namespace NETworkManager.ViewModels;
///
/// ViewModel for the Ping Monitor feature, representing a single monitored host.
///
-public class PingMonitorViewModel : ViewModelBase
+public class PingMonitorViewModel : ViewModelBase, IPingMonitorHostStatus
{
#region Contructor, load settings
diff --git a/Source/NETworkManager/Views/PingMonitorHostView.xaml b/Source/NETworkManager/Views/PingMonitorHostView.xaml
index 1fe837c704..46ddaa631e 100644
--- a/Source/NETworkManager/Views/PingMonitorHostView.xaml
+++ b/Source/NETworkManager/Views/PingMonitorHostView.xaml
@@ -25,7 +25,7 @@
-
+
@@ -253,17 +253,10 @@
-
-
-
-
-
-
-
-
+
+
diff --git a/Source/NETworkManager/Views/PingMonitorView.xaml.cs b/Source/NETworkManager/Views/PingMonitorView.xaml.cs
index 8fdfebfafd..aa0c314e80 100644
--- a/Source/NETworkManager/Views/PingMonitorView.xaml.cs
+++ b/Source/NETworkManager/Views/PingMonitorView.xaml.cs
@@ -1,11 +1,12 @@
using System;
using System.Net;
using System.Windows.Controls;
+using NETworkManager.Models.Network;
using NETworkManager.ViewModels;
namespace NETworkManager.Views;
-public partial class PingMonitorView
+public partial class PingMonitorView : IPingMonitorHostStatus
{
private readonly PingMonitorViewModel _viewModel;
@@ -27,11 +28,18 @@ public PingMonitorView(Guid hostId, Action removeHostByGuid, (IPAddress ip
///
/// The underlying view model, exposed so can
- /// observe per-host status (,
- /// ) for the group up/down summary.
+ /// subscribe to for the group up/down
+ /// summary. Prefer / (via
+ /// ) to just read the current status.
///
public PingMonitorViewModel ViewModel => _viewModel;
+ ///
+ public bool IsReachable => _viewModel.IsReachable;
+
+ ///
+ public bool IsRunning => _viewModel.IsRunning;
+
public void Start()
{
_viewModel.Start();
diff --git a/Website/docs/application/ping-monitor.md b/Website/docs/application/ping-monitor.md
index 08f2cc4a4b..6391f00de4 100644
--- a/Website/docs/application/ping-monitor.md
+++ b/Website/docs/application/ping-monitor.md
@@ -56,11 +56,10 @@ When you zoom or pan, the chart leaves live mode and stops scrolling. A **Live**
Right-click a monitored host (anywhere except the chart) to open the context menu:
-| Action | Description |
-|--------|-------------|
-| **Export...** | Exports the results of the host to a file |
-
-Right-clicking an individual field (hostname, IP address, ...) instead lets you **Copy** its value to the clipboard.
+| Action | Description |
+| ------------- | ------------------------------------------------- |
+| **Copy** | Copies the selected information to the clipboard |
+| **Export...** | Exports the results of the host to a file |
### Notifications