|
11 | 11 |
|
12 | 12 | from ..logging.db import TimePeriod |
13 | 13 |
|
| 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 | + |
14 | 43 | logger = logging.getLogger(__name__) |
15 | 44 |
|
16 | 45 | # ── HTML 模板 ────────────────────────────────────────────────────────────── |
|
20 | 49 | <head> |
21 | 50 | <meta charset="UTF-8" /> |
22 | 51 | <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" /> |
24 | 54 | <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script> |
25 | 55 | <style> |
26 | 56 | :root { |
|
125 | 155 | } |
126 | 156 | .charts-grid-3 { |
127 | 157 | display: grid; |
128 | | - grid-template-columns: 1fr 1fr; |
| 158 | + grid-template-columns: 1fr 2fr; |
129 | 159 | gap: 14px; |
130 | 160 | margin-bottom: 14px; |
131 | 161 | } |
|
197 | 227 | border: 1px solid rgba(188,140,255,.25); |
198 | 228 | } |
199 | 229 | .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); } |
200 | 259 | /* ── 空态 ── */ |
201 | 260 | .empty { |
202 | 261 | text-align: center; padding: 32px; |
|
210 | 269 | <header> |
211 | 270 | <div class="header-left"> |
212 | 271 | <div class="logo">C</div> |
213 | | - <h1>coding-proxy Dashboard</h1> |
| 272 | + <h1>Coding Proxy Dashboard</h1> |
214 | 273 | <span class="badge" id="version-badge">v-.-.-</span> |
215 | 274 | </div> |
216 | 275 | <div class="header-right"> |
|
220 | 279 | </header> |
221 | 280 |
|
222 | 281 | <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 | +
|
223 | 295 | <!-- KPI 卡片 --> |
224 | 296 | <div class="kpi-grid" id="kpi-grid"> |
225 | 297 | <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 | 311 | </div> |
240 | 312 | </div> |
241 | 313 | <div class="card"> |
242 | | - <div class="card-title">近 7 天请求量趋势</div> |
| 314 | + <div class="card-title" id="title-timeline">近 7 天请求量趋势</div> |
243 | 315 | <div class="chart-wrap-lg"> |
244 | 316 | <canvas id="chart-timeline"></canvas> |
245 | 317 | </div> |
246 | 318 | </div> |
247 | 319 | </div> |
248 | 320 |
|
249 | | - <!-- 供应商分布 + Token 类型分布 --> |
| 321 | + <!-- 供应商分布 + Token 量趋势 --> |
250 | 322 | <div class="charts-grid-3"> |
251 | 323 | <div class="card"> |
252 | | - <div class="card-title">供应商请求分布(近 7 天)</div> |
| 324 | + <div class="card-title" id="title-vendor-dist">供应商请求分布(近 7 天)</div> |
253 | 325 | <div class="chart-wrap"> |
254 | 326 | <canvas id="chart-vendor-dist"></canvas> |
255 | 327 | </div> |
256 | 328 | </div> |
257 | 329 | <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> |
261 | 333 | </div> |
262 | 334 | </div> |
263 | 335 | </div> |
|
317 | 389 | // ── 图表实例 ────────────────────────────────────────────── |
318 | 390 | let chartTimeline = null; |
319 | 391 | let chartVendorDist = null; |
320 | | -let chartTokenType = null; |
| 392 | +let chartTokenTimeline = null; |
321 | 393 |
|
322 | 394 | 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; |
325 | 397 | } |
326 | 398 |
|
327 | 399 | // ── 数据拉取 ────────────────────────────────────────────── |
|
507 | 579 | }); |
508 | 580 | } |
509 | 581 |
|
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} |
514 | 586 | const allDates = new Set(); |
515 | 587 | 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; |
523 | 594 | allDates.add(d); |
524 | 595 | } |
525 | 596 | const dates = [...allDates].sort(); |
| 597 | + const vendors = Object.keys(vendorDateMap).sort(); |
526 | 598 |
|
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'); |
529 | 601 | if (!dates.length) { |
530 | 602 | ctx.canvas.parentElement.innerHTML = '<div class="empty">暂无数据</div>'; |
531 | 603 | return; |
532 | 604 | } |
533 | 605 |
|
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, |
541 | 615 | })); |
542 | 616 |
|
543 | | - chartTokenType = new Chart(ctx, { |
544 | | - type: 'bar', |
| 617 | + chartTokenTimeline = new Chart(ctx, { |
| 618 | + type: 'line', |
545 | 619 | data: { labels: dates, datasets }, |
546 | 620 | options: { |
547 | 621 | 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 } } }, |
549 | 624 | scales: { |
550 | | - x: { stacked: true, grid: { color: '#30363d' } }, |
| 625 | + x: { grid: { color: '#30363d' } }, |
551 | 626 | y: { |
552 | | - stacked: true, grid: { color: '#30363d' }, beginAtZero: true, |
| 627 | + grid: { color: '#30363d' }, beginAtZero: true, |
553 | 628 | ticks: { callback: v => fmtTokens(v) }, |
554 | 629 | }, |
555 | 630 | }, |
|
572 | 647 | </tr>`).join(''); |
573 | 648 | } |
574 | 649 |
|
| 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 | +
|
575 | 700 | // ── 主刷新逻辑 ──────────────────────────────────────────── |
576 | 701 | let refreshing = false; |
577 | 702 | async function refresh() { |
578 | 703 | if (refreshing) return; |
579 | 704 | refreshing = true; |
580 | 705 | document.getElementById('refresh-time').textContent = '刷新中…'; |
581 | 706 | try { |
| 707 | + const days = currentDays > 0 ? currentDays : 7; |
582 | 708 | const [summary, timeline, status] = await Promise.all([ |
583 | 709 | fetchJSON('/api/dashboard/summary'), |
584 | | - fetchJSON('/api/dashboard/timeline?days=7'), |
| 710 | + fetchJSON('/api/dashboard/timeline?days=' + days), |
585 | 711 | fetchJSON('/api/status'), |
586 | 712 | ]); |
587 | 713 |
|
|
592 | 718 |
|
593 | 719 | updateKPI(summary); |
594 | 720 | updateVendorStatus(status); |
| 721 | + updateChartTitles(days); |
595 | 722 |
|
596 | 723 | const rows = timeline.rows || []; |
597 | 724 | buildTimeline(rows); |
598 | 725 | buildVendorDist(rows); |
599 | | - buildTokenType(rows); |
| 726 | + buildTokenTimeline(rows); |
600 | 727 |
|
601 | 728 | updateFtTable(summary.failover_stats || []); |
602 | 729 |
|
@@ -687,6 +814,11 @@ def register_dashboard_routes(app: Any) -> None: |
687 | 814 | """注册 Dashboard 相关路由.""" |
688 | 815 | from .. import __version__ |
689 | 816 |
|
| 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 | + |
690 | 822 | @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) |
691 | 823 | async def dashboard() -> HTMLResponse: |
692 | 824 | """返回 Dashboard HTML 页面.""" |
|
0 commit comments