Add historical network metrics page

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:21:09 +08:00
parent b454f3c98b
commit df1c55876d
11 changed files with 1164 additions and 81 deletions
+152 -4
View File
@@ -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"
}
}
+54
View File
@@ -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;
+237
View File
@@ -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)
+38 -68
View File
@@ -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
+18
View File
@@ -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',
+290
View File
@@ -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()
+2
View File
@@ -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',
+7 -7
View File
@@ -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()
+20 -2
View File
@@ -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(
+344
View File
@@ -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()
+2
View File
@@ -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',