autool-test/app/core/stats_exporter.py
2026-06-17 11:13:11 +08:00

1017 lines
38 KiB
Python
Executable File

import asyncio
import time
from pathlib import Path
from typing import TYPE_CHECKING
from app.core.stats_store import StatsStore
if TYPE_CHECKING:
from app.core.dispatcher import Dispatcher
class StatsExporter:
def __init__(self, dispatcher: "Dispatcher", output_dir: str = "stats", interval: int = 10):
self.dispatcher = dispatcher
self.output_dir = Path(output_dir)
self.interval = interval
self._running = False
self._task = None
self.output_dir.mkdir(parents=True, exist_ok=True)
self.store = StatsStore(self.output_dir / "stats.sqlite3")
async def start(self):
self._running = True
self._task = asyncio.create_task(self._run_loop())
async def stop(self):
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run_loop(self):
while self._running:
try:
await self._export_stats()
except Exception as e:
print(f"Error exporting stats: {e}")
await asyncio.sleep(self.interval)
async def export_once(self):
await self._export_stats()
def clear_daily_usage_day(self, day: str):
self.store.delete_day(day)
async def render_dashboard(self) -> str:
stats, usage_summary, daily_usage, config_stats = await self._collect_dashboard_data()
return self._generate_html(config_stats, stats, usage_summary, daily_usage)
def _get_endpoint_status_for_config(self, config_id: str, keys: list) -> list:
"""Get endpoint status from key_manager for a config."""
return self.dispatcher.key_manager.get_endpoint_info(config_id)
async def _export_stats(self):
await self._collect_dashboard_data()
async def _collect_dashboard_data(self) -> tuple[list, dict, list, dict]:
stats = await self.dispatcher.get_all_key_stats()
usage_summary = await self.dispatcher.get_usage_summary()
live_daily_usage = await self.dispatcher.get_daily_usage_history(days=7)
self.store.save_export(stats, usage_summary, live_daily_usage)
daily_usage = self.store.get_daily_usage(days=7) or live_daily_usage
config_stats = self._group_by_config(stats)
return stats, usage_summary, daily_usage, config_stats
def _group_by_config(self, stats: list) -> dict:
grouped = {}
for s in stats:
config_id = s.get('config_id', 'default')
if config_id not in grouped:
grouped[config_id] = {
'config_id': config_id,
'model_name': s['model_name'],
'provider': s['provider'],
'enabled': s['enabled'],
'keys': [],
'endpoints': [],
'total_concurrency': 0,
'total_rpm': 0,
'total_tpm': 0,
'total_rpd': 0,
'active_count': 0,
'cooldown_count': 0,
'disabled_count': 0,
}
grouped[config_id]['keys'].append(s)
grouped[config_id]['total_concurrency'] += s['usage']['current_concurrency']
grouped[config_id]['total_rpm'] += s['usage']['current_rpm']
grouped[config_id]['total_tpm'] += s['usage']['current_tpm']
grouped[config_id]['total_rpd'] += s['usage']['current_rpd']
if s['status'] == 'active':
grouped[config_id]['active_count'] += 1
elif s['status'] == 'cooldown':
grouped[config_id]['cooldown_count'] += 1
else:
grouped[config_id]['disabled_count'] += 1
# Collect endpoint info from key data
endpoint_idx = s.get('endpoint_idx')
if endpoint_idx is not None:
endpoint = {
'idx': endpoint_idx,
'key_id': s['id'],
'status': s['status'],
'enabled': s['enabled'],
'api_base': s.get('api_base', ''),
'usage': s['usage'],
'limits': s['limits'],
}
# Avoid duplicates
if not any(e['idx'] == endpoint_idx for e in grouped[config_id]['endpoints']):
grouped[config_id]['endpoints'].append(endpoint)
# Sort endpoints by idx
for cfg in grouped.values():
cfg['endpoints'].sort(key=lambda x: x['idx'])
return grouped
def _generate_html(self, config_stats: dict, all_stats: list, usage_summary: dict, daily_usage: list) -> str:
now_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
daily_usage_section = self._generate_daily_usage_section(daily_usage)
config_rows = ""
for config_id, cfg in config_stats.items():
status_color = "#10b981" if cfg['enabled'] else "#64748b"
toggle_text = "禁用" if cfg['enabled'] else "启用"
toggle_class = "btn-disable" if cfg['enabled'] else "btn-enable"
active_badge = f"<span class='badge badge-active'>{cfg['active_count']}</span>" if cfg['active_count'] > 0 else ""
cooldown_badge = f"<span class='badge badge-cooldown'>{cfg['cooldown_count']}</span>" if cfg['cooldown_count'] > 0 else ""
disabled_badge = f"<span class='badge badge-disabled'>{cfg['disabled_count']}</span>" if cfg['disabled_count'] > 0 else ""
config_rows += f"""
<tr class="config-row" data-config-id="{config_id}">
<td>
<span class="config-id" title="{config_id}">{config_id[:25]}...</span>
</td>
<td>{cfg['model_name'][:30]}</td>
<td>{cfg['provider']}</td>
<td>
<span class="status-badge" style="background-color: {status_color}">
{'ON' if cfg['enabled'] else 'OFF'}
</span>
</td>
<td class="num-cell">{cfg['total_concurrency']}</td>
<td class="num-cell">{cfg['total_rpm']}</td>
<td class="num-cell">{self._format_number(cfg['total_tpm'])}</td>
<td class="num-cell">{cfg['total_rpd']}</td>
<td>{len(cfg['keys'])} {active_badge}{cooldown_badge}{disabled_badge}</td>
<td>
<button class="btn {toggle_class}" onclick="toggleConfig('{config_id}', {str(cfg['enabled']).lower()})">
{toggle_text}
</button>
<button class="btn btn-expand" onclick="toggleExpand('{config_id}')">
详情
</button>
</td>
</tr>
<tr class="keys-detail" id="detail-{config_id}" style="display: none;">
<td colspan="9">
<div class="keys-table-container">
{self._generate_endpoints_section(cfg)}
<table class="keys-table" style="margin-top: 16px;">
<thead>
<tr>
<th>Key ID</th>
<th>Owner</th>
<th>Status</th>
<th>Concurrency</th>
<th>RPM</th>
<th>TPM</th>
<th>RPD</th>
<th>429</th>
</tr>
</thead>
<tbody>
{self._generate_key_rows(cfg['keys'])}
</tbody>
</table>
</div>
</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="10">
<title>KeyPool Dashboard</title>
<style>
:root {{
--bg-primary: #0c0c14;
--bg-secondary: #12121c;
--bg-card: rgba(255,255,255,0.03);
--border-color: rgba(255,255,255,0.06);
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-blue: #3b82f6;
--accent-cyan: #06b6d4;
--accent-purple: #8b5cf6;
--gradient-primary: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%);
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 24px;
line-height: 1.5;
}}
.container {{ max-width: 1600px; margin: 0 auto; }}
.header {{
text-align: center;
margin-bottom: 32px;
position: relative;
}}
.header-actions {{
display: flex;
justify-content: center;
margin-top: 16px;
}}
h1 {{
font-size: 2.5em;
font-weight: 800;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
margin-bottom: 8px;
}}
.timestamp {{
color: var(--text-muted);
font-size: 0.875em;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}}
.timestamp::before {{
content: '';
width: 8px;
height: 8px;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}}
@keyframes pulse {{
0%, 100% {{ opacity: 1; transform: scale(1); }}
50% {{ opacity: 0.5; transform: scale(0.8); }}
}}
.usage-summary {{
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 16px;
margin-bottom: 32px;
}}
@media (max-width: 1200px) {{
.usage-summary {{ grid-template-columns: repeat(4, 1fr); }}
}}
@media (max-width: 768px) {{
.usage-summary {{ grid-template-columns: repeat(2, 1fr); }}
}}
.usage-card {{
background: var(--bg-card);
border-radius: 20px;
padding: 24px 20px;
text-align: center;
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}}
.usage-card::before {{
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity 0.3s;
}}
.usage-card:hover {{
transform: translateY(-4px);
border-color: rgba(255,255,255,0.1);
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
}}
.usage-card:hover::before {{
opacity: 1;
}}
.usage-card .icon {{
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 12px;
font-size: 1.2em;
}}
.usage-card .value {{
font-size: 2em;
font-weight: 700;
margin-bottom: 4px;
letter-spacing: -0.02em;
}}
.usage-card.rpm .value {{ color: #f472b6; }}
.usage-card.rpm .icon {{ background: rgba(244,114,182,0.15); }}
.usage-card.tpm .value {{ color: #a78bfa; }}
.usage-card.tpm .icon {{ background: rgba(167,139,250,0.15); }}
.usage-card.rpd .value {{ color: #fbbf24; }}
.usage-card.rpd .icon {{ background: rgba(251,191,36,0.15); }}
.usage-card.concurrency .value {{ color: #34d399; }}
.usage-card.concurrency .icon {{ background: rgba(52,211,153,0.15); }}
.usage-card.active .value {{ color: #10b981; }}
.usage-card.active .icon {{ background: rgba(16,185,129,0.15); }}
.usage-card.cooldown .value {{ color: #f97316; }}
.usage-card.cooldown .icon {{ background: rgba(249,115,22,0.15); }}
.usage-card.disabled .value {{ color: #64748b; }}
.usage-card.disabled .icon {{ background: rgba(100,116,139,0.15); }}
.usage-card.total .value {{ color: #3b82f6; }}
.usage-card.total .icon {{ background: rgba(59,130,246,0.15); }}
.daily-panel {{
background: var(--bg-card);
border-radius: 24px;
border: 1px solid var(--border-color);
padding: 28px;
margin-bottom: 32px;
overflow: hidden;
position: relative;
}}
.daily-panel::before {{
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(59,130,246,0.12), transparent 45%);
pointer-events: none;
}}
.daily-panel-header {{
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 24px;
position: relative;
}}
.daily-panel-title h2 {{
font-size: 1.2em;
font-weight: 700;
margin-bottom: 6px;
}}
.daily-panel-stats {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
min-width: 360px;
}}
.daily-stat {{
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 14px 16px;
}}
.daily-stat .label {{
display: block;
color: var(--text-muted);
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}}
.daily-stat .value {{
font-size: 1.35em;
font-weight: 700;
color: #e2e8f0;
}}
.daily-chart {{
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 14px;
align-items: end;
min-height: 220px;
position: relative;
}}
.daily-column {{
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}}
.daily-value {{
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace;
font-size: 0.78em;
color: var(--text-secondary);
}}
.daily-bar-wrap {{
width: 100%;
height: 160px;
display: flex;
align-items: flex-end;
}}
.daily-bar {{
width: 100%;
min-height: 10px;
border-radius: 16px 16px 6px 6px;
background: linear-gradient(180deg, rgba(6,182,212,0.95), rgba(59,130,246,0.6));
box-shadow: 0 12px 24px rgba(6,182,212,0.18);
border: 1px solid rgba(255,255,255,0.08);
}}
.daily-bar.is-zero {{
background: rgba(148,163,184,0.18);
box-shadow: none;
}}
.daily-label {{
color: var(--text-primary);
font-size: 0.82em;
font-weight: 600;
}}
.daily-empty {{
color: var(--text-muted);
font-size: 0.92em;
padding: 16px 0 4px;
}}
@media (max-width: 1100px) {{
.daily-panel-header {{
flex-direction: column;
}}
.daily-panel-stats {{
min-width: 0;
width: 100%;
}}
}}
@media (max-width: 768px) {{
.daily-panel {{
padding: 22px;
}}
.daily-panel-stats {{
grid-template-columns: 1fr;
}}
.daily-chart {{
gap: 8px;
}}
.daily-value {{
font-size: 0.72em;
}}
.daily-label {{
font-size: 0.76em;
}}
}}
.table-container {{
background: var(--bg-card);
border-radius: 20px;
border: 1px solid var(--border-color);
overflow: hidden;
}}
table {{
width: 100%;
border-collapse: collapse;
}}
th {{
background: rgba(59,130,246,0.08);
padding: 16px 14px;
text-align: left;
font-weight: 600;
color: var(--accent-blue);
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border-color);
}}
td {{
padding: 14px;
border-bottom: 1px solid var(--border-color);
font-size: 0.875em;
}}
tr.config-row {{
transition: background 0.2s;
}}
tr.config-row:hover {{
background: rgba(255,255,255,0.02);
}}
.keys-detail {{
background: rgba(0,0,0,0.2);
}}
.keys-detail td {{
padding: 20px;
}}
.keys-table-container {{
max-height: 400px;
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
}}
.keys-table-container::-webkit-scrollbar {{
width: 6px;
}}
.keys-table-container::-webkit-scrollbar-track {{
background: transparent;
}}
.keys-table-container::-webkit-scrollbar-thumb {{
background: var(--text-muted);
border-radius: 3px;
}}
.keys-table {{
font-size: 0.85em;
background: transparent;
}}
.keys-table th {{
background: rgba(59,130,246,0.05);
padding: 12px 14px;
position: sticky;
top: 0;
}}
.keys-table td {{
padding: 10px 14px;
}}
.status-badge {{
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.7em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.badge {{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 0.65em;
font-weight: 600;
margin-left: 6px;
}}
.badge-active {{ background: rgba(16,185,129,0.2); color: #10b981; }}
.badge-cooldown {{ background: rgba(249,115,22,0.2); color: #f97316; }}
.badge-disabled {{ background: rgba(100,116,139,0.2); color: #94a3b8; }}
.config-id {{
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace;
font-size: 0.85em;
color: var(--text-secondary);
}}
.num-cell {{
font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', monospace;
text-align: right;
color: #a5b4fc;
font-weight: 500;
}}
.progress-container {{
display: flex;
align-items: center;
gap: 10px;
}}
.progress-bar {{
flex: 1;
height: 8px;
background: rgba(255,255,255,0.08);
border-radius: 4px;
overflow: hidden;
min-width: 60px;
}}
.progress-fill {{
height: 100%;
border-radius: 4px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}}
.progress-text {{
font-size: 0.75em;
color: var(--text-muted);
min-width: 60px;
text-align: right;
font-family: 'JetBrains Mono', monospace;
}}
.btn {{
padding: 8px 16px;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 0.75em;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}}
.btn-enable {{
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 4px 12px rgba(16,185,129,0.3);
}}
.btn-enable:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(16,185,129,0.4);
}}
.btn-disable {{
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 12px rgba(239,68,68,0.3);
}}
.btn-disable:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(239,68,68,0.4);
}}
.btn-expand {{
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
box-shadow: 0 4px 12px rgba(99,102,241,0.3);
}}
.btn-expand:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99,102,241,0.4);
}}
.btn-reset {{
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
box-shadow: 0 4px 12px rgba(245,158,11,0.3);
}}
.btn-reset:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(245,158,11,0.4);
}}
.footer {{
text-align: center;
margin-top: 32px;
color: var(--text-muted);
font-size: 0.8em;
padding: 20px;
border-top: 1px solid var(--border-color);
}}
.toast {{
position: fixed;
bottom: 24px;
right: 24px;
padding: 16px 28px;
border-radius: 14px;
color: white;
font-size: 0.9em;
font-weight: 500;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
}}
.toast.show {{
opacity: 1;
transform: translateY(0);
}}
.toast.success {{ background: linear-gradient(135deg, #10b981, #059669); }}
.toast.error {{ background: linear-gradient(135deg, #ef4444, #dc2626); }}
.glow {{
position: fixed;
width: 600px;
height: 600px;
border-radius: 50%;
filter: blur(120px);
opacity: 0.15;
pointer-events: none;
z-index: -1;
}}
.glow-1 {{
top: -200px;
left: -200px;
background: #3b82f6;
}}
.glow-2 {{
bottom: -200px;
right: -200px;
background: #8b5cf6;
}}
</style>
</head>
<body>
<div class="glow glow-1"></div>
<div class="glow glow-2"></div>
<div class="container">
<div class="header">
<h1>KeyPool Dashboard</h1>
<div class="timestamp">Last updated: {now_str} | Auto-refresh: 10s</div>
<div class="header-actions">
<button class="btn btn-reset" onclick="resetTodayRpd()">重置今日RPD</button>
</div>
</div>
{daily_usage_section}
{self._generate_usage_summary_cards(usage_summary)}
<div class="table-container">
<table>
<thead>
<tr>
<th>Config ID</th>
<th>Model</th>
<th>Provider</th>
<th>Status</th>
<th>Conc</th>
<th>RPM</th>
<th>TPM</th>
<th>RPD</th>
<th>Keys</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{config_rows}
</tbody>
</table>
</div>
<div class="footer">
KeyPool Smart Gateway | Configuration auto-saved on changes
</div>
</div>
<div id="toast" class="toast"></div>
<script>
function showToast(message, type) {{
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast ' + type + ' show';
setTimeout(() => {{
toast.classList.remove('show');
}}, 3000);
}}
async function toggleConfig(configId, currentEnabled) {{
const newEnabled = !currentEnabled;
const action = newEnabled ? 'enable' : 'disable';
try {{
const response = await fetch('/v1/keys/config/' + encodeURIComponent(configId) + '/' + action, {{
method: 'POST'
}});
if (response.ok) {{
showToast(configId.split(':')[0] + ' ' + (newEnabled ? 'enabled' : 'disabled'), 'success');
setTimeout(() => location.reload(), 800);
}} else {{
const data = await response.json();
showToast('Error: ' + (data.detail || 'Unknown error'), 'error');
}}
}} catch (e) {{
showToast('Network error: ' + e.message, 'error');
}}
}}
async function resetTodayRpd() {{
if (!confirm('Reset today\\'s RPD counters?')) {{
return;
}}
try {{
const response = await fetch('/v1/stats/reset/rpd', {{
method: 'POST'
}});
const data = await response.json();
if (response.ok) {{
showToast('Today RPD reset: ' + (data.deleted_keys || 0), 'success');
setTimeout(() => location.reload(), 500);
}} else {{
showToast('Error: ' + (data.detail || 'Unknown error'), 'error');
}}
}} catch (e) {{
showToast('Network error: ' + e.message, 'error');
}}
}}
function toggleExpand(configId) {{
const detail = document.getElementById('detail-' + configId);
if (detail.style.display === 'none') {{
detail.style.display = 'table-row';
}} else {{
detail.style.display = 'none';
}}
}}
async function toggleEndpoint(configId, endpointIdx, currentEnabled) {{
const newEnabled = !currentEnabled;
const action = newEnabled ? 'enable' : 'disable';
try {{
const response = await fetch('/v1/keys/config/' + encodeURIComponent(configId) + '/endpoint/' + endpointIdx + '/' + action, {{
method: 'POST'
}});
if (response.ok) {{
showToast('Endpoint ' + endpointIdx + ' ' + (newEnabled ? 'enabled' : 'disabled'), 'success');
setTimeout(() => location.reload(), 800);
}} else {{
const data = await response.json();
showToast('Error: ' + (data.detail || 'Unknown error'), 'error');
}}
}} catch (e) {{
showToast('Network error: ' + e.message, 'error');
}}
}}
</script>
</body>
</html>"""
def _generate_daily_usage_section(self, daily_usage: list) -> str:
if not daily_usage:
return """
<section class="daily-panel">
<div class="daily-panel-header">
<div class="daily-panel-title">
<h2>Daily Usage</h2>
</div>
</div>
</section>"""
totals = [item.get("total_rpd", 0) for item in daily_usage]
total_7d = sum(totals)
avg_7d = int(total_7d / len(totals)) if totals else 0
peak_7d = max(totals) if totals else 0
columns = "".join(self._generate_daily_usage_bar(item, peak_7d) for item in daily_usage)
return f"""
<section class="daily-panel">
<div class="daily-panel-header">
<div class="daily-panel-title">
<h2>Daily Usage</h2>
</div>
<div class="daily-panel-stats">
<div class="daily-stat">
<span class="label">7D Total</span>
<span class="value">{self._format_number(total_7d)}</span>
</div>
<div class="daily-stat">
<span class="label">7D Average</span>
<span class="value">{self._format_number(avg_7d)}</span>
</div>
<div class="daily-stat">
<span class="label">Peak Day</span>
<span class="value">{self._format_number(peak_7d)}</span>
</div>
</div>
</div>
<div class="daily-chart">
{columns}
</div>
</section>"""
def _generate_daily_usage_bar(self, item: dict, peak_value: int) -> str:
value = item.get("total_rpd", 0)
peak = max(peak_value, 1)
height_pct = max(8, int(value / peak * 100)) if value > 0 else 8
bar_class = "daily-bar" if value > 0 else "daily-bar is-zero"
title = f"{item.get('day', item.get('label', ''))}: {value}"
return f"""
<div class="daily-column" title="{title}">
<div class="daily-value">{self._format_number(value)}</div>
<div class="daily-bar-wrap">
<div class="{bar_class}" style="height: {height_pct}%"></div>
</div>
<div class="daily-label">{item.get('label', '-')}</div>
</div>"""
def _generate_usage_summary_cards(self, usage_summary: dict) -> str:
cards = [
("total", "K", usage_summary["total_keys"], "Total Keys"),
("active", "A", usage_summary["active_keys"], "Active"),
("cooldown", "C", usage_summary["cooldown_keys"], "Cooldown"),
("disabled", "D", usage_summary["disabled_keys"], "Disabled"),
("concurrency", "Q", usage_summary["total_concurrency"], "Concurrency"),
("rpm", "R", usage_summary["total_rpm"], "RPM"),
("tpm", "T", self._format_number(usage_summary["total_tpm"]), "TPM"),
("rpd", "D", usage_summary["total_rpd"], "RPD"),
("rpd", "Y", usage_summary.get("total_yesterday_rpd", 0), "Yesterday RPD"),
]
card_html = "".join(
f"""
<div class="usage-card {card_class}">
<div class="icon">{icon}</div>
<div class="value">{value}</div>
</div>"""
for card_class, icon, value, label in cards
)
return f"""
<div class="usage-summary">
{card_html}
</div>"""
def _generate_endpoints_section(self, cfg: dict) -> str:
"""Generate endpoint management section for configs with multiple endpoints."""
endpoints = cfg.get('endpoints', [])
if not endpoints:
return ""
config_id = cfg['config_id']
rows = ""
for ep in endpoints:
status_color = "#4ade80" if ep['status'] == 'active' else "#6b7280"
toggle_text = "禁用" if ep['enabled'] else "启用"
toggle_class = "btn-disable" if ep['enabled'] else "btn-enable"
# Get IP from URL for display
api_base = ep.get('api_base', '')
url_display = api_base.replace('http://', '').replace('https://', '')[:30]
conc_pct = (ep['usage']['current_concurrency'] / ep['limits']['max_concurrency'] * 100) if ep['limits']['max_concurrency'] > 0 else 0
rpm_pct = (ep['usage']['current_rpm'] / ep['limits']['rpm'] * 100) if ep['limits']['rpm'] > 0 else 0
rows += f"""
<tr>
<td><span class="config-id" title="{ep['key_id']}">EP-{ep['idx']}</span></td>
<td><span class="config-id" title="{api_base}">{url_display}</span></td>
<td><span class="status-badge" style="background-color: {status_color}">{ep['status']}</span></td>
<td>{self._progress_bar(conc_pct, ep['usage']['current_concurrency'], ep['limits']['max_concurrency'])}</td>
<td>{self._progress_bar(rpm_pct, ep['usage']['current_rpm'], ep['limits']['rpm'])}</td>
<td>
<button class="btn {toggle_class}" onclick="toggleEndpoint('{config_id}', {ep['idx']}, {str(ep['enabled']).lower()})">
{toggle_text}
</button>
</td>
</tr>"""
return f"""
<div style="margin-bottom: 20px;">
<h4 style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.9em;">
本地节点 (Endpoints)
</h4>
<table class="keys-table">
<thead>
<tr>
<th>Node</th>
<th>URL</th>
<th>Status</th>
<th>Concurrency</th>
<th>RPM</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>"""
def _generate_key_rows(self, keys: list) -> str:
rows = ""
for k in keys:
status_color = self._get_status_color(k["status"])
conc_pct = (k["usage"]["current_concurrency"] / k["limits"]["max_concurrency"] * 100) if k["limits"]["max_concurrency"] > 0 else 0
rpm_pct = (k["usage"]["current_rpm"] / k["limits"]["rpm"] * 100) if k["limits"]["rpm"] > 0 else 0
tpm_pct = (k["usage"]["current_tpm"] / k["limits"]["tpm"] * 100) if k["limits"]["tpm"] > 0 else 0
rpd_pct = (k["usage"]["current_rpd"] / k["limits"]["rpd"] * 100) if k["limits"]["rpd"] > 0 else 0
cooldown_str = "<span style='color: #ff6b6b'>429</span>" if k["status"] == "cooldown" else "-"
rows += f"""
<tr>
<td><span class="config-id" title="{k['id']}">{k['id'][:20]}...</span></td>
<td>{k.get('owner', '-')}</td>
<td><span class="status-badge" style="background-color: {status_color}">{k['status']}</span></td>
<td>{self._progress_bar(conc_pct, k['usage']['current_concurrency'], k['limits']['max_concurrency'])}</td>
<td>{self._progress_bar(rpm_pct, k['usage']['current_rpm'], k['limits']['rpm'])}</td>
<td>{self._progress_bar(tpm_pct, k['usage']['current_tpm'], k['limits']['tpm'])}</td>
<td>{self._progress_bar(rpd_pct, k['usage']['current_rpd'], k['limits']['rpd'])}</td>
<td>{cooldown_str}</td>
</tr>"""
return rows
def _get_status_color(self, status: str) -> str:
colors = {
"active": "#4ade80",
"cooldown": "#f97316",
"disabled": "#6b7280",
"error": "#ef4444",
}
return colors.get(status.lower(), "#6b7280")
def _progress_bar(self, percentage: float, current: int, limit: int) -> str:
if limit == 0:
return f"<span style='color: #666'>N/A</span>"
percentage = min(100, max(0, percentage))
if percentage < 50:
color = "#4ade80"
elif percentage < 80:
color = "#fbbf24"
else:
color = "#ef4444"
return f"""
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" style="width: {percentage}%; background-color: {color}"></div>
</div>
<span class="progress-text">{current}/{limit}</span>
</div>"""
def _format_number(self, num: int) -> str:
if num >= 1000000:
return f"{num/1000000:.1f}M"
elif num >= 1000:
return f"{num/1000:.1f}K"
return str(num)