traffic: expose runtime appmarks items + show in gui

This commit is contained in:
beckline
2026-02-15 21:09:46 +03:00
parent b040b9e7d7
commit 1a96e849bb
6 changed files with 261 additions and 0 deletions

View File

@@ -148,6 +148,8 @@ func Run() {
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
// per-app runtime marks (systemd scope / cgroup -> fwmark) // per-app runtime marks (systemd scope / cgroup -> fwmark)
mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks) 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) // persistent app profiles (saved launch configs)
mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles) mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles)

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "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) { func appMarksGetStatus() (vpnCount int, directCount int) {
_ = pruneExpiredAppMarks() _ = pruneExpiredAppMarks()

View File

@@ -227,6 +227,27 @@ type TrafficAppMarksStatusResponse struct {
Message string `json:"message,omitempty"` 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) // traffic app profiles (persistent app launcher configs)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------

View File

@@ -138,6 +138,21 @@ class TrafficAppMarksResult:
timeout_sec: int = 0 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) @dataclass(frozen=True)
class TrafficAppProfile: class TrafficAppProfile:
id: str id: str
@@ -839,6 +854,43 @@ class ApiClient:
message=str(data.get("message") or ""), 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( def traffic_appmarks_apply(
self, self,
*, *,

View File

@@ -34,6 +34,7 @@ from api_client import (
TrafficCandidates, TrafficCandidates,
TrafficAppMarksResult, TrafficAppMarksResult,
TrafficAppMarksStatus, TrafficAppMarksStatus,
TrafficAppMarkItem,
TrafficAppProfile, TrafficAppProfile,
TrafficAppProfileSaveResult, TrafficAppProfileSaveResult,
TrafficInterfaces, TrafficInterfaces,
@@ -715,6 +716,9 @@ class DashboardController:
def traffic_appmarks_status(self) -> TrafficAppMarksStatus: def traffic_appmarks_status(self) -> TrafficAppMarksStatus:
return self.client.traffic_appmarks_status() return self.client.traffic_appmarks_status()
def traffic_appmarks_items(self) -> List[TrafficAppMarkItem]:
return self.client.traffic_appmarks_items()
def traffic_appmarks_apply( def traffic_appmarks_apply(
self, self,
*, *,

View File

@@ -393,6 +393,44 @@ RU: Восстанавливает маршруты/nft из последнег
tab_apps_layout.addWidget(run_group) 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_group = QGroupBox("Active svpn units (systemd --user)")
scopes_layout = QVBoxLayout(scopes_group) 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_state)
QtCore.QTimer.singleShot(0, self.refresh_app_profiles) QtCore.QTimer.singleShot(0, self.refresh_app_profiles)
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts) QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
QtCore.QTimer.singleShot(0, self.refresh_appmarks_items)
QtCore.QTimer.singleShot(0, self.refresh_running_scopes) QtCore.QTimer.singleShot(0, self.refresh_running_scopes)
# EN: Auto-refresh runtime marks/units while dialog is open. # EN: Auto-refresh runtime marks/units while dialog is open.
@@ -561,6 +600,7 @@ RU: Применяет policy-rules и проверяет health. При оши
try: try:
# Only refresh units list when Apps(runtime) tab is visible. # Only refresh units list when Apps(runtime) tab is visible.
if int(self.tabs.currentIndex() or 0) == 1: if int(self.tabs.currentIndex() or 0) == 1:
self.refresh_appmarks_items(quiet=True)
self.refresh_running_scopes(quiet=True) self.refresh_running_scopes(quiet=True)
except Exception: except Exception:
pass pass
@@ -1116,6 +1156,96 @@ RU: Применяет policy-rules и проверяет health. При оши
except Exception as e: except Exception as e:
self.lbl_app_counts.setText(f"Marks: error: {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: def _refresh_last_scope_ui(self) -> None:
unit = (self._last_app_unit or "").strip() unit = (self._last_app_unit or "").strip()
target = (self._last_app_target or "").strip().lower() target = (self._last_app_target or "").strip().lower()