mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-08 17:26:46 +08:00
fix: prevent web dashboard browser freezes
This commit is contained in:
@@ -394,6 +394,33 @@ class ManagerLogicTests(unittest.TestCase):
|
|||||||
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
|
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
|
||||||
self.assertIn('${testBtn}', manager.INDEX_HTML)
|
self.assertIn('${testBtn}', manager.INDEX_HTML)
|
||||||
|
|
||||||
|
def test_web_dashboard_has_browser_freeze_safeguards(self) -> None:
|
||||||
|
self.assertNotIn("backdrop-filter", manager.LOGIN_HTML)
|
||||||
|
self.assertNotIn("backdrop-filter", manager.INDEX_HTML)
|
||||||
|
self.assertNotIn("background-attachment: fixed", manager.INDEX_HTML)
|
||||||
|
self.assertIn("@media (prefers-reduced-motion: reduce)", manager.LOGIN_HTML)
|
||||||
|
self.assertIn("@media (prefers-reduced-motion: reduce)", manager.INDEX_HTML)
|
||||||
|
self.assertIn("const pageSize = 50;", manager.INDEX_HTML)
|
||||||
|
self.assertIn("const MAX_RENDERED_LOG_LINES = 300;", manager.INDEX_HTML)
|
||||||
|
self.assertIn("nodesRequestPromise", manager.INDEX_HTML)
|
||||||
|
self.assertIn("backgroundPollInFlight", manager.INDEX_HTML)
|
||||||
|
self.assertIn('typeof document.hidden !== "boolean" || !document.hidden', manager.INDEX_HTML)
|
||||||
|
self.assertEqual(500, manager.WEB_LOG_MAX_ENTRIES)
|
||||||
|
|
||||||
|
def test_web_log_reader_only_returns_recent_valid_entries(self) -> None:
|
||||||
|
log_file = manager.DATA_DIR / "logs" / "current.json"
|
||||||
|
log_file.parent.mkdir(parents=True)
|
||||||
|
with log_file.open("w", encoding="utf-8") as f:
|
||||||
|
for index in range(520):
|
||||||
|
f.write(json.dumps({"index": index}) + "\n")
|
||||||
|
f.write("not-json\n")
|
||||||
|
|
||||||
|
entries = manager.read_recent_log_entries(log_file)
|
||||||
|
|
||||||
|
self.assertEqual(500, len(entries))
|
||||||
|
self.assertEqual(20, entries[0]["index"])
|
||||||
|
self.assertEqual(519, entries[-1]["index"])
|
||||||
|
|
||||||
def test_web_update_controls_only_expose_stable_main_channel(self) -> None:
|
def test_web_update_controls_only_expose_stable_main_channel(self) -> None:
|
||||||
self.assertEqual("2.1.0", manager.APP_VERSION)
|
self.assertEqual("2.1.0", manager.APP_VERSION)
|
||||||
self.assertEqual("V2.1 正式版", manager.APP_VERSION_LABEL)
|
self.assertEqual("V2.1 正式版", manager.APP_VERSION_LABEL)
|
||||||
|
|||||||
+137
-79
@@ -16,6 +16,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections import deque
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -150,6 +151,7 @@ BLACKLIST_FILE = DATA_DIR / "blacklist.json"
|
|||||||
API_CACHE_FILE = DATA_DIR / "api_snapshot.csv"
|
API_CACHE_FILE = DATA_DIR / "api_snapshot.csv"
|
||||||
API_CACHE_META_FILE = DATA_DIR / "api_snapshot.meta.json"
|
API_CACHE_META_FILE = DATA_DIR / "api_snapshot.meta.json"
|
||||||
BUNDLED_SNAPSHOT_FILE = ROOT_DIR / "mirror" / "vpngate.csv"
|
BUNDLED_SNAPSHOT_FILE = ROOT_DIR / "mirror" / "vpngate.csv"
|
||||||
|
WEB_LOG_MAX_ENTRIES = 500
|
||||||
|
|
||||||
lock = threading.RLock()
|
lock = threading.RLock()
|
||||||
maintenance_lock = threading.Lock()
|
maintenance_lock = threading.Lock()
|
||||||
@@ -385,6 +387,23 @@ def cleanup_old_logs(logs_dir: Path) -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[清理错误] 清理旧日志失败: {e}", flush=True)
|
print(f"[清理错误] 清理旧日志失败: {e}", flush=True)
|
||||||
|
|
||||||
|
def read_recent_log_entries(log_file: Path, limit: int = WEB_LOG_MAX_ENTRIES) -> list[dict[str, Any]]:
|
||||||
|
if limit <= 0 or not log_file.exists():
|
||||||
|
return []
|
||||||
|
entries: deque[dict[str, Any]] = deque(maxlen=limit)
|
||||||
|
with open(log_file, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
entries.append(entry)
|
||||||
|
return list(entries)
|
||||||
|
|
||||||
def log_to_json(level: str, module: str, message: str) -> None:
|
def log_to_json(level: str, module: str, message: str) -> None:
|
||||||
try:
|
try:
|
||||||
logs_dir = DATA_DIR / "logs"
|
logs_dir = DATA_DIR / "logs"
|
||||||
@@ -2484,7 +2503,7 @@ LOGIN_HTML = r"""<!DOCTYPE html>
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #090d16;
|
--bg-dark: #090d16;
|
||||||
--bg-surface: rgba(15, 23, 42, 0.45);
|
--bg-surface: rgba(15, 23, 42, 0.96);
|
||||||
--border-color: rgba(255, 255, 255, 0.08);
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #f8fafc;
|
--text-primary: #f8fafc;
|
||||||
--text-secondary: #94a3b8;
|
--text-secondary: #94a3b8;
|
||||||
@@ -2519,8 +2538,6 @@ LOGIN_HTML = r"""<!DOCTYPE html>
|
|||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 40px 32px;
|
padding: 40px 32px;
|
||||||
@@ -2656,6 +2673,14 @@ LOGIN_HTML = r"""<!DOCTYPE html>
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -2742,7 +2767,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #0b0f19;
|
--bg-dark: #0b0f19;
|
||||||
--bg-surface: rgba(22, 30, 49, 0.6);
|
--bg-surface: rgba(22, 30, 49, 0.94);
|
||||||
--bg-surface-hover: rgba(30, 41, 67, 0.85);
|
--bg-surface-hover: rgba(30, 41, 67, 0.85);
|
||||||
--border-color: rgba(255, 255, 255, 0.08);
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
--border-color-hover: rgba(99, 102, 241, 0.35);
|
--border-color-hover: rgba(99, 102, 241, 0.35);
|
||||||
@@ -2769,7 +2794,6 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.15) 0px, transparent 50%),
|
||||||
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.08) 0px, transparent 50%),
|
radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.08) 0px, transparent 50%),
|
||||||
radial-gradient(at 50% 100%, rgba(79, 70, 229, 0.05) 0px, transparent 50%);
|
radial-gradient(at 50% 100%, rgba(79, 70, 229, 0.05) 0px, transparent 50%);
|
||||||
background-attachment: fixed;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -2777,9 +2801,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
padding: 16px 32px;
|
padding: 16px 32px;
|
||||||
background: rgba(11, 15, 25, 0.7);
|
background: rgba(11, 15, 25, 0.97);
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2909,8 +2931,6 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
.active-card {
|
.active-card {
|
||||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(79, 70, 229, 0.04) 100%);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12) 0%, rgba(79, 70, 229, 0.04) 100%);
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -2975,8 +2995,6 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -3208,8 +3226,6 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -3429,8 +3445,6 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3702,14 +3716,12 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
background: rgba(22, 30, 49, 0.95);
|
background: rgba(22, 30, 49, 0.99);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
.dropdown-content a,
|
.dropdown-content a,
|
||||||
.dropdown-content button {
|
.dropdown-content button {
|
||||||
@@ -3797,14 +3809,12 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background-color: rgba(9, 13, 22, 0.7);
|
background-color: rgba(9, 13, 22, 0.92);
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
-webkit-backdrop-filter: blur(8px);
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: rgba(22, 30, 49, 0.9);
|
background: rgba(22, 30, 49, 0.99);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@@ -3907,6 +3917,15 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -4027,7 +4046,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
收藏菜单
|
收藏菜单
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<div id="favorites_panel" style="display: none; background: rgba(22, 30, 49, 0.85); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid var(--border-color); border-radius: 16px; padding: 20px; margin-bottom: 20px; animation: modalFadeIn 0.25s ease-out;">
|
<div id="favorites_panel" style="display: none; background: rgba(22, 30, 49, 0.97); border: 1px solid var(--border-color); border-radius: 16px; padding: 20px; margin-bottom: 20px; animation: modalFadeIn 0.25s ease-out;">
|
||||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
|
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
|
||||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
@@ -4370,7 +4389,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<script>
|
<script>
|
||||||
let nodes=[], state={}, testingNodeIds = new Set();
|
let nodes=[], state={}, testingNodeIds = new Set();
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
const pageSize = 99999;
|
const pageSize = 50;
|
||||||
let currentPageNodes = [];
|
let currentPageNodes = [];
|
||||||
let selectedDiscoveryCountries = new Set();
|
let selectedDiscoveryCountries = new Set();
|
||||||
let discoveryCountriesInitialized = false;
|
let discoveryCountriesInitialized = false;
|
||||||
@@ -4379,6 +4398,16 @@ let countryFilterSignature = "";
|
|||||||
|
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
const esc=s=>String(s||"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
const esc=s=>String(s||"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||||
|
const renderedHtmlCache = new WeakMap();
|
||||||
|
function setHtmlIfChanged(element, html) {
|
||||||
|
if (!element || renderedHtmlCache.get(element) === html) return false;
|
||||||
|
element.innerHTML = html;
|
||||||
|
renderedHtmlCache.set(element, html);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function isPageVisible() {
|
||||||
|
return typeof document.hidden !== "boolean" || !document.hidden;
|
||||||
|
}
|
||||||
const base=p=>(p||"").split(/[\\/]/).pop();
|
const base=p=>(p||"").split(/[\\/]/).pop();
|
||||||
function time(ts){return ts?new Date(ts*1000).toLocaleString():"从未"}
|
function time(ts){return ts?new Date(ts*1000).toLocaleString():"从未"}
|
||||||
function speed(v){return v?`${(v*8/1000/1000).toFixed(1)} Mbps`:"-"}
|
function speed(v){return v?`${(v*8/1000/1000).toFixed(1)} Mbps`:"-"}
|
||||||
@@ -4658,11 +4687,12 @@ function render(){
|
|||||||
|
|
||||||
// Render separated Active Node Card
|
// Render separated Active Node Card
|
||||||
const activeCardContainer = $("active_node_card");
|
const activeCardContainer = $("active_node_card");
|
||||||
|
let activeCardHtml = "";
|
||||||
if (state.is_connecting && !activeNode) {
|
if (state.is_connecting && !activeNode) {
|
||||||
const busyTitle = state.maintenance_running ? "正在更新节点" : "正在连接";
|
const busyTitle = state.maintenance_running ? "正在更新节点" : "正在连接";
|
||||||
const busyLatency = state.maintenance_running ? "节点检测中" : (state.active_node_latency || "正在连接...");
|
const busyLatency = state.maintenance_running ? "节点检测中" : (state.active_node_latency || "正在连接...");
|
||||||
const busyMessage = state.last_check_message || (state.maintenance_running ? "正在后台拉取并检测节点,已完成的结果会实时显示在下方列表。" : "正在与 VPN 节点建立加密隧道,请稍候...");
|
const busyMessage = state.last_check_message || (state.maintenance_running ? "正在后台拉取并检测节点,已完成的结果会实时显示在下方列表。" : "正在与 VPN 节点建立加密隧道,请稍候...");
|
||||||
activeCardContainer.innerHTML = `
|
activeCardHtml = `
|
||||||
<div class="active-card" style="background: var(--bg-surface); border-color: var(--warning); box-shadow: 0 0 15px rgba(245, 158, 11, 0.15);">
|
<div class="active-card" style="background: var(--bg-surface); border-color: var(--warning); box-shadow: 0 0 15px rgba(245, 158, 11, 0.15);">
|
||||||
<div class="active-card-info">
|
<div class="active-card-info">
|
||||||
<div class="stat-icon-wrapper" style="background: rgba(245, 158, 11, 0.15); border-color: rgba(245, 158, 11, 0.3); width: 48px; height: 48px; border-radius: 12px;">
|
<div class="stat-icon-wrapper" style="background: rgba(245, 158, 11, 0.15); border-color: rgba(245, 158, 11, 0.3); width: 48px; height: 48px; border-radius: 12px;">
|
||||||
@@ -4684,7 +4714,7 @@ function render(){
|
|||||||
const latencyText = nodeLatencyHtml(activeNode);
|
const latencyText = nodeLatencyHtml(activeNode);
|
||||||
const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-";
|
const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-";
|
||||||
const activeFlag = countryFlag(activeNode.country_short);
|
const activeFlag = countryFlag(activeNode.country_short);
|
||||||
activeCardContainer.innerHTML = `
|
activeCardHtml = `
|
||||||
<div class="active-card">
|
<div class="active-card">
|
||||||
<div class="active-card-info">
|
<div class="active-card-info">
|
||||||
<div class="stat-icon-wrapper" style="background: rgba(16, 185, 129, 0.15); border-color: rgba(16, 185, 129, 0.3); width: 48px; height: 48px; border-radius: 12px;">
|
<div class="stat-icon-wrapper" style="background: rgba(16, 185, 129, 0.15); border-color: rgba(16, 185, 129, 0.3); width: 48px; height: 48px; border-radius: 12px;">
|
||||||
@@ -4713,7 +4743,7 @@ function render(){
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
activeCardContainer.innerHTML = `
|
activeCardHtml = `
|
||||||
<div class="active-card" style="background: var(--bg-surface); border-color: var(--border-color); box-shadow: none;">
|
<div class="active-card" style="background: var(--bg-surface); border-color: var(--border-color); box-shadow: none;">
|
||||||
<div class="active-card-info">
|
<div class="active-card-info">
|
||||||
<div class="stat-icon-wrapper" style="background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.2); width: 48px; height: 48px; border-radius: 12px;">
|
<div class="stat-icon-wrapper" style="background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.2); width: 48px; height: 48px; border-radius: 12px;">
|
||||||
@@ -4731,6 +4761,7 @@ function render(){
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
setHtmlIfChanged(activeCardContainer, activeCardHtml);
|
||||||
|
|
||||||
const shown = getFilteredNodes();
|
const shown = getFilteredNodes();
|
||||||
|
|
||||||
@@ -4804,10 +4835,11 @@ function render(){
|
|||||||
currentPageNodes = shown.slice(startIndex, endIndex);
|
currentPageNodes = shown.slice(startIndex, endIndex);
|
||||||
|
|
||||||
// Render table rows
|
// Render table rows
|
||||||
|
let rowsHtml = "";
|
||||||
if (currentPageNodes.length === 0) {
|
if (currentPageNodes.length === 0) {
|
||||||
$("rows").innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 40px 0;">未找到符合过滤条件的备选节点。</td></tr>`;
|
rowsHtml = `<tr><td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 40px 0;">未找到符合过滤条件的备选节点。</td></tr>`;
|
||||||
} else {
|
} else {
|
||||||
$("rows").innerHTML=currentPageNodes.map(n=>{
|
rowsHtml = currentPageNodes.map(n=>{
|
||||||
if (!n) return '';
|
if (!n) return '';
|
||||||
const isCurrentlyActive = activeNode && n.id === activeNode.id;
|
const isCurrentlyActive = activeNode && n.id === activeNode.id;
|
||||||
const isPending = Boolean(state.is_connecting && state.pending_node_id === n.id);
|
const isPending = Boolean(state.is_connecting && state.pending_node_id === n.id);
|
||||||
@@ -4854,6 +4886,7 @@ function render(){
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
setHtmlIfChanged($("rows"), rowsHtml);
|
||||||
|
|
||||||
// Render pagination controls
|
// Render pagination controls
|
||||||
$("page_start").textContent = shown.length > 0 ? startIndex + 1 : 0;
|
$("page_start").textContent = shown.length > 0 ? startIndex + 1 : 0;
|
||||||
@@ -4928,6 +4961,32 @@ async function toggleFavorite(id, event) {
|
|||||||
|
|
||||||
let pollInterval = null;
|
let pollInterval = null;
|
||||||
let refreshPollInterval = null;
|
let refreshPollInterval = null;
|
||||||
|
let refreshPollInFlight = false;
|
||||||
|
let connectionPollInFlight = false;
|
||||||
|
let nodesRequestPromise = null;
|
||||||
|
|
||||||
|
async function fetchNodesSnapshot() {
|
||||||
|
if (nodesRequestPromise) return nodesRequestPromise;
|
||||||
|
const request = (async () => {
|
||||||
|
const response = await fetch("./api/nodes", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(`节点状态请求失败 (${response.status})`);
|
||||||
|
return response.json();
|
||||||
|
})();
|
||||||
|
nodesRequestPromise = request;
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (nodesRequestPromise === request) nodesRequestPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNodesSnapshot(data) {
|
||||||
|
nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
||||||
|
state = data.state || {};
|
||||||
|
stableSortNodes();
|
||||||
|
updateCountryFilter();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
function refreshButtonBusy(message = "正在后台更新...") {
|
function refreshButtonBusy(message = "正在后台更新...") {
|
||||||
const btn = $("refresh");
|
const btn = $("refresh");
|
||||||
@@ -4947,14 +5006,11 @@ function startRefreshPolling() {
|
|||||||
if (refreshPollInterval) clearInterval(refreshPollInterval);
|
if (refreshPollInterval) clearInterval(refreshPollInterval);
|
||||||
refreshButtonBusy("正在检测节点...");
|
refreshButtonBusy("正在检测节点...");
|
||||||
refreshPollInterval = setInterval(async () => {
|
refreshPollInterval = setInterval(async () => {
|
||||||
|
if (refreshPollInFlight || !isPageVisible()) return;
|
||||||
|
refreshPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("./api/nodes");
|
const data = await fetchNodesSnapshot();
|
||||||
const data = await resp.json();
|
applyNodesSnapshot(data);
|
||||||
nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
||||||
state = data.state || {};
|
|
||||||
stableSortNodes();
|
|
||||||
updateCountryFilter();
|
|
||||||
render();
|
|
||||||
|
|
||||||
if (!state.maintenance_running) {
|
if (!state.maintenance_running) {
|
||||||
clearInterval(refreshPollInterval);
|
clearInterval(refreshPollInterval);
|
||||||
@@ -4965,6 +5021,8 @@ function startRefreshPolling() {
|
|||||||
clearInterval(refreshPollInterval);
|
clearInterval(refreshPollInterval);
|
||||||
refreshPollInterval = null;
|
refreshPollInterval = null;
|
||||||
refreshButtonIdle();
|
refreshButtonIdle();
|
||||||
|
} finally {
|
||||||
|
refreshPollInFlight = false;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@@ -4972,14 +5030,11 @@ function startRefreshPolling() {
|
|||||||
function startConnectionPolling() {
|
function startConnectionPolling() {
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
pollInterval = setInterval(async () => {
|
pollInterval = setInterval(async () => {
|
||||||
|
if (connectionPollInFlight || !isPageVisible()) return;
|
||||||
|
connectionPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("./api/nodes");
|
const data = await fetchNodesSnapshot();
|
||||||
const data = await resp.json();
|
applyNodesSnapshot(data);
|
||||||
nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
|
||||||
state = data.state || {};
|
|
||||||
stableSortNodes();
|
|
||||||
updateCountryFilter();
|
|
||||||
render();
|
|
||||||
|
|
||||||
if (!state.is_connecting && !state.maintenance_running) {
|
if (!state.is_connecting && !state.maintenance_running) {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
@@ -4993,6 +5048,8 @@ function startConnectionPolling() {
|
|||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
pollInterval = null;
|
pollInterval = null;
|
||||||
load();
|
load();
|
||||||
|
} finally {
|
||||||
|
connectionPollInFlight = false;
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
@@ -5061,14 +5118,8 @@ async function disconnectNode(){
|
|||||||
|
|
||||||
|
|
||||||
async function load(){
|
async function load(){
|
||||||
const r=await fetch("./api/nodes");
|
const d = await fetchNodesSnapshot();
|
||||||
const d=await r.json();
|
applyNodesSnapshot(d);
|
||||||
nodes=Array.isArray(d.nodes) ? d.nodes : [];
|
|
||||||
state=d.state||{};
|
|
||||||
|
|
||||||
stableSortNodes();
|
|
||||||
updateCountryFilter();
|
|
||||||
render();
|
|
||||||
|
|
||||||
if (state.maintenance_running) {
|
if (state.maintenance_running) {
|
||||||
startRefreshPolling();
|
startRefreshPolling();
|
||||||
@@ -5654,23 +5705,25 @@ async function logoutAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时自动初始化数据
|
// 页面加载时自动初始化数据
|
||||||
load();
|
load().catch(error => console.error("初始化节点数据失败", error));
|
||||||
|
|
||||||
// 每 10 秒在前台空闲时自动更新节点与状态,无需手动刷新页面
|
// 每 10 秒在前台空闲时自动更新节点与状态,无需手动刷新页面
|
||||||
|
let backgroundPollInFlight = false;
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
if (typeof state !== "undefined" && !state.is_connecting && (!testingNodeIds || !testingNodeIds.size) && document.visibilityState === "visible") {
|
if (backgroundPollInFlight || !isPageVisible()) return;
|
||||||
|
if (typeof state !== "undefined" && !state.is_connecting && !state.maintenance_running && (!testingNodeIds || !testingNodeIds.size)) {
|
||||||
|
backgroundPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch("./api/nodes");
|
const data = await fetchNodesSnapshot();
|
||||||
const d = await r.json();
|
applyNodesSnapshot(data);
|
||||||
nodes = d.nodes || [];
|
} catch(e) {
|
||||||
state = d.state || {};
|
} finally {
|
||||||
stableSortNodes();
|
backgroundPollInFlight = false;
|
||||||
updateCountryFilter();
|
}
|
||||||
render();
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
let gatewayPollInterval = null;
|
let gatewayPollInterval = null;
|
||||||
|
let gatewayRequestInFlight = false;
|
||||||
|
|
||||||
function openGatewayModal() {
|
function openGatewayModal() {
|
||||||
$("admin_dropdown").style.display = "none";
|
$("admin_dropdown").style.display = "none";
|
||||||
@@ -5689,14 +5742,19 @@ function closeGatewayModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadGatewayStatus() {
|
async function loadGatewayStatus() {
|
||||||
|
if (gatewayRequestInFlight || !isPageVisible() || $("gateway_modal").style.display !== "flex") return;
|
||||||
|
gatewayRequestInFlight = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("./api/gateway_status");
|
const res = await fetch("./api/gateway_status", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`网关状态请求失败 (${res.status})`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok && data.services) {
|
if (data.ok && data.services) {
|
||||||
renderGatewayServices(data.services);
|
renderGatewayServices(data.services);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("加载网关状态失败", e);
|
console.error("加载网关状态失败", e);
|
||||||
|
} finally {
|
||||||
|
gatewayRequestInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5725,11 +5783,13 @@ function renderGatewayServices(services) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
setHtmlIfChanged(container, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
let logsPollInterval = null;
|
let logsPollInterval = null;
|
||||||
let rawLogsCache = [];
|
let rawLogsCache = [];
|
||||||
|
let logsRequestInFlight = false;
|
||||||
|
const MAX_RENDERED_LOG_LINES = 300;
|
||||||
|
|
||||||
function openLogsModal() {
|
function openLogsModal() {
|
||||||
$("admin_dropdown").style.display = "none";
|
$("admin_dropdown").style.display = "none";
|
||||||
@@ -5748,15 +5808,20 @@ function closeLogsModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
|
if (logsRequestInFlight || !isPageVisible() || $("logs_modal").style.display !== "flex") return;
|
||||||
|
logsRequestInFlight = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("./api/logs");
|
const res = await fetch("./api/logs", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`日志请求失败 (${res.status})`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.logs) {
|
if (Array.isArray(data.logs)) {
|
||||||
rawLogsCache = data.logs;
|
rawLogsCache = data.logs;
|
||||||
filterAndRenderLogs();
|
filterAndRenderLogs();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("加载日志失败", e);
|
console.error("加载日志失败", e);
|
||||||
|
} finally {
|
||||||
|
logsRequestInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5775,11 +5840,11 @@ function filterAndRenderLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
term.innerHTML = `<div style="color: var(--text-secondary); text-align: center; margin-top: 150px;">暂无该类型日志。</div>`;
|
setHtmlIfChanged(term, `<div style="color: var(--text-secondary); text-align: center; margin-top: 150px;">暂无该类型日志。</div>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const linesHtml = filtered.map(l => {
|
const linesHtml = filtered.slice(-MAX_RENDERED_LOG_LINES).map(l => {
|
||||||
let color = "#a5b4fc";
|
let color = "#a5b4fc";
|
||||||
if (l.module === "Proxy") color = "#38bdf8";
|
if (l.module === "Proxy") color = "#38bdf8";
|
||||||
if (l.module === "VPN") color = "#34d399";
|
if (l.module === "VPN") color = "#34d399";
|
||||||
@@ -5791,9 +5856,9 @@ function filterAndRenderLogs() {
|
|||||||
|
|
||||||
const isAtBottom = term.scrollHeight - term.clientHeight <= term.scrollTop + 50;
|
const isAtBottom = term.scrollHeight - term.clientHeight <= term.scrollTop + 50;
|
||||||
|
|
||||||
term.innerHTML = linesHtml;
|
const changed = setHtmlIfChanged(term, linesHtml);
|
||||||
|
|
||||||
if (isAtBottom) {
|
if (changed && isAtBottom) {
|
||||||
term.scrollTop = term.scrollHeight;
|
term.scrollTop = term.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6337,18 +6402,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
logs_dir = DATA_DIR / "logs"
|
logs_dir = DATA_DIR / "logs"
|
||||||
date_str = time.strftime("%Y-%m-%d", time.localtime())
|
date_str = time.strftime("%Y-%m-%d", time.localtime())
|
||||||
log_file = logs_dir / f"{date_str}.json"
|
log_file = logs_dir / f"{date_str}.json"
|
||||||
entries = []
|
entries: list[dict[str, Any]] = []
|
||||||
if log_file.exists():
|
if log_file.exists():
|
||||||
try:
|
try:
|
||||||
with lock:
|
with lock:
|
||||||
with open(log_file, "r", encoding="utf-8") as f:
|
entries = read_recent_log_entries(log_file)
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if line:
|
|
||||||
try:
|
|
||||||
entries.append(json.loads(line))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[API Logs] Error reading log file: {e}", flush=True)
|
print(f"[API Logs] Error reading log file: {e}", flush=True)
|
||||||
self.send_json({"logs": entries})
|
self.send_json({"logs": entries})
|
||||||
|
|||||||
Reference in New Issue
Block a user