diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index b9835e4..0a634ec 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -148,6 +148,8 @@ func Run() { mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) // per-app runtime marks (systemd scope / cgroup -> fwmark) mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks) + // list runtime marks items (for UI) + mux.HandleFunc("/api/v1/traffic/appmarks/items", handleTrafficAppMarksItems) // persistent app profiles (saved launch configs) mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles) diff --git a/selective-vpn-api/app/traffic_appmarks.go b/selective-vpn-api/app/traffic_appmarks.go index d00e5a6..dbcbf01 100644 --- a/selective-vpn-api/app/traffic_appmarks.go +++ b/selective-vpn-api/app/traffic_appmarks.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -238,6 +239,57 @@ func handleTrafficAppMarks(w http.ResponseWriter, r *http.Request) { } } +func handleTrafficAppMarksItems(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _ = pruneExpiredAppMarks() + + appMarksMu.Lock() + st := loadAppMarksState() + appMarksMu.Unlock() + + now := time.Now().UTC() + items := make([]TrafficAppMarkItemView, 0, len(st.Items)) + for _, it := range st.Items { + rem := 0 + exp, err := time.Parse(time.RFC3339, strings.TrimSpace(it.ExpiresAt)) + if err == nil { + rem = int(exp.Sub(now).Seconds()) + if rem < 0 { + rem = 0 + } + } + items = append(items, TrafficAppMarkItemView{ + ID: it.ID, + Target: it.Target, + Cgroup: it.Cgroup, + CgroupRel: it.CgroupRel, + Level: it.Level, + Unit: it.Unit, + Command: it.Command, + AppKey: it.AppKey, + AddedAt: it.AddedAt, + ExpiresAt: it.ExpiresAt, + RemainingSec: rem, + }) + } + + // Sort: target -> app_key -> remaining desc. + sort.Slice(items, func(i, j int) bool { + if items[i].Target != items[j].Target { + return items[i].Target < items[j].Target + } + if items[i].AppKey != items[j].AppKey { + return items[i].AppKey < items[j].AppKey + } + return items[i].RemainingSec > items[j].RemainingSec + }) + + writeJSON(w, http.StatusOK, TrafficAppMarksItemsResponse{Items: items, Message: "ok"}) +} + func appMarksGetStatus() (vpnCount int, directCount int) { _ = pruneExpiredAppMarks() diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index 10a01a3..d941100 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -227,6 +227,27 @@ type TrafficAppMarksStatusResponse struct { Message string `json:"message,omitempty"` } +// EN: Detailed list item for runtime per-app marks (for UI). +// RU: Детальный элемент списка runtime per-app меток (для UI). +type TrafficAppMarkItemView struct { + ID uint64 `json:"id"` + Target string `json:"target"` // vpn|direct + Cgroup string `json:"cgroup,omitempty"` + CgroupRel string `json:"cgroup_rel,omitempty"` + Level int `json:"level,omitempty"` + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` + AppKey string `json:"app_key,omitempty"` + AddedAt string `json:"added_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + RemainingSec int `json:"remaining_sec,omitempty"` +} + +type TrafficAppMarksItemsResponse struct { + Items []TrafficAppMarkItemView `json:"items"` + Message string `json:"message,omitempty"` +} + // --------------------------------------------------------------------- // traffic app profiles (persistent app launcher configs) // --------------------------------------------------------------------- diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index f8cb09a..d17b565 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -138,6 +138,21 @@ class TrafficAppMarksResult: timeout_sec: int = 0 +@dataclass(frozen=True) +class TrafficAppMarkItem: + id: int + target: str # vpn|direct + cgroup: str + cgroup_rel: str + level: int + unit: str + command: str + app_key: str + added_at: str + expires_at: str + remaining_sec: int + + @dataclass(frozen=True) class TrafficAppProfile: id: str @@ -839,6 +854,43 @@ class ApiClient: message=str(data.get("message") or ""), ) + def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/appmarks/items")) or {}, + ) + raw = data.get("items") or [] + if not isinstance(raw, list): + raw = [] + + out: List[TrafficAppMarkItem] = [] + for it in raw: + if not isinstance(it, dict): + continue + try: + mid = int(it.get("id", 0) or 0) + except Exception: + mid = 0 + tgt = str(it.get("target") or "").strip().lower() + if mid <= 0 or tgt not in ("vpn", "direct"): + continue + out.append( + TrafficAppMarkItem( + id=mid, + target=tgt, + cgroup=str(it.get("cgroup") or "").strip(), + cgroup_rel=str(it.get("cgroup_rel") or "").strip(), + level=int(it.get("level", 0) or 0), + unit=str(it.get("unit") or "").strip(), + command=str(it.get("command") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + added_at=str(it.get("added_at") or "").strip(), + expires_at=str(it.get("expires_at") or "").strip(), + remaining_sec=int(it.get("remaining_sec", 0) or 0), + ) + ) + return out + def traffic_appmarks_apply( self, *, diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index 081c1a8..7a3e13a 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -34,6 +34,7 @@ from api_client import ( TrafficCandidates, TrafficAppMarksResult, TrafficAppMarksStatus, + TrafficAppMarkItem, TrafficAppProfile, TrafficAppProfileSaveResult, TrafficInterfaces, @@ -715,6 +716,9 @@ class DashboardController: def traffic_appmarks_status(self) -> TrafficAppMarksStatus: return self.client.traffic_appmarks_status() + def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]: + return self.client.traffic_appmarks_items() + def traffic_appmarks_apply( self, *, diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 83194dc..131f398 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -393,6 +393,44 @@ RU: Восстанавливает маршруты/nft из последнег tab_apps_layout.addWidget(run_group) + marks_group = QGroupBox("Active runtime marks (TTL)") + marks_layout = QVBoxLayout(marks_group) + + marks_row = QHBoxLayout() + self.btn_marks_refresh = QPushButton("Refresh marks") + self.btn_marks_refresh.setToolTip( + "EN: Reload active runtime marks from backend (prunes expired).\n" + "RU: Обновить активные runtime-метки из backend (просроченные удаляются)." + ) + self.btn_marks_refresh.clicked.connect(self.refresh_appmarks_items) + marks_row.addWidget(self.btn_marks_refresh) + + self.btn_marks_unmark = QPushButton("Unmark selected") + self.btn_marks_unmark.setToolTip( + "EN: Remove routing marks for selected items (does not necessarily stop the app).\n" + "RU: Удалить метки маршрутизации для выбранных элементов (не обязательно останавливает приложение)." + ) + self.btn_marks_unmark.clicked.connect(self.on_appmarks_unmark_selected) + marks_row.addWidget(self.btn_marks_unmark) + + marks_row.addStretch(1) + marks_layout.addLayout(marks_row) + + self.lst_marks = QListWidget() + self.lst_marks.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.lst_marks.setToolTip( + "EN: Active runtime marks. Stored by backend with TTL.\n" + "RU: Активные runtime-метки. Хранятся backend с TTL." + ) + self.lst_marks.setFixedHeight(140) + marks_layout.addWidget(self.lst_marks) + + self.lbl_marks = QLabel("Active marks: —") + self.lbl_marks.setStyleSheet("color: gray;") + marks_layout.addWidget(self.lbl_marks) + + tab_apps_layout.addWidget(marks_group) + scopes_group = QGroupBox("Active svpn units (systemd --user)") scopes_layout = QVBoxLayout(scopes_group) @@ -546,6 +584,7 @@ RU: Применяет policy-rules и проверяет health. При оши 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_appmarks_items) QtCore.QTimer.singleShot(0, self.refresh_running_scopes) # EN: Auto-refresh runtime marks/units while dialog is open. @@ -561,6 +600,7 @@ RU: Применяет policy-rules и проверяет health. При оши try: # Only refresh units list when Apps(runtime) tab is visible. if int(self.tabs.currentIndex() or 0) == 1: + self.refresh_appmarks_items(quiet=True) self.refresh_running_scopes(quiet=True) except Exception: pass @@ -1116,6 +1156,96 @@ RU: Применяет policy-rules и проверяет health. При оши except Exception as e: self.lbl_app_counts.setText(f"Marks: error: {e}") + def refresh_appmarks_items(self, quiet: bool = False) -> None: + def work() -> None: + items = list(self.ctrl.traffic_appmarks_items() or []) + self.lst_marks.clear() + + vpn = 0 + direct = 0 + for it in items: + tgt = (getattr(it, "target", "") or "").strip().lower() + if tgt == "vpn": + vpn += 1 + elif tgt == "direct": + direct += 1 + + mid = int(getattr(it, "id", 0) or 0) + app_key = (getattr(it, "app_key", "") or "").strip() + unit = (getattr(it, "unit", "") or "").strip() + cmd = (getattr(it, "command", "") or "").strip() + rem = int(getattr(it, "remaining_sec", 0) or 0) + + rem_h = rem // 3600 + rem_m = (rem % 3600) // 60 + rem_s = rem % 60 + rem_txt = f"{rem_h:02d}:{rem_m:02d}:{rem_s:02d}" + + label = f"{tgt} {app_key or unit or mid} (ttl {rem_txt})" + q = QListWidgetItem(label) + q.setToolTip( + ( + f"id: {mid}\n" + f"target: {tgt}\n" + f"app_key: {app_key}\n" + f"unit: {unit}\n" + f"remaining: {rem}s\n\n" + f"{cmd}" + ).strip() + ) + q.setData(QtCore.Qt.UserRole, it) + self.lst_marks.addItem(q) + + self.lbl_marks.setText(f"Active marks: {len(items)} (VPN={vpn}, Direct={direct})") + self.btn_marks_unmark.setEnabled(self.lst_marks.count() > 0) + + if quiet: + try: + work() + except Exception as e: + self.lbl_marks.setText(f"Active marks: error: {e}") + return + + self._safe(work, title="Refresh marks error") + + def on_appmarks_unmark_selected(self) -> None: + sel = list(self.lst_marks.selectedItems() or []) + if not sel: + return + + # Convert selection to (target,id). + pairs: list[tuple[str, int]] = [] + for it in sel: + obj = it.data(QtCore.Qt.UserRole) + tgt = (getattr(obj, "target", "") or "").strip().lower() + mid = int(getattr(obj, "id", 0) or 0) + if tgt in ("vpn", "direct") and mid > 0: + pairs.append((tgt, mid)) + + if not pairs: + return + + def work() -> None: + if QMessageBox.question( + self, + "Unmark selected", + "Remove routing marks for selected items?\n\n" + + "\n".join([f"{t}:{i}" for (t, i) in pairs[:20]]) + + ("\n..." if len(pairs) > 20 else ""), + ) != QMessageBox.StandardButton.Yes: + return + + for (tgt, mid) in pairs: + res = self.ctrl.traffic_appmarks_apply(op="del", target=tgt, cgroup=str(mid)) + if not res.ok: + raise RuntimeError(res.message or f"unmark failed: {tgt}:{mid}") + + self._set_action_status(f"Unmarked: {len(pairs)} item(s)", ok=True) + self.refresh_appmarks_items(quiet=True) + self.refresh_appmarks_counts() + + self._safe(work, title="Unmark selected error") + def _refresh_last_scope_ui(self) -> None: unit = (self._last_app_unit or "").strip() target = (self._last_app_target or "").strip().lower()