ui+api: dedup per-app marks by app_key; auto-refresh runtime

This commit is contained in:
beckline
2026-02-15 16:31:19 +03:00
parent b77adb153a
commit 70c5eea935
5 changed files with 206 additions and 16 deletions

View File

@@ -48,6 +48,9 @@ type appMarkItem struct {
Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational
CgroupRel string `json:"cgroup_rel"`
Level int `json:"level"`
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
AppKey string `json:"app_key,omitempty"`
AddedAt string `json:"added_at"`
ExpiresAt string `json:"expires_at"`
}
@@ -74,6 +77,9 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
op := TrafficAppMarksOp(strings.ToLower(strings.TrimSpace(string(body.Op))))
target := strings.ToLower(strings.TrimSpace(body.Target))
cgroup := strings.TrimSpace(body.Cgroup)
unit := strings.TrimSpace(body.Unit)
command := strings.TrimSpace(body.Command)
appKey := strings.TrimSpace(body.AppKey)
timeoutSec := body.TimeoutSec
if op == "" {
@@ -165,7 +171,7 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) {
}
}
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, ttl); err != nil {
if err := appMarksAdd(target, inodeID, cgAbs, rel, level, unit, command, appKey, ttl); err != nil {
writeJSON(w, http.StatusOK, TrafficAppMarksResponse{
OK: false,
Op: string(op),
@@ -250,7 +256,7 @@ func appMarksGetStatus() (vpnCount int, directCount int) {
return vpnCount, directCount
}
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, ttlSec int) error {
func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int, unit string, command string, appKey string, ttlSec int) error {
target = strings.ToLower(strings.TrimSpace(target))
if target != "vpn" && target != "direct" {
return fmt.Errorf("invalid target")
@@ -271,6 +277,27 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
st := loadAppMarksState()
changed := pruneExpiredAppMarksLocked(&st, time.Now().UTC())
unit = strings.TrimSpace(unit)
command = strings.TrimSpace(command)
appKey = normalizeAppKey(appKey, command)
// EN: Avoid unbounded growth of marks for the same app.
// RU: Не даём бесконечно плодить метки на одно и то же приложение.
if appKey != "" {
kept := st.Items[:0]
for _, it := range st.Items {
if strings.ToLower(strings.TrimSpace(it.Target)) == target &&
strings.TrimSpace(it.AppKey) == appKey &&
it.ID != id {
_ = nftDeleteAppMarkRule(target, it.ID)
changed = true
continue
}
kept = append(kept, it)
}
st.Items = kept
}
// Replace any existing rule/state for this (target,id).
_ = nftDeleteAppMarkRule(target, id)
if err := nftInsertAppMarkRule(target, rel, level, id); err != nil {
@@ -284,6 +311,9 @@ func appMarksAdd(target string, id uint64, cgAbs string, rel string, level int,
Cgroup: cgAbs,
CgroupRel: rel,
Level: level,
Unit: unit,
Command: command,
AppKey: appKey,
AddedAt: now.Format(time.RFC3339),
ExpiresAt: now.Add(time.Duration(ttlSec) * time.Second).Format(time.RFC3339),
}
@@ -613,6 +643,22 @@ func saveAppMarksState(st appMarksState) error {
return os.Rename(tmp, trafficAppMarksPath)
}
func normalizeAppKey(appKey string, command string) string {
key := strings.TrimSpace(appKey)
if key != "" {
return key
}
cmd := strings.TrimSpace(command)
if cmd == "" {
return ""
}
fields := strings.Fields(cmd)
if len(fields) > 0 {
return strings.TrimSpace(fields[0])
}
return cmd
}
func isAllDigits(s string) bool {
s = strings.TrimSpace(s)
if s == "" {

View File

@@ -200,10 +200,15 @@ const (
// EN: Runtime app marking request. Used by per-app launcher wrappers.
// RU: Runtime app marking запрос. Используется wrapper-лаунчером per-app.
type TrafficAppMarksRequest struct {
Op TrafficAppMarksOp `json:"op"`
Target string `json:"target"` // vpn|direct
Cgroup string `json:"cgroup,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
Op TrafficAppMarksOp `json:"op"`
Target string `json:"target"` // vpn|direct
Cgroup string `json:"cgroup,omitempty"`
// EN: Optional metadata to deduplicate marks per-app across restarts / re-runs.
// RU: Опциональные метаданные, чтобы не плодить метки на одно и то же приложение.
Unit string `json:"unit,omitempty"`
Command string `json:"command,omitempty"`
AppKey string `json:"app_key,omitempty"`
TimeoutSec int `json:"timeout_sec,omitempty"` // only for add
}
type TrafficAppMarksResponse struct {

View File

@@ -825,6 +825,9 @@ class ApiClient:
op: str,
target: str,
cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0,
) -> TrafficAppMarksResult:
payload: Dict[str, Any] = {
@@ -833,6 +836,12 @@ class ApiClient:
}
if cgroup:
payload["cgroup"] = str(cgroup).strip()
if unit:
payload["unit"] = str(unit).strip()
if command:
payload["command"] = str(command).strip()
if app_key:
payload["app_key"] = str(app_key).strip()
if int(timeout_sec or 0) > 0:
payload["timeout_sec"] = int(timeout_sec)

View File

@@ -716,12 +716,18 @@ class DashboardController:
op: str,
target: str,
cgroup: str = "",
unit: str = "",
command: str = "",
app_key: str = "",
timeout_sec: int = 0,
) -> TrafficAppMarksResult:
return self.client.traffic_appmarks_apply(
op=op,
target=target,
cgroup=cgroup,
unit=unit,
command=command,
app_key=app_key,
timeout_sec=timeout_sec,
)

View File

@@ -82,6 +82,8 @@ class TrafficModeDialog(QDialog):
self._settings = QtCore.QSettings("AdGuardVPN", "SelectiveVPNDashboardQt")
self._last_app_unit: str = str(self._settings.value("traffic_app_last_unit", "") or "")
self._last_app_target: str = str(self._settings.value("traffic_app_last_target", "") or "")
self._last_app_key: str = str(self._settings.value("traffic_app_last_key", "") or "")
self._last_app_cmdline: str = str(self._settings.value("traffic_app_last_cmdline", "") or "")
try:
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
except Exception:
@@ -468,6 +470,23 @@ RU: Применяет policy-rules и проверяет health. При оши
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
QtCore.QTimer.singleShot(0, self.refresh_running_scopes)
# EN: Auto-refresh runtime marks/units while dialog is open.
# RU: Авто-обновление runtime меток/юнитов пока окно открыто.
self._runtime_auto_timer = QtCore.QTimer(self)
self._runtime_auto_timer.setInterval(5000)
self._runtime_auto_timer.timeout.connect(self._auto_refresh_runtime)
self._runtime_auto_timer.start()
def _auto_refresh_runtime(self) -> None:
# Keep this quiet: no modal popups.
self.refresh_appmarks_counts()
try:
# Only refresh units list when Apps(runtime) tab is visible.
if int(self.tabs.currentIndex() or 0) == 1:
self.refresh_running_scopes(quiet=True)
except Exception:
pass
def _is_operation_error(self, message: str) -> bool:
low = (message or "").strip().lower()
return ("rolled back" in low) or ("apply failed" in low) or ("verification failed" in low)
@@ -864,6 +883,7 @@ RU: Применяет policy-rules и проверяет health. При оши
def _refresh_last_scope_ui(self) -> None:
unit = (self._last_app_unit or "").strip()
target = (self._last_app_target or "").strip().lower()
app_key = (self._last_app_key or "").strip()
cg_id = int(self._last_app_cgroup_id or 0)
parts = []
@@ -871,6 +891,8 @@ RU: Применяет policy-rules и проверяет health. При оши
parts.append(f"unit={unit}")
if target in ("vpn", "direct"):
parts.append(f"target={target}")
if app_key:
parts.append(f"app={app_key}")
if cg_id > 0:
parts.append(f"cgroup_id={cg_id}")
@@ -882,9 +904,19 @@ RU: Применяет policy-rules и проверяет health. При оши
self.btn_app_stop_last.setEnabled(bool(unit))
self.btn_app_unmark_last.setEnabled(bool(unit) and target in ("vpn", "direct") and cg_id > 0)
def _set_last_scope(self, *, unit: str = "", target: str = "", cgroup_id: int = 0) -> None:
def _set_last_scope(
self,
*,
unit: str = "",
target: str = "",
app_key: str = "",
cmdline: str = "",
cgroup_id: int = 0,
) -> None:
self._last_app_unit = str(unit or "").strip()
self._last_app_target = str(target or "").strip().lower()
self._last_app_key = str(app_key or "").strip()
self._last_app_cmdline = str(cmdline or "").strip()
try:
self._last_app_cgroup_id = int(cgroup_id or 0)
except Exception:
@@ -892,6 +924,8 @@ RU: Применяет policy-rules и проверяет health. При оши
self._settings.setValue("traffic_app_last_unit", self._last_app_unit)
self._settings.setValue("traffic_app_last_target", self._last_app_target)
self._settings.setValue("traffic_app_last_key", self._last_app_key)
self._settings.setValue("traffic_app_last_cmdline", self._last_app_cmdline)
self._settings.setValue("traffic_app_last_cgroup_id", int(self._last_app_cgroup_id or 0))
self._refresh_last_scope_ui()
@@ -1058,24 +1092,86 @@ RU: Применяет policy-rules и проверяет health. При оши
QMessageBox.warning(self, "Missing command", "Please enter a command to run.")
return
app_key = ""
try:
args = shlex.split(cmdline or "")
if args:
app_key = str(args[0] or "").strip()
except Exception:
app_key = ""
if not app_key:
# Fallback: best-effort first token.
app_key = (cmdline.split() or [""])[0].strip()
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
# EN: If the app is already running inside the last svpn unit, don't spawn a new instance.
# RU: Если приложение уже запущено в последнем svpn unit, не запускаем второй экземпляр.
last_unit = (self._last_app_unit or "").strip()
last_target = (self._last_app_target or "").strip().lower()
last_key = (self._last_app_key or "").strip()
if last_unit and last_target == target and last_key and last_key == app_key:
code, out = self._systemctl_user(["is-active", last_unit])
if code == 0 and (out or "").strip().lower() == "active":
cg = self._query_control_group_for_unit(last_unit)
self._append_app_log(
f"[app] already running: app={app_key} target={target} unit={last_unit} (refreshing mark)"
)
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=target,
cgroup=cg,
unit=last_unit,
command=cmdline,
app_key=app_key,
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 refreshed: target={target} cgroup_id={res.cgroup_id}",
ok=True,
)
self._set_last_scope(
unit=last_unit,
target=target,
app_key=app_key,
cmdline=cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
else:
self._append_app_log(f"[appmarks] ERROR: {res.message}")
self._set_action_status(
f"App mark refresh failed: target={target} ({res.message})",
ok=False,
)
QMessageBox.critical(self, "App mark error", res.message or "unknown error")
self.refresh_appmarks_counts()
self.refresh_running_scopes()
return
unit = f"svpn-{target}-{int(time.time())}.service"
self._append_app_log(
f"[app] launching: target={target} ttl={ttl_sec}s unit={unit}"
f"[app] launching: app={app_key or '-'} target={target} ttl={ttl_sec}s unit={unit}"
)
cg, out = self._run_systemd_unit(cmdline, unit=unit)
if out:
self._append_app_log(f"[app] systemd-run:\n{out}")
self._append_app_log(f"[app] ControlGroup: {cg}")
self._set_last_scope(unit=unit, target=target, cgroup_id=0)
self._set_last_scope(unit=unit, target=target, app_key=app_key, cmdline=cmdline, cgroup_id=0)
res = self.ctrl.traffic_appmarks_apply(
op="add",
target=target,
cgroup=cg,
unit=unit,
command=cmdline,
app_key=app_key,
timeout_sec=ttl_sec,
)
if res.ok:
@@ -1086,20 +1182,40 @@ RU: Применяет policy-rules и проверяет health. При оши
f"App mark added: target={target} cgroup_id={res.cgroup_id}",
ok=True,
)
self._set_last_scope(unit=unit, target=target, cgroup_id=int(res.cgroup_id or 0))
self._set_last_scope(
unit=unit,
target=target,
app_key=app_key,
cmdline=cmdline,
cgroup_id=int(res.cgroup_id or 0),
)
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",
)
low = (res.message or "").lower()
if "cgroupv2 path fails" in low or "no such file or directory" in low:
QMessageBox.critical(
self,
"App mark error",
(res.message or "unknown error")
+ "\n\n"
+ "EN: This usually means the app didn't stay inside the new systemd unit "
+ "(often because it was already running). Close the app completely and run again from here.\n"
+ "RU: Обычно это значит, что приложение не осталось в новом systemd unit "
+ "(часто потому что оно уже было запущено). Полностью закрой приложение и запусти снова отсюда.",
)
else:
QMessageBox.critical(
self,
"App mark error",
res.message or "unknown error",
)
self.refresh_appmarks_counts()
self.refresh_running_scopes()
self._safe(work, title="Run app error")
@@ -1292,7 +1408,7 @@ RU: Применяет policy-rules и проверяет health. При оши
except Exception:
return 0
def refresh_running_scopes(self) -> None:
def refresh_running_scopes(self, *, quiet: bool = False) -> None:
def work() -> None:
units = self._list_running_svpn_units()
self.lst_scopes.clear()
@@ -1347,6 +1463,14 @@ RU: Применяет policy-rules и проверяет health. При оши
self.btn_scopes_stop_selected.setEnabled(has_any)
self.btn_scopes_cleanup.setEnabled(has_any)
if quiet:
try:
work()
except Exception:
# Quiet mode: avoid modal popups on background refresh.
pass
return
self._safe(work, title="Units refresh error")
def _copy_scope_unit(self, it: QListWidgetItem) -> None: