Skip to content

Commit 258724b

Browse files
committed
feat(dashboard): 优化 Dashboard UI,新增 Token 趋势图、时间区间选择器及 favicon;
- 标题由「coding-proxy Dashboard」修正为「Coding Proxy Dashboard」; - 新增「近 N 天 Token 量趋势」折线图(按供应商分组),替换原「Token 类型分布」位置; - 调整第二行图表列比(1fr 2fr),收窄供应商分布、拓宽 Token 趋势图; - 在页面顶部新增全局时间区间选择器(最近一周/最近一月/自选区间),联动图表标题与数据; - 新增 /favicon.ico 路由,程序化生成 16×16 蓝紫渐变 ICO,消除浏览器 404 错误; 🤖 Generated with [Claude Code](https://github.com/claude) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
1 parent a489a41 commit 258724b

1 file changed

Lines changed: 171 additions & 39 deletions

File tree

src/coding/proxy/server/dashboard.py

Lines changed: 171 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,35 @@
1111

1212
from ..logging.db import TimePeriod
1313

14+
# ── Favicon (16×16, 蓝紫渐变) ────────────────────────────────────────────
15+
def _build_favicon() -> bytes:
16+
"""程序化生成 16×16 ICO,蓝紫渐变与 Dashboard Logo 一致."""
17+
import struct
18+
19+
width, height = 16, 16
20+
pixel_rows: list[bytes] = []
21+
for y in range(height - 1, -1, -1): # BMP bottom-up
22+
row = bytearray()
23+
for x in range(width):
24+
t = (x + (height - 1 - y)) / (width + height - 2)
25+
r = int(88 + (188 - 88) * t)
26+
g = int(166 + (140 - 166) * t)
27+
b = 255
28+
row.extend([b, g, r, 255]) # BGRA
29+
pixel_rows.append(bytes(row))
30+
31+
bmp_hdr = struct.pack("<IIIHHIIIIII", 40, width, height * 2, 1, 32, 0, 0, 0, 0, 0, 0)
32+
px_data = b"".join(pixel_rows)
33+
mask_data = b"\x00\x00\x00\x00" * height
34+
image_data = bmp_hdr + px_data + mask_data
35+
36+
ico_hdr = struct.pack("<HHH", 0, 1, 1)
37+
dir_entry = struct.pack("<BBBBHHII", width, height, 0, 0, 1, 32, len(image_data), 22)
38+
return ico_hdr + dir_entry + image_data
39+
40+
41+
_FAVICON_ICO: bytes = _build_favicon()
42+
1443
logger = logging.getLogger(__name__)
1544

1645
# ── HTML 模板 ──────────────────────────────────────────────────────────────
@@ -20,7 +49,8 @@
2049
<head>
2150
<meta charset="UTF-8" />
2251
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
23-
<title>coding-proxy Dashboard</title>
52+
<title>Coding Proxy Dashboard</title>
53+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
2454
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
2555
<style>
2656
:root {
@@ -125,7 +155,7 @@
125155
}
126156
.charts-grid-3 {
127157
display: grid;
128-
grid-template-columns: 1fr 1fr;
158+
grid-template-columns: 1fr 2fr;
129159
gap: 14px;
130160
margin-bottom: 14px;
131161
}
@@ -197,6 +227,35 @@
197227
border: 1px solid rgba(188,140,255,.25);
198228
}
199229
.arrow { color: var(--text-secondary); margin: 0 4px; }
230+
/* ── 时间区间选择栏 ── */
231+
.time-range-bar {
232+
display: flex; align-items: center; gap: 8px;
233+
margin-bottom: 16px; flex-wrap: wrap;
234+
}
235+
.time-range-label { font-size: 13px; color: var(--text-secondary); }
236+
.range-btn {
237+
padding: 4px 14px; border-radius: 14px;
238+
background: rgba(48,54,61,.6);
239+
border: 1px solid var(--border);
240+
color: var(--text-secondary);
241+
font-size: 12px; cursor: pointer;
242+
transition: background .15s, color .15s, border-color .15s;
243+
}
244+
.range-btn:hover { background: var(--bg-card-hover); color: var(--text-primary); }
245+
.range-btn.active {
246+
background: rgba(88,166,255,.15);
247+
border-color: rgba(88,166,255,.5);
248+
color: var(--accent-blue);
249+
}
250+
.range-custom { display: none; align-items: center; gap: 6px; }
251+
.range-custom.visible { display: flex; }
252+
.range-date {
253+
padding: 3px 8px; border-radius: var(--radius);
254+
background: var(--bg-card); border: 1px solid var(--border);
255+
color: var(--text-primary); font-size: 12px;
256+
color-scheme: dark;
257+
}
258+
.range-sep { font-size: 12px; color: var(--text-secondary); }
200259
/* ── 空态 ── */
201260
.empty {
202261
text-align: center; padding: 32px;
@@ -210,7 +269,7 @@
210269
<header>
211270
<div class="header-left">
212271
<div class="logo">C</div>
213-
<h1>coding-proxy Dashboard</h1>
272+
<h1>Coding Proxy Dashboard</h1>
214273
<span class="badge" id="version-badge">v-.-.-</span>
215274
</div>
216275
<div class="header-right">
@@ -220,6 +279,19 @@
220279
</header>
221280
222281
<main>
282+
<!-- 时间区间选择器 -->
283+
<div class="time-range-bar">
284+
<span class="time-range-label">数据时间区间:</span>
285+
<button class="range-btn active" onclick="setTimeRange(7, this)">最近一周</button>
286+
<button class="range-btn" onclick="setTimeRange(30, this)">最近一月</button>
287+
<button class="range-btn" onclick="setTimeRange(0, this)">自选区间</button>
288+
<div class="range-custom" id="range-custom">
289+
<input type="date" id="range-start" class="range-date" onchange="applyCustomRange()" />
290+
<span class="range-sep">–</span>
291+
<input type="date" id="range-end" class="range-date" onchange="applyCustomRange()" />
292+
</div>
293+
</div>
294+
223295
<!-- KPI 卡片 -->
224296
<div class="kpi-grid" id="kpi-grid">
225297
<div class="kpi-card"><div class="kpi-label">今日请求数</div><div class="kpi-value color-blue" id="kpi-req-today">–</div><div class="kpi-sub" id="kpi-req-week">本周 –</div></div>
@@ -239,25 +311,25 @@
239311
</div>
240312
</div>
241313
<div class="card">
242-
<div class="card-title">近 7 天请求量趋势</div>
314+
<div class="card-title" id="title-timeline">近 7 天请求量趋势</div>
243315
<div class="chart-wrap-lg">
244316
<canvas id="chart-timeline"></canvas>
245317
</div>
246318
</div>
247319
</div>
248320
249-
<!-- 供应商分布 + Token 类型分布 -->
321+
<!-- 供应商分布 + Token 量趋势 -->
250322
<div class="charts-grid-3">
251323
<div class="card">
252-
<div class="card-title">供应商请求分布(近 7 天)</div>
324+
<div class="card-title" id="title-vendor-dist">供应商请求分布(近 7 天)</div>
253325
<div class="chart-wrap">
254326
<canvas id="chart-vendor-dist"></canvas>
255327
</div>
256328
</div>
257329
<div class="card">
258-
<div class="card-title">Token 类型分布(近 7 天</div>
259-
<div class="chart-wrap">
260-
<canvas id="chart-token-type"></canvas>
330+
<div class="card-title" id="title-token-timeline">近 7 天 Token 量趋势</div>
331+
<div class="chart-wrap-lg">
332+
<canvas id="chart-token-timeline"></canvas>
261333
</div>
262334
</div>
263335
</div>
@@ -317,11 +389,11 @@
317389
// ── 图表实例 ──────────────────────────────────────────────
318390
let chartTimeline = null;
319391
let chartVendorDist = null;
320-
let chartTokenType = null;
392+
let chartTokenTimeline = null;
321393
322394
function destroyCharts() {
323-
[chartTimeline, chartVendorDist, chartTokenType].forEach(c => c && c.destroy());
324-
chartTimeline = chartVendorDist = chartTokenType = null;
395+
[chartTimeline, chartVendorDist, chartTokenTimeline].forEach(c => c && c.destroy());
396+
chartTimeline = chartVendorDist = chartTokenTimeline = null;
325397
}
326398
327399
// ── 数据拉取 ──────────────────────────────────────────────
@@ -507,49 +579,52 @@
507579
});
508580
}
509581
510-
// ── Token 类型堆叠柱图 ────────────────────────────────────
511-
function buildTokenType(rows) {
512-
// 按 date 汇总 token 类型
513-
const byDate = {};
582+
// ── Token 量趋势折线图 ────────────────────────────────────
583+
function buildTokenTimeline(rows) {
584+
// 按 vendor 分组,按 date 汇总 token 总量
585+
const vendorDateMap = {}; // vendor → {date → total_tokens}
514586
const allDates = new Set();
515587
for (const r of rows) {
516-
const d = r.date;
517-
if (!d) continue;
518-
if (!byDate[d]) byDate[d] = {input:0,output:0,cache_creation:0,cache_read:0};
519-
byDate[d].input += r.total_input || 0;
520-
byDate[d].output += r.total_output || 0;
521-
byDate[d].cache_creation += r.total_cache_creation || 0;
522-
byDate[d].cache_read += r.total_cache_read || 0;
588+
const v = r.vendor, d = r.date;
589+
if (!v || !d) continue;
590+
if (!vendorDateMap[v]) vendorDateMap[v] = {};
591+
const total = (r.total_input || 0) + (r.total_output || 0)
592+
+ (r.total_cache_creation || 0) + (r.total_cache_read || 0);
593+
vendorDateMap[v][d] = (vendorDateMap[v][d] || 0) + total;
523594
allDates.add(d);
524595
}
525596
const dates = [...allDates].sort();
597+
const vendors = Object.keys(vendorDateMap).sort();
526598
527-
if (chartTokenType) chartTokenType.destroy();
528-
const ctx = document.getElementById('chart-token-type').getContext('2d');
599+
if (chartTokenTimeline) chartTokenTimeline.destroy();
600+
const ctx = document.getElementById('chart-token-timeline').getContext('2d');
529601
if (!dates.length) {
530602
ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>';
531603
return;
532604
}
533605
534-
const typeLabels = { input:'输入', output:'输出', cache_creation:'缓存写入', cache_read:'缓存读取' };
535-
const datasets = Object.entries(typeLabels).map(([key, label]) => ({
536-
label,
537-
data: dates.map(d => byDate[d]?.[key] || 0),
538-
backgroundColor: TOKEN_COLORS[key],
539-
stack: 'tokens',
540-
borderWidth: 0,
606+
const datasets = vendors.map((v, i) => ({
607+
label: v,
608+
data: dates.map(d => vendorDateMap[v][d] || 0),
609+
borderColor: VENDOR_COLORS[i % VENDOR_COLORS.length],
610+
backgroundColor: VENDOR_COLORS[i % VENDOR_COLORS.length] + '22',
611+
fill: true,
612+
tension: .3,
613+
pointRadius: 3,
614+
pointHoverRadius: 5,
541615
}));
542616
543-
chartTokenType = new Chart(ctx, {
544-
type: 'bar',
617+
chartTokenTimeline = new Chart(ctx, {
618+
type: 'line',
545619
data: { labels: dates, datasets },
546620
options: {
547621
responsive: true, maintainAspectRatio: false,
548-
plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 10 } } },
622+
interaction: { mode: 'index', intersect: false },
623+
plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, padding: 12 } } },
549624
scales: {
550-
x: { stacked: true, grid: { color: '#30363d' } },
625+
x: { grid: { color: '#30363d' } },
551626
y: {
552-
stacked: true, grid: { color: '#30363d' }, beginAtZero: true,
627+
grid: { color: '#30363d' }, beginAtZero: true,
553628
ticks: { callback: v => fmtTokens(v) },
554629
},
555630
},
@@ -572,16 +647,67 @@
572647
</tr>`).join('');
573648
}
574649
650+
// ── 时间区间控制 ──────────────────────────────────────────
651+
let currentDays = 7;
652+
653+
function setTimeRange(days, btn) {
654+
currentDays = days;
655+
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
656+
if (btn) btn.classList.add('active');
657+
const customEl = document.getElementById('range-custom');
658+
if (days === 0) {
659+
customEl.classList.add('visible');
660+
// 初始化日期:默认今天往前 7 天
661+
const today = new Date();
662+
const weekAgo = new Date(today);
663+
weekAgo.setDate(weekAgo.getDate() - 6);
664+
document.getElementById('range-end').value = today.toISOString().slice(0, 10);
665+
document.getElementById('range-start').value = weekAgo.toISOString().slice(0, 10);
666+
applyCustomRange();
667+
} else {
668+
customEl.classList.remove('visible');
669+
refresh();
670+
}
671+
}
672+
673+
function applyCustomRange() {
674+
const s = document.getElementById('range-start').value;
675+
const e = document.getElementById('range-end').value;
676+
if (!s || !e) return;
677+
const startMs = new Date(s).getTime();
678+
const endMs = new Date(e).getTime();
679+
if (endMs < startMs) return;
680+
currentDays = Math.ceil((endMs - startMs) / 86400000) + 1;
681+
refresh();
682+
}
683+
684+
function rangeLabel() {
685+
if (currentDays <= 7) return '近 7 天';
686+
if (currentDays <= 30) return '近 30 天';
687+
return '近 ' + currentDays + ' 天';
688+
}
689+
690+
function updateChartTitles(days) {
691+
const label = days <= 7 ? '近 7 天' : (days <= 30 ? '近 30 天' : '近 ' + days + ' 天');
692+
const tl = document.getElementById('title-timeline');
693+
const tt = document.getElementById('title-token-timeline');
694+
const vd = document.getElementById('title-vendor-dist');
695+
if (tl) tl.textContent = label + ' 请求量趋势';
696+
if (tt) tt.textContent = label + ' Token 量趋势';
697+
if (vd) vd.textContent = '供应商请求分布(' + label + ')';
698+
}
699+
575700
// ── 主刷新逻辑 ────────────────────────────────────────────
576701
let refreshing = false;
577702
async function refresh() {
578703
if (refreshing) return;
579704
refreshing = true;
580705
document.getElementById('refresh-time').textContent = '刷新中…';
581706
try {
707+
const days = currentDays > 0 ? currentDays : 7;
582708
const [summary, timeline, status] = await Promise.all([
583709
fetchJSON('/api/dashboard/summary'),
584-
fetchJSON('/api/dashboard/timeline?days=7'),
710+
fetchJSON('/api/dashboard/timeline?days=' + days),
585711
fetchJSON('/api/status'),
586712
]);
587713
@@ -592,11 +718,12 @@
592718
593719
updateKPI(summary);
594720
updateVendorStatus(status);
721+
updateChartTitles(days);
595722
596723
const rows = timeline.rows || [];
597724
buildTimeline(rows);
598725
buildVendorDist(rows);
599-
buildTokenType(rows);
726+
buildTokenTimeline(rows);
600727
601728
updateFtTable(summary.failover_stats || []);
602729
@@ -687,6 +814,11 @@ def register_dashboard_routes(app: Any) -> None:
687814
"""注册 Dashboard 相关路由."""
688815
from .. import __version__
689816

817+
@app.get("/favicon.ico", include_in_schema=False)
818+
async def favicon() -> Response:
819+
"""返回内嵌 favicon."""
820+
return Response(content=_FAVICON_ICO, media_type="image/x-icon")
821+
690822
@app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
691823
async def dashboard() -> HTMLResponse:
692824
"""返回 Dashboard HTML 页面."""

0 commit comments

Comments
 (0)