const state = { source: null, samples: [], records: [], clientCommand: "", currentSession: null, abortController: null, cancelRequested: false, startedAt: null, partialSaved: false, }; const els = { form: document.getElementById("testForm"), startButton: document.getElementById("startButton"), stopButton: document.getElementById("stopButton"), copyCommandButton: document.getElementById("copyCommandButton"), refreshButton: document.getElementById("refreshButton"), notice: document.getElementById("notice"), serverState: document.getElementById("serverState"), score: document.getElementById("score"), downloadSpeed: document.getElementById("downloadSpeed"), uploadSpeed: document.getElementById("uploadSpeed"), latencyMetric: document.getElementById("latencyMetric"), connectionState: document.getElementById("connectionState"), bandwidthLimit: document.getElementById("bandwidthLimit"), sampleCount: document.getElementById("sampleCount"), cliCommand: document.getElementById("cliCommand"), liveChart: document.getElementById("liveChart"), historyChart: document.getElementById("historyChart"), eventLog: document.getElementById("eventLog"), recordsBody: document.getElementById("recordsBody"), }; const DOWNLOAD_BYTES = 4 * 1024 * 1024; const UPLOAD_BYTES = 2 * 1024 * 1024; els.form.addEventListener("submit", startTest); els.stopButton.addEventListener("click", stopCurrentTest); els.copyCommandButton.addEventListener("click", copyClientCommand); document.querySelectorAll('input[name="durationSeconds"]').forEach(input => { input.addEventListener("change", updateClientCommand); }); els.refreshButton.addEventListener("click", loadRecords); window.addEventListener("resize", () => { drawLiveChart(); drawHistoryChart(); }); window.addEventListener("beforeunload", () => { state.cancelRequested = true; if (!completeCurrentSession({ beacon: true, partial: true })) { cancelCurrentSession({ beacon: true }); } }); initializeDefaultTarget(); updateClientCommand(); connectHeartbeat(); loadConfig(); loadRecords(); drawLiveChart(); drawHistoryChart(); function initializeDefaultTarget() { const currentHost = window.location.host; if (currentHost && !value("target")) { document.getElementById("target").value = currentHost; } if ((window.location.protocol === "http:" || window.location.protocol === "https:") && !value("httpUrl")) { document.getElementById("httpUrl").value = `${window.location.origin}/api/config`; } } function updateClientCommand() { const serverURL = window.location.origin || `http://${window.location.host}`; const duration = numberValue("durationSeconds") || 30; const serverArg = shellQuote(serverURL); const downloadPrefixArg = shellQuote(`${serverURL}/downloads/netstable-linux-`); state.clientCommand = `arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo "unsupported arch: $arch"; exit 1;; esac; tmp=$(mktemp -d); curl -fsSL ${downloadPrefixArg}"$arch".tar.gz | tar -xz -C "$tmp" && chmod +x "$tmp/netstable" && "$tmp/netstable" client -server ${serverArg} -duration ${duration}`; els.cliCommand.textContent = `arch=$(uname -m) case "$arch" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) echo "unsupported arch: $arch"; exit 1 ;; esac tmp=$(mktemp -d) curl -fsSL ${downloadPrefixArg}"$arch".tar.gz | tar -xz -C "$tmp" chmod +x "$tmp/netstable" "$tmp/netstable" client -server ${serverArg} -duration ${duration}`; } async function copyClientCommand() { updateClientCommand(); const command = state.clientCommand; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(command); } else { fallbackCopy(command); } setNotice("命令已复制。"); } catch (error) { fallbackCopy(command); setNotice("命令已复制。"); } } async function stopCurrentTest() { state.cancelRequested = true; if (state.abortController) { state.abortController.abort(); } const saved = await completeCurrentSession({ partial: true }); if (!saved) { await cancelCurrentSession(); } closeSource(); setServerState("idle", "空闲"); setNotice(saved ? "测试已停止,已保存已完成样本。" : "测试已停止,队列已释放。"); setTestingControls(false); } async function cancelCurrentSession(options = {}) { const session = state.currentSession; if (!session || !session.cancelUrl) { return false; } state.currentSession = null; const url = `${session.cancelUrl}?cache=${Date.now()}`; if (options.beacon && navigator.sendBeacon) { return navigator.sendBeacon(url, new Blob([], { type: "text/plain" })); } try { const response = await fetch(url, { method: "POST", cache: "no-store", keepalive: true, }); return response.ok || response.status === 404; } catch (error) { return false; } } function completeCurrentSession(options = {}) { const session = state.currentSession; if (!session || !session.completeUrl || !state.samples.length) { return false; } const startedAt = state.startedAt || new Date(); const body = JSON.stringify({ startedAt: startedAt.toISOString(), finishedAt: new Date().toISOString(), samples: state.samples, }); if (options.beacon && navigator.sendBeacon) { const sent = navigator.sendBeacon(session.completeUrl, new Blob([body], { type: "application/json" })); if (sent) { state.currentSession = null; state.partialSaved = Boolean(options.partial); } return sent; } return completeCurrentSessionWithFetch(session, body, options); } async function completeCurrentSessionWithFetch(session, body, options = {}) { try { const fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body, cache: "no-store", }; if (options.keepalive) { fetchOptions.keepalive = true; } const response = await fetch(session.completeUrl, fetchOptions); const payload = await response.json(); if (!response.ok) { throw new Error(payload.error || "保存测试记录失败"); } state.currentSession = null; state.partialSaved = Boolean(options.partial); updateSummary(payload.summary); await loadRecords(); return true; } catch (error) { return false; } } async function startTest(event) { event.preventDefault(); closeSource(); state.samples = []; state.currentSession = null; state.cancelRequested = false; state.abortController = new AbortController(); state.startedAt = null; state.partialSaved = false; els.eventLog.textContent = ""; setNotice(""); setServerState("running", "运行中"); setTestingControls(true); updateLiveMetrics(); drawLiveChart(); const payload = { target: value("target"), httpUrl: value("httpUrl"), durationSeconds: numberValue("durationSeconds"), timeoutMillis: numberValue("timeoutMillis"), }; try { const response = await fetch("/api/tests", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: state.abortController.signal, }); const body = await response.json(); if (response.status === 409) { setServerState("busy", "忙碌"); setNotice(body.error || "有其他用户正在测速,请稍等。", true); setTestingControls(false); return; } if (!response.ok) { throw new Error(body.error || "创建测试失败"); } state.currentSession = body; await waitForBrowserQueue(body); throwIfCanceled(); await runBrowserSpeedTest(body, payload); } catch (error) { if (state.currentSession && !state.cancelRequested) { await cancelCurrentSession(); } if (state.cancelRequested || error.name === "AbortError") { setServerState("idle", "空闲"); setNotice(state.partialSaved ? "测试已停止,已保存已完成样本。" : "测试已停止,队列已释放。"); } else { setServerState("error", "错误"); setNotice(error.message, true); } } finally { state.abortController = null; state.currentSession = null; state.startedAt = null; state.partialSaved = false; setTestingControls(false); } } async function waitForBrowserQueue(session) { let position = Number(session.queuePosition || 0); if (session.status !== "queued" && position <= 0) { return; } if (!session.queueUrl) { throw new Error("服务端没有返回队列状态地址"); } setServerState("busy", "排队中"); while (true) { throwIfCanceled(); setNotice(queueNotice(position)); await sleep(5000, state.abortController && state.abortController.signal); throwIfCanceled(); const response = await fetch(`${session.queueUrl}?cache=${Date.now()}`, { cache: "no-store", signal: state.abortController && state.abortController.signal, }); const body = await response.json(); if (!response.ok) { throw new Error(body.error || "读取队列状态失败"); } position = Number(body.queuePosition || 0); if (body.status === "ready" || position <= 0) { setServerState("running", "运行中"); return; } } } async function runBrowserSpeedTest(session, request) { state.startedAt = new Date(); const durationMs = Math.max(1000, (request.durationSeconds || 30) * 1000); throwIfCanceled(); setNotice("下载测速中..."); await measureLatency(request.timeoutMillis); await runTransferPhase(durationMs, () => measureDownload(session.downloadUrl, request.timeoutMillis)); throwIfCanceled(); setNotice("上传测速中..."); await measureLatency(request.timeoutMillis); await runTransferPhase(durationMs, () => measureUpload(session.uploadUrl, request.timeoutMillis)); await measureLatency(request.timeoutMillis); throwIfCanceled(); if (!await completeCurrentSession()) { throw new Error("保存测试记录失败"); } setServerState("idle", "空闲"); setNotice("浏览器上传/下载测速完成,记录已保存。"); } async function runTransferPhase(durationMs, measure) { const deadline = performance.now() + durationMs; let samples = 0; while (performance.now() < deadline || samples === 0) { throwIfCanceled(); await measure(); throwIfCanceled(); samples++; } } async function measureLatency(timeoutMillis) { const started = performance.now(); try { const response = await fetchWithTimeout( `/api/config?cache=${Date.now()}`, { cache: "no-store" }, timeoutMillis || 3000, state.abortController && state.abortController.signal, ); await response.arrayBuffer(); recordSample({ at: new Date().toISOString(), kind: "latency", success: response.ok, latencyMs: round(performance.now() - started), error: response.ok ? "" : response.statusText, }); } catch (error) { recordSample({ at: new Date().toISOString(), kind: "latency", success: false, latencyMs: round(performance.now() - started), error: error.message, }); } } async function measureDownload(downloadUrl, timeoutMillis) { const url = `${downloadUrl}&bytes=${DOWNLOAD_BYTES}&cache=${Date.now()}`; const started = performance.now(); try { const response = await fetchWithTimeout( url, { cache: "no-store" }, transferTimeoutMillis(timeoutMillis), state.abortController && state.abortController.signal, ); const data = await response.arrayBuffer(); const elapsed = performance.now() - started; recordSample({ at: new Date().toISOString(), kind: "download", success: response.ok, bytes: data.byteLength, mbps: mbps(data.byteLength, elapsed), latencyMs: round(elapsed), error: response.ok ? "" : response.statusText, }); } catch (error) { recordSample({ at: new Date().toISOString(), kind: "download", success: false, bytes: 0, mbps: 0, latencyMs: round(performance.now() - started), error: error.message, }); } } async function measureUpload(uploadUrl, timeoutMillis) { const payload = new Uint8Array(UPLOAD_BYTES); const started = performance.now(); try { const response = await fetchWithTimeout(`${uploadUrl}&cache=${Date.now()}`, { method: "POST", body: payload, }, transferTimeoutMillis(timeoutMillis), state.abortController && state.abortController.signal); await response.arrayBuffer(); const elapsed = performance.now() - started; recordSample({ at: new Date().toISOString(), kind: "upload", success: response.ok, bytes: payload.byteLength, mbps: mbps(payload.byteLength, elapsed), latencyMs: round(elapsed), error: response.ok ? "" : response.statusText, }); } catch (error) { recordSample({ at: new Date().toISOString(), kind: "upload", success: false, bytes: 0, mbps: 0, latencyMs: round(performance.now() - started), error: error.message, }); } } function recordSample(sample) { state.samples.push(sample); appendLog(sample); updateLiveMetrics(); drawLiveChart(); } function subscribe(eventsUrl) { state.source = new EventSource(eventsUrl); state.source.addEventListener("sample", event => { const sample = JSON.parse(event.data); state.samples.push(sample); appendLog(sample); updateLiveMetrics(); drawLiveChart(); }); state.source.addEventListener("summary", event => { const summary = JSON.parse(event.data); updateSummary(summary); setServerState("idle", "空闲"); setNotice("测试完成,记录已保存。"); els.startButton.disabled = false; closeSource(); loadRecords(); }); state.source.addEventListener("error", event => { if (event.data) { const payload = JSON.parse(event.data); setNotice(payload.error || "测试流发生错误", true); } setServerState("error", "错误"); els.startButton.disabled = false; closeSource(); }); } async function loadRecords() { try { const response = await fetch("/api/records?limit=200"); const body = await response.json(); if (!response.ok) { throw new Error(body.error || "读取记录失败"); } state.records = body.records || []; renderRecords(); drawHistoryChart(); } catch (error) { setNotice(error.message, true); } } function connectHeartbeat() { if (!window.EventSource) { els.connectionState.textContent = "不支持"; return; } const source = new EventSource("/api/health/events"); source.addEventListener("open", () => { els.connectionState.textContent = "已连接"; }); source.addEventListener("heartbeat", event => { const payload = JSON.parse(event.data); els.connectionState.textContent = "已连接"; if (payload.bandwidthLimitLabel) { els.bandwidthLimit.textContent = payload.bandwidthLimitLabel; } }); source.addEventListener("error", () => { els.connectionState.textContent = "断开"; }); } async function loadConfig() { try { const response = await fetch("/api/config"); const body = await response.json(); if (!response.ok) { throw new Error(body.error || "读取配置失败"); } els.bandwidthLimit.textContent = body.bandwidthLimitLabel || "不限速"; } catch (error) { els.bandwidthLimit.textContent = "--"; } } function renderRecords() { if (!state.records.length) { els.recordsBody.innerHTML = `