Skip to content

Commit 6c643f6

Browse files
authored
Feature: Add count up/down, open/closed and re-work ping monitor view (#3572)
* Feature: Add count up/down, open/closed and re-work ping monitor view * Update next-release.md * Fix: Copilot feedback * Fix: Update docs, add context menu, code review fixes
1 parent b344742 commit 6c643f6

18 files changed

Lines changed: 611 additions & 138 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System;
2+
using System.Globalization;
3+
using System.Linq;
4+
using System.Windows;
5+
using System.Windows.Data;
6+
using NETworkManager.Models.Network;
7+
8+
namespace NETworkManager.Converters;
9+
10+
/// <summary>
11+
/// Formats the count of hosts up (reachable), down (unreachable) or paused (not running) within
12+
/// a Ping Monitor group, selected via <c>ConverterParameter</c> ("Up", "Down", "Paused" - formats
13+
/// "{count} {label}" using the label bound as the third value - or "PausedVisibility", which
14+
/// instead returns a <see cref="Visibility"/> so the paused count can be hidden while zero).
15+
/// </summary>
16+
/// <remarks>
17+
/// Bound as a <see cref="MultiBinding"/> with the <see cref="CollectionViewGroup"/> as the first
18+
/// value and a per-group change-notification trigger as the second. The second value isn't used
19+
/// directly, it only forces re-evaluation whenever a host's <see cref="IPingMonitorHostStatus"/>
20+
/// changes, since <see cref="CollectionViewGroup"/> itself only raises change notifications for
21+
/// item add/remove, not for property changes on its items.
22+
/// </remarks>
23+
public sealed class PingMonitorGroupSummaryConverter : IMultiValueConverter
24+
{
25+
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
26+
{
27+
if (values.Length == 0 || values[0] is not CollectionViewGroup group)
28+
return parameter as string == "PausedVisibility" ? Visibility.Collapsed : string.Empty;
29+
30+
var up = 0;
31+
var down = 0;
32+
var paused = 0;
33+
34+
foreach (var host in group.Items.OfType<IPingMonitorHostStatus>())
35+
{
36+
if (!host.IsRunning)
37+
paused++;
38+
else if (host.IsReachable)
39+
up++;
40+
else
41+
down++;
42+
}
43+
44+
return parameter as string switch
45+
{
46+
"Up" => $"{up} {values[2]}",
47+
"Down" => $"{down} {values[2]}",
48+
"Paused" => $"{paused} {values[2]}",
49+
"PausedVisibility" => paused > 0 ? Visibility.Visible : Visibility.Collapsed,
50+
_ => string.Empty
51+
};
52+
}
53+
54+
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
55+
{
56+
throw new NotImplementedException();
57+
}
58+
}

Source/NETworkManager.Localization/Resources/Strings.Designer.cs

Lines changed: 31 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Source/NETworkManager.Localization/Resources/Strings.resx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@
228228
<data name="DontFragment" xml:space="preserve">
229229
<value>Don't fragment</value>
230230
</data>
231+
<data name="Down" xml:space="preserve">
232+
<value>Down</value>
233+
</data>
231234
<data name="EditCredentials" xml:space="preserve">
232235
<value>Edit credentials</value>
233236
</data>
@@ -1252,6 +1255,9 @@ Profile files are not affected!</value>
12521255
<data name="UntrayBringWindowToForeground" xml:space="preserve">
12531256
<value>Untray / Bring window to foreground</value>
12541257
</data>
1258+
<data name="Up" xml:space="preserve">
1259+
<value>Up</value>
1260+
</data>
12551261
<data name="URL" xml:space="preserve">
12561262
<value>URL</value>
12571263
</data>
@@ -2442,6 +2448,9 @@ is disabled!</value>
24422448
<data name="Pause" xml:space="preserve">
24432449
<value>Pause</value>
24442450
</data>
2451+
<data name="Paused" xml:space="preserve">
2452+
<value>Paused</value>
2453+
</data>
24452454
<data name="Resume" xml:space="preserve">
24462455
<value>Resume</value>
24472456
</data>
@@ -2836,7 +2845,7 @@ is disabled!</value>
28362845
<value>Received</value>
28372846
</data>
28382847
<data name="StatusChange" xml:space="preserve">
2839-
<value>Status change</value>
2848+
<value>Last status change</value>
28402849
</data>
28412850
<data name="UpdateAvailable" xml:space="preserve">
28422851
<value>Update available!</value>

Source/NETworkManager.Models/Network/IPScanner.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ public sealed class IPScanner(IPScannerOptions options)
2121
#region Variables
2222

2323
private int _progressValue;
24+
private readonly InterlockedCounter _hostsUp = new();
25+
private readonly InterlockedCounter _hostsDown = new();
26+
27+
/// <summary>
28+
/// Gets the number of hosts found to be reachable so far. Thread-safe; may be read from
29+
/// any thread while the scan is running.
30+
/// </summary>
31+
public int HostsUp => _hostsUp.Value;
32+
33+
/// <summary>
34+
/// Gets the number of hosts found to be unreachable so far. Thread-safe; may be read from
35+
/// any thread while the scan is running.
36+
/// </summary>
37+
public int HostsDown => _hostsDown.Value;
2438

2539
#endregion
2640

@@ -129,6 +143,13 @@ await Parallel.ForEachAsync(hosts, hostParallelOptions, async (host, ct) =>
129143
isAnyPortOpen || // Any port is open
130144
netBIOSInfo.IsReachable; // NetBIOS response
131145

146+
// Count reachable/unreachable hosts unconditionally, since ShowAllResults
147+
// (below) may prevent unreachable hosts from ever reaching HostScanned
148+
if (isReachable)
149+
_hostsUp.Increment();
150+
else
151+
_hostsDown.Increment();
152+
132153
// DNS & ARP
133154
if (isReachable || options.ShowAllResults)
134155
{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace NETworkManager.Models.Network;
2+
3+
/// <summary>
4+
/// Minimal reachability/running status of a Ping Monitor host, exposed so lower-level
5+
/// projects (e.g. converters) can read a host's status without depending on the concrete
6+
/// View/ViewModel types that implement it.
7+
/// </summary>
8+
public interface IPingMonitorHostStatus
9+
{
10+
/// <summary>
11+
/// Gets a value indicating whether the host is reachable (responds to ping).
12+
/// </summary>
13+
bool IsReachable { get; }
14+
15+
/// <summary>
16+
/// Gets a value indicating whether the ping monitoring is currently running.
17+
/// </summary>
18+
bool IsRunning { get; }
19+
}

Source/NETworkManager.Models/Network/PortScanner.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,23 @@ public PortScanner(PortScannerOptions options)
2222
#region Variables
2323

2424
private int _progressValue;
25+
private readonly InterlockedCounter _portsOpen = new();
26+
private readonly InterlockedCounter _portsClosed = new();
2527

2628
private readonly PortScannerOptions _options;
2729

30+
/// <summary>
31+
/// Gets the number of ports found to be open so far. Thread-safe; may be read from any
32+
/// thread while the scan is running.
33+
/// </summary>
34+
public int PortsOpen => _portsOpen.Value;
35+
36+
/// <summary>
37+
/// Gets the number of ports found to be closed (or timed out) so far. Thread-safe; may be
38+
/// read from any thread while the scan is running.
39+
/// </summary>
40+
public int PortsClosed => _portsClosed.Value;
41+
2842
#endregion
2943

3044
#region Events
@@ -102,6 +116,13 @@ await Parallel.ForEachAsync(ports, portParallelOptions, async (port, portCt) =>
102116
var portState = await PortProbe.ProbeAsync(host.ipAddress, port, _options.Timeout, portCt)
103117
.ConfigureAwait(false);
104118

119+
// Count open/closed ports unconditionally, since ShowAllResults (below)
120+
// may prevent closed ports from ever reaching PortScanned
121+
if (portState == PortState.Open)
122+
_portsOpen.Increment();
123+
else
124+
_portsClosed.Increment();
125+
105126
if (_options.ShowAllResults || portState == PortState.Open)
106127
OnPortScanned(new PortScannerPortScannedArgs(
107128
new PortScannerPortInfo(host.ipAddress, hostname, port,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Threading;
2+
3+
namespace NETworkManager.Utilities;
4+
5+
/// <summary>
6+
/// A simple counter that can be incremented from any thread and read from any other thread
7+
/// without locking.
8+
/// </summary>
9+
public sealed class InterlockedCounter
10+
{
11+
private int _value;
12+
13+
/// <summary>
14+
/// Gets the current value.
15+
/// </summary>
16+
public int Value => Volatile.Read(ref _value);
17+
18+
/// <summary>
19+
/// Increments the value by one.
20+
/// </summary>
21+
public void Increment()
22+
{
23+
Interlocked.Increment(ref _value);
24+
}
25+
}

Source/NETworkManager/ViewModels/IPScannerViewModel.cs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ public class IPScannerViewModel : ViewModelBase, IProfileManagerMinimal
5454
// (unconditionally, unlike HostScanned), so it's flushed to the bound property on the same
5555
// timer instead of updating it directly from the background thread on every event.
5656
private int _latestHostsScanned;
57+
private int _latestHostsUp;
58+
private int _latestHostsDown;
5759

5860
/// <summary>
5961
/// Gets or sets the host or IP range to scan.
@@ -209,6 +211,38 @@ public int HostsScanned
209211
}
210212
}
211213

214+
/// <summary>
215+
/// Gets or sets the number of hosts found to be reachable so far.
216+
/// </summary>
217+
public int HostsUp
218+
{
219+
get;
220+
set
221+
{
222+
if (value == field)
223+
return;
224+
225+
field = value;
226+
OnPropertyChanged();
227+
}
228+
}
229+
230+
/// <summary>
231+
/// Gets or sets the number of hosts found to be unreachable so far.
232+
/// </summary>
233+
public int HostsDown
234+
{
235+
get;
236+
set
237+
{
238+
if (value == field)
239+
return;
240+
241+
field = value;
242+
OnPropertyChanged();
243+
}
244+
}
245+
212246
/// <summary>
213247
/// Gets or sets a value indicating whether the scan is being prepared.
214248
/// </summary>
@@ -456,6 +490,17 @@ private async Task Start()
456490

457491
Results.Clear();
458492

493+
// Reset before hostname resolution too (not just after), so a cancellation during
494+
// resolution can't flush the previous scan's stale totals - HostsToScan = 0 also hides
495+
// the up/down summary until the new scan's host count is known.
496+
HostsToScan = 0;
497+
HostsScanned = 0;
498+
HostsUp = 0;
499+
HostsDown = 0;
500+
Volatile.Write(ref _latestHostsScanned, 0);
501+
Volatile.Write(ref _latestHostsUp, 0);
502+
Volatile.Write(ref _latestHostsDown, 0);
503+
459504
DragablzTabItem.SetTabHeader(_tabId, Host);
460505

461506
_cancellationTokenSource?.Dispose();
@@ -484,8 +529,6 @@ private async Task Start()
484529
}
485530

486531
HostsToScan = hosts.hosts.Count;
487-
HostsScanned = 0;
488-
Volatile.Write(ref _latestHostsScanned, 0);
489532

490533
PreparingScan = false;
491534

@@ -768,20 +811,29 @@ private void FlushResultsBuffer()
768811
/// pick up on the next timer tick, instead of updating the bound property directly from a
769812
/// background thread on every single host.
770813
/// </summary>
771-
/// <param name="sender">The source of the event.</param>
814+
/// <param name="sender">The <see cref="IPScanner"/> instance raising the event.</param>
772815
/// <param name="e">The <see cref="ProgressChangedArgs"/> instance containing the event data.</param>
773816
private void ProgressChanged(object sender, ProgressChangedArgs e)
774817
{
775818
Volatile.Write(ref _latestHostsScanned, e.Value);
819+
820+
if (sender is IPScanner ipScanner)
821+
{
822+
Volatile.Write(ref _latestHostsUp, ipScanner.HostsUp);
823+
Volatile.Write(ref _latestHostsDown, ipScanner.HostsDown);
824+
}
776825
}
777826

778827
/// <summary>
779-
/// Pushes the latest buffered progress value into <see cref="HostsScanned"/>. Always called
780-
/// on the UI thread - same calling contexts as <see cref="FlushResultsBuffer"/>.
828+
/// Pushes the latest buffered progress value into <see cref="HostsScanned"/>,
829+
/// <see cref="HostsUp"/> and <see cref="HostsDown"/>. Always called on the UI thread - same
830+
/// calling contexts as <see cref="FlushResultsBuffer"/>.
781831
/// </summary>
782832
private void FlushProgress()
783833
{
784834
HostsScanned = Volatile.Read(ref _latestHostsScanned);
835+
HostsUp = Volatile.Read(ref _latestHostsUp);
836+
HostsDown = Volatile.Read(ref _latestHostsDown);
785837
}
786838

787839
/// <summary>

0 commit comments

Comments
 (0)