mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-07 06:18:05 +03:00
Add configurable traffic usage accumulation
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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."""
|
||||
|
||||
Vendored
+16
@@ -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": "После переподключения начинать подсчёт накопленного исходящего и входящего трафика с нуля."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
Reference in New Issue
Block a user