fix: prevent web dashboard browser freezes

This commit is contained in:
Aimili
2026-08-27 01:21:32 +08:00
parent cbca9a57af
commit bbc2457498
2 changed files with 164 additions and 79 deletions
+27
View File
@@ -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
View File
@@ -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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[c])); const esc=s=>String(s||"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[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})