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)
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user