735 lines
36 KiB
Python
735 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QSize, Qt
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QCheckBox,
|
|
QComboBox,
|
|
QFormLayout,
|
|
QGroupBox,
|
|
QHeaderView,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QListView,
|
|
QListWidget,
|
|
QPlainTextEdit,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QScrollArea,
|
|
QSpinBox,
|
|
QTableWidget,
|
|
QStyle,
|
|
QToolButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
QFrame,
|
|
)
|
|
|
|
|
|
class UITabsSingBoxLayoutMixin:
|
|
def _build_tab_singbox(self) -> None:
|
|
tab = QWidget()
|
|
layout = QVBoxLayout(tab)
|
|
|
|
metrics_row = QHBoxLayout()
|
|
layout.addLayout(metrics_row)
|
|
|
|
(
|
|
_card_conn,
|
|
self.lbl_singbox_metric_conn_value,
|
|
self.lbl_singbox_metric_conn_sub,
|
|
) = self._create_singbox_metric_card("Connection")
|
|
metrics_row.addWidget(_card_conn, stretch=1)
|
|
|
|
(
|
|
_card_profile,
|
|
self.lbl_singbox_metric_profile_value,
|
|
self.lbl_singbox_metric_profile_sub,
|
|
) = self._create_singbox_metric_card("Profile")
|
|
metrics_row.addWidget(_card_profile, stretch=1)
|
|
|
|
(
|
|
_card_proto,
|
|
self.lbl_singbox_metric_proto_value,
|
|
self.lbl_singbox_metric_proto_sub,
|
|
) = self._create_singbox_metric_card("Protocol / Transport / Security")
|
|
metrics_row.addWidget(_card_proto, stretch=1)
|
|
|
|
(
|
|
_card_policy,
|
|
self.lbl_singbox_metric_policy_value,
|
|
self.lbl_singbox_metric_policy_sub,
|
|
) = self._create_singbox_metric_card("Routing / DNS / Killswitch")
|
|
metrics_row.addWidget(_card_policy, stretch=1)
|
|
|
|
profiles_group = QGroupBox("Connection profiles")
|
|
profiles_layout = QVBoxLayout(profiles_group)
|
|
profiles_actions = QHBoxLayout()
|
|
self.btn_singbox_profile_create = QPushButton("Create connection")
|
|
self.btn_singbox_profile_create.clicked.connect(self.on_singbox_create_connection_click)
|
|
profiles_actions.addWidget(self.btn_singbox_profile_create)
|
|
profiles_actions.addStretch(1)
|
|
profiles_layout.addLayout(profiles_actions)
|
|
|
|
self.lst_singbox_profile_cards = QListWidget()
|
|
self.lst_singbox_profile_cards.setViewMode(QListView.IconMode)
|
|
self.lst_singbox_profile_cards.setResizeMode(QListView.Adjust)
|
|
self.lst_singbox_profile_cards.setMovement(QListView.Static)
|
|
self.lst_singbox_profile_cards.setWrapping(True)
|
|
self.lst_singbox_profile_cards.setSpacing(8)
|
|
self.lst_singbox_profile_cards.setGridSize(QSize(240, 88))
|
|
self.lst_singbox_profile_cards.setMinimumHeight(110)
|
|
self.lst_singbox_profile_cards.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.lst_singbox_profile_cards.customContextMenuRequested.connect(
|
|
self.on_singbox_profile_card_context_menu
|
|
)
|
|
self.lst_singbox_profile_cards.itemSelectionChanged.connect(
|
|
self.on_singbox_profile_card_selected
|
|
)
|
|
profiles_layout.addWidget(self.lst_singbox_profile_cards)
|
|
layout.addWidget(profiles_group)
|
|
|
|
card_group = QGroupBox("Connection card (runtime)")
|
|
card_layout = QVBoxLayout(card_group)
|
|
card_row = QHBoxLayout()
|
|
card_layout.addLayout(card_row)
|
|
|
|
self.lbl_transport_selected_engine = QLabel("Selected profile: —")
|
|
self.lbl_transport_selected_engine.setStyleSheet("color: gray;")
|
|
card_row.addWidget(self.lbl_transport_selected_engine, stretch=1)
|
|
|
|
self.cmb_transport_engine = QComboBox()
|
|
self.cmb_transport_engine.setMaxVisibleItems(10)
|
|
self.cmb_transport_engine.currentIndexChanged.connect(
|
|
self.on_transport_engine_selected
|
|
)
|
|
# Hidden selector: internal state source (tiles are the visible selection control).
|
|
self.cmb_transport_engine.setVisible(False)
|
|
|
|
self.btn_transport_engine_refresh = QToolButton()
|
|
self.btn_transport_engine_refresh.setAutoRaise(True)
|
|
self.btn_transport_engine_refresh.setIcon(
|
|
self.style().standardIcon(QStyle.SP_BrowserReload)
|
|
)
|
|
self.btn_transport_engine_refresh.setToolTip("Refresh engines")
|
|
self.btn_transport_engine_refresh.clicked.connect(
|
|
self.on_transport_engine_refresh
|
|
)
|
|
card_row.addWidget(self.btn_transport_engine_refresh)
|
|
|
|
self.btn_transport_engine_provision = QPushButton("Prepare")
|
|
self.btn_transport_engine_provision.setToolTip(
|
|
"Optional: pre-provision runtime/config artifacts for selected profile"
|
|
)
|
|
self.btn_transport_engine_provision.clicked.connect(
|
|
lambda: self.on_transport_engine_action("provision")
|
|
)
|
|
card_row.addWidget(self.btn_transport_engine_provision)
|
|
|
|
self.btn_transport_engine_toggle = QPushButton("Disconnected")
|
|
self.btn_transport_engine_toggle.setCheckable(True)
|
|
self.btn_transport_engine_toggle.setToolTip(
|
|
"Toggle connection for selected profile"
|
|
)
|
|
self.btn_transport_engine_toggle.clicked.connect(
|
|
self.on_transport_engine_toggle
|
|
)
|
|
card_row.addWidget(self.btn_transport_engine_toggle)
|
|
|
|
self.btn_transport_engine_restart = QPushButton("Restart")
|
|
self.btn_transport_engine_restart.clicked.connect(
|
|
lambda: self.on_transport_engine_action("restart")
|
|
)
|
|
card_row.addWidget(self.btn_transport_engine_restart)
|
|
|
|
self.btn_transport_engine_rollback = QPushButton("Rollback policy")
|
|
self.btn_transport_engine_rollback.clicked.connect(
|
|
self.on_transport_policy_rollback
|
|
)
|
|
card_row.addWidget(self.btn_transport_engine_rollback)
|
|
|
|
self.btn_transport_netns_toggle = QPushButton("Debug netns: OFF")
|
|
self.btn_transport_netns_toggle.setToolTip(
|
|
"Toggle netns for all SingBox engines (debug/testing)"
|
|
)
|
|
self.btn_transport_netns_toggle.clicked.connect(
|
|
self.on_transport_netns_toggle
|
|
)
|
|
card_row.addWidget(self.btn_transport_netns_toggle)
|
|
|
|
self.lbl_transport_engine_meta = QLabel("Engine: loading...")
|
|
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
|
|
card_layout.addWidget(self.lbl_transport_engine_meta)
|
|
|
|
layout.addWidget(card_group)
|
|
|
|
settings_toggle_row = QHBoxLayout()
|
|
self.btn_singbox_toggle_profile_settings = QPushButton("Profile settings")
|
|
self.btn_singbox_toggle_profile_settings.setCheckable(True)
|
|
self.btn_singbox_toggle_profile_settings.clicked.connect(
|
|
self.on_toggle_singbox_profile_settings
|
|
)
|
|
settings_toggle_row.addWidget(self.btn_singbox_toggle_profile_settings)
|
|
self.btn_singbox_toggle_global_defaults = QPushButton("Global defaults")
|
|
self.btn_singbox_toggle_global_defaults.setCheckable(True)
|
|
self.btn_singbox_toggle_global_defaults.clicked.connect(
|
|
self.on_toggle_singbox_global_defaults
|
|
)
|
|
settings_toggle_row.addWidget(self.btn_singbox_toggle_global_defaults)
|
|
self.btn_singbox_toggle_activity = QPushButton("Activity log")
|
|
self.btn_singbox_toggle_activity.setCheckable(True)
|
|
self.btn_singbox_toggle_activity.clicked.connect(
|
|
self.on_toggle_singbox_activity
|
|
)
|
|
settings_toggle_row.addWidget(self.btn_singbox_toggle_activity)
|
|
settings_toggle_row.addStretch(1)
|
|
layout.addLayout(settings_toggle_row)
|
|
|
|
profile_group = QGroupBox("Profile settings (SingBox)")
|
|
self.grp_singbox_profile_settings = profile_group
|
|
profile_layout = QVBoxLayout(profile_group)
|
|
|
|
self.lbl_singbox_profile_name = QLabel("Profile: —")
|
|
self.lbl_singbox_profile_name.setStyleSheet("color: gray;")
|
|
profile_layout.addWidget(self.lbl_singbox_profile_name)
|
|
|
|
profile_scope_row = QHBoxLayout()
|
|
self.chk_singbox_profile_use_global_routing = QCheckBox("Use global routing defaults")
|
|
self.chk_singbox_profile_use_global_routing.stateChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_routing)
|
|
self.chk_singbox_profile_use_global_dns = QCheckBox("Use global DNS defaults")
|
|
self.chk_singbox_profile_use_global_dns.stateChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_dns)
|
|
self.chk_singbox_profile_use_global_killswitch = QCheckBox("Use global kill-switch defaults")
|
|
self.chk_singbox_profile_use_global_killswitch.stateChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_killswitch)
|
|
profile_scope_row.addStretch(1)
|
|
profile_layout.addLayout(profile_scope_row)
|
|
|
|
profile_form = QFormLayout()
|
|
self.cmb_singbox_profile_routing = QComboBox()
|
|
self.cmb_singbox_profile_routing.addItem("Global default", "global")
|
|
self.cmb_singbox_profile_routing.addItem("Selective", "selective")
|
|
self.cmb_singbox_profile_routing.addItem("Full tunnel", "full")
|
|
self.cmb_singbox_profile_routing.currentIndexChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_form.addRow("Routing mode:", self.cmb_singbox_profile_routing)
|
|
|
|
self.cmb_singbox_profile_dns = QComboBox()
|
|
self.cmb_singbox_profile_dns.addItem("Global default", "global")
|
|
self.cmb_singbox_profile_dns.addItem("System resolver", "system_resolver")
|
|
self.cmb_singbox_profile_dns.addItem("SingBox DNS", "singbox_dns")
|
|
self.cmb_singbox_profile_dns.currentIndexChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_form.addRow("DNS mode:", self.cmb_singbox_profile_dns)
|
|
|
|
self.cmb_singbox_profile_killswitch = QComboBox()
|
|
self.cmb_singbox_profile_killswitch.addItem("Global default", "global")
|
|
self.cmb_singbox_profile_killswitch.addItem("Enabled", "on")
|
|
self.cmb_singbox_profile_killswitch.addItem("Disabled", "off")
|
|
self.cmb_singbox_profile_killswitch.currentIndexChanged.connect(
|
|
self.on_singbox_profile_scope_changed
|
|
)
|
|
profile_form.addRow("Kill-switch:", self.cmb_singbox_profile_killswitch)
|
|
profile_layout.addLayout(profile_form)
|
|
|
|
profile_actions = QHBoxLayout()
|
|
self.btn_singbox_profile_preview = QPushButton("Preview render")
|
|
self.btn_singbox_profile_preview.clicked.connect(self.on_singbox_profile_preview)
|
|
profile_actions.addWidget(self.btn_singbox_profile_preview)
|
|
self.btn_singbox_profile_validate = QPushButton("Validate profile")
|
|
self.btn_singbox_profile_validate.clicked.connect(self.on_singbox_profile_validate)
|
|
profile_actions.addWidget(self.btn_singbox_profile_validate)
|
|
self.btn_singbox_profile_apply = QPushButton("Apply profile")
|
|
self.btn_singbox_profile_apply.clicked.connect(self.on_singbox_profile_apply)
|
|
profile_actions.addWidget(self.btn_singbox_profile_apply)
|
|
self.btn_singbox_profile_rollback = QPushButton("Rollback profile")
|
|
self.btn_singbox_profile_rollback.clicked.connect(self.on_singbox_profile_rollback)
|
|
profile_actions.addWidget(self.btn_singbox_profile_rollback)
|
|
self.btn_singbox_profile_history = QPushButton("History")
|
|
self.btn_singbox_profile_history.clicked.connect(self.on_singbox_profile_history)
|
|
profile_actions.addWidget(self.btn_singbox_profile_history)
|
|
self.btn_singbox_profile_save = QPushButton("Save draft")
|
|
self.btn_singbox_profile_save.clicked.connect(self.on_singbox_profile_save)
|
|
profile_actions.addWidget(self.btn_singbox_profile_save)
|
|
profile_actions.addStretch(1)
|
|
profile_layout.addLayout(profile_actions)
|
|
|
|
self.lbl_singbox_profile_effective = QLabel("Effective: routing=— | dns=— | kill-switch=—")
|
|
self.lbl_singbox_profile_effective.setStyleSheet("color: gray;")
|
|
profile_layout.addWidget(self.lbl_singbox_profile_effective)
|
|
self._build_singbox_vless_editor(profile_layout)
|
|
self._singbox_editor_default_title = self.grp_singbox_proto_editor.title()
|
|
self.grp_singbox_proto_editor.setVisible(False)
|
|
self.lbl_singbox_editor_hint = QLabel("Right-click a profile card and select Edit to open protocol settings.")
|
|
self.lbl_singbox_editor_hint.setStyleSheet("color: gray;")
|
|
profile_layout.addWidget(self.lbl_singbox_editor_hint)
|
|
|
|
layout.addWidget(profile_group)
|
|
profile_group.setVisible(False)
|
|
|
|
global_group = QGroupBox("Global defaults")
|
|
self.grp_singbox_global_defaults = global_group
|
|
global_layout = QVBoxLayout(global_group)
|
|
global_form = QFormLayout()
|
|
|
|
self.cmb_singbox_global_routing = QComboBox()
|
|
self.cmb_singbox_global_routing.addItem("Selective", "selective")
|
|
self.cmb_singbox_global_routing.addItem("Full tunnel", "full")
|
|
self.cmb_singbox_global_routing.currentIndexChanged.connect(
|
|
self.on_singbox_global_defaults_changed
|
|
)
|
|
global_form.addRow("Default routing mode:", self.cmb_singbox_global_routing)
|
|
|
|
self.cmb_singbox_global_dns = QComboBox()
|
|
self.cmb_singbox_global_dns.addItem("System resolver", "system_resolver")
|
|
self.cmb_singbox_global_dns.addItem("SingBox DNS", "singbox_dns")
|
|
self.cmb_singbox_global_dns.currentIndexChanged.connect(
|
|
self.on_singbox_global_defaults_changed
|
|
)
|
|
global_form.addRow("Default DNS mode:", self.cmb_singbox_global_dns)
|
|
|
|
self.cmb_singbox_global_killswitch = QComboBox()
|
|
self.cmb_singbox_global_killswitch.addItem("Enabled", "on")
|
|
self.cmb_singbox_global_killswitch.addItem("Disabled", "off")
|
|
self.cmb_singbox_global_killswitch.currentIndexChanged.connect(
|
|
self.on_singbox_global_defaults_changed
|
|
)
|
|
global_form.addRow("Default kill-switch:", self.cmb_singbox_global_killswitch)
|
|
global_layout.addLayout(global_form)
|
|
|
|
global_actions = QHBoxLayout()
|
|
self.btn_singbox_global_save = QPushButton("Save global defaults")
|
|
self.btn_singbox_global_save.clicked.connect(self.on_singbox_global_save)
|
|
global_actions.addWidget(self.btn_singbox_global_save)
|
|
global_actions.addStretch(1)
|
|
global_layout.addLayout(global_actions)
|
|
|
|
self.lbl_singbox_global_hint = QLabel(
|
|
"Global defaults are used by profiles with 'Use global ...' enabled."
|
|
)
|
|
self.lbl_singbox_global_hint.setStyleSheet("color: gray;")
|
|
global_layout.addWidget(self.lbl_singbox_global_hint)
|
|
|
|
layout.addWidget(global_group)
|
|
global_group.setVisible(False)
|
|
|
|
# During UI construction routes/dns widgets are not fully created yet,
|
|
# so apply local SingBox control state without touching global save path.
|
|
self._apply_singbox_profile_controls()
|
|
|
|
# Multi-interface routing tools are placed on a dedicated tab.
|
|
self.grp_singbox_activity = QGroupBox("Activity log")
|
|
activity_layout = QVBoxLayout(self.grp_singbox_activity)
|
|
self.txt_transport = QPlainTextEdit()
|
|
self.txt_transport.setReadOnly(True)
|
|
activity_layout.addWidget(self.txt_transport)
|
|
layout.addWidget(self.grp_singbox_activity, stretch=1)
|
|
self.grp_singbox_activity.setVisible(False)
|
|
self._apply_singbox_compact_visibility()
|
|
|
|
self.tabs.addTab(tab, "SingBox")
|
|
|
|
def _build_tab_multiif(self) -> None:
|
|
tab = QWidget()
|
|
layout = QVBoxLayout(tab)
|
|
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(QFrame.NoFrame)
|
|
|
|
scroll_content = QWidget()
|
|
scroll_layout = QVBoxLayout(scroll_content)
|
|
scroll_layout.setContentsMargins(0, 0, 0, 0)
|
|
scroll_layout.setSpacing(8)
|
|
|
|
self.grp_singbox_owner_locks = self._build_singbox_owner_locks_group()
|
|
scroll_layout.addWidget(self.grp_singbox_owner_locks)
|
|
scroll_layout.addStretch(1)
|
|
|
|
scroll.setWidget(scroll_content)
|
|
layout.addWidget(scroll, stretch=1)
|
|
|
|
self.tabs.addTab(tab, "MultiIF")
|
|
|
|
def _build_singbox_owner_locks_group(self) -> QGroupBox:
|
|
group = QGroupBox("Routing policy & ownership locks")
|
|
owner_locks_layout = QVBoxLayout(group)
|
|
|
|
owner_actions = QHBoxLayout()
|
|
self.btn_singbox_owner_locks_refresh = QPushButton("Refresh locks")
|
|
self.btn_singbox_owner_locks_refresh.clicked.connect(
|
|
self.on_singbox_owner_locks_refresh
|
|
)
|
|
owner_actions.addWidget(self.btn_singbox_owner_locks_refresh)
|
|
self.btn_singbox_owner_locks_clear = QPushButton("Clear locks...")
|
|
self.btn_singbox_owner_locks_clear.clicked.connect(
|
|
self.on_singbox_owner_locks_clear
|
|
)
|
|
owner_actions.addWidget(self.btn_singbox_owner_locks_clear)
|
|
owner_actions.addWidget(QLabel("Engine:"))
|
|
self.cmb_singbox_owner_engine_scope = QComboBox()
|
|
self.cmb_singbox_owner_engine_scope.addItem("All", "all")
|
|
self.cmb_singbox_owner_engine_scope.addItem("Transport", "transport")
|
|
self.cmb_singbox_owner_engine_scope.addItem("AdGuard VPN", "adguardvpn")
|
|
self.cmb_singbox_owner_engine_scope.currentIndexChanged.connect(
|
|
self.on_singbox_owner_engine_scope_changed
|
|
)
|
|
owner_actions.addWidget(self.cmb_singbox_owner_engine_scope)
|
|
owner_actions.addStretch(1)
|
|
owner_locks_layout.addLayout(owner_actions)
|
|
|
|
self.lbl_singbox_owner_locks_summary = QLabel("Ownership: — | Locks: —")
|
|
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_summary)
|
|
|
|
self.lbl_singbox_interfaces_hint = QLabel("Interfaces (read-only)")
|
|
self.lbl_singbox_interfaces_hint.setStyleSheet("color: #666;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_interfaces_hint)
|
|
|
|
self.tbl_singbox_interfaces = QTableWidget(0, 7)
|
|
self.tbl_singbox_interfaces.setHorizontalHeaderLabels(
|
|
["Iface ID", "Mode", "Runtime iface", "NetNS", "Routing table", "Clients UP/Total", "Updated"]
|
|
)
|
|
self.tbl_singbox_interfaces.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_interfaces.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.tbl_singbox_interfaces.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_interfaces.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(6, QHeaderView.Stretch)
|
|
self.tbl_singbox_interfaces.setMinimumHeight(120)
|
|
owner_locks_layout.addWidget(self.tbl_singbox_interfaces)
|
|
|
|
self.lbl_singbox_policy_quick_help = QLabel(
|
|
"Policy flow: 1) Add demo/fill intent -> 2) Validate policy -> 3) Validate & apply."
|
|
)
|
|
self.lbl_singbox_policy_quick_help.setStyleSheet("color: #1f6b2f;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_policy_quick_help)
|
|
|
|
policy_group = QGroupBox("Policy intents")
|
|
policy_layout = QVBoxLayout(policy_group)
|
|
|
|
self.lbl_singbox_policy_input_help = QLabel(
|
|
"Intent fields: selector type | selector value | client | mode | priority"
|
|
)
|
|
self.lbl_singbox_policy_input_help.setStyleSheet("color: #666;")
|
|
policy_layout.addWidget(self.lbl_singbox_policy_input_help)
|
|
|
|
policy_template_row = QHBoxLayout()
|
|
self.cmb_singbox_policy_template = QComboBox()
|
|
self.cmb_singbox_policy_template.addItem("Quick template...", "")
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"Domain -> active client (strict)",
|
|
{
|
|
"selector_type": "domain",
|
|
"selector_value": "example.com",
|
|
"mode": "strict",
|
|
"priority": 100,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"Wildcard domain (fallback)",
|
|
{
|
|
"selector_type": "domain",
|
|
"selector_value": "*.example.com",
|
|
"mode": "fallback",
|
|
"priority": 200,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"CIDR subnet (strict)",
|
|
{
|
|
"selector_type": "cidr",
|
|
"selector_value": "1.2.3.0/24",
|
|
"mode": "strict",
|
|
"priority": 100,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"IP host (strict)",
|
|
{
|
|
"selector_type": "cidr",
|
|
"selector_value": "1.2.3.4",
|
|
"mode": "strict",
|
|
"priority": 100,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"App key (strict)",
|
|
{
|
|
"selector_type": "app_key",
|
|
"selector_value": "steam",
|
|
"mode": "strict",
|
|
"priority": 100,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.addItem(
|
|
"UID (strict)",
|
|
{
|
|
"selector_type": "uid",
|
|
"selector_value": "1000",
|
|
"mode": "strict",
|
|
"priority": 100,
|
|
},
|
|
)
|
|
self.cmb_singbox_policy_template.setToolTip(
|
|
"Prefill intent fields from a template. It does not add to draft automatically."
|
|
)
|
|
policy_template_row.addWidget(self.cmb_singbox_policy_template, stretch=2)
|
|
self.btn_singbox_policy_use_template = QPushButton("Use template")
|
|
self.btn_singbox_policy_use_template.clicked.connect(self.on_singbox_policy_use_template)
|
|
policy_template_row.addWidget(self.btn_singbox_policy_use_template)
|
|
self.btn_singbox_policy_add_demo = QPushButton("Add demo intent")
|
|
self.btn_singbox_policy_add_demo.setToolTip(
|
|
"Create one test intent (domain -> selected client) and add it to draft."
|
|
)
|
|
self.btn_singbox_policy_add_demo.clicked.connect(self.on_singbox_policy_add_demo_intent)
|
|
policy_template_row.addWidget(self.btn_singbox_policy_add_demo)
|
|
policy_template_row.addStretch(1)
|
|
policy_layout.addLayout(policy_template_row)
|
|
|
|
policy_input_row = QHBoxLayout()
|
|
self.cmb_singbox_policy_selector_type = QComboBox()
|
|
self.cmb_singbox_policy_selector_type.addItem("domain", "domain")
|
|
self.cmb_singbox_policy_selector_type.addItem("cidr", "cidr")
|
|
self.cmb_singbox_policy_selector_type.addItem("app_key", "app_key")
|
|
self.cmb_singbox_policy_selector_type.addItem("cgroup", "cgroup")
|
|
self.cmb_singbox_policy_selector_type.addItem("uid", "uid")
|
|
self.cmb_singbox_policy_selector_type.currentIndexChanged.connect(
|
|
self.on_singbox_policy_selector_type_changed
|
|
)
|
|
policy_input_row.addWidget(self.cmb_singbox_policy_selector_type)
|
|
|
|
self.ent_singbox_policy_selector_value = QLineEdit()
|
|
self.ent_singbox_policy_selector_value.setPlaceholderText("example.com")
|
|
self.ent_singbox_policy_selector_value.setToolTip(
|
|
"Examples: domain=example.com, cidr=1.2.3.0/24, app_key=steam, cgroup=user.slice/..., uid=1000. Press Enter to add intent."
|
|
)
|
|
self.ent_singbox_policy_selector_value.returnPressed.connect(self.on_singbox_policy_add_intent)
|
|
policy_input_row.addWidget(self.ent_singbox_policy_selector_value, stretch=2)
|
|
|
|
self.cmb_singbox_policy_client_id = QComboBox()
|
|
self.cmb_singbox_policy_client_id.setMinimumWidth(180)
|
|
policy_input_row.addWidget(self.cmb_singbox_policy_client_id, stretch=1)
|
|
|
|
self.cmb_singbox_policy_mode = QComboBox()
|
|
self.cmb_singbox_policy_mode.addItem("strict", "strict")
|
|
self.cmb_singbox_policy_mode.addItem("fallback", "fallback")
|
|
policy_input_row.addWidget(self.cmb_singbox_policy_mode)
|
|
|
|
self.spn_singbox_policy_priority = QSpinBox()
|
|
self.spn_singbox_policy_priority.setRange(1, 10000)
|
|
self.spn_singbox_policy_priority.setValue(100)
|
|
self.spn_singbox_policy_priority.setToolTip("Intent priority")
|
|
policy_input_row.addWidget(self.spn_singbox_policy_priority)
|
|
|
|
self.btn_singbox_policy_add = QPushButton("Add intent")
|
|
self.btn_singbox_policy_add.clicked.connect(self.on_singbox_policy_add_intent)
|
|
policy_input_row.addWidget(self.btn_singbox_policy_add)
|
|
|
|
self.btn_singbox_policy_load_selected = QPushButton("Load selected")
|
|
self.btn_singbox_policy_load_selected.clicked.connect(self.on_singbox_policy_load_selected_intent)
|
|
policy_input_row.addWidget(self.btn_singbox_policy_load_selected)
|
|
|
|
self.btn_singbox_policy_update_selected = QPushButton("Update selected")
|
|
self.btn_singbox_policy_update_selected.clicked.connect(self.on_singbox_policy_update_selected_intent)
|
|
policy_input_row.addWidget(self.btn_singbox_policy_update_selected)
|
|
|
|
self.btn_singbox_policy_remove = QPushButton("Remove selected")
|
|
self.btn_singbox_policy_remove.clicked.connect(self.on_singbox_policy_remove_selected)
|
|
policy_input_row.addWidget(self.btn_singbox_policy_remove)
|
|
policy_layout.addLayout(policy_input_row)
|
|
|
|
policy_actions_row = QHBoxLayout()
|
|
self.btn_singbox_policy_reload = QPushButton("Reload policy")
|
|
self.btn_singbox_policy_reload.clicked.connect(self.on_singbox_policy_reload)
|
|
policy_actions_row.addWidget(self.btn_singbox_policy_reload)
|
|
self.btn_singbox_policy_validate = QPushButton("Validate policy")
|
|
self.btn_singbox_policy_validate.clicked.connect(self.on_singbox_policy_validate)
|
|
policy_actions_row.addWidget(self.btn_singbox_policy_validate)
|
|
self.btn_singbox_policy_apply = QPushButton("Validate & apply")
|
|
self.btn_singbox_policy_apply.clicked.connect(self.on_singbox_policy_apply)
|
|
policy_actions_row.addWidget(self.btn_singbox_policy_apply)
|
|
self.btn_singbox_policy_rollback = QPushButton("Rollback policy")
|
|
self.btn_singbox_policy_rollback.clicked.connect(self.on_singbox_policy_rollback_explicit)
|
|
policy_actions_row.addWidget(self.btn_singbox_policy_rollback)
|
|
policy_actions_row.addStretch(1)
|
|
policy_layout.addLayout(policy_actions_row)
|
|
|
|
self.lbl_singbox_policy_state = QLabel("Policy editor: loading...")
|
|
self.lbl_singbox_policy_state.setStyleSheet("color: gray;")
|
|
policy_layout.addWidget(self.lbl_singbox_policy_state)
|
|
|
|
self.lbl_singbox_policy_conflicts_hint = QLabel(
|
|
"Validation conflicts (last validate/apply, read-only)"
|
|
)
|
|
self.lbl_singbox_policy_conflicts_hint.setStyleSheet("color: #666;")
|
|
policy_layout.addWidget(self.lbl_singbox_policy_conflicts_hint)
|
|
|
|
self.tbl_singbox_policy_conflicts = QTableWidget(0, 5)
|
|
self.tbl_singbox_policy_conflicts.setHorizontalHeaderLabels(
|
|
["Type", "Severity", "Owners", "Reason", "Suggested resolution"]
|
|
)
|
|
self.tbl_singbox_policy_conflicts.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_policy_conflicts.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.tbl_singbox_policy_conflicts.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_policy_conflicts.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
|
|
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
|
|
self.tbl_singbox_policy_conflicts.setMinimumHeight(100)
|
|
policy_layout.addWidget(self.tbl_singbox_policy_conflicts)
|
|
|
|
self.tbl_singbox_policy_intents = QTableWidget(0, 5)
|
|
self.tbl_singbox_policy_intents.setHorizontalHeaderLabels(
|
|
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
|
|
)
|
|
self.tbl_singbox_policy_intents.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_policy_intents.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
self.tbl_singbox_policy_intents.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_policy_intents.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
|
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_intents.setMinimumHeight(130)
|
|
self.tbl_singbox_policy_intents.itemDoubleClicked.connect(
|
|
self.on_singbox_policy_intent_double_clicked
|
|
)
|
|
policy_layout.addWidget(self.tbl_singbox_policy_intents)
|
|
|
|
self.lbl_singbox_policy_applied_hint = QLabel(
|
|
"Applied intents (read-only, current backend policy)"
|
|
)
|
|
self.lbl_singbox_policy_applied_hint.setStyleSheet("color: #666;")
|
|
policy_layout.addWidget(self.lbl_singbox_policy_applied_hint)
|
|
|
|
self.tbl_singbox_policy_applied = QTableWidget(0, 5)
|
|
self.tbl_singbox_policy_applied.setHorizontalHeaderLabels(
|
|
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
|
|
)
|
|
self.tbl_singbox_policy_applied.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_policy_applied.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.tbl_singbox_policy_applied.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_policy_applied.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
|
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_policy_applied.setMinimumHeight(110)
|
|
policy_layout.addWidget(self.tbl_singbox_policy_applied)
|
|
|
|
owner_locks_layout.addWidget(policy_group)
|
|
|
|
self.lbl_singbox_ownership_hint = QLabel(
|
|
"Ownership (read-only, populated after policy apply)"
|
|
)
|
|
self.lbl_singbox_ownership_hint.setStyleSheet("color: #666;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_ownership_hint)
|
|
|
|
self.tbl_singbox_ownership = QTableWidget(0, 6)
|
|
self.tbl_singbox_ownership.setHorizontalHeaderLabels(
|
|
["Selector", "Owner", "Owner scope", "Iface / table", "Status", "Lock"]
|
|
)
|
|
self.tbl_singbox_ownership.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_ownership.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.tbl_singbox_ownership.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_ownership.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_ownership.setMinimumHeight(130)
|
|
owner_locks_layout.addWidget(self.tbl_singbox_ownership)
|
|
|
|
filters_row = QHBoxLayout()
|
|
self.ent_singbox_owner_lock_client = QLineEdit()
|
|
self.ent_singbox_owner_lock_client.setPlaceholderText("Filter client_id (optional)")
|
|
filters_row.addWidget(self.ent_singbox_owner_lock_client, stretch=1)
|
|
self.ent_singbox_owner_lock_destination = QLineEdit()
|
|
self.ent_singbox_owner_lock_destination.setPlaceholderText(
|
|
"Destination IP or CSV list (optional)"
|
|
)
|
|
filters_row.addWidget(self.ent_singbox_owner_lock_destination, stretch=2)
|
|
owner_locks_layout.addLayout(filters_row)
|
|
|
|
self.lbl_singbox_locks_hint = QLabel(
|
|
"Destination locks (read-only, conntrack sticky state)"
|
|
)
|
|
self.lbl_singbox_locks_hint.setStyleSheet("color: #666;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_locks_hint)
|
|
|
|
self.tbl_singbox_owner_locks = QTableWidget(0, 6)
|
|
self.tbl_singbox_owner_locks.setHorizontalHeaderLabels(
|
|
["Destination", "Owner", "Kind", "Iface", "Mark/Proto", "Updated"]
|
|
)
|
|
self.tbl_singbox_owner_locks.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
self.tbl_singbox_owner_locks.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
self.tbl_singbox_owner_locks.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
self.tbl_singbox_owner_locks.verticalHeader().setVisible(False)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(5, QHeaderView.Stretch)
|
|
self.tbl_singbox_owner_locks.setMinimumHeight(170)
|
|
owner_locks_layout.addWidget(self.tbl_singbox_owner_locks)
|
|
|
|
self.lbl_singbox_owner_locks_hint = QLabel(
|
|
"Clear flow is two-step confirm. Empty filter uses selected destination rows."
|
|
)
|
|
self.lbl_singbox_owner_locks_hint.setStyleSheet("color: gray;")
|
|
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_hint)
|
|
return group
|
|
|
|
def _create_singbox_metric_card(self, title: str) -> tuple[QFrame, QLabel, QLabel]:
|
|
frame = QFrame()
|
|
frame.setFrameShape(QFrame.StyledPanel)
|
|
frame.setObjectName("singboxMetricCard")
|
|
frame.setStyleSheet(
|
|
"""
|
|
QFrame#singboxMetricCard {
|
|
border: 1px solid #c9c9c9;
|
|
border-radius: 6px;
|
|
background: #f7f7f7;
|
|
}
|
|
"""
|
|
)
|
|
lay = QVBoxLayout(frame)
|
|
lay.setContentsMargins(10, 8, 10, 8)
|
|
lay.setSpacing(2)
|
|
|
|
lbl_title = QLabel(title)
|
|
lbl_title.setStyleSheet("color: #555; font-size: 11px;")
|
|
lay.addWidget(lbl_title)
|
|
|
|
lbl_value = QLabel("—")
|
|
lbl_value.setStyleSheet("font-weight: 600;")
|
|
lay.addWidget(lbl_value)
|
|
|
|
lbl_sub = QLabel("—")
|
|
lbl_sub.setStyleSheet("color: #666; font-size: 11px;")
|
|
lay.addWidget(lbl_sub)
|
|
return frame, lbl_value, lbl_sub
|