traffic: add persistent app profiles (api+gui)
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user