853 lines
27 KiB
JavaScript
853 lines
27 KiB
JavaScript
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 = `<tr class="empty-row"><td colspan="9">暂无测试记录</td></tr>`;
|
|
return;
|
|
}
|
|
els.recordsBody.innerHTML = state.records.map(record => {
|
|
const summary = record.summary || {};
|
|
const client = record.client || {};
|
|
const startedAt = summary.startedAt || record.createdAt;
|
|
return `
|
|
<tr data-record-id="${escapeHtml(record.id || "")}">
|
|
<td data-label="时间">${formatTime(startedAt)}</td>
|
|
<td data-label="脱敏 IP">${escapeHtml(client.ip || "unknown")}</td>
|
|
<td data-label="地区 / 运营商">${escapeHtml(locationText(client))}</td>
|
|
<td data-label="目标">${escapeHtml(summary.target || "")}</td>
|
|
<td data-label="评分" class="${scoreClass(summary.score)}">${formatNumber(summary.score)}</td>
|
|
<td data-label="下载">${formatNumber(summary.avgDownloadMbps)} Mbps</td>
|
|
<td data-label="上传">${formatNumber(summary.avgUploadMbps)} Mbps</td>
|
|
<td data-label="延迟">${formatNumber(summary.avgLatencyMs)} ms</td>
|
|
<td data-label="样本">${(record.samples || []).length}</td>
|
|
</tr>
|
|
`;
|
|
}).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("'", "'");
|
|
}
|