feat(sidecar-v2): implement multi-pool provider proxy with cooldown, rate limiting, WebUI
BIZ-52 Step3 开发实现: - storage: backend/usage/cooldown/config CRUD with SQLite WAL - crypto: AES-256-GCM API key encryption - pool_manager: primary/fallback pool routing - cooldown_manager: 429 exponential backoff cooldown - rate_limiter: per-backend token bucket RPM control - router: model → backend routing with pool priority - proxy: multi-pool request forwarding with retry - server: FastAPI admin API + OpenAI-compatible proxy + SSE - dashboard: WebUI with provider CRUD, stats, charts Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
<!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>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card-bg: #1a1d28;
|
||||
--border: #2a2d3a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #888;
|
||||
--green: #23d160;
|
||||
--yellow: #ffdd57;
|
||||
--red: #ff3860;
|
||||
--blue: #3273dc;
|
||||
--purple: #b86bff;
|
||||
--cyan: #00d1b2;
|
||||
--orange: #ff8533;
|
||||
}
|
||||
* { 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);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app { display: flex; height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px; background: var(--card-bg); border-right: 1px solid var(--border);
|
||||
padding: 20px 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar h2 { padding: 0 20px 20px; font-size: 16px; color: var(--cyan); border-bottom: 1px solid var(--border); }
|
||||
.sidebar nav { flex: 1; padding: 10px 0; }
|
||||
.sidebar nav a {
|
||||
display: block; padding: 10px 20px; color: var(--text-dim); text-decoration: none;
|
||||
font-size: 13px; transition: 0.2s;
|
||||
}
|
||||
.sidebar nav a:hover, .sidebar nav a.active { color: var(--text); background: rgba(255,255,255,0.05); }
|
||||
.sidebar .status-bar { padding: 15px 20px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
.main { flex: 1; overflow-y: auto; padding: 24px; }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
/* Dashboard Cards */
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.card .label { font-size: 12px; color: var(--text-dim); text-transform: uppercase;letter-spacing:0.5px;margin-bottom:6px; }
|
||||
.card .value { font-size: 28px; font-weight: 700; }
|
||||
.card .sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
||||
|
||||
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.chart-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.chart-card h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-dim); }
|
||||
.chart-card canvas { max-height: 250px; }
|
||||
|
||||
/* Pool Cards */
|
||||
.pool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||||
.pool-card {
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.pool-card h3 { font-size: 15px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.pool-card h3.primary { color: var(--blue); }
|
||||
.pool-card h3.fallback { color: var(--orange); }
|
||||
.pool-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.pool-stat { text-align: center; }
|
||||
.pool-stat .num { font-size: 22px; font-weight: 700; }
|
||||
.pool-stat .lbl { font-size: 11px; color: var(--text-dim); 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(--purple); }
|
||||
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; }
|
||||
th { text-align: left; padding: 10px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); background: rgba(255,255,255,0.03); border-bottom: 1px solid var(--border); }
|
||||
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); }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.badge.healthy { background: rgba(35,209,96,0.15); color: var(--green); }
|
||||
.badge.cooling { background: rgba(255,221,87,0.15); color: var(--yellow); }
|
||||
.badge.error { background: rgba(255,56,96,0.15); color: var(--red); }
|
||||
.badge.disabled { background: rgba(136,136,136,0.15); color: var(--text-dim); }
|
||||
.badge.primary { background: rgba(50,115,220,0.15); color: var(--blue); }
|
||||
.badge.fallback { background: rgba(255,133,51,0.15); color: var(--orange); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: 600;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.btn-primary { background: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-danger:hover { opacity: 0.85; }
|
||||
.btn-sm { padding: 3px 10px; font-size: 11px; }
|
||||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-outline:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.section-header h3 { font-size: 15px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: 560px; max-height: 80vh; overflow-y: auto; }
|
||||
.modal h3 { margin-bottom: 16px; font-size: 16px; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 6px; color: var(--text); font-size: 13px;
|
||||
}
|
||||
.form-group textarea { min-height: 80px; font-family: monospace; font-size: 12px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
||||
.model-mapping-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.model-mapping-row input { flex: 1; }
|
||||
|
||||
/* Utility */
|
||||
.text-green { color: var(--green); }
|
||||
.text-red { color: var(--red); }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.mb-24 { margin-bottom: 24px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts, .pool-grid { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<h2>🚀 Sidecar V2</h2>
|
||||
<nav>
|
||||
<a href="#" data-page="dashboard" class="active">📊 Dashboard</a>
|
||||
<a href="#" data-page="providers">🔌 Providers</a>
|
||||
<a href="#" data-page="usage">📈 Usage Stats</a>
|
||||
<a href="#" data-page="cooldown">🧊 Cooldown Log</a>
|
||||
</nav>
|
||||
<div class="status-bar" id="status-bar">Connected · Sidecar V2</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main">
|
||||
<!-- Dashboard Page -->
|
||||
<div class="page active" id="page-dashboard">
|
||||
<div class="cards" id="stat-cards"></div>
|
||||
<div class="pool-grid" id="pool-grid"></div>
|
||||
<div class="charts" id="charts"></div>
|
||||
</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>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</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-header mt-24 mb-16"><h3>Daily Aggregation</h3></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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Backend Modal -->
|
||||
<div class="modal-overlay" id="backend-modal">
|
||||
<div class="modal">
|
||||
<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 H100 Primary" 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://integrate.api.nvidia.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>Pool</label>
|
||||
<select id="backend-pool">
|
||||
<option value="primary">Primary</option>
|
||||
<option value="fallback">Fallback</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>RPM Limit</label>
|
||||
<input type="number" id="backend-rpm" value="40" min="1" max="1000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Timeout (seconds)</label>
|
||||
<input type="number" id="backend-timeout" value="120" min="10" max="600">
|
||||
</div>
|
||||
<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, cost, ...})</label>
|
||||
<textarea id="backend-mappings" placeholder='{"deepseek-ai/DeepSeek-V4-Pro":{"native_id":"deepseek-ai/deepseek-v4-pro","cost":{"input":0.000001,"output":0.000004}}}'></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('.sidebar nav a').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.sidebar 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');
|
||||
loadPage(a.dataset.page);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SSE Connection ──
|
||||
const sse = new EventSource('/dashboard/sse');
|
||||
sse.onmessage = e => {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'snapshot') updateDashboard(data);
|
||||
};
|
||||
|
||||
sse.onerror = () => {
|
||||
document.getElementById('status-bar').textContent = '⚠️ SSE Disconnected';
|
||||
};
|
||||
|
||||
// ── Dashboard Update ──
|
||||
let costChart = null, tokenChart = null;
|
||||
|
||||
function updateDashboard(data) {
|
||||
document.getElementById('status-bar').textContent =
|
||||
`⚡ Connected · Uptime ${formatDuration(data.uptime_seconds)}`;
|
||||
|
||||
// Stat cards
|
||||
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-cards').innerHTML = `
|
||||
<div class="card"><div class="label">Total Requests</div><div class="value">${fmt(st.total_requests)}</div><div class="sub">Error rate: ${errRate}%</div></div>
|
||||
<div class="card"><div class="label">Total Tokens</div><div class="value">${fmt(st.total_tokens)}</div><div class="sub">Prompt: ${fmt(st.total_prompt_tokens)} · Completion: ${fmt(st.total_completion_tokens)}</div></div>
|
||||
<div class="card"><div class="label">Total Cost</div><div class="value">$${st.total_cost ? st.total_cost.toFixed(4) : '0.0000'}</div><div class="sub">USD</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${formatDuration(data.uptime_seconds)}</div><div class="sub">Sidecar V2</div></div>
|
||||
`;
|
||||
|
||||
// Pool grid
|
||||
let poolHTML = '';
|
||||
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="card">No pools configured</div>';
|
||||
|
||||
// Update backend table if on providers page
|
||||
if (document.getElementById('page-providers').classList.contains('active')) {
|
||||
renderBackendsTable(data.backends || []);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chart Updates (use SSE data to build chart data) ──
|
||||
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: '#00d1b2', backgroundColor: 'rgba(0,209,178,0.1)', fill: true, tension: 0.3 }] },
|
||||
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { labels: { color: '#888' } } }, scales: { x: { ticks: { color: '#888', maxTicksLimit: 12 } }, y: { ticks: { color: '#888' } } } }
|
||||
});
|
||||
|
||||
tokenChart = new Chart(tc, {
|
||||
type: 'line', data: { labels: [], datasets: [{ label: 'Total Tokens', data: [], borderColor: '#b86bff', backgroundColor: 'rgba(184,107,255,0.1)', fill: true, tension: 0.3 }] },
|
||||
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { labels: { color: '#888' } } }, scales: { x: { ticks: { color: '#888', maxTicksLimit: 12 } }, y: { ticks: { color: '#888' } } } }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Providers Page ──
|
||||
function renderBackendsTable(backends) {
|
||||
const tbody = document.querySelector('#backends-table tbody');
|
||||
tbody.innerHTML = backends.map(b => `
|
||||
<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><span class="badge ${b.status}">${b.status}</span></td>
|
||||
<td>${b.rpm_limit}</td>
|
||||
<td>${b.model_count || 0}</td>
|
||||
<td>
|
||||
<button class="btn btn-outline btn-sm" onclick="editBackend('${b.id}')">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteBackend('${b.id}')">Del</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
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-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('active');
|
||||
}
|
||||
|
||||
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-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('active');
|
||||
} catch (e) { alert('Failed to load backend: ' + e.message); }
|
||||
}
|
||||
|
||||
async function saveBackend(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('backend-id').value;
|
||||
const body = {
|
||||
name: document.getElementById('backend-name').value,
|
||||
label: document.getElementById('backend-label').value,
|
||||
api_base_url: document.getElementById('backend-url').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: JSON.parse(document.getElementById('backend-mappings').value || '{}'),
|
||||
};
|
||||
|
||||
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 fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Save failed');
|
||||
closeModal();
|
||||
refreshAll();
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
async function deleteBackend(id) {
|
||||
if (!confirm('Delete this provider? This cannot be undone.')) return;
|
||||
try {
|
||||
await fetch('/api/admin/backends/' + id, { method: 'DELETE' });
|
||||
refreshAll();
|
||||
} catch (e) { alert('Delete failed: ' + e.message); }
|
||||
}
|
||||
|
||||
function closeModal() { document.getElementById('backend-modal').classList.remove('active'); }
|
||||
|
||||
// ── Load Pages ──
|
||||
async function loadPage(page) {
|
||||
if (page === 'dashboard') {
|
||||
initCharts();
|
||||
loadChartData();
|
||||
} else if (page === 'providers') {
|
||||
refreshAll();
|
||||
} else if (page === 'usage') {
|
||||
loadUsageFilter();
|
||||
loadUsage();
|
||||
loadDaily();
|
||||
} else if (page === 'cooldown') {
|
||||
loadCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/backends');
|
||||
const backends = await res.json();
|
||||
renderBackendsTable(backends);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
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.map(r => `
|
||||
<tr>
|
||||
<td>${r.hour_bucket}</td>
|
||||
<td>${r.backend_id}</td>
|
||||
<td>${h(r.model)}</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>${r.consecutive_count}</td>
|
||||
<td>${r.cooldown_seconds}s</td>
|
||||
<td>${h(r.response_summary)}</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadChartData() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/stats/hourly?hours=168');
|
||||
const data = await res.json();
|
||||
// Group by hour, sum
|
||||
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();
|
||||
const costs = hours.map(h => byHour[h].cost);
|
||||
const tokens = hours.map(h => byHour[h].tokens);
|
||||
const labels = hours.map(h => h.slice(11, 16) + ' ' + h.slice(5, 10));
|
||||
|
||||
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(e); }
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
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);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const parts = [];
|
||||
if (d) parts.push(d + 'd');
|
||||
if (h) parts.push(h + 'h');
|
||||
if (m || !parts.length) parts.push(m + 'm');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Initial load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Ensure chart containers exist
|
||||
if (!document.getElementById('cost-chart')) {
|
||||
const chartsDiv = document.getElementById('charts');
|
||||
if (chartsDiv) {
|
||||
chartsDiv.innerHTML = `
|
||||
<div class="chart-card"><h3>Cost Over Time</h3><canvas id="cost-chart"></canvas></div>
|
||||
<div class="chart-card"><h3>Token Usage Over Time</h3><canvas id="token-chart"></canvas></div>`;
|
||||
}
|
||||
}
|
||||
initCharts();
|
||||
loadChartData();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user