Add configurable traffic usage accumulation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:21:57 +08:00
parent 4213014ba4
commit 66c07f8da8
6 changed files with 205 additions and 7 deletions
+11
View File
@@ -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."""
+16
View File
@@ -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": "После переподключения начинать подсчёт накопленного исходящего и входящего трафика с нуля."
}
}
+35 -1
View File
@@ -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
+124 -6
View File
@@ -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()
+3
View File
@@ -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,
+16
View File
@@ -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.'),