diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 5878398..6b49112 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -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" } } diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index e7469d8..b55715f 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -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; diff --git a/Furious/Service/MetricsDataManager.py b/Furious/Service/MetricsDataManager.py new file mode 100644 index 0000000..1619e86 --- /dev/null +++ b/Furious/Service/MetricsDataManager.py @@ -0,0 +1,237 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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) diff --git a/Furious/Service/TrafficStatsManager.py b/Furious/Service/TrafficStatsManager.py index a4130fc..fc916f3 100644 --- a/Furious/Service/TrafficStatsManager.py +++ b/Furious/Service/TrafficStatsManager.py @@ -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 diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index d1ad5d6..620cee9 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -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', diff --git a/Furious/Widget/MetricsGraph.py b/Furious/Widget/MetricsGraph.py new file mode 100644 index 0000000..fb56271 --- /dev/null +++ b/Furious/Widget/MetricsGraph.py @@ -0,0 +1,290 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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() diff --git a/Furious/Widget/__init__.py b/Furious/Widget/__init__.py index b5a819d..30bebd8 100644 --- a/Furious/Widget/__init__.py +++ b/Furious/Widget/__init__.py @@ -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', diff --git a/Furious/Window/HomePage.py b/Furious/Window/HomePage.py index 134a3e2..d5719ac 100644 --- a/Furious/Window/HomePage.py +++ b/Furious/Window/HomePage.py @@ -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() diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 7805d34..040f8a6 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -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( diff --git a/Furious/Window/MetricsPage.py b/Furious/Window/MetricsPage.py new file mode 100644 index 0000000..e46f03c --- /dev/null +++ b/Furious/Window/MetricsPage.py @@ -0,0 +1,344 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# 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 . + +"""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() diff --git a/Furious/Window/__init__.py b/Furious/Window/__init__.py index 9502052..084fef9 100644 --- a/Furious/Window/__init__.py +++ b/Furious/Window/__init__.py @@ -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',