From 66c07f8da856d4e54b87c0a180052773ad26da8d Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Tue, 11 Aug 2026 11:58:03 +0800 Subject: [PATCH] Add configurable traffic usage accumulation Signed-off-by: Loren Eteval --- Furious/Actions/Settings.py | 11 +++ Furious/Externals/GenTranslation.py | 16 +++ Furious/Service/MetricsDataManager.py | 36 ++++++- Furious/Service/TrafficStatsManager.py | 130 +++++++++++++++++++++++-- Furious/Window/MainWindow.py | 3 + Furious/Window/SettingsPage.py | 16 +++ 6 files changed, 205 insertions(+), 7 deletions(-) diff --git a/Furious/Actions/Settings.py b/Furious/Actions/Settings.py index d16c1ad..e1d260d 100644 --- a/Furious/Actions/Settings.py +++ b/Furious/Actions/Settings.py @@ -22,6 +22,9 @@ from __future__ import annotations from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Service.TrafficStatsManager import ( + CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING, +) __all__ = ['SettingsAction', 'SettingsController', 'TUNModeAction'] @@ -166,6 +169,14 @@ class SettingsController: else: AppSettings.turnOFF('ShowProgressBarWhenConnecting') + @staticmethod + def setClearTrafficUsageOnReconnect(enabled: bool): + """Persist whether reconnecting starts a fresh usage session.""" + if enabled: + AppSettings.turnON_(CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING) + else: + AppSettings.turnOFF(CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING) + @staticmethod def setEditorWhitespaceVisible(enabled: bool): """Apply and persist editor whitespace visibility.""" diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 4ce7e23..959f3f0 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -2709,5 +2709,21 @@ TRANSLATION = { "RU": "Идентификатор аутентификации, указанный сервером Hysteria2.", "ZH": "Hysteria2服务器报告的身份验证 ID。", "isReviewed": "True" + }, + "Clear Traffic Usage Statistics On Reconnect": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "重新连接时清除流量使用统计", + "isReviewed": "True", + "RU": "Очищать статистику трафика при переподключении" + }, + "Start accumulated upload and download usage from zero after reconnecting.": { + "source": [ + "Furious.Window.SettingsPage" + ], + "ZH": "重新连接后,将累计上传和下载流量从零开始计算。", + "isReviewed": "True", + "RU": "После переподключения начинать подсчёт накопленного исходящего и входящего трафика с нуля." } } diff --git a/Furious/Service/MetricsDataManager.py b/Furious/Service/MetricsDataManager.py index 1619e86..d616799 100644 --- a/Furious/Service/MetricsDataManager.py +++ b/Furious/Service/MetricsDataManager.py @@ -116,9 +116,43 @@ class MetricsDataManager(QtCore.QObject): self._samples.clear() self.historyChanged.emit() + def clearMetrics(self, metricKeys): + """Remove selected metric values while preserving other history.""" + keys = frozenset(metricKeys) + + if not keys or not self._samples: + return + + retainedSamples = deque() + changed = False + + for sample in self._samples: + retainedValues = { + key: value for key, value in sample.values.items() if key not in keys + } + + if len(retainedValues) != len(sample.values): + changed = True + + if retainedValues: + retainedSamples.append(MetricSample(sample.sampledAt, retainedValues)) + + if changed: + self._samples = retainedSamples + self.historyChanged.emit() + + @QtCore.Slot() + def clearTrafficUsageHistory(self): + """Clear usage graphs without discarding upload/download speed history.""" + self.clearMetrics((DOWNLOAD_USAGE_METRIC, UPLOAD_USAGE_METRIC)) + @QtCore.Slot(object) def recordTrafficSample(self, sample): - """Convert one provider-facing traffic sample into generic metrics.""" + """Convert one session-normalized traffic sample into generic metrics. + + Raw proxy-core counter lifetimes are reconciled upstream by + ``TrafficStatsManager`` so this class remains provider-independent. + """ if not isinstance(sample, TrafficStatsSample): return diff --git a/Furious/Service/TrafficStatsManager.py b/Furious/Service/TrafficStatsManager.py index fc916f3..ac76627 100644 --- a/Furious/Service/TrafficStatsManager.py +++ b/Furious/Service/TrafficStatsManager.py @@ -19,18 +19,21 @@ from __future__ import annotations -from Furious.Frozenlib import APP, Mixins +from Furious.Frozenlib import APP, AppSettings, Mixins, registerAppSettings from Furious.Plugins import TrafficCounters, getPluginRegistry from PySide6 import QtCore from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass +from typing import Optional, Tuple import time import logging +import operator __all__ = [ + 'CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING', 'TrafficStatsSample', 'TrafficStatsManager', 'formatTrafficSpeed', @@ -42,6 +45,9 @@ logger = logging.getLogger(__name__) KIBIBYTE = 1024 MEBIBYTE = KIBIBYTE * KIBIBYTE TRAFFIC_STATS_SAMPLE_INTERVAL = 2000 +CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING = 'ClearTrafficUsageOnReconnect' + +registerAppSettings(CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING, isBinary=True) @dataclass(frozen=True) @@ -55,6 +61,61 @@ class TrafficStatsSample: downloadUsage: int +@dataclass +class _TrafficUsageAccumulator: + """Convert per-core counters into monotonic application-session totals.""" + + uploadTotal: int = 0 + downloadTotal: int = 0 + previousCounters: Optional[TrafficCounters] = None + + def beginConnection(self): + """Start a new raw-counter lifetime without discarding session totals.""" + self.previousCounters = None + + def clear(self): + """Discard application totals and the current raw-counter baseline.""" + self.uploadTotal = 0 + self.downloadTotal = 0 + self.previousCounters = None + + def update( + self, + counters: TrafficCounters, + *, + clearOnReset=False, + ) -> Tuple[TrafficCounters, bool]: + """Accumulate deltas and report whether a configured reset occurred.""" + previousCounters = self.previousCounters + + counterReset = previousCounters is not None and ( + counters.uplink < previousCounters.uplink + or counters.downlink < previousCounters.downlink + ) + + if counterReset and clearOnReset: + self.clear() + + previousCounters = None + + self.previousCounters = counters + + if previousCounters is not None: + self.uploadTotal += max(counters.uplink - previousCounters.uplink, 0) + self.downloadTotal += max( + counters.downlink - previousCounters.downlink, + 0, + ) + + return ( + TrafficCounters( + uplink=self.uploadTotal, + downlink=self.downloadTotal, + ), + counterReset and clearOnReset, + ) + + def formatTrafficSpeed(bytesPerSecond: float) -> str: """Format a byte rate using compact binary units.""" try: @@ -123,6 +184,7 @@ class TrafficStatsManager( speedChanged = QtCore.Signal(float, float) usageChanged = QtCore.Signal(object, object) sampleChanged = QtCore.Signal(object) + usageHistoryReset = QtCore.Signal() statisticsUnavailable = QtCore.Signal() _sampleReady = QtCore.Signal(int, object, float) @@ -137,6 +199,8 @@ class TrafficStatsManager( self._queryInFlight = False self._previousCounters = None self._previousSampleTime = None + self._usageAccumulator = _TrafficUsageAccumulator() + self._hasConnected = False self._sampleTimer = QtCore.QTimer(self) self._sampleTimer.setInterval(TRAFFIC_STATS_SAMPLE_INTERVAL) @@ -212,17 +276,32 @@ class TrafficStatsManager( pass def _resetSamples(self): - """Discard the previous counter baseline and in-flight marker.""" + """Discard the speed baseline and in-flight marker.""" self._queryInFlight = False self._previousCounters = None self._previousSampleTime = None + def _beginConnectionUsage(self): + """Reset only the raw usage baseline for a new core lifetime.""" + self._usageAccumulator.beginConnection() + + def _clearSessionUsage(self): + """Clear accumulated totals and notify history consumers.""" + self._usageAccumulator.clear() + self.usageHistoryReset.emit() + + @staticmethod + def _clearUsageOnReconnectEnabled() -> bool: + """Return the current persistent reconnect-reset preference.""" + return AppSettings.isStateON_(CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING) + def _activateMonitor(self, monitor): """Begin sampling one plugin-provided monitor.""" self._sampleTimer.stop() self._generation += 1 self._monitor = monitor self._resetSamples() + self._beginConnectionUsage() if monitor is None: self._closeExecutor() @@ -264,7 +343,15 @@ class TrafficStatsManager( def _updateSpeeds(self, counters: TrafficCounters, sampledAt: float): """Calculate and emit rates from one cumulative counter sample.""" - self.usageChanged.emit(counters.uplink, counters.downlink) + sessionCounters, usageWasReset = self._usageAccumulator.update( + counters, + clearOnReset=self._clearUsageOnReconnectEnabled(), + ) + + if usageWasReset: + self.usageHistoryReset.emit() + + self.usageChanged.emit(sessionCounters.uplink, sessionCounters.downlink) previousCounters = self._previousCounters previousSampleTime = self._previousSampleTime @@ -291,11 +378,35 @@ class TrafficStatsManager( sampledAt=sampledAt, uploadSpeed=uploadSpeed, downloadSpeed=downloadSpeed, - uploadUsage=counters.uplink, - downloadUsage=counters.downlink, + uploadUsage=sessionCounters.uplink, + downloadUsage=sessionCounters.downlink, ) ) + @staticmethod + def _validatedCounters(counters) -> Optional[TrafficCounters]: + """Return integral, non-negative provider counters or ``None``.""" + if not isinstance(counters, TrafficCounters): + return None + + values = [] + + for value in (counters.uplink, counters.downlink): + if isinstance(value, bool): + return None + + try: + normalized = operator.index(value) + except TypeError: + return None + + if normalized < 0: + return None + + values.append(normalized) + + return TrafficCounters(uplink=values[0], downlink=values[1]) + @QtCore.Slot(int, object, float) def _consumeResult(self, generation, counters, sampledAt): """Consume one worker result on Qt's GUI thread.""" @@ -305,7 +416,9 @@ class TrafficStatsManager( self._future = None self._queryInFlight = False - if not isinstance(counters, TrafficCounters): + counters = self._validatedCounters(counters) + + if counters is None: self._previousCounters = None self._previousSampleTime = None self.statisticsUnavailable.emit() @@ -316,6 +429,10 @@ class TrafficStatsManager( def connectedCallback(self): """Discover and activate statistics for the connected runtime.""" + if self._hasConnected and self._clearUsageOnReconnectEnabled(): + self._clearSessionUsage() + + self._hasConnected = True monitor = getPluginRegistry().trafficStatsMonitorForKernels( self._activeProcesses() ) @@ -327,6 +444,7 @@ class TrafficStatsManager( self._generation += 1 self._monitor = None self._resetSamples() + self._beginConnectionUsage() self._closeExecutor() self.statisticsUnavailable.emit() diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 6fc53f0..5f04b22 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -76,6 +76,9 @@ class MainWindow(AppQMainWindow): self.homePage.trafficStatsManager.sampleChanged.connect( self.metricsDataManager.recordTrafficSample ) + self.homePage.trafficStatsManager.usageHistoryReset.connect( + self.metricsDataManager.clearTrafficUsageHistory + ) self.metricsPage = MetricsPage( self.metricsDataManager, parent=self.navigationView, diff --git a/Furious/Window/SettingsPage.py b/Furious/Window/SettingsPage.py index c88546f..e6f98c6 100644 --- a/Furious/Window/SettingsPage.py +++ b/Furious/Window/SettingsPage.py @@ -32,6 +32,9 @@ from Furious.Backends.Hysteria2.Stats import ( from Furious.Frozenlib import * from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Service.TrafficStatsManager import ( + CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING, +) from PySide6 import QtCore, QtGui from PySide6.QtWidgets import * @@ -414,6 +417,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.networkTestCard, self.forceLocalhostCard, self.connectionProgressCard, + self.clearTrafficUsageCard, self.editorWhitespaceCard, ) = ( _ActionSettingsCard( @@ -438,6 +442,11 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): 'ShowProgressBarWhenConnecting', SettingsController.setConnectionProgressVisible, ), + _ToggleSettingsCard( + 'arrow-repeat.svg', + CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING, + SettingsController.setClearTrafficUsageOnReconnect, + ), _ToggleSettingsCard( 'text-paragraph.svg', 'ShowTabAndSpacesInEditor', @@ -452,6 +461,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): self.connectionSection.addCard(self.networkTestCard) self.connectionSection.addCard(self.forceLocalhostCard) self.connectionSection.addCard(self.connectionProgressCard) + self.connectionSection.addCard(self.clearTrafficUsageCard) self.connectionSection.addCard(self.editorWhitespaceCard) if SystemRuntime.isAssetsFolderWritable(): @@ -667,6 +677,12 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow): _('Show Progress Bar When Connecting'), _('Show connection progress while proxy services are starting.'), ) + self.clearTrafficUsageCard.setTexts( + _('Clear Traffic Usage Statistics On Reconnect'), + _( + 'Start accumulated upload and download usage from zero after reconnecting.' + ), + ) self.editorWhitespaceCard.setTexts( _('Show Tab And Spaces In Editor'), _('Display whitespace markers in configuration editors.'),