ui: apps runtime tab + fix subnet filter presets
This commit is contained in:
@@ -72,4 +72,3 @@ sudo chown -R dev:dev <path>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then retry the rebase.
|
Then retry the rebase.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user