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 = `暂无测试记录`; return; } els.recordsBody.innerHTML = state.records.map(record => { const summary = record.summary || {}; const client = record.client || {}; const startedAt = summary.startedAt || record.createdAt; return ` ${formatTime(startedAt)} ${escapeHtml(client.ip || "unknown")} ${escapeHtml(locationText(client))} ${escapeHtml(summary.target || "")} ${formatNumber(summary.score)} ${formatNumber(summary.avgDownloadMbps)} Mbps ${formatNumber(summary.avgUploadMbps)} Mbps ${formatNumber(summary.avgLatencyMs)} ms ${(record.samples || []).length} `; }).join(""); } function updateLiveMetrics() { const successes = state.samples.filter(sample => sample.success); const failures = state.samples.length - successes.length; const latencies = successes.filter(sample => sample.kind === "latency").map(sample => sample.latencyMs || 0); const downloads = successes.filter(sample => sample.kind === "download").map(sample => sample.mbps || 0); const uploads = successes.filter(sample => sample.kind === "upload").map(sample => sample.mbps || 0); const avg = average(latencies); const jitter = average(latencies.slice(1).map((value, index) => Math.abs(value - latencies[index]))); const loss = state.samples.length ? failures / state.samples.length * 100 : 0; const score = state.samples.length ? Math.max(0, 100 - loss * 1.5 - Math.min(avg / 20, 20) - Math.min(jitter / 5, 15)) : 0; els.score.textContent = state.samples.length ? formatNumber(score) : "--"; els.downloadSpeed.textContent = downloads.length ? `${formatNumber(average(downloads))} Mbps` : "--"; els.uploadSpeed.textContent = uploads.length ? `${formatNumber(average(uploads))} Mbps` : "--"; els.latencyMetric.textContent = latencies.length ? `${formatNumber(avg)} ms` : "--"; els.sampleCount.textContent = `${state.samples.length} 个样本`; } function updateSummary(summary) { els.score.textContent = formatNumber(summary.score); els.downloadSpeed.textContent = `${formatNumber(summary.avgDownloadMbps)} Mbps`; els.uploadSpeed.textContent = `${formatNumber(summary.avgUploadMbps)} Mbps`; els.latencyMetric.textContent = `${formatNumber(summary.avgLatencyMs)} ms`; } function drawLiveChart() { const points = state.samples .filter(sample => sample.success && typeof sample.mbps === "number" && sample.mbps > 0) .map(sample => ({ x: new Date(sample.at), y: sample.mbps })); drawLineChart(els.liveChart, points, { empty: "等待上传/下载样本", yLabel: "Mbps", line: "#2563eb", }); } function drawHistoryChart() { const points = state.records .map(record => { const summary = record.summary || {}; return { x: new Date(summary.startedAt || record.createdAt), y: Number(summary.avgDownloadMbps || 0), score: Number(summary.score || 0), }; }) .filter(point => Number.isFinite(point.y) && point.x.toString() !== "Invalid Date") .sort((a, b) => a.x - b.x); drawLineChart(els.historyChart, points, { empty: "暂无历史记录", yLabel: "下载 Mbps", line: "#0f9f6e", showPeriods: true, }); } function drawLineChart(canvas, points, options) { const ctx = canvas.getContext("2d"); const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; const scale = window.devicePixelRatio || 1; canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale); ctx.setTransform(scale, 0, 0, scale, 0, 0); ctx.clearRect(0, 0, width, height); const pad = { left: 48, right: 18, top: 18, bottom: 36 }; const plotWidth = width - pad.left - pad.right; const plotHeight = height - pad.top - pad.bottom; ctx.fillStyle = "#fbfdff"; ctx.fillRect(0, 0, width, height); if (options.showPeriods) { drawPeriodBands(ctx, pad, plotWidth, plotHeight); } ctx.strokeStyle = "#d8e0ea"; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = pad.top + plotHeight * i / 4; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke(); } if (!points.length) { ctx.fillStyle = "#627084"; ctx.font = "13px system-ui"; ctx.fillText(options.empty, pad.left, pad.top + 24); return; } const ys = points.map(point => point.y); const minY = Math.min(0, ...ys); const maxY = Math.max(...ys, 10); const spanY = maxY - minY || 1; const xMin = points[0].x.getTime(); const xMax = points[points.length - 1].x.getTime(); const spanX = xMax - xMin || 1; const px = point => pad.left + (point.x.getTime() - xMin) / spanX * plotWidth; const py = point => pad.top + plotHeight - (point.y - minY) / spanY * plotHeight; ctx.strokeStyle = options.line; ctx.lineWidth = 2; ctx.beginPath(); points.forEach((point, index) => { const x = px(point); const y = py(point); if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.fillStyle = options.line; points.forEach(point => { ctx.beginPath(); ctx.arc(px(point), py(point), 3, 0, Math.PI * 2); ctx.fill(); }); ctx.fillStyle = "#627084"; ctx.font = "12px system-ui"; ctx.fillText(`${formatNumber(maxY)} ${options.yLabel}`, 8, pad.top + 4); ctx.fillText(`${formatNumber(minY)} ${options.yLabel}`, 8, pad.top + plotHeight); ctx.fillText(formatTime(points[0].x), pad.left, height - 12); ctx.fillText(formatTime(points[points.length - 1].x), Math.max(pad.left, width - pad.right - 120), height - 12); } function drawPeriodBands(ctx, pad, plotWidth, plotHeight) { const labels = [ { text: "早上", color: "rgba(37, 99, 235, 0.06)" }, { text: "中午", color: "rgba(183, 107, 0, 0.08)" }, { text: "晚上", color: "rgba(15, 159, 110, 0.07)" }, ]; labels.forEach((label, index) => { const x = pad.left + plotWidth * index / labels.length; const w = plotWidth / labels.length; ctx.fillStyle = label.color; ctx.fillRect(x, pad.top, w, plotHeight); ctx.fillStyle = "#627084"; ctx.font = "12px system-ui"; ctx.fillText(label.text, x + 8, pad.top + 18); }); } function appendLog(sample) { const value = sample.kind === "latency" ? `${formatNumber(sample.latencyMs)}ms` : `瞬时 ${formatNumber(sample.mbps)}Mbps`; const line = `${formatTime(sample.at)} ${sample.kind} ${sample.success ? "OK" : "FAIL"} ${value} ${sample.error || ""}`; const div = document.createElement("div"); div.textContent = line; els.eventLog.prepend(div); while (els.eventLog.children.length > 30) { els.eventLog.removeChild(els.eventLog.lastChild); } } function setServerState(type, label) { els.serverState.className = `state ${type}`; els.serverState.textContent = label; } function setNotice(message, isError = false) { els.notice.textContent = message; els.notice.className = isError ? "notice error" : "notice"; } function queueNotice(position) { if (position > 0) { return `正在排队,当前排第 ${position} 位,等待其他用户完成...`; } return "正在排队,等待其他用户完成..."; } function setTestingControls(running) { els.startButton.disabled = running; els.stopButton.hidden = !running; els.stopButton.disabled = !running; } function throwIfCanceled() { if (state.cancelRequested || (state.abortController && state.abortController.signal.aborted)) { throw cancellationError(); } } function cancellationError() { const error = new Error("测试已停止"); error.name = "AbortError"; return error; } function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal && signal.aborted) { reject(cancellationError()); return; } const timer = window.setTimeout(resolve, ms); const abort = () => { window.clearTimeout(timer); reject(cancellationError()); }; if (signal) { signal.addEventListener("abort", abort, { once: true }); } }); } function closeSource() { if (state.source) { state.source.close(); state.source = null; } } function value(id) { return document.getElementById(id).value.trim(); } function numberValue(id) { const field = document.getElementById(id); if (field) { return Number(field.value); } const selected = document.querySelector(`input[name="${id}"]:checked`); return selected ? Number(selected.value) : 0; } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function fallbackCopy(value) { const textarea = document.createElement("textarea"); textarea.value = value; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.top = "-1000px"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } function average(values) { if (!values.length) return 0; return values.reduce((sum, value) => sum + value, 0) / values.length; } function transferTimeoutMillis(timeoutMillis) { return Math.max(timeoutMillis || 3000, 30000); } async function fetchWithTimeout(url, options, timeoutMillis, signal) { const controller = new AbortController(); const timeoutID = window.setTimeout(() => controller.abort(), timeoutMillis); const abort = () => controller.abort(); if (signal) { if (signal.aborted) { controller.abort(); } else { signal.addEventListener("abort", abort, { once: true }); } } try { return await fetch(url, { ...options, signal: controller.signal }); } finally { window.clearTimeout(timeoutID); if (signal) { signal.removeEventListener("abort", abort); } } } function mbps(bytes, elapsedMs) { if (!bytes || !elapsedMs) return 0; return round(bytes * 8 / elapsedMs / 1000); } function round(value) { return Math.round(value * 100) / 100; } function formatNumber(value) { const number = Number(value); if (!Number.isFinite(number)) return "--"; return number.toFixed(number >= 100 ? 0 : 1); } function formatTime(value) { if (!value) return "--"; const date = value instanceof Date ? value : new Date(value); if (date.toString() === "Invalid Date") return "--"; return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }).format(date); } function locationText(client) { const place = [client.country, client.region, client.city].filter(Boolean).join(" ") || "未知地区"; const network = [client.isp || "未知运营商", client.asn].filter(Boolean).join(" / "); return [place, network].filter(Boolean).join(" | ") || "未知"; } function scoreClass(score) { const value = Number(score || 0); if (value >= 85) return "score-good"; if (value >= 60) return "score-mid"; return "score-bad"; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); }