traffic: expose runtime appmarks items + show in gui
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user