848 lines
41 KiB
Python
848 lines
41 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from collections import deque
|
||
from pathlib import Path
|
||
|
||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||
from PyQt6.QtGui import QBrush, QColor
|
||
from PyQt6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QStackedWidget,
|
||
QTableWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
from qfluentwidgets import (
|
||
BodyLabel,
|
||
BreadcrumbBar,
|
||
CaptionLabel,
|
||
CardWidget,
|
||
ComboBox,
|
||
FluentIcon as FIF,
|
||
PrimaryPushButton,
|
||
SmoothScrollArea,
|
||
StrongBodyLabel,
|
||
SubtitleLabel,
|
||
SwitchButton,
|
||
TableWidget,
|
||
)
|
||
|
||
from ..models import AppSettings, Node, RoutingSettings
|
||
from .traffic_graph import DetailTrafficGraphWidget, TrafficGraphWidget
|
||
|
||
|
||
def _format_speed(value_bps: float) -> str:
|
||
value = 0.0 if not math.isfinite(value_bps) else max(0.0, value_bps)
|
||
units = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s", "PB/s", "EB/s"]
|
||
unit_index = 0
|
||
while value >= 1024.0 and unit_index < len(units) - 1:
|
||
value /= 1024.0
|
||
unit_index += 1
|
||
if unit_index == 0:
|
||
return f"{int(value)} {units[unit_index]}"
|
||
return f"{value:.2f} {units[unit_index]}"
|
||
|
||
|
||
def _format_latency(value_ms: int | None) -> str:
|
||
if value_ms is None:
|
||
return "--"
|
||
return f"{value_ms} ms"
|
||
|
||
|
||
def _mode_title(mode: str) -> str:
|
||
mapping = {
|
||
"global": "Глобальный",
|
||
"rule": "Правила",
|
||
"direct": "Прямой",
|
||
}
|
||
return mapping.get(mode, mode.title() or "Неизвестно")
|
||
|
||
|
||
class DashboardPage(QWidget):
|
||
toggle_connection_requested = pyqtSignal()
|
||
node_selected = pyqtSignal(str)
|
||
mode_changed = pyqtSignal(str)
|
||
tun_toggled = pyqtSignal(bool)
|
||
proxy_toggled = pyqtSignal(bool)
|
||
|
||
def __init__(self, parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setObjectName("dashboard")
|
||
|
||
self._nodes: list[Node] = []
|
||
self._node_ids: list[str] = []
|
||
self._selected_node: Node | None = None
|
||
self._connected = False
|
||
self._connection_phase = "idle"
|
||
self._connection_message = "Прокси остановлен"
|
||
self._mode = "rule"
|
||
self._settings = AppSettings()
|
||
self._routing = RoutingSettings()
|
||
self._selected_latency_ms: int | None = None
|
||
self._live_rtt_ms: int | None = None
|
||
self._proxy_socks_port = 0
|
||
self._proxy_http_port = 0
|
||
self._transition_busy = False
|
||
self._last_down_bps = 0.0
|
||
self._last_up_bps = 0.0
|
||
self._peak_bps = 0.0
|
||
self._down_history: deque[float] = deque(maxlen=300)
|
||
self._up_history: deque[float] = deque(maxlen=300)
|
||
self._last_process_stats: list | None = None
|
||
|
||
self._refresh_timer = QTimer(self)
|
||
self._refresh_timer.setSingleShot(True)
|
||
self._refresh_timer.setInterval(30)
|
||
self._refresh_timer.timeout.connect(self._do_refresh_dashboard)
|
||
|
||
# ── Outer layout with QStackedWidget ──────────────────
|
||
outer = QVBoxLayout(self)
|
||
outer.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self._stack = QStackedWidget(self)
|
||
outer.addWidget(self._stack)
|
||
|
||
# ── Page 0: main dashboard ────────────────────────────
|
||
main_page = QWidget()
|
||
main_page.setStyleSheet("QWidget { background: transparent; }")
|
||
self._stack.addWidget(main_page)
|
||
|
||
main_outer = QVBoxLayout(main_page)
|
||
main_outer.setContentsMargins(0, 0, 0, 0)
|
||
|
||
self._scroll = SmoothScrollArea(main_page)
|
||
self._scroll.setWidgetResizable(True)
|
||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
self._scroll.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
||
main_outer.addWidget(self._scroll)
|
||
|
||
container = QWidget()
|
||
container.setStyleSheet("QWidget { background: transparent; }")
|
||
self._scroll.setWidget(container)
|
||
|
||
root = QVBoxLayout(container)
|
||
root.setContentsMargins(24, 20, 24, 20)
|
||
root.setSpacing(12)
|
||
|
||
root.addWidget(SubtitleLabel("Панель управления", container))
|
||
self.summary_label = CaptionLabel("Краткий обзор подключения, профиля, трафика и маршрутизации.", self)
|
||
self.summary_label.setWordWrap(True)
|
||
root.addWidget(self.summary_label)
|
||
|
||
grid = QGridLayout()
|
||
grid.setHorizontalSpacing(12)
|
||
grid.setVerticalSpacing(12)
|
||
grid.setColumnStretch(0, 1)
|
||
grid.setColumnStretch(1, 1)
|
||
|
||
# ── Connection card ───────────────────────────────────
|
||
self.connection_card = CardWidget(self)
|
||
connection_layout = QVBoxLayout(self.connection_card)
|
||
connection_layout.setContentsMargins(18, 16, 18, 16)
|
||
connection_layout.setSpacing(6)
|
||
connection_layout.addWidget(StrongBodyLabel("Подключение", self.connection_card))
|
||
self.connection_state_label = SubtitleLabel("Ожидание", self.connection_card)
|
||
self.connection_engine_label = BodyLabel("Системный прокси", self.connection_card)
|
||
self.connection_status_label = CaptionLabel("Прокси остановлен", self.connection_card)
|
||
self.connection_target_label = CaptionLabel("Активный профиль не выбран", self.connection_card)
|
||
self.connection_target_label.setWordWrap(True)
|
||
connection_layout.addWidget(self.connection_state_label)
|
||
connection_layout.addWidget(self.connection_engine_label)
|
||
|
||
switches_row = QHBoxLayout()
|
||
switches_row.setSpacing(20)
|
||
tun_label = CaptionLabel("VPN (TUN)", self.connection_card)
|
||
self.tun_switch = SwitchButton(self.connection_card)
|
||
self.tun_switch.setOnText("Вкл")
|
||
self.tun_switch.setOffText("Выкл")
|
||
switches_row.addWidget(tun_label)
|
||
switches_row.addWidget(self.tun_switch)
|
||
switches_row.addSpacing(12)
|
||
proxy_label = CaptionLabel("Сист. прокси", self.connection_card)
|
||
self.proxy_switch = SwitchButton(self.connection_card)
|
||
self.proxy_switch.setOnText("Вкл")
|
||
self.proxy_switch.setOffText("Выкл")
|
||
switches_row.addWidget(proxy_label)
|
||
switches_row.addWidget(self.proxy_switch)
|
||
switches_row.addStretch(1)
|
||
connection_layout.addLayout(switches_row)
|
||
|
||
# Toggle button inside connection card
|
||
self.toggle_btn = PrimaryPushButton(FIF.PLAY_SOLID, "Запустить прокси", self.connection_card)
|
||
connection_layout.addWidget(self.toggle_btn)
|
||
|
||
# Hidden node combo (keeps selection logic intact, UI on nodes page)
|
||
self.node_combo = ComboBox(self.connection_card)
|
||
self.node_combo.setVisible(False)
|
||
|
||
connection_layout.addStretch(1)
|
||
self.connection_status_label.setWordWrap(True)
|
||
connection_layout.addWidget(self.connection_status_label)
|
||
self.connection_target_label.setWordWrap(True)
|
||
connection_layout.addWidget(self.connection_target_label)
|
||
|
||
# Profile info labels (read-only, hidden — data shown via status/target labels)
|
||
self.profile_name_label = BodyLabel("", self)
|
||
self.profile_name_label.setVisible(False)
|
||
self.profile_endpoint_label = CaptionLabel("", self)
|
||
self.profile_endpoint_label.setVisible(False)
|
||
self.profile_group_label = CaptionLabel("", self)
|
||
self.profile_group_label.setVisible(False)
|
||
self.profile_latency_label = CaptionLabel("", self)
|
||
self.profile_latency_label.setVisible(False)
|
||
|
||
# ── Traffic card ──────────────────────────────────────
|
||
self.traffic_card = CardWidget(self)
|
||
traffic_layout = QVBoxLayout(self.traffic_card)
|
||
traffic_layout.setContentsMargins(18, 16, 18, 16)
|
||
traffic_layout.setSpacing(6)
|
||
traffic_layout.addWidget(StrongBodyLabel("Трафик", self.traffic_card))
|
||
self.traffic_down_label = BodyLabel("Загрузка: 0 B/s", self.traffic_card)
|
||
self.traffic_up_label = BodyLabel("Выгрузка: 0 B/s", self.traffic_card)
|
||
self.traffic_rtt_label = BodyLabel("RTT: --", self.traffic_card)
|
||
self.traffic_graph = TrafficGraphWidget(self.traffic_card)
|
||
self.traffic_graph.clicked.connect(self._show_traffic_page)
|
||
self.traffic_peak_label = CaptionLabel("Пик: 0 B/s", self.traffic_card)
|
||
traffic_layout.addWidget(self.traffic_down_label)
|
||
traffic_layout.addWidget(self.traffic_up_label)
|
||
traffic_layout.addWidget(self.traffic_rtt_label)
|
||
traffic_layout.addWidget(self.traffic_graph, 1)
|
||
traffic_layout.addWidget(self.traffic_peak_label)
|
||
|
||
# ── Process traffic table (TUN mode only) ────────────
|
||
self._proc_traffic_card = CardWidget(self)
|
||
proc_layout = QVBoxLayout(self._proc_traffic_card)
|
||
proc_layout.setContentsMargins(18, 16, 18, 16)
|
||
proc_layout.setSpacing(6)
|
||
proc_header = QHBoxLayout()
|
||
proc_header.addWidget(StrongBodyLabel("Трафик по процессам", self._proc_traffic_card))
|
||
proc_header.addStretch(1)
|
||
from qfluentwidgets import TransparentToolButton
|
||
self._proc_detail_btn = TransparentToolButton(FIF.CHEVRON_RIGHT_MED, self._proc_traffic_card)
|
||
self._proc_detail_btn.setFixedSize(32, 32)
|
||
self._proc_detail_btn.setToolTip("Развернуть на весь экран")
|
||
self._proc_detail_btn.clicked.connect(self._show_proc_page)
|
||
proc_header.addWidget(self._proc_detail_btn)
|
||
proc_layout.addLayout(proc_header)
|
||
|
||
self._proc_traffic_table = TableWidget(self._proc_traffic_card)
|
||
self._proc_traffic_table.setColumnCount(7)
|
||
self._proc_traffic_table.setHorizontalHeaderLabels(
|
||
["Процесс", "Скорость", "VPN", "Прямой", "Соед.", "Хост", "Всего"]
|
||
)
|
||
_col_tooltips = [
|
||
"Имя исполняемого файла приложения",
|
||
"Текущая скорость загрузки/выгрузки",
|
||
"Объём трафика через VPN (зашифрованный, через прокси-сервер)",
|
||
"Объём трафика напрямую (без VPN, к серверу напрямую)",
|
||
"Активные соединения (всего за сессию)",
|
||
"Домен или IP с наибольшим трафиком",
|
||
"Общий объём трафика за сессию",
|
||
]
|
||
for col, tip in enumerate(_col_tooltips):
|
||
item = self._proc_traffic_table.horizontalHeaderItem(col)
|
||
if item:
|
||
item.setToolTip(tip)
|
||
self._proc_traffic_table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Interactive
|
||
)
|
||
self._proc_traffic_table.horizontalHeader().setSectionResizeMode(
|
||
5, QHeaderView.ResizeMode.Stretch
|
||
)
|
||
for col in (1, 2, 3, 4, 6):
|
||
self._proc_traffic_table.horizontalHeader().setSectionResizeMode(
|
||
col, QHeaderView.ResizeMode.ResizeToContents
|
||
)
|
||
self._proc_traffic_table.verticalHeader().setVisible(False)
|
||
self._proc_traffic_table.setEditTriggers(
|
||
QAbstractItemView.EditTrigger.NoEditTriggers
|
||
)
|
||
self._proc_traffic_table.setSelectionMode(
|
||
QAbstractItemView.SelectionMode.NoSelection
|
||
)
|
||
self._proc_traffic_table.setMinimumHeight(150)
|
||
proc_layout.addWidget(self._proc_traffic_table, 1)
|
||
|
||
# ── Routing card ──────────────────────────────────────
|
||
self.routing_card = CardWidget(self)
|
||
routing_layout = QVBoxLayout(self.routing_card)
|
||
routing_layout.setContentsMargins(18, 16, 18, 16)
|
||
routing_layout.setSpacing(8)
|
||
routing_layout.addWidget(StrongBodyLabel("Маршрутизация", self.routing_card))
|
||
self.mode_combo = ComboBox(self.routing_card)
|
||
self.mode_combo.addItem("Глобальный", userData="global")
|
||
self.mode_combo.addItem("Правила", userData="rule")
|
||
self.mode_combo.addItem("Прямой", userData="direct")
|
||
routing_layout.addWidget(self.mode_combo)
|
||
self.routing_mode_label = BodyLabel("Правила", self.routing_card)
|
||
self.routing_dns_label = CaptionLabel("DNS: Системный", self.routing_card)
|
||
self.routing_rules_label = CaptionLabel("Прямые: 0 Прокси: 0 Блок: 0", self.routing_card)
|
||
self.routing_bypass_label = CaptionLabel("Обход LAN: включён", self.routing_card)
|
||
self.routing_bypass_label.setWordWrap(True)
|
||
routing_layout.addWidget(self.routing_mode_label)
|
||
routing_layout.addStretch(1)
|
||
routing_layout.addWidget(self.routing_dns_label)
|
||
routing_layout.addWidget(self.routing_rules_label)
|
||
routing_layout.addWidget(self.routing_bypass_label)
|
||
|
||
grid.addWidget(self.connection_card, 0, 0)
|
||
grid.addWidget(self.routing_card, 0, 1)
|
||
root.addLayout(grid)
|
||
root.addWidget(self.traffic_card)
|
||
root.addWidget(self._proc_traffic_card)
|
||
root.addStretch(1)
|
||
|
||
# ── Page 1: traffic detail subpage ────────────────────
|
||
self._traffic_detail_page = QWidget()
|
||
self._traffic_detail_page.setStyleSheet("QWidget { background: transparent; }")
|
||
self._stack.addWidget(self._traffic_detail_page)
|
||
|
||
detail_layout = QVBoxLayout(self._traffic_detail_page)
|
||
detail_layout.setContentsMargins(24, 20, 24, 20)
|
||
detail_layout.setSpacing(12)
|
||
|
||
self._traffic_breadcrumb = BreadcrumbBar(self._traffic_detail_page)
|
||
self._traffic_breadcrumb.addItem("dashboard", "Панель управления")
|
||
self._traffic_breadcrumb.addItem("traffic", "Трафик")
|
||
self._traffic_breadcrumb.currentItemChanged.connect(self._on_traffic_breadcrumb)
|
||
detail_layout.addWidget(self._traffic_breadcrumb)
|
||
|
||
self._detail_graph = DetailTrafficGraphWidget(self._traffic_detail_page)
|
||
detail_layout.addWidget(self._detail_graph, 1)
|
||
|
||
detail_stats_row = QHBoxLayout()
|
||
detail_stats_row.setSpacing(16)
|
||
self._detail_down_label = BodyLabel("Загрузка: 0 B/s", self._traffic_detail_page)
|
||
self._detail_up_label = BodyLabel("Выгрузка: 0 B/s", self._traffic_detail_page)
|
||
self._detail_rtt_label = BodyLabel("RTT: --", self._traffic_detail_page)
|
||
self._detail_peak_label = BodyLabel("Пик: 0 B/s", self._traffic_detail_page)
|
||
detail_stats_row.addWidget(self._detail_down_label)
|
||
detail_stats_row.addWidget(self._detail_up_label)
|
||
detail_stats_row.addWidget(self._detail_rtt_label)
|
||
detail_stats_row.addWidget(self._detail_peak_label)
|
||
detail_stats_row.addStretch(1)
|
||
detail_layout.addLayout(detail_stats_row)
|
||
|
||
# ── Page 2: process traffic detail subpage ─────────────
|
||
self._proc_detail_page = QWidget()
|
||
self._proc_detail_page.setStyleSheet("QWidget { background: transparent; }")
|
||
self._stack.addWidget(self._proc_detail_page)
|
||
|
||
proc_detail_layout = QVBoxLayout(self._proc_detail_page)
|
||
proc_detail_layout.setContentsMargins(24, 20, 24, 20)
|
||
proc_detail_layout.setSpacing(12)
|
||
|
||
self._proc_breadcrumb = BreadcrumbBar(self._proc_detail_page)
|
||
self._proc_breadcrumb.addItem("dashboard", "Панель управления")
|
||
self._proc_breadcrumb.addItem("processes", "Трафик по процессам")
|
||
self._proc_breadcrumb.currentItemChanged.connect(self._on_proc_breadcrumb)
|
||
proc_detail_layout.addWidget(self._proc_breadcrumb)
|
||
|
||
self._proc_detail_table = TableWidget(self._proc_detail_page)
|
||
self._proc_detail_table.setColumnCount(7)
|
||
self._proc_detail_table.setHorizontalHeaderLabels(
|
||
["Процесс", "Скорость", "VPN", "Прямой", "Соединения", "Основной хост", "Всего"]
|
||
)
|
||
self._proc_detail_table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Interactive
|
||
)
|
||
self._proc_detail_table.horizontalHeader().setSectionResizeMode(
|
||
5, QHeaderView.ResizeMode.Stretch
|
||
)
|
||
for col in (1, 2, 3, 4, 6):
|
||
self._proc_detail_table.horizontalHeader().setSectionResizeMode(
|
||
col, QHeaderView.ResizeMode.ResizeToContents
|
||
)
|
||
for col, tip in enumerate(_col_tooltips):
|
||
item = self._proc_detail_table.horizontalHeaderItem(col)
|
||
if item:
|
||
item.setToolTip(tip)
|
||
self._proc_detail_table.verticalHeader().setVisible(False)
|
||
self._proc_detail_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self._proc_detail_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self._proc_detail_table.setMinimumHeight(400)
|
||
proc_detail_layout.addWidget(self._proc_detail_table, 1)
|
||
|
||
# ── Signal connections ────────────────────────────────
|
||
self.node_combo.currentIndexChanged.connect(self._on_node_changed)
|
||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||
self.tun_switch.checkedChanged.connect(self._on_tun_toggled)
|
||
self.proxy_switch.checkedChanged.connect(self._on_proxy_toggled)
|
||
self.toggle_btn.clicked.connect(self.toggle_connection_requested)
|
||
|
||
self._stack.setCurrentIndex(0)
|
||
self._refresh_dashboard()
|
||
|
||
# ── Public API ────────────────────────────────────────────
|
||
|
||
def set_nodes(self, nodes: list[Node], selected_node_id: str | None) -> None:
|
||
self._nodes = list(nodes)
|
||
self._node_ids = []
|
||
|
||
self.node_combo.blockSignals(True)
|
||
self.node_combo.clear()
|
||
|
||
selected_index = 0
|
||
for index, node in enumerate(self._nodes):
|
||
self.node_combo.addItem(self._node_title(node))
|
||
self._node_ids.append(node.id)
|
||
if selected_node_id and selected_node_id == node.id:
|
||
selected_index = index
|
||
|
||
if self._nodes:
|
||
self.node_combo.setEnabled(True)
|
||
self.node_combo.setCurrentIndex(selected_index)
|
||
self._selected_node = self._nodes[selected_index]
|
||
else:
|
||
self.node_combo.addItem("Профили не импортированы")
|
||
self.node_combo.setEnabled(False)
|
||
self._selected_node = None
|
||
|
||
self.node_combo.blockSignals(False)
|
||
self._refresh_dashboard()
|
||
|
||
def set_selected_node(self, node: Node | None) -> None:
|
||
self._selected_node = node
|
||
if node is not None and node.id in self._node_ids:
|
||
self.node_combo.blockSignals(True)
|
||
self.node_combo.setCurrentIndex(self._node_ids.index(node.id))
|
||
self.node_combo.blockSignals(False)
|
||
self._refresh_dashboard()
|
||
|
||
def set_connection(self, connected: bool) -> None:
|
||
self._connected = connected
|
||
if not connected:
|
||
self._last_down_bps = 0.0
|
||
self._last_up_bps = 0.0
|
||
self._live_rtt_ms = None
|
||
self._peak_bps = 0.0
|
||
self._down_history.clear()
|
||
self._up_history.clear()
|
||
self.traffic_graph.clear_data()
|
||
self._clear_process_tables()
|
||
self._last_process_stats = None
|
||
if self._connection_phase == "running":
|
||
self._connection_phase = "idle"
|
||
self._connection_message = self._default_connection_message()
|
||
elif self._connection_phase == "idle":
|
||
self._connection_phase = "running"
|
||
self._connection_message = self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_runtime_status(self, phase: str, message: str) -> None:
|
||
normalized = (phase or "idle").strip().lower()
|
||
if normalized not in {"idle", "starting", "running", "error"}:
|
||
normalized = "idle"
|
||
self._connection_phase = normalized
|
||
self._connection_message = (message or "").strip() or self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_mode(self, mode: str) -> None:
|
||
self._mode = mode
|
||
self._routing.mode = mode
|
||
self.mode_combo.blockSignals(True)
|
||
for index in range(self.mode_combo.count()):
|
||
if self.mode_combo.itemData(index) == mode:
|
||
self.mode_combo.setCurrentIndex(index)
|
||
break
|
||
self.mode_combo.blockSignals(False)
|
||
self._refresh_dashboard()
|
||
|
||
def set_proxy_ports(self, socks_port: int, http_port: int) -> None:
|
||
self._proxy_socks_port = max(0, int(socks_port))
|
||
self._proxy_http_port = max(0, int(http_port))
|
||
self._settings.tun_mode = False
|
||
if self._connection_phase in {"idle", "running"}:
|
||
self._connection_message = self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_tun_mode(self, enabled: bool) -> None:
|
||
self._settings.tun_mode = enabled
|
||
if self._connection_phase in {"idle", "running"}:
|
||
self._connection_message = self._default_connection_message()
|
||
self._refresh_dashboard()
|
||
|
||
def set_settings_snapshot(self, settings: AppSettings) -> None:
|
||
self._settings = settings
|
||
if self._connection_phase in {"idle", "running"}:
|
||
self._connection_message = self._default_connection_message()
|
||
self._sync_switches()
|
||
self._refresh_dashboard()
|
||
|
||
def set_routing_snapshot(self, routing: RoutingSettings) -> None:
|
||
self._routing = routing
|
||
self.set_mode(routing.mode)
|
||
|
||
def set_selected_latency(self, value: int | None) -> None:
|
||
self._selected_latency_ms = value
|
||
if self._selected_node is not None:
|
||
self._selected_node.ping_ms = value
|
||
self._refresh_dashboard()
|
||
|
||
def set_live_metrics(self, down_bps: float, up_bps: float, latency_ms: int | None) -> None:
|
||
self._last_down_bps = max(0.0, down_bps)
|
||
self._last_up_bps = max(0.0, up_bps)
|
||
self._live_rtt_ms = latency_ms
|
||
self._peak_bps = max(self._peak_bps, self._last_down_bps, self._last_up_bps)
|
||
self._down_history.append(self._last_down_bps)
|
||
self._up_history.append(self._last_up_bps)
|
||
self.traffic_graph.add_point(self._last_down_bps, self._last_up_bps)
|
||
if self._stack.currentIndex() == 1:
|
||
self._detail_graph.add_point(self._last_down_bps, self._last_up_bps)
|
||
self._refresh_dashboard()
|
||
|
||
def set_process_stats(self, stats: list | None) -> None:
|
||
if stats is None:
|
||
self._last_process_stats = None
|
||
self._clear_process_tables()
|
||
return
|
||
self._last_process_stats = list(stats)
|
||
self._apply_process_stats_to_table(self._proc_traffic_table, stats)
|
||
if self._stack.currentIndex() == 2:
|
||
self._apply_process_stats_to_table(self._proc_detail_table, stats)
|
||
|
||
def set_transition_busy(self, busy: bool) -> None:
|
||
self._transition_busy = busy
|
||
self._apply_interaction_state()
|
||
|
||
@staticmethod
|
||
def _format_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]}"
|
||
|
||
# ── Refresh logic ─────────────────────────────────────────
|
||
|
||
def _refresh_dashboard(self) -> None:
|
||
if not self._refresh_timer.isActive():
|
||
self._refresh_timer.start()
|
||
|
||
def _do_refresh_dashboard(self) -> None:
|
||
self._refresh_connection_card()
|
||
self._refresh_profile_card()
|
||
self._refresh_traffic_card()
|
||
self._refresh_routing_card()
|
||
self._apply_interaction_state()
|
||
if self._stack.currentIndex() == 1:
|
||
self._refresh_detail_stats()
|
||
|
||
def _refresh_connection_card(self) -> None:
|
||
state_title, status_text = self._connection_texts()
|
||
self.connection_state_label.setText(state_title)
|
||
self.connection_engine_label.setText(self._route_engine_label())
|
||
self.connection_status_label.setText(status_text)
|
||
self.connection_target_label.setText(self._selected_node_summary())
|
||
self.toggle_btn.setText(self._toggle_action_text())
|
||
self.toggle_btn.setIcon(FIF.PAUSE_BOLD if self._connected else FIF.PLAY_SOLID)
|
||
self.summary_label.setText(self._summary_text())
|
||
|
||
def _refresh_profile_card(self) -> None:
|
||
selected = self._selected_node
|
||
if selected is None:
|
||
self.profile_name_label.setText("Профиль не выбран")
|
||
self.profile_endpoint_label.setText("Сначала импортируйте или выберите узел")
|
||
self.profile_group_label.setText(f"Профилей: {len(self._nodes)}")
|
||
self.profile_latency_label.setText("Задержка: --")
|
||
return
|
||
|
||
self.profile_name_label.setText(selected.name or "Безымянный профиль")
|
||
scheme = selected.scheme.upper() if selected.scheme else "NODE"
|
||
self.profile_endpoint_label.setText(f"{selected.server or '--'}:{selected.port or '--'} ({scheme})")
|
||
self.profile_group_label.setText(f"Группа: {selected.group or 'По умолчанию'}")
|
||
self.profile_latency_label.setText(f"Задержка: {_format_latency(self._effective_latency())}")
|
||
|
||
def _refresh_traffic_card(self) -> None:
|
||
self.traffic_down_label.setText(f"Загрузка: {_format_speed(self._last_down_bps)}")
|
||
self.traffic_up_label.setText(f"Выгрузка: {_format_speed(self._last_up_bps)}")
|
||
self.traffic_rtt_label.setText(f"RTT: {_format_latency(self._effective_latency())}")
|
||
self.traffic_peak_label.setText(f"Пик: {_format_speed(self._peak_bps)}")
|
||
|
||
def _refresh_routing_card(self) -> None:
|
||
if not self._settings.tun_mode:
|
||
core = "sing-box" if self._is_singbox_proxy_mode() else "xray"
|
||
self.routing_mode_label.setText(f"Routing из raw {core} config")
|
||
self.routing_dns_label.setText("DNS и routing берутся из editor JSON")
|
||
self.routing_rules_label.setText(
|
||
f"Правила {core} работают для трафика, уже вошедшего в локальный SOCKS/HTTP inbound"
|
||
)
|
||
if self._settings.enable_system_proxy:
|
||
self.routing_bypass_label.setText("Сейчас это обычно трафик приложений, которые используют системный прокси Windows")
|
||
else:
|
||
self.routing_bypass_label.setText(
|
||
f"Сист. прокси выключен: трафик попадёт в {core} только из приложений с ручной proxy-настройкой"
|
||
)
|
||
return
|
||
if self._is_xray_tun_mode():
|
||
self.routing_mode_label.setText("Routing из raw xray config")
|
||
self.routing_dns_label.setText("xray TUN подаёт системный трафик прямо в xray, системный прокси Windows здесь не используется")
|
||
self.routing_rules_label.setText(
|
||
"Process/path rules из raw xray routing начинают работать на системный трафик; GUI routing не влияет на xray TUN"
|
||
)
|
||
self.routing_bypass_label.setText(
|
||
"Режим experimental: live traffic totals работают, но process traffic telemetry остаётся скромнее, чем у sing-box TUN"
|
||
)
|
||
return
|
||
if self._settings.tun_mode and self._settings.tun_engine == "singbox":
|
||
self.routing_mode_label.setText("Routing из raw sing-box config")
|
||
self.routing_dns_label.setText("DNS и routing берутся из editor JSON")
|
||
self.routing_rules_label.setText(
|
||
"sing-box TUN перехватывает системный трафик, поэтому process/path rules работают полноценно"
|
||
)
|
||
self.routing_bypass_label.setText(
|
||
"GUI routing не влияет на sing-box. Unsupported node transports вроде xhttp будут автоматически "
|
||
"обслужены через local xray sidecar внутри этого же режима."
|
||
)
|
||
return
|
||
self.routing_mode_label.setText(_mode_title(self._routing.mode))
|
||
self.routing_dns_label.setText(f"DNS: {self._routing.dns_mode.title()}")
|
||
self.routing_rules_label.setText(
|
||
f"Прямые: {len(self._routing.direct_domains)} Прокси: {len(self._routing.proxy_domains)} Блок: {len(self._routing.block_domains)}"
|
||
)
|
||
bypass = "включён" if self._routing.bypass_lan else "выключен"
|
||
self.routing_bypass_label.setText(f"Обход LAN: {bypass}")
|
||
|
||
def _refresh_detail_stats(self) -> None:
|
||
self._detail_down_label.setText(f"Загрузка: {_format_speed(self._last_down_bps)}")
|
||
self._detail_up_label.setText(f"Выгрузка: {_format_speed(self._last_up_bps)}")
|
||
self._detail_rtt_label.setText(f"RTT: {_format_latency(self._effective_latency())}")
|
||
self._detail_peak_label.setText(f"Пик: {_format_speed(self._peak_bps)}")
|
||
|
||
# ── Traffic subpage navigation ────────────────────────────
|
||
|
||
def _show_traffic_page(self) -> None:
|
||
"""Switch to the traffic detail subpage."""
|
||
self._detail_graph.set_data(self._down_history, self._up_history)
|
||
self._refresh_detail_stats()
|
||
self._reset_traffic_breadcrumb()
|
||
self._stack.setCurrentIndex(1)
|
||
|
||
def _show_main_page(self) -> None:
|
||
"""Switch back to the main dashboard."""
|
||
self._stack.setCurrentIndex(0)
|
||
|
||
def _on_traffic_breadcrumb(self, routeKey: str) -> None:
|
||
if routeKey == "dashboard":
|
||
self._show_main_page()
|
||
|
||
def _reset_traffic_breadcrumb(self) -> None:
|
||
"""Reset breadcrumb to initial two-item state."""
|
||
self._traffic_breadcrumb.blockSignals(True)
|
||
self._traffic_breadcrumb.clear()
|
||
self._traffic_breadcrumb.addItem("dashboard", "Панель управления")
|
||
self._traffic_breadcrumb.addItem("traffic", "Трафик")
|
||
self._traffic_breadcrumb.blockSignals(False)
|
||
|
||
# ── Process subpage navigation ──────────────────────────
|
||
|
||
def _show_proc_page(self) -> None:
|
||
self._update_proc_detail_table()
|
||
self._reset_proc_breadcrumb()
|
||
self._stack.setCurrentIndex(2)
|
||
|
||
def _on_proc_breadcrumb(self, routeKey: str) -> None:
|
||
if routeKey == "dashboard":
|
||
self._show_main_page()
|
||
|
||
def _reset_proc_breadcrumb(self) -> None:
|
||
self._proc_breadcrumb.blockSignals(True)
|
||
self._proc_breadcrumb.clear()
|
||
self._proc_breadcrumb.addItem("dashboard", "Панель управления")
|
||
self._proc_breadcrumb.addItem("processes", "Трафик по процессам")
|
||
self._proc_breadcrumb.blockSignals(False)
|
||
|
||
def _update_proc_detail_table(self) -> None:
|
||
"""Refresh detail table from the latest cached process stats."""
|
||
self._apply_process_stats_to_table(self._proc_detail_table, self._last_process_stats or [])
|
||
|
||
def _clear_process_tables(self) -> None:
|
||
self._proc_traffic_table.setRowCount(0)
|
||
self._proc_detail_table.setRowCount(0)
|
||
|
||
def _apply_process_stats_to_table(self, table: TableWidget, stats: list) -> None:
|
||
table.setUpdatesEnabled(False)
|
||
try:
|
||
if table.rowCount() != len(stats):
|
||
table.setRowCount(len(stats))
|
||
for row, ps in enumerate(stats):
|
||
speed = f"↓{_format_speed(ps.down_speed)} ↑{_format_speed(ps.up_speed)}"
|
||
conn_text = f"{ps.connections} ({ps.total_connections})" if ps.total_connections > ps.connections else str(ps.connections)
|
||
host = ps.top_host
|
||
if len(host) > 30:
|
||
host = host[:27] + "..."
|
||
total = ps.upload + ps.download
|
||
|
||
self._set_table_text(table, row, 0, ps.exe)
|
||
self._set_table_text(table, row, 1, speed)
|
||
vpn_item = self._set_table_text(table, row, 2, self._format_bytes(ps.proxy_bytes))
|
||
vpn_item.setForeground(QColor("#2ecc71") if ps.proxy_bytes > 0 else QBrush())
|
||
self._set_table_text(table, row, 3, self._format_bytes(ps.direct_bytes))
|
||
self._set_table_text(table, row, 4, conn_text)
|
||
self._set_table_text(table, row, 5, host)
|
||
self._set_table_text(table, row, 6, self._format_bytes(total))
|
||
finally:
|
||
table.setUpdatesEnabled(True)
|
||
|
||
def _set_table_text(self, table: TableWidget, row: int, column: int, text: str) -> QTableWidgetItem:
|
||
item = table.item(row, column)
|
||
if item is None:
|
||
item = QTableWidgetItem(text)
|
||
table.setItem(row, column, item)
|
||
return item
|
||
if item.text() != text:
|
||
item.setText(text)
|
||
return item
|
||
|
||
# ── Helpers ───────────────────────────────────────────────
|
||
|
||
def _effective_latency(self) -> int | None:
|
||
return self._live_rtt_ms if self._live_rtt_ms is not None else self._selected_latency_ms
|
||
|
||
def _is_xray_tun_mode(self) -> bool:
|
||
return bool(self._settings.tun_mode and self._settings.tun_engine == "xray")
|
||
|
||
def _is_tun2socks_mode(self) -> bool:
|
||
return bool(self._settings.tun_mode and self._settings.tun_engine == "tun2socks")
|
||
|
||
def _is_singbox_proxy_mode(self) -> bool:
|
||
return bool(not self._settings.tun_mode and self._settings.proxy_engine == "singbox")
|
||
|
||
def _route_engine_label(self) -> str:
|
||
if self._settings.tun_mode:
|
||
if self._settings.tun_engine == "singbox":
|
||
return "VPN (TUN) -> sing-box (recommended, auto hybrid)"
|
||
if self._is_xray_tun_mode():
|
||
config_name = Path(self._settings.xray_config_file or "default.json").name
|
||
return f"VPN (TUN) -> xray (experimental) · raw xray config: {config_name}"
|
||
return "VPN (TUN) -> tun2socks"
|
||
if self._is_singbox_proxy_mode():
|
||
config_name = Path(self._settings.singbox_config_file or "default.json").name
|
||
target = f"sing-box extended config: {config_name}"
|
||
else:
|
||
config_name = Path(self._settings.xray_config_file or "default.json").name
|
||
target = f"xray config: {config_name}"
|
||
if self._settings.enable_system_proxy:
|
||
return f"Системный прокси Windows -> {target}"
|
||
return f"Локальный proxy -> {target} (только вручную настроенные приложения)"
|
||
|
||
def _default_connection_message(self) -> str:
|
||
action = "VPN" if self._settings.tun_mode else "Прокси"
|
||
return f"{action} {'работает' if self._connected else 'остановлен'}"
|
||
|
||
def _singbox_editor_summary(self) -> str:
|
||
config_name = Path(self._settings.singbox_config_file or "default.json").name
|
||
return f"sing-box config: {config_name}"
|
||
|
||
def _xray_editor_summary(self) -> str:
|
||
config_name = Path(self._settings.xray_config_file or "default.json").name
|
||
return f"xray config: {config_name}"
|
||
|
||
def _connection_texts(self) -> tuple[str, str]:
|
||
if self._connection_phase == "starting":
|
||
return "Запуск", self._connection_message
|
||
if self._connection_phase == "error":
|
||
return "Ошибка", self._connection_message
|
||
if self._connection_phase == "running" or self._connected:
|
||
return "Подключено", self._connection_message or self._default_connection_message()
|
||
return "Ожидание", self._connection_message or self._default_connection_message()
|
||
|
||
def _toggle_action_text(self) -> str:
|
||
if self._settings.tun_mode:
|
||
return "Остановить VPN" if self._connected else "Запустить VPN"
|
||
return "Остановить прокси" if self._connected else "Запустить прокси"
|
||
|
||
def _selected_node_summary(self) -> str:
|
||
if self._selected_node is None:
|
||
if (self._settings.tun_mode and self._settings.tun_engine == "singbox") or self._is_singbox_proxy_mode():
|
||
return self._singbox_editor_summary()
|
||
if (not self._settings.tun_mode and self._settings.proxy_engine == "xray") or self._is_xray_tun_mode():
|
||
return self._xray_editor_summary()
|
||
return "Активный профиль не выбран"
|
||
group = self._selected_node.group or "По умолчанию"
|
||
scheme = self._selected_node.scheme.upper() if self._selected_node.scheme else "NODE"
|
||
server = self._selected_node.server or "unknown-host"
|
||
port = self._selected_node.port or "--"
|
||
return f"{group} {scheme} {server}:{port}"
|
||
|
||
def _summary_text(self) -> str:
|
||
if self._connection_phase in {"starting", "error"}:
|
||
return self._connection_message
|
||
if self._selected_node is None:
|
||
if (self._settings.tun_mode and self._settings.tun_engine == "singbox") or self._is_singbox_proxy_mode():
|
||
return (
|
||
f"Готов к запуску: {self._singbox_editor_summary()}"
|
||
if not self._connected
|
||
else f"Активный сеанс: {self._singbox_editor_summary()}"
|
||
)
|
||
if (not self._settings.tun_mode and self._settings.proxy_engine == "xray") or self._is_xray_tun_mode():
|
||
return (
|
||
f"Готов к запуску: {self._xray_editor_summary()}"
|
||
if not self._connected
|
||
else f"Активный сеанс: {self._xray_editor_summary()}"
|
||
)
|
||
return "Выберите узел, чтобы запустить прокси или VPN и просмотреть состояние сеанса."
|
||
if not self._settings.tun_mode and not self._settings.enable_system_proxy:
|
||
core = "sing-box" if self._is_singbox_proxy_mode() else "xray"
|
||
if self._connected:
|
||
return f"Активен локальный {core} proxy: только приложения с ручной proxy-настройкой попадут в {core}"
|
||
return f"Готов к запуску локального {core} proxy: без системного прокси приложения должны быть настроены вручную"
|
||
if self._connected:
|
||
return f"Активный сеанс: {self._selected_node_summary()}"
|
||
return f"Готов к запуску: {self._selected_node_summary()}"
|
||
|
||
def _node_title(self, node: Node) -> str:
|
||
name = node.name or node.server or "Безымянный"
|
||
scheme = node.scheme.upper() if node.scheme else "NODE"
|
||
return f"{name} ({scheme})"
|
||
|
||
# ── Signal handlers ───────────────────────────────────────
|
||
|
||
def _on_node_changed(self, index: int) -> None:
|
||
if 0 <= index < len(self._node_ids):
|
||
self.node_selected.emit(self._node_ids[index])
|
||
|
||
def _on_mode_changed(self, index: int) -> None:
|
||
value = self.mode_combo.itemData(index)
|
||
if value:
|
||
self.mode_changed.emit(str(value))
|
||
|
||
def _on_tun_toggled(self, checked: bool) -> None:
|
||
self.proxy_switch.setEnabled(not checked)
|
||
self.tun_toggled.emit(checked)
|
||
|
||
def _on_proxy_toggled(self, checked: bool) -> None:
|
||
self.proxy_toggled.emit(checked)
|
||
|
||
def _sync_switches(self) -> None:
|
||
self.tun_switch.blockSignals(True)
|
||
self.tun_switch.setChecked(self._settings.tun_mode)
|
||
self.tun_switch.setText("Вкл" if self._settings.tun_mode else "Выкл")
|
||
self.tun_switch.blockSignals(False)
|
||
|
||
self.proxy_switch.blockSignals(True)
|
||
self.proxy_switch.setChecked(self._settings.enable_system_proxy)
|
||
self.proxy_switch.setText("Вкл" if self._settings.enable_system_proxy else "Выкл")
|
||
self.proxy_switch.blockSignals(False)
|
||
self._apply_interaction_state()
|
||
|
||
def _apply_interaction_state(self) -> None:
|
||
has_profiles = bool(self._nodes) or not self._settings.tun_mode or (
|
||
self._settings.tun_mode and self._settings.tun_engine in {"singbox", "xray"}
|
||
)
|
||
busy = self._transition_busy or self._connection_phase == "starting"
|
||
self.toggle_btn.setEnabled(has_profiles and not busy)
|
||
self.tun_switch.setEnabled(not busy)
|
||
self.mode_combo.setEnabled(not busy and self._is_tun2socks_mode())
|
||
self.proxy_switch.setEnabled(not busy and not self._settings.tun_mode)
|