| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>LLM Proxy Dashboard</title> |
| <style> |
| :root { |
| --bg-primary: #0d1117; |
| --bg-secondary: #161b22; |
| --bg-tertiary: #21262d; |
| --text-primary: #f0f6fc; |
| --text-secondary: #8b949e; |
| --accent-blue: #58a6ff; |
| --accent-green: #3fb950; |
| --accent-yellow: #d29922; |
| --accent-red: #f85149; |
| --accent-purple: #a371f7; |
| --border-color: #30363d; |
| } |
| |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; |
| background: var(--bg-primary); |
| color: var(--text-primary); |
| min-height: 100vh; |
| padding: 20px; |
| } |
| |
| .container { |
| max-width: 1200px; |
| margin: 0 auto; |
| } |
| |
| header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 24px; |
| padding-bottom: 16px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| h1 { |
| font-size: 1.5rem; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .header-right { |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| } |
| |
| .refresh-info { |
| color: var(--text-secondary); |
| font-size: 0.85rem; |
| } |
| |
| .refresh-btn { |
| background: var(--accent-blue); |
| color: white; |
| border: none; |
| padding: 8px 16px; |
| border-radius: 6px; |
| cursor: pointer; |
| font-size: 0.9rem; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| transition: opacity 0.2s; |
| } |
| |
| .refresh-btn:hover { |
| opacity: 0.9; |
| } |
| |
| .refresh-btn:disabled { |
| opacity: 0.5; |
| cursor: not-allowed; |
| } |
| |
| .summary-card { |
| background: linear-gradient(135deg, #1a1f35 0%, #141824 100%); |
| border: 1px solid var(--border-color); |
| border-radius: 12px; |
| padding: 24px; |
| margin-bottom: 24px; |
| } |
| |
| .summary-title { |
| font-size: 1rem; |
| color: var(--text-secondary); |
| margin-bottom: 16px; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .summary-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
| gap: 20px; |
| } |
| |
| .stat-box { |
| text-align: center; |
| } |
| |
| .stat-value { |
| font-size: 2rem; |
| font-weight: bold; |
| color: var(--accent-blue); |
| } |
| |
| .stat-label { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| margin-top: 4px; |
| } |
| |
| .accounts-section { |
| display: grid; |
| gap: 16px; |
| } |
| |
| .account-card { |
| background: var(--bg-secondary); |
| border: 1px solid var(--border-color); |
| border-radius: 12px; |
| overflow: hidden; |
| } |
| |
| .account-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 16px 20px; |
| background: var(--bg-tertiary); |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| .account-name { |
| font-weight: 600; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .account-tier { |
| font-size: 0.75rem; |
| padding: 2px 8px; |
| border-radius: 12px; |
| background: var(--accent-purple); |
| color: white; |
| } |
| |
| .account-status { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| font-size: 0.85rem; |
| } |
| |
| .status-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| } |
| |
| .status-active { |
| background: var(--accent-green); |
| } |
| |
| .status-cooldown { |
| background: var(--accent-yellow); |
| } |
| |
| .status-exhausted { |
| background: var(--accent-red); |
| } |
| |
| .account-body { |
| padding: 16px 20px; |
| } |
| |
| .account-stats { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 12px; |
| margin-bottom: 16px; |
| padding-bottom: 16px; |
| border-bottom: 1px solid var(--border-color); |
| } |
| |
| .mini-stat { |
| text-align: center; |
| } |
| |
| .mini-stat-value { |
| font-size: 1.2rem; |
| font-weight: bold; |
| color: var(--text-primary); |
| } |
| |
| .mini-stat-label { |
| font-size: 0.7rem; |
| color: var(--text-secondary); |
| } |
| |
| .models-list { |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .model-row { |
| display: grid; |
| grid-template-columns: 200px 1fr 100px 100px; |
| gap: 12px; |
| align-items: center; |
| } |
| |
| .model-name { |
| font-size: 0.85rem; |
| color: var(--text-primary); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .progress-bar { |
| height: 8px; |
| background: var(--bg-tertiary); |
| border-radius: 4px; |
| overflow: hidden; |
| } |
| |
| .progress-fill { |
| height: 100%; |
| border-radius: 4px; |
| transition: width 0.3s ease; |
| } |
| |
| .progress-green { |
| background: var(--accent-green); |
| } |
| |
| .progress-yellow { |
| background: var(--accent-yellow); |
| } |
| |
| .progress-red { |
| background: var(--accent-red); |
| } |
| |
| .quota-text { |
| font-size: 0.8rem; |
| color: var(--text-secondary); |
| text-align: right; |
| } |
| |
| .reset-time { |
| font-size: 0.75rem; |
| color: var(--accent-yellow); |
| text-align: right; |
| } |
| |
| .loading { |
| text-align: center; |
| padding: 60px; |
| color: var(--text-secondary); |
| } |
| |
| .error-message { |
| background: rgba(248, 81, 73, 0.1); |
| border: 1px solid var(--accent-red); |
| color: var(--accent-red); |
| padding: 16px; |
| border-radius: 8px; |
| margin-bottom: 16px; |
| } |
| |
| @media (max-width: 768px) { |
| .model-row { |
| grid-template-columns: 1fr; |
| gap: 4px; |
| } |
| |
| .account-stats { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| |
| .summary-grid { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="container"> |
| <header> |
| <h1>🛡️ LLM Proxy Dashboard</h1> |
| <div class="header-right"> |
| <span class="refresh-info"> |
| Last updated: <span id="lastUpdate">--:--:--</span> • |
| Auto-refresh in <span id="countdown">10</span>s |
| </span> |
| <button class="refresh-btn" onclick="fetchData()" id="refreshBtn"> |
| 🔄 Refresh |
| </button> |
| </div> |
| </header> |
|
|
| <div id="content"> |
| <div class="loading">Loading dashboard data...</div> |
| </div> |
| </div> |
|
|
| <script> |
| const API_KEY = 'sk-antigravity-proxy-123'; |
| let countdown = 10; |
| let countdownInterval; |
| |
| function formatTime(date) { |
| return date.toLocaleTimeString('en-US', { hour12: false }); |
| } |
| |
| function formatResetTime(timestamp) { |
| if (!timestamp) return '--'; |
| const resetDate = new Date(timestamp * 1000); |
| const now = new Date(); |
| const diffMs = resetDate - now; |
| |
| if (diffMs <= 0) return 'Now'; |
| |
| const hours = Math.floor(diffMs / (1000 * 60 * 60)); |
| const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); |
| |
| if (hours > 24) { |
| const days = Math.floor(hours / 24); |
| return `${days}d ${hours % 24}h`; |
| } |
| return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; |
| } |
| |
| function getProgressColor(used, max) { |
| const pct = (used / max) * 100; |
| if (pct >= 80) return 'progress-red'; |
| if (pct >= 50) return 'progress-yellow'; |
| return 'progress-green'; |
| } |
| |
| function getStatusClass(status) { |
| if (status === 'active') return 'status-active'; |
| if (status === 'cooldown') return 'status-cooldown'; |
| return 'status-exhausted'; |
| } |
| |
| async function fetchData() { |
| const btn = document.getElementById('refreshBtn'); |
| btn.disabled = true; |
| btn.innerHTML = '⏳ Loading...'; |
| |
| try { |
| // Fetch stats for ALL providers (removed ?provider=antigravity) |
| const response = await fetch('/v1/quota-stats', { |
| headers: { |
| 'Authorization': `Bearer ${API_KEY}` |
| } |
| }); |
| |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| |
| const data = await response.json(); |
| renderDashboard(data); |
| document.getElementById('lastUpdate').textContent = formatTime(new Date()); |
| countdown = 10; |
| |
| } catch (error) { |
| document.getElementById('content').innerHTML = ` |
| <div class="error-message"> |
| ❌ Failed to fetch data: ${error.message}<br> |
| <small>Make sure the proxy is running and API key is correct.</small> |
| </div> |
| `; |
| } finally { |
| btn.disabled = false; |
| btn.innerHTML = '🔄 Refresh'; |
| } |
| } |
| |
| function renderDashboard(data) { |
| const providers = data.providers || {}; |
| |
| if (Object.keys(providers).length === 0) { |
| document.getElementById('content').innerHTML = '<div class="loading">No provider data available</div>'; |
| return; |
| } |
| |
| let html = ''; |
| // Render each provider in its own section |
| for (const [name, stats] of Object.entries(providers)) { |
| html += renderProviderSection(name, stats); |
| } |
| document.getElementById('content').innerHTML = html; |
| } |
| |
| function renderProviderSection(providerName, provider) { |
| const tokens = provider.tokens || {}; |
| const totalTokens = (tokens.input_uncached || 0) + (tokens.input_cached || 0) + (tokens.output || 0); |
| |
| // Format provider name nicely (e.g., "gemini_cli" -> "Gemini CLI") |
| const displayName = providerName.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); |
| |
| let html = ` |
| <h2 style="margin-top: 30px; margin-bottom: 15px; border-bottom: 2px solid var(--border-color); padding-bottom: 10px;"> |
| 🤖 ${displayName} |
| </h2> |
| <div class="summary-card"> |
| <div class="summary-title">📊 TOTAL USAGE - ${displayName.toUpperCase()}</div> |
| <div class="summary-grid"> |
| <div class="stat-box"> |
| <div class="stat-value">${provider.total_requests || 0}</div> |
| <div class="stat-label">Requests</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value">${tokens.input_uncached || 0}</div> |
| <div class="stat-label">Input Tokens</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value">${tokens.input_cached || 0}</div> |
| <div class="stat-label">Cached Tokens</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value">${tokens.output || 0}</div> |
| <div class="stat-label">Output Tokens</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value">${totalTokens}</div> |
| <div class="stat-label">Total Tokens</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value">${provider.credential_count || 0}</div> |
| <div class="stat-label">Accounts</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value" style="color: var(--accent-green)">${provider.active_count || 0}</div> |
| <div class="stat-label">Active</div> |
| </div> |
| <div class="stat-box"> |
| <div class="stat-value" style="color: var(--accent-red)">${provider.exhausted_count || 0}</div> |
| <div class="stat-label">Exhausted</div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="accounts-section"> |
| `; |
| |
| // Masked email patterns based on credential index |
| const maskedEmails = [ |
| 'cr******68@gmail.com', |
| 'ow******88@gmail.com', |
| 'ba******92@gmail.com' |
| ]; |
| |
| const credentials = provider.credentials || []; |
| if (credentials.length === 0) { |
| html += '<div style="color: var(--text-secondary); padding: 20px; text-align: center;">No credentials configured</div>'; |
| } |
| |
| credentials.forEach((cred, index) => { |
| const credTokens = cred.tokens || {}; |
| const credTotalTokens = (credTokens.input_uncached || 0) + (credTokens.input_cached || 0) + (credTokens.output || 0); |
| // Use built-in masked email if available, else fallback |
| const maskedEmail = maskedEmails[index] || `Account #${index + 1}`; |
| |
| html += ` |
| <div class="account-card"> |
| <div class="account-header"> |
| <div class="account-name"> |
| 👤 ${maskedEmail} |
| <span class="account-tier">${cred.tier || 'unknown'}</span> |
| </div> |
| <div class="account-status"> |
| <span class="status-dot ${getStatusClass(cred.status)}"></span> |
| ${cred.status || 'unknown'} |
| </div> |
| </div> |
| <div class="account-body"> |
| <div class="account-stats"> |
| <div class="mini-stat"> |
| <div class="mini-stat-value">${cred.requests || 0}</div> |
| <div class="mini-stat-label">Requests</div> |
| </div> |
| <div class="mini-stat"> |
| <div class="mini-stat-value">${credTokens.input_uncached || 0}</div> |
| <div class="mini-stat-label">Input</div> |
| </div> |
| <div class="mini-stat"> |
| <div class="mini-stat-value">${credTokens.output || 0}</div> |
| <div class="mini-stat-label">Output</div> |
| </div> |
| <div class="mini-stat"> |
| <div class="mini-stat-value">${credTotalTokens}</div> |
| <div class="mini-stat-label">Total</div> |
| </div> |
| </div> |
| <div class="models-list"> |
| `; |
| |
| // Show individual models from cred.models |
| const models = cred.models || {}; |
| const modelEntries = Object.entries(models).sort((a, b) => { |
| // Sort by usage (most used first) |
| return (b[1].request_count || 0) - (a[1].request_count || 0); |
| }); |
| |
| modelEntries.forEach(([modelName, modelStats]) => { |
| // Remove provider prefix for cleaner display |
| const shortName = modelName.replace(`${providerName}/`, ''); |
| const used = modelStats.request_count || 0; |
| const max = modelStats.quota_max_requests || 0; |
| const pct = max > 0 ? (used / max) * 100 : 0; |
| const resetTime = modelStats.quota_reset_ts; |
| |
| html += ` |
| <div class="model-row"> |
| <div class="model-name" title="${modelName}">${shortName}</div> |
| <div class="progress-bar"> |
| <div class="progress-fill ${getProgressColor(used, max || 1)}" style="width: ${pct}%"></div> |
| </div> |
| <div class="quota-text">${used}/${max}</div> |
| <div class="reset-time">${formatResetTime(resetTime)}</div> |
| </div> |
| `; |
| }); |
| |
| // If no models, show placeholder |
| if (modelEntries.length === 0) { |
| html += '<div style="color: var(--text-secondary); font-size: 0.85rem;">No usage data yet</div>'; |
| } |
| |
| html += ` |
| </div> |
| </div> |
| </div> |
| `; |
| }); |
| |
| html += '</div>'; |
| return html; |
| } |
| |
| function startCountdown() { |
| countdownInterval = setInterval(() => { |
| countdown--; |
| document.getElementById('countdown').textContent = countdown; |
| |
| if (countdown <= 0) { |
| countdown = 10; |
| fetchData(); |
| } |
| }, 1000); |
| } |
| |
| // Initial load |
| fetchData(); |
| startCountdown(); |
| </script> |
| </body> |
|
|
| </html> |