Files
2026-06-10 02:33:35 +00:00

342 lines
14 KiB
JavaScript

// ── 全局状态 ─────────────────────────────────────────────────
const API = '/api/stats';
let charts = {};
let refreshTimer = null;
// ── 工具函数 ─────────────────────────────────────────────────
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return json.data || json;
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (d === 0 && h === 0) parts.push(`${s}s`);
return parts.join(' ') || '0s';
}
function formatNum(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return '' + n;
}
// ── 图表创建 ─────────────────────────────────────────────────
function createOrUpdateChart(id, config) {
if (charts[id]) {
charts[id].data = config.data;
charts[id].options = config.options || {};
charts[id].update();
} else {
const ctx = document.getElementById(id).getContext('2d');
charts[id] = new Chart(ctx, config);
}
}
// ── 更新概览卡片 ────────────────────────────────────────────
async function refreshOverview() {
try {
const data = await fetchJSON(API + '/overview');
document.getElementById('totalRequests').textContent = formatNum(data.total_requests);
document.getElementById('qps').textContent = data.requests_per_sec;
document.getElementById('activeConn').textContent = data.active_conn;
document.getElementById('avgLatency').textContent = data.avg_latency_ms;
document.getElementById('errorRate').textContent = data.error_rate;
document.getElementById('uniqueIPs').textContent = formatNum(data.unique_ips);
// 运行时间
const uptime = data.uptime_seconds ? (data.uptime_seconds / 1e9) : 0;
document.getElementById('uptime').textContent = '运行 ' + formatDuration(uptime);
// 状态指示
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
dot.className = 'status-dot online';
text.textContent = '运行中';
} catch (e) {
document.getElementById('statusDot').className = 'status-dot offline';
document.getElementById('statusText').textContent = '离线';
console.error('overview fetch error:', e);
}
}
// ── QPS 时间序列图 ──────────────────────────────────────────
async function refreshTimeSeries() {
const period = document.getElementById('periodSelect').value || '1h';
try {
const data = await fetchJSON(API + `/requests?period=${period}&buckets=60`);
const labels = data.map(d => d.time);
const counts = data.map(d => d.count);
const errors = data.map(d => d.errors);
createOrUpdateChart('qpsChart', {
type: 'line',
data: {
labels,
datasets: [
{
label: '请求数',
data: counts,
borderColor: '#6366f1',
backgroundColor: 'rgba(99,102,241,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
},
{
label: '错误数',
data: errors,
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#8b8d9a' } } },
scales: {
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 12 }, grid: { color: '#2a2d3e' } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
},
interaction: { intersect: false, mode: 'index' }
}
});
} catch (e) { console.error('timeseries error:', e); }
}
// ── 状态码分布图 ────────────────────────────────────────────
async function refreshStatus() {
try {
const data = await fetchJSON(API + '/status');
const labels = data.map(d => d.code);
const values = data.map(d => d.count);
const colors = {
'2xx': '#10b981',
'3xx': '#22d3ee',
'4xx': '#f59e0b',
'5xx': '#ef4444',
};
createOrUpdateChart('statusChart', {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: labels.map(l => colors[l] || '#6b7280'),
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#8b8d9a', padding: 16 } }
}
}
});
} catch (e) { console.error('status error:', e); }
}
// ── 并发连接图 ──────────────────────────────────────────────
async function refreshConcurrency() {
try {
const data = await fetchJSON(API + '/concurrency');
document.getElementById('activeConn').textContent = data.current;
const samples = data.samples || [];
const labels = samples.map(s => s.time);
const values = samples.map(s => s.value);
createOrUpdateChart('connChart', {
type: 'line',
data: {
labels,
datasets: [{
label: '并发连接',
data: values,
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8d9a' } },
annotation: data.peak ? {
annotations: {
peakLine: {
type: 'line',
yMin: data.peak,
yMax: data.peak,
borderColor: '#ef4444',
borderDash: [4, 4],
label: { content: '峰值 ' + data.peak, display: true }
}
}
} : {}
},
scales: {
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 10 }, grid: { color: '#2a2d3e' } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
}
}
});
} catch (e) { console.error('concurrency error:', e); }
}
// ── 延迟百分位图 ────────────────────────────────────────────
async function refreshLatency() {
try {
const data = await fetchJSON(API + '/latency');
const metrics = ['P50', 'P90', 'P95', 'P99', 'Avg', 'Max'];
const values = [data.p50_ms, data.p90_ms, data.p95_ms, data.p99_ms, data.avg_ms, data.max_ms];
const bgColors = ['#6366f1', '#6366f1', '#6366f1', '#f59e0b', '#10b981', '#ef4444'];
createOrUpdateChart('latencyChart', {
type: 'bar',
data: {
labels: metrics,
datasets: [{
label: '延迟 (ms)',
data: values,
backgroundColor: bgColors,
borderRadius: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#8b8d9a' }, grid: { display: false } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
}
}
});
} catch (e) { console.error('latency error:', e); }
}
// ── 地理分布图 ──────────────────────────────────────────────
async function refreshGeo() {
try {
const data = await fetchJSON(API + '/geo?top=10');
const labels = data.map(d => d.region || '未知');
const values = data.map(d => d.count);
createOrUpdateChart('geoChart', {
type: 'bar',
data: {
labels,
datasets: [{
label: '请求数',
data: values,
backgroundColor: [
'#6366f1', '#22d3ee', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#64748b'
],
borderRadius: 6,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true },
y: { ticks: { color: '#8b8d9a' }, grid: { display: false } }
}
}
});
} catch (e) { console.error('geo error:', e); }
}
// ── Top 端点表格 ────────────────────────────────────────────
async function refreshTopEndpoints() {
try {
const data = await fetchJSON(API + '/endpoints?top=10');
if (!data || data.length === 0) {
document.getElementById('topEndpoints').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
return;
}
const maxCount = data[0].count || 1;
let html = '<table><thead><tr><th>端点</th><th>请求数</th><th>占比</th><th>平均延迟</th><th>错误率</th></tr></thead><tbody>';
const total = data.reduce((s, d) => s + d.count, 0);
data.forEach(d => {
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
html += `<tr>
<td title="${d.path}">${d.path.length > 40 ? d.path.slice(0, 37) + '...' : d.path}</td>
<td>${d.count}</td>
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*100}px;"></div>${pct}%</div></td>
<td>${d.avg_latency_ms}ms</td>
<td style="color:${d.error_rate > 5 ? '#ef4444' : d.error_rate > 1 ? '#f59e0b' : '#10b981'}">${d.error_rate}%</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('topEndpoints').innerHTML = html;
} catch (e) { console.error('endpoints error:', e); }
}
// ── Top IPs ─────────────────────────────────────────────────
async function refreshTopIPs() {
try {
const data = await fetchJSON(API + '/top-ips?top=15');
if (!data || data.length === 0) {
document.getElementById('topIPs').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
return;
}
const maxCount = data[0].count || 1;
let html = '<table><thead><tr><th>IP</th><th>地区</th><th>请求数</th><th>占比</th></tr></thead><tbody>';
const total = data.reduce((s, d) => s + d.count, 0);
data.forEach(d => {
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
html += `<tr>
<td>${d.ip}</td>
<td>${d.region || '未知'}</td>
<td>${d.count}</td>
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*80}px;"></div>${pct}%</div></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('topIPs').innerHTML = html;
} catch (e) { console.error('top-ips error:', e); }
}
// ── 全部刷新 ────────────────────────────────────────────────
async function refreshAll() {
await Promise.all([
refreshOverview(),
refreshTimeSeries(),
refreshStatus(),
refreshConcurrency(),
refreshLatency(),
refreshGeo(),
refreshTopEndpoints(),
refreshTopIPs(),
]);
}
// ── 启动 ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
refreshAll();
refreshTimer = setInterval(refreshAll, 10000); // 每10秒刷新
});