bardd's picture
Update src/proxy_app/static/dashboard.html
edc838a verified
<!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>