1017 lines
38 KiB
Python
Executable File
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)
|