diff --git a/vpngate_manager.py b/vpngate_manager.py index b580873..7059aa8 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -176,7 +176,8 @@ def load_ui_config() -> dict[str, Any]: "force_country": "", "routing_ip_type": "all", "connection_enabled": True, - "fixed_node_id": "" + "fixed_node_id": "", + "favorite_node_ids": [] } updated = False if auth_file.exists(): @@ -184,7 +185,7 @@ def load_ui_config() -> dict[str, Any]: data = json.loads(auth_file.read_text(encoding="utf-8")) for key, val in data.items(): config[key] = val - for key in ["routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id"]: + for key in ["routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id", "favorite_node_ids"]: if key not in data: updated = True except Exception: @@ -303,6 +304,7 @@ def get_state() -> dict[str, Any]: state["routing_ip_type"] = ui_cfg.get("routing_ip_type", "all") state["connection_enabled"] = ui_cfg.get("connection_enabled", True) state["fixed_node_id"] = ui_cfg.get("fixed_node_id", "") + state["favorite_node_ids"] = ui_cfg.get("favorite_node_ids", []) return state @@ -1116,6 +1118,9 @@ def auto_switch_node(attempt: int = 0) -> None: if n.get("country") == target_country or vpn_utils.COUNTRY_TRANSLATIONS.get(n.get("country", ""), n.get("country", "")) == target_country ] + if routing_mode == "favorites": + fav_ids = set(ui_cfg.get("favorite_node_ids", [])) + candidates = [n for n in candidates if n.get("id") in fav_ids] # Apply routing_ip_type filter routing_ip_type = ui_cfg.get("routing_ip_type", "all") @@ -1363,14 +1368,36 @@ def maintain_valid_nodes(force: bool = False) -> str: to_test = [n for n in current_nodes if not n.get("active")] to_test_ids = [n["id"] for n in to_test] - print(f"[维护线程] 正在并发检测列表中所有节点,共 {len(to_test_ids)} 个...", flush=True) + msg = f"开始对列表中所有候选节点进行周期连通性与延迟测试,待检测节点共 {len(to_test_ids)} 个" + print(f"[周期检测] {msg}", flush=True) + log_to_json("INFO", "Main", msg) + set_state(is_connecting=True, last_check_message="正在并发检测所有节点可用性...") test_multiple_nodes(to_test_ids) - is_connecting = False with lock: merged = read_json(NODES_FILE, []) + + # Identify available, unavailable, and active nodes + available_nodes = [n["id"] for n in merged if n.get("probe_status") == "available"] + unavailable_nodes = [n["id"] for n in merged if n.get("probe_status") == "unavailable"] + active_node = next((n["id"] for n in merged if n.get("active")), "无") + + status_report = ( + f"周期节点检测完成。实时同步状态: 获取到候选节点共 {len(merged)} 个。 " + f"其中【可用节点】{len(available_nodes)} 个: {available_nodes[:15]}...; " + f"【不可用节点】{len(unavailable_nodes)} 个; " + f"当前【正在正常运行的活动连接节点】为: {active_node}。" + ) + print(f"[周期检测] {status_report}", flush=True) + log_to_json("INFO", "Main", status_report) + + if active_node != "无" and not active_openvpn_running(): + warn_msg = f"[诊断警告] 活动节点 {active_node} 被标记为活动状态,但 OpenVPN 进程实际并未正常运行!" + print(warn_msg, flush=True) + log_to_json("WARNING", "Main", warn_msg) + if not active_openvpn_running(): ui_cfg = load_ui_config() connection_enabled = ui_cfg.get("connection_enabled", True) @@ -1386,6 +1413,9 @@ def maintain_valid_nodes(force: bool = False) -> str: if n.get("country") == target_country or vpn_utils.COUNTRY_TRANSLATIONS.get(n.get("country", ""), n.get("country", "")) == target_country ] + elif routing_mode == "favorites": + fav_ids = set(ui_cfg.get("favorite_node_ids", [])) + available_candidates = [n for n in available_candidates if n.get("id") in fav_ids] # Apply routing_ip_type filter for auto-connect routing_ip_type = ui_cfg.get("routing_ip_type", "all") @@ -1417,10 +1447,16 @@ def collector_loop() -> None: last_collector_heartbeat = time.time() success = False try: + print("[守护线程] 开始执行节点拉取与可用性检测周期任务...", flush=True) + log_to_json("INFO", "Main", "开始执行节点拉取与可用性检测周期任务...") res = maintain_valid_nodes(force=False) if "没有拉取到新节点" not in res: success = True + log_to_json("INFO", "Main", f"周期同步与检测任务完成,结果: {res}") except Exception as exc: + err_msg = f"周期节点同步任务执行异常: {exc}" + print(f"[错误] {err_msg}", flush=True) + log_to_json("ERROR", "Main", err_msg) set_state(last_check_at=time.time(), last_check_message=f"check error: {exc}") if not active_openvpn_running() and not success: @@ -2660,6 +2696,7 @@ INDEX_HTML = r""" +
+| 物理位置 | 运营主体 / ISP | IP 类型 | -操作 | +操作 | @@ -2780,6 +2821,7 @@ INDEX_HTML = r""" + @@ -3118,6 +3160,9 @@ function getFilteredNodes() { if (selectedStatus === "unavailable" && (n.probe_status !== "unavailable" || n.active)) { return false; } + if (selectedStatus === "favorites" && (!state.favorite_node_ids || !state.favorite_node_ids.includes(n.id))) { + return false; + } const searchStr = [ n.country || "", n.country_short || "", n.ip || "", n.remote_host || "", n.proto || "", translateQuality(n.quality), translateIpType(n.ip_type), n.location || "", n.owner || "", n.as_name || "" @@ -3277,6 +3322,10 @@ function render(){ } } + const isFavoritesFilter = $("status_filter").value === "favorites"; + const banner = $("favorites_banner"); + if (banner) banner.style.display = isFavoritesFilter ? "block" : "none"; + // Pagination calculation const totalPages = Math.ceil(shown.length / pageSize) || 1; if (currentPage > totalPages) currentPage = totalPages; @@ -3306,12 +3355,18 @@ function render(){ const testBtnText = isTesting ? `${testSpinner}检测中` : '检测'; const testBtn = ``; + // Connect button is disabled if probe status is "unavailable" and not already active, or if we are already connecting // Connect button is disabled if probe status is "unavailable" and not already active, or if we are already connecting const isUnavailable = n.probe_status === "unavailable"; const connectBtn = isCurrentlyActive ? `` : ``; + const isFav = state.favorite_node_ids && state.favorite_node_ids.includes(n.id); + const favBtn = isFav + ? `` + : ``; + return `
|---|---|
| ${badgeText} | @@ -3320,6 +3375,7 @@ function render(){
+ ${favBtn}
${connectBtn}
|
@@ -3380,6 +3436,24 @@ async function testNode(btn, id, event){
}
}
+async function toggleFavorite(id, event) {
+ if (event) event.stopPropagation();
+ try {
+ const response = await fetch("./api/toggle_favorite", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id })
+ });
+ const result = await response.json();
+ if (result.ok) {
+ state.favorite_node_ids = result.favorite_node_ids || [];
+ render();
+ }
+ } catch (e) {
+ console.error("切换收藏失败", e);
+ }
+}
+
let pollInterval = null;
function startConnectionPolling() {
@@ -3578,6 +3652,12 @@ function handleRoutingModeChange(mode) {
warningDiv.style.background = "rgba(245, 158, 11, 0.1)";
warningDiv.style.border = "1px solid rgba(245, 158, 11, 0.2)";
warningDiv.innerHTML = `⚠️ 固定地区:限制仅连接选定国家的节点,且后台仅并发测速该国家的节点。如果该国的所有可用节点都失效,会造成代理中断且绝不自动切换到其他国家的节点。`;
+ } else if (mode === "favorites") {
+ countryGroup.style.display = "none";
+ warningDiv.style.color = "var(--warning)";
+ warningDiv.style.background = "rgba(245, 158, 11, 0.1)";
+ warningDiv.style.border = "1px solid rgba(245, 158, 11, 0.2)";
+ warningDiv.innerHTML = `⚠️ 仅用收藏:只连接和切换您收藏的节点。如果所有收藏的节点均失效,系统不会自动切换到未收藏的节点。请确保收藏列表中有足够多且可用的节点。`;
} else if (mode === "fixed_ip") {
countryGroup.style.display = "none";
warningDiv.style.color = "var(--warning)";
@@ -4589,7 +4669,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"ok": False, "error": "安全后缀仅能由英文字母和数字组成"}, HTTPStatus.BAD_REQUEST)
return
- if routing_mode not in ("auto", "fixed_ip", "fixed_region"):
+ if (routing_mode not in ("auto", "fixed_ip", "fixed_region", "favorites")):
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
return
if routing_ip_type not in ("all", "residential", "hosting"):
@@ -4637,7 +4717,7 @@ class Handler(BaseHTTPRequestHandler):
force_country = str(payload.get("force_country") or "").strip()
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
- if routing_mode not in ("auto", "fixed_ip", "fixed_region"):
+ if routing_mode not in ("auto", "fixed_ip", "fixed_region", "favorites"):
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
return
if routing_ip_type not in ("all", "residential", "hosting"):
@@ -4660,6 +4740,33 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
return
+ elif effective_path == "/api/toggle_favorite":
+ try:
+ length = parse_int(self.headers.get("Content-Length"))
+ payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
+ node_id = str(payload.get("id") or "").strip()
+
+ ui_cfg = load_ui_config()
+ fav_ids = ui_cfg.get("favorite_node_ids", [])
+ if not isinstance(fav_ids, list):
+ fav_ids = []
+
+ if node_id in fav_ids:
+ fav_ids.remove(node_id)
+ else:
+ fav_ids.append(node_id)
+
+ ui_cfg["favorite_node_ids"] = fav_ids
+ auth_file = DATA_DIR / "ui_auth.json"
+ with lock:
+ DATA_DIR.mkdir(exist_ok=True, parents=True)
+ auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ self.send_json({"ok": True, "favorite_node_ids": fav_ids})
+ except Exception as exc:
+ self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
+ return
+
if effective_path == "/api/check":
try:
self.send_json({"ok": True, "message": maintain_valid_nodes(force=True)})