Files
llm_speed_test_app/frontend/js/app.js
T

665 lines
27 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 大模型速度测试助手 - 前端主逻辑 (Ollama 版)
const RECOMMENDED_CASES = ["case_01_generation", "case_02_simple_tool", "case_03_file_analysis", "case_04_parallel_tool"];
let charts = {};
let currentTestData = null;
let pollTimer = null;
let backendCases = {}; // 从 /api/cases 动态加载的用例
let appConfig = {}; // 从 /api/config 动态加载的后端配置
// 初始化
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
renderTestCaseList();
loadModels();
updateUI();
});
// ---------- 后端配置 ----------
async function loadConfig() {
try {
const resp = await fetch('/api/config');
appConfig = await resp.json();
} catch (e) {
appConfig = {};
}
const backend = appConfig.backend || 'ollama';
const label = document.getElementById('versionLabel');
if (label) {
label.textContent = `v2.1 | ${backend === 'openai' ? 'OpenAI 兼容' : 'Ollama'}`;
}
const welcome = document.getElementById('welcomeBackend');
if (welcome) {
welcome.textContent = backend === 'openai'
? `(OpenAI 兼容:${appConfig.openai_base_url || ''})`
: '(本地 Ollama 服务)';
}
}
// ---------- 用例列表 ----------
function renderTestCaseList() {
const container = document.getElementById('testCaseList');
const entries = Object.entries(backendCases);
if (entries.length === 0) {
container.innerHTML = '<p style="padding:10px;color:#64748b;font-size:13px;">无法加载用例列表</p>';
return;
}
container.innerHTML = entries.map(([id, info]) => `
<div class="test-case-item" onclick="toggleCase('${id}')">
<input type="checkbox" id="cb_${id}" data-case-id="${id}" onclick="event.stopPropagation()">
<div class="test-case-info">
<div class="test-case-name">${info.name}</div>
<div class="test-case-diff diff-${info.difficulty}">${info.difficulty} · 预计 ${info.estimated_seconds}s</div>
</div>
</div>
`).join('');
}
function toggleCase(caseId) {
const cb = document.getElementById(`cb_${caseId}`);
cb.checked = !cb.checked;
updateUI();
}
function selectAll() {
Object.keys(backendCases).forEach(id => { document.getElementById(`cb_${id}`).checked = true; });
updateUI();
}
function clearAll() {
Object.keys(backendCases).forEach(id => { document.getElementById(`cb_${id}`).checked = false; });
updateUI();
}
function selectRecommended() {
Object.keys(backendCases).forEach(id => {
document.getElementById(`cb_${id}`).checked = RECOMMENDED_CASES.includes(id);
});
updateUI();
}
function getSelectedCases() {
const selected = [];
Object.keys(backendCases).forEach(id => {
if (document.getElementById(`cb_${id}`).checked) selected.push(id);
});
return selected;
}
function updateUI() {
const startBtn = document.getElementById('startBtn');
startBtn.disabled = getSelectedCases().length === 0;
}
// ---------- 模型列表 ----------
async function loadModels() {
const sel = document.getElementById('modelSelect');
try {
const resp = await fetch('/api/models');
const data = await resp.json();
if (data.models && data.models.length > 0) {
sel.innerHTML = data.models.map(m => `<option value="${m}">${m}</option>`).join('');
} else {
sel.innerHTML = `<option value="">${data.error || '未找到已拉取的模型'}</option>`;
}
} catch (e) {
sel.innerHTML = `<option value="">后端未启动(请运行 python run.py)</option>`;
}
}
// ---------- 用例列表(从后端加载) ----------
async function loadCases() {
try {
const resp = await fetch('/api/cases');
backendCases = await resp.json();
} catch (e) {
console.warn('加载用例列表失败', e);
backendCases = {};
}
renderTestCaseList();
}
// ---------- 开始 / 停止 ----------
async function startTests() {
const selectedCases = getSelectedCases();
if (selectedCases.length === 0) {
alert('请至少选择一个测试用例');
return;
}
const repeats = parseInt(document.getElementById('repeatsInput').value) || 3;
const skipHeavy = document.getElementById('runMode').value === 'skip_heavy';
const model = document.getElementById('modelSelect').value;
if (!model) {
const backend = appConfig.backend || 'ollama';
alert(backend === 'openai'
? '未选择模型。请检查 OpenAI 兼容服务地址与 API Key 配置,并刷新页面加载模型列表。'
: '未选择模型。请确认 Ollama 已启动(ollama serve)并已拉取模型(ollama pull <模型名>)');
return;
}
// UI 状态
document.getElementById('welcomeCard').style.display = 'none';
document.getElementById('resultsArea').classList.remove('visible');
document.getElementById('statusPanel').classList.add('active');
document.getElementById('stepList').innerHTML = '';
document.getElementById('currentCaseInfo').textContent = `模型: ${model} | 重复: ${repeats} 次`;
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
updateStatus('running', '正在启动测试...');
updateProgress(0);
try {
const resp = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cases: selectedCases,
repeats: repeats,
model: model,
skip_heavy: skipHeavy
})
});
const data = await resp.json();
if (data.error) {
updateStatus('error', data.error);
showError(data.error);
resetButtons();
return;
}
pollStatus();
} catch (e) {
updateStatus('error', '无法连接后端服务,请先运行 python run.py');
showError('无法连接后端服务,请先运行 python run.py');
resetButtons();
}
}
async function stopTests() {
document.getElementById('stopBtn').disabled = true;
updateStatus('running', '正在停止...');
try {
await fetch('/api/stop', { method: 'POST' });
} catch (e) {
// 忽略网络错误
}
}
// ---------- 轮询进度 ----------
async function pollStatus() {
try {
const resp = await fetch('/api/status');
const st = await resp.json();
renderRunStatus(st);
if (st.status === 'running') {
pollTimer = setTimeout(pollStatus, 800);
} else {
finishRun(st);
}
} catch (e) {
// 后端暂时不可达,继续重试
pollTimer = setTimeout(pollStatus, 1500);
}
}
function renderRunStatus(st) {
const total = st.total_cases || 0;
const idx = st.current_case_index || 0;
updateStatus('running', st.message || '运行中...');
updateProgress(total > 0 ? (idx / total) * 100 : 0);
if (st.current_case_name) {
document.getElementById('currentCaseInfo').textContent = `当前: ${st.current_case_name}`;
}
renderSteps(st);
}
function renderSteps(st) {
const cases = st.cases || [];
const list = document.getElementById('stepList');
const currentIdx = st.current_case_index || 0;
list.innerHTML = cases.map((cid, i) => {
const info = backendCases[cid] || { name: cid };
let cls = '', icon = '○';
if (i < currentIdx) { cls = 'completed'; icon = '✓'; }
else if (i === currentIdx) { cls = 'active'; icon = '⟳'; }
return `<li class="step-item ${cls}"><span class="step-icon">${icon}</span>${info.name}</li>`;
}).join('');
}
// ---------- 测试完成 ----------
async function finishRun(st) {
resetButtons();
if (st.status === 'failed') {
updateStatus('error', st.message || '测试失败');
showError(st.message || '测试失败');
return;
}
if (st.status === 'stopped') {
updateStatus('error', '测试已停止');
} else {
updateStatus('complete', '测试完成!');
updateProgress(100);
}
try {
const resp = await fetch('/api/results/latest.json');
const report = await resp.json();
if (report.error) throw new Error(report.error);
currentTestData = report;
displayResults(report);
} catch (e) {
console.warn('加载结果失败', e);
showError('测试结束,但加载结果失败:' + e.message);
}
}
function resetButtons() {
document.getElementById('stopBtn').disabled = true;
document.getElementById('startBtn').disabled = false;
}
// ---------- 结果显示 ----------
function showError(msg) {
document.getElementById('resultsArea').classList.add('visible');
document.getElementById('resultsArea').insertAdjacentHTML(
'afterbegin',
`<div class="error-banner">${msg}</div>`
);
}
function renderInferenceMetrics(data) {
const results = data.results || [];
const ttft = [], prefill = [], decode = [];
results.forEach(r => {
if (r.raw_data && r.raw_data.results) {
r.raw_data.results.forEach(item => {
if (item.ttft_ms > 0) ttft.push(item.ttft_ms);
if (item.prefill_ms > 0) prefill.push(item.prefill_ms);
if (item.decode_speed_tok_s > 0) decode.push(item.decode_speed_tok_s);
});
}
});
const avg = arr => arr.length ? (arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 'N/A';
const grid = document.getElementById('inferenceMetricsGrid');
grid.innerHTML = `
<div class="metric-card" style="border-left-color:#6366f1;">
<div class="label">TTFT (首Token延迟)</div>
<div class="value" style="color:#6366f1;">${avg(ttft)} ms</div>
<div class="metric-card-value-desc">均值(越低越好)</div>
</div>
<div class="metric-card" style="border-left-color:#8b5cf6;">
<div class="label">Prefill Time</div>
<div class="value" style="color:#8b5cf6;">${avg(prefill)} ms</div>
<div class="metric-card-value-desc">Prompt 处理时间</div>
</div>
<div class="metric-card" style="border-left-color:#22c55e;">
<div class="label">Decode Speed</div>
<div class="value" style="color:#22c55e;">${avg(decode)} tok/s</div>
<div class="metric-card-value-desc">生成速度(越高越好)</div>
</div>
`;
document.getElementById('inferenceCard').style.display = 'block';
}
function renderConcurrencySummary(data) {
const card = document.getElementById('concurrencyCard');
const grid = document.getElementById('concurrencyMetricsGrid');
const concurrency = (data.results || []).find(r => r.case_id === 'case_08_concurrency');
if (!concurrency || !concurrency.raw_data) {
card.style.display = 'none';
return;
}
const raw = concurrency.raw_data;
const keep = raw.tps_keep_rate_pct;
const keepColor = keep >= 60 ? '#22c55e' : (keep > 0 ? '#f59e0b' : '#ef4444');
const errColor = raw.error_rate_pct === 0 ? '#22c55e' : '#ef4444';
const metric = (label, val, color) => `
<div class="metric-card" style="border-left-color:${color};">
<div class="label">${label}</div>
<div class="value" style="color:${color};">${val}</div>
</div>`;
grid.innerHTML =
metric('并发数', raw.concurrency, '#8b5cf6') +
metric('基准 TPS', `${raw.baseline_tps ?? 'N/A'} tok/s`, '#6366f1') +
metric('并发 TPS', `${raw.concurrent_tps ?? 'N/A'} tok/s`, '#6366f1') +
metric('TPS 保持率', `${raw.tps_keep_rate_pct ?? 'N/A'}%`, keepColor) +
metric('P95 延迟', `${raw.e2e_p95_ms ?? 'N/A'} ms`, '#ef4444') +
metric('P99 延迟', `${raw.e2e_p99_ms ?? 'N/A'} ms`, '#ef4444') +
metric('错误率', `${raw.error_rate_pct ?? 'N/A'}%`, errColor) +
metric('成功/失败', `${raw.success_count ?? 0}/${raw.failed_count ?? 0}`, '#64748b');
card.style.display = 'block';
}
function displayResults(data) {
document.getElementById('resultsArea').classList.add('visible');
document.querySelectorAll('#resultsArea .error-banner').forEach(el => el.remove());
renderInferenceMetrics(data);
renderConcurrencySummary(data);
const summary = data.summary || {};
document.getElementById('metricGrid').innerHTML = `
<div class="metric-card"><div class="label">总用例数</div><div class="value">${summary.total_cases || 0}</div></div>
<div class="metric-card success"><div class="label">通过</div><div class="value" style="color:#22c55e">${summary.passed || 0}</div></div>
<div class="metric-card ${summary.failed > 0 ? 'danger' : 'success'}">
<div class="label">失败</div>
<div class="value" style="color:${summary.failed > 0 ? '#ef4444' : '#22c55e'}">${summary.failed || 0}</div>
</div>
<div class="metric-card warning"><div class="label">总耗时</div><div class="value">${(summary.total_elapsed_seconds || 0).toFixed(2)}s</div></div>
`;
document.getElementById('overviewCard').style.display = 'block';
buildCharts(data);
document.getElementById('chartCard').style.display = 'block';
buildDataTable(data);
document.getElementById('tableCard').style.display = 'block';
document.getElementById('actionButtons').style.display = 'flex';
}
function buildCharts(data) {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const results = data.results || [];
const labels = [], means = [], p95s = [];
results.forEach(r => {
if (r.statistics && Object.keys(r.statistics).length > 0) {
labels.push(r.case_name);
means.push(r.statistics.mean_ms || 0);
p95s.push(r.statistics.p95_ms || 0);
}
});
const barCtx = document.getElementById('barChart').getContext('2d');
charts.bar = new Chart(barCtx, {
type: 'bar',
data: {
labels, datasets: [{
label: '平均延迟 (ms)', data: means,
backgroundColor: 'rgba(99, 102, 241, 0.7)', borderColor: 'rgba(99, 102, 241, 1)', borderWidth: 1
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
const p95Ctx = document.getElementById('p95Chart').getContext('2d');
charts.p95 = new Chart(p95Ctx, {
type: 'bar',
data: {
labels, datasets: [{
label: 'P95延迟 (ms)', data: p95s,
backgroundColor: 'rgba(239, 68, 68, 0.7)', borderColor: 'rgba(239, 68, 68, 1)', borderWidth: 1
}]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
});
}
function buildDataTable(data) {
const tbody = document.getElementById('dataTableBody');
const diffColors = { "简单": "#22c55e", "中等": "#f59e0b", "耗时": "#ef4444", "压力": "#8b5cf6" };
tbody.innerHTML = (data.results || []).map(r => {
const stats = r.statistics || {};
const statusClass = r.status === 'passed' ? 'status-passed' : 'status-failed';
return `
<tr>
<td><strong>${r.case_name}</strong></td>
<td><span class="diff-badge" style="background:${diffColors[r.difficulty] || '#6b7280'};padding:4px 10px;border-radius:12px;color:white;font-size:12px;">${r.difficulty}</span></td>
<td class="${statusClass}" style="font-weight:bold;">${r.status}</td>
<td class="mono">${stats.mean_ms != null ? stats.mean_ms : 'N/A'}</td>
<td class="mono">${stats.std_ms != null ? stats.std_ms : 'N/A'}</td>
<td class="mono">${stats.min_ms != null ? stats.min_ms : 'N/A'}</td>
<td class="mono">${stats.max_ms != null ? stats.max_ms : 'N/A'}</td>
<td class="mono">${stats.p95_ms != null ? stats.p95_ms : 'N/A'}</td>
</tr>
`;
}).join('');
}
// ---------- 操作按钮 ----------
function openReport() {
window.open('/report.html', '_blank');
}
function exportCSV() {
if (!currentTestData) return;
let csv = '用例ID,用例名称,难度,状态,平均延迟(ms),标准差(ms),最小延迟(ms),最大延迟(ms),P95延迟(ms)\n';
currentTestData.results.forEach(r => {
const stats = r.statistics || {};
csv += `${r.case_id},${r.case_name},${r.difficulty},${r.status},${stats.mean_ms || ''},${stats.std_ms || ''},${stats.min_ms || ''},${stats.max_ms || ''},${stats.p95_ms || ''}\n`;
});
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `speed_test_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
}
function exportJSON() {
if (!currentTestData) return;
const blob = new Blob([JSON.stringify(currentTestData, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `speed_test_${new Date().toISOString().slice(0, 10)}.json`;
link.click();
}
function runNewTest() {
if (pollTimer) clearTimeout(pollTimer);
document.getElementById('resultsArea').classList.remove('visible');
document.getElementById('statusPanel').classList.remove('active');
document.getElementById('welcomeCard').style.display = 'block';
document.getElementById('stepList').innerHTML = '';
Object.values(charts).forEach(c => c.destroy());
charts = {};
currentTestData = null;
updateUI();
}
let historyData = null;
let compareMode = false;
async function loadHistory() {
try {
const resp = await fetch('/api/results/list');
const data = await resp.json();
if (!data.results || data.results.length === 0) {
alert('暂无历史结果');
return;
}
// 构建选择对话框
const listHtml = data.results
.map((r, i) => `<option value="${i}">${r.filename} — ${r.model} (${r.total_cases}用例, ${r.passed}/${r.total_cases}通过)</option>`)
.join('\n');
const dialog = document.createElement('div');
dialog.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000;';
dialog.innerHTML = `
<div style="background:white;border-radius:12px;padding:24px;max-width:600px;width:90%;max-height:80vh;overflow:auto;">
<h3 style="margin-bottom:16px;">历史测试结果对比</h3>
<p style="color:#64748b;font-size:13px;margin-bottom:12px;">选择两次运行结果进行对比(跳过耗时用例):</p>
<div style="display:flex;gap:12px;margin-bottom:16px;">
<div style="flex:1;">
<label style="font-size:12px;color:#64748b;">第一次运行</label>
<select id="historyA" style="width:100%;padding:8px;border:1px solid #e2e8f0;border-radius:8px;">${listHtml}</select>
</div>
<div style="flex:1;">
<label style="font-size:12px;color:#64748b;">第二次运行</label>
<select id="historyB" style="width:100%;padding:8px;border:1px solid #e2e8f0;border-radius:8px;">${listHtml}</select>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button onclick="this.closest('div[style]').parentElement.remove()" style="padding:8px 16px;border:1px solid #e2e8f0;border-radius:8px;cursor:pointer;">取消</button>
<button id="compareBtn" style="padding:8px 16px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;">对比</button>
</div>
</div>
`;
document.body.appendChild(dialog);
// 默认选择最后两个
if (data.results.length >= 2) {
dialog.querySelector('#historyA').value = data.results.length - 2;
dialog.querySelector('#historyB').value = data.results.length - 1;
}
dialog.querySelector('#compareBtn').onclick = async () => {
const idxA = dialog.querySelector('#historyA').value;
const idxB = dialog.querySelector('#historyB').value;
dialog.remove();
if (idxA === idxB) {
alert('请选择不同的两次运行结果');
return;
}
await showComparison(data.results[parseInt(idxA)], data.results[parseInt(idxB)]);
};
// 点击背景关闭
dialog.onclick = (e) => { if (e.target === dialog) dialog.remove(); };
} catch (e) {
console.warn('加载历史记录失败', e);
alert('无法加载历史记录');
}
}
async function showComparison(runA, runB) {
if (runA.error || runB.error) {
alert('无法读取选定的结果文件');
return;
}
// 加载完整数据
const [dataA, dataB] = await Promise.all([
fetch(`/api/results/${runA.filename}`).then(r => r.json()),
fetch(`/api/results/${runB.filename}`).then(r => r.json()),
]);
if (dataA.error || dataB.error) {
alert('无法加载结果数据');
return;
}
// 显示对比
document.getElementById('welcomeCard').style.display = 'none';
document.getElementById('resultsArea').classList.add('visible');
document.querySelectorAll('#resultsArea .error-banner').forEach(el => el.remove());
const summaryA = dataA.summary || {};
const summaryB = dataB.summary || {};
// 对比卡片
const card = document.createElement('div');
card.className = 'report-card';
card.innerHTML = `
<h2>📊 运行对比:${runA.filename} vs ${runB.filename}</h2>
<table class="data-table" style="margin-top:16px;">
<thead>
<tr><th>指标</th><th>${runA.timestamp ? new Date(runA.timestamp).toLocaleString('zh-CN') : runA.filename}</th><th>${runB.timestamp ? new Date(runB.timestamp).toLocaleString('zh-CN') : runB.filename}</th><th>变化</th></tr>
</thead>
<tbody>
<tr><td>模型</td><td>${dataA.environment?.model || 'N/A'}</td><td>${dataB.environment?.model || 'N/A'}</td><td>${dataA.environment?.model !== dataB.environment?.model ? '⚠️ 不同' : '—'}</td></tr>
<tr><td>总用例数</td><td>${summaryA.total_cases || 0}</td><td>${summaryB.total_cases || 0}</td><td>—</td></tr>
<tr><td>通过</td><td>${summaryA.passed || 0}</td><td>${summaryB.passed || 0}</td><td>${diffArrow(summaryA.passed, summaryB.passed)}</td></tr>
<tr><td>失败</td><td>${summaryA.failed || 0}</td><td>${summaryB.failed || 0}</td><td>${diffArrow(summaryA.failed, summaryB.failed)}</td></tr>
<tr><td>总耗时</td><td>${(summaryA.total_elapsed_seconds || 0).toFixed(1)}s</td><td>${(summaryB.total_elapsed_seconds || 0).toFixed(1)}s</td><td>${diffArrow(summaryA.total_elapsed_seconds, summaryB.total_elapsed_seconds)}</td></tr>
</tbody>
</table>
<div style="margin-top:16px;font-size:12px;color:#64748b;">
<p>注:仅对比都通过的用例。跳过耗时用例(长上下文、推理、多轮对话)。</p>
</div>
`;
// 插入到 resultsArea 开头
const resultsArea = document.getElementById('resultsArea');
resultsArea.insertBefore(card, resultsArea.firstChild);
// 对比详细用例数据
const compareCard = document.createElement('div');
compareCard.className = 'report-card';
compareCard.innerHTML = `
<h2>📋 用例级对比(跳过耗时用例)</h2>
<div style="overflow-x:auto;margin-top:16px;">
<table class="data-table">
<thead>
<tr>
<th>用例名称</th>
<th>运行A 平均延迟</th>
<th>运行B 平均延迟</th>
<th>变化</th>
<th>运行A P95</th>
<th>运行B P95</th>
<th>变化</th>
</tr>
</thead>
<tbody id="compareTableBody"></tbody>
</table>
</div>
`;
resultsArea.insertBefore(compareCard, card.nextSibling);
// 填充对比数据
const tbody = document.getElementById('compareTableBody');
const resultsA = (dataA.results || []).filter(r => r.difficulty !== '耗时' && r.status === 'passed');
const resultsB = (dataB.results || []).filter(r => r.difficulty !== '耗时' && r.status === 'passed');
const mapA = {};
resultsA.forEach(r => mapA[r.case_id] = r);
const mapB = {};
resultsB.forEach(r => mapB[r.case_id] = r);
const allIds = [...new Set([...Object.keys(mapA), ...Object.keys(mapB)])];
tbody.innerHTML = allIds.map(id => {
const a = mapA[id], b = mapB[id];
if (!a || !b) return `<tr><td>${id}</td><td colspan="6" style="color:#64748b;">仅在一次运行中出现</td></tr>`;
const statsA = a.statistics || {};
const statsB = b.statistics || {};
const meanDiff = diffPercent(statsA.mean_ms, statsB.mean_ms);
const p95Diff = diffPercent(statsA.p95_ms, statsB.p95_ms);
return `
<tr>
<td><strong>${a.case_name}</strong></td>
<td class="mono">${statsA.mean_ms || 'N/A'}</td>
<td class="mono">${statsB.mean_ms || 'N/A'}</td>
<td style="color:${meanDiff > 0 ? '#ef4444' : meanDiff < 0 ? '#22c55e' : '#64748b'}">${meanDiff === 0 ? '—' : (meanDiff > 0 ? '↑' : '↓') + Math.abs(meanDiff).toFixed(1) + '%'}</td>
<td class="mono">${statsA.p95_ms || 'N/A'}</td>
<td class="mono">${statsB.p95_ms || 'N/A'}</td>
<td style="color:${p95Diff > 0 ? '#ef4444' : p95Diff < 0 ? '#22c55e' : '#64748b'}">${p95Diff === 0 ? '—' : (p95Diff > 0 ? '↑' : '↓') + Math.abs(p95Diff).toFixed(1) + '%'}</td>
</tr>
`;
}).join('');
// 滚动到对比区域
card.scrollIntoView({ behavior: 'smooth' });
}
function diffArrow(current, previous) {
if (!current || !previous) return '—';
const diff = current - previous;
if (diff === 0) return '—';
return diff > 0 ? `↑+${diff}` : `↓${diff}`;
}
function diffPercent(current, previous) {
if (!current || !previous || previous === 0) return 0;
return ((current - previous) / previous) * 100;
}