Files
sidecar-v2/dashboard.html
T
vincent 8531a3b595 feat: dashboard UX optimization + real-time backend stats + health probe fix + pool shuffle
- dashboard.html: major UX overhaul (+657/-308 lines)
- server.py: /api/admin/backends now returns real-time RPM and model_count
- pool_manager.py: random.shuffle backends for load distribution
- config.py: health probe endpoint /v1/models → /models
- docker-compose.yml: add SIDECAR_PRIMARY_WAIT_MAX_RETRIES=6

BIZ-52 post-review optimizations
2026-07-03 16:32:42 +08:00

953 lines
39 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sidecar V2 — Provider Pool Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
(function() {
var check = function() {
if (typeof Chart === 'undefined') {
var s = document.createElement('script');
s.src = '/static/chart.umd.min.js';
s.onerror = function() { console.warn('Chart.js unavailable (CDN + local both failed). Charts disabled.'); };
document.head.appendChild(s);
}
};
setTimeout(check, 2000);
})();
</script>
<style>
:root {
--bg: #0f1117; --bg2: #1a1d27; --bg3: #232734;
--border: #2e3344; --text: #e0e2e8; --text2: #9ca3af;
--primary: #3B82F6; --primary2: #2563EB;
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
--cooling: #f59e0b; --purple: #a855f7; --cyan: #06b6d4;
--radius: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
display: flex; height: 100vh; overflow: hidden;
}
/* Sidebar */
aside {
width: 240px; background: var(--bg2); border-right: 1px solid var(--border);
display: flex; flex-direction: column; flex-shrink: 0;
}
.logo { padding: 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
.logo-icon {
width: 32px; height: 32px; background: var(--primary); border-radius: 8px;
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
.logo h1 { font-size: 16px; font-weight: 600; }
.logo span { font-size: 11px; color: var(--text2); }
nav { flex: 1; padding: 12px 8px; }
nav a {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border-radius: var(--radius); text-decoration: none; color: var(--text2);
font-size: 14px; transition: all .15s; cursor: pointer;
}
nav a:hover { background: var(--bg3); color: var(--text); }
nav a.active { background: var(--primary2); color: #fff; }
.nav-badge { margin-left: auto; font-size: 11px; padding: 2px 7px; border-radius: 10px; font-weight: 600; }
.nav-badge.ok { background: var(--green); color: #000; }
.nav-badge.warn { background: var(--yellow); color: #000; }
.sidebar-footer { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text2); }
.sidebar-footer .status { display: flex; align-items: center; gap: 6px; }
.sidebar-footer .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
/* Main */
main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
header {
padding: 16px 24px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; background: var(--bg2);
}
header .tl h2 { font-size: 18px; font-weight: 600; }
header .tl p { font-size: 12px; color: var(--text2); margin-top: 2px; }
header .tr { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--text2); }
.content { flex: 1; overflow-y: auto; padding: 24px; }
.page { display: none; }
.page.active { display: block; }
/* Stat Cards */
.stat-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; position: relative;
}
.stat-card .label { font-size: 12px; color: var(--text2); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 28px; font-weight: 700; }
.stat-card .sub { font-size: 12px; color: var(--text2); margin-top: 4px; }
/* Pool Grid */
.pool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.pool-card {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 18px;
}
.pool-card h3 { font-size: 15px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
.pool-card h3.primary { color: var(--primary); }
.pool-card h3.fallback { color: var(--yellow); }
.pool-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.pool-stat { text-align: center; }
.pool-stat .num { font-size: 22px; font-weight: 700; }
.pool-stat .lbl { font-size: 11px; color: var(--text2); margin-top: 2px; }
.pool-stat.healthy .num { color: var(--green); }
.pool-stat.cooling .num { color: var(--yellow); }
.pool-stat.error .num { color: var(--red); }
.pool-stat.total .num { color: var(--primary); }
/* Tables */
table { width: 100%; border-collapse: collapse; background: var(--bg2); border-radius: var(--radius); overflow: hidden; }
th {
text-align: left; padding: 10px 12px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--text2); background: rgba(255,255,255,0.03);
border-bottom: 1px solid var(--border); font-weight: 500;
}
td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:hover { background: rgba(255,255,255,0.02); }
/* Expandable sub-row */
.sub-row { background: var(--bg3); }
.sub-row td { padding: 0; }
.sub-row-content { padding: 8px 12px 12px; }
.model-mini-table { width: 100%; }
.model-mini-table th { font-size: 10px; padding: 4px 8px; background: transparent; border-bottom: 1px solid var(--border); }
.model-mini-table td { padding: 4px 8px; font-size: 11px; border-bottom: 1px solid rgba(46,51,68,0.5); }
.model-mini-table tr:last-child td { border-bottom: none; }
/* Badges */
.badge {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px;
border-radius: 20px; font-size: 11px; font-weight: 600;
}
.badge.healthy { background: rgba(34,197,94,0.15); color: var(--green); }
.badge.cooling { background: rgba(245,158,11,0.15); color: var(--cooling); }
.badge.error { background: rgba(239,68,68,0.15); color: var(--red); }
.badge.disabled { background: rgba(156,163,175,0.15); color: var(--text2); }
.badge.primary { background: rgba(59,130,246,0.15); color: var(--primary); }
.badge.fallback { background: rgba(234,179,8,0.15); color: var(--yellow); }
/* Tag */
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 3px; }
.tag.true { background: rgba(34,197,94,0.15); color: var(--green); }
.tag.false { background: rgba(156,163,175,0.15); color: var(--text2); }
/* Progress Bar */
.progress-bar { width: 80px; height: 6px; background: var(--bg3); border-radius: 3px; overflow: hidden; display: inline-block; vertical-align: middle; margin-right: 6px; }
.progress-bar .fill { height: 100%; border-radius: 3px; transition: width .3s; }
.fill.low { background: var(--green); } .fill.mid { background: var(--yellow); } .fill.high { background: var(--red); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px;
border-radius: var(--radius); border: none; cursor: pointer;
font-size: 12px; font-weight: 500; transition: all .15s; white-space: nowrap;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary2); }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-outline:hover { background: var(--bg3); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-action {
background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 3px 10px; font-size: 11px; border-radius: var(--radius); cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.btn-action:hover { background: var(--bg3); }
.btn-pause { border-color: var(--yellow); color: var(--yellow); }
.btn-pause:hover { background: rgba(234,179,8,0.12); }
.btn-resume { border-color: var(--green); color: var(--green); }
.btn-resume:hover { background: rgba(34,197,94,0.12); }
.btn-edit { border-color: var(--primary); color: var(--primary); }
.btn-edit:hover { background: rgba(59,130,246,0.12); }
.btn-del { border-color: var(--red); color: var(--red); }
.btn-del:hover { background: rgba(239,68,68,0.1); }
.btn-group { display: flex; gap: 5px; flex-wrap: wrap; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.section-header h3 { font-size: 15px; font-weight: 600; }
/* Expand Icon */
.expand-icon { font-size: 12px; transition: transform .2s; display: inline-block; cursor: pointer; }
.expand-icon.open { transform: rotate(90deg); }
/* Charts */
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
.chart-box {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
}
.chart-box h4 { font-size: 13px; margin-bottom: 12px; color: var(--text2); }
.chart-box canvas { max-height: 220px; }
.chart-full { grid-column: span 2; }
/* Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 100; align-items: flex-start; justify-content: center; padding-top: 40px;
}
.modal-overlay.show { display: flex; }
.modal {
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
width: 720px; max-height: 85vh; overflow-y: auto; padding: 24px;
}
.modal h3 { font-size: 16px; margin-bottom: 20px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 12px; color: var(--text2); margin-bottom: 5px; font-weight: 500; }
.form-group input, .form-group select, .form-group textarea {
width: 100%; background: var(--bg3); border: 1px solid var(--border);
color: var(--text); padding: 8px 12px; border-radius: var(--radius); font-size: 13px;
}
.form-group textarea { min-height: 120px; font-family: 'SF Mono','Cascadia Code',monospace; font-size: 12px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row.three { grid-template-columns: 1fr 1fr 1fr; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
/* Utility */
.text-green { color: var(--green); }
.text-red { color: var(--red); }
.text-dim { color: var(--text2); }
.mb-16 { margin-bottom: 16px; }
.mb-24 { margin-bottom: 24px; }
.mt-24 { margin-top: 24px; }
.section-title { font-size: 15px; font-weight: 600; margin: 24px 0 12px; }
/* Refresh */
.refresh-indicator { font-size: 11px; color: var(--text2); }
@media (max-width: 768px) {
.stat-cards, .pool-grid, .chart-grid { grid-template-columns: 1fr; }
.chart-full { grid-column: span 1; }
aside { display: none; }
.btn-group { width: 100%; }
}
</style>
</head>
<body>
<aside>
<div class="logo">
<div class="logo-icon"></div>
<div><h1>Sidecar V2</h1><span>Provider Pool Manager</span></div>
</div>
<nav>
<a class="active" data-page="dashboard">📊 Dashboard</a>
<a data-page="providers">🔌 Providers <span class="nav-badge ok" id="provider-count">0</span></a>
<a data-page="usage">📈 Usage Stats</a>
<a data-page="cooldown">🧊 Cooldown Log</a>
</nav>
<div class="sidebar-footer">
<div class="status"><span class="dot" id="status-dot"></span> <span id="status-text">Connected</span></div>
<div id="uptime-text">Uptime: --</div>
</div>
</aside>
<main>
<header>
<div class="tl"><h2 id="page-title">📊 Dashboard</h2><p id="page-sub">Provider 池全局概览</p></div>
<div class="tr">
<span id="last-refresh">--</span>
<button class="btn btn-sm btn-outline" onclick="refreshAll()">🔄 Refresh</button>
<button class="btn btn-sm btn-outline" onclick="setAdminToken()" title="Set Admin Token">🔑</button>
</div>
</header>
<div class="content">
<!-- ====== Dashboard Page ====== -->
<div class="page active" id="page-dashboard">
<!-- Stat Cards -->
<div class="stat-cards" id="stat-cards">
<div class="stat-card"><div class="label">Total Requests</div><div class="value" id="stat-req">--</div><div class="sub">Error rate: <span id="stat-err">--</span></div></div>
<div class="stat-card"><div class="label">Total Tokens</div><div class="value" id="stat-tokens">--</div><div class="sub">Prompt: <span id="stat-prompt">--</span> · Completion: <span id="stat-compl">--</span></div></div>
<div class="stat-card"><div class="label">Total Cost</div><div class="value" style="color:var(--yellow)" id="stat-cost">--</div><div class="sub">USD</div></div>
<div class="stat-card"><div class="label">Uptime</div><div class="value" id="stat-uptime">--</div><div class="sub">Sidecar V2</div></div>
</div>
<!-- Pool Summary -->
<div class="pool-grid" id="pool-grid">
<div class="pool-card"><h3 class="primary">Primary Pool</h3><div class="pool-stats"><div class="pool-stat total"><div class="num">--</div><div class="lbl">Total</div></div><div class="pool-stat healthy"><div class="num">--</div><div class="lbl">Healthy</div></div><div class="pool-stat cooling"><div class="num">--</div><div class="lbl">Cooling</div></div><div class="pool-stat error"><div class="num">--</div><div class="lbl">Error</div></div></div></div>
<div class="pool-card"><h3 class="fallback">Fallback Pool</h3><div class="pool-stats"><div class="pool-stat total"><div class="num">--</div><div class="lbl">Total</div></div><div class="pool-stat healthy"><div class="num">--</div><div class="lbl">Healthy</div></div><div class="pool-stat cooling"><div class="num">--</div><div class="lbl">Cooling</div></div><div class="pool-stat error"><div class="num">--</div><div class="lbl">Error</div></div></div></div>
</div>
<!-- Charts -->
<div class="chart-grid">
<div class="chart-box"><h4>Cost Trend (7 days)</h4><canvas id="cost-chart"></canvas></div>
<div class="chart-box"><h4>Token Usage Trend (7 days)</h4><canvas id="token-chart"></canvas></div>
</div>
<!-- Provider Status Table -->
<div class="section-title">Provider Status</div>
<table id="dashboard-backends-table">
<thead><tr><th>Name</th><th>Label</th><th>Pool</th><th>Models</th><th>Status</th><th>RPM</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- ====== Providers Page ====== -->
<div class="page" id="page-providers">
<div class="section-header">
<h3>Provider Backends</h3>
<button class="btn btn-primary" onclick="showAddBackend()">+ Add Provider</button>
</div>
<table id="backends-table">
<thead>
<tr><th style="width:30px"></th><th>Name</th><th>Label</th><th>Pool</th><th>Status</th><th>RPM</th><th>Models</th><th>Actions</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- ====== Usage Page ====== -->
<div class="page" id="page-usage">
<div class="section-header"><h3>Hourly Usage Statistics</h3></div>
<div class="mb-16">
<select id="usage-backend-filter" onchange="loadUsage()" class="btn btn-outline btn-sm">
<option value="">All Backends</option>
</select>
</div>
<table id="usage-table">
<thead><tr><th>Hour</th><th>Backend</th><th>Model</th><th>Requests</th><th>Errors</th><th>Tokens</th><th>Cost</th><th>Avg Latency</th></tr></thead>
<tbody></tbody>
</table>
<div class="section-title mt-24">Daily Aggregation</div>
<table id="daily-table">
<thead><tr><th>Date</th><th>Pool</th><th>Requests</th><th>Errors</th><th>Tokens</th><th>Cost</th><th>Backends</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- ====== Cooldown Page ====== -->
<div class="page" id="page-cooldown">
<div class="section-header"><h3>Cooldown Event History</h3></div>
<table id="cooldown-table">
<thead><tr><th>Time</th><th>Backend</th><th>Consecutive 429s</th><th>Duration</th><th>Summary</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div><!-- .content -->
</main>
<!-- ====== Add/Edit Backend Modal ====== -->
<div class="modal-overlay" id="backend-modal">
<div class="modal" id="backend-modal-content">
<h3 id="modal-title">Add Provider</h3>
<form id="backend-form" onsubmit="saveBackend(event)">
<input type="hidden" id="backend-id">
<div class="form-row">
<div class="form-group">
<label>Name *</label>
<input type="text" id="backend-name" placeholder="e.g. NVIDIA Key 1" required>
</div>
<div class="form-group">
<label>Label</label>
<input type="text" id="backend-label" placeholder="e.g. nvidia, siliconflow">
</div>
</div>
<div class="form-group">
<label>API Base URL *</label>
<input type="url" id="backend-url" placeholder="https://api.example.com/v1" required>
</div>
<div class="form-group">
<label>API Key *</label>
<input type="password" id="backend-key" placeholder="sk-..." required>
</div>
<div class="form-row">
<div class="form-group">
<label>API Protocol</label>
<select id="backend-api">
<option value="openai-completions">openai-completions</option>
</select>
</div>
<div class="form-group">
<label>Pool</label>
<select id="backend-pool">
<option value="primary">Primary</option>
<option value="fallback">Fallback</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>RPM Limit</label>
<input type="number" id="backend-rpm" value="40" min="1" max="1000">
</div>
<div class="form-group">
<label>Timeout (seconds)</label>
<input type="number" id="backend-timeout" value="120" min="10" max="600">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Enabled</label>
<select id="backend-enabled">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<div class="form-group">
<label>Model Mappings (JSON: canonical → {native_id, reasoning, cost, context_window, max_tokens, ...})</label>
<textarea id="backend-mappings" placeholder='{"deepseek-ai/deepseek-v4-pro":{"native_id":"deepseek-ai/deepseek-v4-pro","reasoning":true,"cost":{"input":12,"output":24,"cacheRead":0.025,"cacheWrite":12},"context_window":1000000,"max_tokens":262144,"input_modalities":["text"]}}'></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-outline" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script>
// ===== Navigation =====
document.querySelectorAll('nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
document.querySelectorAll('nav a').forEach(l => l.classList.remove('active'));
a.classList.add('active');
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + a.dataset.page).classList.add('active');
// Update header
const titles = {
dashboard: ['📊 Dashboard', 'Provider 池全局概览'],
providers: ['🔌 Providers', 'Provider 后端管理与模型映射配置'],
usage: ['📈 Usage Stats', '按小时/按天统计的请求量、Token 消耗与费用'],
cooldown: ['🧊 Cooldown Log', '429 冷却事件历史记录']
};
document.getElementById('page-title').textContent = titles[a.dataset.page][0];
document.getElementById('page-sub').textContent = titles[a.dataset.page][1];
loadPage(a.dataset.page);
});
});
// ===== Auth =====
let ADMIN_TOKEN = localStorage.getItem('sidecar_admin_token') || 'c4571baa6b210235aa72a4b18630fb9806ec68f23628e127';
function setAdminToken() {
const token = prompt('Enter Admin Token (SIDECAR_ADMIN_TOKEN):', ADMIN_TOKEN);
if (token) {
ADMIN_TOKEN = token;
localStorage.setItem('sidecar_admin_token', token);
alert('Admin token saved.');
}
}
function authFetch(url, options = {}) {
if (!options.headers) options.headers = {};
// Only add auth for write operations
if (options.method && options.method !== 'GET') {
options.headers['Authorization'] = 'Bearer ' + ADMIN_TOKEN;
}
return fetch(url, options);
}
// ===== SSE Connection =====
let sseData = null;
const sse = new EventSource('/dashboard/sse');
sse.onmessage = e => {
sseData = JSON.parse(e.data);
if (sseData.type === 'snapshot') updateDashboard(sseData);
};
sse.onerror = () => {
document.getElementById('status-text').textContent = 'Disconnected';
document.getElementById('status-dot').style.background = 'var(--red)';
};
sse.onopen = () => {
document.getElementById('status-text').textContent = 'Connected';
document.getElementById('status-dot').style.background = 'var(--green)';
};
// ===== Dashboard Update =====
function updateDashboard(data) {
// Updates the live dashboard widgets (stat cards, pool grid, dashboard table)
document.getElementById('uptime-text').textContent = 'Uptime: ' + formatDuration(data.uptime_seconds);
const st = data.total || {};
const errRate = st.total_requests > 0 ? ((st.total_errors || 0) / st.total_requests * 100).toFixed(1) : '0.0';
document.getElementById('stat-req').textContent = fmt(st.total_requests);
document.getElementById('stat-err').textContent = errRate + '%';
document.getElementById('stat-tokens').textContent = fmt(st.total_tokens);
document.getElementById('stat-prompt').textContent = fmt(st.total_prompt_tokens);
document.getElementById('stat-compl').textContent = fmt(st.total_completion_tokens);
document.getElementById('stat-cost').textContent = '$' + (st.total_cost || 0).toFixed(4);
document.getElementById('stat-uptime').textContent = formatDuration(data.uptime_seconds);
// Pool grid
let poolHTML = '';
const backends = data.backends || [];
for (const [pool, ps] of Object.entries(data.pool || {})) {
poolHTML += `<div class="pool-card"><h3 class="${pool}">${pool}</h3>
<div class="pool-stats">
<div class="pool-stat total"><div class="num">${ps.total}</div><div class="lbl">Total</div></div>
<div class="pool-stat healthy"><div class="num">${ps.healthy}</div><div class="lbl">Healthy</div></div>
<div class="pool-stat cooling"><div class="num">${ps.cooling}</div><div class="lbl">Cooling</div></div>
<div class="pool-stat error"><div class="num">${ps.error}</div><div class="lbl">Error</div></div>
</div></div>`;
}
document.getElementById('pool-grid').innerHTML = poolHTML || '<div class="pool-card"><p class="text-dim">No pools configured</p></div>';
// Provider count badge
document.getElementById('provider-count').textContent = backends.length;
// Dashboard table
if (document.getElementById('page-dashboard').classList.contains('active')) {
renderDashboardTable(backends);
}
// Providers table
if (document.getElementById('page-providers').classList.contains('active')) {
renderBackendsTable(backends);
}
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
}
// ===== Dashboard Provider Table =====
function renderDashboardTable(backends) {
const tbody = document.querySelector('#dashboard-backends-table tbody');
tbody.innerHTML = backends.map(b => {
const rpmPct = b.rpm_limit > 0 ? Math.min(100, Math.round((b.stats?.rpm_current || 0) / b.rpm_limit * 100)) : 0;
const fillClass = rpmPct >= 80 ? 'high' : rpmPct >= 50 ? 'mid' : 'low';
const modelCount = getModelCount(b);
return `<tr>
<td><strong>${h(b.name)}</strong></td>
<td><span class="badge ${b.label ? 'primary' : ''}">${h(b.label || '-')}</span></td>
<td><span class="badge ${b.pool}">${b.pool}</span></td>
<td>${modelCount}</td>
<td><span class="badge ${b.enabled ? b.status : 'disabled'}">${b.enabled ? b.status : 'disabled'}</span></td>
<td><span class="progress-bar"><span class="fill ${fillClass}" style="width:${rpmPct}%"></span></span>${rpmPct}% <span class="text-dim">(${b.stats?.rpm_current || 0}/${b.rpm_limit})</span></td>
<td>${buildActionButtons(b)}</td>
</tr>`;
}).join('');
}
// ===== Providers Page =====
function renderBackendsTable(backends) {
const tbody = document.querySelector('#backends-table tbody');
tbody.innerHTML = backends.map((b, i) => {
const modelCount = getModelCount(b);
const rpmPct = b.rpm_limit > 0 ? Math.min(100, Math.round((b.stats?.rpm_current || 0) / b.rpm_limit * 100)) : 0;
const fillClass = rpmPct >= 80 ? 'high' : rpmPct >= 50 ? 'mid' : 'low';
return `
<tr id="row-${b.id}">
<td><span class="expand-icon" onclick="toggleExpand('${b.id}')" title="Expand Model Details">▶</span></td>
<td><strong>${h(b.name)}</strong></td>
<td><span class="badge ${b.label ? 'primary' : ''}">${h(b.label || '-')}</span></td>
<td><span class="badge ${b.pool}">${b.pool}</span></td>
<td><span class="badge ${b.enabled ? b.status : 'disabled'}">${b.enabled ? b.status : 'disabled'}</span></td>
<td><span class="progress-bar"><span class="fill ${fillClass}" style="width:${rpmPct}%"></span></span>${rpmPct}%</td>
<td>${modelCount}</td>
<td>${buildActionButtons(b)}</td>
</tr>
<tr class="sub-row" id="expand-${b.id}" style="display:none">
<td colspan="8"><div class="sub-row-content">${buildModelTable(b)}</div></td>
</tr>`;
}).join('');
}
function toggleExpand(id) {
const row = document.getElementById('expand-' + id);
const icon = document.querySelector(`#row-${id} .expand-icon`);
if (row.style.display === 'none') {
row.style.display = '';
if (icon) icon.classList.add('open');
} else {
row.style.display = 'none';
if (icon) icon.classList.remove('open');
}
}
function buildModelTable(b) {
const mappings = b.model_mappings || {};
const keys = Object.keys(mappings);
if (keys.length === 0) return '<p class="text-dim">No model mappings configured</p>';
return `<table class="model-mini-table"><thead><tr>
<th>Canonical Name</th><th>Native ID</th><th>Reasoning</th>
<th>Cost (in/out)</th><th>Context W.</th><th>Max Tokens</th><th>Modalities</th>
</tr></thead><tbody>
${keys.map(k => {
const m = mappings[k];
return `<tr>
<td><strong>${h(k)}</strong></td>
<td><code style="font-size:10px">${h(m.native_id || '-')}</code></td>
<td><span class="tag ${m.reasoning}">${m.reasoning ? '✅' : '❌'}</span></td>
<td>$${(m.cost?.input || 0).toFixed(2)} / $${(m.cost?.output || 0).toFixed(2)}</td>
<td>${fmt(m.context_window || 0)}</td>
<td>${fmt(m.max_tokens || 0)}</td>
<td>${(m.input_modalities || ['text']).map(t => `<span class="tag true">${h(t)}</span>`).join('')}</td>
</tr>`;
}).join('')}
</tbody></table>`;
}
// ===== Action Buttons (BIZ-62: Enable/Disable Toggle) =====
function buildActionButtons(b) {
let btns = '';
if (b.enabled) {
btns += `<button class="btn-action btn-pause" onclick="toggleBackend('${b.id}', false)" title="Pause this provider">⏸ Pause</button>`;
} else {
btns += `<button class="btn-action btn-resume" onclick="toggleBackend('${b.id}', true)" title="Resume this provider">▶ Resume</button>`;
}
btns += `<button class="btn-action btn-edit" onclick="editBackend('${b.id}')" title="Edit">✎ Edit</button>`;
btns += `<button class="btn-action btn-del" onclick="deleteBackend('${b.id}')" title="Delete">✕ Del</button>`;
return `<div class="btn-group">${btns}</div>`;
}
// ===== Enable/Disable Toggle (BIZ-62 core) =====
async function toggleBackend(id, enable) {
const verb = enable ? 'resume' : 'pause';
if (!confirm(`${enable ? '▶ Resume' : '⏸ Pause'} this provider?`)) return;
try {
const res = await authFetch('/api/admin/backends/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enable })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `${verb} failed`);
}
refreshAll();
} catch (e) {
alert(`Failed to ${verb} provider: ${e.message}`);
}
}
// ===== Modal: Add/Edit Backend =====
function showAddBackend() {
document.getElementById('modal-title').textContent = 'Add Provider';
document.getElementById('backend-id').value = '';
document.getElementById('backend-name').value = '';
document.getElementById('backend-label').value = '';
document.getElementById('backend-url').value = '';
document.getElementById('backend-key').value = '';
document.getElementById('backend-key').required = true;
document.getElementById('backend-key').placeholder = 'sk-...';
document.getElementById('backend-api').value = 'openai-completions';
document.getElementById('backend-pool').value = 'primary';
document.getElementById('backend-rpm').value = '40';
document.getElementById('backend-timeout').value = '120';
document.getElementById('backend-enabled').value = 'true';
document.getElementById('backend-mappings').value = '{}';
document.getElementById('backend-modal').classList.add('show');
}
async function editBackend(id) {
try {
const res = await fetch('/api/admin/backends/' + id);
const b = await res.json();
document.getElementById('modal-title').textContent = 'Edit Provider';
document.getElementById('backend-id').value = b.id;
document.getElementById('backend-name').value = b.name;
document.getElementById('backend-label').value = b.label || '';
document.getElementById('backend-url').value = b.api_base_url;
document.getElementById('backend-key').value = '';
document.getElementById('backend-key').placeholder = '(leave blank to keep current)';
document.getElementById('backend-key').required = false;
document.getElementById('backend-api').value = b.api || 'openai-completions';
document.getElementById('backend-pool').value = b.pool;
document.getElementById('backend-rpm').value = b.rpm_limit;
document.getElementById('backend-timeout').value = b.timeout_seconds;
document.getElementById('backend-enabled').value = b.enabled ? 'true' : 'false';
document.getElementById('backend-mappings').value = JSON.stringify(b.model_mappings || {}, null, 2);
document.getElementById('backend-modal').classList.add('show');
} catch (e) { alert('Failed to load backend: ' + e.message); }
}
async function saveBackend(e) {
e.preventDefault();
const id = document.getElementById('backend-id').value;
let mappings = {};
try {
mappings = JSON.parse(document.getElementById('backend-mappings').value || '{}');
} catch (err) {
alert('Invalid JSON in Model Mappings: ' + err.message);
return;
}
const body = {
name: document.getElementById('backend-name').value,
label: document.getElementById('backend-label').value,
api_base_url: document.getElementById('backend-url').value,
api: document.getElementById('backend-api').value,
pool: document.getElementById('backend-pool').value,
rpm_limit: parseInt(document.getElementById('backend-rpm').value),
timeout_seconds: parseInt(document.getElementById('backend-timeout').value),
enabled: document.getElementById('backend-enabled').value === 'true',
model_mappings: mappings,
};
const key = document.getElementById('backend-key').value;
if (key) body.api_key = key;
try {
const method = id ? 'PUT' : 'POST';
const url = id ? '/api/admin/backends/' + id : '/api/admin/backends';
const res = await authFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Save failed');
}
closeModal();
refreshAll();
} catch (err) { alert('Error: ' + err.message); }
}
async function deleteBackend(id) {
if (!confirm('Delete this provider? This action cannot be undone.')) return;
try {
const res = await authFetch('/api/admin/backends/' + id, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Delete failed');
}
refreshAll();
} catch (e) { alert('Delete failed: ' + e.message); }
}
function closeModal() { document.getElementById('backend-modal').classList.remove('show'); }
// Close modal on overlay click
document.getElementById('backend-modal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ===== Chart Management =====
let costChart = null, tokenChart = null;
function initCharts() {
const cc = document.getElementById('cost-chart');
const tc = document.getElementById('token-chart');
if (!cc || !tc) return;
if (costChart) costChart.destroy();
if (tokenChart) tokenChart.destroy();
costChart = new Chart(cc, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Cost (USD)',
data: [],
borderColor: '#06b6d4',
backgroundColor: 'rgba(6,182,212,0.1)',
fill: true, tension: 0.3, pointRadius: 0
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { labels: { color: '#9ca3af', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#9ca3af', maxTicksLimit: 12, font: { size: 10 } }, grid: { color: 'rgba(46,51,68,0.3)' } },
y: { ticks: { color: '#9ca3af', font: { size: 10 } }, grid: { color: 'rgba(46,51,68,0.3)' } }
}
}
});
tokenChart = new Chart(tc, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Total Tokens',
data: [],
borderColor: '#a855f7',
backgroundColor: 'rgba(168,85,247,0.1)',
fill: true, tension: 0.3, pointRadius: 0
}]
},
options: {
responsive: true, maintainAspectRatio: true,
plugins: { legend: { labels: { color: '#9ca3af', font: { size: 11 } } } },
scales: {
x: { ticks: { color: '#9ca3af', maxTicksLimit: 12, font: { size: 10 } }, grid: { color: 'rgba(46,51,68,0.3)' } },
y: { ticks: { color: '#9ca3af', font: { size: 10 } }, grid: { color: 'rgba(46,51,68,0.3)' } }
}
}
});
}
async function loadChartData() {
try {
const res = await fetch('/api/admin/stats/hourly?hours=168');
const data = await res.json();
const byHour = {};
data.forEach(r => {
const hour = r.hour_bucket.slice(0, 13);
if (!byHour[hour]) byHour[hour] = { cost: 0, tokens: 0 };
byHour[hour].cost += (r.cost || 0);
byHour[hour].tokens += (r.total_tokens || 0);
});
const hours = Object.keys(byHour).sort();
if (hours.length === 0) return;
const costs = hours.map(h => byHour[h].cost);
const tokens = hours.map(h => byHour[h].tokens);
const labels = hours.map(h => {
const d = new Date(h + ':00Z');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
});
if (costChart) {
costChart.data.labels = labels;
costChart.data.datasets[0].data = costs;
costChart.update();
}
if (tokenChart) {
tokenChart.data.labels = labels;
tokenChart.data.datasets[0].data = tokens;
tokenChart.update();
}
} catch (e) { console.error('Chart data load failed:', e); }
}
// ===== Usage / Cooldown Pages =====
async function loadUsageFilter() {
try {
const res = await fetch('/api/admin/backends');
const backends = await res.json();
const sel = document.getElementById('usage-backend-filter');
sel.innerHTML = '<option value="">All Backends</option>' +
backends.map(b => `<option value="${b.id}">${h(b.name)}</option>`).join('');
} catch (e) {}
}
async function loadUsage() {
const sel = document.getElementById('usage-backend-filter');
const backendId = sel.value;
const url = backendId
? `/api/admin/stats/hourly?backend_id=${backendId}&hours=72`
: '/api/admin/stats/hourly?hours=72';
try {
const res = await fetch(url);
const data = await res.json();
const tbody = document.querySelector('#usage-table tbody');
tbody.innerHTML = data.slice(0, 200).map(r => `
<tr>
<td>${r.hour_bucket}</td>
<td>${r.backend_id}</td>
<td><code style="font-size:11px">${h(r.model || '-')}</code></td>
<td>${fmt(r.request_count)}</td>
<td class="${r.error_count > 0 ? 'text-red' : 'text-green'}">${r.error_count}</td>
<td>${fmt(r.total_tokens)}</td>
<td>$${(r.cost || 0).toFixed(6)}</td>
<td>${r.avg_latency_ms}ms</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
async function loadDaily() {
try {
const res = await fetch('/api/admin/stats/daily?days=30');
const data = await res.json();
const tbody = document.querySelector('#daily-table tbody');
tbody.innerHTML = data.map(r => `
<tr>
<td>${r.date}</td>
<td><span class="badge ${r.pool}">${r.pool}</span></td>
<td>${fmt(r.total_requests)}</td>
<td>${fmt(r.total_errors)}</td>
<td>${fmt(r.total_tokens)}</td>
<td>$${(r.total_cost || 0).toFixed(6)}</td>
<td>${r.unique_backends}</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
async function loadCooldown() {
try {
const res = await fetch('/api/admin/stats/cooldown?limit=100');
const data = await res.json();
const tbody = document.querySelector('#cooldown-table tbody');
tbody.innerHTML = data.map(r => `
<tr>
<td>${r.started_at}</td>
<td>${r.backend_id}</td>
<td><span class="badge ${r.consecutive_count >= 3 ? 'error' : 'cooling'}">${r.consecutive_count}</span></td>
<td>${r.cooldown_seconds}s</td>
<td>${h(r.response_summary || '-')}</td>
</tr>`).join('');
} catch (e) { console.error(e); }
}
// ===== Page Loading =====
async function loadPage(page) {
switch (page) {
case 'dashboard':
initCharts();
loadChartData();
refreshAll(); // ensure dashboard table is populated
break;
case 'providers':
refreshAll();
break;
case 'usage':
loadUsageFilter();
loadUsage();
loadDaily();
break;
case 'cooldown':
loadCooldown();
break;
}
}
async function refreshAll() {
try {
const res = await fetch('/api/admin/backends');
const backends = await res.json();
document.getElementById('provider-count').textContent = backends.length;
if (document.getElementById('page-providers').classList.contains('active')) {
renderBackendsTable(backends);
}
if (document.getElementById('page-dashboard').classList.contains('active')) {
renderDashboardTable(backends);
}
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
} catch (e) { console.error('refreshAll failed:', e); }
}
// ===== Helpers =====
function getModelCount(b) {
const mm = b.model_mappings;
if (!mm) return 0;
return typeof mm === 'object' ? Object.keys(mm).length : 0;
}
function fmt(n) { return (n || 0).toLocaleString(); }
function h(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function formatDuration(s) {
const d = Math.floor(s / 86400), hr = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
const parts = [];
if (d) parts.push(d + 'd');
if (hr) parts.push(hr + 'h');
if (m || !parts.length) parts.push(m + 'm');
return parts.join(' ');
}
// ===== Initialize =====
document.addEventListener('DOMContentLoaded', () => {
// Ensure chart containers
const chartsDiv = document.getElementById('chart-grid') || document.querySelector('.chart-grid');
initCharts();
loadChartData();
refreshAll();
});
</script>
</body>
</html>