from __future__ import annotations import ipaddress from types import SimpleNamespace from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication, QMessageBox, QTableWidgetItem from api_client import ApiError, TransportPolicyIntent from netns_debug import apply_singbox_netns_toggle class SingBoxRuntimeTransportMixin: def _update_transport_engine_view(self) -> None: selected = self._selected_transport_client() supported = bool(self._transport_api_supported) has_selected = selected is not None policy_supported = bool(supported) multiif_refresh_supported = bool(supported) self.btn_singbox_profile_create.setEnabled(supported) self.btn_transport_engine_provision.setEnabled(supported and has_selected) self.btn_transport_engine_toggle.setEnabled(supported and has_selected) self.btn_transport_engine_restart.setEnabled(supported and has_selected) self.btn_transport_engine_rollback.setEnabled(supported) self.btn_transport_netns_toggle.setEnabled(supported and bool(self._transport_clients)) self.btn_singbox_profile_preview.setEnabled(supported and has_selected) self.btn_singbox_profile_validate.setEnabled(supported and has_selected) self.btn_singbox_profile_apply.setEnabled(supported and has_selected) self.btn_singbox_profile_rollback.setEnabled(supported and has_selected) self.btn_singbox_profile_history.setEnabled(supported and has_selected) self.btn_singbox_profile_save.setEnabled(supported and has_selected) self.btn_singbox_owner_locks_refresh.setEnabled(multiif_refresh_supported) self.btn_singbox_owner_locks_clear.setEnabled(policy_supported) self.btn_singbox_policy_add.setEnabled(policy_supported) self.btn_singbox_policy_load_selected.setEnabled(policy_supported) self.btn_singbox_policy_update_selected.setEnabled(policy_supported) self.btn_singbox_policy_use_template.setEnabled(policy_supported) self.btn_singbox_policy_add_demo.setEnabled(policy_supported) self.btn_singbox_policy_remove.setEnabled(policy_supported) self.btn_singbox_policy_reload.setEnabled(policy_supported) self.btn_singbox_policy_validate.setEnabled(policy_supported) self.btn_singbox_policy_apply.setEnabled(policy_supported) self.btn_singbox_policy_rollback.setEnabled(policy_supported) self.cmb_singbox_policy_selector_type.setEnabled(policy_supported) self.cmb_singbox_policy_template.setEnabled(policy_supported) self.ent_singbox_policy_selector_value.setEnabled(policy_supported) self.cmb_singbox_policy_client_id.setEnabled(policy_supported) self.cmb_singbox_policy_mode.setEnabled(policy_supported) self.spn_singbox_policy_priority.setEnabled(policy_supported) if hasattr(self, "ent_singbox_owner_lock_client"): self.ent_singbox_owner_lock_client.setEnabled(policy_supported) if hasattr(self, "ent_singbox_owner_lock_destination"): self.ent_singbox_owner_lock_destination.setEnabled(policy_supported) if hasattr(self, "cmb_singbox_owner_engine_scope"): self.cmb_singbox_owner_engine_scope.setEnabled(True) self._refresh_transport_netns_toggle_button() if not supported: self._clear_singbox_editor() self._set_singbox_editor_enabled(False) self.lbl_transport_selected_engine.setText("Selected profile: API unavailable") self.lbl_transport_selected_engine.setStyleSheet("color: orange;") self.btn_transport_engine_toggle.blockSignals(True) self.btn_transport_engine_toggle.setChecked(False) self.btn_transport_engine_toggle.blockSignals(False) self.btn_transport_engine_toggle.setText("Disconnected") self.btn_transport_engine_toggle.setStyleSheet("color: gray;") self.lbl_transport_engine_meta.setText("Engine: transport API is unavailable on current backend") self.lbl_transport_engine_meta.setStyleSheet("color: orange;") self.lbl_singbox_profile_name.setText("Profile: —") self.lbl_singbox_profile_name.setStyleSheet("color: gray;") self.lbl_singbox_metric_conn_value.setText("API unavailable") self.lbl_singbox_metric_conn_sub.setText("Transport endpoints are not exposed") self.lbl_singbox_metric_profile_value.setText("—") self.lbl_singbox_metric_profile_sub.setText("—") self.lbl_singbox_metric_proto_value.setText("—") self.lbl_singbox_metric_proto_sub.setText("—") self.lbl_singbox_metric_policy_value.setText("—") self.lbl_singbox_metric_policy_sub.setText("—") return if selected is None: self._clear_singbox_editor() self._set_singbox_editor_enabled(False) self.lbl_transport_selected_engine.setText("Selected profile: —") self.lbl_transport_selected_engine.setStyleSheet("color: gray;") self.btn_transport_engine_toggle.blockSignals(True) self.btn_transport_engine_toggle.setChecked(False) self.btn_transport_engine_toggle.blockSignals(False) self.btn_transport_engine_toggle.setText("Disconnected") self.btn_transport_engine_toggle.setStyleSheet("color: gray;") self.lbl_transport_engine_meta.setText("Engine: no SingBox clients configured") self.lbl_transport_engine_meta.setStyleSheet("color: gray;") self.lbl_singbox_profile_name.setText("Profile: —") self.lbl_singbox_profile_name.setStyleSheet("color: gray;") self.lbl_singbox_metric_conn_value.setText("Not selected") self.lbl_singbox_metric_conn_sub.setText("Choose profile card below") self.lbl_singbox_metric_profile_value.setText("—") self.lbl_singbox_metric_profile_sub.setText("—") self.lbl_singbox_metric_proto_value.setText("—") self.lbl_singbox_metric_proto_sub.setText("—") self.lbl_singbox_metric_policy_value.setText("—") self.lbl_singbox_metric_policy_sub.setText("—") return status, latency, last_error, last_check = self._transport_live_health_for_client(selected) kind = str(getattr(selected, "kind", "") or "").strip().lower() or "engine" cid = str(getattr(selected, "id", "") or "").strip() iface = str(getattr(selected, "iface", "") or "").strip() table = str(getattr(selected, "routing_table", "") or "").strip() egress_item = self._refresh_egress_identity_scope( f"transport:{cid}", trigger_refresh=True, silent=True, ) egress_short = self._format_egress_identity_short(egress_item) if status == "up": color = "green" elif status in ("degraded", "unknown"): color = "orange" else: color = "gray" parts = [f"{kind} ({cid})", f"status={status}"] if iface: parts.append(f"iface={iface}") if table: parts.append(f"table={table}") if latency > 0: parts.append(f"latency={latency}ms") if last_error: short_err = last_error if len(last_error) <= 180 else last_error[:177] + "..." parts.append(f"error={short_err}") self.lbl_transport_engine_meta.setText("Engine: " + " | ".join(parts)) self.lbl_transport_engine_meta.setStyleSheet(f"color: {color};") protocol_txt = self._singbox_client_protocol_summary(selected) route, dns, killswitch = self._effective_singbox_policy() profile_title = str(getattr(selected, "name", "") or "").strip() or cid self.lbl_transport_selected_engine.setText( f"Selected profile: {profile_title} ({cid})" ) self.lbl_transport_selected_engine.setStyleSheet("color: black;") self.lbl_singbox_profile_name.setText(f"Profile: {profile_title} ({cid})") self.lbl_singbox_profile_name.setStyleSheet("color: black;") self.btn_transport_engine_toggle.blockSignals(True) if status == "up": self.btn_transport_engine_toggle.setChecked(True) self.btn_transport_engine_toggle.setText("Connected") self.btn_transport_engine_toggle.setStyleSheet("color: #1f6b2f;") self.btn_transport_engine_toggle.setToolTip("Connected. Click to disconnect.") elif status == "starting": self.btn_transport_engine_toggle.setChecked(False) self.btn_transport_engine_toggle.setText("Connecting...") self.btn_transport_engine_toggle.setStyleSheet("color: orange;") self.btn_transport_engine_toggle.setToolTip("Engine is starting. Please wait.") else: self.btn_transport_engine_toggle.setChecked(False) self.btn_transport_engine_toggle.setText("Disconnected") self.btn_transport_engine_toggle.setStyleSheet("color: gray;") self.btn_transport_engine_toggle.setToolTip("Disconnected. Click to connect.") self.btn_transport_engine_toggle.blockSignals(False) self.lbl_singbox_metric_conn_value.setText(status.upper()) conn_sub = "No latency sample" if last_error: short = last_error if len(last_error) <= 72 else last_error[:69] + "..." conn_sub = short elif latency > 0: conn_sub = f"Latency {latency}ms" if egress_short: conn_sub = f"{conn_sub} · {egress_short}" self.lbl_singbox_metric_conn_sub.setText(conn_sub) updated_at = str(getattr(selected, "updated_at", "") or "").strip() stamp = last_check or updated_at self.lbl_singbox_metric_profile_value.setText(profile_title) if stamp: self.lbl_singbox_metric_profile_sub.setText(f"{cid} · updated {stamp}") else: self.lbl_singbox_metric_profile_sub.setText(cid) self.lbl_singbox_metric_proto_value.setText(protocol_txt) tunnel_bits = [] if iface: tunnel_bits.append(f"iface={iface}") if table: tunnel_bits.append(f"table={table}") self.lbl_singbox_metric_proto_sub.setText(" | ".join(tunnel_bits) if tunnel_bits else "iface/table n/a") self.lbl_singbox_metric_policy_value.setText( f"{self._singbox_value_label('routing', route)} + {self._singbox_value_label('dns', dns)}" ) self.lbl_singbox_metric_policy_sub.setText( f"Kill-switch: {self._singbox_value_label('killswitch', killswitch)}" ) def _sort_transport_clients(self, clients) -> list: return sorted( list(clients or []), key=lambda x: ( str(getattr(x, "kind", "") or "").lower(), str(getattr(x, "name", "") or getattr(x, "id", "")).lower(), str(getattr(x, "id", "") or "").lower(), ), ) def _refresh_transport_policy_clients_cache(self) -> None: fallback = self._sort_transport_clients(self._transport_clients) try: clients = self.ctrl.transport_clients( enabled_only=False, kind="", include_virtual=True, ) self._transport_policy_clients = self._sort_transport_clients(clients) except Exception: self._transport_policy_clients = fallback def _policy_clients_for_editor(self) -> list: clients = list(getattr(self, "_transport_policy_clients", []) or []) if clients: return clients return self._sort_transport_clients(self._transport_clients) def _is_virtual_policy_client(self, client) -> bool: cid = str(getattr(client, "id", "") or "").strip().lower() kind = str(getattr(client, "kind", "") or "").strip().lower() if cid == "adguardvpn": return True return kind in ("adguardvpn", "virtual") def _policy_client_role(self, client) -> str: return "virtual" if self._is_virtual_policy_client(client) else "transport" def _policy_client_role_by_id(self, client_id: str) -> str: cid = str(client_id or "").strip().lower() if not cid: return "transport" for client in self._policy_clients_for_editor(): cur = str(getattr(client, "id", "") or "").strip().lower() if cur == cid: return self._policy_client_role(client) if cid == "adguardvpn": return "virtual" return "transport" def _policy_client_caption(self, client) -> str: cid = str(getattr(client, "id", "") or "").strip() if not cid: return "-" role = self._policy_client_role(client) name = str(getattr(client, "name", "") or "").strip() or cid base = cid if name == cid else f"{name} ({cid})" return f"[{role}] {base}" def refresh_transport_engines(self, *, silent: bool = True) -> None: prev_id = self._selected_transport_engine_id() try: clients = self.ctrl.transport_clients(enabled_only=False, kind=self._transport_kind) self._transport_api_supported = True self._transport_clients = self._sort_transport_clients(clients) status_by_id = { str(getattr(c, "id", "") or "").strip(): str(getattr(c, "status", "") or "").strip().lower() for c in self._transport_clients if str(getattr(c, "id", "") or "").strip() } keep_ids = { str(getattr(c, "id", "") or "").strip() for c in self._transport_clients if str(getattr(c, "id", "") or "").strip() } cleaned_live = {} for cid, snap in (self._transport_health_live or {}).items(): if cid not in keep_ids: continue base_status = status_by_id.get(cid, "") snap_status = str((snap or {}).get("status") or "").strip().lower() # Drop stale optimistic/live "UP" when backend state is already non-UP. if base_status and base_status != "up" and snap_status == "up": continue cleaned_live[cid] = snap self._transport_health_live = cleaned_live except ApiError as e: code = int(getattr(e, "status_code", 0) or 0) self._transport_clients = [] self._transport_policy_clients = [] self._transport_api_supported = False self._transport_health_live = {} self.cmb_transport_engine.blockSignals(True) self.cmb_transport_engine.clear() if code == 404: self.cmb_transport_engine.addItem("Transport API unavailable", "") else: self.cmb_transport_engine.addItem("Transport engine load failed", "") self.cmb_transport_engine.blockSignals(False) self._render_singbox_profile_cards() self._update_transport_engine_view() self.refresh_transport_policy_locks(silent=True) if not silent and code != 404: QMessageBox.warning(self, "Transport engine error", str(e)) return except Exception as e: self._transport_clients = [] self._transport_policy_clients = [] self._transport_api_supported = False self._transport_health_live = {} self.cmb_transport_engine.blockSignals(True) self.cmb_transport_engine.clear() self.cmb_transport_engine.addItem("Transport engine load failed", "") self.cmb_transport_engine.blockSignals(False) self._render_singbox_profile_cards() self._update_transport_engine_view() self.refresh_transport_policy_locks(silent=True) if not silent: QMessageBox.warning(self, "Transport engine error", str(e)) return self.cmb_transport_engine.blockSignals(True) self.cmb_transport_engine.clear() pick = -1 up_pick = -1 for i, c in enumerate(self._transport_clients): cid = str(getattr(c, "id", "") or "").strip() if not cid: continue kind = str(getattr(c, "kind", "") or "").strip().upper() name = str(getattr(c, "name", "") or "").strip() or cid status = str(getattr(c, "status", "") or "").strip().lower() or "unknown" self.cmb_transport_engine.addItem(f"{kind} · {name} [{status}]", cid) if prev_id and cid == prev_id: pick = self.cmb_transport_engine.count() - 1 if up_pick < 0 and status == "up": up_pick = self.cmb_transport_engine.count() - 1 if self.cmb_transport_engine.count() == 0: self.cmb_transport_engine.addItem("No SingBox clients configured", "") pick = 0 elif pick < 0: pick = up_pick if up_pick >= 0 else 0 self.cmb_transport_engine.setCurrentIndex(max(0, pick)) self.cmb_transport_engine.blockSignals(False) self._render_singbox_profile_cards() self._sync_singbox_profile_card_selection(self._selected_transport_engine_id()) self._sync_selected_singbox_profile_link(silent=True) self._load_singbox_editor_for_selected(silent=True) self._update_transport_engine_view() self.refresh_transport_policy_locks(silent=True) def _build_switch_intents( self, intents, client_id: str, ) -> tuple[list[TransportPolicyIntent], int, int]: out: list[TransportPolicyIntent] = [] changed = 0 total = 0 for it in list(intents or []): total += 1 selector_type = str(getattr(it, "selector_type", "") or "").strip().lower() selector_value = str(getattr(it, "selector_value", "") or "").strip() prev_client_id = str(getattr(it, "client_id", "") or "").strip() if not selector_type or not selector_value: continue if prev_client_id != client_id: changed += 1 try: prio = int(getattr(it, "priority", 100) or 100) except Exception: prio = 100 mode = str(getattr(it, "mode", "strict") or "strict").strip().lower() if mode not in ("strict", "fallback"): mode = "strict" out.append( TransportPolicyIntent( selector_type=selector_type, selector_value=selector_value, client_id=client_id, priority=prio if prio > 0 else 100, mode=mode, ) ) return out, changed, total def _apply_transport_switch_policy(self, client_id: str) -> tuple[bool, str]: pol = self.ctrl.transport_policy() next_intents, changed, total = self._build_switch_intents(pol.intents, client_id) if total <= 0 or not next_intents: return True, "policy intents are empty; routing switch skipped" if changed <= 0: return True, f"policy already points to {client_id}; switch skipped" flow = self.ctrl.transport_flow_draft(next_intents, base_revision=int(pol.revision)) flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True) if flow.phase == "risky": preview = [] for c in list(flow.conflicts or [])[:5]: reason = str(getattr(c, "reason", "") or "").strip() ctype = str(getattr(c, "type", "") or "").strip() or "conflict" if reason: preview.append(f"- {ctype}: {reason}") else: preview.append(f"- {ctype}") extra = "\n".join(preview) txt = ( f"Switch has blocking conflicts.\n\n" f"blocks={flow.block_count}, warns={flow.warn_count}\n\n" f"{extra}\n\n" f"Force apply anyway?" ) ans = QMessageBox.question( self, "Confirm risky switch", txt, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ans != QMessageBox.Yes: return False, "connect/switch canceled by user" flow = self.ctrl.transport_flow_confirm(flow) flow = self.ctrl.transport_flow_apply(flow, force_override=True) else: flow = self.ctrl.transport_flow_apply(flow, force_override=False) if flow.phase != "applied": return False, f"policy apply failed: {flow.message or '-'} (code={flow.code or '-'})" return True, f"policy applied revision={flow.applied_revision} apply_id={flow.apply_id or '-'}" def on_transport_engine_selected(self, _index: int = 0) -> None: self._sync_singbox_profile_card_selection(self._selected_transport_engine_id()) self._sync_selected_singbox_profile_link(silent=True) self._load_singbox_editor_for_selected(silent=True) self._update_transport_engine_view() self._refresh_selected_transport_health_live(silent=True) def on_transport_engine_refresh(self) -> None: selected_id = self._selected_transport_engine_id() try: targets = [selected_id] if selected_id else None self.ctrl.transport_health_refresh(client_ids=targets, force=True) except Exception: # Endpoint is best-effort; UI must keep working with older API builds. pass try: scope = f"transport:{selected_id}" if selected_id else "" if scope: self.ctrl.egress_identity_refresh(scopes=[scope], force=True) except Exception: # Optional API on older backend builds. pass self.refresh_transport_engines(silent=False) self._refresh_selected_transport_health_live(force=True, silent=True) def on_transport_engine_toggle(self, _checked: bool = False) -> None: selected = self._selected_transport_client() if selected is None: QMessageBox.warning(self, "Transport engine", "Select a transport engine first") return status = str(getattr(selected, "status", "") or "").strip().lower() if status == "starting": QMessageBox.information(self, "Transport engine", "Engine is still starting, wait a moment") return action: Literal["provision", "start", "stop", "restart"] = "stop" if status == "up" else "start" self.on_transport_engine_action(action) def on_transport_netns_toggle(self) -> None: def work(): if not self._transport_api_supported: raise RuntimeError("Transport API is unavailable") clients = list(self._transport_clients or []) if not clients: raise RuntimeError("No SingBox engines configured") all_enabled, _any_enabled = self._singbox_clients_netns_state() target = not all_enabled target_text = "ON" if target else "OFF" ans = QMessageBox.question( self, "Debug netns", ( f"Set netns={target_text} for all SingBox engines?\n\n" "Applied now via backend orchestration.\n" "Config+provision are executed in Go API,\n" "running engines will be restarted." ), QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) if ans != QMessageBox.Yes: return def log_netns_line(line: str) -> None: msg = (line or "").strip() if not msg: return self._append_transport_log(f"[netns] {msg}") self.ctrl.log_gui(f"[netns] {msg}") failures = apply_singbox_netns_toggle( self.ctrl, clients, target, log_netns_line, ) self.refresh_transport_engines(silent=True) self.refresh_status_tab() if failures: raise RuntimeError( "netns toggle completed with errors:\n" + "\n".join(failures) ) self.lbl_transport_engine_meta.setText( f"Engine: debug netns set to {target_text} for all SingBox engines" ) self.lbl_transport_engine_meta.setStyleSheet("color: green;") self._safe(work, title="Debug netns toggle error") def _selected_multiif_engine_scope(self) -> str: combo = getattr(self, "cmb_singbox_owner_engine_scope", None) if combo is None: return "all" scope = str(combo.currentData() or "").strip().lower() if scope not in ("all", "transport", "adguardvpn"): return "all" return scope def on_singbox_owner_engine_scope_changed(self, _index: int = 0) -> None: self._update_transport_engine_view() self._safe( lambda: self.refresh_transport_policy_locks(silent=True), title="MultiIF engine filter error", ) def _new_readonly_table_item(self, text: str, *, user_data: str = "") -> QTableWidgetItem: item = QTableWidgetItem(str(text or "")) item.setFlags(item.flags() & ~Qt.ItemIsEditable) if user_data: item.setData(Qt.UserRole, str(user_data)) return item def _selected_owner_lock_destinations(self) -> list[str]: rows = sorted({idx.row() for idx in self.tbl_singbox_owner_locks.selectedIndexes()}) out: list[str] = [] seen: set[str] = set() for row in rows: it = self.tbl_singbox_owner_locks.item(row, 0) if it is None: continue ip = str(it.text() or "").strip() if not ip or ip in seen: continue seen.add(ip) out.append(ip) return out def _parse_owner_lock_destination_filter(self) -> list[str]: raw = str(self.ent_singbox_owner_lock_destination.text() or "").strip() if not raw: return [] buf = raw.replace(",", " ").replace(";", " ").replace("\n", " ") out: list[str] = [] seen: set[str] = set() for token in buf.split(): ip = token.strip() if not ip or ip in seen: continue seen.add(ip) out.append(ip) return out def on_singbox_policy_selector_type_changed(self, _index: int = 0) -> None: selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower() placeholder = "example.com" if selector_type == "cidr": placeholder = "1.2.3.0/24 or 1.2.3.4" elif selector_type == "app_key": placeholder = "steam" elif selector_type == "cgroup": placeholder = "user.slice/app.scope" elif selector_type == "uid": placeholder = "1000" self.ent_singbox_policy_selector_value.setPlaceholderText(placeholder) def _set_singbox_policy_state(self, text: str, color: str = "gray") -> None: self.lbl_singbox_policy_state.setText(str(text or "").strip() or "Policy editor: —") self.lbl_singbox_policy_state.setStyleSheet(f"color: {color};") def _sync_singbox_policy_client_options(self) -> None: prev = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() self.cmb_singbox_policy_client_id.blockSignals(True) self.cmb_singbox_policy_client_id.clear() seen = set() for client in self._policy_clients_for_editor(): cid = str(getattr(client, "id", "") or "").strip() if not cid or cid in seen: continue seen.add(cid) self.cmb_singbox_policy_client_id.addItem(self._policy_client_caption(client), cid) if self.cmb_singbox_policy_client_id.count() == 0: self.cmb_singbox_policy_client_id.addItem("No clients", "") idx = self.cmb_singbox_policy_client_id.findData(prev) if idx < 0: idx = 0 self.cmb_singbox_policy_client_id.setCurrentIndex(max(0, idx)) self.cmb_singbox_policy_client_id.blockSignals(False) def _policy_client_label(self, client_id: str) -> str: cid = str(client_id or "").strip() if not cid: return "-" for client in self._policy_clients_for_editor(): cur = str(getattr(client, "id", "") or "").strip() if cur != cid: continue return self._policy_client_caption(client) return f"[{self._policy_client_role_by_id(cid)}] {cid}" def _set_policy_client_selection(self, client_id: str) -> None: cid = str(client_id or "").strip() if not cid: return idx = self.cmb_singbox_policy_client_id.findData(cid) if idx >= 0: self.cmb_singbox_policy_client_id.setCurrentIndex(idx) def _intent_parts(self, intent) -> tuple[str, str, str, str, int]: if isinstance(intent, dict): selector_type = str(intent.get("selector_type") or "").strip().lower() selector_value = str(intent.get("selector_value") or "").strip() client_id = str(intent.get("client_id") or "").strip() mode = str(intent.get("mode") or "strict").strip().lower() or "strict" try: priority = int(intent.get("priority") or 100) except Exception: priority = 100 else: selector_type = str(getattr(intent, "selector_type", "") or "").strip().lower() selector_value = str(getattr(intent, "selector_value", "") or "").strip() client_id = str(getattr(intent, "client_id", "") or "").strip() mode = str(getattr(intent, "mode", "strict") or "strict").strip().lower() or "strict" try: priority = int(getattr(intent, "priority", 100) or 100) except Exception: priority = 100 if mode not in ("strict", "fallback"): mode = "strict" if priority <= 0: priority = 100 return selector_type, selector_value, client_id, mode, priority def _render_singbox_policy_table_rows(self, table, intents) -> None: table.setRowCount(0) for it in list(intents or []): selector_type, selector_value, client_id, mode, priority = self._intent_parts(it) row = table.rowCount() table.insertRow(row) table.setItem(row, 0, self._new_readonly_table_item(selector_type)) table.setItem(row, 1, self._new_readonly_table_item(selector_value)) table.setItem( row, 2, self._new_readonly_table_item(self._policy_client_label(client_id), user_data=client_id), ) table.setItem(row, 3, self._new_readonly_table_item(mode)) table.setItem(row, 4, self._new_readonly_table_item(str(priority))) table.resizeRowsToContents() def _render_singbox_policy_table(self) -> None: self._render_singbox_policy_table_rows( self.tbl_singbox_policy_intents, self._transport_policy_draft_intents, ) self._render_singbox_policy_table_rows( self.tbl_singbox_policy_applied, self._transport_policy_applied_intents, ) def _render_singbox_policy_conflicts(self, conflicts) -> None: self.tbl_singbox_policy_conflicts.setRowCount(0) for it in list(conflicts or []): ctype = str(getattr(it, "type", "") or "").strip() or "-" severity = str(getattr(it, "severity", "") or "").strip() or "-" owners_list = list(getattr(it, "owners", []) or []) owners = ", ".join(str(x).strip() for x in owners_list if str(x).strip()) or "-" reason = str(getattr(it, "reason", "") or "").strip() or "-" suggested = str(getattr(it, "suggested_resolution", "") or "").strip() or "-" row = self.tbl_singbox_policy_conflicts.rowCount() self.tbl_singbox_policy_conflicts.insertRow(row) self.tbl_singbox_policy_conflicts.setItem(row, 0, self._new_readonly_table_item(ctype)) self.tbl_singbox_policy_conflicts.setItem(row, 1, self._new_readonly_table_item(severity.upper())) self.tbl_singbox_policy_conflicts.setItem(row, 2, self._new_readonly_table_item(owners)) self.tbl_singbox_policy_conflicts.setItem(row, 3, self._new_readonly_table_item(reason)) self.tbl_singbox_policy_conflicts.setItem(row, 4, self._new_readonly_table_item(suggested)) self.tbl_singbox_policy_conflicts.resizeRowsToContents() def refresh_transport_policy_editor(self, *, silent: bool = True, force: bool = False, policy=None) -> None: self._sync_singbox_policy_client_options() if not getattr(self, "_transport_api_supported", False): self.tbl_singbox_policy_intents.setRowCount(0) self.tbl_singbox_policy_applied.setRowCount(0) self.tbl_singbox_policy_conflicts.setRowCount(0) self._set_singbox_policy_state("Policy editor: API unavailable", "orange") return pol = policy if pol is None: try: pol = self.ctrl.transport_policy() except ApiError as e: code = int(getattr(e, "status_code", 0) or 0) self.tbl_singbox_policy_intents.setRowCount(0) self.tbl_singbox_policy_applied.setRowCount(0) self.tbl_singbox_policy_conflicts.setRowCount(0) if code == 404: self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray") return self._set_singbox_policy_state("Policy editor: refresh failed", "orange") if not silent: raise return backend_revision = int(getattr(pol, "revision", 0) or 0) backend_intents = list(getattr(pol, "intents", []) or []) self._transport_policy_applied_intents = list(backend_intents) should_reload = bool(force or not self._transport_policy_dirty or self._transport_policy_base_revision <= 0) if should_reload: self._transport_policy_base_revision = backend_revision self._transport_policy_draft_intents = list(backend_intents) self._transport_policy_dirty = False self._transport_policy_last_apply_id = "" self._transport_policy_last_conflicts = [] self._render_singbox_policy_table() self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts) draft_count = len(list(self._transport_policy_draft_intents or [])) applied_count = len(list(self._transport_policy_applied_intents or [])) backend_count = len(backend_intents) if self._transport_policy_dirty: self._set_singbox_policy_state( f"Policy draft: dirty (draft={draft_count}, applied={applied_count}, base_rev={self._transport_policy_base_revision}, backend_rev={backend_revision}, backend_intents={backend_count})", "#b58900", ) else: self._set_singbox_policy_state( f"Policy draft: clean (rev={self._transport_policy_base_revision}, draft={draft_count}, applied={applied_count})", "gray", ) def on_singbox_policy_reload(self) -> None: self._safe( lambda: self.refresh_transport_policy_editor(silent=False, force=True), title="Policy reload error", ) def on_singbox_policy_use_template(self) -> None: data = self.cmb_singbox_policy_template.currentData() if not isinstance(data, dict): QMessageBox.information( self, "Policy template", "Select a template first.", ) return selector_type = str(data.get("selector_type") or "").strip().lower() selector_value = str(data.get("selector_value") or "").strip() mode = str(data.get("mode") or "strict").strip().lower() try: priority = int(data.get("priority") or 100) except Exception: priority = 100 idx = self.cmb_singbox_policy_selector_type.findData(selector_type) if idx >= 0: self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) self.ent_singbox_policy_selector_value.setText(selector_value) idx_mode = self.cmb_singbox_policy_mode.findData(mode) if idx_mode >= 0: self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100) self._set_singbox_policy_state( f"Template loaded: {selector_type}:{selector_value} ({mode}, prio={priority})", "#1f6b2f", ) def on_singbox_policy_add_demo_intent(self) -> None: def work() -> None: client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() if not client_id: for i in range(self.cmb_singbox_policy_client_id.count()): cand = str(self.cmb_singbox_policy_client_id.itemData(i) or "").strip() if cand: client_id = cand break if not client_id: QMessageBox.information( self, "Policy intent", "No client is available yet. Create or select a connection first.", ) return used_values = { str(it.selector_value or "").strip().lower() for it in list(self._transport_policy_draft_intents or []) if str(it.selector_type or "").strip().lower() == "domain" } n = 1 selector_value = "demo.invalid" while selector_value.lower() in used_values: n += 1 selector_value = f"demo-{n}.invalid" idx = self.cmb_singbox_policy_selector_type.findData("domain") if idx >= 0: self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) self.ent_singbox_policy_selector_value.setText(selector_value) self._set_policy_client_selection(client_id) idx_mode = self.cmb_singbox_policy_mode.findData("strict") if idx_mode >= 0: self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) self.spn_singbox_policy_priority.setValue(100) self.on_singbox_policy_add_intent() self._set_singbox_policy_state( f"Demo intent added: domain:{selector_value} -> {client_id}", "#1f6b2f", ) self._safe(work, title="Policy demo intent error") def _selected_policy_intent_row(self) -> int: rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()}) if not rows: return -1 row = rows[0] if row < 0 or row >= len(self._transport_policy_draft_intents or []): return -1 return row def on_singbox_policy_load_selected_intent(self, *, _silent: bool = False) -> None: row = self._selected_policy_intent_row() if row < 0: if not _silent: QMessageBox.information(self, "Policy intent", "Select an intent row first.") return selector_type, selector_value, client_id, mode, priority = self._intent_parts( self._transport_policy_draft_intents[row] ) idx = self.cmb_singbox_policy_selector_type.findData(selector_type) if idx >= 0: self.cmb_singbox_policy_selector_type.setCurrentIndex(idx) self.ent_singbox_policy_selector_value.setText(selector_value) self._set_policy_client_selection(client_id) idx_mode = self.cmb_singbox_policy_mode.findData(mode) if idx_mode >= 0: self.cmb_singbox_policy_mode.setCurrentIndex(idx_mode) self.spn_singbox_policy_priority.setValue(priority if priority > 0 else 100) self._set_singbox_policy_state( f"Intent loaded from draft row {row + 1}: {selector_type}:{selector_value} -> {client_id}", "#1f6b2f", ) def on_singbox_policy_intent_double_clicked(self, item: QTableWidgetItem | None) -> None: if item is None: return row = int(item.row()) if row < 0: return self.tbl_singbox_policy_intents.selectRow(row) self.on_singbox_policy_load_selected_intent(_silent=True) def _build_policy_intent_from_form(self) -> tuple[TransportPolicyIntent, str, str, str, str, int]: selector_type = str(self.cmb_singbox_policy_selector_type.currentData() or "").strip().lower() selector_value = str(self.ent_singbox_policy_selector_value.text() or "").strip() client_id = str(self.cmb_singbox_policy_client_id.currentData() or "").strip() mode = str(self.cmb_singbox_policy_mode.currentData() or "strict").strip().lower() priority = int(self.spn_singbox_policy_priority.value() or 100) if not selector_type or not selector_value or not client_id: raise ValueError("Fill selector type/value and client ID before saving intent.") if selector_type == "cidr": try: if "/" in selector_value: ipaddress.ip_network(selector_value, strict=False) else: ipaddress.ip_address(selector_value) except Exception as e: raise ValueError("CIDR/IP value looks invalid. Example: 1.2.3.0/24 or 1.2.3.4") from e if selector_type == "uid" and not selector_value.isdigit(): raise ValueError("UID must be numeric (example: 1000).") if selector_type == "domain" and selector_value.isdigit(): raise ValueError("Domain value looks invalid. Use host/domain (example.com).") normalized_mode = mode if mode in ("strict", "fallback") else "strict" normalized_priority = priority if priority > 0 else 100 intent = TransportPolicyIntent( selector_type=selector_type, selector_value=selector_value, client_id=client_id, priority=normalized_priority, mode=normalized_mode, ) return ( intent, selector_type, selector_value, client_id, normalized_mode, normalized_priority, ) def _policy_intent_exists(self, intent: TransportPolicyIntent, *, skip_index: int = -1) -> bool: target = self._intent_parts(intent) for i, existing in enumerate(list(self._transport_policy_draft_intents or [])): if skip_index >= 0 and i == skip_index: continue if self._intent_parts(existing) == target: return True return False def on_singbox_policy_add_intent(self) -> None: def work() -> None: try: intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form() except ValueError as e: QMessageBox.information(self, "Policy intent", str(e)) return if self._policy_intent_exists(intent): QMessageBox.information( self, "Policy intent", "Same intent is already present in draft.", ) return self._transport_policy_draft_intents.append(intent) self._transport_policy_dirty = True self._render_singbox_policy_table() row = len(self._transport_policy_draft_intents) - 1 if row >= 0 and row < self.tbl_singbox_policy_intents.rowCount(): self.tbl_singbox_policy_intents.selectRow(row) self._set_singbox_policy_state( f"Policy draft: dirty (intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", "#b58900", ) self._append_transport_log( f"[policy] draft add: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})" ) self.ctrl.log_gui( f"[transport-policy] draft add {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}" ) self._safe(work, title="Policy add error") def on_singbox_policy_update_selected_intent(self) -> None: def work() -> None: row = self._selected_policy_intent_row() if row < 0: QMessageBox.information(self, "Policy intent", "Select an intent row first.") return try: intent, selector_type, selector_value, client_id, mode, priority = self._build_policy_intent_from_form() except ValueError as e: QMessageBox.information(self, "Policy intent", str(e)) return if self._policy_intent_exists(intent, skip_index=row): QMessageBox.information( self, "Policy intent", "Another row already has the same intent.", ) return self._transport_policy_draft_intents[row] = intent self._transport_policy_dirty = True self._render_singbox_policy_table() if row < self.tbl_singbox_policy_intents.rowCount(): self.tbl_singbox_policy_intents.selectRow(row) self._set_singbox_policy_state( f"Policy draft: dirty (updated row={row + 1}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", "#b58900", ) self._append_transport_log( f"[policy] draft update row={row + 1}: {selector_type}:{selector_value} -> {client_id} ({mode}, prio={priority})" ) self.ctrl.log_gui( f"[transport-policy] draft update row={row + 1} {selector_type}:{selector_value} -> {client_id} mode={mode} priority={priority}" ) self._safe(work, title="Policy update error") def on_singbox_policy_remove_selected(self) -> None: def work() -> None: rows = sorted({idx.row() for idx in self.tbl_singbox_policy_intents.selectedIndexes()}, reverse=True) if not rows: QMessageBox.information(self, "Policy intent", "Select one or more intent rows.") return removed = 0 for row in rows: if 0 <= row < len(self._transport_policy_draft_intents): del self._transport_policy_draft_intents[row] removed += 1 if removed <= 0: return self._transport_policy_dirty = True self._render_singbox_policy_table() self._set_singbox_policy_state( f"Policy draft: dirty (removed={removed}, intents={len(self._transport_policy_draft_intents)}, base_rev={self._transport_policy_base_revision})", "#b58900", ) self._append_transport_log(f"[policy] draft remove: {removed} intent(s)") self.ctrl.log_gui(f"[transport-policy] draft remove: {removed}") self._safe(work, title="Policy remove error") def _validate_policy_draft_flow(self): flow = self.ctrl.transport_flow_draft( list(self._transport_policy_draft_intents or []), base_revision=int(self._transport_policy_base_revision or 0), ) flow = self.ctrl.transport_flow_validate(flow, allow_warnings=True) line = ( f"validate: phase={flow.phase} blocks={int(flow.block_count)} warns={int(flow.warn_count)} " f"diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)} " f"code={flow.code or '-'}" ) self._append_transport_log(f"[policy] {line}") self.ctrl.log_gui(f"[transport-policy] {line}") self._transport_policy_last_conflicts = list(flow.conflicts or []) self._render_singbox_policy_conflicts(self._transport_policy_last_conflicts) if flow.phase == "validated": self._set_singbox_policy_state( f"Policy validated: blocks=0 warns={int(flow.warn_count)} diff +{int(flow.diff_added)}/~{int(flow.diff_changed)}/-{int(flow.diff_removed)}", "green", ) elif flow.phase == "risky": self._set_singbox_policy_state( f"Policy has blocking conflicts: blocks={int(flow.block_count)} warns={int(flow.warn_count)}", "#b58900", ) else: self._set_singbox_policy_state( f"Policy validate failed: {flow.message or '-'} (code={flow.code or '-'})", "orange", ) return flow def on_singbox_policy_validate(self) -> None: def work() -> None: flow = self._validate_policy_draft_flow() if flow.phase == "risky": preview = [] for c in list(flow.conflicts or [])[:8]: reason = str(getattr(c, "reason", "") or "").strip() ctype = str(getattr(c, "type", "") or "").strip() or "conflict" if reason: preview.append(f"- {ctype}: {reason}") else: preview.append(f"- {ctype}") if preview: QMessageBox.information( self, "Policy conflicts", "Blocking conflicts found:\n\n" + "\n".join(preview), ) self._safe(work, title="Policy validate error") def on_singbox_policy_apply(self) -> None: def work() -> None: flow = self._validate_policy_draft_flow() if flow.phase == "risky": preview = [] for c in list(flow.conflicts or [])[:8]: reason = str(getattr(c, "reason", "") or "").strip() ctype = str(getattr(c, "type", "") or "").strip() or "conflict" if reason: preview.append(f"- {ctype}: {reason}") else: preview.append(f"- {ctype}") text = ( f"Policy has blocking conflicts.\n\n" f"blocks={int(flow.block_count)}, warns={int(flow.warn_count)}\n\n" f"{chr(10).join(preview)}\n\n" "Force apply anyway?" ) ans = QMessageBox.question( self, "Confirm risky policy apply", text, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ans != QMessageBox.Yes: self._append_transport_log("[policy] apply canceled by user") self.ctrl.log_gui("[transport-policy] apply canceled by user") return flow = self.ctrl.transport_flow_confirm(flow) flow = self.ctrl.transport_flow_apply(flow, force_override=True) else: flow = self.ctrl.transport_flow_apply(flow, force_override=False) if flow.phase != "applied": raise RuntimeError(f"{flow.message or 'policy apply failed'} (code={flow.code or '-'})") self._transport_policy_dirty = False self._transport_policy_base_revision = int(flow.applied_revision or flow.current_revision or self._transport_policy_base_revision) self._transport_policy_last_apply_id = str(flow.apply_id or "").strip() self._append_transport_log( f"[policy] applied: revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}" ) self.ctrl.log_gui( f"[transport-policy] applied revision={int(flow.applied_revision)} apply_id={flow.apply_id or '-'}" ) self.refresh_transport_policy_locks(silent=True) self.refresh_transport_engines(silent=True) self.refresh_status_tab() self._safe(work, title="Policy apply error") def on_singbox_policy_rollback_explicit(self) -> None: def work() -> None: res = self.ctrl.transport_policy_rollback_action() line = (res.pretty_text or "").strip() or "policy rollback" self._append_transport_log(f"[policy] {line}") self.ctrl.log_gui(f"[transport-policy] {line}") if not res.ok: raise RuntimeError(line) self._transport_policy_dirty = False self._transport_policy_last_conflicts = [] self.refresh_transport_policy_editor(silent=True, force=True) self.refresh_transport_policy_locks(silent=True) self.refresh_transport_engines(silent=True) self.refresh_status_tab() self._safe(work, title="Policy rollback error") def refresh_transport_policy_locks(self, *, silent: bool = True) -> None: scope = self._selected_multiif_engine_scope() show_transport_rows = scope in ("all", "transport") show_adguard_rows = scope in ("all", "adguardvpn") if not getattr(self, "_transport_api_supported", False): self._transport_policy_clients = [] self.tbl_singbox_interfaces.setRowCount(0) self.tbl_singbox_policy_intents.setRowCount(0) self.tbl_singbox_policy_applied.setRowCount(0) self.tbl_singbox_policy_conflicts.setRowCount(0) self.tbl_singbox_ownership.setRowCount(0) self.tbl_singbox_owner_locks.setRowCount(0) self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership: API unavailable") self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;") self._set_singbox_policy_state("Policy editor: API unavailable", "orange") return ifaces = SimpleNamespace(count=0, items=[]) pol = SimpleNamespace(revision=0, intents=[]) owners = SimpleNamespace(count=0, lock_count=0, plan_digest="", items=[]) locks = SimpleNamespace(count=0, policy_revision=0, items=[]) try: ifaces = self.ctrl.transport_interfaces() pol = self.ctrl.transport_policy() owners = self.ctrl.transport_ownership() locks = self.ctrl.transport_owner_locks() except ApiError as e: self.tbl_singbox_interfaces.setRowCount(0) self.tbl_singbox_policy_intents.setRowCount(0) self.tbl_singbox_policy_applied.setRowCount(0) self.tbl_singbox_policy_conflicts.setRowCount(0) self.tbl_singbox_ownership.setRowCount(0) self.tbl_singbox_owner_locks.setRowCount(0) code = int(getattr(e, "status_code", 0) or 0) if code == 404: self.lbl_singbox_owner_locks_summary.setText( "Interfaces/Ownership: endpoint is unavailable on current backend" ) self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;") self._set_singbox_policy_state("Policy editor: endpoint unavailable", "gray") return self.lbl_singbox_owner_locks_summary.setText("Interfaces/Ownership refresh failed") self.lbl_singbox_owner_locks_summary.setStyleSheet("color: orange;") self._set_singbox_policy_state("Policy editor: refresh failed", "orange") if not silent: raise return self._refresh_transport_policy_clients_cache() self.refresh_transport_policy_editor(silent=True, force=False, policy=pol) scope_title = { "all": "all", "transport": "transport", "adguardvpn": "adguardvpn", }.get(scope, "all") self.lbl_singbox_interfaces_hint.setText(f"Interfaces (read-only, filter={scope_title}, labels=[transport]/[virtual])") self.tbl_singbox_interfaces.setRowCount(0) adguard_iface = None for rec in list(getattr(ifaces, "items", []) or []): rec_id = str(getattr(rec, "id", "") or "").strip().lower() is_adguard = rec_id == "adguardvpn" if is_adguard: adguard_iface = rec if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): continue row = self.tbl_singbox_interfaces.rowCount() self.tbl_singbox_interfaces.insertRow(row) clients_txt = f"{int(rec.up_count or 0)}/{int(rec.client_count or 0)}" iface_role = "virtual" if is_adguard else "transport" mode_text = (rec.mode or "-").upper() if is_adguard: mode_text = f"{mode_text} | VIRTUAL" self.tbl_singbox_interfaces.setItem(row, 0, self._new_readonly_table_item(f"[{iface_role}] {rec.id}", user_data=rec.id)) self.tbl_singbox_interfaces.setItem(row, 1, self._new_readonly_table_item(mode_text)) self.tbl_singbox_interfaces.setItem(row, 2, self._new_readonly_table_item(rec.runtime_iface or "-")) self.tbl_singbox_interfaces.setItem(row, 3, self._new_readonly_table_item(rec.netns_name or "-")) self.tbl_singbox_interfaces.setItem(row, 4, self._new_readonly_table_item(rec.routing_table or "-")) self.tbl_singbox_interfaces.setItem(row, 5, self._new_readonly_table_item(clients_txt)) self.tbl_singbox_interfaces.setItem(row, 6, self._new_readonly_table_item(rec.updated_at or "-")) self.tbl_singbox_ownership.setRowCount(0) for rec in list(getattr(owners, "items", []) or []): owner_scope_raw = str(getattr(rec, "owner_scope", "") or "").strip().lower() owner_id = str(getattr(rec, "client_id", "") or "").strip().lower() is_adguard = owner_scope_raw == "adguardvpn" or owner_id == "adguardvpn" if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): continue row = self.tbl_singbox_ownership.rowCount() self.tbl_singbox_ownership.insertRow(row) selector = f"{rec.selector_type}:{rec.selector_value}" owner = rec.client_id if rec.client_kind: owner = f"{owner} ({rec.client_kind})" owner_role = "virtual" if is_adguard else "transport" owner = f"[{owner_role}] {owner}" owner_scope = str(getattr(rec, "owner_scope", "") or "").strip() or "-" iface_table = rec.iface_id or "-" if rec.routing_table: iface_table = f"{iface_table} · {rec.routing_table}" status = rec.owner_status or "unknown" lock_txt = "active" if rec.lock_active else "free" self.tbl_singbox_ownership.setItem(row, 0, self._new_readonly_table_item(selector, user_data=rec.key)) self.tbl_singbox_ownership.setItem(row, 1, self._new_readonly_table_item(owner, user_data=rec.client_id)) self.tbl_singbox_ownership.setItem(row, 2, self._new_readonly_table_item(owner_scope)) self.tbl_singbox_ownership.setItem(row, 3, self._new_readonly_table_item(iface_table)) self.tbl_singbox_ownership.setItem(row, 4, self._new_readonly_table_item(status.upper())) self.tbl_singbox_ownership.setItem(row, 5, self._new_readonly_table_item(lock_txt.upper())) self.tbl_singbox_owner_locks.setRowCount(0) for rec in list(getattr(locks, "items", []) or []): owner_id = str(getattr(rec, "client_id", "") or "").strip().lower() is_adguard = owner_id == "adguardvpn" if (is_adguard and not show_adguard_rows) or ((not is_adguard) and not show_transport_rows): continue row = self.tbl_singbox_owner_locks.rowCount() self.tbl_singbox_owner_locks.insertRow(row) mark_proto = rec.mark_hex or "-" if rec.proto: mark_proto = f"{mark_proto} / {rec.proto}" self.tbl_singbox_owner_locks.setItem( row, 0, self._new_readonly_table_item(rec.destination_ip, user_data=rec.destination_ip), ) lock_role = "virtual" if is_adguard else "transport" self.tbl_singbox_owner_locks.setItem(row, 1, self._new_readonly_table_item(f"[{lock_role}] {rec.client_id}", user_data=rec.client_id)) kind_text = rec.client_kind or ("adguardvpn" if is_adguard else "-") self.tbl_singbox_owner_locks.setItem(row, 2, self._new_readonly_table_item(f"[{lock_role}] {kind_text}")) self.tbl_singbox_owner_locks.setItem(row, 3, self._new_readonly_table_item(rec.iface_id or "-")) self.tbl_singbox_owner_locks.setItem(row, 4, self._new_readonly_table_item(mark_proto)) self.tbl_singbox_owner_locks.setItem(row, 5, self._new_readonly_table_item(rec.updated_at or "-")) self.tbl_singbox_interfaces.resizeRowsToContents() self.tbl_singbox_ownership.resizeRowsToContents() self.tbl_singbox_owner_locks.resizeRowsToContents() filter_label = { "all": "All", "transport": "Transport", "adguardvpn": "AdGuard VPN", }.get(scope, "All") parts = [ f"Engine filter: {filter_label}", f"Interfaces: {self.tbl_singbox_interfaces.rowCount()}", ] plan_digest = str(getattr(owners, "plan_digest", "") or "").strip() plan_digest_short = plan_digest[:12] if plan_digest else "-" parts.append( "Policy: " f"rev={int(getattr(pol, 'revision', 0) or 0)} intents={len(list(getattr(pol, 'intents', []) or []))}" f" draft={len(list(self._transport_policy_draft_intents or []))}" f" applied={len(list(self._transport_policy_applied_intents or []))}" ) policy_clients = self._policy_clients_for_editor() seen_target_ids = set() virtual_targets = 0 transport_targets = 0 for client in policy_clients: cid = str(getattr(client, "id", "") or "").strip().lower() if not cid or cid in seen_target_ids: continue seen_target_ids.add(cid) if self._is_virtual_policy_client(client): virtual_targets += 1 else: transport_targets += 1 parts.append(f"Targets: transport={transport_targets} virtual={virtual_targets}") parts.append( "Ownership: " f"{int(getattr(owners, 'count', 0) or 0)} selectors " f"(lock_active={int(getattr(owners, 'lock_count', 0) or 0)})" f" · digest={plan_digest_short}" ) parts.append( "Locks: " f"{int(getattr(locks, 'count', 0) or 0)}" f" · revision={int(getattr(locks, 'policy_revision', 0) or 0)}" ) if show_adguard_rows: if adguard_iface is None: parts.append("AdGuard: unavailable") else: adg_status = "UP" if int(getattr(adguard_iface, "up_count", 0) or 0) > 0 else "DOWN" adg_iface = str(getattr(adguard_iface, "runtime_iface", "") or "").strip() or "-" adg_table = str(getattr(adguard_iface, "routing_table", "") or "").strip() or "-" parts.append(f"AdGuard: {adg_status} iface={adg_iface} table={adg_table}") self.lbl_singbox_owner_locks_summary.setText(" | ".join(parts)) self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;") if int(getattr(pol, "revision", 0) or 0) <= 0 and len(list(getattr(pol, "intents", []) or [])) == 0: self._set_singbox_policy_state("Policy editor: no policy yet. Add intent -> Validate -> Apply.", "#1f6b2f") def on_singbox_owner_locks_refresh(self) -> None: self._safe( lambda: self.refresh_transport_policy_locks(silent=False), title="Ownership locks refresh error", ) def on_singbox_owner_locks_clear(self) -> None: def work() -> None: if not self._transport_api_supported: raise RuntimeError("Transport API is unavailable") client_id = str(self.ent_singbox_owner_lock_client.text() or "").strip() destination_ips = self._parse_owner_lock_destination_filter() if not destination_ips: destination_ips = self._selected_owner_lock_destinations() if not client_id and not destination_ips: msg = ( "Empty filter: set client_id or destination IP,\n" "or select destination rows in locks table." ) self._append_transport_log("[owner-lock] clear skipped: empty filter") self.ctrl.log_gui("[owner-lock] clear skipped: empty filter") QMessageBox.information(self, "Owner locks", msg) return base_revision = 0 try: snap = self.ctrl.transport_owner_locks() base_revision = int(getattr(snap, "policy_revision", 0) or 0) except Exception: pass probe = self.ctrl.transport_owner_locks_clear( base_revision=base_revision, client_id=client_id, destination_ips=destination_ips, confirm_token="", ) self._append_transport_log( f"[owner-lock] clear probe: {probe.message or '-'} (code={probe.code or '-'})" ) self.ctrl.log_gui( f"[owner-lock] clear probe: code={probe.code or '-'} match={int(probe.match_count or 0)}" ) if probe.ok and not probe.confirm_required: self.refresh_transport_policy_locks(silent=True) self.lbl_transport_engine_meta.setText( f"Engine: owner locks cleared ({int(probe.cleared_count or 0)})" ) self.lbl_transport_engine_meta.setStyleSheet("color: green;") return if not probe.confirm_required: raise RuntimeError( f"{probe.message or 'owner-lock clear failed'} (code={probe.code or '-'})" ) preview_lines = [] for it in list(probe.items or [])[:12]: preview_lines.append(f"- {it.destination_ip} -> {it.client_id}") if int(probe.match_count or 0) > len(preview_lines): preview_lines.append(f"... +{int(probe.match_count or 0) - len(preview_lines)} more") preview = "\n".join(preview_lines) if preview_lines else "No preview rows" text = ( f"Matched locks: {int(probe.match_count or 0)}\n" f"Current revision: {int(probe.base_revision or base_revision)}\n\n" f"{preview}\n\n" "Clear matched lock records?" ) ans = QMessageBox.question( self, "Confirm owner-lock clear", text, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if ans != QMessageBox.Yes: self._append_transport_log("[owner-lock] clear canceled by user") self.ctrl.log_gui("[owner-lock] clear canceled by user") return confirmed = self.ctrl.transport_owner_locks_clear( base_revision=int(probe.base_revision or base_revision), client_id=client_id, destination_ips=destination_ips, confirm_token=str(probe.confirm_token or "").strip(), ) line = ( f"{confirmed.message or '-'} " f"(code={confirmed.code or '-'}, cleared={int(confirmed.cleared_count or 0)})" ) self._append_transport_log(f"[owner-lock] clear apply: {line}") self.ctrl.log_gui(f"[owner-lock] clear apply: {line}") if not confirmed.ok: raise RuntimeError(line) self.refresh_transport_policy_locks(silent=True) self.lbl_transport_engine_meta.setText( f"Engine: owner locks cleared ({int(confirmed.cleared_count or 0)})" ) self.lbl_transport_engine_meta.setStyleSheet("color: green;") self._safe(work, title="Owner locks clear error")