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 change Update 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" /> + + + + + + + + + + + + + + + + + + +