ui: apps runtime tab + fix subnet filter presets

This commit is contained in:
beckline
2026-02-14 17:31:32 +03:00
parent 90907219dc
commit dd1078f944
4 changed files with 272 additions and 286 deletions

View File

@@ -72,4 +72,3 @@ sudo chown -R dev:dev <path>
``` ```
Then retry the rebase. Then retry the rebase.

View File

@@ -1,261 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import shlex
import subprocess
import time
from dataclasses import dataclass
from typing import Callable, Optional
from PySide6 import QtCore
from PySide6.QtWidgets import (
QButtonGroup,
QDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QRadioButton,
QSpinBox,
QVBoxLayout,
)
from dashboard_controller import DashboardController
@dataclass(frozen=True)
class RunScopeResult:
ok: bool
unit: str = ""
cgroup: str = ""
message: str = ""
stdout: str = ""
class AppRouteDialog(QDialog):
"""
EN: Launch an app inside a systemd --user scope and register its cgroup id in backend nftsets.
RU: Запускает приложение в systemd --user scope и регистрирует его cgroup id в nftset'ах backend-а.
"""
def __init__(
self,
controller: DashboardController,
*,
log_cb: Callable[[str], None] | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.ctrl = controller
self.log_cb = log_cb
self.setWindowTitle("Run app via VPN / Direct (runtime)")
self.resize(720, 520)
root = QVBoxLayout(self)
hint = QLabel(
"Runtime per-app routing (Wayland-friendly):\n"
"- Launch uses systemd-run --user --scope.\n"
"- Backend adds the scope cgroup into nftset -> fwmark rules.\n"
"- Marks are temporary (TTL). Use Traffic overrides for persistent policy."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
root.addWidget(hint)
grp = QGroupBox("Run")
gl = QVBoxLayout(grp)
row_cmd = QHBoxLayout()
row_cmd.addWidget(QLabel("Command"))
self.ed_cmd = QLineEdit()
self.ed_cmd.setPlaceholderText("e.g. firefox --private-window https://example.com")
self.ed_cmd.setToolTip(
"EN: Command line to run. This runs as current user in a systemd --user scope.\n"
"RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope."
)
row_cmd.addWidget(self.ed_cmd, stretch=1)
gl.addLayout(row_cmd)
row_target = QHBoxLayout()
row_target.addWidget(QLabel("Route via"))
self.rad_vpn = QRadioButton("VPN")
self.rad_vpn.setToolTip(
"EN: Force this app traffic via VPN policy table (agvpn).\n"
"RU: Форсировать трафик приложения через VPN policy-table (agvpn)."
)
self.rad_direct = QRadioButton("Direct")
self.rad_direct.setToolTip(
"EN: Force this app traffic to bypass VPN (lookup main), even in full tunnel.\n"
"RU: Форсировать трафик приложения мимо VPN (lookup main), даже в full tunnel."
)
bg = QButtonGroup(self)
bg.addButton(self.rad_vpn)
bg.addButton(self.rad_direct)
self.rad_vpn.setChecked(True)
row_target.addWidget(self.rad_vpn)
row_target.addWidget(self.rad_direct)
row_target.addStretch(1)
row_ttl = QHBoxLayout()
row_ttl.addWidget(QLabel("TTL (hours)"))
self.spn_ttl = QSpinBox()
self.spn_ttl.setRange(1, 24 * 30) # up to ~30 days
self.spn_ttl.setValue(24)
self.spn_ttl.setToolTip(
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
)
row_ttl.addWidget(self.spn_ttl)
row_ttl.addStretch(1)
gl.addLayout(row_target)
gl.addLayout(row_ttl)
row_btn = QHBoxLayout()
self.btn_run = QPushButton("Run in scope + apply mark")
self.btn_run.clicked.connect(self.on_run_clicked)
row_btn.addWidget(self.btn_run)
self.btn_refresh = QPushButton("Refresh counts")
self.btn_refresh.clicked.connect(self.on_refresh_counts)
row_btn.addWidget(self.btn_refresh)
row_btn.addStretch(1)
gl.addLayout(row_btn)
self.lbl_counts = QLabel("Marks: —")
self.lbl_counts.setStyleSheet("color: gray;")
gl.addWidget(self.lbl_counts)
root.addWidget(grp)
self.txt = QPlainTextEdit()
self.txt.setReadOnly(True)
root.addWidget(self.txt, stretch=1)
row_bottom = QHBoxLayout()
row_bottom.addStretch(1)
btn_close = QPushButton("Close")
btn_close.clicked.connect(self.accept)
row_bottom.addWidget(btn_close)
root.addLayout(row_bottom)
QtCore.QTimer.singleShot(0, self.on_refresh_counts)
def _emit_log(self, msg: str) -> None:
text = (msg or "").strip()
if not text:
return
if self.log_cb:
self.log_cb(text)
return
try:
self.ctrl.log_gui(text)
except Exception:
pass
def _append(self, msg: str) -> None:
text = (msg or "").rstrip()
if not text:
return
self.txt.appendPlainText(text)
self._emit_log(text)
def on_refresh_counts(self) -> None:
try:
st = self.ctrl.traffic_appmarks_status()
self.lbl_counts.setText(f"Marks: VPN={st.vpn_count}, Direct={st.direct_count}")
except Exception as e:
self.lbl_counts.setText(f"Marks: error: {e}")
def _run_scope(self, cmdline: str, *, unit: str) -> RunScopeResult:
args = shlex.split(cmdline)
if not args:
return RunScopeResult(ok=False, message="empty command")
# Launch the scope.
run_cmd = [
"systemd-run",
"--user",
"--scope",
"--unit",
unit,
"--collect",
"--same-dir",
] + args
p = subprocess.run(
run_cmd,
capture_output=True,
text=True,
check=False,
)
out = (p.stdout or "") + (p.stderr or "")
if p.returncode != 0:
return RunScopeResult(ok=False, unit=unit, message=f"systemd-run failed: {p.returncode}", stdout=out.strip())
# Get cgroup path for this unit.
p2 = subprocess.run(
["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit],
capture_output=True,
text=True,
check=False,
)
cg = (p2.stdout or "").strip()
out2 = (p2.stdout or "") + (p2.stderr or "")
if p2.returncode != 0 or not cg:
return RunScopeResult(ok=False, unit=unit, message="failed to query ControlGroup", stdout=(out + "\n" + out2).strip())
return RunScopeResult(ok=True, unit=unit, cgroup=cg, message="ok", stdout=out.strip())
def on_run_clicked(self) -> None:
cmdline = self.ed_cmd.text().strip()
if not cmdline:
QMessageBox.warning(self, "Missing command", "Please enter a command to run.")
return
target = "vpn" if self.rad_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_ttl.value()) * 3600
unit = f"svpn-{target}-{int(time.time())}.scope"
self._append(f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}")
try:
rr = self._run_scope(cmdline, unit=unit)
except Exception as e:
QMessageBox.critical(self, "Run failed", str(e))
return
if not rr.ok:
self._append(f"[app] ERROR: {rr.message}\n{rr.stdout}".rstrip())
QMessageBox.critical(self, "Run failed", rr.message)
return
self._append(f"[app] scope started: unit={rr.unit}")
self._append(f"[app] ControlGroup: {rr.cgroup}")
try:
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=target,
cgroup=rr.cgroup,
timeout_sec=ttl_sec,
)
except Exception as e:
self._append(f"[appmarks] ERROR calling API: {e}")
QMessageBox.critical(self, "API error", str(e))
return
if res.ok:
self._append(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s")
else:
self._append(f"[appmarks] ERROR: {res.message}")
QMessageBox.critical(self, "App mark error", res.message or "unknown error")
self.on_refresh_counts()

View File

@@ -2,10 +2,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import subprocess
import time
from typing import Callable from typing import Callable
from PySide6 import QtCore, QtGui from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
@@ -20,6 +24,7 @@ from PySide6.QtWidgets import (
QPlainTextEdit, QPlainTextEdit,
QPushButton, QPushButton,
QRadioButton, QRadioButton,
QSpinBox,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -154,6 +159,104 @@ RU: Восстанавливает маршруты/nft из последнег
tab_basic_layout.addStretch(1) tab_basic_layout.addStretch(1)
self.tabs.addTab(tab_basic, "Traffic basics") self.tabs.addTab(tab_basic, "Traffic basics")
# -----------------------------------------------------------------
# Apps (runtime): systemd --user scope + backend appmarks
# -----------------------------------------------------------------
tab_apps = QWidget()
tab_apps_layout = QVBoxLayout(tab_apps)
apps_hint = QLabel(
"Runtime per-app routing (Wayland-friendly):\n"
"- Launch uses systemd-run --user --scope.\n"
"- Backend adds the scope cgroup into nftset -> fwmark rules.\n"
"- Marks are temporary (TTL). Use Policy overrides for persistent policy."
)
apps_hint.setWordWrap(True)
apps_hint.setStyleSheet("color: gray;")
tab_apps_layout.addWidget(apps_hint)
run_group = QGroupBox("Run app in scope + apply mark")
run_layout = QVBoxLayout(run_group)
row_cmd = QHBoxLayout()
row_cmd.addWidget(QLabel("Command"))
self.ed_app_cmd = QLineEdit()
self.ed_app_cmd.setPlaceholderText(
"e.g. firefox --private-window https://example.com"
)
self.ed_app_cmd.setToolTip(
"EN: Command line to run. This runs as current user in a systemd --user scope.\n"
"RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope."
)
row_cmd.addWidget(self.ed_app_cmd, stretch=1)
run_layout.addLayout(row_cmd)
row_target = QHBoxLayout()
row_target.addWidget(QLabel("Route via"))
self.rad_app_vpn = QRadioButton("VPN")
self.rad_app_vpn.setToolTip(
"EN: Force this app traffic via VPN policy table (agvpn).\n"
"RU: Форсировать трафик приложения через VPN policy-table (agvpn)."
)
self.rad_app_direct = QRadioButton("Direct")
self.rad_app_direct.setToolTip(
"EN: Force this app traffic to bypass VPN (lookup main), even in full tunnel.\n"
"RU: Форсировать трафик приложения мимо VPN (lookup main), даже в full tunnel."
)
bg_app = QButtonGroup(self)
bg_app.addButton(self.rad_app_vpn)
bg_app.addButton(self.rad_app_direct)
self.rad_app_vpn.setChecked(True)
row_target.addWidget(self.rad_app_vpn)
row_target.addWidget(self.rad_app_direct)
row_target.addStretch(1)
run_layout.addLayout(row_target)
row_ttl = QHBoxLayout()
row_ttl.addWidget(QLabel("TTL (hours)"))
self.spn_app_ttl = QSpinBox()
self.spn_app_ttl.setRange(1, 24 * 30) # up to ~30 days
self.spn_app_ttl.setValue(24)
self.spn_app_ttl.setToolTip(
"EN: How long the runtime mark stays active (backend nftset element timeout).\n"
"RU: Сколько живет runtime-метка (timeout элемента в nftset)."
)
row_ttl.addWidget(self.spn_app_ttl)
row_ttl.addStretch(1)
run_layout.addLayout(row_ttl)
row_btn = QHBoxLayout()
self.btn_app_run = QPushButton("Run + apply mark")
self.btn_app_run.clicked.connect(self.on_app_run)
row_btn.addWidget(self.btn_app_run)
self.btn_app_refresh = QPushButton("Refresh counts")
self.btn_app_refresh.clicked.connect(self.refresh_appmarks_counts)
row_btn.addWidget(self.btn_app_refresh)
self.btn_app_clear_vpn = QPushButton("Clear VPN marks")
self.btn_app_clear_vpn.clicked.connect(lambda: self.on_appmarks_clear("vpn"))
row_btn.addWidget(self.btn_app_clear_vpn)
self.btn_app_clear_direct = QPushButton("Clear Direct marks")
self.btn_app_clear_direct.clicked.connect(
lambda: self.on_appmarks_clear("direct")
)
row_btn.addWidget(self.btn_app_clear_direct)
row_btn.addStretch(1)
run_layout.addLayout(row_btn)
self.lbl_app_counts = QLabel("Marks: —")
self.lbl_app_counts.setStyleSheet("color: gray;")
run_layout.addWidget(self.lbl_app_counts)
tab_apps_layout.addWidget(run_group)
self.txt_app = QPlainTextEdit()
self.txt_app.setReadOnly(True)
tab_apps_layout.addWidget(self.txt_app, stretch=1)
tab_apps_layout.addStretch(1)
self.tabs.addTab(tab_apps, "Apps (runtime)")
tab_adv = QWidget() tab_adv = QWidget()
tab_adv_layout = QVBoxLayout(tab_adv) tab_adv_layout = QVBoxLayout(tab_adv)
@@ -251,6 +354,7 @@ RU: Применяет policy-rules и проверяет health. При оши
root.addLayout(row_bottom) root.addLayout(row_bottom)
QtCore.QTimer.singleShot(0, self.refresh_state) QtCore.QTimer.singleShot(0, self.refresh_state)
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
def _is_operation_error(self, message: str) -> bool: def _is_operation_error(self, message: str) -> bool:
low = (message or "").strip().lower() low = (message or "").strip().lower()
@@ -622,6 +726,143 @@ RU: Применяет policy-rules и проверяет health. При оши
self._safe(work, title="Apply overrides error") self._safe(work, title="Apply overrides error")
# -----------------------------------------------------------------
# Apps (runtime) tab
# -----------------------------------------------------------------
def _append_app_log(self, msg: str) -> None:
line = (msg or "").rstrip()
if not line:
return
try:
self.txt_app.appendPlainText(line)
except Exception:
pass
self._emit_log(line)
def refresh_appmarks_counts(self) -> None:
try:
st = self.ctrl.traffic_appmarks_status()
self.lbl_app_counts.setText(
f"Marks: VPN={st.vpn_count}, Direct={st.direct_count}"
)
except Exception as e:
self.lbl_app_counts.setText(f"Marks: error: {e}")
def _run_systemd_scope(self, cmdline: str, *, unit: str) -> tuple[str, str]:
args = shlex.split(cmdline or "")
if not args:
raise ValueError("empty command")
run_cmd = [
"systemd-run",
"--user",
"--scope",
"--unit",
unit,
"--collect",
"--same-dir",
] + args
p = subprocess.run(run_cmd, capture_output=True, text=True, check=False)
out = ((p.stdout or "") + (p.stderr or "")).strip()
if p.returncode != 0:
raise RuntimeError(f"systemd-run failed: {p.returncode}\n{out}".strip())
p2 = subprocess.run(
["systemctl", "--user", "show", "-p", "ControlGroup", "--value", unit],
capture_output=True,
text=True,
check=False,
)
out2 = ((p2.stdout or "") + (p2.stderr or "")).strip()
cg = (p2.stdout or "").strip()
if p2.returncode != 0 or not cg:
raise RuntimeError(f"failed to query ControlGroup\n{out2}".strip())
return cg, out
def on_app_run(self) -> None:
def work() -> None:
cmdline = (self.ed_app_cmd.text() or "").strip()
if not cmdline:
QMessageBox.warning(self, "Missing command", "Please enter a command to run.")
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
unit = f"svpn-{target}-{int(time.time())}.scope"
self._append_app_log(
f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}"
)
cg, out = self._run_systemd_scope(cmdline, unit=unit)
if out:
self._append_app_log(f"[app] systemd-run:\n{out}")
self._append_app_log(f"[app] ControlGroup: {cg}")
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=target,
cgroup=cg,
timeout_sec=ttl_sec,
)
if res.ok:
self._append_app_log(
f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s"
)
self._set_action_status(
f"App mark added: target={target} cgroup_id={res.cgroup_id}",
ok=True,
)
else:
self._append_app_log(f"[appmarks] ERROR: {res.message}")
self._set_action_status(
f"App mark failed: target={target} ({res.message})",
ok=False,
)
QMessageBox.critical(
self,
"App mark error",
res.message or "unknown error",
)
self.refresh_appmarks_counts()
self._safe(work, title="Run app error")
def on_appmarks_clear(self, target: str) -> None:
tgt = (target or "").strip().lower()
if tgt not in ("vpn", "direct"):
return
def work() -> None:
if QMessageBox.question(
self,
"Clear marks",
f"Clear ALL runtime app marks for target={tgt}?",
) != QMessageBox.StandardButton.Yes:
return
res = self.ctrl.traffic_appmarks_apply(op="clear", target=tgt)
if res.ok:
self._append_app_log(f"[appmarks] cleared: target={tgt}")
self._set_action_status(f"App marks cleared: target={tgt}", ok=True)
else:
self._append_app_log(f"[appmarks] clear error: {res.message}")
self._set_action_status(
f"App marks clear failed: target={tgt} ({res.message})",
ok=False,
)
QMessageBox.critical(
self, "Clear marks error", res.message or "unknown error"
)
self.refresh_appmarks_counts()
self._safe(work, title="Clear app marks error")
def on_rollback(self) -> None: def on_rollback(self) -> None:
def work() -> None: def work() -> None:
res = self.ctrl.routes_clear() res = self.ctrl.routes_clear()
@@ -800,6 +1041,33 @@ RU: Скрывает элементы, которые уже есть в Force V
if filt is not None: if filt is not None:
filt.setText(text) filt.setText(text)
def _preset_filter_subnets(self, *, lan: bool, docker: bool, link: bool) -> None:
# EN: "Filter LAN/Docker" buttons should not rely on text search; they should
# EN: toggle the kind checkboxes and clear the text filter.
# RU: Кнопки "Filter LAN/Docker" не должны полагаться на текстовый поиск;
# RU: они должны переключать чекбоксы видов и очищать текстовый фильтр.
chk_lan = getattr(self, "chk_sub_show_lan", None)
chk_docker = getattr(self, "chk_sub_show_docker", None)
chk_link = getattr(self, "chk_sub_show_link", None)
if chk_lan is None or chk_docker is None or chk_link is None:
self._preset_set_filter("Subnets", "")
self._refilter_current()
return
for chk, val in (
(chk_lan, lan),
(chk_docker, docker),
(chk_link, link),
):
try:
chk.blockSignals(True)
chk.setChecked(bool(val))
finally:
chk.blockSignals(False)
self._preset_set_filter("Subnets", "")
self._refilter_current()
def _update_item_render(self, it: QListWidgetItem, kind: str) -> None: def _update_item_render(self, it: QListWidgetItem, kind: str) -> None:
value = str(it.data(QtCore.Qt.UserRole) or "").strip() value = str(it.data(QtCore.Qt.UserRole) or "").strip()
base_label = str(it.data(QtCore.Qt.UserRole + 2) or it.text() or "") base_label = str(it.data(QtCore.Qt.UserRole + 2) or it.text() or "")
@@ -1021,13 +1289,13 @@ RU: Скрывает элементы, которые уже есть в Force V
row2 = QHBoxLayout() row2 = QHBoxLayout()
btn_f_lan = QPushButton("Filter LAN") btn_f_lan = QPushButton("Filter LAN")
btn_f_lan.clicked.connect(lambda: self._preset_set_filter("Subnets", "lan")) btn_f_lan.clicked.connect(lambda: self._preset_filter_subnets(lan=True, docker=False, link=True))
row2.addWidget(btn_f_lan) row2.addWidget(btn_f_lan)
btn_f_docker = QPushButton("Filter Docker") btn_f_docker = QPushButton("Filter Docker")
btn_f_docker.clicked.connect(lambda: self._preset_set_filter("Subnets", "docker")) btn_f_docker.clicked.connect(lambda: self._preset_filter_subnets(lan=False, docker=True, link=False))
row2.addWidget(btn_f_docker) row2.addWidget(btn_f_docker)
btn_f_clear = QPushButton("Clear filter") btn_f_clear = QPushButton("Clear filter")
btn_f_clear.clicked.connect(lambda: self._preset_set_filter("Subnets", "")) btn_f_clear.clicked.connect(lambda: self._preset_filter_subnets(lan=True, docker=True, link=True))
row2.addWidget(btn_f_clear) row2.addWidget(btn_f_clear)
row2.addStretch(1) row2.addStretch(1)
layout.addLayout(row2) layout.addLayout(row2)
@@ -1045,7 +1313,7 @@ RU: Скрывает элементы, которые уже есть в Force V
self.chk_sub_show_docker.stateChanged.connect(lambda _s: self._refilter_current()) self.chk_sub_show_docker.stateChanged.connect(lambda _s: self._refilter_current())
row3.addWidget(self.chk_sub_show_docker) row3.addWidget(self.chk_sub_show_docker)
self.chk_sub_show_link = QCheckBox("Link") self.chk_sub_show_link = QCheckBox("Link (scope link)")
self.chk_sub_show_link.setChecked(True) 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.setToolTip("EN: Show link-scope routes.\nRU: Показать маршруты scope link.")
self.chk_sub_show_link.stateChanged.connect(lambda _s: self._refilter_current()) self.chk_sub_show_link.stateChanged.connect(lambda _s: self._refilter_current())

View File

@@ -36,7 +36,6 @@ from PySide6.QtWidgets import (
from api_client import ApiClient, DnsUpstreams from api_client import ApiClient, DnsUpstreams
from dashboard_controller import DashboardController, TraceMode from dashboard_controller import DashboardController, TraceMode
from app_route_dialog import AppRouteDialog
from traffic_mode_dialog import TrafficModeDialog from traffic_mode_dialog import TrafficModeDialog
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s") _NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
@@ -353,13 +352,6 @@ class MainWindow(QMainWindow):
self.btn_traffic_settings = QPushButton("Open traffic settings") self.btn_traffic_settings = QPushButton("Open traffic settings")
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings) self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
relay_row.addWidget(self.btn_traffic_settings) relay_row.addWidget(self.btn_traffic_settings)
self.btn_app_route = QPushButton("Run app via VPN/Direct")
self.btn_app_route.setToolTip(
"EN: Launch an app in a systemd --user scope and apply a temporary per-app routing mark (Wayland-friendly).\n"
"RU: Запуск приложения в systemd --user scope + временная per-app метка маршрутизации."
)
self.btn_app_route.clicked.connect(self.on_open_app_route)
relay_row.addWidget(self.btn_app_route)
self.btn_traffic_test = QPushButton("Test mode") self.btn_traffic_test = QPushButton("Test mode")
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode) self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
relay_row.addWidget(self.btn_traffic_test) relay_row.addWidget(self.btn_traffic_test)
@@ -1351,18 +1343,6 @@ RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, и
refresh_all_traffic() refresh_all_traffic()
self._safe(work, title="Traffic mode dialog error") self._safe(work, title="Traffic mode dialog error")
def on_open_app_route(self) -> None:
def work():
dlg = AppRouteDialog(
self.ctrl,
log_cb=self._append_routes_log,
parent=self,
)
dlg.exec()
self.refresh_routes_tab()
self.refresh_status_tab()
self._safe(work, title="App route dialog error")
def on_test_traffic_mode(self) -> None: def on_test_traffic_mode(self) -> None:
def work(): def work():
view = self.ctrl.traffic_mode_test() view = self.ctrl.traffic_mode_test()