zapret-kvn/xray_fluent/ui/history_page.py

289 lines
12 KiB
Python
Raw Permalink 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.

from __future__ import annotations
from datetime import datetime, timezone
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QHBoxLayout, QHeaderView, QTableWidgetItem, QVBoxLayout, QWidget
from qfluentwidgets import (
BodyLabel,
ComboBox,
FluentIcon as FIF,
PushButton,
SettingCardGroup,
SmoothScrollArea,
SubtitleLabel,
TableWidget,
TitleLabel,
)
from ..traffic_history import TrafficHistoryStorage, TrafficSession
class HistoryPage(QWidget):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("history")
self._storage: TrafficHistoryStorage | None = None
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = SmoothScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
outer.addWidget(scroll)
container = QWidget()
container.setStyleSheet("QWidget { background: transparent; }")
scroll.setWidget(container)
root = QVBoxLayout(container)
root.setContentsMargins(24, 20, 24, 20)
root.setSpacing(12)
root.addWidget(SubtitleLabel("История трафика", container))
# ── Controls row ──
controls = QHBoxLayout()
controls.setSpacing(12)
self._period_combo = ComboBox(container)
self._period_combo.addItem("7 дней", userData=7)
self._period_combo.addItem("30 дней", userData=30)
self._period_combo.addItem("Всё время", userData=3650)
self._period_combo.setCurrentIndex(1)
self._period_combo.setMinimumWidth(160)
controls.addWidget(self._period_combo)
self._refresh_btn = PushButton("Обновить", container)
self._refresh_btn.setIcon(FIF.SYNC)
controls.addWidget(self._refresh_btn)
controls.addStretch()
root.addLayout(controls)
# ── Summary cards ──
summary_row = QHBoxLayout()
summary_row.setSpacing(16)
self._total_down_label = TitleLabel("0 B", container)
self._total_up_label = TitleLabel("0 B", container)
self._session_count_label = BodyLabel("0 сессий", container)
down_col = QVBoxLayout()
down_col.addWidget(BodyLabel("Загрузка", container))
down_col.addWidget(self._total_down_label)
summary_row.addLayout(down_col)
up_col = QVBoxLayout()
up_col.addWidget(BodyLabel("Отдача", container))
up_col.addWidget(self._total_up_label)
summary_row.addLayout(up_col)
count_col = QVBoxLayout()
count_col.addWidget(BodyLabel("Сессий", container))
count_col.addWidget(self._session_count_label)
summary_row.addLayout(count_col)
summary_row.addStretch()
root.addLayout(summary_row)
# ── Sessions table ──
root.addWidget(BodyLabel("Сессии", container))
self._sessions_table = TableWidget(container)
self._sessions_table.setColumnCount(7)
self._sessions_table.setHorizontalHeaderLabels([
"Дата", "Сервер", "Режим", "Длительность", "Загрузка", "Отдача", "Процессы",
])
self._sessions_table.setEditTriggers(TableWidget.EditTrigger.NoEditTriggers)
self._sessions_table.setSelectionBehavior(TableWidget.SelectionBehavior.SelectRows)
self._sessions_table.verticalHeader().setVisible(False)
hdr = self._sessions_table.horizontalHeader()
hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch)
self._sessions_table.setMinimumHeight(300)
root.addWidget(self._sessions_table)
# ── Daily totals table ──
root.addWidget(BodyLabel("Трафик по дням", container))
self._daily_table = TableWidget(container)
self._daily_table.setColumnCount(3)
self._daily_table.setHorizontalHeaderLabels(["Дата", "Загрузка", "Отдача"])
self._daily_table.setEditTriggers(TableWidget.EditTrigger.NoEditTriggers)
self._daily_table.setSelectionBehavior(TableWidget.SelectionBehavior.SelectRows)
self._daily_table.verticalHeader().setVisible(False)
dhdr = self._daily_table.horizontalHeader()
dhdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
dhdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
dhdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self._daily_table.setMinimumHeight(180)
root.addWidget(self._daily_table)
# ── Per-process totals table ──
root.addWidget(BodyLabel("Трафик по процессам (итого за период)", container))
self._proc_table = TableWidget(container)
self._proc_table.setColumnCount(4)
self._proc_table.setHorizontalHeaderLabels(["Процесс", "Загрузка", "Отдача", "Маршрут"])
self._proc_table.setEditTriggers(TableWidget.EditTrigger.NoEditTriggers)
self._proc_table.setSelectionBehavior(TableWidget.SelectionBehavior.SelectRows)
self._proc_table.verticalHeader().setVisible(False)
phdr = self._proc_table.horizontalHeader()
phdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
phdr.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
phdr.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
phdr.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self._proc_table.setMinimumHeight(200)
root.addWidget(self._proc_table)
root.addStretch(1)
# Signals
self._period_combo.currentIndexChanged.connect(self._refresh)
self._refresh_btn.clicked.connect(self._refresh)
def set_storage(self, storage: TrafficHistoryStorage) -> None:
self._storage = storage
self._refresh()
def _refresh(self) -> None:
if not self._storage:
return
days = self._period_combo.currentData() or 30
sessions = self._storage.get_sessions(days)
sessions.sort(key=lambda s: s.started_at, reverse=True)
# Summary
total_up = sum(s.total_upload for s in sessions)
total_down = sum(s.total_download for s in sessions)
self._total_down_label.setText(_fmt_bytes(total_down))
self._total_up_label.setText(_fmt_bytes(total_up))
self._session_count_label.setText(f"{len(sessions)} сессий")
# Sessions table
self._sessions_table.setRowCount(len(sessions))
for row, s in enumerate(sessions):
# Date
date_str = _fmt_datetime(s.started_at)
self._sessions_table.setItem(row, 0, QTableWidgetItem(date_str))
# Node
self._sessions_table.setItem(row, 1, QTableWidgetItem(s.node_name))
# Mode
mode_text = {
"xray": "Прокси",
"xray-tun": "TUN (xray experimental)",
"singbox": "TUN (sing-box)",
"tun2socks": "TUN (tun2socks)",
}.get(s.mode, s.mode)
mode_item = QTableWidgetItem(mode_text)
if "tun" in s.mode.lower() or s.mode == "singbox":
mode_item.setForeground(QColor("#3498db"))
self._sessions_table.setItem(row, 2, mode_item)
# Duration
self._sessions_table.setItem(row, 3, QTableWidgetItem(_fmt_duration(s.started_at, s.ended_at)))
# Download
down_item = QTableWidgetItem(_fmt_bytes(s.total_download))
if s.total_download > 0:
down_item.setForeground(QColor("#2ecc71"))
self._sessions_table.setItem(row, 4, down_item)
# Upload
self._sessions_table.setItem(row, 5, QTableWidgetItem(_fmt_bytes(s.total_upload)))
# Processes
procs = ", ".join(sorted(s.processes.keys())[:5])
if len(s.processes) > 5:
procs += f" (+{len(s.processes) - 5})"
self._sessions_table.setItem(row, 6, QTableWidgetItem(procs))
# Daily totals
daily = self._storage.get_daily_totals(days)
sorted_days = sorted(daily.items(), key=lambda kv: kv[0], reverse=True)
self._daily_table.setRowCount(len(sorted_days))
for row, (date_key, totals) in enumerate(sorted_days):
self._daily_table.setItem(row, 0, QTableWidgetItem(date_key))
down_item = QTableWidgetItem(_fmt_bytes(totals.get("download", 0)))
if totals.get("download", 0) > 0:
down_item.setForeground(QColor("#2ecc71"))
self._daily_table.setItem(row, 1, down_item)
self._daily_table.setItem(row, 2, QTableWidgetItem(_fmt_bytes(totals.get("upload", 0))))
# Process totals
proc_totals = self._storage.get_process_totals(days)
sorted_procs = sorted(proc_totals.items(), key=lambda kv: kv[1]["download"], reverse=True)
self._proc_table.setRowCount(len(sorted_procs))
for row, (exe, stats) in enumerate(sorted_procs):
self._proc_table.setItem(row, 0, QTableWidgetItem(exe))
down_item = QTableWidgetItem(_fmt_bytes(int(stats["download"])))
if int(stats["download"]) > 0:
down_item.setForeground(QColor("#2ecc71"))
self._proc_table.setItem(row, 1, down_item)
self._proc_table.setItem(row, 2, QTableWidgetItem(_fmt_bytes(int(stats["upload"]))))
route = str(stats.get("route", ""))
route_text = {"proxy": "VPN", "direct": "Прямой", "mixed": "Смешанный"}.get(route, route)
self._proc_table.setItem(row, 3, QTableWidgetItem(route_text))
def _fmt_bytes(b: int) -> str:
value = float(max(0, int(b)))
units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
unit_idx = 0
while value >= 1024.0 and unit_idx < len(units) - 1:
value /= 1024.0
unit_idx += 1
if unit_idx == 0:
return f"{int(value)} {units[unit_idx]}"
if unit_idx <= 2:
return f"{value:.1f} {units[unit_idx]}"
return f"{value:.2f} {units[unit_idx]}"
def _fmt_datetime(iso: str) -> str:
if not iso:
return ""
try:
dt = datetime.fromisoformat(iso).astimezone()
return dt.strftime("%d.%m.%Y %H:%M")
except Exception:
return iso[:16]
def _fmt_duration(start: str, end: str | None) -> str:
if not start:
return ""
try:
s = datetime.fromisoformat(start)
e = datetime.fromisoformat(end) if end else datetime.now(timezone.utc)
delta = e - s
total_sec = int(delta.total_seconds())
if total_sec < 0:
return ""
hours, rem = divmod(total_sec, 3600)
minutes, secs = divmod(rem, 60)
if hours > 0:
return f"{hours}ч {minutes}м"
elif minutes > 0:
return f"{minutes}м {secs}с"
else:
return f"{secs}с"
except Exception:
return ""