- Add POST /v1/messages (Anthropic-format passthrough) to core with full ticket tracking — every Claude Code request creates a TokenVault ticket - Add GET /v1/models passthrough so Anthropic SDK model listing works - Proxy /v1/messages + /v1/models in dashboard so public URL forwards to core - Add /health endpoint to dashboard for infra monitoring - Overview tab now shows RTK savings banner (3.5M tokens, 753 commands) alongside LLM ticket stats — Overview no longer looks empty - Provider split bar chart rendered when request data is available ANTHROPIC_BASE_URL=https://tokenvault.fichtmueller.org now set globally in ~/.zshrc — every new Claude Code session is tracked automatically.
1055 lines
53 KiB
HTML
1055 lines
53 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>TokenVault — LLM Cost Intelligence</title>
|
||
<style>
|
||
:root {
|
||
--primary: #6366f1;
|
||
--primary-light: #818cf8;
|
||
--primary-dark: #4f46e5;
|
||
--bg: #f8fafc;
|
||
--surface: #ffffff;
|
||
--text: #1e293b;
|
||
--text-muted: #64748b;
|
||
--border: #e2e8f0;
|
||
--green: #22c55e;
|
||
--red: #ef4444;
|
||
--amber: #f59e0b;
|
||
--blue: #3b82f6;
|
||
--radius: 12px;
|
||
--shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
|
||
}
|
||
* { 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); }
|
||
|
||
/* Header */
|
||
.header { background:var(--surface); border-bottom:1px solid var(--border); padding:16px 24px; display:flex; align-items:center; justify-content:space-between; }
|
||
.header h1 { font-size:20px; font-weight:700; display:flex; align-items:center; gap:8px; }
|
||
.header h1 span { color:var(--primary); }
|
||
.header .lang { display:flex; gap:4px; }
|
||
.header .lang button { padding:4px 10px; border:1px solid var(--border); border-radius:6px; background:var(--surface); cursor:pointer; font-size:12px; }
|
||
.header .lang button.active { background:var(--primary); color:#fff; border-color:var(--primary); }
|
||
|
||
/* Tabs */
|
||
.tabs { display:flex; gap:0; background:var(--surface); border-bottom:2px solid var(--border); padding:0 24px; overflow-x:auto; }
|
||
.tab { padding:12px 20px; cursor:pointer; font-size:14px; font-weight:500; color:var(--text-muted); border-bottom:2px solid transparent; margin-bottom:-2px; white-space:nowrap; }
|
||
.tab:hover { color:var(--text); }
|
||
.tab.active { color:var(--primary); border-bottom-color:var(--primary); }
|
||
|
||
/* Content */
|
||
.content { max-width:1400px; margin:0 auto; padding:24px; }
|
||
|
||
/* Cards */
|
||
.cards { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:16px; margin-bottom:24px; }
|
||
.card { background:var(--surface); border-radius:var(--radius); padding:20px; box-shadow:var(--shadow); border:1px solid var(--border); }
|
||
.card .label { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); margin-bottom:4px; }
|
||
.card .value { font-size:28px; font-weight:700; }
|
||
.card .sub { font-size:12px; color:var(--text-muted); margin-top:4px; }
|
||
.card .value.green { color:var(--green); }
|
||
.card .value.primary { color:var(--primary); }
|
||
.card .value.amber { color:var(--amber); }
|
||
|
||
/* Table */
|
||
.table-wrap { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid var(--border); overflow-x:auto; }
|
||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||
th { background:#f1f5f9; text-align:left; padding:10px 14px; font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); border-bottom:1px solid var(--border); }
|
||
td { padding:10px 14px; border-bottom:1px solid var(--border); }
|
||
tr:last-child td { border-bottom:none; }
|
||
tr:hover { background:#f8fafc; }
|
||
|
||
/* Badges */
|
||
.badge { display:inline-block; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:600; }
|
||
.badge.completed { background:#dcfce7; color:#166534; }
|
||
.badge.cached { background:#dbeafe; color:#1e40af; }
|
||
.badge.failed { background:#fef2f2; color:#991b1b; }
|
||
.badge.pending_review { background:#fef3c7; color:#92400e; }
|
||
|
||
/* Provider badges */
|
||
.provider { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:600; }
|
||
.provider.anthropic { background:#fdf4ff; color:#86198f; }
|
||
.provider.openai { background:#f0fdf4; color:#166534; }
|
||
.provider.ollama { background:#eff6ff; color:#1e40af; }
|
||
.provider.google { background:#fef9c3; color:#854d0e; }
|
||
.provider.groq { background:#fce7f3; color:#9d174d; }
|
||
|
||
/* Section */
|
||
.section { margin-bottom:24px; }
|
||
.section h2 { font-size:16px; font-weight:600; margin-bottom:12px; color:var(--text); display:flex; align-items:center; gap:8px; }
|
||
|
||
/* Charts placeholder */
|
||
.chart-placeholder { background:var(--surface); border-radius:var(--radius); padding:40px; text-align:center; color:var(--text-muted); border:1px solid var(--border); box-shadow:var(--shadow); }
|
||
.chart-row { display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-bottom:24px; }
|
||
@media (max-width: 768px) { .chart-row { grid-template-columns:1fr; } }
|
||
|
||
/* Loading */
|
||
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||
.spin { display:inline-block; width:20px; height:20px; border:2px solid var(--border); border-top-color:var(--primary); border-radius:50%; animation:spin 0.6s linear infinite; }
|
||
@keyframes spin { to { transform:rotate(360deg); } }
|
||
|
||
/* Refresh button */
|
||
.refresh { padding:6px 14px; background:var(--primary); color:#fff; border:none; border-radius:8px; cursor:pointer; font-size:13px; font-weight:500; }
|
||
.refresh:hover { background:var(--primary-dark); }
|
||
|
||
/* Settings */
|
||
.settings-grid { display:grid; grid-template-columns:240px 1fr; gap:0; background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow); border:1px solid var(--border); min-height:600px; }
|
||
@media (max-width: 768px) { .settings-grid { grid-template-columns:1fr; } }
|
||
.settings-nav { border-right:1px solid var(--border); padding:8px 0; }
|
||
.settings-nav-item { padding:10px 20px; cursor:pointer; font-size:13px; font-weight:500; color:var(--text-muted); display:flex; align-items:center; gap:8px; transition:all 0.15s; }
|
||
.settings-nav-item:hover { background:#f1f5f9; color:var(--text); }
|
||
.settings-nav-item.active { background:#eef2ff; color:var(--primary); border-right:2px solid var(--primary); font-weight:600; }
|
||
.settings-panel { padding:24px; }
|
||
.settings-panel h3 { font-size:16px; font-weight:600; margin-bottom:4px; }
|
||
.settings-panel .desc { font-size:13px; color:var(--text-muted); margin-bottom:20px; }
|
||
.form-group { margin-bottom:20px; }
|
||
.form-group label { display:block; font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); margin-bottom:6px; }
|
||
.form-group input, .form-group select, .form-group textarea { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:14px; background:var(--bg); transition:border-color 0.15s; }
|
||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline:none; border-color:var(--primary); box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
|
||
.form-group .hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||
.form-group input[type="password"] { font-family:monospace; letter-spacing:2px; }
|
||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||
@media (max-width: 768px) { .form-row { grid-template-columns:1fr; } }
|
||
.btn { padding:8px 16px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-weight:500; cursor:pointer; transition:all 0.15s; }
|
||
.btn-primary { background:var(--primary); color:#fff; border-color:var(--primary); }
|
||
.btn-primary:hover { background:var(--primary-dark); }
|
||
.btn-danger { background:var(--red); color:#fff; border-color:var(--red); }
|
||
.btn-outline { background:var(--surface); color:var(--text); }
|
||
.btn-outline:hover { background:#f1f5f9; }
|
||
.btn-group { display:flex; gap:8px; margin-top:16px; }
|
||
.toggle { position:relative; display:inline-block; width:44px; height:24px; }
|
||
.toggle input { opacity:0; width:0; height:0; }
|
||
.toggle .slider { position:absolute; inset:0; background:#cbd5e1; border-radius:24px; cursor:pointer; transition:0.2s; }
|
||
.toggle .slider:before { content:''; position:absolute; height:18px; width:18px; left:3px; bottom:3px; background:#fff; border-radius:50%; transition:0.2s; }
|
||
.toggle input:checked + .slider { background:var(--primary); }
|
||
.toggle input:checked + .slider:before { transform:translateX(20px); }
|
||
.divider { height:1px; background:var(--border); margin:24px 0; }
|
||
.status-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; }
|
||
.status-dot.green { background:var(--green); }
|
||
.status-dot.red { background:var(--red); }
|
||
.status-dot.amber { background:var(--amber); }
|
||
.key-mask { font-family:monospace; font-size:13px; color:var(--text-muted); }
|
||
.provider-card { background:var(--bg); border:1px solid var(--border); border-radius:var(--radius); padding:16px; margin-bottom:12px; }
|
||
.provider-card h4 { font-size:14px; font-weight:600; margin-bottom:8px; display:flex; align-items:center; gap:6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<h1><span>Token</span>Vault <small style="font-size:12px;color:var(--text-muted);margin-left:4px">v0.1.0</small></h1>
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div class="lang">
|
||
<button class="active" onclick="setLang('en')">EN</button>
|
||
<button onclick="setLang('de')">DE</button>
|
||
</div>
|
||
<button class="refresh" onclick="loadAll()">Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" data-tab="overview">Overview</div>
|
||
<div class="tab" data-tab="tickets">Tickets</div>
|
||
<div class="tab" data-tab="cost">Cost Analysis</div>
|
||
<div class="tab" data-tab="providers">Providers</div>
|
||
<div class="tab" data-tab="rtk">RTK Savings</div>
|
||
<div class="tab" data-tab="settings">⚙ Settings</div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- Overview Tab -->
|
||
<div id="tab-overview">
|
||
<div class="cards" id="stats-cards">
|
||
<div class="loading"><div class="spin"></div> Loading...</div>
|
||
</div>
|
||
<!-- RTK savings banner — always visible since RTK data exists independently of LLM routing -->
|
||
<div id="rtk-overview-banner" style="display:none;margin:16px 0;background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:1px solid #86efac;border-radius:12px;padding:20px 24px">
|
||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||
<span style="font-size:20px">⚡</span>
|
||
<strong style="color:#16a34a;font-size:15px">RTK Token Savings (all-time)</strong>
|
||
<span style="margin-left:auto;font-size:12px;color:#15803d" id="rtk-banner-host"></span>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px" id="rtk-banner-cards"></div>
|
||
</div>
|
||
<div class="chart-row">
|
||
<div class="chart-placeholder" id="cost-timeline">Cost timeline will appear here after requests are tracked</div>
|
||
<div id="provider-split" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;flex:1;min-height:180px">
|
||
<div style="font-weight:600;margin-bottom:12px;color:var(--text-muted)">Provider Split</div>
|
||
<div id="provider-split-inner" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:40px">Provider split will appear here after requests are tracked</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tickets Tab -->
|
||
<div id="tab-tickets" style="display:none">
|
||
<div class="section">
|
||
<h2>Recent Tickets</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Ticket</th>
|
||
<th>Provider</th>
|
||
<th>Model</th>
|
||
<th>Status</th>
|
||
<th>Tokens In</th>
|
||
<th>Tokens Out</th>
|
||
<th>Cached</th>
|
||
<th>Saved</th>
|
||
<th>Cost</th>
|
||
<th>Latency</th>
|
||
<th>Time</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="ticket-rows">
|
||
<tr><td colspan="11" class="loading"><div class="spin"></div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cost Tab -->
|
||
<div id="tab-cost" style="display:none">
|
||
<div class="cards" id="cost-cards"></div>
|
||
<div class="section">
|
||
<h2>Cost Breakdown by Provider</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Provider</th><th>Requests</th><th>Tokens In</th><th>Tokens Out</th><th>Cost</th><th>Saved</th></tr></thead>
|
||
<tbody id="breakdown-rows"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Providers Tab -->
|
||
<div id="tab-providers" style="display:none">
|
||
<div class="section">
|
||
<h2>Configured Providers</h2>
|
||
<div id="provider-list" class="cards"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- RTK Savings Tab -->
|
||
<div id="tab-rtk" style="display:none">
|
||
<div class="cards" id="rtk-stats-cards">
|
||
<div class="loading"><div class="spin"></div> Loading RTK stats...</div>
|
||
</div>
|
||
<div class="chart-row">
|
||
<div class="section" style="margin:0">
|
||
<h2>Top Commands by Savings</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Command</th><th>Count</th><th>Saved</th><th>Avg %</th></tr></thead>
|
||
<tbody id="rtk-commands-rows"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="section" style="margin:0">
|
||
<h2>Hosts</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Host</th><th>Commands</th><th>Saved</th><th>Last Seen</th></tr></thead>
|
||
<tbody id="rtk-hosts-rows"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>About RTK Integration</h2>
|
||
<div class="card">
|
||
<p style="font-size:13px;line-height:1.6;color:var(--text-muted)">
|
||
<strong style="color:var(--text)">RTK (Rust Token Killer)</strong> — MIT-licensed open-source tool that reduces LLM token consumption by 60-90% through CLI output compression.
|
||
TokenVault ingests RTK's local SQLite stats from every machine you run the <code>tokenvault-rtk-sync</code> agent on, providing cross-machine aggregation, team/project attribution, and long-term cost trend analysis.
|
||
</p>
|
||
<p style="font-size:12px;margin-top:12px">
|
||
<strong>Setup on a new machine:</strong><br>
|
||
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">brew install rtk</code> ·
|
||
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">npm i -g @tokenvault/rtk-bridge</code> ·
|
||
<code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">tokenvault-rtk-sync</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings Tab -->
|
||
<div id="tab-settings" style="display:none">
|
||
<div class="settings-grid">
|
||
<div class="settings-nav">
|
||
<div class="settings-nav-item active" data-settings="providers" onclick="showSettings('providers')">LLM Providers</div>
|
||
<div class="settings-nav-item" data-settings="budgets" onclick="showSettings('budgets')">Budgets & Alerts</div>
|
||
<div class="settings-nav-item" data-settings="cache" onclick="showSettings('cache')">Semantic Cache</div>
|
||
<div class="settings-nav-item" data-settings="compression" onclick="showSettings('compression')">Compression</div>
|
||
<div class="settings-nav-item" data-settings="routing" onclick="showSettings('routing')">Model Routing</div>
|
||
<div class="settings-nav-item" data-settings="governance" onclick="showSettings('governance')">Governance & RBAC</div>
|
||
<div class="settings-nav-item" data-settings="notifications" onclick="showSettings('notifications')">Notifications</div>
|
||
<div class="settings-nav-item" data-settings="database" onclick="showSettings('database')">Database</div>
|
||
<div class="settings-nav-item" data-settings="general" onclick="showSettings('general')">General</div>
|
||
<div class="settings-nav-item" data-settings="about" onclick="showSettings('about')">About</div>
|
||
</div>
|
||
<div class="settings-panel" id="settings-content">
|
||
<!-- Default: Providers -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '/api';
|
||
let lang = 'en';
|
||
const t = {
|
||
en: { totalCost:'Total Cost', totalSaved:'Total Saved', requests:'Requests', cacheRate:'Cache Hit Rate', tokensIn:'Tokens In', tokensOut:'Tokens Out' },
|
||
de: { totalCost:'Gesamtkosten', totalSaved:'Gespart', requests:'Anfragen', cacheRate:'Cache Hit Rate', tokensIn:'Tokens Ein', tokensOut:'Tokens Aus' },
|
||
};
|
||
|
||
function setLang(l) {
|
||
lang = l;
|
||
document.querySelectorAll('.lang button').forEach(b => b.classList.toggle('active', b.textContent === l.toUpperCase()));
|
||
loadAll();
|
||
}
|
||
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.querySelectorAll('[id^="tab-"]').forEach(el => el.style.display = 'none');
|
||
document.getElementById('tab-' + tab.dataset.tab).style.display = 'block';
|
||
});
|
||
});
|
||
|
||
function fmt(n) { return typeof n === 'number' ? (n < 0.01 ? n.toFixed(6) : n.toFixed(2)) : '0'; }
|
||
function fmtK(n) { return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n); }
|
||
function fmtTime(d) { return new Date(d).toLocaleString(lang === 'de' ? 'de-DE' : 'en-US', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); }
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const [data, rtkStats] = await Promise.all([
|
||
(await fetch(API + '/tickets/stats?period=month')).json(),
|
||
fetch(API + '/rtk/stats?period=all').then(r => r.ok ? r.json() : null).catch(() => null),
|
||
]);
|
||
const labels = t[lang];
|
||
document.getElementById('stats-cards').innerHTML = `
|
||
<div class="card"><div class="label">${labels.totalCost}</div><div class="value primary">$${fmt(data.total_cost_usd)}</div><div class="sub">Last 30 days</div></div>
|
||
<div class="card"><div class="label">${labels.totalSaved}</div><div class="value green">$${fmt(data.total_saved_usd)}</div><div class="sub">Via compression + caching</div></div>
|
||
<div class="card"><div class="label">${labels.requests}</div><div class="value">${fmtK(data.total_requests)}</div><div class="sub">Tickets tracked</div></div>
|
||
<div class="card"><div class="label">${labels.cacheRate}</div><div class="value amber">${(data.cache_hit_rate * 100).toFixed(0)}%</div><div class="sub">Semantic cache hits</div></div>
|
||
<div class="card"><div class="label">${labels.tokensIn}</div><div class="value">${fmtK(data.total_tokens_in)}</div></div>
|
||
<div class="card"><div class="label">${labels.tokensOut}</div><div class="value">${fmtK(data.total_tokens_out)}</div></div>
|
||
`;
|
||
|
||
// ── RTK savings banner ──────────────────────────────────────────────────
|
||
if (rtkStats && rtkStats.total_commands > 0) {
|
||
const banner = document.getElementById('rtk-overview-banner');
|
||
banner.style.display = 'block';
|
||
document.getElementById('rtk-banner-host').textContent =
|
||
`${rtkStats.unique_hosts} host${rtkStats.unique_hosts !== 1 ? 's' : ''} · ${rtkStats.unique_projects} project${rtkStats.unique_projects !== 1 ? 's' : ''}`;
|
||
document.getElementById('rtk-banner-cards').innerHTML = `
|
||
<div style="background:#fff;border-radius:8px;padding:12px;text-align:center">
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">TOKENS SAVED</div>
|
||
<div style="font-size:22px;font-weight:700;color:#16a34a">${fmtK(rtkStats.total_saved_tokens)}</div>
|
||
</div>
|
||
<div style="background:#fff;border-radius:8px;padding:12px;text-align:center">
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">COMMANDS</div>
|
||
<div style="font-size:22px;font-weight:700;color:#6366f1">${fmtK(rtkStats.total_commands)}</div>
|
||
</div>
|
||
<div style="background:#fff;border-radius:8px;padding:12px;text-align:center">
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">AVG SAVINGS</div>
|
||
<div style="font-size:22px;font-weight:700;color:#f59e0b">${rtkStats.avg_savings_pct.toFixed(1)}%</div>
|
||
</div>
|
||
<div style="background:#fff;border-radius:8px;padding:12px;text-align:center">
|
||
<div style="font-size:11px;color:#6b7280;margin-bottom:4px">INPUT TOKENS</div>
|
||
<div style="font-size:22px;font-weight:700">${fmtK(rtkStats.total_input_tokens)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Provider split (simple bar chart) ───────────────────────────────────
|
||
try {
|
||
const breakdown = await (await fetch(API + '/cost/breakdown?group_by=provider')).json();
|
||
const total = breakdown.reduce((s, b) => s + b.request_count, 0);
|
||
if (total > 0) {
|
||
const colors = { anthropic:'#6366f1', openai:'#10b981', ollama:'#f59e0b', 'ai-bridge':'#3b82f6' };
|
||
document.getElementById('provider-split-inner').innerHTML = breakdown.map(b => {
|
||
const pct = ((b.request_count / total) * 100).toFixed(0);
|
||
const col = colors[b.group_value] || '#94a3b8';
|
||
return `<div style="margin-bottom:10px">
|
||
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px">
|
||
<span style="font-weight:500">${b.group_value}</span><span style="color:var(--text-muted)">${b.request_count} req · ${pct}%</span>
|
||
</div>
|
||
<div style="background:var(--border);border-radius:4px;height:6px">
|
||
<div style="background:${col};width:${pct}%;height:6px;border-radius:4px"></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
} catch {}
|
||
} catch { document.getElementById('stats-cards').innerHTML = '<div class="card"><div class="label">Status</div><div class="value">Offline</div><div class="sub">TokenVault core not reachable</div></div>'; }
|
||
}
|
||
|
||
async function loadTickets() {
|
||
try {
|
||
const data = await (await fetch(API + '/tickets?limit=50')).json();
|
||
document.getElementById('ticket-rows').innerHTML = data.tickets.map(t => `
|
||
<tr>
|
||
<td><strong>${t.ticket_display}</strong></td>
|
||
<td><span class="provider ${t.provider}">${t.provider}</span></td>
|
||
<td style="font-size:12px">${t.model}</td>
|
||
<td><span class="badge ${t.status}">${t.status}</span></td>
|
||
<td>${fmtK(t.tokens_in)}</td>
|
||
<td>${fmtK(t.tokens_out)}</td>
|
||
<td>${fmtK(t.tokens_cached)}</td>
|
||
<td style="color:var(--green)">${fmtK(t.tokens_saved)}</td>
|
||
<td>$${fmt(t.cost_usd)}</td>
|
||
<td>${t.latency_ms}ms</td>
|
||
<td style="font-size:11px;color:var(--text-muted)">${fmtTime(t.created_at)}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="11" style="text-align:center;color:var(--text-muted)">No tickets yet — send your first request through the proxy</td></tr>';
|
||
} catch { document.getElementById('ticket-rows').innerHTML = '<tr><td colspan="11" class="loading">Could not load tickets</td></tr>'; }
|
||
}
|
||
|
||
async function loadBreakdown() {
|
||
try {
|
||
const [stats, breakdown] = await Promise.all([
|
||
(await fetch(API + '/cost?period=month')).json(),
|
||
(await fetch(API + '/cost/breakdown?group_by=provider')).json(),
|
||
]);
|
||
const labels = t[lang];
|
||
document.getElementById('cost-cards').innerHTML = `
|
||
<div class="card"><div class="label">${labels.totalCost} (30d)</div><div class="value primary">$${fmt(stats.total_cost_usd)}</div></div>
|
||
<div class="card"><div class="label">${labels.totalSaved}</div><div class="value green">$${fmt(stats.total_saved_usd)}</div></div>
|
||
<div class="card"><div class="label">Avg Compression</div><div class="value">${(stats.avg_compression_ratio * 100).toFixed(0)}%</div></div>
|
||
`;
|
||
document.getElementById('breakdown-rows').innerHTML = breakdown.map(b => `
|
||
<tr>
|
||
<td><span class="provider ${b.group_value}">${b.group_value}</span></td>
|
||
<td>${b.request_count}</td>
|
||
<td>${fmtK(b.tokens_in)}</td>
|
||
<td>${fmtK(b.tokens_out)}</td>
|
||
<td>$${fmt(b.cost_usd)}</td>
|
||
<td style="color:var(--green)">$${fmt(b.saved_usd)}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="6">No data yet</td></tr>';
|
||
} catch {}
|
||
}
|
||
|
||
async function loadProviders() {
|
||
try {
|
||
const data = await (await fetch(API + '/health')).json();
|
||
document.getElementById('provider-list').innerHTML = data.providers.map(p => `
|
||
<div class="card">
|
||
<div class="label">${p.name.toUpperCase()}</div>
|
||
<div class="value" style="font-size:18px">${p.configured ? '✓ Active' : '✗ Not configured'}</div>
|
||
<div class="sub">${p.models} model${p.models !== 1 ? 's' : ''} available</div>
|
||
</div>
|
||
`).join('');
|
||
} catch {}
|
||
}
|
||
|
||
async function loadRtk() {
|
||
try {
|
||
const [stats, commands, hosts] = await Promise.all([
|
||
(await fetch(API + '/rtk/stats?period=all')).json(),
|
||
(await fetch(API + '/rtk/commands')).json(),
|
||
(await fetch(API + '/rtk/hosts')).json(),
|
||
]);
|
||
|
||
document.getElementById('rtk-stats-cards').innerHTML = `
|
||
<div class="card"><div class="label">Total Saved</div><div class="value green">${fmtK(stats.total_saved_tokens)}</div><div class="sub">tokens across all hosts</div></div>
|
||
<div class="card"><div class="label">Commands</div><div class="value primary">${fmtK(stats.total_commands)}</div><div class="sub">via RTK</div></div>
|
||
<div class="card"><div class="label">Avg Savings</div><div class="value amber">${stats.avg_savings_pct.toFixed(1)}%</div><div class="sub">per command</div></div>
|
||
<div class="card"><div class="label">Input Tokens</div><div class="value">${fmtK(stats.total_input_tokens)}</div><div class="sub">before RTK</div></div>
|
||
<div class="card"><div class="label">Output Tokens</div><div class="value">${fmtK(stats.total_output_tokens)}</div><div class="sub">after RTK</div></div>
|
||
<div class="card"><div class="label">Hosts</div><div class="value">${stats.unique_hosts}</div><div class="sub">machines tracked</div></div>
|
||
`;
|
||
|
||
document.getElementById('rtk-commands-rows').innerHTML = commands.commands.map(c => `
|
||
<tr>
|
||
<td><code style="font-size:12px">${c.cmd}</code></td>
|
||
<td>${c.count}</td>
|
||
<td style="color:var(--green)">${fmtK(c.saved_tokens)}</td>
|
||
<td>${c.avg_savings_pct.toFixed(1)}%</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">No RTK data yet — run <code>tokenvault-rtk-sync</code> to ingest</td></tr>';
|
||
|
||
document.getElementById('rtk-hosts-rows').innerHTML = hosts.hosts.map(h => `
|
||
<tr>
|
||
<td><strong>${h.host}</strong></td>
|
||
<td>${fmtK(h.commands)}</td>
|
||
<td style="color:var(--green)">${fmtK(h.saved_tokens)}</td>
|
||
<td style="font-size:11px;color:var(--text-muted)">${fmtTime(h.last_seen)}</td>
|
||
</tr>
|
||
`).join('') || '<tr><td colspan="4" style="text-align:center;color:var(--text-muted)">No hosts synced yet</td></tr>';
|
||
} catch (err) {
|
||
document.getElementById('rtk-stats-cards').innerHTML = '<div class="card"><div class="label">RTK</div><div class="value">Offline</div><div class="sub">Core endpoint unreachable</div></div>';
|
||
}
|
||
}
|
||
|
||
function loadAll() { loadStats(); loadTickets(); loadBreakdown(); loadProviders(); loadRtk(); }
|
||
loadAll();
|
||
setInterval(loadAll, 30000);
|
||
|
||
// ─── Settings ──────────────────────────────────────────────────────────────
|
||
|
||
const settingsPages = {
|
||
providers: () => `
|
||
<h3>${lang==='de'?'LLM Provider Konfiguration':'LLM Provider Configuration'}</h3>
|
||
<p class="desc">${lang==='de'?'API-Schlüssel für jeden Provider. Keys werden serverseitig gespeichert und nie im Browser angezeigt.':'API keys for each provider. Keys are stored server-side and never shown in the browser.'}</p>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot" id="dot-anthropic"></span> Anthropic Claude</h4>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="key-anthropic" placeholder="sk-ant-..." />
|
||
<div class="hint">Claude Opus, Sonnet, Haiku — api.anthropic.com</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
|
||
<select id="model-anthropic">
|
||
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
|
||
<option value="claude-haiku-3-20250630">Claude Haiku 3</option>
|
||
<option value="claude-opus-4-20250514">Claude Opus 4</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot" id="dot-openai"></span> OpenAI</h4>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="key-openai" placeholder="sk-..." />
|
||
<div class="hint">GPT-4o, GPT-4o-mini, o1 — api.openai.com</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
|
||
<select id="model-openai">
|
||
<option value="gpt-4o">GPT-4o</option>
|
||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||
<option value="o1">o1</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot" id="dot-google"></span> Google Gemini</h4>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="key-google" placeholder="AIza..." />
|
||
<div class="hint">Gemini 2.0 Flash, Gemini 2.5 Pro</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Standard-Modell':'Default Model'}</label>
|
||
<select id="model-google">
|
||
<option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
|
||
<option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot" id="dot-mistral"></span> Mistral AI</h4>
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="key-mistral" placeholder="..." />
|
||
<div class="hint">Mistral Large, Mistral Small</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot" id="dot-groq"></span> Groq</h4>
|
||
<div class="form-group">
|
||
<label>API Key</label>
|
||
<input type="password" id="key-groq" placeholder="gsk_..." />
|
||
<div class="hint">${lang==='de'?'Kostenlos — Llama 3.3 70B, Gemma':'Free tier — Llama 3.3 70B, Gemma'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="provider-card">
|
||
<h4><span class="status-dot green"></span> Ollama (Local)</h4>
|
||
<div class="form-group">
|
||
<label>Ollama URL</label>
|
||
<input type="text" id="url-ollama" placeholder="http://localhost:11434" />
|
||
<div class="hint">${lang==='de'?'Lokale Modelle — kostenlos, keine API-Keys nötig':'Local models — free, no API keys needed'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('providers')">${lang==='de'?'Speichern':'Save Changes'}</button>
|
||
<button class="btn btn-outline" onclick="testProviders()">${lang==='de'?'Verbindung testen':'Test Connections'}</button>
|
||
</div>
|
||
`,
|
||
|
||
budgets: () => `
|
||
<h3>${lang==='de'?'Budgets & Alerts':'Budgets & Alerts'}</h3>
|
||
<p class="desc">${lang==='de'?'Setze Ausgabenlimits pro Projekt, Team oder global. Bei Überschreitung werden Alerts ausgelöst oder Requests pausiert.':'Set spending limits per project, team, or globally. Exceeding triggers alerts or pauses requests.'}</p>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Globales Monatslimit (USD)':'Global Monthly Limit (USD)'}</label>
|
||
<input type="number" id="budget-global" placeholder="100.00" step="0.01" />
|
||
<div class="hint">${lang==='de'?'Alle Provider zusammen. 0 = kein Limit.':'All providers combined. 0 = no limit.'}</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Alert-Schwelle':'Alert Threshold'}</label>
|
||
<select id="budget-threshold">
|
||
<option value="0.5">50%</option>
|
||
<option value="0.7">70%</option>
|
||
<option value="0.8" selected>80%</option>
|
||
<option value="0.9">90%</option>
|
||
<option value="0.95">95%</option>
|
||
</select>
|
||
<div class="hint">${lang==='de'?'Alert bei diesem Prozentsatz des Limits':'Alert at this percentage of limit'}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Hard-Limit':'Hard Limit'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="budget-hard"><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Requests bei 100% pausieren':'Pause requests at 100%'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Projekt-Budgets':'Project Budgets'}</h3>
|
||
<div id="project-budgets">
|
||
<div class="form-row" style="margin-bottom:8px">
|
||
<div class="form-group"><label>Projekt</label><input type="text" placeholder="eo-global-pulse" /></div>
|
||
<div class="form-group"><label>Limit (USD/Monat)</label><input type="number" placeholder="25.00" step="0.01" /></div>
|
||
</div>
|
||
<div class="form-row" style="margin-bottom:8px">
|
||
<div class="form-group"><input type="text" placeholder="tip-platform" /></div>
|
||
<div class="form-group"><input type="number" placeholder="50.00" step="0.01" /></div>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-outline" onclick="addProjectBudget()">+ ${lang==='de'?'Projekt hinzufügen':'Add Project'}</button>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Anomalie-Erkennung':'Anomaly Detection'}</h3>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Sigma-Schwelle':'Sigma Threshold'}</label>
|
||
<select id="anomaly-sigma">
|
||
<option value="2">2σ (${lang==='de'?'Empfindlich':'Sensitive'})</option>
|
||
<option value="3" selected>3σ (Standard)</option>
|
||
<option value="4">4σ (${lang==='de'?'Nur Extremfälle':'Extreme only'})</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Anomalie-Erkennung aktiv':'Anomaly Detection Active'}</label>
|
||
<div style="padding-top:4px"><label class="toggle"><input type="checkbox" id="anomaly-enabled" checked><span class="slider"></span></label></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('budgets')">${lang==='de'?'Speichern':'Save Changes'}</button>
|
||
</div>
|
||
`,
|
||
|
||
cache: () => `
|
||
<h3>Semantic Cache</h3>
|
||
<p class="desc">${lang==='de'?'Qdrant-basierter Cache. Ähnliche Anfragen werden aus dem Cache beantwortet statt die API erneut aufzurufen.':'Qdrant-powered cache. Similar requests are answered from cache instead of calling the API again.'}</p>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Cache aktiv':'Cache Enabled'}</label>
|
||
<div style="padding-top:4px"><label class="toggle"><input type="checkbox" id="cache-enabled" checked><span class="slider"></span></label></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Ähnlichkeitsschwelle':'Similarity Threshold'}</label>
|
||
<input type="number" id="cache-threshold" value="0.95" min="0.80" max="0.99" step="0.01" />
|
||
<div class="hint">${lang==='de'?'0.95 = 95% Ähnlichkeit für Cache-Hit':'0.95 = 95% similarity for cache hit'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Cache TTL</label>
|
||
<select id="cache-ttl">
|
||
<option value="3600">1 ${lang==='de'?'Stunde':'hour'}</option>
|
||
<option value="86400" selected>24 ${lang==='de'?'Stunden':'hours'}</option>
|
||
<option value="604800">7 ${lang==='de'?'Tage':'days'}</option>
|
||
<option value="2592000">30 ${lang==='de'?'Tage':'days'}</option>
|
||
<option value="0">${lang==='de'?'Kein Ablauf':'No expiry'}</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Qdrant URL</label>
|
||
<input type="text" id="cache-qdrant" placeholder="http://localhost:6333" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Qdrant Collection</label>
|
||
<input type="text" id="cache-collection" placeholder="tokenvault_cache" />
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('cache')">${lang==='de'?'Speichern':'Save'}</button>
|
||
<button class="btn btn-outline" onclick="flushCache()">${lang==='de'?'Cache leeren':'Flush Cache'}</button>
|
||
</div>
|
||
`,
|
||
|
||
compression: () => `
|
||
<h3>${lang==='de'?'Kompression':'Compression'}</h3>
|
||
<p class="desc">${lang==='de'?'AST-basierte Token-Kompression für Code und CLI-Output. Reduziert Token-Verbrauch um 60-90%.':'AST-based token compression for code and CLI output. Reduces token usage by 60-90%.'}</p>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Auto-Modus':'Auto Mode'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="compress-auto" checked><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Automatisch besten Modus wählen (Entropy-Analyse)':'Automatically select best mode (entropy analysis)'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Standard-Modus (wenn Auto aus)':'Default Mode (when auto off)'}</label>
|
||
<select id="compress-mode">
|
||
<option value="full">Full (${lang==='de'?'keine Kompression':'no compression'})</option>
|
||
<option value="signatures" selected>Signatures (AST)</option>
|
||
<option value="map">Map (${lang==='de'?'Abhängigkeitsgraph':'dependency graph'})</option>
|
||
<option value="aggressive">Aggressive</option>
|
||
<option value="entropy">Entropy</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Shell-Hooks aktiv':'Shell Hooks Active'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="compress-shell" checked><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'90+ Patterns für git, npm, docker, cargo, kubectl...':'90+ patterns for git, npm, docker, cargo, kubectl...'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Prompt Caching':'Prompt Caching'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="compress-prompt-cache" checked><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Anthropic cache_control + OpenAI automatisches Caching orchestrieren':'Orchestrate Anthropic cache_control + OpenAI automatic caching'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('compression')">${lang==='de'?'Speichern':'Save'}</button>
|
||
</div>
|
||
`,
|
||
|
||
routing: () => `
|
||
<h3>${lang==='de'?'Model Routing':'Model Routing'}</h3>
|
||
<p class="desc">${lang==='de'?'Automatisches Routing zum günstigsten/schnellsten Modell basierend auf Anfragekomplexität.':'Automatic routing to cheapest/fastest model based on request complexity.'}</p>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Routing-Strategie':'Routing Strategy'}</label>
|
||
<select id="route-strategy">
|
||
<option value="cost">${lang==='de'?'Günstigstes Modell':'Cheapest model'}</option>
|
||
<option value="quality">${lang==='de'?'Bestes Modell':'Best model'}</option>
|
||
<option value="balanced" selected>${lang==='de'?'Balanced (Kosten vs. Qualität)':'Balanced (cost vs. quality)'}</option>
|
||
<option value="latency">${lang==='de'?'Schnellstes Modell':'Fastest model'}</option>
|
||
<option value="manual">${lang==='de'?'Manuell (kein Auto-Routing)':'Manual (no auto-routing)'}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Fallback-Kette':'Fallback Chain'}</label>
|
||
<textarea id="route-fallback" rows="4" placeholder="anthropic/claude-sonnet-4-20250514 openai/gpt-4o ollama/qwen2.5:14b groq/llama-3.3-70b"></textarea>
|
||
<div class="hint">${lang==='de'?'Ein Modell pro Zeile. Bei Ausfall wird das nächste verwendet.':'One model per line. Next is used on failure.'}</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Max Tokens pro Request':'Max Tokens per Request'}</label>
|
||
<input type="number" id="route-max-tokens" value="4096" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Standard-Temperatur':'Default Temperature'}</label>
|
||
<input type="number" id="route-temp" value="0.7" min="0" max="2" step="0.1" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('routing')">${lang==='de'?'Speichern':'Save'}</button>
|
||
</div>
|
||
`,
|
||
|
||
governance: () => `
|
||
<h3>${lang==='de'?'Governance & Zugriffskontrolle':'Governance & Access Control'}</h3>
|
||
<p class="desc">${lang==='de'?'Wer darf welches Modell nutzen? Rate Limits pro Team/User.':'Who can use which model? Rate limits per team/user.'}</p>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'RBAC aktiv':'RBAC Enabled'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="gov-rbac"><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Rollenbasierte Zugriffskontrolle aktivieren':'Enable role-based access control'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Erlaubte Modell-Tiers':'Allowed Model Tiers'}</label>
|
||
<div style="display:flex;gap:16px;padding-top:4px">
|
||
<label style="font-size:13px"><input type="checkbox" checked> Fast (Haiku, Mini)</label>
|
||
<label style="font-size:13px"><input type="checkbox" checked> Standard (Sonnet, GPT-4o)</label>
|
||
<label style="font-size:13px"><input type="checkbox"> Premium (Opus, o1)</label>
|
||
<label style="font-size:13px"><input type="checkbox"> Reasoning (o1)</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Rate Limit (${lang==='de'?'Requests/Minute':'Requests/Minute'})</label>
|
||
<input type="number" id="gov-rate" value="60" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Max Token/Request':'Max Tokens/Request'}</label>
|
||
<input type="number" id="gov-max-tokens" value="8192" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Audit Trail':'Audit Trail'}</h3>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Audit-Logging':'Audit Logging'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="gov-audit" checked><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Alle Aktionen in immutablem Log speichern (GDPR/SOC2)':'Log all actions to immutable audit trail (GDPR/SOC2)'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'PII-Scrubbing':'PII Scrubbing'}</label>
|
||
<div style="display:flex;align-items:center;gap:8px;padding-top:4px">
|
||
<label class="toggle"><input type="checkbox" id="gov-pii" checked><span class="slider"></span></label>
|
||
<span style="font-size:13px">${lang==='de'?'Persönliche Daten aus Ticket-Logs entfernen':'Remove personal data from ticket logs'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('governance')">${lang==='de'?'Speichern':'Save'}</button>
|
||
</div>
|
||
`,
|
||
|
||
notifications: () => `
|
||
<h3>${lang==='de'?'Benachrichtigungen':'Notifications'}</h3>
|
||
<p class="desc">${lang==='de'?'Werde benachrichtigt bei Budget-Überschreitungen, Anomalien und Ausfällen.':'Get notified on budget exceedances, anomalies, and outages.'}</p>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'E-Mail Benachrichtigungen':'Email Notifications'}</label>
|
||
<input type="email" id="notify-email" placeholder="rf@context-x.org" />
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Webhook URL</label>
|
||
<input type="url" id="notify-webhook" placeholder="https://hooks.slack.com/..." />
|
||
<div class="hint">${lang==='de'?'Slack, Teams, Discord oder benutzerdefinierter Webhook':'Slack, Teams, Discord, or custom webhook'}</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Benachrichtigungs-Events':'Notification Events'}</h3>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Budget-Alert (Schwelle erreicht)':'Budget alert (threshold reached)'}</label>
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Budget-Limit (100% erreicht)':'Budget limit (100% reached)'}</label>
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox" checked> ${lang==='de'?'Kosten-Anomalie erkannt':'Cost anomaly detected'}</label>
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Provider-Ausfall':'Provider outage'}</label>
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Täglicher Kostenreport':'Daily cost report'}</label>
|
||
<label style="font-size:13px;display:flex;align-items:center;gap:8px"><input type="checkbox"> ${lang==='de'?'Wöchentlicher Sparreport':'Weekly savings report'}</label>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('notifications')">${lang==='de'?'Speichern':'Save'}</button>
|
||
<button class="btn btn-outline" onclick="testNotification()">${lang==='de'?'Test senden':'Send Test'}</button>
|
||
</div>
|
||
`,
|
||
|
||
database: () => `
|
||
<h3>${lang==='de'?'Datenbank':'Database'}</h3>
|
||
<p class="desc">${lang==='de'?'PostgreSQL + TimescaleDB Konfiguration für Ticket-Speicherung und Zeitreihen.':'PostgreSQL + TimescaleDB configuration for ticket storage and time-series.'}</p>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Host</label>
|
||
<input type="text" id="db-host" placeholder="127.0.0.1" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Port</label>
|
||
<input type="number" id="db-port" placeholder="5432" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Database</label>
|
||
<input type="text" id="db-name" placeholder="tokenvault" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>User</label>
|
||
<input type="text" id="db-user" placeholder="tokenvault" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Datenbereinigung':'Data Retention'}</h3>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Tickets aufbewahren':'Retain Tickets'}</label>
|
||
<select id="db-retention">
|
||
<option value="30">30 ${lang==='de'?'Tage':'days'}</option>
|
||
<option value="90" selected>90 ${lang==='de'?'Tage':'days'}</option>
|
||
<option value="365">1 ${lang==='de'?'Jahr':'year'}</option>
|
||
<option value="0">${lang==='de'?'Unbegrenzt':'Forever'}</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Audit-Logs aufbewahren':'Retain Audit Logs'}</label>
|
||
<select id="db-audit-retention">
|
||
<option value="365" selected>1 ${lang==='de'?'Jahr':'year'}</option>
|
||
<option value="730">2 ${lang==='de'?'Jahre':'years'}</option>
|
||
<option value="0">${lang==='de'?'Unbegrenzt':'Forever'}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('database')">${lang==='de'?'Speichern':'Save'}</button>
|
||
<button class="btn btn-outline" onclick="testDb()">${lang==='de'?'Verbindung testen':'Test Connection'}</button>
|
||
</div>
|
||
`,
|
||
|
||
general: () => `
|
||
<h3>${lang==='de'?'Allgemein':'General'}</h3>
|
||
<p class="desc">${lang==='de'?'Globale Einstellungen für den TokenVault Server.':'Global settings for the TokenVault server.'}</p>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Server-Port':'Server Port'}</label>
|
||
<input type="number" id="gen-port" placeholder="3350" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Dashboard-Port':'Dashboard Port'}</label>
|
||
<input type="number" id="gen-dash-port" placeholder="3301" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Sprache':'Language'}</label>
|
||
<select id="gen-lang">
|
||
<option value="en">English</option>
|
||
<option value="de">Deutsch</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Auto-Refresh Intervall':'Auto-Refresh Interval'}</label>
|
||
<select id="gen-refresh">
|
||
<option value="10">10s</option>
|
||
<option value="30" selected>30s</option>
|
||
<option value="60">60s</option>
|
||
<option value="0">${lang==='de'?'Aus':'Off'}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>${lang==='de'?'Theme':'Theme'}</label>
|
||
<select id="gen-theme">
|
||
<option value="light" selected>Light (MAGATAMA)</option>
|
||
<option value="dark">Dark</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Gefährliche Zone':'Danger Zone'}</h3>
|
||
<div class="form-group">
|
||
<button class="btn btn-danger" onclick="if(confirm('${lang==='de'?'Alle Tickets löschen?':'Delete all tickets?'}')) resetData()">${lang==='de'?'Alle Daten zurücksetzen':'Reset All Data'}</button>
|
||
</div>
|
||
|
||
<div class="btn-group">
|
||
<button class="btn btn-primary" onclick="saveSettings('general')">${lang==='de'?'Speichern':'Save'}</button>
|
||
</div>
|
||
`,
|
||
|
||
about: () => `
|
||
<h3>TokenVault</h3>
|
||
<p class="desc">${lang==='de'?'Hybrid MCP + Proxy Plattform für LLM Token-Einsparung':'Hybrid MCP + Proxy platform for LLM token savings'}</p>
|
||
|
||
<div style="display:grid;grid-template-columns:120px 1fr;gap:8px;font-size:13px;margin-bottom:20px">
|
||
<strong>Version</strong><span>0.1.0 (MVP)</span>
|
||
<strong>${lang==='de'?'Lizenz':'License'}</strong><span>Apache-2.0</span>
|
||
<strong>${lang==='de'?'Autor':'Author'}</strong><span>Context X (context-x.org)</span>
|
||
<strong>Gitea</strong><span><a href="https://gitea.context-x.org/rene/tokenvault" target="_blank" style="color:var(--primary)">rene/tokenvault</a></span>
|
||
<strong>Stack</strong><span>Fastify 5.x, PostgreSQL 17, Qdrant, TypeScript</span>
|
||
<strong>Packages</strong><span>@tokenvault/core, /mcp, /client, /dashboard</span>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Provider':'Providers'}</h3>
|
||
<div style="font-size:13px;line-height:2">
|
||
Anthropic (Claude) · OpenAI (GPT) · Google (Gemini) · Mistral · Groq · Cerebras · Ollama
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
<h3>${lang==='de'?'Funktionen':'Features'}</h3>
|
||
<div style="font-size:13px;line-height:2">
|
||
✓ OpenAI-${lang==='de'?'kompatibler Proxy':'compatible proxy'} ·
|
||
✓ Ticket-System (TV-00001) ·
|
||
✓ ${lang==='de'?'Kosten-Tracking':'Cost tracking'} ·
|
||
✓ ${lang==='de'?'Multi-Provider Routing':'Multi-provider routing'} ·
|
||
✓ MCP Server ·
|
||
✓ TypeScript SDK<br>
|
||
◻ AST-Compression (Phase 2) ·
|
||
◻ Semantic Cache (Phase 2) ·
|
||
◻ Prompt Caching (Phase 2) ·
|
||
◻ Budget Enforcement (Phase 3) ·
|
||
◻ Anomaly Detection (Phase 3)
|
||
</div>
|
||
`,
|
||
};
|
||
|
||
function showSettings(page) {
|
||
document.querySelectorAll('.settings-nav-item').forEach(i => i.classList.toggle('active', i.dataset.settings === page));
|
||
const panel = document.getElementById('settings-content');
|
||
panel.innerHTML = settingsPages[page]?.() ?? '<p>Not found</p>';
|
||
if (page === 'providers') updateProviderDots();
|
||
}
|
||
|
||
async function updateProviderDots() {
|
||
try {
|
||
const data = await (await fetch(API + '/health')).json();
|
||
data.providers.forEach(p => {
|
||
const dot = document.getElementById('dot-' + p.name);
|
||
if (dot) { dot.className = 'status-dot ' + (p.configured ? 'green' : 'red'); }
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
function saveSettings(section) {
|
||
// MVP: show toast, Phase 2 will persist to API
|
||
const toast = document.createElement('div');
|
||
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:var(--green);color:#fff;padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;z-index:999;animation:fadeIn 0.2s';
|
||
toast.textContent = lang === 'de' ? 'Einstellungen gespeichert' : 'Settings saved';
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 3000);
|
||
}
|
||
|
||
function addProjectBudget() {
|
||
const container = document.getElementById('project-budgets');
|
||
const row = document.createElement('div');
|
||
row.className = 'form-row';
|
||
row.style.marginBottom = '8px';
|
||
row.innerHTML = '<div class="form-group"><input type="text" placeholder="project-name" /></div><div class="form-group"><input type="number" placeholder="0.00" step="0.01" /></div>';
|
||
container.appendChild(row);
|
||
}
|
||
|
||
function testProviders() { saveSettings('providers'); }
|
||
function testNotification() { saveSettings('notifications'); }
|
||
function testDb() { saveSettings('database'); }
|
||
function flushCache() { if(confirm(lang==='de'?'Cache wirklich leeren?':'Flush cache?')) saveSettings('cache'); }
|
||
function resetData() { saveSettings('general'); }
|
||
|
||
// Init settings when tab shown
|
||
showSettings('providers');
|
||
</script>
|
||
</body>
|
||
</html>
|