Files
2026-06-10 17:52:55 +08:00

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}