traffic: add persistent app profiles (api+gui)

This commit is contained in:
beckline
2026-02-15 20:56:57 +03:00
parent 70c5eea935
commit b040b9e7d7
7 changed files with 743 additions and 5 deletions

View File

@@ -138,6 +138,26 @@ class TrafficAppMarksResult:
timeout_sec: int = 0
@dataclass(frozen=True)
class TrafficAppProfile:
id: str
name: str
app_key: str
command: str
target: str # vpn|direct
ttl_sec: int
vpn_profile: str
created_at: str
updated_at: str
@dataclass(frozen=True)
class TrafficAppProfileSaveResult:
ok: bool
message: str
profile: Optional[TrafficAppProfile] = None
@dataclass(frozen=True)
class TrafficCandidateSubnet:
@@ -860,6 +880,114 @@ class ApiClient:
timeout_sec=int(data.get("timeout_sec", 0) or 0),
)
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
data = cast(
Dict[str, Any],
self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {},
)
raw = data.get("profiles") or []
if not isinstance(raw, list):
raw = []
out: List[TrafficAppProfile] = []
for it in raw:
if not isinstance(it, dict):
continue
pid = str(it.get("id") or "").strip()
if not pid:
continue
out.append(
TrafficAppProfile(
id=pid,
name=str(it.get("name") or "").strip(),
app_key=str(it.get("app_key") or "").strip(),
command=str(it.get("command") or "").strip(),
target=str(it.get("target") or "").strip().lower(),
ttl_sec=int(it.get("ttl_sec", 0) or 0),
vpn_profile=str(it.get("vpn_profile") or "").strip(),
created_at=str(it.get("created_at") or "").strip(),
updated_at=str(it.get("updated_at") or "").strip(),
)
)
return out
def traffic_app_profile_upsert(
self,
*,
id: str = "",
name: str = "",
app_key: str = "",
command: str,
target: str,
ttl_sec: int = 0,
vpn_profile: str = "",
) -> TrafficAppProfileSaveResult:
payload: Dict[str, Any] = {
"command": str(command or "").strip(),
"target": str(target or "").strip().lower(),
}
if id:
payload["id"] = str(id).strip()
if name:
payload["name"] = str(name).strip()
if app_key:
payload["app_key"] = str(app_key).strip()
if int(ttl_sec or 0) > 0:
payload["ttl_sec"] = int(ttl_sec)
if vpn_profile:
payload["vpn_profile"] = str(vpn_profile).strip()
data = cast(
Dict[str, Any],
self._json(
self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload)
)
or {},
)
msg = str(data.get("message") or "")
raw = data.get("profiles") or []
if not isinstance(raw, list):
raw = []
prof: Optional[TrafficAppProfile] = None
if raw and isinstance(raw[0], dict):
it = cast(Dict[str, Any], raw[0])
pid = str(it.get("id") or "").strip()
if pid:
prof = TrafficAppProfile(
id=pid,
name=str(it.get("name") or "").strip(),
app_key=str(it.get("app_key") or "").strip(),
command=str(it.get("command") or "").strip(),
target=str(it.get("target") or "").strip().lower(),
ttl_sec=int(it.get("ttl_sec", 0) or 0),
vpn_profile=str(it.get("vpn_profile") or "").strip(),
created_at=str(it.get("created_at") or "").strip(),
updated_at=str(it.get("updated_at") or "").strip(),
)
ok = bool(prof) and (msg.strip().lower() in ("saved", "ok"))
if not msg and ok:
msg = "saved"
return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof)
def traffic_app_profile_delete(self, id: str) -> CmdResult:
pid = str(id or "").strip()
if not pid:
raise ValueError("missing id")
data = cast(
Dict[str, Any],
self._json(
self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid})
)
or {},
)
return CmdResult(
ok=bool(data.get("ok", False)),
message=str(data.get("message") or ""),
exit_code=None,
stdout="",
stderr="",
)
# DNS / SmartDNS
def dns_upstreams_get(self) -> DnsUpstreams:

View File

@@ -34,6 +34,8 @@ from api_client import (
TrafficCandidates,
TrafficAppMarksResult,
TrafficAppMarksStatus,
TrafficAppProfile,
TrafficAppProfileSaveResult,
TrafficInterfaces,
TrafficModeStatus,
TraceDump,
@@ -200,6 +202,9 @@ class DashboardController:
return ["routes"]
if k == "traffic_mode_changed":
return ["routes", "status"]
if k == "traffic_profiles_changed":
# Used by Traffic mode dialog (Apps/runtime) for persistent app profiles.
return ["routes"]
return []
# -------- helpers --------
@@ -731,6 +736,33 @@ class DashboardController:
timeout_sec=timeout_sec,
)
def traffic_app_profiles_list(self) -> List[TrafficAppProfile]:
return self.client.traffic_app_profiles_list()
def traffic_app_profile_upsert(
self,
*,
id: str = "",
name: str = "",
app_key: str = "",
command: str,
target: str,
ttl_sec: int = 0,
vpn_profile: str = "",
) -> TrafficAppProfileSaveResult:
return self.client.traffic_app_profile_upsert(
id=id,
name=name,
app_key=app_key,
command=command,
target=target,
ttl_sec=ttl_sec,
vpn_profile=vpn_profile,
)
def traffic_app_profile_delete(self, id: str) -> CmdResult:
return self.client.traffic_app_profile_delete(id)
def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView:
"""
Превращает Event(kind='routes_nft_progress') в удобную модель

View File

@@ -213,6 +213,83 @@ RU: Восстанавливает маршруты/nft из последнег
apps_hint.setStyleSheet("color: gray;")
tab_apps_layout.addWidget(apps_hint)
profiles_group = QGroupBox("Added apps (profiles)")
profiles_layout = QVBoxLayout(profiles_group)
profiles_hint = QLabel(
"Persistent launch configs (saved):\n"
"- These describe what to run and how to route it.\n"
"- Separate from runtime marks/units (which are tied to a specific cgroup)."
)
profiles_hint.setWordWrap(True)
profiles_hint.setStyleSheet("color: gray;")
profiles_layout.addWidget(profiles_hint)
row_prof_name = QHBoxLayout()
row_prof_name.addWidget(QLabel("Name"))
self.ed_app_profile_name = QLineEdit()
self.ed_app_profile_name.setPlaceholderText(
"optional (default: basename of app)"
)
self.ed_app_profile_name.setToolTip(
"EN: Optional profile name (for display). Leave empty to auto-name.\n"
"RU: Необязательное имя профиля (для отображения). Можно оставить пустым."
)
row_prof_name.addWidget(self.ed_app_profile_name, stretch=1)
self.btn_app_profiles_refresh = QPushButton("Refresh profiles")
self.btn_app_profiles_refresh.setToolTip(
"EN: Reload saved app profiles from backend.\n"
"RU: Обновить список сохранённых профилей из backend."
)
self.btn_app_profiles_refresh.clicked.connect(self.refresh_app_profiles)
row_prof_name.addWidget(self.btn_app_profiles_refresh)
profiles_layout.addLayout(row_prof_name)
row_prof_btn = QHBoxLayout()
self.btn_app_profile_save = QPushButton("Save / update profile")
self.btn_app_profile_save.setToolTip(
"EN: Save current command/route/TTL as a persistent profile (upsert).\n"
"RU: Сохранить текущую команду/маршрут/TTL как постоянный профиль (upsert)."
)
self.btn_app_profile_save.clicked.connect(self.on_app_profile_save)
row_prof_btn.addWidget(self.btn_app_profile_save)
self.btn_app_profile_load = QPushButton("Load to form")
self.btn_app_profile_load.setToolTip(
"EN: Load selected profile into the form (command/route/TTL).\n"
"RU: Загрузить выбранный профиль в форму (команда/маршрут/TTL)."
)
self.btn_app_profile_load.clicked.connect(self.on_app_profile_load)
row_prof_btn.addWidget(self.btn_app_profile_load)
self.btn_app_profile_delete = QPushButton("Delete profile")
self.btn_app_profile_delete.setToolTip(
"EN: Delete selected saved profile.\n"
"RU: Удалить выбранный сохранённый профиль."
)
self.btn_app_profile_delete.clicked.connect(self.on_app_profile_delete)
row_prof_btn.addWidget(self.btn_app_profile_delete)
row_prof_btn.addStretch(1)
profiles_layout.addLayout(row_prof_btn)
self.lst_app_profiles = QListWidget()
self.lst_app_profiles.setSelectionMode(QAbstractItemView.SingleSelection)
self.lst_app_profiles.setToolTip(
"EN: Saved app profiles. Double click loads into the form.\n"
"RU: Сохранённые профили приложений. Двойной клик загружает в форму."
)
self.lst_app_profiles.itemDoubleClicked.connect(
lambda _it: self.on_app_profile_load()
)
self.lst_app_profiles.setFixedHeight(140)
profiles_layout.addWidget(self.lst_app_profiles)
self.lbl_app_profiles = QLabel("Saved profiles: —")
self.lbl_app_profiles.setStyleSheet("color: gray;")
profiles_layout.addWidget(self.lbl_app_profiles)
tab_apps_layout.addWidget(profiles_group)
run_group = QGroupBox("Run app (systemd unit) + apply mark")
run_layout = QVBoxLayout(run_group)
@@ -467,6 +544,7 @@ RU: Применяет policy-rules и проверяет health. При оши
root.addLayout(row_bottom)
QtCore.QTimer.singleShot(0, self.refresh_state)
QtCore.QTimer.singleShot(0, self.refresh_app_profiles)
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
QtCore.QTimer.singleShot(0, self.refresh_running_scopes)
@@ -871,6 +949,164 @@ RU: Применяет policy-rules и проверяет health. При оши
pass
self._emit_log(line)
def _infer_app_key_from_cmdline(self, cmdline: str) -> str:
cmd = (cmdline or "").strip()
if not cmd:
return ""
try:
args = shlex.split(cmd)
if args:
return str(args[0] or "").strip()
except Exception:
pass
# Fallback: first token
return (cmd.split() or [""])[0].strip()
def _selected_app_profile(self):
it = self.lst_app_profiles.currentItem()
if not it:
return None
return it.data(QtCore.Qt.UserRole)
def refresh_app_profiles(self, quiet: bool = False) -> None:
def work() -> None:
profs = list(self.ctrl.traffic_app_profiles_list() or [])
self.lst_app_profiles.clear()
for p in profs:
# p is a UI-friendly dataclass from ApiClient.
name = (getattr(p, "name", "") or "").strip()
pid = (getattr(p, "id", "") or "").strip()
target = (getattr(p, "target", "") or "").strip().lower()
app_key = (getattr(p, "app_key", "") or "").strip()
cmd = (getattr(p, "command", "") or "").strip()
ttl_sec = int(getattr(p, "ttl_sec", 0) or 0)
label = name or pid or "(unnamed)"
if target in ("vpn", "direct"):
label += f" [{target}]"
it = QListWidgetItem(label)
it.setToolTip(
(
f"id: {pid}\n"
f"app_key: {app_key}\n"
f"target: {target}\n"
f"ttl: {ttl_sec}s\n\n"
f"{cmd}"
).strip()
)
it.setData(QtCore.Qt.UserRole, p)
self.lst_app_profiles.addItem(it)
self.lbl_app_profiles.setText(f"Saved profiles: {len(profs)}")
if quiet:
try:
work()
except Exception as e:
self.lbl_app_profiles.setText(f"Saved profiles: error: {e}")
return
self._safe(work, title="Refresh profiles error")
def on_app_profile_save(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 first.")
return
target = "vpn" if self.rad_app_vpn.isChecked() else "direct"
ttl_sec = int(self.spn_app_ttl.value()) * 3600
name = (self.ed_app_profile_name.text() or "").strip()
app_key = self._infer_app_key_from_cmdline(cmdline)
res = self.ctrl.traffic_app_profile_upsert(
name=name,
app_key=app_key,
command=cmdline,
target=target,
ttl_sec=ttl_sec,
)
if not res.ok:
self._set_action_status(f"Save profile failed: {res.message}", ok=False)
raise RuntimeError(res.message or "save failed")
prof = getattr(res, "profile", None)
pid = (getattr(prof, "id", "") or "").strip() if prof is not None else ""
self._set_action_status(f"Profile saved: {pid or '(ok)'}", ok=True)
self._append_app_log(f"[profile] saved: id={pid or '-'} target={target} app={app_key or '-'}")
self.refresh_app_profiles(quiet=True)
# Best-effort select newly saved profile.
if pid:
for i in range(self.lst_app_profiles.count()):
it = self.lst_app_profiles.item(i)
if not it:
continue
p = it.data(QtCore.Qt.UserRole)
if (getattr(p, "id", "") or "").strip() == pid:
self.lst_app_profiles.setCurrentRow(i)
break
self._safe(work, title="Save profile error")
def on_app_profile_load(self) -> None:
prof = self._selected_app_profile()
if prof is None:
return
def work() -> None:
cmd = (getattr(prof, "command", "") or "").strip()
target = (getattr(prof, "target", "") or "").strip().lower()
ttl_sec = int(getattr(prof, "ttl_sec", 0) or 0)
name = (getattr(prof, "name", "") or "").strip()
if cmd:
self.ed_app_cmd.setText(cmd)
if target == "direct":
self.rad_app_direct.setChecked(True)
else:
self.rad_app_vpn.setChecked(True)
if ttl_sec > 0:
# UI uses hours; round up.
hours = max(1, (ttl_sec + 3599) // 3600)
self.spn_app_ttl.setValue(int(hours))
self.ed_app_profile_name.setText(name)
self._set_action_status("Profile loaded into form", ok=True)
self._safe(work, title="Load profile error")
def on_app_profile_delete(self) -> None:
prof = self._selected_app_profile()
if prof is None:
return
pid = (getattr(prof, "id", "") or "").strip()
if not pid:
return
def work() -> None:
if QMessageBox.question(
self,
"Delete profile",
f"Delete saved profile?\n\nid={pid}",
) != QMessageBox.StandardButton.Yes:
return
res = self.ctrl.traffic_app_profile_delete(pid)
if not res.ok:
self._set_action_status(f"Delete profile failed: {res.message}", ok=False)
raise RuntimeError(res.message or "delete failed")
self._set_action_status(f"Profile deleted: {pid}", ok=True)
self._append_app_log(f"[profile] deleted: id={pid}")
self.refresh_app_profiles(quiet=True)
self._safe(work, title="Delete profile error")
def refresh_appmarks_counts(self) -> None:
try:
st = self.ctrl.traffic_appmarks_status()