diff --git a/Source/NETworkManager.Converters/BandwidthBytesToSpeedConverter.cs b/Source/NETworkManager.Converters/BandwidthBytesToSpeedConverter.cs index 6288cc1d1d..1f6d2a02cf 100644 --- a/Source/NETworkManager.Converters/BandwidthBytesToSpeedConverter.cs +++ b/Source/NETworkManager.Converters/BandwidthBytesToSpeedConverter.cs @@ -1,21 +1,31 @@ -using System; +using System; using System.Globalization; using System.Windows.Data; using NETworkManager.Utilities; namespace NETworkManager.Converters; +/// +/// Converts a byte-per-second value to a human-readable speed string. +/// Pass the converter parameter "bytes" to format as byte/s (e.g. "1.54 MB/s"); +/// any other value (default) formats as bit/s (e.g. "12.3 Mbit/s") to match the chart axis. +/// public sealed class BandwidthBytesToSpeedConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return value != null - ? $"{FileSizeConverter.GetBytesReadable((long)value * 8)}it/s ({FileSizeConverter.GetBytesReadable((long)value)}/s)" - : "-/-"; + if (value == null) + return "-/-"; + + var bytesPerSecond = (long)value; + + return parameter as string == "bytes" + ? $"{FileSizeConverter.GetBytesReadable(bytesPerSecond)}/s" + : $"{FileSizeConverter.GetBytesReadable(bytesPerSecond * 8)}it/s"; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } -} \ No newline at end of file +} diff --git a/Source/NETworkManager.Converters/BandwidthBytesWithSizeConverter.cs b/Source/NETworkManager.Converters/BandwidthBytesWithSizeConverter.cs deleted file mode 100644 index 114095183c..0000000000 --- a/Source/NETworkManager.Converters/BandwidthBytesWithSizeConverter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using NETworkManager.Utilities; - -namespace NETworkManager.Converters; - -public sealed class BandwidthBytesWithSizeConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return value != null ? $"{(long)value} ({FileSizeConverter.GetBytesReadable((long)value)})" : "-/-"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Converters/BytesToExactStringConverter.cs b/Source/NETworkManager.Converters/BytesToExactStringConverter.cs new file mode 100644 index 0000000000..062dad8cb0 --- /dev/null +++ b/Source/NETworkManager.Converters/BytesToExactStringConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using NETworkManager.Localization.Resources; + +namespace NETworkManager.Converters; + +/// +/// Converts a byte count to its exact value with thousands separators and a "Bytes" unit +/// (e.g. "6,783,176,192 Bytes"). Intended for tooltips that complement a human-readable size. +/// +public sealed class BytesToExactStringConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value != null ? string.Format(culture, "{0:N0} {1}", (long)value, Strings.Bytes) : "-/-"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Source/NETworkManager.Converters/LvlChartsBandwidthValueConverter.cs b/Source/NETworkManager.Converters/LvlChartsBandwidthValueConverter.cs deleted file mode 100644 index a2a9f6b09d..0000000000 --- a/Source/NETworkManager.Converters/LvlChartsBandwidthValueConverter.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using NETworkManager.Utilities; - -namespace NETworkManager.Converters; - -public sealed class LvlChartsBandwidthValueConverter : IValueConverter -{ - /// - /// - /// ChartPoint.Instance (object) - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is LvlChartsDefaultInfo info) - return $"{FileSizeConverter.GetBytesReadable((long)info.Value * 8)}it/s"; - - return "-/-"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Source/NETworkManager.Documentation/DocumentationManager.cs b/Source/NETworkManager.Documentation/DocumentationManager.cs index 6347394f27..f1631e0a45 100644 --- a/Source/NETworkManager.Documentation/DocumentationManager.cs +++ b/Source/NETworkManager.Documentation/DocumentationManager.cs @@ -255,6 +255,7 @@ public static DocumentationIdentifier GetIdentifierBySettingsName(SettingsName n SettingsName.Profiles => DocumentationIdentifier.SettingsProfiles, SettingsName.Settings => DocumentationIdentifier.SettingsSettings, SettingsName.Dashboard => GetIdentifierByApplicationName(ApplicationName.Dashboard), + SettingsName.NetworkInterface => GetIdentifierByApplicationName(ApplicationName.NetworkInterface), SettingsName.IPScanner => GetIdentifierByApplicationName(ApplicationName.IPScanner), SettingsName.PortScanner => GetIdentifierByApplicationName(ApplicationName.PortScanner), SettingsName.PingMonitor => GetIdentifierByApplicationName(ApplicationName.PingMonitor), diff --git a/Source/NETworkManager.Models/Network/BandwidthMeter.cs b/Source/NETworkManager.Models/Network/BandwidthMeter.cs index 22b4afd548..33018b3634 100644 --- a/Source/NETworkManager.Models/Network/BandwidthMeter.cs +++ b/Source/NETworkManager.Models/Network/BandwidthMeter.cs @@ -1,5 +1,7 @@ -using System; +using System; +using System.Diagnostics; using System.Linq; +using System.Net.NetworkInformation; using System.Windows.Threading; namespace NETworkManager.Models.Network; @@ -30,6 +32,12 @@ private void Timer_Tick(object sender, EventArgs e) #region Variables + /// + /// The default sample interval in milliseconds. Kept as a constant because the speed + /// calculation and the consuming view model derive timing from it. + /// + public const double DefaultUpdateInterval = 1000; + public double UpdateInterval { get; @@ -42,12 +50,13 @@ public double UpdateInterval field = value; } - } = 1000; + } = DefaultUpdateInterval; public bool IsRunning => _timer.IsEnabled; private readonly DispatcherTimer _timer = new(); private readonly System.Net.NetworkInformation.NetworkInterface _networkInterface; + private readonly Stopwatch _stopwatch = new(); private long _previousBytesSent; private long _previousBytesReceived; private bool _canUpdate; // Collect initial data for correct calculation @@ -76,34 +85,60 @@ public void Stop() { _timer.Stop(); - // Reset + // Reset, so the next sample re-seeds the baseline (avoids a speed spike after a pause). _canUpdate = false; } private void Update() { - var stats = _networkInterface.GetIPv4Statistics(); + // The interface may have been removed/disabled after this meter was created. + if (_networkInterface == null) + return; - var totalBytesSent = stats.BytesSent; - var totalBytesReceived = stats.BytesReceived; + IPInterfaceStatistics stats; - var byteSentSpeed = totalBytesSent - _previousBytesSent; - var byteReceivedSpeed = totalBytesReceived - _previousBytesReceived; + try + { + // IPStatistics covers both IPv4 and IPv6 traffic on the interface. + stats = _networkInterface.GetIPStatistics(); + } + catch (NetworkInformationException) + { + // Transient failure (e.g. adapter going down) - skip this tick. + return; + } - _previousBytesSent = stats.BytesSent; - _previousBytesReceived = stats.BytesReceived; + var totalBytesSent = stats.BytesSent; + var totalBytesReceived = stats.BytesReceived; - // Need to collect initial data for correct calculation... + // First sample after start/resume: seed the baseline and start timing, no speed yet. if (!_canUpdate) { + _previousBytesSent = totalBytesSent; + _previousBytesReceived = totalBytesReceived; + _stopwatch.Restart(); _canUpdate = true; return; } - OnUpdateSpeed(new BandwidthMeterSpeedArgs(DateTime.Now, totalBytesReceived, totalBytesSent, byteReceivedSpeed, - byteSentSpeed)); + var elapsedSeconds = _stopwatch.Elapsed.TotalSeconds; + _stopwatch.Restart(); + + // Clamp negative deltas: cumulative counters can reset/wrap (driver reset, disable/enable). + var deltaSent = Math.Max(0, totalBytesSent - _previousBytesSent); + var deltaReceived = Math.Max(0, totalBytesReceived - _previousBytesReceived); + + // Derive a true per-second rate from the measured elapsed time (robust against timer jitter). + var byteSentSpeed = elapsedSeconds > 0 ? (long)(deltaSent / elapsedSeconds) : 0; + var byteReceivedSpeed = elapsedSeconds > 0 ? (long)(deltaReceived / elapsedSeconds) : 0; + + _previousBytesSent = totalBytesSent; + _previousBytesReceived = totalBytesReceived; + + OnUpdateSpeed(new BandwidthMeterSpeedArgs(DateTime.Now, totalBytesReceived, totalBytesSent, + byteReceivedSpeed, byteSentSpeed)); } #endregion -} \ No newline at end of file +} diff --git a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs index 30df1a5117..0252eb58a4 100644 --- a/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs +++ b/Source/NETworkManager.Settings/GlobalStaticConfiguration.cs @@ -104,6 +104,7 @@ public static class GlobalStaticConfiguration // Application: Network Interface public static ExportFileType NetworkInterface_ExportFileType => ExportFileType.Csv; + public static int NetworkInterface_BandwidthChartTime => 60; // Application: WiFi public static bool WiFi_Show2dot4GHzNetworks => true; diff --git a/Source/NETworkManager.Settings/SettingsInfo.cs b/Source/NETworkManager.Settings/SettingsInfo.cs index 09a73eccb0..ffbcdc1f37 100644 --- a/Source/NETworkManager.Settings/SettingsInfo.cs +++ b/Source/NETworkManager.Settings/SettingsInfo.cs @@ -894,6 +894,22 @@ public ExportFileType NetworkInterface_ExportFileType } } = GlobalStaticConfiguration.NetworkInterface_ExportFileType; + /// + /// Gets or sets the bandwidth chart time window in seconds. + /// + public int NetworkInterface_BandwidthChartTime + { + get; + set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } = GlobalStaticConfiguration.NetworkInterface_BandwidthChartTime; + #endregion #region WiFi diff --git a/Source/NETworkManager.Settings/SettingsName.cs b/Source/NETworkManager.Settings/SettingsName.cs index 949de02c75..e54842d6c2 100644 --- a/Source/NETworkManager.Settings/SettingsName.cs +++ b/Source/NETworkManager.Settings/SettingsName.cs @@ -14,6 +14,7 @@ public enum SettingsName Profiles, Settings, Dashboard, + NetworkInterface, IPScanner, PortScanner, PingMonitor, diff --git a/Source/NETworkManager.Settings/SettingsViewManager.cs b/Source/NETworkManager.Settings/SettingsViewManager.cs index 08950281ac..7843409dff 100644 --- a/Source/NETworkManager.Settings/SettingsViewManager.cs +++ b/Source/NETworkManager.Settings/SettingsViewManager.cs @@ -48,6 +48,8 @@ public static class SettingsViewManager // Applications new(SettingsName.Dashboard, ApplicationManager.GetIcon(ApplicationName.Dashboard), SettingsGroup.Application), + new(SettingsName.NetworkInterface, ApplicationManager.GetIcon(ApplicationName.NetworkInterface), + SettingsGroup.Application), new(SettingsName.IPScanner, ApplicationManager.GetIcon(ApplicationName.IPScanner), SettingsGroup.Application), new(SettingsName.PortScanner, ApplicationManager.GetIcon(ApplicationName.PortScanner), diff --git a/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml new file mode 100644 index 0000000000..0d03d5e0e2 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs new file mode 100644 index 0000000000..85abf5f6b4 --- /dev/null +++ b/Source/NETworkManager/Controls/LiveChartsBandwidthTooltip.xaml.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using LiveChartsCore; +using LiveChartsCore.Kernel; +using LiveChartsCore.Kernel.Sketches; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using NETworkManager.Utilities; +using SkiaSharp; + +namespace NETworkManager.Controls; + +public partial class LiveChartsBandwidthTooltip : IChartTooltip, INotifyPropertyChanged +{ + private readonly Popup _popup; + + public LiveChartsBandwidthTooltip() + { + InitializeComponent(); + DataContext = this; + _popup = new Popup + { + AllowsTransparency = true, + Placement = PlacementMode.MousePoint, + StaysOpen = true, + Child = this + }; + } + + public event PropertyChangedEventHandler PropertyChanged; + + public string HeaderText + { + get; + private set + { + if (field == value) return; + field = value; + OnPropertyChanged(); + } + } + + public ObservableCollection TooltipEntries { get; } = []; + + public void Show(IEnumerable tooltipPoints, Chart chart) + { + var points = tooltipPoints.ToList(); + if (points.Count == 0) return; + + if (points[0].Context.DataSource is LvlChartsDefaultInfo firstInfo) + HeaderText = DateTimeHelper.DateTimeToTimeString(firstInfo.DateTime); + + TooltipEntries.Clear(); + foreach (var point in points) + { + var value = point.Context.DataSource is LvlChartsDefaultInfo info + ? $"{FileSizeConverter.GetBytesReadable((long)info.Value * 8)}it/s" + : "-/-"; + TooltipEntries.Add(new TooltipEntry( + SkColorToBrush(GetSeriesColor(point)), + value, + point.Context.Series.Name ?? string.Empty)); + } + + _popup.PlacementTarget = chart.View as FrameworkElement; + _popup.IsOpen = true; + } + + public void Hide(Chart chart) + { + _popup.IsOpen = false; + } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + private static SKColor GetSeriesColor(ChartPoint point) + { + if (point.Context.Series is LineSeries ls && ls.Stroke is SolidColorPaint paint) + return paint.Color; + return SKColors.Gray; + } + + private static Brush SkColorToBrush(SKColor color) + => new SolidColorBrush(Color.FromArgb(color.Alpha, color.Red, color.Green, color.Blue)); + + public record TooltipEntry(Brush SeriesColor, string Value, string SeriesName); +} diff --git a/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml b/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml deleted file mode 100644 index 8542c1077a..0000000000 --- a/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml.cs b/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml.cs deleted file mode 100644 index 916a23bcc3..0000000000 --- a/Source/NETworkManager/Controls/LvlChartsBandwidthTooltip.xaml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; -using LiveCharts; -using LiveCharts.Wpf; - -namespace NETworkManager.Controls; - -public partial class LvlChartsBandwidthTooltip : IChartTooltip -{ - public LvlChartsBandwidthTooltip() - { - InitializeComponent(); - - DataContext = this; - } - - public event PropertyChangedEventHandler PropertyChanged; - - public TooltipData Data - { - get; - set - { - field = value; - OnPropertyChanged(); - } - } - - public TooltipSelectionMode? SelectionMode { get; set; } - - protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} \ No newline at end of file diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceSettingsViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceSettingsViewModel.cs new file mode 100644 index 0000000000..450a2c5a5c --- /dev/null +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceSettingsViewModel.cs @@ -0,0 +1,55 @@ +using NETworkManager.Settings; + +namespace NETworkManager.ViewModels; + +/// +/// Represents the settings for the Network Interface. +/// +public class NetworkInterfaceSettingsViewModel : ViewModelBase +{ + #region Variables + + private readonly bool _isLoading; + + /// + /// Gets or sets the bandwidth chart time window in seconds. + /// + public int BandwidthChartTime + { + get; + set + { + if (value == field) + return; + + if (!_isLoading) + SettingsManager.Current.NetworkInterface_BandwidthChartTime = value; + + field = value; + OnPropertyChanged(); + } + } + + #endregion + + #region Constructor, load settings + + /// + /// Initializes a new instance of the class. + /// + public NetworkInterfaceSettingsViewModel() + { + _isLoading = true; + + LoadSettings(); + + _isLoading = false; + } + + private void LoadSettings() + { + BandwidthChartTime = SettingsManager.Current.NetworkInterface_BandwidthChartTime; + } + + #endregion +} diff --git a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs index 1c265f963e..ec851aa94d 100644 --- a/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs +++ b/Source/NETworkManager/ViewModels/NetworkInterfaceViewModel.cs @@ -1,6 +1,9 @@ -using LiveCharts; -using LiveCharts.Configurations; -using LiveCharts.Wpf; +using LiveChartsCore; +using LiveChartsCore.Drawing; +using LiveChartsCore.Kernel; +using LiveChartsCore.SkiaSharpView; +using LiveChartsCore.SkiaSharpView.Painting; +using LiveChartsCore.SkiaSharpView.Painting.Effects; using log4net; using MahApps.Metro.Controls; using MahApps.Metro.SimpleChildWindow; @@ -14,6 +17,7 @@ using NETworkManager.Settings; using NETworkManager.Utilities; using NETworkManager.Views; +using SkiaSharp; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -689,15 +693,17 @@ public GridLength ProfileWidth /// /// Initializes a new instance of the class. /// - /// The dialog coordinator instance. public NetworkInterfaceViewModel() { _isLoading = true; - _ = LoadNetworkInterfaces(); - + // Initialize the bandwidth chart before loading interfaces, so the chart collections + // exist by the time the (possibly synchronous) interface load selects an interface and + // starts the bandwidth meter. InitialBandwidthChart(); + _ = LoadNetworkInterfaces(); + // Profiles CreateTags(); @@ -716,44 +722,193 @@ public NetworkInterfaceViewModel() NetworkChange.NetworkAvailabilityChanged += (_, _) => ReloadNetworkInterfaces(); NetworkChange.NetworkAddressChanged += (_, _) => ReloadNetworkInterfaces(); + // React to settings changes (e.g. the configurable bandwidth chart time window) + SettingsManager.Current.PropertyChanged += SettingsManager_PropertyChanged; + LoadSettings(); _isLoading = false; } /// - /// Initializes the bandwidth chart configuration. + /// The visible chart time window in seconds, configurable via the settings. /// + private static double BandwidthChartWindowSeconds => SettingsManager.Current.NetworkInterface_BandwidthChartTime; + + /// + /// Extra share of samples kept beyond the visible window so the chart has a little + /// off-screen history (smoother left edge while live + minor pan-back headroom). + /// + private const double BandwidthValuesHeadroom = 1.1; + + private int _maxBandwidthValues; + private bool _updatingBandwidthAxisFromCode; + private DateTime _bandwidthSessionStartTime; + + private ObservableCollection _bandwidthReceivedValues; + private ObservableCollection _bandwidthSentValues; + + // Capped rolling history kept off-screen so the chart can be rebuilt when the user + // returns to live mode after panning/zooming (see BandwidthGoLiveAction). + private readonly List _bandwidthReceivedHistory = []; + private readonly List _bandwidthSentHistory = []; + private void InitialBandwidthChart() { - var dayConfig = Mappers.Xy() - .X(dayModel => (double)dayModel.DateTime.Ticks / TimeSpan.FromHours(1).Ticks) - .Y(dayModel => dayModel.Value); + _bandwidthReceivedValues = []; + _bandwidthSentValues = []; + _bandwidthSessionStartTime = DateTime.Now; - Series = new SeriesCollection(dayConfig) - { - new LineSeries + // Start in live mode so samples are plotted immediately, independent of when the meter + // starts relative to this initialization. + IsBandwidthLiveMode = true; + + UpdateMaxBandwidthValues(); + + var downloadColor = SKColor.Parse("#1ba1e2"); + var uploadColor = SKColor.Parse("#7fba00"); + + var labelColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray5") is System.Windows.Media.SolidColorBrush gray5 + ? new SKColor(gray5.Color.R, gray5.Color.G, gray5.Color.B, gray5.Color.A) + : new SKColor(0x68, 0x68, 0x68); + + var separatorColor = Application.Current?.TryFindResource("MahApps.Brushes.Gray8") is System.Windows.Media.SolidColorBrush gray8 + ? new SKColor(gray8.Color.R, gray8.Color.G, gray8.Color.B, gray8.Color.A) + : new SKColor(0x80, 0x80, 0x80); + + Series = + [ + new LineSeries { - Title = "Download", - Values = new ChartValues(), - PointGeometry = null + Name = Strings.Download, + Values = _bandwidthReceivedValues, + Mapping = (info, _) => double.IsNaN(info.Value) + ? Coordinate.Empty + : new((info.DateTime - _bandwidthSessionStartTime).TotalSeconds, info.Value), + GeometrySize = 0, + LineSmoothness = 0.3, + DataPadding = new LvcPoint(0, 0), + Stroke = new SolidColorPaint(downloadColor) { StrokeThickness = 1.5f }, + Fill = new SolidColorPaint(downloadColor.WithAlpha(0x33)) }, - new LineSeries + new LineSeries { - Title = "Upload", - Values = new ChartValues(), - PointGeometry = null + Name = Strings.Upload, + Values = _bandwidthSentValues, + Mapping = (info, _) => double.IsNaN(info.Value) + ? Coordinate.Empty + : new((info.DateTime - _bandwidthSessionStartTime).TotalSeconds, info.Value), + GeometrySize = 0, + LineSmoothness = 0.3, + DataPadding = new LvcPoint(0, 0), + Stroke = new SolidColorPaint(uploadColor) { StrokeThickness = 1.5f }, + Fill = new SolidColorPaint(uploadColor.WithAlpha(0x33)) + } + ]; + + BandwidthXAxes = + [ + new Axis + { + Labeler = value => DateTimeHelper.DateTimeToTimeString( + _bandwidthSessionStartTime.AddSeconds(value)), + TextSize = 10, + Padding = new Padding(0, 4, 0, 0), + LabelsPaint = new SolidColorPaint(labelColor), + SeparatorsPaint = new SolidColorPaint(separatorColor) + { + StrokeThickness = 1, + PathEffect = new DashEffect([10f, 10f]) + }, + MinStep = BandwidthChartWindowSeconds / 4.0, + ForceStepToMin = true + } + ]; + + BandwidthYAxes = + [ + new Axis + { + MinLimit = 0, + Labeler = value => $"{FileSizeConverter.GetBytesReadable((long)value * 8)}it/s", + TextSize = 11, + Padding = new Padding(4, 0), + LabelsPaint = new SolidColorPaint(labelColor), + SeparatorsPaint = new SolidColorPaint(separatorColor) + { + StrokeThickness = 1, + PathEffect = new DashEffect([10f, 10f]) + } + } + ]; + + BandwidthLegendTextPaint = new SolidColorPaint(labelColor) { SKTypeface = SKTypeface.Default }; + + BandwidthXAxes[0].PropertyChanged += (_, args) => + { + if (_updatingBandwidthAxisFromCode) + return; + + if (args.PropertyName is not (nameof(Axis.MinLimit) or nameof(Axis.MaxLimit))) + return; + + IsBandwidthLiveMode = false; + + var axis = BandwidthXAxes[0]; + if (axis.MinLimit.HasValue && axis.MaxLimit.HasValue) + { + _updatingBandwidthAxisFromCode = true; + axis.MinStep = (axis.MaxLimit.Value - axis.MinLimit.Value) / 4.0; + _updatingBandwidthAxisFromCode = false; } }; - FormatterDate = value => - DateTimeHelper.DateTimeToTimeString(new DateTime((long)(value * TimeSpan.FromHours(1).Ticks))); - FormatterSpeed = value => $"{FileSizeConverter.GetBytesReadable((long)value * 8)}it/s"; + UpdateBandwidthXAxisWindow(DateTime.Now); } - public Func FormatterDate { get; set; } - public Func FormatterSpeed { get; set; } - public SeriesCollection Series { get; set; } + /// + /// Gets the series collection for the bandwidth chart (download/upload). + /// + public ISeries[] Series { get; private set; } + + /// + /// Gets the X-axes configuration for the bandwidth chart. + /// + public Axis[] BandwidthXAxes { get; private set; } + + /// + /// Gets the Y-axes configuration for the bandwidth chart. + /// + public Axis[] BandwidthYAxes { get; private set; } + + /// + /// Gets the themed paint used for the chart legend text. + /// + public SolidColorPaint BandwidthLegendTextPaint { get; private set; } + + /// + /// Gets a value indicating whether the bandwidth chart is in live (auto-scrolling) mode. + /// + public bool IsBandwidthLiveMode + { + get; + private set + { + if (value == field) + return; + + field = value; + OnPropertyChanged(); + } + } + + private void UpdateMaxBandwidthValues() + { + // Number of samples that fit in the visible window (derived from the meter's sample + // interval), plus a little off-screen headroom. + var samplesPerWindow = BandwidthChartWindowSeconds * 1000 / BandwidthMeter.DefaultUpdateInterval; + _maxBandwidthValues = (int)Math.Ceiling(samplesPerWindow * BandwidthValuesHeadroom); + } /// /// Loads the network interfaces. @@ -1523,21 +1678,83 @@ private void ResizeProfile(bool dueToChangedSize) private void ResetBandwidthChart() { - if (Series == null) + if (_bandwidthReceivedValues == null) return; - Series[0].Values.Clear(); - Series[1].Values.Clear(); + _bandwidthReceivedValues.Clear(); + _bandwidthSentValues.Clear(); + _bandwidthReceivedHistory.Clear(); + _bandwidthSentHistory.Clear(); - var currentDateTime = DateTime.Now; + _bandwidthSessionStartTime = DateTime.Now; + IsBandwidthLiveMode = true; - for (var i = 60; i > 0; i--) - { - var bandwidthInfo = new LvlChartsDefaultInfo(currentDateTime.AddSeconds(-i), double.NaN); + UpdateBandwidthXAxisWindow(DateTime.Now); + } - Series[0].Values.Add(bandwidthInfo); - Series[1].Values.Add(bandwidthInfo); - } + private void UpdateBandwidthXAxisWindow(DateTime now) + { + _updatingBandwidthAxisFromCode = true; + var axis = BandwidthXAxes[0]; + var elapsed = (now - _bandwidthSessionStartTime).TotalSeconds; + axis.MinStep = BandwidthChartWindowSeconds / 4.0; + axis.MinLimit = elapsed - BandwidthChartWindowSeconds; + axis.MaxLimit = elapsed; + _updatingBandwidthAxisFromCode = false; + } + + /// + /// Rescales the Y axis to the current values. The step is derived from a 20% padded + /// max and MaxLimit is set to step * 3, so the top label lands exactly on MaxLimit. + /// + private void UpdateBandwidthYAxis() + { + var maxVal = _bandwidthReceivedValues.Concat(_bandwidthSentValues) + .Where(p => !double.IsNaN(p.Value)) + .Select(p => p.Value) + .DefaultIfEmpty(0) + .Max(); + + if (!(maxVal > 0)) + return; + + var yAxis = BandwidthYAxes[0]; + var step = Math.Ceiling(maxVal * 1.2 / 3.0); + yAxis.MinStep = step; + yAxis.MaxLimit = step * 3; + } + + private void TrimBandwidthHistory(List history) + { + var excess = history.Count - _maxBandwidthValues; + if (excess > 0) + history.RemoveRange(0, excess); + } + + /// + /// Gets the command to return the bandwidth chart to live (auto-scrolling) mode. + /// + public ICommand BandwidthGoLiveCommand => new RelayCommand(_ => BandwidthGoLiveAction()); + + private void BandwidthGoLiveAction() + { + IsBandwidthLiveMode = true; + + // Samples received while inspecting were not added to the chart, so rebuild the + // rolling buffers from the most recent history to resume at the current time. + var recentReceived = _bandwidthReceivedHistory + .Skip(Math.Max(0, _bandwidthReceivedHistory.Count - _maxBandwidthValues)); + var recentSent = _bandwidthSentHistory + .Skip(Math.Max(0, _bandwidthSentHistory.Count - _maxBandwidthValues)); + + _bandwidthReceivedValues = new ObservableCollection(recentReceived); + _bandwidthSentValues = new ObservableCollection(recentSent); + + ((LineSeries)Series[0]).Values = _bandwidthReceivedValues; + ((LineSeries)Series[1]).Values = _bandwidthSentValues; + + UpdateBandwidthXAxisWindow(DateTime.Now); + UpdateBandwidthYAxis(); } private bool _resetBandwidthStatisticOnNextUpdate; @@ -1560,6 +1777,8 @@ private void ResumeBandwidthMeter() if (_bandwidthMeter is not { IsRunning: false }) return; + // The meter is only paused while this view is hidden (i.e. another application is shown). + // Returning to it starts a fresh measurement: reset the chart and statistics, then start. ResetBandwidthChart(); _resetBandwidthStatisticOnNextUpdate = true; @@ -1685,6 +1904,20 @@ private void SearchDispatcherTimer_Tick(object sender, EventArgs e) IsSearching = false; } + private void SettingsManager_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SettingsInfo.NetworkInterface_BandwidthChartTime): + UpdateMaxBandwidthValues(); + + // Re-apply the visible window immediately so the change is reflected while running. + if (IsBandwidthLiveMode && _bandwidthMeter is { IsRunning: true }) + UpdateBandwidthXAxisWindow(DateTime.Now); + break; + } + } + private void BandwidthMeter_UpdateSpeed(object sender, BandwidthMeterSpeedArgs e) { // Reset statistics @@ -1700,26 +1933,52 @@ private void BandwidthMeter_UpdateSpeed(object sender, BandwidthMeterSpeedArgs e // Measured time BandwidthMeasuredTime = DateTime.Now - BandwidthStartTime; - // Current download/upload + // Interface totals (cumulative, since boot) + current speed BandwidthTotalBytesReceived = e.TotalBytesReceived; BandwidthTotalBytesSent = e.TotalBytesSent; BandwidthBytesReceivedSpeed = e.ByteReceivedSpeed; BandwidthBytesSentSpeed = e.ByteSentSpeed; - // Total download/upload + // A counter reset (e.g. adapter disable/enable, driver reset, sleep/resume) can drop the + // cumulative totals below the session baseline. Re-baseline in that case so the session + // amounts below never go negative; they then resume counting from the reset point. + if (BandwidthTotalBytesReceived < _bandwidthTotalBytesReceivedTemp) + _bandwidthTotalBytesReceivedTemp = BandwidthTotalBytesReceived; + + if (BandwidthTotalBytesSent < _bandwidthTotalBytesSentTemp) + _bandwidthTotalBytesSentTemp = BandwidthTotalBytesSent; + + // Amount transferred since the measurement started (this session) BandwidthDiffBytesReceived = BandwidthTotalBytesReceived - _bandwidthTotalBytesReceivedTemp; BandwidthDiffBytesSent = BandwidthTotalBytesSent - _bandwidthTotalBytesSentTemp; // Add chart entry - Series[0].Values.Add(new LvlChartsDefaultInfo(e.DateTime, e.ByteReceivedSpeed)); - Series[1].Values.Add(new LvlChartsDefaultInfo(e.DateTime, e.ByteSentSpeed)); + var receivedInfo = new LvlChartsDefaultInfo(e.DateTime, e.ByteReceivedSpeed); + var sentInfo = new LvlChartsDefaultInfo(e.DateTime, e.ByteSentSpeed); + + // Always record history (capped) so the chart can be rebuilt when returning to live mode. + _bandwidthReceivedHistory.Add(receivedInfo); + _bandwidthSentHistory.Add(sentInfo); + TrimBandwidthHistory(_bandwidthReceivedHistory); + TrimBandwidthHistory(_bandwidthSentHistory); + + // While the user inspects the chart (panned/zoomed, i.e. not live), keep the view + // frozen: skip updating the visible buffer and axes. New samples are still recorded + // above and become visible again via BandwidthGoLiveCommand. + if (!IsBandwidthLiveMode) + return; + + _bandwidthReceivedValues.Add(receivedInfo); + _bandwidthSentValues.Add(sentInfo); + + if (_bandwidthReceivedValues.Count > _maxBandwidthValues) + _bandwidthReceivedValues.RemoveAt(0); - // Remove data older than 60 seconds - if (Series[0].Values.Count > 59) - Series[0].Values.RemoveAt(0); + if (_bandwidthSentValues.Count > _maxBandwidthValues) + _bandwidthSentValues.RemoveAt(0); - if (Series[1].Values.Count > 59) - Series[1].Values.RemoveAt(0); + UpdateBandwidthXAxisWindow(e.DateTime); + UpdateBandwidthYAxis(); } private void NetworkInterface_UserHasCanceled(object sender, EventArgs e) diff --git a/Source/NETworkManager/ViewModels/SettingsViewModel.cs b/Source/NETworkManager/ViewModels/SettingsViewModel.cs index 36b73fd514..be82a8a755 100644 --- a/Source/NETworkManager/ViewModels/SettingsViewModel.cs +++ b/Source/NETworkManager/ViewModels/SettingsViewModel.cs @@ -120,6 +120,7 @@ public SettingsViewInfo SelectedSettingsView private SettingsSettingsView _settingsSettingsView; private SettingsProfilesView _settingsProfilesView; private DashboardSettingsView _dashboardSettingsView; + private NetworkInterfaceSettingsView _networkInterfaceSettingsView; private IPScannerSettingsView _ipScannerSettingsView; private PortScannerSettingsView _portScannerSettingsView; private PingMonitorSettingsView _pingMonitorSettingsView; @@ -265,6 +266,11 @@ private void ChangeSettingsContent(SettingsViewInfo settingsViewInfo) SettingsContent = _dashboardSettingsView; break; + case SettingsName.NetworkInterface: + _networkInterfaceSettingsView ??= new NetworkInterfaceSettingsView(); + + SettingsContent = _networkInterfaceSettingsView; + break; case SettingsName.IPScanner: _ipScannerSettingsView ??= new IPScannerSettingsView(); diff --git a/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml b/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml new file mode 100644 index 0000000000..64e1a969ee --- /dev/null +++ b/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml.cs b/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml.cs new file mode 100644 index 0000000000..c0ce345c91 --- /dev/null +++ b/Source/NETworkManager/Views/NetworkInterfaceSettingsView.xaml.cs @@ -0,0 +1,14 @@ +using NETworkManager.ViewModels; + +namespace NETworkManager.Views; + +public partial class NetworkInterfaceSettingsView +{ + private readonly NetworkInterfaceSettingsViewModel _viewModel = new(); + + public NetworkInterfaceSettingsView() + { + InitializeComponent(); + DataContext = _viewModel; + } +} diff --git a/Source/NETworkManager/Views/NetworkInterfaceView.xaml b/Source/NETworkManager/Views/NetworkInterfaceView.xaml index ffbddcdcc1..a8054f4002 100644 --- a/Source/NETworkManager/Views/NetworkInterfaceView.xaml +++ b/Source/NETworkManager/Views/NetworkInterfaceView.xaml @@ -14,7 +14,7 @@ xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization" xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings" xmlns:internalControls="clr-namespace:NETworkManager.Controls" - xmlns:liveChart="clr-namespace:LiveCharts.Wpf;assembly=LiveCharts.Wpf" + xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF" xmlns:profiles="clr-namespace:NETworkManager.Profiles;assembly=NETworkManager.Profiles" xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF" xmlns:networkManager="clr-namespace:NETworkManager" @@ -30,8 +30,8 @@ + - @@ -841,28 +841,62 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -897,19 +931,23 @@ + Text="{Binding BandwidthBytesReceivedSpeed, Converter={StaticResource BandwidthBytesToSpeedConverter}}" + ToolTip="{Binding BandwidthBytesReceivedSpeed, Converter={StaticResource BandwidthBytesToSpeedConverter}, ConverterParameter=bytes}" /> + Text="{Binding BandwidthBytesSentSpeed, Converter={StaticResource BandwidthBytesToSpeedConverter}}" + ToolTip="{Binding BandwidthBytesSentSpeed, Converter={StaticResource BandwidthBytesToSpeedConverter}, ConverterParameter=bytes}" /> + Text="{Binding BandwidthTotalBytesReceived, Converter={StaticResource Bytes1000ToSizeConverter}}" + ToolTip="{Binding BandwidthTotalBytesReceived, Converter={StaticResource BytesToExactStringConverter}}" /> + Text="{Binding BandwidthTotalBytesSent, Converter={StaticResource Bytes1000ToSizeConverter}}" + ToolTip="{Binding BandwidthTotalBytesSent, Converter={StaticResource BytesToExactStringConverter}}" /> @@ -920,11 +958,13 @@ + Text="{Binding BandwidthDiffBytesReceived, Converter={StaticResource Bytes1000ToSizeConverter}}" + ToolTip="{Binding BandwidthDiffBytesReceived, Converter={StaticResource BytesToExactStringConverter}}" /> + Text="{Binding BandwidthDiffBytesSent, Converter={StaticResource Bytes1000ToSizeConverter}}" + ToolTip="{Binding BandwidthDiffBytesSent, Converter={StaticResource BytesToExactStringConverter}}" /> @@ -1540,7 +1580,7 @@ - @@ -1548,7 +1588,7 @@ IsChecked="{Binding Path=ProfileFilterTagsMatchAll}" Margin="10,0,0,0" /> -