zapret-kvn/xray_fluent/ui/dashboard_page.py
2026-07-11 21:01:15 +03:00

848 lines
41 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
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)