From 7cee3d99280fa39ca1799e774f6984a95925f668 Mon Sep 17 00:00:00 2001 From: beckline Date: Sat, 14 Feb 2026 16:06:34 +0300 Subject: [PATCH] ui: smarter override candidates picker Add per-tab quick filters, hide-linkdown, and in-dialog status; live-mark already-added entries. --- selective-vpn-gui/traffic_mode_dialog.py | 216 ++++++++++++++++++++--- 1 file changed, 194 insertions(+), 22 deletions(-) diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 7bf9672..037d81d 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -686,6 +686,8 @@ RU: Скрывает элементы, которые уже есть в Force V self._tab_kind: dict[QWidget, str] = {} self._tab_list: dict[QWidget, QListWidget] = {} self._tab_filter: dict[QWidget, QLineEdit] = {} + self._list_kind: dict[QListWidget, str] = {} + self._list_title: dict[QListWidget, str] = {} self.tabs.currentChanged.connect(lambda _idx: self._refilter_current()) self._build_subnets_tab() @@ -707,6 +709,11 @@ RU: Скрывает элементы, которые уже есть в Force V row.addWidget(btn_close) root.addLayout(row) + self.lbl_status = QLabel("—") + self.lbl_status.setWordWrap(True) + self.lbl_status.setStyleSheet("color: gray;") + root.addWidget(self.lbl_status) + def _mark_state(self, kind: str, value: str) -> tuple[bool, bool]: k = (kind or "").strip().lower() v = (value or "").strip() @@ -716,9 +723,32 @@ RU: Скрывает элементы, которые уже есть в Force V in_direct = v in (self.existing.get("direct", {}).get(k, set()) or set()) return bool(in_vpn), bool(in_direct) + def _set_status(self, msg: str, ok: bool | None = None) -> None: + text = (msg or "").strip() or "—" + self.lbl_status.setText(text) + if ok is True: + self.lbl_status.setStyleSheet("color: green;") + elif ok is False: + self.lbl_status.setStyleSheet("color: red;") + else: + self.lbl_status.setStyleSheet("color: gray;") + def _apply_filter(self, lst: QListWidget, query: str) -> None: q = (query or "").strip().lower() hide_existing = bool(self.chk_hide_existing.isChecked()) + kind = (self._list_kind.get(lst) or "").strip().lower() + + # Subnets-only quick filters. + allow_lan = True + allow_docker = True + allow_link = True + allow_linkdown = True + if kind == "subnet": + allow_lan = bool(getattr(self, "chk_sub_show_lan", None) and self.chk_sub_show_lan.isChecked()) + allow_docker = bool(getattr(self, "chk_sub_show_docker", None) and self.chk_sub_show_docker.isChecked()) + allow_link = bool(getattr(self, "chk_sub_show_link", None) and self.chk_sub_show_link.isChecked()) + allow_linkdown = not bool(getattr(self, "chk_sub_hide_linkdown", None) and self.chk_sub_hide_linkdown.isChecked()) + for i in range(lst.count()): it = lst.item(i) if not it: @@ -726,6 +756,23 @@ RU: Скрывает элементы, которые уже есть в Force V if hide_existing and bool(it.data(QtCore.Qt.UserRole + 1) or False): it.setHidden(True) continue + + if kind == "subnet": + it_kind = str(it.data(QtCore.Qt.UserRole + 4) or "").strip().lower() + it_linkdown = bool(it.data(QtCore.Qt.UserRole + 5) or False) + if it_linkdown and not allow_linkdown: + it.setHidden(True) + continue + if it_kind == "docker" and not allow_docker: + it.setHidden(True) + continue + if it_kind == "lan" and not allow_lan: + it.setHidden(True) + continue + if it_kind == "link" and not allow_link: + it.setHidden(True) + continue + if not q: it.setHidden(False) continue @@ -741,6 +788,42 @@ RU: Скрывает элементы, которые уже есть в Force V return self._apply_filter(lst, filt.text()) + def _filter_for_title(self, title: str) -> QLineEdit | None: + for i in range(self.tabs.count()): + if self.tabs.tabText(i) == title: + tab = self.tabs.widget(i) + return self._tab_filter.get(tab) + return None + + def _preset_set_filter(self, title: str, text: str) -> None: + filt = self._filter_for_title(title) + if filt is not None: + filt.setText(text) + + def _update_item_render(self, it: QListWidgetItem, kind: str) -> None: + value = str(it.data(QtCore.Qt.UserRole) or "").strip() + base_label = str(it.data(QtCore.Qt.UserRole + 2) or it.text() or "") + base_tip = str(it.data(QtCore.Qt.UserRole + 3) or it.toolTip() or "") + in_vpn, in_direct = self._mark_state(kind, value) + flags = [] + if in_vpn: + flags.append("VPN") + if in_direct: + flags.append("DIRECT") + label = base_label + if flags: + label = f"{base_label} [{' + '.join(flags)}]" + it.setText(label) + it.setData(QtCore.Qt.UserRole + 1, bool(in_vpn or in_direct)) + if base_tip.strip(): + extra_tip = ( + f"\n\nAlready in Force VPN: {'yes' if in_vpn else 'no'}\n" + f"Already in Force Direct: {'yes' if in_direct else 'no'}" + ) + it.setToolTip(base_tip + extra_tip) + if in_vpn or in_direct: + it.setForeground(QtGui.QBrush(QtGui.QColor("gray"))) + def _add_tab(self, title: str, kind: str, items: list[tuple[str, str, str]], *, extra=None) -> None: tab = QWidget() layout = QVBoxLayout(tab) @@ -758,27 +841,16 @@ RU: Скрывает элементы, которые уже есть в Force V label = str(entry[0]) if len(entry) > 0 else "" value = str(entry[1]) if len(entry) > 1 else "" tip = str(entry[2]) if len(entry) > 2 else "" - in_vpn, in_direct = self._mark_state(kind, value) - flags = [] - if in_vpn: - flags.append("VPN") - if in_direct: - flags.append("DIRECT") - if flags: - label = f"{label} [{' + '.join(flags)}]" - + meta_kind = str(entry[3]) if len(entry) > 3 else "" + meta_linkdown = bool(entry[4]) if len(entry) > 4 else False it = QListWidgetItem(label) it.setData(QtCore.Qt.UserRole, value) - it.setData(QtCore.Qt.UserRole + 1, bool(in_vpn or in_direct)) - if tip.strip(): - extra_tip = ( - f"\n\nAlready in Force VPN: {'yes' if in_vpn else 'no'}\n" - f"Already in Force Direct: {'yes' if in_direct else 'no'}" - ) - it.setToolTip(tip + extra_tip) - if in_vpn or in_direct: - it.setForeground(QtGui.QBrush(QtGui.QColor("gray"))) + it.setData(QtCore.Qt.UserRole + 2, label) # base label (without [VPN]/[DIRECT]) + it.setData(QtCore.Qt.UserRole + 3, tip) # base tooltip (without existing-state) + it.setData(QtCore.Qt.UserRole + 4, meta_kind) + it.setData(QtCore.Qt.UserRole + 5, meta_linkdown) lst.addItem(it) + self._update_item_render(it, kind) layout.addWidget(lst, stretch=1) filt.textChanged.connect(lambda txt, l=lst: self._apply_filter(l, txt)) @@ -787,6 +859,8 @@ RU: Скрывает элементы, которые уже есть в Force V self._tab_kind[tab] = kind self._tab_list[tab] = lst self._tab_filter[tab] = filt + self._list_kind[lst] = kind + self._list_title[lst] = title def _current_kind_and_list(self) -> tuple[str, QListWidget | None]: tab = self.tabs.currentWidget() @@ -815,8 +889,52 @@ RU: Скрывает элементы, которые уже есть в Force V seen.add(v) out.append(v) - if out: - self.add_cb(target, kind, out) + if not out: + self._set_status("Nothing selected", ok=None) + return + + tgt = (target or "").strip().lower() + k = (kind or "").strip().lower() + other = "direct" if tgt == "vpn" else "vpn" + have_tgt = self.existing.get(tgt, {}).get(k, set()) or set() + have_other = self.existing.get(other, {}).get(k, set()) or set() + + to_add: list[str] = [] + skipped = 0 + conflicts = 0 + for v in out: + if v in have_tgt: + skipped += 1 + continue + if v in have_other: + conflicts += 1 + to_add.append(v) + + if not to_add: + self._set_status(f"Nothing new to add (skipped={skipped}, conflicts={conflicts})", ok=None) + return + + self.add_cb(target, kind, to_add) + + # Update local state so UI marks newly added items immediately. + if tgt not in self.existing: + self.existing[tgt] = {} + if k not in self.existing[tgt]: + self.existing[tgt][k] = set() + for v in to_add: + self.existing[tgt][k].add(v) + + for i in range(lst.count()): + it = lst.item(i) + if it is None: + continue + self._update_item_render(it, kind) + self._refilter_current() + + msg = f"Added {len(to_add)} item(s) to Force {tgt.upper()} ({k})." + if skipped or conflicts: + msg += f" skipped={skipped} conflicts={conflicts}" + self._set_status(msg, ok=True) def _list_for_title(self, title: str) -> QListWidget | None: for i in range(self.tabs.count()): @@ -866,7 +984,7 @@ RU: Скрывает элементы, которые уже есть в Force V def _build_subnets_tab(self) -> None: subs = list(getattr(self.cands, "subnets", []) or []) - items: list[tuple[str, str, str]] = [] + items: list[tuple[str, str, str, str, bool]] = [] for s in subs: cidr = str(getattr(s, "cidr", "") or "").strip() if not cidr: @@ -888,7 +1006,7 @@ RU: Скрывает элементы, которые уже есть в Force V "EN: Source subnet overrides affect forwarded traffic (Docker).\n" "RU: Source subnet влияет на forwarded трафик (Docker)." ) - items.append((f"{cidr}{tag_txt}", cidr, tip)) + items.append((f"{cidr}{tag_txt}", cidr, tip, kind, linkdown)) def extra(layout: QVBoxLayout) -> None: row = QHBoxLayout() @@ -901,6 +1019,47 @@ RU: Скрывает элементы, которые уже есть в Force V row.addStretch(1) layout.addLayout(row) + row2 = QHBoxLayout() + btn_f_lan = QPushButton("Filter LAN") + btn_f_lan.clicked.connect(lambda: self._preset_set_filter("Subnets", "lan")) + row2.addWidget(btn_f_lan) + btn_f_docker = QPushButton("Filter Docker") + btn_f_docker.clicked.connect(lambda: self._preset_set_filter("Subnets", "docker")) + row2.addWidget(btn_f_docker) + btn_f_clear = QPushButton("Clear filter") + btn_f_clear.clicked.connect(lambda: self._preset_set_filter("Subnets", "")) + row2.addWidget(btn_f_clear) + row2.addStretch(1) + layout.addLayout(row2) + + row3 = QHBoxLayout() + self.chk_sub_show_lan = QCheckBox("LAN") + self.chk_sub_show_lan.setChecked(True) + self.chk_sub_show_lan.setToolTip("EN: Show LAN subnets.\nRU: Показать LAN подсети.") + self.chk_sub_show_lan.stateChanged.connect(lambda _s: self._refilter_current()) + row3.addWidget(self.chk_sub_show_lan) + + self.chk_sub_show_docker = QCheckBox("Docker") + self.chk_sub_show_docker.setChecked(True) + self.chk_sub_show_docker.setToolTip("EN: Show Docker/container subnets.\nRU: Показать Docker/контейнерные подсети.") + self.chk_sub_show_docker.stateChanged.connect(lambda _s: self._refilter_current()) + row3.addWidget(self.chk_sub_show_docker) + + self.chk_sub_show_link = QCheckBox("Link") + self.chk_sub_show_link.setChecked(True) + self.chk_sub_show_link.setToolTip("EN: Show link-scope routes.\nRU: Показать маршруты scope link.") + self.chk_sub_show_link.stateChanged.connect(lambda _s: self._refilter_current()) + row3.addWidget(self.chk_sub_show_link) + + self.chk_sub_hide_linkdown = QCheckBox("Hide linkdown") + self.chk_sub_hide_linkdown.setChecked(True) + self.chk_sub_hide_linkdown.setToolTip("EN: Hide routes marked as linkdown.\nRU: Скрыть маршруты с меткой linkdown.") + self.chk_sub_hide_linkdown.stateChanged.connect(lambda _s: self._refilter_current()) + row3.addWidget(self.chk_sub_hide_linkdown) + + row3.addStretch(1) + layout.addLayout(row3) + self._add_tab("Subnets", "subnet", items, extra=extra) def _preset_add_lan_direct(self) -> None: @@ -964,6 +1123,19 @@ RU: Скрывает элементы, которые уже есть в Force V row.addStretch(1) layout.addLayout(row) + row2 = QHBoxLayout() + btn_f_docker = QPushButton("Filter docker") + btn_f_docker.clicked.connect(lambda: self._preset_set_filter("Services", "docker")) + row2.addWidget(btn_f_docker) + btn_f_media = QPushButton("Filter media") + btn_f_media.clicked.connect(lambda: self._preset_set_filter("Services", "jellyfin")) + row2.addWidget(btn_f_media) + btn_f_clear = QPushButton("Clear filter") + btn_f_clear.clicked.connect(lambda: self._preset_set_filter("Services", "")) + row2.addWidget(btn_f_clear) + row2.addStretch(1) + layout.addLayout(row2) + self._add_tab("Services", "cgroup", items, extra=extra) def _build_uids_tab(self) -> None: