mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-30 02:48:11 +03:00
Add historical network metrics page
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+152
-4
@@ -2308,7 +2308,8 @@ TRANSLATION = {
|
||||
},
|
||||
"Upload Speed": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
"Furious.Window.HomePage",
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Скорость отдачи",
|
||||
"ZH": "上传速度",
|
||||
@@ -2316,7 +2317,8 @@ TRANSLATION = {
|
||||
},
|
||||
"Download Speed": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
"Furious.Window.HomePage",
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Скорость загрузки",
|
||||
"ZH": "下载速度",
|
||||
@@ -2324,7 +2326,8 @@ TRANSLATION = {
|
||||
},
|
||||
"Upload Traffic Usage": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
"Furious.Window.HomePage",
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Объём исходящего трафика",
|
||||
"ZH": "上传流量",
|
||||
@@ -2332,10 +2335,155 @@ TRANSLATION = {
|
||||
},
|
||||
"Download Traffic Usage": {
|
||||
"source": [
|
||||
"Furious.Window.HomePage"
|
||||
"Furious.Window.HomePage",
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Объём входящего трафика",
|
||||
"ZH": "下载流量",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Metrics": {
|
||||
"source": [
|
||||
"Furious.Window.MainWindow"
|
||||
],
|
||||
"RU": "Метрики",
|
||||
"ZH": "指标",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Network Metrics": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Сетевые метрики",
|
||||
"ZH": "网络指标",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Time Range": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Временной диапазон",
|
||||
"ZH": "时间范围",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Granularity": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Детализация",
|
||||
"ZH": "粒度",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Download": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Загрузка",
|
||||
"ZH": "下载",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Upload": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Отдача",
|
||||
"ZH": "上传",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Last 5 minutes": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Последние 5 минут",
|
||||
"ZH": "最近 5 分钟",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Last 15 minutes": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Последние 15 минут",
|
||||
"ZH": "最近 15 分钟",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Last hour": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Последний час",
|
||||
"ZH": "最近 1 小时",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Last 6 hours": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Последние 6 часов",
|
||||
"ZH": "最近 6 小时",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Last 24 hours": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Последние 24 часа",
|
||||
"ZH": "最近 24 小时",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Auto": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "Авто",
|
||||
"ZH": "自动",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"2 seconds": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "2 секунды",
|
||||
"ZH": "2 秒",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"10 seconds": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "10 секунд",
|
||||
"ZH": "10 秒",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"1 minute": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "1 минута",
|
||||
"ZH": "1 分钟",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"5 minutes": {
|
||||
"source": [
|
||||
"Furious.Window.MetricsPage"
|
||||
],
|
||||
"RU": "5 минут",
|
||||
"ZH": "5 分钟",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Now": {
|
||||
"source": [
|
||||
"Furious.Widget.MetricsGraph"
|
||||
],
|
||||
"RU": "Сейчас",
|
||||
"ZH": "现在",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Waiting for network statistics": {
|
||||
"source": [
|
||||
"Furious.Widget.MetricsGraph"
|
||||
],
|
||||
"RU": "Ожидание сетевой статистики",
|
||||
"ZH": "正在等待网络统计数据",
|
||||
"isReviewed": "True"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,13 @@ class AppStyleSheet:
|
||||
'connection_disconnected': '#D94F64',
|
||||
'connection_connecting': '#3B82F6',
|
||||
'connection_connected': '#159A70',
|
||||
# Metrics visualization semantics.
|
||||
'metrics_download': '#0F8F87',
|
||||
'metrics_download_fill': '#BFEDE7',
|
||||
'metrics_upload': '#D9772F',
|
||||
'metrics_upload_fill': '#F8D7BB',
|
||||
'metrics_grid': '#D9E1EC',
|
||||
'metrics_axis': '#5E6B80',
|
||||
},
|
||||
Dark: {
|
||||
# Application surfaces and interaction states.
|
||||
@@ -155,6 +162,13 @@ class AppStyleSheet:
|
||||
'connection_disconnected': '#FF6B7A',
|
||||
'connection_connecting': '#5B9BFF',
|
||||
'connection_connected': '#42D39A',
|
||||
# Metrics visualization semantics.
|
||||
'metrics_download': '#36D6C5',
|
||||
'metrics_download_fill': '#174C47',
|
||||
'metrics_upload': '#FF9A52',
|
||||
'metrics_upload_fill': '#57321F',
|
||||
'metrics_grid': '#2A3340',
|
||||
'metrics_axis': '#9AA7B8',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -307,6 +321,46 @@ class AppStyleSheet:
|
||||
background-color: {palette['window']};
|
||||
}}
|
||||
|
||||
QScrollArea#MetricsScrollArea,
|
||||
QWidget#MetricsPageContent {{
|
||||
border: none;
|
||||
background-color: {palette['window']};
|
||||
}}
|
||||
|
||||
QLabel#MetricsPageTitle {{
|
||||
color: {palette['text_strong']};
|
||||
font-size: 16pt;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
QFrame#MetricsSection {{
|
||||
border: 1px solid {palette['border']};
|
||||
border-radius: 10px;
|
||||
background-color: {palette['panel']};
|
||||
}}
|
||||
|
||||
QLabel#MetricsSectionTitle {{
|
||||
color: {palette['text_strong']};
|
||||
font-size: 12pt;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
QFrame#MetricCard {{
|
||||
border: 1px solid {palette['border']};
|
||||
border-radius: 8px;
|
||||
background-color: {palette['panel_alt']};
|
||||
}}
|
||||
|
||||
QLabel#MetricCardTitle {{
|
||||
color: {palette['muted']};
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
QWidget#MetricsGraphWidget {{
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QGroupBox {{
|
||||
margin-top: 16px;
|
||||
padding: 14px 12px 12px 12px;
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Store and aggregate timestamped application metrics independently of UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Service.TrafficStatsManager import TrafficStatsSample
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
__all__ = [
|
||||
'DOWNLOAD_SPEED_METRIC',
|
||||
'DOWNLOAD_USAGE_METRIC',
|
||||
'MetricPoint',
|
||||
'MetricSample',
|
||||
'MetricsDataManager',
|
||||
'UPLOAD_SPEED_METRIC',
|
||||
'UPLOAD_USAGE_METRIC',
|
||||
]
|
||||
|
||||
DOWNLOAD_SPEED_METRIC = 'network.download.speed'
|
||||
DOWNLOAD_USAGE_METRIC = 'network.download.usage'
|
||||
UPLOAD_SPEED_METRIC = 'network.upload.speed'
|
||||
UPLOAD_USAGE_METRIC = 'network.upload.usage'
|
||||
|
||||
MEAN_AGGREGATION = 'mean'
|
||||
LAST_AGGREGATION = 'last'
|
||||
SUPPORTED_AGGREGATIONS = (MEAN_AGGREGATION, LAST_AGGREGATION)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricSample:
|
||||
"""Store one timestamp and a generic set of metric values."""
|
||||
|
||||
sampledAt: float
|
||||
values: Mapping[str, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetricPoint:
|
||||
"""Represent one graph-ready aggregated metric value."""
|
||||
|
||||
sampledAt: float
|
||||
value: float
|
||||
|
||||
|
||||
class MetricsDataManager(QtCore.QObject):
|
||||
"""Maintain bounded metric history and aggregate it for consumers."""
|
||||
|
||||
historyChanged = QtCore.Signal()
|
||||
|
||||
MaximumHistorySeconds = 24 * 60 * 60
|
||||
AutoBucketTarget = 120
|
||||
|
||||
def __init__(self, parent=None, *, maximumHistorySeconds=None):
|
||||
"""Initialize generic metric definitions and bounded sample storage."""
|
||||
super().__init__(parent)
|
||||
|
||||
self._maximumHistorySeconds = max(
|
||||
float(maximumHistorySeconds or self.MaximumHistorySeconds),
|
||||
1.0,
|
||||
)
|
||||
self._samples = deque()
|
||||
self._aggregations = {}
|
||||
|
||||
self.registerMetric(DOWNLOAD_SPEED_METRIC, MEAN_AGGREGATION)
|
||||
self.registerMetric(DOWNLOAD_USAGE_METRIC, LAST_AGGREGATION)
|
||||
self.registerMetric(UPLOAD_SPEED_METRIC, MEAN_AGGREGATION)
|
||||
self.registerMetric(UPLOAD_USAGE_METRIC, LAST_AGGREGATION)
|
||||
|
||||
def registerMetric(self, metricKey: str, aggregation=MEAN_AGGREGATION):
|
||||
"""Register a metric and its bucket aggregation strategy."""
|
||||
if not isinstance(metricKey, str) or not metricKey:
|
||||
raise ValueError('metricKey must be a non-empty string')
|
||||
|
||||
if aggregation not in SUPPORTED_AGGREGATIONS:
|
||||
raise ValueError(f'unsupported metric aggregation: {aggregation}')
|
||||
|
||||
self._aggregations[metricKey] = aggregation
|
||||
|
||||
def metricKeys(self) -> tuple[str, ...]:
|
||||
"""Return all metrics currently understood by the manager."""
|
||||
return tuple(self._aggregations)
|
||||
|
||||
def sampleCount(self) -> int:
|
||||
"""Return the number of raw samples retained in memory."""
|
||||
return len(self._samples)
|
||||
|
||||
def clearHistory(self):
|
||||
"""Discard all retained metrics and notify interested consumers."""
|
||||
if not self._samples:
|
||||
return
|
||||
|
||||
self._samples.clear()
|
||||
self.historyChanged.emit()
|
||||
|
||||
@QtCore.Slot(object)
|
||||
def recordTrafficSample(self, sample):
|
||||
"""Convert one provider-facing traffic sample into generic metrics."""
|
||||
if not isinstance(sample, TrafficStatsSample):
|
||||
return
|
||||
|
||||
self.recordSample(
|
||||
{
|
||||
DOWNLOAD_SPEED_METRIC: sample.downloadSpeed,
|
||||
DOWNLOAD_USAGE_METRIC: sample.downloadUsage,
|
||||
UPLOAD_SPEED_METRIC: sample.uploadSpeed,
|
||||
UPLOAD_USAGE_METRIC: sample.uploadUsage,
|
||||
},
|
||||
sample.sampledAt,
|
||||
)
|
||||
|
||||
def recordSample(self, values: Mapping[str, float], sampledAt=None):
|
||||
"""Record finite values for registered metrics at one timestamp."""
|
||||
if not isinstance(values, Mapping):
|
||||
raise TypeError('values must be a mapping')
|
||||
|
||||
timestamp = time.monotonic() if sampledAt is None else float(sampledAt)
|
||||
|
||||
if not math.isfinite(timestamp):
|
||||
raise ValueError('sampledAt must be finite')
|
||||
|
||||
normalizedValues = {}
|
||||
|
||||
for metricKey, value in values.items():
|
||||
if metricKey not in self._aggregations:
|
||||
continue
|
||||
|
||||
try:
|
||||
normalizedValue = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
|
||||
if math.isfinite(normalizedValue):
|
||||
normalizedValues[metricKey] = max(normalizedValue, 0.0)
|
||||
|
||||
if not normalizedValues:
|
||||
return
|
||||
|
||||
if self._samples and timestamp < self._samples[-1].sampledAt:
|
||||
timestamp = self._samples[-1].sampledAt
|
||||
|
||||
self._samples.append(MetricSample(timestamp, normalizedValues))
|
||||
self._pruneHistory(timestamp)
|
||||
self.historyChanged.emit()
|
||||
|
||||
def _pruneHistory(self, now: float):
|
||||
"""Remove samples older than the configured in-memory history."""
|
||||
oldestAllowed = now - self._maximumHistorySeconds
|
||||
|
||||
while self._samples and self._samples[0].sampledAt < oldestAllowed:
|
||||
self._samples.popleft()
|
||||
|
||||
def effectiveGranularity(self, rangeSeconds, granularitySeconds=0) -> float:
|
||||
"""Return an explicit or automatically selected bucket duration."""
|
||||
rangeSeconds = max(float(rangeSeconds), 1.0)
|
||||
granularitySeconds = float(granularitySeconds or 0)
|
||||
|
||||
if granularitySeconds > 0:
|
||||
return min(granularitySeconds, rangeSeconds)
|
||||
|
||||
return max(rangeSeconds / self.AutoBucketTarget, 1.0)
|
||||
|
||||
def series(
|
||||
self,
|
||||
metricKey: str,
|
||||
rangeSeconds: float,
|
||||
granularitySeconds=0,
|
||||
*,
|
||||
now=None,
|
||||
) -> tuple[MetricPoint, ...]:
|
||||
"""Return graph-ready values aggregated into time buckets."""
|
||||
aggregation = self._aggregations.get(metricKey)
|
||||
|
||||
if aggregation is None:
|
||||
raise KeyError(f'unknown metric: {metricKey}')
|
||||
|
||||
if not self._samples:
|
||||
return tuple()
|
||||
|
||||
rangeSeconds = max(float(rangeSeconds), 1.0)
|
||||
currentTime = time.monotonic() if now is None else float(now)
|
||||
startTime = currentTime - rangeSeconds
|
||||
granularity = self.effectiveGranularity(
|
||||
rangeSeconds,
|
||||
granularitySeconds,
|
||||
)
|
||||
buckets = {}
|
||||
|
||||
for sample in self._samples:
|
||||
if sample.sampledAt < startTime or sample.sampledAt > currentTime:
|
||||
continue
|
||||
|
||||
if metricKey not in sample.values:
|
||||
continue
|
||||
|
||||
bucketIndex = int((sample.sampledAt - startTime) / granularity)
|
||||
bucket = buckets.setdefault(bucketIndex, [])
|
||||
bucket.append(float(sample.values[metricKey]))
|
||||
|
||||
points = []
|
||||
|
||||
for bucketIndex, values in sorted(buckets.items()):
|
||||
if aggregation == LAST_AGGREGATION:
|
||||
value = values[-1]
|
||||
else:
|
||||
value = sum(values) / len(values)
|
||||
|
||||
bucketEnd = min(
|
||||
startTime + ((bucketIndex + 1) * granularity),
|
||||
currentTime,
|
||||
)
|
||||
points.append(MetricPoint(bucketEnd, value))
|
||||
|
||||
return tuple(points)
|
||||
@@ -25,11 +25,13 @@ from Furious.Plugins import TrafficCounters, getPluginRegistry
|
||||
from PySide6 import QtCore
|
||||
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
__all__ = [
|
||||
'TrafficStatsSample',
|
||||
'TrafficStatsManager',
|
||||
'formatTrafficSpeed',
|
||||
'formatTrafficUsage',
|
||||
@@ -42,6 +44,17 @@ MEBIBYTE = KIBIBYTE * KIBIBYTE
|
||||
TRAFFIC_STATS_SAMPLE_INTERVAL = 2000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrafficStatsSample:
|
||||
"""Describe one timestamped traffic-rate and counter observation."""
|
||||
|
||||
sampledAt: float
|
||||
uploadSpeed: float
|
||||
downloadSpeed: float
|
||||
uploadUsage: int
|
||||
downloadUsage: int
|
||||
|
||||
|
||||
def formatTrafficSpeed(bytesPerSecond: float) -> str:
|
||||
"""Format a byte rate using compact binary units."""
|
||||
try:
|
||||
@@ -105,10 +118,11 @@ class TrafficStatsManager(
|
||||
Mixins.CleanupOnExit,
|
||||
QtCore.QObject,
|
||||
):
|
||||
"""Publish plugin traffic usage and rates while its parent is visible."""
|
||||
"""Publish plugin traffic usage and rates independently from presentation."""
|
||||
|
||||
speedChanged = QtCore.Signal(float, float)
|
||||
usageChanged = QtCore.Signal(object, object)
|
||||
sampleChanged = QtCore.Signal(object)
|
||||
statisticsUnavailable = QtCore.Signal()
|
||||
_sampleReady = QtCore.Signal(int, object, float)
|
||||
|
||||
@@ -129,32 +143,9 @@ class TrafficStatsManager(
|
||||
self._sampleTimer.timeout.connect(self._requestSample)
|
||||
self._sampleReady.connect(self._consumeResult)
|
||||
|
||||
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._queryInFlight
|
||||
self._sampleTimer.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():
|
||||
"""Resume polling whenever a statistics monitor is available."""
|
||||
if self._monitor is None:
|
||||
return
|
||||
|
||||
if not self._ensureExecutor():
|
||||
@@ -165,29 +156,6 @@ class TrafficStatsManager(
|
||||
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."""
|
||||
@@ -267,7 +235,7 @@ class TrafficStatsManager(
|
||||
@QtCore.Slot()
|
||||
def _requestSample(self):
|
||||
"""Queue one statistics query unless another query is still running."""
|
||||
if self._monitor is None or self._queryInFlight or not self._parentIsVisible():
|
||||
if self._monitor is None or self._queryInFlight:
|
||||
return
|
||||
|
||||
if not self._ensureExecutor():
|
||||
@@ -305,30 +273,32 @@ class TrafficStatsManager(
|
||||
self._previousSampleTime = sampledAt
|
||||
|
||||
if previousCounters is None or previousSampleTime is None:
|
||||
self.speedChanged.emit(0.0, 0.0)
|
||||
uploadSpeed, downloadSpeed = 0.0, 0.0
|
||||
else:
|
||||
elapsed = sampledAt - previousSampleTime
|
||||
|
||||
return
|
||||
if elapsed <= 0:
|
||||
uploadSpeed, downloadSpeed = 0.0, 0.0
|
||||
else:
|
||||
uplinkDelta = max(counters.uplink - previousCounters.uplink, 0)
|
||||
downlinkDelta = max(counters.downlink - previousCounters.downlink, 0)
|
||||
uploadSpeed = uplinkDelta / elapsed
|
||||
downloadSpeed = downlinkDelta / elapsed
|
||||
|
||||
elapsed = sampledAt - previousSampleTime
|
||||
|
||||
if elapsed <= 0:
|
||||
self.speedChanged.emit(0.0, 0.0)
|
||||
|
||||
return
|
||||
|
||||
uplinkDelta = max(counters.uplink - previousCounters.uplink, 0)
|
||||
downlinkDelta = max(counters.downlink - previousCounters.downlink, 0)
|
||||
|
||||
self.speedChanged.emit(uplinkDelta / elapsed, downlinkDelta / elapsed)
|
||||
self.speedChanged.emit(uploadSpeed, downloadSpeed)
|
||||
self.sampleChanged.emit(
|
||||
TrafficStatsSample(
|
||||
sampledAt=sampledAt,
|
||||
uploadSpeed=uploadSpeed,
|
||||
downloadSpeed=downloadSpeed,
|
||||
uploadUsage=counters.uplink,
|
||||
downloadUsage=counters.downlink,
|
||||
)
|
||||
)
|
||||
|
||||
@QtCore.Slot(int, object, float)
|
||||
def _consumeResult(self, generation, counters, sampledAt):
|
||||
"""Consume one worker result on Qt's GUI thread."""
|
||||
if not self._parentIsVisible():
|
||||
self._suspendSampling()
|
||||
|
||||
return
|
||||
|
||||
if generation != self._generation:
|
||||
return
|
||||
|
||||
|
||||
@@ -32,12 +32,22 @@ from .LogManager import (
|
||||
coreLogCallback,
|
||||
formatLogEntry,
|
||||
)
|
||||
from .MetricsDataManager import (
|
||||
DOWNLOAD_SPEED_METRIC,
|
||||
DOWNLOAD_USAGE_METRIC,
|
||||
UPLOAD_SPEED_METRIC,
|
||||
UPLOAD_USAGE_METRIC,
|
||||
MetricPoint,
|
||||
MetricSample,
|
||||
MetricsDataManager,
|
||||
)
|
||||
from .SubscriptionImporter import (
|
||||
SubscriptionImportResult,
|
||||
SubscriptionImportService,
|
||||
SubscriptionSource,
|
||||
)
|
||||
from .TrafficStatsManager import (
|
||||
TrafficStatsSample,
|
||||
TrafficStatsManager,
|
||||
formatTrafficSpeed,
|
||||
formatTrafficUsage,
|
||||
@@ -54,10 +64,18 @@ __all__ = [
|
||||
'TUN2SOCKS_LOG_CATEGORY',
|
||||
'ApplicationLogHandler',
|
||||
'LogManager',
|
||||
'DOWNLOAD_SPEED_METRIC',
|
||||
'DOWNLOAD_USAGE_METRIC',
|
||||
'UPLOAD_SPEED_METRIC',
|
||||
'UPLOAD_USAGE_METRIC',
|
||||
'MetricPoint',
|
||||
'MetricSample',
|
||||
'MetricsDataManager',
|
||||
'coreLogCallback',
|
||||
'SubscriptionImportResult',
|
||||
'SubscriptionImportService',
|
||||
'SubscriptionSource',
|
||||
'TrafficStatsSample',
|
||||
'TrafficStatsManager',
|
||||
'UpdateManager',
|
||||
'formatTrafficSpeed',
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Render lightweight time-series metrics with Qt's painting primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import APP
|
||||
from Furious.Qt import AppStyleSheet
|
||||
from Furious.Qt import gettext as _
|
||||
from Furious.Service.MetricsDataManager import MetricPoint
|
||||
|
||||
from PySide6 import QtCore, QtGui
|
||||
from PySide6.QtWidgets import QSizePolicy, QWidget
|
||||
|
||||
from typing import Callable, Iterable
|
||||
|
||||
__all__ = ['MetricsGraphWidget']
|
||||
|
||||
|
||||
class MetricsGraphWidget(QWidget):
|
||||
"""Paint one prepared metric series without owning collection logic."""
|
||||
|
||||
Download = 'download'
|
||||
Upload = 'upload'
|
||||
Directions = (Download, Upload)
|
||||
|
||||
LeftMargin = 82
|
||||
TopMargin = 16
|
||||
RightMargin = 16
|
||||
BottomMargin = 30
|
||||
HorizontalGridLines = 4
|
||||
VerticalGridLines = 4
|
||||
|
||||
def __init__(self, direction: str, valueFormatter: Callable, parent=None):
|
||||
"""Initialize an empty graph for one traffic direction."""
|
||||
super().__init__(parent)
|
||||
|
||||
if direction not in self.Directions:
|
||||
raise ValueError(f'unsupported metrics direction: {direction}')
|
||||
|
||||
if not callable(valueFormatter):
|
||||
raise TypeError('valueFormatter must be callable')
|
||||
|
||||
self._direction = direction
|
||||
self._valueFormatter = valueFormatter
|
||||
self._points = tuple()
|
||||
self._rangeSeconds = 300.0
|
||||
self._currentTime = 0.0
|
||||
|
||||
self.setObjectName('MetricsGraphWidget')
|
||||
self.setMinimumSize(340, 220)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Expanding,
|
||||
)
|
||||
|
||||
def setSeries(
|
||||
self,
|
||||
points: Iterable[MetricPoint],
|
||||
rangeSeconds: float,
|
||||
currentTime: float,
|
||||
):
|
||||
"""Replace the prepared series and schedule one repaint."""
|
||||
self._points = tuple(
|
||||
point for point in points if isinstance(point, MetricPoint)
|
||||
)
|
||||
self._rangeSeconds = max(float(rangeSeconds), 1.0)
|
||||
self._currentTime = float(currentTime)
|
||||
self.update()
|
||||
|
||||
def clear(self):
|
||||
"""Discard displayed points and schedule one repaint."""
|
||||
if not self._points:
|
||||
return
|
||||
|
||||
self._points = tuple()
|
||||
self.update()
|
||||
|
||||
def _palette(self):
|
||||
"""Return the current application palette for custom painting."""
|
||||
return AppStyleSheet.paletteForTheme(APP().theme())
|
||||
|
||||
def _lineColor(self, palette):
|
||||
"""Return the semantic line color for this traffic direction."""
|
||||
return QtGui.QColor(palette[f'metrics_{self._direction}'])
|
||||
|
||||
def _fillColor(self, palette):
|
||||
"""Return the semantic area-fill color for this traffic direction."""
|
||||
return QtGui.QColor(palette[f'metrics_{self._direction}_fill'])
|
||||
|
||||
@staticmethod
|
||||
def _relativeTimeLabel(seconds: float) -> str:
|
||||
"""Return a compact negative offset label for the horizontal axis."""
|
||||
seconds = max(float(seconds), 0.0)
|
||||
|
||||
if seconds >= 3600:
|
||||
hours = seconds / 3600
|
||||
value = f'{hours:.1f}'.rstrip('0').rstrip('.')
|
||||
|
||||
return f'-{value}h'
|
||||
|
||||
if seconds >= 60:
|
||||
minutes = seconds / 60
|
||||
value = f'{minutes:.1f}'.rstrip('0').rstrip('.')
|
||||
|
||||
return f'-{value}m'
|
||||
|
||||
return f'-{int(round(seconds))}s'
|
||||
|
||||
def _chartRect(self) -> QtCore.QRectF:
|
||||
"""Return the drawable plot rectangle inside axis-label margins."""
|
||||
return QtCore.QRectF(
|
||||
self.LeftMargin,
|
||||
self.TopMargin,
|
||||
max(self.width() - self.LeftMargin - self.RightMargin, 1),
|
||||
max(self.height() - self.TopMargin - self.BottomMargin, 1),
|
||||
)
|
||||
|
||||
def _drawGrid(self, painter, chartRect, palette, maximumValue):
|
||||
"""Draw subtle Fluent-style grid lines and compact axis labels."""
|
||||
gridPen = QtGui.QPen(QtGui.QColor(palette['metrics_grid']))
|
||||
gridPen.setWidthF(1.0)
|
||||
|
||||
painter.setPen(gridPen)
|
||||
|
||||
labelColor = QtGui.QColor(palette['metrics_axis'])
|
||||
|
||||
labelFont = QtGui.QFont(self.font())
|
||||
labelFont.setPointSizeF(max(labelFont.pointSizeF() - 1, 7))
|
||||
|
||||
painter.setFont(labelFont)
|
||||
|
||||
for index in range(self.HorizontalGridLines + 1):
|
||||
ratio = index / self.HorizontalGridLines
|
||||
y = chartRect.bottom() - (ratio * chartRect.height())
|
||||
|
||||
painter.drawLine(
|
||||
QtCore.QPointF(chartRect.left(), y),
|
||||
QtCore.QPointF(chartRect.right(), y),
|
||||
)
|
||||
|
||||
value = maximumValue * ratio
|
||||
labelRect = QtCore.QRectF(
|
||||
0,
|
||||
y - 10,
|
||||
self.LeftMargin - 8,
|
||||
20,
|
||||
)
|
||||
|
||||
painter.setPen(labelColor)
|
||||
painter.drawText(
|
||||
labelRect,
|
||||
QtCore.Qt.AlignmentFlag.AlignRight
|
||||
| QtCore.Qt.AlignmentFlag.AlignVCenter,
|
||||
self._valueFormatter(value),
|
||||
)
|
||||
painter.setPen(gridPen)
|
||||
|
||||
for index in range(self.VerticalGridLines + 1):
|
||||
ratio = index / self.VerticalGridLines
|
||||
x = chartRect.left() + (ratio * chartRect.width())
|
||||
|
||||
painter.drawLine(
|
||||
QtCore.QPointF(x, chartRect.top()),
|
||||
QtCore.QPointF(x, chartRect.bottom()),
|
||||
)
|
||||
|
||||
if index == self.VerticalGridLines:
|
||||
label = _('Now')
|
||||
else:
|
||||
label = self._relativeTimeLabel(self._rangeSeconds * (1.0 - ratio))
|
||||
|
||||
labelRect = QtCore.QRectF(
|
||||
x - 40,
|
||||
chartRect.bottom() + 5,
|
||||
80,
|
||||
self.BottomMargin - 5,
|
||||
)
|
||||
|
||||
painter.setPen(labelColor)
|
||||
painter.drawText(
|
||||
labelRect,
|
||||
QtCore.Qt.AlignmentFlag.AlignHCenter | QtCore.Qt.AlignmentFlag.AlignTop,
|
||||
label,
|
||||
)
|
||||
painter.setPen(gridPen)
|
||||
|
||||
def _drawEmptyState(self, painter, chartRect, palette):
|
||||
"""Describe why a graph has no line without fabricating data."""
|
||||
painter.setPen(QtGui.QColor(palette['metrics_axis']))
|
||||
painter.drawText(
|
||||
chartRect,
|
||||
QtCore.Qt.AlignmentFlag.AlignCenter,
|
||||
_('Waiting for network statistics'),
|
||||
)
|
||||
|
||||
def _graphPath(self, chartRect, maximumValue):
|
||||
"""Convert prepared metric points into a painter path."""
|
||||
startTime = self._currentTime - self._rangeSeconds
|
||||
path = QtGui.QPainterPath()
|
||||
|
||||
for index, point in enumerate(self._points):
|
||||
xRatio = min(
|
||||
max((point.sampledAt - startTime) / self._rangeSeconds, 0.0),
|
||||
1.0,
|
||||
)
|
||||
yRatio = min(max(point.value / maximumValue, 0.0), 1.0)
|
||||
pointPosition = QtCore.QPointF(
|
||||
chartRect.left() + (xRatio * chartRect.width()),
|
||||
chartRect.bottom() - (yRatio * chartRect.height()),
|
||||
)
|
||||
|
||||
if index == 0:
|
||||
path.moveTo(pointPosition)
|
||||
else:
|
||||
path.lineTo(pointPosition)
|
||||
|
||||
return path
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Paint axes, an optional area fill, and the current metric line."""
|
||||
super().paintEvent(event)
|
||||
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
|
||||
|
||||
palette = self._palette()
|
||||
chartRect = self._chartRect()
|
||||
maximumValue = max(
|
||||
(point.value for point in self._points),
|
||||
default=0.0,
|
||||
)
|
||||
maximumValue = max(maximumValue * 1.1, 1.0)
|
||||
|
||||
self._drawGrid(painter, chartRect, palette, maximumValue)
|
||||
|
||||
if not self._points:
|
||||
self._drawEmptyState(painter, chartRect, palette)
|
||||
painter.end()
|
||||
|
||||
return
|
||||
|
||||
path = self._graphPath(chartRect, maximumValue)
|
||||
|
||||
fillPath = QtGui.QPainterPath(path)
|
||||
fillPath.lineTo(path.currentPosition().x(), chartRect.bottom())
|
||||
fillPath.lineTo(path.elementAt(0).x, chartRect.bottom())
|
||||
fillPath.closeSubpath()
|
||||
|
||||
fillColor = self._fillColor(palette)
|
||||
|
||||
transparentFill = QtGui.QColor(fillColor)
|
||||
transparentFill.setAlpha(0)
|
||||
gradient = QtGui.QLinearGradient(
|
||||
chartRect.topLeft(),
|
||||
chartRect.bottomLeft(),
|
||||
)
|
||||
|
||||
gradient.setColorAt(0.0, fillColor)
|
||||
gradient.setColorAt(1.0, transparentFill)
|
||||
|
||||
painter.fillPath(fillPath, gradient)
|
||||
|
||||
linePen = QtGui.QPen(self._lineColor(palette))
|
||||
linePen.setWidthF(2.25)
|
||||
linePen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
||||
linePen.setJoinStyle(QtCore.Qt.PenJoinStyle.RoundJoin)
|
||||
|
||||
painter.setPen(linePen)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setBrush(self._lineColor(palette))
|
||||
painter.setPen(QtCore.Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(path.currentPosition(), 3.5, 3.5)
|
||||
painter.end()
|
||||
@@ -20,6 +20,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .ConnectionProgressWidget import ConnectionProgressWidget
|
||||
from .MetricsGraph import MetricsGraphWidget
|
||||
from .NavigationView import NavigationView
|
||||
from .ServerTableView import ServerTableView
|
||||
from .SubscriptionTableView import SubscriptionTableView
|
||||
@@ -27,6 +28,7 @@ from .WaitingSpinner import WaitingSpinner
|
||||
|
||||
__all__ = [
|
||||
'ConnectionProgressWidget',
|
||||
'MetricsGraphWidget',
|
||||
'NavigationView',
|
||||
'ServerTableView',
|
||||
'SubscriptionTableView',
|
||||
|
||||
@@ -267,12 +267,12 @@ class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
|
||||
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.uploadIconLabel = QLabel(parent=self)
|
||||
self.uploadTextLabel = AppQLabel(translatable=False, parent=self)
|
||||
self.uploadUsageLabel = AppQLabel(translatable=False, parent=self)
|
||||
|
||||
self.uploadUsageLabel.setObjectName('TrafficUsageLabel')
|
||||
self.downloadUsageLabel.setObjectName('TrafficUsageLabel')
|
||||
@@ -280,13 +280,13 @@ class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
|
||||
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._layout.addSpacing(4)
|
||||
self._layout.addWidget(self.uploadIconLabel)
|
||||
self._layout.addWidget(self.uploadTextLabel)
|
||||
self._layout.addWidget(self.uploadUsageLabel)
|
||||
|
||||
self.setIconByTheme(APP().theme())
|
||||
self.updateToolTips()
|
||||
|
||||
@@ -23,9 +23,11 @@ from Furious.Frozenlib import *
|
||||
from Furious.Models import ConfigFactory, ServerProfile
|
||||
from Furious.Qt import *
|
||||
from Furious.Qt import gettext as _
|
||||
from Furious.Service import MetricsDataManager
|
||||
from Furious.Widget.NavigationView import NavigationView
|
||||
from Furious.Window.HomePage import HomePage
|
||||
from Furious.Window.LogPage import LogPage
|
||||
from Furious.Window.MetricsPage import MetricsPage
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
@@ -44,7 +46,7 @@ registerAppSettings('AppMainWindowState')
|
||||
registerAppSettings('AppMainWindowSelectedPage', default='home')
|
||||
registerAppSettings('AppMainWindowNavigationExpanded', isBinary=True)
|
||||
|
||||
_TRANSLATABLE_NAVIGATION_LABELS = (_('Home'), _('Log'))
|
||||
_TRANSLATABLE_NAVIGATION_LABELS = (_('Home'), _('Metrics'), _('Log'))
|
||||
|
||||
|
||||
class MainWindow(AppQMainWindow):
|
||||
@@ -64,6 +66,14 @@ class MainWindow(AppQMainWindow):
|
||||
|
||||
self.navigationView = NavigationView(parent=self)
|
||||
self.homePage = HomePage(parent=self.navigationView)
|
||||
self.metricsDataManager = MetricsDataManager(parent=self)
|
||||
self.homePage.trafficStatsManager.sampleChanged.connect(
|
||||
self.metricsDataManager.recordTrafficSample
|
||||
)
|
||||
self.metricsPage = MetricsPage(
|
||||
self.metricsDataManager,
|
||||
parent=self.navigationView,
|
||||
)
|
||||
self.logPage = AppLogPage()
|
||||
|
||||
if not isinstance(self.logPage, LogPage):
|
||||
@@ -75,6 +85,12 @@ class MainWindow(AppQMainWindow):
|
||||
'Home',
|
||||
'house-door.svg',
|
||||
)
|
||||
self.navigationView.addPage(
|
||||
'metrics',
|
||||
self.metricsPage,
|
||||
'Metrics',
|
||||
'speedometer2.svg',
|
||||
)
|
||||
self.navigationView.addPage(
|
||||
'log',
|
||||
self.logPage,
|
||||
@@ -192,14 +208,16 @@ class MainWindow(AppQMainWindow):
|
||||
|
||||
if PLATFORM == 'Darwin':
|
||||
APP().processEvents()
|
||||
|
||||
size = self.size()
|
||||
|
||||
if size == QtCore.QSize(640, 480):
|
||||
logger.error(
|
||||
f'detected unresolve Qt bug on macOS. '
|
||||
f'detected unresolved Qt bug on macOS. '
|
||||
f'Resizing main window to default '
|
||||
f'{self.DEFAULT_WINDOW_SIZE.toTuple()}'
|
||||
)
|
||||
|
||||
self.resize(self.DEFAULT_WINDOW_SIZE)
|
||||
else:
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||||
#
|
||||
# This file is part of Furious.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""Present lazily rendered historical network metrics as an application page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from Furious.Frozenlib import Mixins
|
||||
from Furious.Qt import AppQLabel
|
||||
from Furious.Qt import gettext as _
|
||||
from Furious.Service import (
|
||||
DOWNLOAD_SPEED_METRIC,
|
||||
DOWNLOAD_USAGE_METRIC,
|
||||
UPLOAD_SPEED_METRIC,
|
||||
UPLOAD_USAGE_METRIC,
|
||||
MetricsDataManager,
|
||||
formatTrafficSpeed,
|
||||
formatTrafficUsage,
|
||||
)
|
||||
from Furious.Widget.MetricsGraph import MetricsGraphWidget
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtWidgets import *
|
||||
|
||||
import time
|
||||
|
||||
__all__ = ['MetricsPage']
|
||||
|
||||
|
||||
class _MetricCard(QFrame):
|
||||
"""Frame one graph with a concise metric title."""
|
||||
|
||||
def __init__(self, graph: MetricsGraphWidget, parent=None):
|
||||
"""Initialize one graph card."""
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName('MetricCard')
|
||||
self.titleLabel = AppQLabel(translatable=False, parent=self)
|
||||
self.titleLabel.setObjectName('MetricCardTitle')
|
||||
self.graph = graph
|
||||
self.graph.setParent(self)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(self.titleLabel)
|
||||
layout.addWidget(self.graph, 1)
|
||||
|
||||
def setTitle(self, title: str):
|
||||
"""Set the already translated metric title."""
|
||||
self.titleLabel.setText(title)
|
||||
|
||||
|
||||
class _MetricsSection(QFrame):
|
||||
"""Group speed and traffic-usage graphs for one direction."""
|
||||
|
||||
def __init__(self, speedGraph, usageGraph, parent=None):
|
||||
"""Initialize a two-card metrics section."""
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName('MetricsSection')
|
||||
self.titleLabel = AppQLabel(translatable=False, parent=self)
|
||||
self.titleLabel.setObjectName('MetricsSectionTitle')
|
||||
self.speedCard = _MetricCard(speedGraph, parent=self)
|
||||
self.usageCard = _MetricCard(usageGraph, parent=self)
|
||||
|
||||
graphLayout = QHBoxLayout()
|
||||
graphLayout.setContentsMargins(0, 0, 0, 0)
|
||||
graphLayout.setSpacing(12)
|
||||
graphLayout.addWidget(self.speedCard, 1)
|
||||
graphLayout.addWidget(self.usageCard, 1)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 14, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
layout.addWidget(self.titleLabel)
|
||||
layout.addLayout(graphLayout, 1)
|
||||
|
||||
def setTitles(self, sectionTitle, speedTitle, usageTitle):
|
||||
"""Set translated section and graph-card titles."""
|
||||
self.titleLabel.setText(sectionTitle)
|
||||
self.speedCard.setTitle(speedTitle)
|
||||
self.usageCard.setTitle(usageTitle)
|
||||
|
||||
|
||||
class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
|
||||
"""Coordinate time controls and visible-only graph rendering."""
|
||||
|
||||
RenderDelay = 100
|
||||
DefaultTimeRange = 15 * 60
|
||||
DefaultGranularity = 0
|
||||
|
||||
def __init__(self, manager: MetricsDataManager, parent=None):
|
||||
"""Initialize the network metrics page around a data-only manager."""
|
||||
super().__init__(parent)
|
||||
|
||||
if not isinstance(manager, MetricsDataManager):
|
||||
raise TypeError('manager must be a MetricsDataManager')
|
||||
|
||||
self.setObjectName('MetricsPage')
|
||||
self.manager = manager
|
||||
self._dirty = True
|
||||
self._renderRevision = 0
|
||||
|
||||
self.pageTitleLabel = AppQLabel(translatable=False)
|
||||
self.pageTitleLabel.setObjectName('MetricsPageTitle')
|
||||
self.timeRangeLabel = AppQLabel(translatable=False)
|
||||
self.granularityLabel = AppQLabel(translatable=False)
|
||||
self.timeRangeComboBox = QComboBox()
|
||||
self.granularityComboBox = QComboBox()
|
||||
self.timeRangeComboBox.setMinimumWidth(160)
|
||||
self.granularityComboBox.setMinimumWidth(130)
|
||||
|
||||
self.downloadSpeedGraph = MetricsGraphWidget(
|
||||
MetricsGraphWidget.Download,
|
||||
formatTrafficSpeed,
|
||||
)
|
||||
self.downloadUsageGraph = MetricsGraphWidget(
|
||||
MetricsGraphWidget.Download,
|
||||
formatTrafficUsage,
|
||||
)
|
||||
self.uploadSpeedGraph = MetricsGraphWidget(
|
||||
MetricsGraphWidget.Upload,
|
||||
formatTrafficSpeed,
|
||||
)
|
||||
self.uploadUsageGraph = MetricsGraphWidget(
|
||||
MetricsGraphWidget.Upload,
|
||||
formatTrafficUsage,
|
||||
)
|
||||
|
||||
self.downloadSection = _MetricsSection(
|
||||
self.downloadSpeedGraph,
|
||||
self.downloadUsageGraph,
|
||||
)
|
||||
self.uploadSection = _MetricsSection(
|
||||
self.uploadSpeedGraph,
|
||||
self.uploadUsageGraph,
|
||||
)
|
||||
|
||||
controlsLayout = QHBoxLayout()
|
||||
controlsLayout.setContentsMargins(0, 0, 0, 0)
|
||||
controlsLayout.setSpacing(8)
|
||||
controlsLayout.addWidget(self.pageTitleLabel)
|
||||
controlsLayout.addStretch(1)
|
||||
controlsLayout.addWidget(self.timeRangeLabel)
|
||||
controlsLayout.addWidget(self.timeRangeComboBox)
|
||||
controlsLayout.addSpacing(8)
|
||||
controlsLayout.addWidget(self.granularityLabel)
|
||||
controlsLayout.addWidget(self.granularityComboBox)
|
||||
|
||||
contentWidget = QWidget()
|
||||
contentWidget.setObjectName('MetricsPageContent')
|
||||
contentLayout = QVBoxLayout(contentWidget)
|
||||
contentLayout.setContentsMargins(20, 18, 20, 20)
|
||||
contentLayout.setSpacing(14)
|
||||
contentLayout.addLayout(controlsLayout)
|
||||
contentLayout.addWidget(self.downloadSection, 1)
|
||||
contentLayout.addWidget(self.uploadSection, 1)
|
||||
|
||||
self.scrollArea = QScrollArea()
|
||||
self.scrollArea.setObjectName('MetricsScrollArea')
|
||||
self.scrollArea.setWidgetResizable(True)
|
||||
self.scrollArea.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.scrollArea.setWidget(contentWidget)
|
||||
self.setCentralWidget(self.scrollArea)
|
||||
|
||||
self._renderTimer = QtCore.QTimer(self)
|
||||
self._renderTimer.setSingleShot(True)
|
||||
self._renderTimer.setInterval(self.RenderDelay)
|
||||
self._renderTimer.timeout.connect(self._renderLatest)
|
||||
|
||||
self.manager.historyChanged.connect(self._historyChanged)
|
||||
self.timeRangeComboBox.currentIndexChanged.connect(self._selectionChanged)
|
||||
self.granularityComboBox.currentIndexChanged.connect(self._selectionChanged)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
@staticmethod
|
||||
def _timeRangeOptions():
|
||||
"""Return selectable ranges in short-to-long order."""
|
||||
return (
|
||||
(5 * 60, _('Last 5 minutes')),
|
||||
(15 * 60, _('Last 15 minutes')),
|
||||
(60 * 60, _('Last hour')),
|
||||
(6 * 60 * 60, _('Last 6 hours')),
|
||||
(24 * 60 * 60, _('Last 24 hours')),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _granularityOptions():
|
||||
"""Return selectable aggregation granularities."""
|
||||
return (
|
||||
(0, _('Auto')),
|
||||
(2, _('2 seconds')),
|
||||
(10, _('10 seconds')),
|
||||
(60, _('1 minute')),
|
||||
(5 * 60, _('5 minutes')),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _populateComboBox(comboBox, options, selectedValue):
|
||||
"""Rebuild translated choices while preserving semantic data."""
|
||||
blocker = QtCore.QSignalBlocker(comboBox)
|
||||
|
||||
comboBox.clear()
|
||||
|
||||
for value, text in options:
|
||||
comboBox.addItem(text, value)
|
||||
|
||||
selectedIndex = comboBox.findData(selectedValue)
|
||||
|
||||
comboBox.setCurrentIndex(max(selectedIndex, 0))
|
||||
|
||||
del blocker
|
||||
|
||||
def _pageCanRender(self) -> bool:
|
||||
"""Return whether painting work can currently reach the screen."""
|
||||
window = self.window()
|
||||
|
||||
return self.isVisible() and not (
|
||||
hasattr(window, 'isMinimized') and window.isMinimized()
|
||||
)
|
||||
|
||||
def _scheduleRender(self):
|
||||
"""Coalesce visible history updates into one graph refresh."""
|
||||
if self._pageCanRender() and not self._renderTimer.isActive():
|
||||
self._renderTimer.start()
|
||||
|
||||
@QtCore.Slot()
|
||||
def _historyChanged(self):
|
||||
"""Mark graph data stale without touching hidden graph widgets."""
|
||||
self._dirty = True
|
||||
self._scheduleRender()
|
||||
|
||||
@QtCore.Slot()
|
||||
def _selectionChanged(self):
|
||||
"""Refresh visible graphs after range or granularity selection."""
|
||||
self._dirty = True
|
||||
self._scheduleRender()
|
||||
|
||||
@QtCore.Slot()
|
||||
def _renderLatest(self):
|
||||
"""Prepare and submit graph series only while the page is visible."""
|
||||
if not self._dirty or not self._pageCanRender():
|
||||
return
|
||||
|
||||
rangeSeconds = float(
|
||||
self.timeRangeComboBox.currentData() or self.DefaultTimeRange
|
||||
)
|
||||
granularity = float(
|
||||
self.granularityComboBox.currentData() or self.DefaultGranularity
|
||||
)
|
||||
currentTime = time.monotonic()
|
||||
graphMetrics = (
|
||||
(self.downloadSpeedGraph, DOWNLOAD_SPEED_METRIC),
|
||||
(self.downloadUsageGraph, DOWNLOAD_USAGE_METRIC),
|
||||
(self.uploadSpeedGraph, UPLOAD_SPEED_METRIC),
|
||||
(self.uploadUsageGraph, UPLOAD_USAGE_METRIC),
|
||||
)
|
||||
|
||||
for graph, metricKey in graphMetrics:
|
||||
graph.setSeries(
|
||||
self.manager.series(
|
||||
metricKey,
|
||||
rangeSeconds,
|
||||
granularity,
|
||||
now=currentTime,
|
||||
),
|
||||
rangeSeconds,
|
||||
currentTime,
|
||||
)
|
||||
|
||||
self._dirty = False
|
||||
self._renderRevision += 1
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Render the latest retained history when the page becomes visible."""
|
||||
super().showEvent(event)
|
||||
|
||||
self._dirty = True
|
||||
self._scheduleRender()
|
||||
|
||||
def hideEvent(self, event):
|
||||
"""Stop pending rendering while leaving data collection untouched."""
|
||||
self._renderTimer.stop()
|
||||
self._dirty = True
|
||||
|
||||
super().hideEvent(event)
|
||||
|
||||
def themeChangedCallback(self, theme: str):
|
||||
"""Repaint custom graphs for a theme change only when visible."""
|
||||
self._dirty = True
|
||||
self._scheduleRender()
|
||||
|
||||
def retranslate(self):
|
||||
"""Refresh page, section, graph, and selector text."""
|
||||
selectedRange = self.timeRangeComboBox.currentData() or self.DefaultTimeRange
|
||||
selectedGranularity = (
|
||||
self.granularityComboBox.currentData()
|
||||
if self.granularityComboBox.count()
|
||||
else self.DefaultGranularity
|
||||
)
|
||||
|
||||
self.pageTitleLabel.setText(_('Network Metrics'))
|
||||
self.timeRangeLabel.setText(_('Time Range'))
|
||||
self.granularityLabel.setText(_('Granularity'))
|
||||
self.downloadSection.setTitles(
|
||||
_('Download'),
|
||||
_('Download Speed'),
|
||||
_('Download Traffic Usage'),
|
||||
)
|
||||
self.uploadSection.setTitles(
|
||||
_('Upload'),
|
||||
_('Upload Speed'),
|
||||
_('Upload Traffic Usage'),
|
||||
)
|
||||
|
||||
self._populateComboBox(
|
||||
self.timeRangeComboBox,
|
||||
self._timeRangeOptions(),
|
||||
selectedRange,
|
||||
)
|
||||
self._populateComboBox(
|
||||
self.granularityComboBox,
|
||||
self._granularityOptions(),
|
||||
selectedGranularity,
|
||||
)
|
||||
|
||||
self._dirty = True
|
||||
self._scheduleRender()
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
from .IndentDialog import IndentDialog
|
||||
from .HomePage import HomePage
|
||||
from .LogPage import LogPage
|
||||
from .MetricsPage import MetricsPage
|
||||
from .MainWindow import MainWindow
|
||||
from .NetworkTestDialog import NetworkTestDialog
|
||||
from .ProxyBypassDialog import ProxyBypassDialog
|
||||
@@ -34,6 +35,7 @@ __all__ = [
|
||||
'IndentDialog',
|
||||
'HomePage',
|
||||
'LogPage',
|
||||
'MetricsPage',
|
||||
'MainWindow',
|
||||
'NetworkTestDialog',
|
||||
'ProxyBypassDialog',
|
||||
|
||||
Reference in New Issue
Block a user