Files
LorenEteval_Furious/Furious/Window/MetricsPage.py
T
2026-08-20 16:55:58 +08:00

465 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AppQComboBox, AppQLabel
from Furious.Qt import gettext as _
from Furious.Service import (
DOWNLOAD_SPEED_METRIC,
DOWNLOAD_USAGE_METRIC,
UPLOAD_SPEED_METRIC,
UPLOAD_USAGE_METRIC,
EndpointInfoService,
MetricsHistory,
formatTrafficSpeed,
formatTrafficUsage,
)
from Furious.Widget.EndpointInfoWidget import EndpointInfoWidget
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)
self.graph.setMetricLabel(title)
class _TrafficMetricsCard(QFrame):
"""Group selectors and all traffic graphs in one metrics card."""
def __init__(
self,
downloadSpeedGraph,
downloadUsageGraph,
uploadSpeedGraph,
uploadUsageGraph,
parent=None,
):
"""Initialize the combined download and upload metrics card."""
super().__init__(parent)
self.setObjectName('TrafficMetricsCard')
self.titleLabel = AppQLabel(_('Traffic Statistics'), parent=self)
self.titleLabel.setObjectName('TrafficMetricsCardTitle')
self.timeRangeLabel, self.granularityLabel = (
AppQLabel(_('Time Range'), parent=self),
AppQLabel(_('Granularity'), parent=self),
)
self.timeRangeComboBox, self.granularityComboBox = (
AppQComboBox(parent=self),
AppQComboBox(parent=self),
)
self.timeRangeComboBox.setMinimumWidth(160)
self.granularityComboBox.setMinimumWidth(130)
self.downloadTitleLabel, self.uploadTitleLabel = (
AppQLabel(translatable=False, parent=self),
AppQLabel(translatable=False, parent=self),
)
for titleLabel in (self.downloadTitleLabel, self.uploadTitleLabel):
titleLabel.setObjectName('MetricsDirectionTitle')
self.downloadSpeedCard, self.downloadUsageCard = (
_MetricCard(downloadSpeedGraph, parent=self),
_MetricCard(downloadUsageGraph, parent=self),
)
self.uploadSpeedCard, self.uploadUsageCard = (
_MetricCard(uploadSpeedGraph, parent=self),
_MetricCard(uploadUsageGraph, parent=self),
)
headerLayout = QHBoxLayout()
headerLayout.setContentsMargins(0, 0, 0, 0)
headerLayout.setSpacing(8)
headerLayout.addWidget(self.titleLabel)
headerLayout.addStretch(1)
headerLayout.addWidget(self.timeRangeLabel)
headerLayout.addWidget(self.timeRangeComboBox)
headerLayout.addSpacing(8)
headerLayout.addWidget(self.granularityLabel)
headerLayout.addWidget(self.granularityComboBox)
downloadLayout = QHBoxLayout()
downloadLayout.setContentsMargins(0, 0, 0, 0)
downloadLayout.setSpacing(12)
downloadLayout.addWidget(self.downloadSpeedCard, 1)
downloadLayout.addWidget(self.downloadUsageCard, 1)
uploadLayout = QHBoxLayout()
uploadLayout.setContentsMargins(0, 0, 0, 0)
uploadLayout.setSpacing(12)
uploadLayout.addWidget(self.uploadSpeedCard, 1)
uploadLayout.addWidget(self.uploadUsageCard, 1)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(10)
layout.addLayout(headerLayout)
layout.addWidget(self.downloadTitleLabel)
layout.addLayout(downloadLayout, 1)
layout.addWidget(self.uploadTitleLabel)
layout.addLayout(uploadLayout, 1)
def setTitles(
self,
downloadTitle,
downloadSpeedTitle,
downloadUsageTitle,
uploadTitle,
uploadSpeedTitle,
uploadUsageTitle,
):
"""Set translated direction and graph-card titles."""
self.downloadTitleLabel.setText(downloadTitle)
self.downloadSpeedCard.setTitle(downloadSpeedTitle)
self.downloadUsageCard.setTitle(downloadUsageTitle)
self.uploadTitleLabel.setText(uploadTitle)
self.uploadSpeedCard.setTitle(uploadSpeedTitle)
self.uploadUsageCard.setTitle(uploadUsageTitle)
class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
"""Coordinate time controls and visible-only graph rendering."""
RenderDelay = 100
TimelineRefreshInterval = 1000
DefaultTimeRange = 15 * 60
DefaultGranularity = 0
def __init__(
self,
history: MetricsHistory,
parent=None,
*,
endpointInfoService=None,
):
"""Initialize the network metrics page around its metrics history."""
super().__init__(parent)
if not isinstance(history, MetricsHistory):
raise TypeError('history must be a MetricsHistory')
self.setObjectName('MetricsPage')
self.history = history
if endpointInfoService is None:
self.endpointInfoService = EndpointInfoService(parent=self)
else:
self.endpointInfoService = endpointInfoService
self._dirty = True
self._renderRevision = 0
self.pageTitleLabel = AppQLabel(_('Network Metrics'))
self.pageTitleLabel.setObjectName('MetricsPageTitle')
(
self.downloadSpeedGraph,
self.downloadUsageGraph,
self.uploadSpeedGraph,
self.uploadUsageGraph,
) = (
MetricsGraphWidget(
MetricsGraphWidget.Download,
formatTrafficSpeed,
),
MetricsGraphWidget(
MetricsGraphWidget.Download,
formatTrafficUsage,
),
MetricsGraphWidget(
MetricsGraphWidget.Upload,
formatTrafficSpeed,
),
MetricsGraphWidget(
MetricsGraphWidget.Upload,
formatTrafficUsage,
),
)
self.metricsCard = _TrafficMetricsCard(
self.downloadSpeedGraph,
self.downloadUsageGraph,
self.uploadSpeedGraph,
self.uploadUsageGraph,
parent=self,
)
(
self.timeRangeLabel,
self.granularityLabel,
self.timeRangeComboBox,
self.granularityComboBox,
) = (
self.metricsCard.timeRangeLabel,
self.metricsCard.granularityLabel,
self.metricsCard.timeRangeComboBox,
self.metricsCard.granularityComboBox,
)
self._populateComboBox(
self.timeRangeComboBox,
self._timeRangeOptions(),
self.DefaultTimeRange,
)
self._populateComboBox(
self.granularityComboBox,
self._granularityOptions(),
self.DefaultGranularity,
)
self.endpointInfoWidget = EndpointInfoWidget(
self.endpointInfoService,
parent=self,
)
self.endpointInfoWidget.setVisible(self.endpointInfoService.enabled)
self.endpointInfoService.enabledChanged.connect(
self._endpointInfoEnabledChanged
)
contentWidget = QWidget()
contentWidget.setObjectName('MetricsPageContent')
contentLayout = QVBoxLayout(contentWidget)
contentLayout.setContentsMargins(20, 18, 20, 20)
contentLayout.setSpacing(14)
contentLayout.addWidget(self.pageTitleLabel)
contentLayout.addWidget(self.metricsCard, 1)
contentLayout.addWidget(self.endpointInfoWidget)
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)
# This timer advances only the visible rolling window. Statistics
# collection remains independent, and hidden pages do no graph work.
self._timelineTimer = QtCore.QTimer(self)
self._timelineTimer.setInterval(self.TimelineRefreshInterval)
self._timelineTimer.timeout.connect(self._timelineAdvanced)
self.history.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):
"""Populate translated choices and select one semantic value."""
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(bool)
def setEndpointInfoEnabled(self, enabled: bool):
"""Apply the persisted endpoint-inspection preference to the page service."""
self.endpointInfoService.setEnabled(enabled)
@QtCore.Slot(bool)
def _endpointInfoEnabledChanged(self, enabled: bool):
"""Remove the privacy-sensitive section entirely while it is disabled."""
self.endpointInfoWidget.setVisible(enabled)
@QtCore.Slot()
def _selectionChanged(self):
"""Refresh visible graphs after range or granularity selection."""
self._dirty = True
self._scheduleRender()
@QtCore.Slot()
def _timelineAdvanced(self):
"""Move the visible time window without altering stored samples."""
if not self._pageCanRender():
return
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()
currentWallTime = time.time()
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.history.series(
metricKey,
rangeSeconds,
granularity,
now=currentTime,
),
rangeSeconds,
currentTime,
currentWallTime,
)
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._timelineTimer.start()
self.endpointInfoService.setPageVisible(True)
self._scheduleRender()
def hideEvent(self, event):
"""Stop pending rendering while leaving data collection untouched."""
self._renderTimer.stop()
self._timelineTimer.stop()
self._dirty = True
self.endpointInfoService.setPageVisible(False)
super().hideEvent(event)
def themeChangedCallback(self, theme: str):
"""Repaint custom graphs for a theme change only when visible."""
self._dirty = True
self.endpointInfoWidget.themeChangedCallback(theme)
self._scheduleRender()
def retranslate(self):
"""Refresh composite section, graph, and endpoint presentation text."""
self.metricsCard.setTitles(
_('Download'),
_('Download Speed'),
_('Download Traffic Usage'),
_('Upload'),
_('Upload Speed'),
_('Upload Traffic Usage'),
)
self.endpointInfoWidget.retranslate()
self._dirty = True
self._scheduleRender()