mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-07 16:56:36 +08:00
feat(ui): 添加 API 调用趋势图及时间区间切换
- 在 keys_status 页面引入 Chart.js(CDN),新增“调用趋势图”卡片 - 支持 1分钟/1小时/24小时切换,默认展示 1小时 - 前端从 /api/stats/details?period= 拉取数据,按时间桶聚合成功/失败并绘制 - 调整样式与布局:图表卡片跨列显示,固定容器高度并适配小屏 - 便于可视化监控调用成功/失败趋势,辅助排障与容量评估
This commit is contained in:
@@ -1436,6 +1436,148 @@ function initializeDropdownMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Chart: API success/failure over time ---
|
||||||
|
let apiStatsChart = null;
|
||||||
|
|
||||||
|
function buildChartConfig(labels, successData, failureData) {
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '成功',
|
||||||
|
data: successData,
|
||||||
|
borderColor: 'rgba(16,185,129,1)', // emerald-500
|
||||||
|
backgroundColor: 'rgba(16,185,129,0.15)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '失败',
|
||||||
|
data: failureData,
|
||||||
|
borderColor: 'rgba(239,68,68,1)', // red-500
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' },
|
||||||
|
tooltip: { mode: 'index', intersect: false },
|
||||||
|
},
|
||||||
|
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: '时间' } },
|
||||||
|
y: { title: { display: true, text: '调用次数' }, beginAtZero: true, ticks: { precision: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPeriodDetails(period) {
|
||||||
|
// Uses backend endpoint /api/stats/details?period={period}
|
||||||
|
return await fetchAPI(`/api/stats/details?period=${period}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketizeDetails(period, details) {
|
||||||
|
// details is expected to be an array of call records with fields: timestamp, status
|
||||||
|
// Build buckets depending on period
|
||||||
|
const buckets = new Map();
|
||||||
|
const addToBucket = (key, isSuccess) => {
|
||||||
|
if (!buckets.has(key)) buckets.set(key, { success: 0, failure: 0 });
|
||||||
|
const obj = buckets.get(key);
|
||||||
|
if (isSuccess) obj.success += 1; else obj.failure += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toKey = (ts) => {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (period === '1m') {
|
||||||
|
// bucket by second within last minute
|
||||||
|
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2,'0');
|
||||||
|
return `${mm}:${ss}`;
|
||||||
|
} else if (period === '1h') {
|
||||||
|
// bucket by minute
|
||||||
|
const HH = String(d.getHours()).padStart(2,'0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||||
|
return `${HH}:${mm}`;
|
||||||
|
} else {
|
||||||
|
// 24h: bucket by hour
|
||||||
|
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||||
|
const DD = String(d.getDate()).padStart(2,'0');
|
||||||
|
const HH = String(d.getHours()).padStart(2,'0');
|
||||||
|
return `${MM}-${DD} ${HH}:00`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(details || []).forEach((call) => {
|
||||||
|
const key = toKey(call.timestamp);
|
||||||
|
const isSuccess = call.status === 'success';
|
||||||
|
addToBucket(key, isSuccess);
|
||||||
|
});
|
||||||
|
|
||||||
|
// sort labels chronologically by parsing back to date when possible
|
||||||
|
const labels = Array.from(buckets.keys()).sort((a,b)=>{
|
||||||
|
// Try to create date objects relative to today for ordering; fallback to string compare
|
||||||
|
const da = Date.parse(a) || 0;
|
||||||
|
const db = Date.parse(b) || 0;
|
||||||
|
if (da && db) return da - db;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
const successData = labels.map(l => buckets.get(l).success);
|
||||||
|
const failureData = labels.map(l => buckets.get(l).failure);
|
||||||
|
return { labels, successData, failureData };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderApiChart(period) {
|
||||||
|
const canvas = document.getElementById('apiStatsChart');
|
||||||
|
if (!canvas || typeof Chart === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const details = await fetchPeriodDetails(period);
|
||||||
|
const { labels, successData, failureData } = bucketizeDetails(period, details || []);
|
||||||
|
const cfg = buildChartConfig(labels, successData, failureData);
|
||||||
|
if (apiStatsChart) {
|
||||||
|
apiStatsChart.destroy();
|
||||||
|
}
|
||||||
|
apiStatsChart = new Chart(canvas.getContext('2d'), cfg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to render chart:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChartControls() {
|
||||||
|
const btn1m = document.getElementById('chartBtn1m');
|
||||||
|
const btn1h = document.getElementById('chartBtn1h');
|
||||||
|
const btn24h = document.getElementById('chartBtn24h');
|
||||||
|
const setActive = (activeBtn) => {
|
||||||
|
[btn1m, btn1h, btn24h].forEach(btn => {
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn === activeBtn) {
|
||||||
|
btn.classList.remove('bg-gray-200');
|
||||||
|
btn.classList.add('bg-primary-600','text-white');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('bg-gray-200');
|
||||||
|
btn.classList.remove('bg-primary-600','text-white');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (btn1m) btn1m.addEventListener('click', async () => { setActive(btn1m); await renderApiChart('1m'); });
|
||||||
|
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
|
||||||
|
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
|
||||||
|
|
||||||
|
// default period
|
||||||
|
if (btn1h) setActive(btn1h);
|
||||||
|
renderApiChart('1h');
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initializePageAnimationsAndEffects();
|
initializePageAnimationsAndEffects();
|
||||||
@@ -1446,6 +1588,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
initializeKeyPaginationAndSearch(); // This will also handle initial display
|
initializeKeyPaginationAndSearch(); // This will also handle initial display
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
initializeDropdownMenu(); // 初始化下拉菜单
|
initializeDropdownMenu(); // 初始化下拉菜单
|
||||||
|
initChartControls(); // 初始化图表与时间区间切换
|
||||||
|
|
||||||
// Initial batch actions update might be needed if not covered by displayPage
|
// Initial batch actions update might be needed if not covered by displayPage
|
||||||
// updateBatchActions('valid');
|
// updateBatchActions('valid');
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ endblock %} {% block head_extra_styles %}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 让图表卡片在网格中占满整行 */
|
||||||
|
.stats-card.chart-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
/* 图表容器固定高度,配合 Chart.js maintainAspectRatio:false */
|
||||||
|
.chart-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chart-container { height: 220px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* 统计卡片样式 */
|
/* 统计卡片样式 */
|
||||||
.stats-card {
|
.stats-card {
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
@@ -1096,6 +1108,8 @@ endblock %} {% block head_extra_styles %}
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %} {% block head_extra_scripts %}
|
{% endblock %} {% block head_extra_scripts %}
|
||||||
|
<!-- Chart.js for time-series chart -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
|
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
|
||||||
<script src="/static/js/keys_status.js"></script>
|
<script src="/static/js/keys_status.js"></script>
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
@@ -1245,7 +1259,25 @@ endblock %} {% block head_extra_styles %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 可切换时间区间的成功/失败图表卡片 -->
|
||||||
|
<div class="stats-card chart-wide">
|
||||||
|
<div class="stats-card-header">
|
||||||
|
<h3 class="stats-card-title">
|
||||||
|
<i class="fas fa-chart-bar"></i>
|
||||||
|
<span>调用趋势图</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<button id="chartBtn1m" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1分钟</button>
|
||||||
|
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
||||||
|
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 chart-container">
|
||||||
|
<canvas id="apiStatsChart"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 有效密钥区域 -->
|
<!-- 有效密钥区域 -->
|
||||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||||
|
|||||||
Reference in New Issue
Block a user