zapret-kvn/xray_fluent/ui/dashboard_page.py
loop-uh 1e6268b6a8
All checks were successful
Windows project source guards / test (push) Successful in 1m19s
fix: align Hysteria startup and defer collapsed server updates
2026-09-06 13:50:00 +03:00

848 lines
42 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, QEvent, pyqtSignal
from PyQt6.QtGui import QBrush
from PyQt6.QtWidgets import (
QAbstractItemView,
QGridLayout,
QHBoxLayout,
QHeaderView,
QSizePolicy,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from qfluentwidgets import (
BodyLabel,
CaptionLabel,
CardWidget,
SwitchSettingCard,
ExpandGroupSettingCard,
PushSettingCard,
PushButton,
IndeterminateProgressBar,
ComboBox,
FluentIcon as FIF,
PrimaryPushButton,
StrongBodyLabel,
SubtitleLabel,
SwitchButton,
TableWidget,
)
from ..diagnostics.connection_message import connection_message
from ..profiles.models import AppSettings, Node, RoutingSettings
from ..platform.windows.proxy_manager import SystemProxyState
from .base_page import ScrollablePage
from .detail_page import DetailPage, StackedSection
from .privacy import masked_endpoint, node_name_text
from .theme import success_color
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(StackedSection):
logs_requested = pyqtSignal()
toggle_connection_requested = pyqtSignal()
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._node_count = 0
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._system_proxy_state: SystemProxyState | None = None
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._initializing = 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._grid_narrow = False
self._in_grid_relayout = False
self._refresh_timer = QTimer(self)
self._refresh_timer.setSingleShot(True)
self._refresh_timer.setInterval(30)
self._refresh_timer.timeout.connect(self._do_refresh_dashboard)
# ── Root view: main dashboard (scrollable, AC7) ────────
self._main_page = ScrollablePage()
self.set_root_page(self._main_page)
container = self._main_page.body
root = self._main_page.body_layout
root.addWidget(SubtitleLabel("Панель управления", container))
self.summary_label = CaptionLabel("Краткий обзор подключения, профиля, трафика и маршрутизации.", self)
self.summary_label.setWordWrap(True)
self.summary_label.hide()
self._main_page.scroll_area.viewport().installEventFilter(self)
grid = QGridLayout()
grid.setHorizontalSpacing(12)
grid.setVerticalSpacing(12)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(1, 1)
self._cards_grid = grid
# ── 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_state_label.setWordWrap(True)
self.connection_engine_label = BodyLabel("Системный прокси", self.connection_card)
self.connection_engine_label.setWordWrap(True)
self.connection_ports_label = CaptionLabel("", self.connection_card)
self.connection_ports_label.setWordWrap(True)
self.connection_ports_label.setVisible(False)
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)
connection_layout.addWidget(self.connection_ports_label)
# Toggle button inside connection card
self.toggle_btn = PrimaryPushButton(FIF.PLAY_SOLID, "Запустить прокси", self.connection_card)
connection_layout.addWidget(self.toggle_btn)
self.startup_progress = IndeterminateProgressBar(self.connection_card)
self.startup_progress.hide()
connection_layout.addWidget(self.startup_progress)
self.logs_btn = PushButton(FIF.DOCUMENT, "Открыть логи", self.connection_card)
self.logs_btn.clicked.connect(self.logs_requested)
self.logs_btn.hide()
connection_layout.addWidget(self.logs_btn)
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)
metrics = QHBoxLayout()
for label in (self.traffic_down_label, self.traffic_up_label, self.traffic_rtt_label):
metrics.addWidget(label, 1)
traffic_layout.addLayout(metrics)
traffic_layout.addWidget(self.traffic_graph, 1)
traffic_layout.addWidget(self.traffic_peak_label)
_col_tooltips = [
"Имя исполняемого файла приложения",
"Текущая скорость загрузки/выгрузки",
"Объём трафика через VPN (зашифрованный, через прокси-сервер)",
"Объём трафика напрямую (без VPN, к серверу напрямую)",
"Активные соединения (всего за сессию)",
"Домен или IP с наибольшим трафиком",
"Общий объём трафика за сессию",
]
# ── Routing card ──────────────────────────────────────
self.routing_card = QWidget(self)
controls_layout = QVBoxLayout(self.routing_card)
controls_layout.setContentsMargins(0, 0, 0, 0)
controls_layout.setSpacing(12)
self.tun_card = SwitchSettingCard(FIF.GLOBE, "VPN (TUN)", parent=self.routing_card)
self.proxy_card = SwitchSettingCard(FIF.LINK, "Системный прокси", parent=self.routing_card)
self.tun_switch = self.tun_card.switchButton
self.proxy_switch = self.proxy_card.switchButton
for switch in (self.tun_switch, self.proxy_switch):
switch.setOnText("Вкл")
switch.setOffText("Выкл")
controls_layout.addWidget(self.tun_card)
controls_layout.addWidget(self.proxy_card)
self.routing_expander = ExpandGroupSettingCard(FIF.SETTING, "Маршрутизация", "Подробности подключения", self.routing_card)
controls_layout.addWidget(self.routing_expander)
controls_layout.addStretch(1)
routing_details = QWidget(self.routing_expander)
routing_layout = QVBoxLayout(routing_details)
routing_layout.setContentsMargins(18, 16, 18, 16)
self.routing_expander.addGroupWidget(routing_details)
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_mode_label.setWordWrap(True)
self.routing_dns_label = CaptionLabel("DNS: Системный", self.routing_card)
self.routing_dns_label.setWordWrap(True)
self.routing_rules_label = CaptionLabel("Прямые: 0 Прокси: 0 Блок: 0", self.routing_card)
self.routing_rules_label.setWordWrap(True)
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)
for card in (
self.connection_card,
self.routing_card,
self.traffic_card,
):
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
grid.addWidget(self.connection_card, 0, 0)
grid.addWidget(self.routing_card, 0, 1)
root.addLayout(grid)
root.addWidget(self.traffic_card)
self.process_link = PushSettingCard("Открыть", FIF.APPLICATION, "Трафик по процессам", "Статистика появится после подключения", parent=container)
self.process_link.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.process_link.clicked.connect(self._show_proc_page)
root.addWidget(self.process_link)
root.addStretch(1)
# ── Sub-page: traffic detail (scrollable, AC7) ──
self._traffic_detail_page = DetailPage(
"Панель управления",
"Трафик",
root_key="dashboard",
page_key="traffic",
)
self.add_sub_page(self._traffic_detail_page)
detail_layout = self._traffic_detail_page.content_layout
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)
# ── Sub-page: process traffic detail (scrollable, AC7) ──
self._proc_detail_page = DetailPage(
"Панель управления",
"Трафик по процессам",
root_key="dashboard",
page_key="processes",
)
self.add_sub_page(self._proc_detail_page)
proc_detail_layout = self._proc_detail_page.content_layout
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.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.show_root()
self._sync_switches()
self._refresh_dashboard()
# ── Adaptive card grid (AC10) ─────────────────────────────
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._update_adaptive_grid()
def _update_adaptive_grid(self) -> None:
"""Move the routing card to its own row when the viewport is narrow.
Below 900 px the two top cards no longer fit side by side, so the
routing card is re-anchored to a second grid row (spanning both
columns); at >= 900 px the original two-column layout is restored.
Widgets are moved with removeWidget/addWidget — never recreated.
"""
if self._in_grid_relayout:
return
narrow = self._main_page.scroll_area.viewport().width() < 900
if narrow == self._grid_narrow:
return
self._in_grid_relayout = True
try:
self._grid_narrow = narrow
self._cards_grid.removeWidget(self.routing_card)
self._cards_grid.removeWidget(self.connection_card)
if narrow:
self._cards_grid.addWidget(self.connection_card, 0, 0, 1, 2)
self._cards_grid.addWidget(self.routing_card, 1, 0, 1, 2)
else:
self._cards_grid.addWidget(self.connection_card, 0, 0)
self._cards_grid.addWidget(self.routing_card, 0, 1)
finally:
self._in_grid_relayout = False
def eventFilter(self, obj, event):
if obj is self._main_page.scroll_area.viewport() and event.type() == QEvent.Type.Resize:
QTimer.singleShot(0, self._update_adaptive_grid)
return super().eventFilter(obj, event)
def set_initializing(self, initializing: bool):
self._initializing = initializing
self.startup_progress.setVisible(initializing)
if initializing:
self.startup_progress.start()
else:
self.startup_progress.stop()
self._refresh_dashboard()
# ── Public API ────────────────────────────────────────────
def set_nodes(self, nodes: list[Node], selected_node_id: str | None) -> None:
self._node_count = len(nodes)
self._selected_node = next(
(node for node in nodes if selected_node_id and node.id == selected_node_id),
nodes[0] if nodes else None,
)
self._refresh_dashboard()
def set_selected_node(self, node: Node | None) -> None:
self._selected_node = node
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
self.process_link.setContent("Статистика появится после подключения")
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 = connection_message(message) 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._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_system_proxy_state(self, state: SystemProxyState | None) -> None:
"""Реальное состояние системного прокси Windows (снимок из реестра).
На не-Windows (``supported=False``) переключатель по-прежнему отражает
сохранённый флаг ``enable_system_proxy``.
"""
self._system_proxy_state = state
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.currentWidget() is self._traffic_detail_page:
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()
self.process_link.setContent("Статистика недоступна для текущего подключения")
return
self._last_process_stats = list(stats)
self.process_link.setContent(f"Процессов: {len(stats)}" if stats else "Активных соединений пока нет")
if self._stack.currentWidget() is self._proc_detail_page:
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.currentWidget() is self._traffic_detail_page:
self._refresh_detail_stats()
def _refresh_connection_card(self) -> None:
state_title, status_text = self._connection_texts()
proxy_note = self._system_proxy_note()
if proxy_note:
status_text = f"{status_text} • {proxy_note}" if status_text else proxy_note
self.connection_state_label.setText("Подготовка" if self._initializing else state_title)
self.logs_btn.setVisible(self._connection_phase == "error")
core = self._settings.tun_engine if self._settings.tun_mode else self._settings.proxy_engine
self.connection_engine_label.setText(f"{'VPN' if self._settings.tun_mode else 'Прокси'} · {core}")
self.connection_engine_label.setToolTip(self._route_engine_label())
ports_text = self._proxy_ports_text()
self.connection_ports_label.setText(ports_text)
self.connection_ports_label.setVisible(bool(ports_text))
self.connection_status_label.setText("Загрузка данных…" if self._initializing else status_text)
self.connection_target_label.setText(self._selected_node_summary())
self.toggle_btn.setText("Подготовка…" if self._initializing else 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"Профилей: {self._node_count}")
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"{masked_endpoint()} ({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; ссылки Hysteria2 — через официальный Hysteria 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.show_sub_page(self._traffic_detail_page)
def _show_main_page(self) -> None:
"""Switch back to the main dashboard."""
self.show_root()
# ── Process subpage navigation ──────────────────────────
def _show_proc_page(self) -> None:
self._update_proc_detail_table()
self.show_sub_page(self._proc_detail_page)
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_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(success_color() 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 _proxy_ports_text(self) -> str:
if (
not self._connected
or self._settings.tun_mode
or self._proxy_socks_port <= 0
or self._proxy_http_port <= 0
):
return ""
socks_role = "Mixed (SOCKS5 + HTTP)" if self._is_singbox_proxy_mode() else "SOCKS5"
return (
f"{socks_role}: 127.0.0.1:{self._proxy_socks_port} · "
f"HTTP: 127.0.0.1:{self._proxy_http_port}"
)
def _system_proxy_note(self) -> str:
"""Пояснение о реальном состоянии прокси Windows (чужой прокси / PAC)."""
state = self._system_proxy_state
if state is None or not state.supported:
return ""
if state.autoconfig_url:
return "PAC-скрипт активен в Windows"
if state.enabled and not state.is_ours:
return "Системный прокси: включён (другое приложение)"
return ""
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"
return f"{node_name_text(self._selected_node)} · {scheme} · {masked_endpoint()}"
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()}"
# ── Signal handlers ───────────────────────────────────────
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)
proxy_on = self._settings.enable_system_proxy
state = self._system_proxy_state
if state is not None and state.supported and state.enabled and state.is_ours:
# Наш прокси реально активен в Windows — показываем «Вкл»,
# даже если сохранённый флаг ещё не синхронизирован.
proxy_on = True
self.proxy_switch.blockSignals(True)
self.proxy_switch.setChecked(proxy_on)
self.proxy_switch.setText("Вкл" if proxy_on else "Выкл")
self.proxy_switch.blockSignals(False)
self._apply_interaction_state()
def _apply_interaction_state(self) -> None:
has_profiles = self._node_count > 0 or not self._settings.tun_mode or (
self._settings.tun_mode and self._settings.tun_engine in {"singbox", "xray"}
)
busy = self._initializing or 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.setVisible(self._is_tun2socks_mode())
self.mode_combo.setEnabled(not busy and self._is_tun2socks_mode())
self.proxy_switch.setEnabled(not busy and not self._settings.tun_mode)