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 change Update 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