| | <!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> |