342 lines
14 KiB
JavaScript
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秒刷新
|
|
});
|