UNPKG

lynkr

Version:

Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.

678 lines (608 loc) 33.1 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Lynkr Dashboard</title> <script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> <style> body { font-family: 'Inter', system-ui, sans-serif; } .fade-in { animation: fadeIn 0.15s ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } .ring-tab { transition: color 0.15s, border-color 0.15s; } ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: #1e293b; } ::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; } .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .table-row:hover { background: rgba(59,130,246,0.07); } .badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; } </style> </head> <body class="bg-slate-900 text-slate-100 min-h-screen"> <!-- Top Nav --> <header class="bg-slate-800 border-b border-slate-700 sticky top-0 z-50"> <div class="max-w-7xl mx-auto px-4 flex items-center h-14 gap-6"> <div class="flex items-center gap-2 mr-2"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> </svg> <span class="font-bold text-white text-base tracking-tight">Lynkr</span> <span id="nav-version" class="text-xs text-slate-500 ml-1"></span> </div> <nav class="flex gap-1"> <button onclick="App.navigate('overview')" data-page="overview" class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700"> Overview </button> <button onclick="App.navigate('usage')" data-page="usage" class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700"> Usage </button> <button onclick="App.navigate('routing')" data-page="routing" class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700"> Routing </button> <button onclick="App.navigate('logs')" data-page="logs" class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700"> Logs </button> </nav> <div class="ml-auto flex items-center gap-3"> <div id="refresh-indicator" class="flex items-center gap-2 text-xs text-slate-500"> <div class="status-dot bg-green-500" id="live-dot"></div> <span id="refresh-countdown">30s</span> </div> <button onclick="App.refresh()" class="text-xs text-slate-500 hover:text-slate-300 px-2 py-1 rounded hover:bg-slate-700"> ↺ Refresh </button> </div> </div> </header> <!-- Page Content --> <main id="content" class="max-w-7xl mx-auto px-4 py-6"></main> <script> /* ───────────────────────────────────────────── Helpers ───────────────────────────────────────────── */ const fmt = { num: n => (n ?? 0).toLocaleString(), tok: n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n||0), usd: n => '$'+(n||0).toFixed(4), usd2: n => '$'+(n||0).toFixed(2), pct: n => (n||0).toFixed(1)+'%', ms: n => n == null ? '—' : n < 1000 ? n+'ms' : (n/1000).toFixed(1)+'s', ago: ts => { if (!ts) return '—'; const s = Math.floor((Date.now()-ts)/1000); if (s < 60) return s+'s ago'; if (s < 3600) return Math.floor(s/60)+'m ago'; return Math.floor(s/3600)+'h ago'; }, time: ts => ts ? new Date(ts).toLocaleTimeString() : '—', }; function tierColor(tier) { return { SIMPLE:'text-green-400', MEDIUM:'text-blue-400', COMPLEX:'text-amber-400', REASONING:'text-purple-400' }[tier] || 'text-slate-400'; } function tierBg(tier) { return { SIMPLE:'bg-green-900/40 text-green-300', MEDIUM:'bg-blue-900/40 text-blue-300', COMPLEX:'bg-amber-900/40 text-amber-300', REASONING:'bg-purple-900/40 text-purple-300' }[tier] || 'bg-slate-700 text-slate-300'; } function statusBadge(code) { if (!code) return '<span class="badge bg-slate-700 text-slate-400"></span>'; if (code < 300) return `<span class="badge bg-green-900/50 text-green-300">${code}</span>`; if (code < 500) return `<span class="badge bg-amber-900/50 text-amber-300">${code}</span>`; return `<span class="badge bg-red-900/50 text-red-300">${code}</span>`; } function providerDot(type) { return type === 'local' ? 'bg-green-500' : 'bg-blue-500'; } function card(inner, extra='') { return `<div class="bg-slate-800 border border-slate-700 rounded-xl p-5 ${extra}">${inner}</div>`; } function statCard(label, value, sub='', color='text-white') { return card(` <div class="text-xs text-slate-500 uppercase tracking-wider mb-1">${label}</div> <div class="text-2xl font-bold ${color}">${value}</div> ${sub ? `<div class="text-xs text-slate-500 mt-1">${sub}</div>` : ''} `); } function emptyState(msg) { return `<div class="text-center py-16 text-slate-500"> <svg class="mx-auto mb-3 opacity-30" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> <p class="text-sm">${msg}</p> </div>`; } function loadingState() { return `<div class="text-center py-16 text-slate-600"> <div class="inline-block w-6 h-6 border-2 border-slate-600 border-t-blue-500 rounded-full animate-spin"></div> </div>`; } /* ───────────────────────────────────────────── Main App ───────────────────────────────────────────── */ const App = { page: 'overview', data: {}, usageWindow: '7d', logFilters: { provider: '', tier: '', error: false }, _refreshTimer: null, _countdownTimer: null, _countdown: 30, _charts: {}, init() { const hash = location.hash.slice(1) || 'overview'; this.navigate(hash, true); this._startCountdown(); }, navigate(page, initial=false) { this.page = page; location.hash = page; this._updateTabs(); document.getElementById('content').innerHTML = loadingState(); this._destroyCharts(); this.refresh(); }, _updateTabs() { document.querySelectorAll('[data-page]').forEach(btn => { const active = btn.dataset.page === this.page; btn.className = active ? 'ring-tab px-4 py-2 rounded-md text-sm font-medium text-white bg-slate-700' : 'ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700'; }); }, _startCountdown() { this._countdown = 30; clearInterval(this._countdownTimer); this._countdownTimer = setInterval(() => { this._countdown--; const el = document.getElementById('refresh-countdown'); if (el) el.textContent = this._countdown + 's'; if (this._countdown <= 0) { this._countdown = 30; this.refresh(true); // silent refresh } }, 1000); }, async refresh(silent=false) { this._countdown = 30; if (!silent) document.getElementById('content').innerHTML = loadingState(); const dot = document.getElementById('live-dot'); if (dot) { dot.className = 'status-dot bg-yellow-500'; } try { let data; if (this.page === 'overview') data = await this._fetch('/dashboard/api/overview'); if (this.page === 'usage') data = await this._fetch(`/dashboard/api/usage?window=${this.usageWindow}`); if (this.page === 'routing') data = await this._fetch('/dashboard/api/routing'); if (this.page === 'logs') { const q = new URLSearchParams({ limit: 100 }); if (this.logFilters.provider) q.set('provider', this.logFilters.provider); if (this.logFilters.tier) q.set('tier', this.logFilters.tier); if (this.logFilters.error) q.set('error', 'true'); data = await this._fetch('/dashboard/api/logs?' + q); } this.data[this.page] = data; this._render(data); if (dot) dot.className = 'status-dot bg-green-500'; // update version/port in nav if (this.page === 'overview' && data) { const el = document.getElementById('nav-version'); if (el) el.textContent = `v${data.version} · :${data.port}`; } } catch (err) { document.getElementById('content').innerHTML = card(`<div class="text-red-400 text-sm">Failed to load: ${err.message}</div>`); if (dot) dot.className = 'status-dot bg-red-500'; } }, async _fetch(url) { const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }, _render(data) { this._destroyCharts(); const html = { overview: () => this._renderOverview(data), usage: () => this._renderUsage(data), routing: () => this._renderRouting(data), logs: () => this._renderLogs(data), }[this.page]?.() || ''; document.getElementById('content').innerHTML = `<div class="fade-in">${html}</div>`; this._afterRender(data); }, _destroyCharts() { Object.values(this._charts).forEach(c => { try { c.destroy(); } catch {} }); this._charts = {}; }, /* ── OVERVIEW ────────────────────────────── */ _renderOverview(d) { if (!d) return emptyState('No data'); const t = d.today; const s = d.stats; const tierLabel = t => t === 'default' ? 'default' : String(t).toLowerCase(); const providerCards = d.providers.length === 0 ? `<p class="text-slate-500 text-sm">No providers configured</p>` : d.providers.map(p => ` <div class="flex items-center justify-between bg-slate-700/50 rounded-lg px-4 py-3"> <div class="flex items-center gap-2"> <span class="status-dot ${providerDot(p.type)}"></span> <span class="text-sm font-medium text-slate-200">${p.name}</span> ${(p.tiers || []).map(t => `<span class="badge bg-slate-600/60 text-slate-300">${tierLabel(t)}</span>`).join('')} </div> <span class="text-xs ${p.type === 'local' ? 'text-green-400' : 'text-blue-400'}">${p.type}</span> </div>`).join(''); const providerWarnings = (d.providerWarnings || []).map(w => ` <div class="flex items-center justify-between bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-3"> <div class="flex items-center gap-2"> <span class="text-amber-400 text-sm"></span> <span class="text-sm font-medium text-amber-200">${w.name}</span> ${(w.tiers || []).map(t => `<span class="badge bg-amber-500/20 text-amber-300">${tierLabel(t)}</span>`).join('')} </div> <span class="text-xs text-amber-400">no credentials</span> </div>`).join(''); const recentRows = (d.recentRequests || []).map(r => ` <tr class="table-row border-b border-slate-700/50"> <td class="py-2 px-3 text-xs text-slate-500">${fmt.ago(r.timestamp)}</td> <td class="py-2 px-3 text-xs font-mono text-slate-300">${r.provider || '—'}</td> <td class="py-2 px-3 text-xs text-slate-400 max-w-[160px] truncate">${r.model || '—'}</td> <td class="py-2 px-3"><span class="badge ${tierBg(r.tier)}">${r.tier || '—'}</span></td> <td class="py-2 px-3 text-xs text-slate-400">${fmt.ms(r.latency_ms)}</td> <td class="py-2 px-3">${statusBadge(r.status_code)}</td> <td class="py-2 px-3 text-xs text-slate-400">${r.error_type ? `<span class="text-red-400">${r.error_type}</span>` : '—'}</td> </tr>`).join(''); return ` <!-- Stat cards --> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> ${statCard('Requests Today', fmt.num(t.requests), `${fmt.num(d.metrics.requestsTotal)} total lifetime`)} ${statCard('Tokens Today', fmt.tok(t.totalTokens), `${fmt.num(d.metrics.responsesError)} errors lifetime`, 'text-white')} ${statCard('Cost Today', fmt.usd(t.cost), `${fmt.usd(t.flagshipCost||0)} flagship equiv.`, 'text-amber-300')} ${statCard('Saved Today', fmt.usd(t.saved), `${fmt.pct(t.savedPercent)} vs flagship`, 'text-green-400')} </div> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6"> <!-- Providers --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">Configured Providers</h3> <div class="flex flex-col gap-2">${providerCards}${providerWarnings}</div> `)} <!-- 24h Stats --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">Last ${d.statsWindow || '24h'}</h3> ${s ? ` <div class="grid grid-cols-2 gap-3"> <div><p class="text-xs text-slate-500">Requests</p><p class="text-lg font-bold">${fmt.num(s.totalRequests)}</p></div> <div><p class="text-xs text-slate-500">Error Rate</p><p class="text-lg font-bold ${s.errorRate > 5 ? 'text-red-400' : 'text-green-400'}">${fmt.pct(s.errorRate)}</p></div> <div><p class="text-xs text-slate-500">Over-provisioned</p><p class="text-lg font-bold text-amber-400">${fmt.pct(s.overProvisionedPct)}</p></div> <div><p class="text-xs text-slate-500">Under-provisioned</p><p class="text-lg font-bold text-red-400">${fmt.pct(s.underProvisionedPct)}</p></div> </div> ` : emptyState('No requests yet')} `, 'col-span-2')} </div> <!-- Recent Requests --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">Recent Requests</h3> ${recentRows.length === 0 ? emptyState('No requests recorded yet') : ` <div class="overflow-x-auto"> <table class="w-full text-left"> <thead> <tr class="border-b border-slate-700"> <th class="py-2 px-3 text-xs text-slate-500 font-medium">When</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Provider</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Model</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Tier</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Latency</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Status</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium">Error</th> </tr> </thead> <tbody>${recentRows}</tbody> </table> </div> `} `)} `; }, /* ── USAGE ───────────────────────────────── */ _renderUsage(d) { if (!d) return emptyState('No data'); const t = d.totals; const hasData = t?.requests > 0; const windowBtns = ['1d','7d','30d','all'].map(w => `<button onclick="App.setUsageWindow('${w}')" class="px-3 py-1.5 text-xs rounded-md font-medium ${this.usageWindow===w ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white hover:bg-slate-700'}">${w}</button>` ).join(''); const tierRows = Object.entries(d.byTier || {}).sort((a,b)=>b[1].requests-a[1].requests).map(([tier, b]) => ` <tr class="table-row border-b border-slate-700/50"> <td class="py-2 px-4"><span class="badge ${tierBg(tier)}">${tier}</span></td> <td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td> <td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td> <td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td> <td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td> <td class="py-2 px-4 text-xs text-slate-500">${fmt.pct(b.savedPercent)}</td> </tr>`).join(''); const providerRows = Object.entries(d.byProvider || {}).sort((a,b)=>b[1].requests-a[1].requests).map(([prov, b]) => ` <tr class="table-row border-b border-slate-700/50"> <td class="py-2 px-4 text-sm font-mono text-slate-300">${prov}</td> <td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td> <td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td> <td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td> <td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td> <td class="py-2 px-4 text-xs text-slate-500">${fmt.pct(b.savedPercent)}</td> </tr>`).join(''); const modelRows = Object.entries(d.byModel || {}).sort((a,b)=>b[1].requests-a[1].requests).slice(0,20).map(([model, b]) => ` <tr class="table-row border-b border-slate-700/50"> <td class="py-2 px-4 text-xs font-mono text-slate-300 max-w-[220px] truncate">${model}</td> <td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td> <td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td> <td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td> <td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td> </tr>`).join(''); const tableHead = ` <thead><tr class="border-b border-slate-700"> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Name</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Requests</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Tokens</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Cost</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Saved</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Saved%</th> </tr></thead>`; return ` <div class="flex items-center justify-between mb-5"> <h2 class="text-lg font-semibold">Usage</h2> <div class="flex gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">${windowBtns}</div> </div> <!-- Summary --> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> ${statCard('Requests', fmt.num(t?.requests), d.since ? `since ${new Date(d.since).toLocaleDateString()}` : 'all time')} ${statCard('Total Tokens', fmt.tok(t?.totalTokens), `${fmt.tok(t?.inputTokens)} in / ${fmt.tok(t?.outputTokens)} out`)} ${statCard('Actual Cost', fmt.usd2(t?.actualCost), `vs ${fmt.usd2(t?.flagshipCost)} flagship`, 'text-amber-300')} ${statCard('Total Saved', fmt.usd2(t?.saved), `${fmt.pct(t?.savedPercent)} savings rate`, 'text-green-400')} </div> ${!hasData ? card(emptyState('No usage data for this window. Make some requests first.')) : ` <!-- Chart --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-4">Daily Requests by Tier</h3> <div style="height:220px"><canvas id="usage-chart"></canvas></div> `, 'mb-6')} <!-- By Tier --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">By Tier</h3> ${tierRows ? `<table class="w-full">${tableHead}<tbody>${tierRows}</tbody></table>` : emptyState('No tier data')} `, 'mb-4')} <!-- By Provider --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">By Provider</h3> ${providerRows ? `<table class="w-full">${tableHead}<tbody>${providerRows}</tbody></table>` : emptyState('No provider data')} `, 'mb-4')} <!-- By Model --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">By Model <span class="text-slate-500 font-normal text-xs">(top 20)</span></h3> ${modelRows ? `<div class="overflow-x-auto"><table class="w-full">${tableHead}<tbody>${modelRows}</tbody></table></div>` : emptyState('No model data')} `)} `} `; }, /* ── ROUTING ─────────────────────────────── */ _renderRouting(d) { if (!d) return emptyState('No data'); const tierColors = { SIMPLE:'border-green-700/60 bg-green-900/20', MEDIUM:'border-blue-700/60 bg-blue-900/20', COMPLEX:'border-amber-700/60 bg-amber-900/20', REASONING:'border-purple-700/60 bg-purple-900/20' }; const tierTextColors = { SIMPLE:'text-green-300', MEDIUM:'text-blue-300', COMPLEX:'text-amber-300', REASONING:'text-purple-300' }; const tierCards = Object.entries(d.tierDefinitions || {}).map(([name, def]) => ` <div class="border rounded-xl p-4 ${tierColors[name] || 'border-slate-700 bg-slate-800'}"> <div class="flex items-center justify-between mb-2"> <span class="font-semibold text-sm ${tierTextColors[name] || 'text-slate-300'}">${name}</span> <span class="text-xs text-slate-500">${def.range[0]}–${def.range[1]}</span> </div> <p class="text-xs text-slate-400">${def.description}</p> </div>`).join(''); const acc = d.accuracy; const providerRows = Object.entries(d.providerStats || {}).map(([name, s]) => ` <tr class="table-row border-b border-slate-700/50"> <td class="py-2.5 px-4 text-sm font-mono text-slate-300">${name}</td> <td class="py-2.5 px-4 text-sm">${fmt.num(s.total)}</td> <td class="py-2.5 px-4 text-sm">${fmt.ms(s.avgLatency)}</td> <td class="py-2.5 px-4 text-sm ${s.errorRate > 5 ? 'text-red-400' : 'text-green-400'}">${fmt.pct(s.errorRate)}</td> <td class="py-2.5 px-4 text-sm text-slate-400">${fmt.pct(s.fallbackRate)}</td> <td class="py-2.5 px-4 text-sm text-slate-400">${s.avgTokensPerSecond != null ? s.avgTokensPerSecond+' t/s' : '—'}</td> <td class="py-2.5 px-4 text-sm text-amber-300">${fmt.usd(s.totalCost)}</td> </tr>`).join(''); const cbEntries = Object.entries(d.circuitBreakers || {}); const cbRows = cbEntries.map(([name, cb]) => { const state = cb.state || 'unknown'; const stateColor = state === 'open' ? 'text-red-400' : state === 'half-open' ? 'text-amber-400' : 'text-green-400'; return `<tr class="table-row border-b border-slate-700/50"> <td class="py-2.5 px-4 text-sm font-mono text-slate-300">${name}</td> <td class="py-2.5 px-4 text-sm font-semibold ${stateColor}">${state}</td> <td class="py-2.5 px-4 text-sm text-slate-400">${cb.failures ?? '—'}</td> <td class="py-2.5 px-4 text-xs text-slate-500">${cb.lastFailure ? fmt.ago(cb.lastFailure) : '—'}</td> </tr>`; }).join(''); return ` <h2 class="text-lg font-semibold mb-5">Routing</h2> <!-- Tiers --> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">${tierCards}</div> <!-- Accuracy --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-4">Routing Accuracy (last ${d.window || '24h'})</h3> ${acc ? ` <div class="grid grid-cols-3 gap-4"> <div class="text-center"> <p class="text-2xl font-bold">${fmt.num(acc.totalRequests)}</p> <p class="text-xs text-slate-500 mt-1">Total Requests</p> </div> <div class="text-center"> <p class="text-2xl font-bold text-amber-400">${fmt.pct(acc.overProvisionedPct)}</p> <p class="text-xs text-slate-500 mt-1">Over-provisioned</p> <p class="text-xs text-slate-600">(used REASONING for simple task)</p> </div> <div class="text-center"> <p class="text-2xl font-bold text-red-400">${fmt.pct(acc.underProvisionedPct)}</p> <p class="text-xs text-slate-500 mt-1">Under-provisioned</p> <p class="text-xs text-slate-600">(low quality on SIMPLE tier)</p> </div> </div> ` : emptyState('No routing data for last 24h')} `, 'mb-6')} <!-- Provider Stats --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3> ${providerRows ? ` <div class="overflow-x-auto"> <table class="w-full"> <thead><tr class="border-b border-slate-700"> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Provider</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Requests</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Avg Latency</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Error Rate</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Fallback Rate</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Avg t/s</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Cost</th> </tr></thead> <tbody>${providerRows}</tbody> </table> </div> ` : emptyState('No provider stats yet')} `, 'mb-6')} <!-- Circuit Breakers --> ${card(` <h3 class="text-sm font-semibold text-slate-300 mb-3">Circuit Breakers</h3> ${cbRows ? ` <table class="w-full"> <thead><tr class="border-b border-slate-700"> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Name</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">State</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Failures</th> <th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Last Failure</th> </tr></thead> <tbody>${cbRows}</tbody> </table> ` : emptyState('No circuit breakers active')} `)} `; }, /* ── LOGS ────────────────────────────────── */ _renderLogs(rows) { rows = rows || []; const providers = [...new Set(rows.map(r => r.provider).filter(Boolean))]; const tiers = ['SIMPLE','MEDIUM','COMPLEX','REASONING']; const providerOpts = ['', ...providers].map(p => `<option value="${p}" ${this.logFilters.provider===p?'selected':''}>${p||'All providers'}</option>` ).join(''); const tierOpts = ['', ...tiers].map(t => `<option value="${t}" ${this.logFilters.tier===t?'selected':''}>${t||'All tiers'}</option>` ).join(''); const tableRows = rows.map(r => ` <tr class="table-row border-b border-slate-700/40 text-sm"> <td class="py-2 px-3 text-xs text-slate-500 whitespace-nowrap">${fmt.time(r.timestamp)}</td> <td class="py-2 px-3 text-xs font-mono text-slate-300">${r.provider||'—'}</td> <td class="py-2 px-3 text-xs text-slate-400 max-w-[160px] truncate" title="${r.model||''}">${r.model||'—'}</td> <td class="py-2 px-3"><span class="badge ${tierBg(r.tier)}">${r.tier||'—'}</span></td> <td class="py-2 px-3 text-xs text-slate-400">${r.input_tokens!=null?fmt.tok(r.input_tokens):'—'}</td> <td class="py-2 px-3 text-xs text-slate-400">${r.output_tokens!=null?fmt.tok(r.output_tokens):'—'}</td> <td class="py-2 px-3 text-xs text-slate-400">${fmt.ms(r.latency_ms)}</td> <td class="py-2 px-3">${statusBadge(r.status_code)}</td> <td class="py-2 px-3 text-xs ${r.error_type?'text-red-400':'text-slate-500'}">${r.error_type||'—'}</td> <td class="py-2 px-3 text-xs text-amber-300">${r.cost_usd!=null?fmt.usd(r.cost_usd):'—'}</td> <td class="py-2 px-3">${r.was_fallback?'<span class="badge bg-amber-900/40 text-amber-300">fallback</span>':'—'}</td> </tr>`).join(''); return ` <div class="flex items-center justify-between mb-5"> <h2 class="text-lg font-semibold">Request Logs</h2> <span class="text-xs text-slate-500">${rows.length} rows</span> </div> <!-- Filters --> ${card(` <div class="flex flex-wrap gap-3 items-center"> <select onchange="App.setLogFilter('provider', this.value)" class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500"> ${providerOpts} </select> <select onchange="App.setLogFilter('tier', this.value)" class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500"> ${tierOpts} </select> <label class="flex items-center gap-2 text-sm text-slate-400 cursor-pointer"> <input type="checkbox" ${this.logFilters.error?'checked':''} onchange="App.setLogFilter('error', this.checked)" class="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"> Errors only </label> </div> `, 'mb-4')} <!-- Table --> ${rows.length === 0 ? card(emptyState('No log entries match your filters')) : card(` <div class="overflow-x-auto"> <table class="w-full min-w-[900px]"> <thead><tr class="border-b border-slate-700"> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Time</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Provider</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Model</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Tier</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">In</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Out</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Latency</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Status</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Error</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Cost</th> <th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Flags</th> </tr></thead> <tbody>${tableRows}</tbody> </table> </div> `)} `; }, /* ── After render hooks (charts) ─────────── */ _afterRender(data) { if (this.page === 'usage' && data?.daily?.length && data.totals?.requests > 0) { this._drawUsageChart(data.daily); } }, _drawUsageChart(daily) { const canvas = document.getElementById('usage-chart'); if (!canvas || typeof Chart === 'undefined') return; const TIER_COLORS = { SIMPLE: 'rgba(34,197,94,0.8)', MEDIUM: 'rgba(59,130,246,0.8)', COMPLEX: 'rgba(245,158,11,0.8)', REASONING: 'rgba(168,85,247,0.8)', UNKNOWN: 'rgba(100,116,139,0.5)', }; const allTiers = [...new Set(daily.flatMap(d => Object.keys(d.byTier)))]; const datasets = allTiers.map(tier => ({ label: tier, data: daily.map(d => d.byTier[tier] || 0), backgroundColor: TIER_COLORS[tier] || TIER_COLORS.UNKNOWN, borderRadius: 3, borderSkipped: false, })); this._charts.usage = new Chart(canvas, { type: 'bar', data: { labels: daily.map(d => d.label), datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } }, tooltip: { backgroundColor: '#1e293b', titleColor: '#f1f5f9', bodyColor: '#94a3b8', borderColor: '#334155', borderWidth: 1 }, }, scales: { x: { stacked: true, ticks: { color: '#64748b', font: { size: 11 } }, grid: { color: '#1e293b' } }, y: { stacked: true, ticks: { color: '#64748b', font: { size: 11 } }, grid: { color: '#334155' }, beginAtZero: true }, }, }, }); }, /* ── Public actions ──────────────────────── */ setUsageWindow(w) { this.usageWindow = w; this.refresh(); }, setLogFilter(key, value) { this.logFilters[key] = value; this.refresh(); }, }; window.addEventListener('hashchange', () => App.navigate(location.hash.slice(1) || 'overview')); App.init(); </script> </body> </html>