ui: smarter override candidates picker

Add per-tab quick filters, hide-linkdown, and in-dialog status; live-mark already-added entries.
This commit is contained in:
beckline
2026-02-14 16:06:34 +03:00
parent dc0cb88832
commit 7cee3d9928

View File

@@ -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: