Files
elmprodvpn/selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py

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