Improve traffic status presentation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:49 +08:00
parent c9c3eceefc
commit 3264dcef61
5 changed files with 152 additions and 29 deletions
+3 -3
View File
@@ -196,9 +196,9 @@ does not import or own Qt UI merely because its plugin also offers settings.
Traffic monitoring is similarly optional. A `TrafficStatsProvider` associates
one or more kernel types with a `TrafficStatsMonitor`. The monitor contains a
picklable query callable and backend-specific target, allowing the host service
to run blocking native API calls outside the GUI process. The service converts
cumulative `TrafficCounters` into periodic rates and exposes only speed updates
to Qt widgets.
to run blocking native API calls outside the GUI process. The service exposes
both cumulative usage and calculated rates to Qt widgets, and suspends polling
while its parent widget is hidden or minimized.
## Profiles and persistence
+6 -2
View File
@@ -967,7 +967,7 @@ class AppStyleSheet:
color: {palette['danger']};
}}
QWidget#TrafficSpeedBadge {{
QWidget#TrafficStatsBadge {{
min-height: 24px;
padding: 0 2px;
border: 1px solid {palette['border']};
@@ -976,13 +976,17 @@ class AppStyleSheet:
color: {palette['text_strong']};
}}
QWidget#TrafficSpeedBadge QLabel {{
QWidget#TrafficStatsBadge QLabel {{
border: none;
padding: 0;
background-color: transparent;
color: {palette['text_strong']};
}}
QWidget#TrafficStatsBadge QLabel#TrafficUsageLabel {{
color: {palette['muted']};
}}
QSplitter::handle {{
background-color: {palette['border']};
}}
+107 -11
View File
@@ -29,7 +29,11 @@ import multiprocessing
import queue
import time
__all__ = ['TrafficStatsManager', 'formatTrafficSpeed']
__all__ = [
'TrafficStatsManager',
'formatTrafficSpeed',
'formatTrafficUsage',
]
logger = logging.getLogger(__name__)
@@ -61,6 +65,30 @@ def formatTrafficSpeed(bytesPerSecond: float) -> str:
return f'{formatted} {unit}'
def formatTrafficUsage(bytesUsed: int) -> str:
"""Format cumulative traffic using compact binary units."""
try:
value = max(int(bytesUsed), 0)
except (TypeError, ValueError, OverflowError):
value = 0
if value < KIBIBYTE:
return f'{value} B'
amount = float(value)
units = ('KiB', 'MiB', 'GiB', 'TiB', 'PiB')
for unit in units:
amount /= KIBIBYTE
if amount < KIBIBYTE or unit == units[-1]:
formatted = f'{amount:.2f}'.rstrip('0').rstrip('.')
return f'{formatted} {unit}'
return f'{value} B'
def _queryTrafficStats(requestQueue, resultQueue):
"""Run potentially GIL-blocking statistics calls in an isolated process."""
while True:
@@ -87,9 +115,10 @@ class TrafficStatsManager(
Mixins.CleanupOnExit,
QtCore.QObject,
):
"""Periodically convert cumulative plugin counters into traffic speeds."""
"""Publish plugin traffic usage and rates while its parent is visible."""
speedChanged = QtCore.Signal(float, float)
usageChanged = QtCore.Signal(object, object)
statisticsUnavailable = QtCore.Signal()
def __init__(self, parent=None):
@@ -114,6 +143,71 @@ class TrafficStatsManager(
self._resultTimer.setInterval(TRAFFIC_STATS_RESULT_INTERVAL)
self._resultTimer.timeout.connect(self._consumeResults)
if parent is not None:
parent.installEventFilter(self)
def _parentIsVisible(self) -> bool:
"""Return whether the parent widget can currently display updates."""
parent = self.parent()
if parent is None or not hasattr(parent, 'isVisible'):
return True
return parent.isVisible() and not (
hasattr(parent, 'isMinimized') and parent.isMinimized()
)
def _suspendSampling(self):
"""Pause polling and discard the current rate baseline."""
wasActive = (
self._sampleTimer.isActive()
or self._resultTimer.isActive()
or self._queryInFlight
)
self._sampleTimer.stop()
self._resultTimer.stop()
if wasActive:
self._generation += 1
self._resetSamples()
def _resumeSampling(self):
"""Resume polling when a monitor and visible parent are available."""
if self._monitor is None or not self._parentIsVisible():
return
if not self._ensureWorker():
self.statisticsUnavailable.emit()
return
self._resultTimer.start()
self._sampleTimer.start()
self._requestSample()
@QtCore.Slot()
def _syncSamplingVisibility(self):
"""Synchronize polling with the parent widget's visibility."""
if self._parentIsVisible():
self._resumeSampling()
else:
self._suspendSampling()
def eventFilter(self, watched, event):
"""Pause traffic queries while the parent widget is not visible."""
if watched is self.parent():
eventType = event.type()
if eventType == QtCore.QEvent.Type.Hide:
self._suspendSampling()
elif eventType in (
QtCore.QEvent.Type.Show,
QtCore.QEvent.Type.WindowStateChange,
):
QtCore.QTimer.singleShot(0, self._syncSamplingVisibility)
return super().eventFilter(watched, event)
@staticmethod
def _activeProcesses():
"""Return the processes owned by the active tray connection."""
@@ -184,6 +278,8 @@ class TrafficStatsManager(
def _activateMonitor(self, monitor):
"""Begin sampling one plugin-provided monitor."""
self._sampleTimer.stop()
self._resultTimer.stop()
self._generation += 1
self._monitor = monitor
self._resetSamples()
@@ -194,19 +290,12 @@ class TrafficStatsManager(
return
if not self._ensureWorker():
self.statisticsUnavailable.emit()
return
self._resultTimer.start()
self._sampleTimer.start()
self._requestSample()
self._resumeSampling()
@QtCore.Slot()
def _requestSample(self):
"""Queue one statistics query unless another query is still running."""
if self._monitor is None or self._queryInFlight:
if self._monitor is None or self._queryInFlight or not self._parentIsVisible():
return
if not self._ensureWorker():
@@ -227,6 +316,8 @@ 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)
previousCounters = self._previousCounters
previousSampleTime = self._previousSampleTime
self._previousCounters = counters
@@ -252,6 +343,11 @@ class TrafficStatsManager(
@QtCore.Slot()
def _consumeResults(self):
"""Drain worker results and ignore samples from older connections."""
if not self._parentIsVisible():
self._suspendSampling()
return
resultQueue = self._resultQueue
if resultQueue is None:
+6 -1
View File
@@ -27,7 +27,11 @@ from .SubscriptionImporter import (
SubscriptionImportService,
SubscriptionSource,
)
from .TrafficStatsManager import TrafficStatsManager, formatTrafficSpeed
from .TrafficStatsManager import (
TrafficStatsManager,
formatTrafficSpeed,
formatTrafficUsage,
)
from .UpdateManager import UpdateManager
__all__ = [
@@ -40,4 +44,5 @@ __all__ = [
'TrafficStatsManager',
'UpdateManager',
'formatTrafficSpeed',
'formatTrafficUsage',
]
+30 -12
View File
@@ -31,6 +31,7 @@ from Furious.Service import (
TrafficStatsManager,
UpdateManager,
formatTrafficSpeed,
formatTrafficUsage,
)
from Furious.Actions.Import import (
ImportFromFileAction,
@@ -258,33 +259,40 @@ class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
self.updateStatusText()
class TrafficSpeedBadge(Mixins.ThemeAware, QWidget):
"""Display independently updated upload and download speeds."""
class TrafficStatsBadge(Mixins.ThemeAware, QWidget):
"""Display independently updated traffic speeds and usage."""
UploadIconFileName = 'cloud-arrow-up.svg'
DownloadIconFileName = 'cloud-arrow-down.svg'
UploadIconFileName = 'cloud-upload.svg'
DownloadIconFileName = 'cloud-download.svg'
IconSize = QtCore.QSize(16, 16)
def __init__(self, parent=None):
"""Initialize dedicated traffic-direction icons and labels."""
super().__init__(parent)
self.setObjectName('TrafficSpeedBadge')
self.setObjectName('TrafficStatsBadge')
self.setVisible(False)
self.uploadIconLabel = QLabel(parent=self)
self.uploadTextLabel = AppQLabel(translatable=False, parent=self)
self.uploadUsageLabel = AppQLabel(translatable=False, parent=self)
self.downloadIconLabel = QLabel(parent=self)
self.downloadTextLabel = AppQLabel(translatable=False, parent=self)
self.downloadUsageLabel = AppQLabel(translatable=False, parent=self)
self.uploadUsageLabel.setObjectName('TrafficUsageLabel')
self.downloadUsageLabel.setObjectName('TrafficUsageLabel')
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(8, 3, 8, 3)
self._layout.setSpacing(6)
self._layout.addWidget(self.uploadIconLabel)
self._layout.addWidget(self.uploadTextLabel)
self._layout.addWidget(self.uploadUsageLabel)
self._layout.addSpacing(4)
self._layout.addWidget(self.downloadIconLabel)
self._layout.addWidget(self.downloadTextLabel)
self._layout.addWidget(self.downloadUsageLabel)
self.setIconByTheme(APP().theme())
@@ -307,11 +315,20 @@ class TrafficSpeedBadge(Mixins.ThemeAware, QWidget):
self.downloadTextLabel.setText(formatTrafficSpeed(download))
self.setVisible(True)
@QtCore.Slot(object, object)
def setUsage(self, upload: int, download: int):
"""Display formatted cumulative upload and download usage."""
self.uploadUsageLabel.setText(f'({formatTrafficUsage(upload)})')
self.downloadUsageLabel.setText(f'({formatTrafficUsage(download)})')
self.setVisible(True)
@QtCore.Slot()
def clearSpeeds(self):
"""Hide stale speeds when statistics are unavailable."""
def clearStatistics(self):
"""Hide stale speed and usage values when statistics are unavailable."""
self.uploadTextLabel.clear()
self.uploadUsageLabel.clear()
self.downloadTextLabel.clear()
self.downloadUsageLabel.clear()
self.setVisible(False)
def themeChangedCallback(self, theme: str):
@@ -328,13 +345,13 @@ class ConnectionStatusWidget(QWidget):
self.setObjectName('ConnectionStatusWidget')
self.networkState = NetworkStateBadge(parent=self)
self.trafficSpeed = TrafficSpeedBadge(parent=self)
self.trafficStats = TrafficStatsBadge(parent=self)
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(6)
self._layout.addWidget(self.trafficStats)
self._layout.addWidget(self.networkState)
self._layout.addWidget(self.trafficSpeed)
class SearchButton(AppQPushButton):
@@ -812,11 +829,12 @@ class MainWindow(AppQMainWindow):
self.connectionStatus = ConnectionStatusWidget(parent=self)
self.networkState = self.connectionStatus.networkState
self.trafficSpeed = self.connectionStatus.trafficSpeed
self.trafficStats = self.connectionStatus.trafficStats
self.trafficStatsManager = TrafficStatsManager(parent=self)
self.trafficStatsManager.speedChanged.connect(self.trafficSpeed.setSpeeds)
self.trafficStatsManager.speedChanged.connect(self.trafficStats.setSpeeds)
self.trafficStatsManager.usageChanged.connect(self.trafficStats.setUsage)
self.trafficStatsManager.statisticsUnavailable.connect(
self.trafficSpeed.clearSpeeds
self.trafficStats.clearStatistics
)
self.statusBar().addPermanentWidget(self.connectionStatus)