traffic: add persistent app profiles (api+gui)
This commit is contained in:
@@ -17,6 +17,7 @@ const (
|
||||
dnsModePath = stateDir + "/dns-mode.json"
|
||||
trafficModePath = stateDir + "/traffic-mode.json"
|
||||
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
|
||||
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json"
|
||||
|
||||
traceLogPath = stateDir + "/trace.log"
|
||||
smartdnsLogPath = stateDir + "/smartdns.log"
|
||||
|
||||
@@ -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)
|
||||
// persistent app profiles (saved launch configs)
|
||||
mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles)
|
||||
|
||||
// trace: хвост + JSON + append для GUI
|
||||
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)
|
||||
|
||||
306
selective-vpn-api/app/traffic_app_profiles.go
Normal file
306
selective-vpn-api/app/traffic_app_profiles.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic app profiles (persistent app configs)
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// EN: App profiles are persistent configs that describe *what* to launch and
|
||||
// EN: how to route it. They are separate from runtime marks, because runtime
|
||||
// EN: marks are tied to a конкретный systemd unit/cgroup.
|
||||
// RU: App profiles - это постоянные конфиги, которые описывают *что* запускать
|
||||
// RU: и как маршрутизировать. Они отдельно от runtime marks, потому что marks
|
||||
// RU: привязаны к конкретному systemd unit/cgroup.
|
||||
|
||||
const (
|
||||
trafficAppProfilesDefaultTTLSec = 24 * 60 * 60
|
||||
)
|
||||
|
||||
var trafficAppProfilesMu sync.Mutex
|
||||
|
||||
type trafficAppProfilesState struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Profiles []TrafficAppProfile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
profiles := listTrafficAppProfiles()
|
||||
if profiles == nil {
|
||||
profiles = []TrafficAppProfile{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: profiles, Message: "ok"})
|
||||
case http.MethodPost:
|
||||
var body TrafficAppProfileUpsertRequest
|
||||
if r.Body != nil {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
prof, err := upsertTrafficAppProfile(body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: nil, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
events.push("traffic_profiles_changed", map[string]any{"id": prof.ID, "target": prof.Target})
|
||||
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: []TrafficAppProfile{prof}, Message: "saved"})
|
||||
case http.MethodDelete:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ok, msg := deleteTrafficAppProfile(id)
|
||||
events.push("traffic_profiles_changed", map[string]any{"id": id, "deleted": ok})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "message": msg})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func listTrafficAppProfiles() []TrafficAppProfile {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
out := append([]TrafficAppProfile(nil), st.Profiles...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
// Newest first.
|
||||
return out[i].UpdatedAt > out[j].UpdatedAt
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
|
||||
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||
if target == "" {
|
||||
target = "vpn"
|
||||
}
|
||||
if target != "vpn" && target != "direct" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("target must be vpn|direct")
|
||||
}
|
||||
|
||||
cmd := strings.TrimSpace(req.Command)
|
||||
if cmd == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("missing command")
|
||||
}
|
||||
|
||||
appKey := strings.TrimSpace(req.AppKey)
|
||||
if appKey == "" {
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) > 0 {
|
||||
appKey = strings.TrimSpace(fields[0])
|
||||
}
|
||||
}
|
||||
if appKey == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key")
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if id == "" {
|
||||
// If profile for same app_key+target exists, update it.
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target {
|
||||
id = strings.TrimSpace(p.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
id = deriveTrafficAppProfileID(appKey, target, st.Profiles)
|
||||
}
|
||||
if id == "" {
|
||||
return TrafficAppProfile{}, fmt.Errorf("cannot derive profile id")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = filepath.Base(appKey)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = id
|
||||
}
|
||||
}
|
||||
|
||||
ttl := req.TTLSec
|
||||
if ttl <= 0 {
|
||||
ttl = trafficAppProfilesDefaultTTLSec
|
||||
}
|
||||
|
||||
vpnProfile := strings.TrimSpace(req.VPNProfile)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
prof := TrafficAppProfile{
|
||||
ID: id,
|
||||
Name: name,
|
||||
AppKey: appKey,
|
||||
Command: cmd,
|
||||
Target: target,
|
||||
TTLSec: ttl,
|
||||
VPNProfile: vpnProfile,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Upsert.
|
||||
updated := false
|
||||
for i := range st.Profiles {
|
||||
if strings.TrimSpace(st.Profiles[i].ID) != id {
|
||||
continue
|
||||
}
|
||||
// Keep created_at stable.
|
||||
prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt)
|
||||
if prof.CreatedAt == "" {
|
||||
prof.CreatedAt = now
|
||||
}
|
||||
st.Profiles[i] = prof
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
if !updated {
|
||||
prof.CreatedAt = now
|
||||
st.Profiles = append(st.Profiles, prof)
|
||||
}
|
||||
|
||||
if err := saveTrafficAppProfilesState(st); err != nil {
|
||||
return TrafficAppProfile{}, err
|
||||
}
|
||||
return prof, nil
|
||||
}
|
||||
|
||||
func deleteTrafficAppProfile(id string) (bool, string) {
|
||||
trafficAppProfilesMu.Lock()
|
||||
defer trafficAppProfilesMu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, "empty id"
|
||||
}
|
||||
|
||||
st := loadTrafficAppProfilesState()
|
||||
kept := st.Profiles[:0]
|
||||
found := false
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.ID) == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
st.Profiles = kept
|
||||
|
||||
if !found {
|
||||
return true, "not found"
|
||||
}
|
||||
if err := saveTrafficAppProfilesState(st); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, "deleted"
|
||||
}
|
||||
|
||||
func deriveTrafficAppProfileID(appKey string, target string, existing []TrafficAppProfile) string {
|
||||
base := filepath.Base(strings.TrimSpace(appKey))
|
||||
if base == "" || base == "/" || base == "." {
|
||||
base = "app"
|
||||
}
|
||||
base = sanitizeID(base)
|
||||
if base == "" {
|
||||
base = "app"
|
||||
}
|
||||
|
||||
idBase := base + "-" + strings.ToLower(strings.TrimSpace(target))
|
||||
id := idBase
|
||||
|
||||
used := map[string]struct{}{}
|
||||
for _, p := range existing {
|
||||
used[strings.TrimSpace(p.ID)] = struct{}{}
|
||||
}
|
||||
if _, ok := used[id]; !ok {
|
||||
return id
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
cand := fmt.Sprintf("%s-%d", idBase, i)
|
||||
if _, ok := used[cand]; !ok {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeID(s string) string {
|
||||
in := strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
b.Grow(len(in))
|
||||
lastDash := false
|
||||
for i := 0; i < len(in); i++ {
|
||||
ch := in[i]
|
||||
isAZ := ch >= 'a' && ch <= 'z'
|
||||
is09 := ch >= '0' && ch <= '9'
|
||||
if isAZ || is09 {
|
||||
b.WriteByte(ch)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
return out
|
||||
}
|
||||
|
||||
func loadTrafficAppProfilesState() trafficAppProfilesState {
|
||||
st := trafficAppProfilesState{Version: 1}
|
||||
data, err := os.ReadFile(trafficAppProfilesPath)
|
||||
if err != nil {
|
||||
return st
|
||||
}
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return trafficAppProfilesState{Version: 1}
|
||||
}
|
||||
if st.Version == 0 {
|
||||
st.Version = 1
|
||||
}
|
||||
if st.Profiles == nil {
|
||||
st.Profiles = nil
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func saveTrafficAppProfilesState(st trafficAppProfilesState) error {
|
||||
st.Version = 1
|
||||
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(trafficAppProfilesPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := trafficAppProfilesPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, trafficAppProfilesPath)
|
||||
}
|
||||
@@ -227,6 +227,39 @@ type TrafficAppMarksStatusResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// traffic app profiles (persistent app launcher configs)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// EN: Persistent per-app launcher profile (separate from runtime marks).
|
||||
// RU: Постоянный профиль запуска приложения (отдельно от runtime marks).
|
||||
type TrafficAppProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"`
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficAppProfilesResponse struct {
|
||||
Profiles []TrafficAppProfile `json:"profiles"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type TrafficAppProfileUpsertRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
AppKey string `json:"app_key,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Target string `json:"target,omitempty"` // vpn|direct
|
||||
TTLSec int `json:"ttl_sec,omitempty"`
|
||||
VPNProfile string `json:"vpn_profile,omitempty"`
|
||||
}
|
||||
|
||||
type SystemdState struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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') в удобную модель
|
||||
|
||||
@@ -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