diff --git a/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
new file mode 100644
index 0000000000..97161a0c4d
--- /dev/null
+++ b/Source/NETworkManager.Converters/PingMonitorGroupSummaryConverter.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Data;
+using NETworkManager.Models.Network;
+
+namespace NETworkManager.Converters;
+
+///
+/// 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 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.
+///
+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 up = 0;
+ var down = 0;
+ var paused = 0;
+
+ foreach (var host in group.Items.OfType())
+ {
+ 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)
+ {
+ throw new NotImplementedException();
+ }
+}
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..986ba87a82 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 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 => _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 => _hostsDown.Value;
#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)
+ _hostsUp.Increment();
+ else
+ _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 fff1c51681..64b87762bf 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 readonly InterlockedCounter _portsOpen = new();
+ private readonly InterlockedCounter _portsClosed = new();
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 => _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 => _portsClosed.Value;
+
#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)
+ _portsOpen.Increment();
+ else
+ _portsClosed.Increment();
+
if (_options.ShowAllResults || portState == PortState.Open)
OnPortScanned(new PortScannerPortScannedArgs(
new PortScannerPortInfo(host.ipAddress, hostname, port,
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/IPScannerViewModel.cs b/Source/NETworkManager/ViewModels/IPScannerViewModel.cs
index 388ceb5a64..3d00a2ee17 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.
///
@@ -456,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();
@@ -484,8 +529,6 @@ private async Task Start()
}
HostsToScan = hosts.hosts.Count;
- HostsScanned = 0;
- Volatile.Write(ref _latestHostsScanned, 0);
PreparingScan = false;
@@ -768,20 +811,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/PingMonitorHostViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorHostViewModel.cs
index 5244f1e1b3..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;
@@ -9,6 +10,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net;
@@ -17,6 +19,7 @@
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
+using System.Windows.Threading;
namespace NETworkManager.ViewModels;
@@ -136,6 +139,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 +174,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 +395,42 @@ 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.
+ ///
+ ///
+ /// /
+ /// 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 not (nameof(PingMonitorViewModel.IsReachable) or nameof(PingMonitorViewModel.IsRunning)))
+ return;
+
+ Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.Normal, () => HostsChangeVersion++);
+ }
+
#endregion
}
diff --git a/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs b/Source/NETworkManager/ViewModels/PingMonitorViewModel.cs
index 7604960eb5..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
@@ -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..926037f288 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.
///
@@ -407,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();
@@ -438,8 +483,6 @@ private async Task Start()
var ports = await PortRangeHelper.ConvertPortRangeToIntArrayAsync(Ports);
PortsToScan = ports.Length * hosts.hosts.Count;
- PortsScanned = 0;
- Volatile.Write(ref _latestPortsScanned, 0);
PreparingScan = false;
@@ -593,15 +636,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,71 @@
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/NETworkManager/Views/PingMonitorView.xaml.cs b/Source/NETworkManager/Views/PingMonitorView.xaml.cs
index 004d4e1749..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;
@@ -25,6 +26,20 @@ public PingMonitorView(Guid hostId, Action removeHostByGuid, (IPAddress ip
public string Group => _viewModel.Group;
+ ///
+ /// The underlying view model, exposed so can
+ /// 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/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" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+