Reconcile 6-week divergence: Gitea main (injection-defense, output-defense, prompt-guard-client, admin-auth, start-with-env, dashboard-v2, savings-calculator, race-mode, gamification + 13 more modules) merged with Erik's deployed features (usage-report endpoint, per-device entries, CEST timezone, cost-panel, bridge routing). ecosystem.config.cjs excluded (live token, never commit).
3516 lines
159 KiB
HTML
3516 lines
159 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script>
|
||
/* Force timestamps to Europe/Berlin (CEST/CET, auto-DST) */
|
||
(function(){var TZ="Europe/Berlin";["toLocaleString","toLocaleTimeString","toLocaleDateString"].forEach(function(fn){var o=Date.prototype[fn];Date.prototype[fn]=function(l,op){op=Object.assign({},op||{},{timeZone:TZ});return o.call(this,l||"de-DE",op);};});})();
|
||
</script>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>llm.gateway / workbench</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ─── Reset ──────────────────────────────────────────────────────────── */
|
||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body { background: #f4f7fa; color: #24313d; }
|
||
|
||
/* ─── Design tokens ──────────────────────────────────────────────────── */
|
||
:root {
|
||
--bg: #f4f7fa;
|
||
--bg-1: #ffffff;
|
||
--bg-2: #eef3f6;
|
||
--bg-3: #dde7ed;
|
||
--line: #d6e0e7;
|
||
--line-2: #bdc9d3;
|
||
--line-3: #8799a8;
|
||
--text: #24313d;
|
||
--dim: #667684;
|
||
--dim-2: #93a1ad;
|
||
--accent: #0f766e;
|
||
--accent-dim: #8ab9b5;
|
||
--warn: #b45309;
|
||
--err: #b42318;
|
||
--ok: #15803d;
|
||
--info: #2563eb;
|
||
--mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||
--sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--sans);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
min-height: 100vh;
|
||
background: var(--bg);
|
||
}
|
||
|
||
/* ─── Layout shell ──────────────────────────────────────────────────── */
|
||
.shell {
|
||
max-width: 1480px;
|
||
margin: 0 auto;
|
||
padding: 20px 32px 80px;
|
||
}
|
||
|
||
/* ─── Top bar ────────────────────────────────────────────────────────── */
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
padding: 16px 0;
|
||
border-bottom: 1px solid var(--line);
|
||
margin-bottom: 8px;
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 14px;
|
||
font-family: var(--mono);
|
||
}
|
||
.brand-mark {
|
||
font-weight: 700;
|
||
font-size: 1.05rem;
|
||
color: var(--text);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.brand-mark::before {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
margin-right: 10px;
|
||
background: var(--accent);
|
||
}
|
||
.brand-tag {
|
||
font-size: 0.72rem;
|
||
color: var(--dim);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
.brand-tag::before { content: '/ '; color: var(--dim-2); }
|
||
|
||
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
||
|
||
/* ─── Status strip ──────────────────────────────────────────────────── */
|
||
.status-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
padding: 10px 0 18px;
|
||
border-bottom: 1px solid var(--line);
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.status-cell {
|
||
padding: 4px 14px;
|
||
border-right: 1px solid var(--line);
|
||
color: var(--dim);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.status-cell:first-child { padding-left: 0; }
|
||
.status-cell:last-child { border-right: none; margin-left: auto; }
|
||
.status-cell .dot {
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--dim-2);
|
||
box-shadow: 0 0 0 0 currentColor;
|
||
}
|
||
.status-cell .dot.ok { background: var(--ok); box-shadow: 0 0 0 3px rgba(21,128,61,0.12); animation: pulse 2.4s infinite; }
|
||
.status-cell .dot.err { background: var(--err); }
|
||
.status-cell .label { color: var(--dim-2); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.68rem; }
|
||
.status-cell .val { color: var(--text); }
|
||
@keyframes pulse {
|
||
0%, 100% { box-shadow: 0 0 0 3px rgba(21,128,61,0.10); }
|
||
50% { box-shadow: 0 0 0 6px rgba(21,128,61,0.06); }
|
||
}
|
||
|
||
/* ─── Tab navigation ──────────────────────────────────────────────────── */
|
||
.tabs {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0;
|
||
border-bottom: 1px solid var(--line);
|
||
margin: 0 0 28px;
|
||
}
|
||
.tab-trigger {
|
||
background: none;
|
||
border: none;
|
||
color: var(--dim);
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
padding: 14px 16px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
letter-spacing: 0.02em;
|
||
white-space: nowrap;
|
||
transition: color 0.15s;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
}
|
||
.tab-trigger:hover { color: var(--text); }
|
||
.tab-trigger .tab-num {
|
||
color: var(--dim-2);
|
||
font-size: 0.7rem;
|
||
margin-right: 6px;
|
||
}
|
||
.tab-trigger.active {
|
||
color: var(--accent);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
.tab-trigger.active .tab-num { color: var(--accent-dim); }
|
||
.tab-trigger .tab-badge {
|
||
display: inline-block;
|
||
margin-left: 8px;
|
||
padding: 1px 6px;
|
||
border: 1px solid var(--line-2);
|
||
border-radius: 2px;
|
||
font-size: 0.62rem;
|
||
color: var(--dim);
|
||
}
|
||
.tab-trigger.active .tab-badge { border-color: var(--accent-dim); color: var(--accent); }
|
||
|
||
.tab-panel { display: none; animation: fadein 0.3s ease; }
|
||
.tab-panel.active { display: block; }
|
||
@keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||
|
||
/* ─── Section headings ────────────────────────────────────────────────── */
|
||
.h-section {
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--dim);
|
||
margin: 24px 0 14px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
}
|
||
.h-section::before { content: ''; width: 18px; height: 2px; background: var(--accent); margin-right: 8px; }
|
||
.h-section .h-meta { font-size: 0.7rem; color: var(--dim-2); letter-spacing: 0.05em; text-transform: none; }
|
||
|
||
/* ─── Metric grid (Overview tab) ──────────────────────────────────────── */
|
||
.metric-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
}
|
||
.metric {
|
||
padding: 22px 24px 20px;
|
||
border-right: 1px solid var(--line);
|
||
border-bottom: 1px solid var(--line);
|
||
position: relative;
|
||
transition: background 0.15s;
|
||
}
|
||
.metric:hover { background: var(--bg-2); }
|
||
.metric:last-child { border-right: none; }
|
||
.metric-label {
|
||
font-family: var(--mono);
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.16em;
|
||
color: var(--dim);
|
||
margin-bottom: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.metric-label::before {
|
||
content: '';
|
||
width: 6px; height: 6px;
|
||
background: var(--accent);
|
||
display: inline-block;
|
||
}
|
||
.metric-value {
|
||
font-family: var(--mono);
|
||
font-size: 2.1rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
letter-spacing: -0.02em;
|
||
line-height: 1;
|
||
}
|
||
.metric-value .metric-unit {
|
||
font-size: 0.85rem;
|
||
color: var(--dim);
|
||
font-weight: 400;
|
||
margin-left: 4px;
|
||
}
|
||
.metric-change {
|
||
font-family: var(--mono);
|
||
font-size: 0.7rem;
|
||
color: var(--dim-2);
|
||
margin-top: 8px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
/* ─── Two-column grid for sub/caller chips ────────────────────────────── */
|
||
.chip-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.chip {
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
padding: 14px 16px;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
.chip:hover { border-color: var(--line-3); background: var(--bg-2); }
|
||
.chip-name {
|
||
font-family: var(--mono);
|
||
font-size: 0.85rem;
|
||
color: var(--text);
|
||
margin-bottom: 6px;
|
||
word-break: break-all;
|
||
}
|
||
.chip-meta {
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
color: var(--dim);
|
||
}
|
||
.chip-meta .num { color: var(--accent); font-weight: 600; }
|
||
|
||
/* ─── Subscription cards ──────────────────────────────────────────────── */
|
||
.auto-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 16px 20px;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
margin-bottom: 18px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.auto-banner .banner-text {
|
||
flex: 1 1 auto;
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
color: var(--dim);
|
||
}
|
||
.auto-banner .banner-text strong { color: var(--accent); font-weight: 600; }
|
||
.auto-banner code {
|
||
font-family: var(--mono);
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line);
|
||
padding: 2px 8px;
|
||
color: var(--text);
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.subs-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.subs-card {
|
||
background: var(--bg-1);
|
||
padding: 18px 20px;
|
||
border-right: 1px solid var(--line);
|
||
border-bottom: 1px solid var(--line);
|
||
position: relative;
|
||
}
|
||
.subs-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 2px;
|
||
background: var(--dim-2);
|
||
}
|
||
.subs-card.installed::before { background: var(--info); }
|
||
.subs-card.running::before { background: var(--ok); }
|
||
.subs-card.missing { opacity: 0.55; }
|
||
.subs-card.missing::before { background: var(--line-2); }
|
||
|
||
.subs-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.subs-label {
|
||
font-weight: 600;
|
||
font-size: 0.95rem;
|
||
color: var(--text);
|
||
flex: 1 1 auto;
|
||
}
|
||
.subs-state {
|
||
font-family: var(--mono);
|
||
font-size: 0.65rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
padding: 3px 8px;
|
||
border: 1px solid var(--line-2);
|
||
color: var(--dim);
|
||
white-space: nowrap;
|
||
}
|
||
.subs-state.running { color: var(--accent); border-color: var(--accent-dim); }
|
||
.subs-state.installed { color: var(--info); border-color: rgba(37,99,235,0.24); }
|
||
.subs-state.missing { color: var(--dim-2); }
|
||
.subs-meta {
|
||
font-family: var(--mono);
|
||
font-size: 0.74rem;
|
||
color: var(--dim);
|
||
margin-bottom: 4px;
|
||
}
|
||
.subs-bridge-url, .subs-models {
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
color: var(--dim);
|
||
margin-top: 6px;
|
||
word-break: break-all;
|
||
}
|
||
.subs-bridge-url {
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
padding: 6px 10px;
|
||
color: var(--text);
|
||
}
|
||
.subs-models { color: var(--dim); }
|
||
.subs-models::before { content: 'models: '; color: var(--dim-2); }
|
||
.subs-install-hint {
|
||
font-family: var(--mono);
|
||
font-size: 0.7rem;
|
||
color: var(--warn);
|
||
background: rgba(180,83,9,0.08);
|
||
border: 1px solid rgba(180,83,9,0.22);
|
||
padding: 6px 10px;
|
||
margin-top: 8px;
|
||
}
|
||
.subs-install-hint code {
|
||
background: var(--bg);
|
||
padding: 1px 5px;
|
||
border-radius: 0;
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* ─── Knowledge Graph ─────────────────────────────────────────────── */
|
||
.graph-wrap { background: var(--bg-1); border: 1px solid var(--line); padding: 12px; }
|
||
.graph-wrap svg { width: 100%; height: 460px; display: block; }
|
||
.graph-wrap svg .node { cursor: pointer; transition: transform 0.15s; }
|
||
.graph-wrap svg .node:hover { transform: scale(1.1); }
|
||
.graph-wrap svg .node-caller { fill: var(--accent); }
|
||
.graph-wrap svg .node-fact-key { fill: #2563eb; }
|
||
.graph-wrap svg .node-fact-value { fill: #a78bfa; }
|
||
.graph-wrap svg .edge { stroke: var(--line-2); stroke-opacity: 0.6; fill: none; }
|
||
.graph-wrap svg text.label { font-family: var(--mono); font-size: 10px; fill: var(--text); pointer-events: none; }
|
||
.graph-legend {
|
||
display: flex; gap: 18px; margin-top: 10px; padding: 6px 12px;
|
||
background: var(--bg); border: 1px solid var(--line);
|
||
font-family: var(--mono); font-size: 0.74rem; color: var(--dim);
|
||
}
|
||
.graph-legend .dot { display: inline-block; width: 10px; height: 10px; margin-right: 6px; vertical-align: middle; }
|
||
|
||
/* ─── Leaderboard ─────────────────────────────────────────────────── */
|
||
.leaderboard-podium {
|
||
display: grid; grid-template-columns: 1fr 1.2fr 1fr;
|
||
gap: 12px; align-items: end; margin-bottom: 22px;
|
||
}
|
||
.podium-step {
|
||
padding: 18px 14px; border: 1px solid var(--line);
|
||
background: var(--bg-1); text-align: center;
|
||
display: flex; flex-direction: column; gap: 6px;
|
||
}
|
||
.podium-step.gold { background: #fefce8; border-color: #facc15; min-height: 200px; order: 2; }
|
||
.podium-step.silver { background: #f8fafc; border-color: #cbd5e1; min-height: 170px; order: 1; }
|
||
.podium-step.bronze { background: #fef3c7; border-color: #f59e0b; min-height: 150px; order: 3; }
|
||
.podium-rank { font-family: var(--mono); font-weight: 700; font-size: 1.4rem; color: var(--text); }
|
||
.podium-medal { font-size: 2.4rem; line-height: 1; }
|
||
.podium-model { font-family: var(--mono); font-weight: 600; font-size: 0.95rem; color: var(--text); word-break: break-all; }
|
||
.podium-stat { font-family: var(--mono); font-size: 0.78rem; color: var(--dim); }
|
||
.leaderboard-table { background: var(--bg-1); border: 1px solid var(--line); }
|
||
.lb-row {
|
||
display: grid; grid-template-columns: 40px 1fr 80px 80px 80px 80px;
|
||
gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--line);
|
||
font-family: var(--mono); font-size: 0.82rem; align-items: center;
|
||
}
|
||
.lb-row.head { background: var(--bg-2); color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.66rem; }
|
||
.lb-row:last-child { border-bottom: none; }
|
||
.lb-row .lb-pos { font-weight: 700; text-align: center; }
|
||
.lb-row .lb-num { text-align: right; }
|
||
.lb-row.medal-gold { background: rgba(250,204,21,0.06); }
|
||
.lb-row.medal-silver { background: rgba(203,213,225,0.10); }
|
||
.lb-row.medal-bronze { background: rgba(245,158,11,0.06); }
|
||
|
||
/* ─── Share + Report ──────────────────────────────────────────────── */
|
||
.share-controls {
|
||
display: flex; gap: 16px; flex-wrap: wrap; align-items: center;
|
||
padding: 14px; border: 1px solid var(--line); background: var(--bg-1);
|
||
margin-bottom: 12px;
|
||
}
|
||
.share-preview {
|
||
border: 1px solid var(--line); background: var(--bg-2);
|
||
padding: 12px; text-align: center;
|
||
}
|
||
.share-preview img { max-width: 100%; height: auto; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||
.share-url {
|
||
font-family: var(--mono); font-size: 0.78rem; color: var(--dim);
|
||
padding: 8px 12px; background: var(--bg); border: 1px solid var(--line);
|
||
margin-top: 8px; word-break: break-all;
|
||
}
|
||
.share-hint { font-size: 0.82rem; color: var(--dim); margin-top: 8px; }
|
||
.share-hint code { font-family: var(--mono); background: var(--bg-2); padding: 2px 6px; border-radius: 2px; }
|
||
|
||
/* ─── Caller deep-dive modal additions ──────────────────────────── */
|
||
.caller-summary {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 0; border: 1px solid var(--line); margin-bottom: 16px;
|
||
}
|
||
.caller-summary > div { padding: 10px 14px; border-right: 1px solid var(--line); }
|
||
.caller-summary > div:last-child { border-right: none; }
|
||
.caller-summary .label { font-size: 0.66rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; font-family: var(--mono); }
|
||
.caller-summary .val { font-family: var(--mono); font-size: 1rem; font-weight: 600; color: var(--text); margin-top: 4px; }
|
||
.caller-hour-bars { display: flex; gap: 2px; align-items: end; height: 60px; padding: 8px; border: 1px solid var(--line); background: var(--bg); }
|
||
.caller-hour-bars .bar { flex: 1; background: var(--accent); min-height: 1px; transition: height 0.2s; }
|
||
.caller-hour-axis { display: flex; gap: 2px; padding: 0 8px; font-family: var(--mono); font-size: 0.6rem; color: var(--dim-2); }
|
||
.caller-hour-axis > span { flex: 1; text-align: center; }
|
||
|
||
/* clickable caller chips */
|
||
.chip { cursor: pointer; }
|
||
.chip:hover { border-color: var(--accent); }
|
||
|
||
/* Layer breakdown under hero counter */
|
||
.hero-layer-breakdown {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
margin-top: 12px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
.layer-row {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
font-family: var(--mono); font-size: 0.78rem;
|
||
}
|
||
.layer-name { color: var(--dim); }
|
||
.layer-val { color: var(--text); font-weight: 600; }
|
||
|
||
/* ─── Simple Mode CSS — hide non-configured cards ───────────────────── */
|
||
body.simple-mode .subs-card.missing { display: none; }
|
||
body.simple-mode #savingsAxes .axis[data-empty="true"] { display: none; }
|
||
body.hide-empty-providers .provider-item[data-status="unconfigured"] { display: none; }
|
||
body.hide-empty-providers .wallet-card[data-status="unknown"] { display: none; }
|
||
|
||
/* In Simple Mode, hide the noisy "5-axis" header explainer */
|
||
body.simple-mode .h-section .h-meta:contains('LLM Gateway') { display: none; }
|
||
|
||
/* ─── Hero (Buddy + Savings + Cost-VS) ───────────────────────────────── */
|
||
.hero-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1.5fr 1.2fr;
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
margin-bottom: 22px;
|
||
overflow: hidden;
|
||
}
|
||
.hero-grid > div { padding: 22px 24px; border-right: 1px solid var(--line); }
|
||
.hero-grid > div:last-child { border-right: none; }
|
||
|
||
.hero-eyebrow {
|
||
font-family: var(--mono);
|
||
font-size: 0.66rem;
|
||
letter-spacing: 0.2em;
|
||
text-transform: uppercase;
|
||
color: var(--dim);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* Buddy */
|
||
.hero-buddy { display: flex; flex-direction: column; gap: 8px; }
|
||
.buddy-name { font-weight: 700; font-size: 1.1rem; color: var(--text); }
|
||
.buddy-rarity {
|
||
display: inline-block; font-family: var(--mono); font-size: 0.62rem;
|
||
padding: 2px 8px; border: 1px solid var(--line-2); margin-left: 6px;
|
||
letter-spacing: 0.1em; text-transform: uppercase; vertical-align: middle;
|
||
}
|
||
.buddy-rarity.legendary { color: #b45309; border-color: #b45309; background: rgba(180,83,9,0.06); }
|
||
.buddy-rarity.epic { color: #7c3aed; border-color: #7c3aed; background: rgba(124,58,237,0.06); }
|
||
.buddy-rarity.rare { color: #2563eb; border-color: #2563eb; background: rgba(37,99,235,0.06); }
|
||
.buddy-rarity.uncommon { color: var(--accent); border-color: var(--accent); background: rgba(15,118,110,0.06); }
|
||
.buddy-rarity.common { color: var(--dim); }
|
||
.buddy-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--dim); }
|
||
.buddy-art {
|
||
font-family: var(--mono); font-size: 0.8rem; line-height: 1.1;
|
||
white-space: pre; color: var(--accent);
|
||
padding: 8px; background: var(--bg);
|
||
border: 1px solid var(--line); margin: 4px 0;
|
||
}
|
||
.buddy-xp-bar {
|
||
height: 6px; background: var(--bg-3); border-radius: 1px;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.buddy-xp-fill {
|
||
height: 100%; background: linear-gradient(90deg, var(--accent), #2dd4bf);
|
||
transition: width 0.4s;
|
||
}
|
||
.buddy-xp-text {
|
||
font-family: var(--mono); font-size: 0.7rem; color: var(--dim-2);
|
||
display: flex; justify-content: space-between;
|
||
}
|
||
.buddy-speech {
|
||
font-style: italic; font-size: 0.84rem; color: var(--text);
|
||
padding: 8px 12px; background: var(--bg-2); border-left: 2px solid var(--accent);
|
||
margin-top: 6px;
|
||
}
|
||
.buddy-mood-happy::before { content: '😊 '; }
|
||
.buddy-mood-content::before { content: '😌 '; }
|
||
.buddy-mood-sleepy::before { content: '😴 '; }
|
||
.buddy-mood-hungry::before { content: '🍴 '; }
|
||
.buddy-mood-excited::before { content: '🤩 '; }
|
||
|
||
/* Hero savings counter */
|
||
.hero-savings { display: flex; flex-direction: column; gap: 14px; }
|
||
.hero-counter {
|
||
font-family: var(--mono); font-size: 3.6rem; font-weight: 700;
|
||
color: var(--accent); letter-spacing: -0.03em; line-height: 0.95;
|
||
}
|
||
.hero-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||
.hero-pill {
|
||
flex: 1 1 100px; padding: 8px 12px; border: 1px solid var(--line);
|
||
background: var(--bg); display: flex; flex-direction: column; gap: 2px;
|
||
}
|
||
.hero-pill-label {
|
||
font-family: var(--mono); font-size: 0.62rem; color: var(--dim-2);
|
||
letter-spacing: 0.1em; text-transform: uppercase;
|
||
}
|
||
.hero-pill-val { font-family: var(--mono); font-size: 1.05rem; font-weight: 600; color: var(--text); }
|
||
|
||
/* Cost-VS comparison */
|
||
.hero-cost { display: flex; flex-direction: column; gap: 10px; }
|
||
.cost-vs { display: flex; align-items: center; gap: 10px; }
|
||
.cost-side { flex: 1; padding: 10px 14px; border: 1px solid var(--line); }
|
||
.cost-side.without { background: rgba(180,35,24,0.04); border-color: rgba(180,35,24,0.2); }
|
||
.cost-side.with { background: rgba(15,118,110,0.06); border-color: rgba(15,118,110,0.3); }
|
||
.cost-label {
|
||
font-family: var(--mono); font-size: 0.62rem; color: var(--dim-2);
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.cost-amount {
|
||
font-family: var(--mono); font-weight: 700; font-size: 1.6rem;
|
||
letter-spacing: -0.02em; margin-top: 2px;
|
||
}
|
||
.cost-side.without .cost-amount { color: var(--err); }
|
||
.cost-side.with .cost-amount { color: var(--accent); }
|
||
.cost-arrow { color: var(--dim-2); font-size: 1.4rem; }
|
||
.cost-saved-line { font-size: 0.84rem; color: var(--text); }
|
||
.cost-saved-line strong { color: var(--accent); font-weight: 700; }
|
||
|
||
/* Savings axes (5-source breakdown) */
|
||
.savings-axes {
|
||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 0;
|
||
border: 1px solid var(--line); background: var(--bg-1);
|
||
}
|
||
.axis {
|
||
padding: 14px 16px; border-right: 1px solid var(--line);
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.axis:last-child { border-right: none; }
|
||
.axis-label {
|
||
font-family: var(--mono); font-size: 0.66rem; color: var(--dim);
|
||
letter-spacing: 0.1em; text-transform: uppercase;
|
||
}
|
||
.axis-icon { font-size: 1.2rem; }
|
||
.axis-cost {
|
||
font-family: var(--mono); font-weight: 700; font-size: 1.3rem;
|
||
color: var(--accent);
|
||
}
|
||
.axis-detail { font-family: var(--mono); font-size: 0.7rem; color: var(--dim-2); }
|
||
|
||
/* Two-column overview rows */
|
||
.overview-row-2col {
|
||
display: grid; grid-template-columns: 1fr 1fr; gap: 28px;
|
||
margin-top: 22px;
|
||
}
|
||
|
||
/* Calendar heatmap */
|
||
.heatmap {
|
||
display: grid; grid-template-columns: repeat(53, 11px);
|
||
grid-auto-rows: 11px; gap: 2px;
|
||
padding: 12px; border: 1px solid var(--line); background: var(--bg-1);
|
||
}
|
||
.heatmap-cell { width: 11px; height: 11px; border-radius: 2px; background: var(--bg-3); cursor: pointer; transition: transform 0.1s; }
|
||
.heatmap-cell:hover { transform: scale(1.4); outline: 1px solid var(--accent); }
|
||
.heatmap-cell.l1 { background: #2dd4bf40; }
|
||
.heatmap-cell.l2 { background: #2dd4bf80; }
|
||
.heatmap-cell.l3 { background: #2dd4bfc0; }
|
||
.heatmap-cell.l4 { background: var(--accent); }
|
||
|
||
/* Forecast */
|
||
.forecast { padding: 18px; border: 1px solid var(--line); background: var(--bg-1); }
|
||
.forecast-row {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
padding: 8px 0; border-bottom: 1px solid var(--line);
|
||
}
|
||
.forecast-row:last-child { border-bottom: none; }
|
||
.forecast-window { font-family: var(--mono); font-size: 0.72rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; }
|
||
.forecast-amount { font-family: var(--mono); font-weight: 700; color: var(--accent); font-size: 1.1rem; }
|
||
.forecast-trend {
|
||
font-family: var(--mono); font-size: 0.78rem; padding-top: 8px;
|
||
color: var(--dim);
|
||
}
|
||
.forecast-trend.up { color: var(--accent); }
|
||
.forecast-trend.down { color: var(--err); }
|
||
.forecast-trend::before { content: '→ '; }
|
||
.forecast-trend.up::before { content: '↗ '; }
|
||
.forecast-trend.down::before { content: '↘ '; }
|
||
|
||
/* Live events feed */
|
||
.events-feed {
|
||
max-height: 380px; overflow-y: auto;
|
||
border: 1px solid var(--line); background: var(--bg-1);
|
||
font-family: var(--mono);
|
||
}
|
||
.event-row {
|
||
display: grid; grid-template-columns: auto 1fr auto; gap: 10px;
|
||
padding: 8px 14px; border-bottom: 1px solid var(--line);
|
||
font-size: 0.78rem; align-items: center;
|
||
}
|
||
.event-row:last-child { border-bottom: none; }
|
||
.event-row:hover { background: var(--bg-2); }
|
||
.event-icon { font-size: 1rem; }
|
||
.event-body { color: var(--text); }
|
||
.event-caller { color: var(--accent); font-weight: 600; }
|
||
.event-detail { color: var(--dim); margin-top: 2px; font-size: 0.7rem; }
|
||
.event-time { color: var(--dim-2); font-size: 0.68rem; }
|
||
|
||
/* Achievements */
|
||
.achievements-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.achievement {
|
||
padding: 12px 14px; border: 1px solid var(--line); background: var(--bg-1);
|
||
display: flex; gap: 12px; align-items: flex-start;
|
||
transition: transform 0.15s, border-color 0.15s;
|
||
}
|
||
.achievement.unlocked { border-color: var(--accent); }
|
||
.achievement.unlocked:hover { transform: translateY(-2px); }
|
||
.achievement.locked { opacity: 0.45; filter: grayscale(0.8); }
|
||
.ach-icon { font-size: 1.6rem; line-height: 1; }
|
||
.ach-info { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||
.ach-title { font-weight: 600; font-size: 0.88rem; color: var(--text); }
|
||
.ach-desc { font-size: 0.74rem; color: var(--dim); font-family: var(--mono); }
|
||
|
||
/* Streak badge */
|
||
#streakBadge { color: var(--accent); font-weight: 700; }
|
||
|
||
@media (max-width: 1100px) {
|
||
.hero-grid { grid-template-columns: 1fr; }
|
||
.hero-grid > div { border-right: none; border-bottom: 1px solid var(--line); }
|
||
.savings-axes { grid-template-columns: repeat(2, 1fr); }
|
||
.axis { border-bottom: 1px solid var(--line); }
|
||
.overview-row-2col { grid-template-columns: 1fr; }
|
||
.heatmap { grid-template-columns: repeat(26, 11px); }
|
||
}
|
||
|
||
/* ─── Savings ───────────────────────────────────────────────────────── */
|
||
.savings-hero {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1.4fr;
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
}
|
||
.savings-headline {
|
||
padding: 28px 32px;
|
||
border-right: 1px solid var(--line);
|
||
}
|
||
.savings-eyebrow {
|
||
font-family: var(--mono);
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.18em;
|
||
color: var(--dim);
|
||
margin-bottom: 10px;
|
||
}
|
||
.savings-counter {
|
||
font-family: var(--mono);
|
||
font-size: 3.4rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
line-height: 1;
|
||
color: var(--accent);
|
||
transition: color 0.4s;
|
||
}
|
||
.savings-sub {
|
||
font-family: var(--mono);
|
||
font-size: 0.85rem;
|
||
color: var(--dim);
|
||
margin-top: 14px;
|
||
}
|
||
.savings-spark {
|
||
padding: 24px 32px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.savings-spark svg {
|
||
width: 100%;
|
||
height: 80px;
|
||
}
|
||
.savings-spark svg path.area { fill: rgba(15,118,110,0.10); stroke: none; }
|
||
.savings-spark svg path.line { fill: none; stroke: var(--accent); stroke-width: 1.4; }
|
||
.savings-spark svg circle.last { fill: var(--accent); }
|
||
.savings-spark-meta {
|
||
display: flex; justify-content: space-between;
|
||
font-family: var(--mono); font-size: 0.7rem;
|
||
color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.savings-spark-meta #savingsHitRate { color: var(--accent); }
|
||
|
||
/* ─── Wallet ───────────────────────────────────────────────────────── */
|
||
.wallet-banner {
|
||
padding: 14px 18px;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
margin-bottom: 18px;
|
||
font-size: 0.86rem;
|
||
color: var(--dim);
|
||
}
|
||
.wallet-banner strong { color: var(--accent); font-weight: 600; }
|
||
.wallet-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.wallet-card {
|
||
background: var(--bg-1);
|
||
padding: 18px 20px 16px;
|
||
border-right: 1px solid var(--line);
|
||
border-bottom: 1px solid var(--line);
|
||
position: relative;
|
||
}
|
||
.wallet-head {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
margin-bottom: 10px;
|
||
}
|
||
.wallet-label {
|
||
font-weight: 600; font-size: 0.95rem; color: var(--text);
|
||
}
|
||
.wallet-rec {
|
||
font-family: var(--mono); font-size: 0.65rem;
|
||
letter-spacing: 0.1em; text-transform: uppercase;
|
||
padding: 2px 8px; border: 1px solid var(--line-2);
|
||
color: var(--dim);
|
||
}
|
||
.wallet-rec.use-this { color: var(--ok); border-color: rgba(21,128,61,0.4); background: rgba(21,128,61,0.05); }
|
||
.wallet-rec.available { color: var(--info); border-color: rgba(37,99,235,0.4); }
|
||
.wallet-rec.near-limit { color: var(--warn); border-color: rgba(180,83,9,0.4); background: rgba(180,83,9,0.05); }
|
||
.wallet-rec.exhausted { color: var(--err); border-color: rgba(180,35,24,0.4); background: rgba(180,35,24,0.05); }
|
||
.wallet-rec.unknown { color: var(--dim-2); }
|
||
|
||
.wallet-bar {
|
||
height: 8px;
|
||
background: var(--bg-2);
|
||
border-radius: 1px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
margin: 12px 0 10px;
|
||
}
|
||
.wallet-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
transition: width 0.4s ease;
|
||
}
|
||
.wallet-bar-fill.warn { background: var(--warn); }
|
||
.wallet-bar-fill.err { background: var(--err); }
|
||
.wallet-meta {
|
||
display: flex; justify-content: space-between;
|
||
font-family: var(--mono); font-size: 0.74rem;
|
||
color: var(--dim);
|
||
}
|
||
.wallet-meta strong { color: var(--text); font-weight: 600; }
|
||
.wallet-reset {
|
||
font-family: var(--mono); font-size: 0.7rem;
|
||
color: var(--dim-2); margin-top: 6px;
|
||
}
|
||
|
||
/* ─── Memory ───────────────────────────────────────────────────────── */
|
||
.memory-form {
|
||
display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px;
|
||
align-items: center;
|
||
}
|
||
.mem-list {
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
}
|
||
.mem-row {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: grid;
|
||
grid-template-columns: 1.2fr 2fr 1fr;
|
||
gap: 12px;
|
||
align-items: center;
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
}
|
||
.mem-row:last-child { border-bottom: none; }
|
||
.mem-key { font-weight: 600; color: var(--accent); }
|
||
.mem-val { color: var(--text); }
|
||
.mem-meta { color: var(--dim-2); font-size: 0.72rem; text-align: right; }
|
||
|
||
/* ─── Providers ──────────────────────────────────────────────────────── */
|
||
.providers-stack > section { margin-bottom: 22px; }
|
||
.providers-stack > section:last-child { margin-bottom: 0; }
|
||
.providers-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||
gap: 0;
|
||
border: 1px solid var(--line);
|
||
}
|
||
.provider-item {
|
||
background: var(--bg-1);
|
||
border-right: 1px solid var(--line);
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 14px 16px;
|
||
}
|
||
.provider-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.provider-name { font-weight: 600; color: var(--text); font-size: 0.9rem; }
|
||
.provider-tech-name {
|
||
font-family: var(--mono); font-size: 0.7rem; color: var(--dim-2);
|
||
margin-bottom: 6px;
|
||
}
|
||
.provider-tag {
|
||
font-family: var(--mono); font-size: 0.62rem;
|
||
letter-spacing: 0.1em; text-transform: uppercase;
|
||
padding: 2px 8px;
|
||
border: 1px solid var(--line-2);
|
||
color: var(--dim);
|
||
white-space: nowrap;
|
||
}
|
||
.provider-tag.tag-configured { color: var(--ok); border-color: rgba(21,128,61,0.24); }
|
||
.provider-tag.tag-unconfigured { color: var(--dim); }
|
||
.provider-runtime {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-family: var(--mono);
|
||
font-size: 0.68rem;
|
||
color: var(--dim-2);
|
||
margin-top: 8px;
|
||
min-height: 18px;
|
||
}
|
||
.provider-runtime .runtime-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: var(--dim-2);
|
||
flex: 0 0 auto;
|
||
}
|
||
.provider-runtime.runtime-ready { color: var(--ok); }
|
||
.provider-runtime.runtime-ready .runtime-dot { background: var(--ok); box-shadow: 0 0 0 3px rgba(21,128,61,0.12); }
|
||
.provider-runtime.runtime-warn { color: var(--warn); }
|
||
.provider-runtime.runtime-warn .runtime-dot { background: var(--warn); box-shadow: 0 0 0 3px rgba(180,83,9,0.12); }
|
||
.provider-runtime.runtime-muted { color: var(--dim); }
|
||
.provider-runtime.runtime-muted .runtime-dot { background: var(--dim); }
|
||
.provider-models {
|
||
font-family: var(--mono); font-size: 0.72rem; color: var(--dim);
|
||
margin-top: 4px; word-break: break-all;
|
||
}
|
||
.provider-rate {
|
||
font-family: var(--mono); font-size: 0.68rem; color: var(--dim-2);
|
||
margin-top: 4px;
|
||
}
|
||
.provider-env-hint {
|
||
font-family: var(--mono); font-size: 0.68rem;
|
||
color: var(--warn);
|
||
background: rgba(180,83,9,0.08);
|
||
border: 1px solid rgba(180,83,9,0.22);
|
||
padding: 4px 8px;
|
||
margin-top: 6px;
|
||
}
|
||
.provider-env-hint code { background: var(--bg); padding: 1px 4px; color: var(--accent); }
|
||
|
||
/* ─── Activity / Requests table ───────────────────────────────────────── */
|
||
.filters {
|
||
display: flex; gap: 4px;
|
||
margin-bottom: 14px;
|
||
font-family: var(--mono);
|
||
}
|
||
.filter-btn {
|
||
background: transparent;
|
||
border: 1px solid var(--line);
|
||
color: var(--dim);
|
||
padding: 6px 16px;
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.filter-btn:hover { color: var(--text); border-color: var(--line-3); }
|
||
.filter-btn.active {
|
||
color: var(--accent);
|
||
border-color: var(--accent-dim);
|
||
background: rgba(15,118,110,0.08);
|
||
}
|
||
|
||
.req-table {
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
}
|
||
.req-row {
|
||
display: grid;
|
||
grid-template-columns: 1.15fr 1fr 1.2fr 0.7fr 0.7fr 0.7fr 0.65fr 0.9fr 0.75fr 0.7fr;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--line);
|
||
align-items: center;
|
||
}
|
||
.req-row.head {
|
||
background: var(--bg-2);
|
||
color: var(--dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
font-size: 0.66rem;
|
||
}
|
||
.req-row:last-child { border-bottom: none; }
|
||
.req-row.body:hover { background: var(--bg-2); }
|
||
.req-row > div { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.req-status {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
font-size: 0.66rem;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
border: 1px solid var(--line-2);
|
||
}
|
||
.req-status.approved { color: var(--ok); border-color: rgba(21,128,61,0.24); }
|
||
.req-status.error, .req-status.rejected { color: var(--err); border-color: rgba(180,35,24,0.24); }
|
||
.req-status.warning, .req-status.pending_review { color: var(--warn); border-color: rgba(180,83,9,0.24); }
|
||
|
||
.client-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.client-item {
|
||
border: 1px solid var(--line);
|
||
background: var(--bg-1);
|
||
padding: 12px 14px;
|
||
font-family: var(--mono);
|
||
min-height: 88px;
|
||
}
|
||
.client-top {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
margin-bottom: 9px;
|
||
}
|
||
.client-name {
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
font-size: 0.78rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.client-state {
|
||
border: 1px solid var(--line-2);
|
||
padding: 2px 7px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
font-size: 0.62rem;
|
||
color: var(--dim);
|
||
white-space: nowrap;
|
||
}
|
||
.client-state.live {
|
||
color: var(--ok);
|
||
border-color: rgba(21,128,61,0.28);
|
||
background: rgba(21,128,61,0.06);
|
||
}
|
||
.client-state.not-connected {
|
||
color: var(--warn);
|
||
border-color: rgba(180,83,9,0.26);
|
||
background: rgba(180,83,9,0.06);
|
||
}
|
||
.client-meta {
|
||
color: var(--dim);
|
||
font-size: 0.72rem;
|
||
line-height: 1.55;
|
||
}
|
||
.client-meta strong {
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 40px 20px;
|
||
text-align: center;
|
||
color: var(--dim-2);
|
||
font-family: var(--mono);
|
||
font-size: 0.85rem;
|
||
}
|
||
.loading {
|
||
padding: 30px 20px;
|
||
text-align: center;
|
||
color: var(--dim);
|
||
font-family: var(--mono);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* ─── Discover Panel ──────────────────────────────────────────────── */
|
||
.discover-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 12px; margin-bottom: 16px;
|
||
}
|
||
.discover-card {
|
||
border: 1px solid var(--line-2);
|
||
border-radius: 10px;
|
||
padding: 12px 14px;
|
||
background: var(--surface-1, rgba(255,255,255,0.02));
|
||
}
|
||
.discover-card-title {
|
||
font-size: 0.72rem; color: var(--text-muted, #888);
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
margin-bottom: 4px;
|
||
}
|
||
.discover-card-stat {
|
||
font-family: var(--mono); font-size: 1.4rem;
|
||
color: var(--accent); margin-bottom: 8px;
|
||
}
|
||
.discover-card-list {
|
||
list-style: none; padding: 0; margin: 0;
|
||
font-size: 0.78rem; font-family: var(--mono);
|
||
}
|
||
.discover-card-list li {
|
||
padding: 4px 0;
|
||
border-top: 1px solid var(--line-1, rgba(255,255,255,0.05));
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.discover-card-list li:first-child { border-top: none; }
|
||
.discover-card-list .disc-ok { color: var(--accent); }
|
||
.discover-card-list .disc-no { color: var(--text-muted, #888); opacity: 0.6; }
|
||
|
||
/* ─── API Tab ──────────────────────────────────────────────────────── */
|
||
.api-card {
|
||
border: 1px solid var(--line-2);
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 14px;
|
||
background: var(--surface-1, rgba(255,255,255,0.02));
|
||
}
|
||
.api-card-head {
|
||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||
margin-bottom: 10px;
|
||
}
|
||
.api-method {
|
||
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
||
padding: 3px 8px; border-radius: 4px;
|
||
background: var(--accent); color: #fff; letter-spacing: 0.05em;
|
||
}
|
||
.api-path {
|
||
font-family: var(--mono); font-size: 0.92rem;
|
||
color: var(--text);
|
||
}
|
||
.api-tag {
|
||
font-size: 0.72rem; color: var(--text-muted, #888);
|
||
font-style: italic; flex: 1;
|
||
}
|
||
.api-snippet {
|
||
font-family: var(--mono); font-size: 0.8rem;
|
||
background: var(--surface-2, rgba(0,0,0,0.25));
|
||
border: 1px solid var(--line-1, rgba(255,255,255,0.05));
|
||
padding: 12px 14px; border-radius: 6px;
|
||
overflow-x: auto; white-space: pre;
|
||
color: var(--text); margin: 0;
|
||
}
|
||
.api-snippet code { background: transparent; padding: 0; }
|
||
.api-copy { padding: 4px 12px; font-size: 0.7rem; }
|
||
|
||
.api-tryout {
|
||
border: 1px solid var(--line-2);
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
background: var(--surface-1, rgba(255,255,255,0.02));
|
||
}
|
||
.api-tryout-row { display: flex; flex-wrap: wrap; align-items: center; }
|
||
|
||
.api-bridge-table-wrap { overflow-x: auto; border: 1px solid var(--line-2); border-radius: 10px; }
|
||
.api-bridge-table {
|
||
width: 100%; border-collapse: collapse; font-size: 0.85rem;
|
||
}
|
||
.api-bridge-table th, .api-bridge-table td {
|
||
padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--line-1, rgba(255,255,255,0.05));
|
||
}
|
||
.api-bridge-table th {
|
||
font-weight: 600; color: var(--text-muted, #888);
|
||
text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.72rem;
|
||
}
|
||
.api-bridge-table tr:last-child td { border-bottom: none; }
|
||
.api-bridge-status { font-family: var(--mono); font-size: 0.78rem; }
|
||
.api-bridge-status.ok { color: var(--accent); }
|
||
.api-bridge-status.err { color: #e34; }
|
||
|
||
/* ─── Buttons ────────────────────────────────────────────────────────── */
|
||
.btn {
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
padding: 8px 18px;
|
||
border: 1px solid var(--line-2);
|
||
background: transparent;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
letter-spacing: 0.02em;
|
||
transition: all 0.15s;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
background: rgba(15,118,110,0.06);
|
||
}
|
||
.btn.primary {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
background: rgba(15,118,110,0.08);
|
||
}
|
||
.btn.primary:hover { background: var(--accent); color: #ffffff; }
|
||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.btn-sm { padding: 5px 12px; font-size: 0.72rem; }
|
||
|
||
/* ─── Settings modal ─────────────────────────────────────────────────── */
|
||
.modal-overlay {
|
||
position: fixed; inset: 0;
|
||
background: rgba(56,68,82,0.28);
|
||
backdrop-filter: blur(2px);
|
||
display: none;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
overflow-y: auto;
|
||
padding: 40px 16px;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal {
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line-2);
|
||
max-width: 760px;
|
||
width: 100%;
|
||
max-height: calc(100vh - 80px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 24px 60px rgba(75,91,108,0.20);
|
||
}
|
||
.modal-header {
|
||
padding: 18px 24px;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
}
|
||
.modal-header h2 {
|
||
font-family: var(--mono);
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
.modal-header h2::before { content: ''; display: inline-block; width: 7px; height: 7px; margin-right: 9px; background: var(--accent); }
|
||
.modal-close {
|
||
background: none;
|
||
border: 1px solid var(--line-2);
|
||
width: 32px; height: 32px;
|
||
cursor: pointer;
|
||
color: var(--dim);
|
||
font-size: 1.1rem;
|
||
line-height: 1;
|
||
transition: all 0.15s;
|
||
}
|
||
.modal-close:hover { color: var(--err); border-color: var(--err); }
|
||
.modal-body { padding: 22px 24px; overflow-y: auto; flex: 1 1 auto; }
|
||
.modal-footer {
|
||
padding: 14px 24px;
|
||
border-top: 1px solid var(--line);
|
||
display: flex; gap: 10px; justify-content: flex-end; align-items: center;
|
||
}
|
||
.modal-footer .save-status {
|
||
margin-right: auto;
|
||
font-family: var(--mono);
|
||
font-size: 0.78rem;
|
||
color: var(--dim);
|
||
}
|
||
.modal-footer .save-status.ok { color: var(--ok); }
|
||
.modal-footer .save-status.err { color: var(--err); }
|
||
|
||
.settings-section { margin-bottom: 26px; }
|
||
.settings-section:last-child { margin-bottom: 0; }
|
||
.settings-section-title {
|
||
font-family: var(--mono);
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
color: var(--accent);
|
||
margin-bottom: 6px;
|
||
padding-bottom: 6px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.settings-section-title::before { content: ''; display: inline-block; width: 14px; height: 2px; margin-right: 8px; background: var(--accent-dim); vertical-align: middle; }
|
||
.settings-section-desc { font-size: 0.78rem; color: var(--dim); margin-bottom: 10px; }
|
||
|
||
.settings-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 12px;
|
||
align-items: center;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.settings-row:last-child { border-bottom: none; }
|
||
.settings-row-info { display: flex; flex-direction: column; gap: 2px; }
|
||
.settings-row-label { font-weight: 600; font-size: 0.88rem; color: var(--text); }
|
||
.settings-row-meta {
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
color: var(--dim);
|
||
}
|
||
|
||
.settings-toggle { position: relative; width: 42px; height: 22px; flex-shrink: 0; }
|
||
.settings-toggle input { opacity: 0; width: 0; height: 0; }
|
||
.settings-toggle .slider {
|
||
position: absolute; cursor: pointer;
|
||
inset: 0;
|
||
background: var(--bg-3);
|
||
border: 1px solid var(--line-2);
|
||
transition: 0.2s;
|
||
}
|
||
.settings-toggle .slider::before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 14px; height: 14px;
|
||
left: 3px; top: 50%; transform: translateY(-50%);
|
||
background: var(--dim);
|
||
transition: 0.2s;
|
||
}
|
||
.settings-toggle input:checked + .slider {
|
||
border-color: var(--accent-dim);
|
||
background: rgba(15,118,110,0.10);
|
||
}
|
||
.settings-toggle input:checked + .slider::before {
|
||
transform: translate(20px, -50%);
|
||
background: var(--accent);
|
||
box-shadow: none;
|
||
}
|
||
|
||
.settings-input {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line-2);
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
margin-top: 6px;
|
||
}
|
||
.settings-input:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px rgba(15,118,110,0.16);
|
||
}
|
||
|
||
.settings-radio-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.settings-radio {
|
||
flex: 1 1 calc(50% - 4px);
|
||
min-width: 140px;
|
||
padding: 10px 14px;
|
||
border: 1px solid var(--line-2);
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-family: var(--mono);
|
||
text-align: center;
|
||
transition: all 0.15s;
|
||
color: var(--dim);
|
||
}
|
||
.settings-radio:hover { color: var(--text); border-color: var(--line-3); }
|
||
.settings-radio.active {
|
||
border-color: var(--accent);
|
||
background: rgba(15,118,110,0.08);
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.settings-radio input { display: none; }
|
||
|
||
/* ─── Floating connection indicator ──────────────────────────────────── */
|
||
.conn-pill {
|
||
position: fixed;
|
||
bottom: 16px; right: 16px;
|
||
padding: 6px 14px;
|
||
background: var(--bg-1);
|
||
border: 1px solid var(--line-2);
|
||
font-family: var(--mono);
|
||
font-size: 0.72rem;
|
||
color: var(--dim);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
z-index: 50;
|
||
}
|
||
.conn-pill .dot {
|
||
width: 6px; height: 6px;
|
||
background: var(--ok);
|
||
box-shadow: 0 0 0 3px rgba(21,128,61,0.10);
|
||
animation: pulse 2.4s infinite;
|
||
}
|
||
|
||
/* ─── Responsive ─────────────────────────────────────────────────────── */
|
||
@media (max-width: 768px) {
|
||
.shell { padding: 16px 18px 60px; }
|
||
.topbar { flex-direction: column; align-items: flex-start; gap: 12px; }
|
||
.metric { padding: 16px 18px; }
|
||
.metric-value { font-size: 1.7rem; }
|
||
.req-row { grid-template-columns: 1fr 1fr; gap: 6px; padding: 10px; font-size: 0.72rem; }
|
||
.req-row.head { display: none; }
|
||
.req-row > div:nth-child(n+5) { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell">
|
||
|
||
<!-- ─── Top bar ──────────────────────────────────────────────────────── -->
|
||
<div class="topbar">
|
||
<div class="brand">
|
||
<span class="brand-mark">llm.gateway</span>
|
||
<span class="brand-tag">gateway workbench · v1.0</span>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<button class="btn btn-sm" id="settingsBtn" type="button" title="Configure subscriptions and API keys">
|
||
⚙ settings
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Status strip ─────────────────────────────────────────────────── -->
|
||
<div class="status-strip">
|
||
<div class="status-cell">
|
||
<span class="dot ok" id="dbStatusIndicator"></span>
|
||
<span class="label">db</span>
|
||
<span class="val" id="dbStatus">connecting</span>
|
||
</div>
|
||
<div class="status-cell">
|
||
<span class="dot ok" id="pollingStatusIndicator"></span>
|
||
<span class="label">poll</span>
|
||
<span class="val" id="pollingStatus">starting</span>
|
||
</div>
|
||
<div class="status-cell">
|
||
<span class="label">interval</span>
|
||
<span class="val" id="pollInterval">3s</span>
|
||
</div>
|
||
<div class="status-cell">
|
||
<span class="label">mode</span>
|
||
<span class="val" id="routingModeBadge">auto</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Tab bar ──────────────────────────────────────────────────────── -->
|
||
<nav class="tabs" role="tablist">
|
||
<button class="tab-trigger active" data-tab="overview" role="tab" title="Headline stats: tokens saved, cost, buddy, achievements"><span class="tab-num">01</span>overview</button>
|
||
<button class="tab-trigger" data-tab="subscriptions" role="tab" title="Your CLI subscriptions (Claude Code, Codex, …) and their bridge status"><span class="tab-num">02</span>subscriptions <span class="tab-badge" id="subsTabBadge">·</span></button>
|
||
<button class="tab-trigger" data-tab="providers" role="tab" title="All configured LLM providers (local Ollama, paid APIs, free tiers) — advanced"><span class="tab-num">03</span>providers <span class="tab-badge" id="providersTabBadge">·</span></button>
|
||
<button class="tab-trigger" data-tab="activity" role="tab" title="Live request log — every call that went through the gateway"><span class="tab-num">04</span>activity</button>
|
||
<button class="tab-trigger" data-tab="savings" role="tab" title="Cost & token savings counter — main 'wow how much I saved' page"><span class="tab-num">05</span>savings <span class="tab-badge" id="savingsTabBadge">·</span></button>
|
||
<button class="tab-trigger" data-tab="wallet" role="tab" title="Subscription quotas — how much of each Pro plan you've used in the current window"><span class="tab-num">06</span>wallet <span class="tab-badge" id="walletTabBadge">·</span></button>
|
||
<button class="tab-trigger" data-tab="memory" role="tab" title="Persistent facts the gateway knows about each caller — auto-injected into prompts"><span class="tab-num">07</span>memory</button>
|
||
<button class="tab-trigger" data-tab="leaderboard" role="tab" title="Race-mode results — fastest model leaderboard if you ran multi-model races"><span class="tab-num">08</span>races <span class="tab-badge" id="leaderboardTabBadge">·</span></button>
|
||
<button class="tab-trigger" data-tab="share" role="tab" title="Generate an embeddable SVG card showing your savings (for blog/Twitter/README)"><span class="tab-num">09</span>share</button>
|
||
<button class="tab-trigger" data-tab="report" role="tab" title="Generate a printable monthly PDF report"><span class="tab-num">10</span>report</button>
|
||
<button class="tab-trigger" data-tab="api" role="tab" title="API reference — copy-paste curl/SDK examples for OpenAI-compat, Anthropic-compat, native"><span class="tab-num">11</span>api</button>
|
||
</nav>
|
||
|
||
<!-- ─── Tab: Overview ────────────────────────────────────────────────── -->
|
||
<section class="tab-panel active" data-tab="overview">
|
||
|
||
<!-- ─── Hero: Buddy + Headline Savings + Forecast ──────────────────── -->
|
||
<div class="hero-grid">
|
||
<!-- Left: Pet/Buddy -->
|
||
<div class="hero-buddy" id="heroBuddy">
|
||
<div class="loading">summoning buddy</div>
|
||
</div>
|
||
|
||
<!-- Center: Headline savings counter — combined all layers -->
|
||
<div class="hero-savings">
|
||
<div class="hero-eyebrow">total tokens saved · all layers · all-time</div>
|
||
<div class="hero-counter"><span id="heroTokensSavedCombined">0</span><span style="font-size:1.1rem;color:var(--dim);font-weight:400;margin-left:8px;">tokens</span></div>
|
||
<div class="hero-layer-breakdown" id="heroLayerBreakdown">
|
||
<div class="layer-row"><span class="layer-name">⚡ Gateway (LLM calls)</span><span class="layer-val" id="heroTokensSaved">0</span></div>
|
||
<div class="layer-row" id="heroExternalToolRow" style="display:none;"><span class="layer-name">🗜 External tool compression (legacy)</span><span class="layer-val" id="heroExternalToolTokens">—</span></div>
|
||
</div>
|
||
<div class="hero-row">
|
||
<div class="hero-pill">
|
||
<span class="hero-pill-label">cost saved</span>
|
||
<span class="hero-pill-val" id="heroCostSaved">$0.00</span>
|
||
</div>
|
||
<div class="hero-pill">
|
||
<span class="hero-pill-label">cache hits</span>
|
||
<span class="hero-pill-val" id="heroCacheHits">0</span>
|
||
</div>
|
||
<div class="hero-pill">
|
||
<span class="hero-pill-label">savings rate</span>
|
||
<span class="hero-pill-val" id="heroSavingsRate">0%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Cost analysis (without vs with) — competitor comparison -->
|
||
<div class="hero-cost">
|
||
<div class="hero-eyebrow">cost analysis · last 24h · USD</div>
|
||
<div class="cost-vs">
|
||
<div class="cost-side without">
|
||
<div class="cost-label">without gateway</div>
|
||
<div class="cost-amount" id="costWithout">$0.00</div>
|
||
</div>
|
||
<div class="cost-arrow">→</div>
|
||
<div class="cost-side with">
|
||
<div class="cost-label">with gateway</div>
|
||
<div class="cost-amount" id="costWith">$0.00</div>
|
||
</div>
|
||
</div>
|
||
<div class="cost-saved-line">you saved <strong id="costSavedLine">$0.00</strong> · <span id="costSavedPercent">0%</span> reduction</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Five-Axis Savings Breakdown — full savings breakdown ── -->
|
||
<h2 class="h-section">Savings Sources <span class="h-meta">5 measurement axes across all calls</span></h2>
|
||
<div class="savings-axes" id="savingsAxes">
|
||
<div class="loading">loading</div>
|
||
</div>
|
||
|
||
<!-- ─── Quick Metrics Grid ──────────────────────────────────────────── -->
|
||
<h2 class="h-section">Live Metrics <span class="h-meta">last 24h</span></h2>
|
||
<div class="metric-grid">
|
||
<div class="metric">
|
||
<div class="metric-label">requests</div>
|
||
<div class="metric-value" id="totalRequests">0</div>
|
||
<div class="metric-change" id="requestsChange">routed</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">success rate</div>
|
||
<div class="metric-value" id="successRate">0<span class="metric-unit">%</span></div>
|
||
<div class="metric-change" id="successChange">approved/total</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">avg latency</div>
|
||
<div class="metric-value" id="avgLatency">0<span class="metric-unit">ms</span></div>
|
||
<div class="metric-change" id="latencyChange">end-to-end</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">spent today</div>
|
||
<div class="metric-value" id="totalCost">$0.00</div>
|
||
<div class="metric-change" id="costChange">actual usd</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">confidence</div>
|
||
<div class="metric-value" id="avgConfidence">0<span class="metric-unit">/10</span></div>
|
||
<div class="metric-change" id="confidenceChange">post-val</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">fallback usage</div>
|
||
<div class="metric-value" id="fallbackPercent">0<span class="metric-unit">%</span></div>
|
||
<div class="metric-change" id="fallbackChange">primary→fallback</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Calendar heatmap (GitHub style) + Forecast ──────────────────── -->
|
||
<div class="overview-row-2col">
|
||
<div>
|
||
<h2 class="h-section">Activity · last 365 days <span class="h-meta">streak <span id="streakBadge">0</span>d</span></h2>
|
||
<div class="heatmap" id="heatmap"><div class="loading">loading activity</div></div>
|
||
</div>
|
||
<div>
|
||
<h2 class="h-section">Forecast <span class="h-meta">based on recent trend</span></h2>
|
||
<div class="forecast" id="forecast"><div class="loading">computing forecast</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Live Events Feed + Top Models / Callers ─────────────────────── -->
|
||
<div class="overview-row-2col">
|
||
<div>
|
||
<h2 class="h-section">Live Activity <span class="h-meta">most recent first</span></h2>
|
||
<div class="events-feed" id="eventsFeed"><div class="loading">listening</div></div>
|
||
</div>
|
||
<div>
|
||
<h2 class="h-section">Top Models <span class="h-meta">last 24h</span></h2>
|
||
<div class="chip-grid" id="topModels"><div class="loading">analyzing routing</div></div>
|
||
|
||
<h2 class="h-section" style="margin-top: 18px;">Top Callers</h2>
|
||
<div class="chip-grid" id="topCallers"><div class="loading">analyzing callers</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Achievements ──────────────────────────────────────────────────── -->
|
||
<h2 class="h-section">Achievements <span class="h-meta"><span id="achievementsProgress">0/0</span></span></h2>
|
||
<div class="achievements-grid" id="achievementsGrid"><div class="loading">checking quests</div></div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Subscriptions ──────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="subscriptions">
|
||
<div class="auto-banner">
|
||
<div class="banner-text">
|
||
<strong>auto-gateway</strong> <span id="subsAutoState">detection only</span>
|
||
— installed CLI subscriptions are wrapped into HTTP bridges and exposed via <code>/v1/chat/completions</code>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button class="btn btn-sm" id="discoverFullBtn" type="button" title="Full-system scan: CLIs + local LLMs + API keys, then auto-spawn any detected bridges">⚡ discover & connect all</button>
|
||
<button class="btn btn-sm primary" id="subsSpawnBtn" type="button">⟳ spawn missing bridges</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Full discovery report (populated by discover button) ────────── -->
|
||
<div id="discoverReportWrap" style="display: none; margin-bottom: 14px;">
|
||
<h2 class="h-section">Discovery Report <span class="h-meta" id="discoverReportMeta">—</span></h2>
|
||
<div class="discover-grid">
|
||
<div class="discover-card">
|
||
<div class="discover-card-title">CLI Subscriptions</div>
|
||
<div class="discover-card-stat"><span id="discCntSubs">0</span> detected</div>
|
||
<ul class="discover-card-list" id="discListSubs"></ul>
|
||
</div>
|
||
<div class="discover-card">
|
||
<div class="discover-card-title">Local LLM Servers</div>
|
||
<div class="discover-card-stat"><span id="discCntLocal">0</span> running</div>
|
||
<ul class="discover-card-list" id="discListLocal"></ul>
|
||
</div>
|
||
<div class="discover-card">
|
||
<div class="discover-card-title">API-Key Providers</div>
|
||
<div class="discover-card-stat"><span id="discCntKeys">0</span> configured</div>
|
||
<ul class="discover-card-list" id="discListKeys"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="subs-grid" id="subscriptionsList">
|
||
<div class="loading">discovering installed subscriptions</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Providers ──────────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="providers">
|
||
<div class="providers-stack">
|
||
<section>
|
||
<h2 class="h-section">Local <span class="h-meta">on-host inference</span></h2>
|
||
<div class="providers-grid" id="providersList_local">
|
||
<div class="loading">enumerating local models</div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2 class="h-section">Subscription <span class="h-meta">paid plans via bridges</span></h2>
|
||
<div class="providers-grid" id="providersList_subscription">
|
||
<div class="loading">enumerating subscription providers</div>
|
||
</div>
|
||
</section>
|
||
<section>
|
||
<h2 class="h-section">Free Tier <span class="h-meta">api-key authenticated</span></h2>
|
||
<div class="providers-grid" id="providersList_free">
|
||
<div class="loading">enumerating free-tier endpoints</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Activity ──────────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="activity">
|
||
<h2 class="h-section">Desktop AI Coverage <span class="h-meta">only gateway traffic is counted</span></h2>
|
||
<div class="client-grid" id="clientsCoverage">
|
||
<div class="loading">checking connected clients</div>
|
||
</div>
|
||
<h2 class="h-section">Recent Requests <span class="h-meta">live polling</span></h2>
|
||
<div class="filters">
|
||
<button class="filter-btn active" data-hours="24">last 24h</button>
|
||
<button class="filter-btn" data-hours="168">last 7d</button>
|
||
<button class="filter-btn" data-hours="720">last 30d</button>
|
||
</div>
|
||
<div class="req-table">
|
||
<div class="req-row head">
|
||
<div>request id</div>
|
||
<div>caller</div>
|
||
<div>model</div>
|
||
<div>status</div>
|
||
<div>ctx before</div>
|
||
<div>ctx sent</div>
|
||
<div>saved</div>
|
||
<div>compression</div>
|
||
<div>cost</div>
|
||
<div>latency</div>
|
||
</div>
|
||
<div id="requestsTable">
|
||
<div class="empty-state">no requests yet</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Savings ─────────────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="savings">
|
||
<div class="savings-hero">
|
||
<div class="savings-headline">
|
||
<div class="savings-eyebrow">cumulative savings · last 24h</div>
|
||
<div class="savings-counter" id="savingsCounter">$0.00</div>
|
||
<div class="savings-sub" id="savingsSubLine">— · — tokens prevented · — cache hits</div>
|
||
</div>
|
||
<div class="savings-spark">
|
||
<svg id="savingsSparkline" viewBox="0 0 320 64" preserveAspectRatio="none"></svg>
|
||
<div class="savings-spark-meta">
|
||
<span id="savingsSparkLabel">$ saved per hour</span>
|
||
<span id="savingsHitRate">hit rate —</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="metric-grid" style="margin-top:18px;">
|
||
<div class="metric">
|
||
<div class="metric-label">cache entries</div>
|
||
<div class="metric-value" id="cacheEntries">0</div>
|
||
<div class="metric-change">distinct cached responses</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">tokens prevented</div>
|
||
<div class="metric-value" id="tokensPrevented">0</div>
|
||
<div class="metric-change">never sent to LLM</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">cache hit rate</div>
|
||
<div class="metric-value" id="cacheHitRate">0<span class="metric-unit">%</span></div>
|
||
<div class="metric-change">hits ÷ total req</div>
|
||
</div>
|
||
<div class="metric">
|
||
<div class="metric-label">compressed since last restart</div>
|
||
<div class="metric-value" id="compressedSinceRestart">0</div>
|
||
<div class="metric-change" id="compressedSinceRestartMeta">— · — ops · since —</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 class="h-section">Top Caching Callers <span class="h-meta">most savings</span></h2>
|
||
<div class="chip-grid" id="topSavingCallers">
|
||
<div class="loading">loading</div>
|
||
</div>
|
||
|
||
<h2 class="h-section">Cache Controls <span class="h-meta">manual invalidation</span></h2>
|
||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||
<input id="cacheClearCaller" class="settings-input" style="max-width:280px;" placeholder="caller id (e.g. cursor)">
|
||
<button class="btn" id="cacheClearBtn" type="button">clear caller's cache</button>
|
||
<button class="btn" id="cachePruneBtn" type="button">prune entries > 7 days</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Wallet (Subscription Pool — UNIQUE feature) ────────────── -->
|
||
<section class="tab-panel" data-tab="wallet">
|
||
<div class="wallet-banner">
|
||
<div>
|
||
<strong>Subscription Pool Wallet</strong> — tracks <strong>API calls</strong>
|
||
(not tokens) against each Pro plan's quota window. Numbers here are
|
||
<em>messages remaining</em>, not tokens. For token savings via cache,
|
||
see the Savings tab.
|
||
</div>
|
||
</div>
|
||
<div class="wallet-grid" id="walletList">
|
||
<div class="loading">loading wallet</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Memory ───────────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="memory">
|
||
<div class="memory-form">
|
||
<input id="memCaller" class="settings-input" style="max-width:280px;" placeholder="caller id">
|
||
<button class="btn" id="memLoadBtn" type="button">load facts</button>
|
||
<span style="flex:1;"></span>
|
||
<input id="memFactKey" class="settings-input" style="max-width:200px;" placeholder="fact key">
|
||
<input id="memFactValue" class="settings-input" style="max-width:280px;" placeholder="fact value">
|
||
<button class="btn" id="memSaveBtn" type="button">remember</button>
|
||
</div>
|
||
<div class="mem-list" id="memList">
|
||
<div class="empty-state">enter a caller id and click load</div>
|
||
</div>
|
||
|
||
<h2 class="h-section">Knowledge Graph <span class="h-meta">all callers + facts</span></h2>
|
||
<div class="graph-wrap">
|
||
<svg id="memoryGraph" viewBox="0 0 880 460" preserveAspectRatio="xMidYMid meet"></svg>
|
||
<div class="graph-legend">
|
||
<span><span class="dot" style="background:#0f766e;"></span> caller</span>
|
||
<span><span class="dot" style="background:#2563eb;"></span> fact key</span>
|
||
<span><span class="dot" style="background:#a78bfa;"></span> value</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Leaderboard ─────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="leaderboard">
|
||
<div class="leaderboard-podium" id="leaderboardPodium">
|
||
<div class="loading">computing standings</div>
|
||
</div>
|
||
<h2 class="h-section">Race Leaderboard <span class="h-meta">last 7 days</span></h2>
|
||
<div class="leaderboard-table" id="leaderboardTable"><div class="loading">loading</div></div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Share Card ──────────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="share">
|
||
<h2 class="h-section">Public Share Card <span class="h-meta">embeddable SVG · OG-card sized · no auth required</span></h2>
|
||
<div class="share-controls">
|
||
<label class="settings-row-label">Period:
|
||
<select id="shareCardPeriod" class="settings-input" style="width: 140px; display:inline-block; margin-left:8px;">
|
||
<option value="day">day</option>
|
||
<option value="week">week</option>
|
||
<option value="month" selected>month</option>
|
||
<option value="all">all-time</option>
|
||
</select>
|
||
</label>
|
||
<label class="settings-row-label" style="margin-left:24px;">Theme:
|
||
<select id="shareCardTheme" class="settings-input" style="width: 120px; display:inline-block; margin-left:8px;">
|
||
<option value="dark">dark</option>
|
||
<option value="light">light</option>
|
||
</select>
|
||
</label>
|
||
<button class="btn primary" id="shareCardRefresh" type="button">refresh</button>
|
||
<button class="btn" id="shareCardCopyUrl" type="button">copy URL</button>
|
||
<button class="btn" id="shareCardDownload" type="button">download SVG</button>
|
||
</div>
|
||
<div class="share-preview">
|
||
<img id="shareCardImg" alt="LLM Gateway share card" loading="lazy">
|
||
</div>
|
||
<div class="share-url" id="shareCardUrl"></div>
|
||
<div class="share-hint">Use this URL anywhere — Twitter/LinkedIn previews, blog headers, README badges. Updates automatically every 5 min.</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: Monthly Report ──────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="report">
|
||
<h2 class="h-section">Monthly Report <span class="h-meta">save as PDF via browser print</span></h2>
|
||
<div class="share-controls">
|
||
<label class="settings-row-label">Year:
|
||
<input id="reportYear" class="settings-input" type="number" style="width:120px;display:inline-block;margin-left:8px;">
|
||
</label>
|
||
<label class="settings-row-label" style="margin-left:24px;">Month:
|
||
<input id="reportMonth" class="settings-input" type="number" min="1" max="12" style="width:90px;display:inline-block;margin-left:8px;">
|
||
</label>
|
||
<button class="btn primary" id="reportOpen" type="button">open report</button>
|
||
</div>
|
||
<div class="share-hint">Tip: in the report window, press <code>Cmd/Ctrl+P</code> → "Save as PDF". The report is fully styled for A4 print.</div>
|
||
</section>
|
||
|
||
<!-- ─── Tab: API Reference ─────────────────────────────────────────── -->
|
||
<section class="tab-panel" data-tab="api">
|
||
<h2 class="h-section">API Reference <span class="h-meta">all endpoints route through compression + caller tracking</span></h2>
|
||
|
||
<div class="api-intro" style="margin: 8px 0 16px; color: var(--text-muted, #888); font-size: 13px; line-height: 1.5;">
|
||
The LLM Gateway exposes three POST endpoints and one GET. Every call is logged in
|
||
<em>activity</em>, compressed when input ≥ 700 tokens, and routed via <code>routing-rules.yaml</code>
|
||
to the right subscription bridge (Claude Code, ChatGPT, Copilot, M365 Copilot, Codex) or local Ollama.
|
||
</div>
|
||
|
||
<!-- ── Endpoint card: OpenAI-compatible ─────────────────────────── -->
|
||
<div class="api-card" data-endpoint="chat">
|
||
<div class="api-card-head">
|
||
<span class="api-method">POST</span>
|
||
<code class="api-path">/v1/chat/completions</code>
|
||
<span class="api-tag">OpenAI-compatible · works with `openai` SDK</span>
|
||
<button class="btn ghost api-copy" data-target="api-snippet-chat" type="button">copy</button>
|
||
</div>
|
||
<pre id="api-snippet-chat" class="api-snippet"><code>curl https://llm-gateway.context-x.org/v1/chat/completions \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"model": "claude-sonnet-4.6",
|
||
"messages": [{"role": "user", "content": "hi"}]
|
||
}'</code></pre>
|
||
</div>
|
||
|
||
<!-- ── Endpoint card: Anthropic-compatible ──────────────────────── -->
|
||
<div class="api-card" data-endpoint="messages">
|
||
<div class="api-card-head">
|
||
<span class="api-method">POST</span>
|
||
<code class="api-path">/v1/messages</code>
|
||
<span class="api-tag">Anthropic-compatible · works with `@anthropic-ai/sdk`</span>
|
||
<button class="btn ghost api-copy" data-target="api-snippet-messages" type="button">copy</button>
|
||
</div>
|
||
<pre id="api-snippet-messages" class="api-snippet"><code>curl https://llm-gateway.context-x.org/v1/messages \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"model": "claude-sonnet-4.6",
|
||
"messages": [{"role": "user", "content": "hi"}],
|
||
"max_tokens": 1024
|
||
}'</code></pre>
|
||
</div>
|
||
|
||
<!-- ── Endpoint card: Native ────────────────────────────────────── -->
|
||
<div class="api-card" data-endpoint="completion">
|
||
<div class="api-card-head">
|
||
<span class="api-method">POST</span>
|
||
<code class="api-path">/v1/completion</code>
|
||
<span class="api-tag">native — full caller-tracking + compression options</span>
|
||
<button class="btn ghost api-copy" data-target="api-snippet-completion" type="button">copy</button>
|
||
</div>
|
||
<pre id="api-snippet-completion" class="api-snippet"><code>curl https://llm-gateway.context-x.org/v1/completion \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"caller": "my-app",
|
||
"task_type": "generic_qa",
|
||
"input": "your prompt here",
|
||
"options": { "compression": { "enabled": true, "mode": "auto" } }
|
||
}'</code></pre>
|
||
</div>
|
||
|
||
<!-- ── Endpoint card: Models list ───────────────────────────────── -->
|
||
<div class="api-card" data-endpoint="models">
|
||
<div class="api-card-head">
|
||
<span class="api-method">GET</span>
|
||
<code class="api-path">/v1/models</code>
|
||
<span class="api-tag">list every model the gateway can route to</span>
|
||
<button class="btn ghost api-copy" data-target="api-snippet-models" type="button">copy</button>
|
||
</div>
|
||
<pre id="api-snippet-models" class="api-snippet"><code>curl https://llm-gateway.context-x.org/v1/models</code></pre>
|
||
</div>
|
||
|
||
<!-- ── Try-It-Out playground ────────────────────────────────────── -->
|
||
<h2 class="h-section" style="margin-top: 28px;">Try it out <span class="h-meta">live POST against the gateway</span></h2>
|
||
<div class="api-tryout">
|
||
<div class="api-tryout-row">
|
||
<label class="settings-row-label">Endpoint:
|
||
<select id="apiTryEndpoint" class="settings-input" style="width: 220px; margin-left: 8px;">
|
||
<option value="/v1/completion">/v1/completion (native)</option>
|
||
<option value="/v1/chat/completions">/v1/chat/completions (OpenAI)</option>
|
||
<option value="/v1/messages">/v1/messages (Anthropic)</option>
|
||
</select>
|
||
</label>
|
||
<label class="settings-row-label" style="margin-left: 18px;">Model:
|
||
<input id="apiTryModel" class="settings-input" type="text" value="claude-sonnet-4.6" style="width: 200px; margin-left: 8px;">
|
||
</label>
|
||
</div>
|
||
<label class="settings-row-label" style="display: block; margin-top: 10px;">Prompt:
|
||
<textarea id="apiTryPrompt" class="settings-input" rows="4" style="width: 100%; margin-top: 6px;" placeholder="Type your prompt — long inputs (>700 tokens) will be compressed automatically.">Say hello in three different languages.</textarea>
|
||
</label>
|
||
<div style="margin-top: 10px;">
|
||
<button class="btn primary" id="apiTryRun" type="button">send request</button>
|
||
<span id="apiTryStatus" style="margin-left: 12px; font-size: 12px; color: var(--text-muted, #888);"></span>
|
||
</div>
|
||
<div id="apiTryResultWrap" style="margin-top: 14px; display: none;">
|
||
<div class="api-tryout-meta" id="apiTryMeta" style="font-size: 12px; color: var(--text-muted, #888); margin-bottom: 6px;"></div>
|
||
<pre class="api-snippet"><code id="apiTryResult"></code></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Bridge mapping (model → subscription) ────────────────────── -->
|
||
<h2 class="h-section" style="margin-top: 28px;">Model → Bridge Mapping <span class="h-meta">which subscription each model alias routes to</span></h2>
|
||
<div class="api-bridge-table-wrap">
|
||
<table class="api-bridge-table" id="apiBridgeTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Model alias</th>
|
||
<th>Bridge</th>
|
||
<th>Subscription used</th>
|
||
<th>Port</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr><td><code>claude-sonnet-4.6</code>, <code>claude-haiku</code>, <code>claude-opus</code></td><td>claude-bridge</td><td>Claude Code Max (OAuth)</td><td>3250</td><td class="api-bridge-status" data-bridge="claude-bridge">—</td></tr>
|
||
<tr><td><code>gpt-4o</code>, <code>gpt-4.1</code>, <code>gpt-5.x</code></td><td>openai-bridge</td><td>ChatGPT Plus / Pro</td><td>3251</td><td class="api-bridge-status" data-bridge="openai-bridge">—</td></tr>
|
||
<tr><td><code>copilot-gpt-4o</code>, <code>copilot-claude-3.7</code></td><td>copilot-bridge</td><td>GitHub Copilot</td><td>3252</td><td class="api-bridge-status" data-bridge="copilot-bridge">—</td></tr>
|
||
<tr><td><code>codex-mini</code>, <code>gpt-5.1-codex-mini</code></td><td>codex-bridge</td><td>OpenAI Codex CLI</td><td>3253</td><td class="api-bridge-status" data-bridge="codex-bridge">—</td></tr>
|
||
<tr><td><code>m365-copilot</code></td><td>m365-copilot-bridge</td><td>Microsoft 365 Copilot</td><td>3257</td><td class="api-bridge-status" data-bridge="m365-copilot-bridge">—</td></tr>
|
||
<tr><td><code>qwen2.5:3b / 7b / 14b / 32b</code>, <code>magatama:32b</code>, <code>magatama-coder</code></td><td>ollama (Mac Studio)</td><td>local — no cost</td><td>11434</td><td class="api-bridge-status" data-bridge="ollama">—</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="share-hint" style="margin-top: 12px;">
|
||
The gateway picks the bridge from <code>routing-rules.yaml</code> based on <code>task_type</code> and the
|
||
requested <code>model</code>. You can also hit a bridge directly (e.g. <code>http://82.165.222.127:3250/v1/messages</code>)
|
||
— but then you bypass compression, savings tracking, and the routing rules.
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ─── Caller Deep-Dive Modal ───────────────────────────────────── -->
|
||
<div class="modal-overlay" id="callerModal" role="dialog" aria-modal="true">
|
||
<div class="modal" style="max-width: 900px;">
|
||
<div class="modal-header">
|
||
<h2 id="callerModalTitle">caller details</h2>
|
||
<button class="modal-close" id="callerModalClose" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal-body" id="callerModalBody">
|
||
<div class="loading">loading caller details</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ─── Settings Modal ──────────────────────────────────────────────── -->
|
||
<div class="modal-overlay" id="settingsModal" role="dialog" aria-modal="true">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>gateway settings</h2>
|
||
<button class="modal-close" id="settingsClose" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">dashboard view</div>
|
||
<p class="settings-section-desc">Hide advanced features you don't use. <strong>Recommended for users with 1–3 subscriptions.</strong></p>
|
||
<div class="settings-row">
|
||
<div class="settings-row-info">
|
||
<span class="settings-row-label">Simple Mode</span>
|
||
<span class="settings-row-meta">Show only: overview · subscriptions · wallet · activity · savings. Hide: providers, races, share, report, memory.</span>
|
||
</div>
|
||
<label class="settings-toggle">
|
||
<input type="checkbox" id="uiSimpleMode">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="settings-row-info">
|
||
<span class="settings-row-label">Hide unconfigured providers</span>
|
||
<span class="settings-row-meta">Don't show provider cards that aren't enabled (Cerebras, Groq, etc.)</span>
|
||
</div>
|
||
<label class="settings-toggle">
|
||
<input type="checkbox" id="uiHideEmpty">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="settings-row-info">
|
||
<span class="settings-row-label">Tab tooltips</span>
|
||
<span class="settings-row-meta">Show a one-line explanation on hover for every tab.</span>
|
||
</div>
|
||
<label class="settings-toggle">
|
||
<input type="checkbox" id="uiTooltips">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">routing mode</div>
|
||
<p class="settings-section-desc">Restrict which provider categories the gateway is allowed to use.</p>
|
||
<div class="settings-radio-group" id="routingModeGroup">
|
||
<label class="settings-radio"><input type="radio" name="routingMode" value="auto"><span>auto · all</span></label>
|
||
<label class="settings-radio"><input type="radio" name="routingMode" value="subscription-only"><span>subscriptions only</span></label>
|
||
<label class="settings-radio"><input type="radio" name="routingMode" value="api-only"><span>api only</span></label>
|
||
<label class="settings-radio"><input type="radio" name="routingMode" value="local-only"><span>local · ollama only</span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">cli subscriptions (abos)</div>
|
||
<p class="settings-section-desc">Toggle which subscription CLIs you have. The auto-gateway only spawns bridges for enabled ones.</p>
|
||
<div id="settingsSubscriptionsList"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">api providers</div>
|
||
<p class="settings-section-desc">API keys for paid/free-tier endpoints. Stored locally with file mode 0600 — never returned in plaintext.</p>
|
||
<div id="settingsApiList"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">local · ollama</div>
|
||
<div class="settings-row">
|
||
<div class="settings-row-info">
|
||
<span class="settings-row-label">Ollama Base URL</span>
|
||
<span class="settings-row-meta">OLLAMA_BASE_URL</span>
|
||
<input class="settings-input" type="text" id="ollamaBaseUrl" placeholder="http://localhost:11434">
|
||
</div>
|
||
<label class="settings-toggle">
|
||
<input type="checkbox" id="ollamaEnabled">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<span class="save-status" id="settingsSaveStatus"></span>
|
||
<button class="btn" id="settingsCancel" type="button">cancel</button>
|
||
<button class="btn primary" id="settingsSave" type="button">save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="conn-pill">
|
||
<span class="dot" id="connectionDot"></span>
|
||
<span id="connectionText">connected</span>
|
||
</div>
|
||
|
||
<script>
|
||
const HEALTH_CHECK_INTERVAL = 30000;
|
||
const METRICS_REFRESH_INTERVAL = 15000;
|
||
const REQUESTS_REFRESH_INTERVAL = 15000;
|
||
const API_BASE = '';
|
||
let selectedHours = 24;
|
||
let lastMetrics = null;
|
||
let metricsIntervalId = null;
|
||
let requestsIntervalId = null;
|
||
const DASHBOARD_TOKEN_KEY = 'llmGatewayDashboardToken';
|
||
|
||
function getDashboardToken() {
|
||
return localStorage.getItem(DASHBOARD_TOKEN_KEY) || '';
|
||
}
|
||
|
||
function setDashboardToken(token) {
|
||
if (token) localStorage.setItem(DASHBOARD_TOKEN_KEY, token);
|
||
else localStorage.removeItem(DASHBOARD_TOKEN_KEY);
|
||
}
|
||
|
||
function withAuthHeaders(headers = {}) {
|
||
const token = getDashboardToken();
|
||
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
|
||
}
|
||
|
||
async function apiFetch(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
...options,
|
||
headers: withAuthHeaders(options.headers || {}),
|
||
});
|
||
if (response.status !== 401 && response.status !== 503) return response;
|
||
|
||
const token = prompt('Dashboard admin token');
|
||
if (!token) return response;
|
||
setDashboardToken(token.trim());
|
||
return fetch(url, {
|
||
...options,
|
||
headers: withAuthHeaders(options.headers || {}),
|
||
});
|
||
}
|
||
|
||
// ─── Tab switching ───────────────────────────────────────────────────
|
||
document.querySelectorAll('.tab-trigger').forEach(t => {
|
||
t.addEventListener('click', () => {
|
||
const target = t.dataset.tab;
|
||
document.querySelectorAll('.tab-trigger').forEach(x => x.classList.toggle('active', x === t));
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.dataset.tab === target));
|
||
history.replaceState(null, '', `#${target}`);
|
||
});
|
||
});
|
||
if (location.hash) {
|
||
const target = location.hash.slice(1);
|
||
const trigger = document.querySelector(`.tab-trigger[data-tab="${target}"]`);
|
||
if (trigger) trigger.click();
|
||
}
|
||
|
||
// Health check
|
||
async function checkHealth() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/dashboard/health`);
|
||
const data = await response.json();
|
||
const isHealthy = data.status === 'ok';
|
||
updateHealthStatus(isHealthy, data);
|
||
return isHealthy;
|
||
} catch (error) {
|
||
console.error('Health check failed:', error);
|
||
updateHealthStatus(false, { error: error.message });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function updateHealthStatus(isHealthy, _data) {
|
||
const indicator = document.getElementById('dbStatusIndicator');
|
||
const status = document.getElementById('dbStatus');
|
||
indicator.className = isHealthy ? 'dot ok' : 'dot err';
|
||
status.textContent = isHealthy ? 'connected' : 'disconnected';
|
||
}
|
||
|
||
// Load recent requests
|
||
async function loadRequests() {
|
||
try {
|
||
const [response, clientsResponse] = await Promise.all([
|
||
apiFetch(`${API_BASE}/api/dashboard/requests?limit=50&hours=${selectedHours}`),
|
||
apiFetch(`${API_BASE}/api/dashboard/clients?hours=${selectedHours}`)
|
||
]);
|
||
const data = await response.json();
|
||
const clients = await clientsResponse.json();
|
||
if (clients.success) renderClients(clients.data);
|
||
if (data.success) renderRequests(data.data);
|
||
} catch (error) {
|
||
console.error('Failed to load requests:', error);
|
||
}
|
||
}
|
||
|
||
function renderClients(clients) {
|
||
const el = document.getElementById('clientsCoverage');
|
||
el.innerHTML = clients.map(client => {
|
||
const lastSeen = client.lastSeen ? new Date(client.lastSeen).toLocaleString() : 'never';
|
||
const callerList = client.callers?.length ? client.callers.join(', ') : 'no caller id seen';
|
||
const bridgeState = client.bridgeProvider
|
||
? `${client.bridgeProvider}: ${client.bridgeStatus || 'not configured'}${client.bridgeDetail ? ` (${client.bridgeDetail})` : ''}`
|
||
: 'bridge: OpenAI-compatible / manual client config';
|
||
return `
|
||
<div class="client-item">
|
||
<div class="client-top">
|
||
<div class="client-name" title="${escapeHtml(client.label)}">${escapeHtml(client.label)}</div>
|
||
<div class="client-state ${client.status}">${client.status.replace('-', ' ')}</div>
|
||
</div>
|
||
<div class="client-meta">
|
||
<div><strong>${formatNumber(client.requestCount)}</strong> requests · <strong>${formatNumber(client.tokensSaved)}</strong> saved</div>
|
||
<div title="${escapeHtml(callerList)}">caller: ${escapeHtml(callerList)}</div>
|
||
<div title="${escapeHtml(bridgeState)}">gateway: ${escapeHtml(bridgeState)}</div>
|
||
<div>last: ${escapeHtml(lastSeen)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderRequests(requests) {
|
||
const table = document.getElementById('requestsTable');
|
||
if (!requests.length) {
|
||
table.innerHTML = '<div class="empty-state">no requests in selected timeframe</div>';
|
||
return;
|
||
}
|
||
table.innerHTML = requests.map(req => `
|
||
<div class="req-row body">
|
||
<div title="${req.request_id}">${req.request_id.substring(0, 14)}…</div>
|
||
<div>${escapeHtml(req.caller)}</div>
|
||
<div title="${req.model}">${req.model}</div>
|
||
<div><span class="req-status ${req.status}">${req.status}</span></div>
|
||
<div>${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}</div>
|
||
<div>${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}</div>
|
||
<div>${formatSavedTokens(req.compression_tokens_saved ?? 0)}</div>
|
||
<div title="${escapeHtml(req.compression_mode || 'not tracked')}">${formatCompression(req)}</div>
|
||
<div>${formatCost(req.cost_usd)}</div>
|
||
<div>${req.latency_ms}ms</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
return Number(value || 0).toLocaleString();
|
||
}
|
||
|
||
function formatSavedTokens(value) {
|
||
const saved = Number(value || 0);
|
||
return saved > 0 ? saved.toLocaleString() : '0';
|
||
}
|
||
|
||
function formatCompression(req) {
|
||
const mode = String(req.compression_mode || 'none:none').split(':').pop() || 'none';
|
||
const pct = Number(req.compression_savings_pct || 0);
|
||
if (!req.compression_mode) return 'not tracked';
|
||
if (pct <= 0) return mode === 'none' ? 'checked' : `${escapeHtml(mode)} · 0%`;
|
||
return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||
}
|
||
|
||
// Load metrics
|
||
async function loadMetrics() {
|
||
try {
|
||
const bucketMinutes = (selectedHours || 24) * 60;
|
||
const response = await apiFetch(`${API_BASE}/api/dashboard/request-metrics?bucket_minutes=${bucketMinutes}`);
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
updateMetrics(data.data);
|
||
lastMetrics = data.data;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load metrics:', error);
|
||
}
|
||
}
|
||
|
||
function formatCost(cost) {
|
||
const c = cost || 0;
|
||
if (c === 0) return '$0.00';
|
||
if (c < 0.01) return '$' + c.toFixed(6);
|
||
if (c < 1) return '$' + c.toFixed(4);
|
||
return '$' + c.toFixed(2);
|
||
}
|
||
|
||
function updateMetrics(metrics) {
|
||
document.getElementById('totalRequests').textContent = (metrics.total_requests || 0).toLocaleString();
|
||
document.getElementById('successRate').innerHTML = ((metrics.success_rate || 0) * 100).toFixed(1) + '<span class="metric-unit">%</span>';
|
||
document.getElementById('avgLatency').innerHTML = Math.round(metrics.avg_latency || 0) + '<span class="metric-unit">ms</span>';
|
||
document.getElementById('totalCost').textContent = formatCost(metrics.total_cost);
|
||
document.getElementById('avgConfidence').innerHTML = (metrics.avg_confidence || 0).toFixed(1) + '<span class="metric-unit">/10</span>';
|
||
document.getElementById('fallbackPercent').innerHTML = ((metrics.compression_rate || 0) * 100).toFixed(1) + '<span class="metric-unit">%</span>';
|
||
document.getElementById('requestsChange').textContent = `${(metrics.total_tokens || 0).toLocaleString()} tokens`;
|
||
document.getElementById('costChange').textContent = `avoided ${formatCost(metrics.estimated_api_cost_avoided)}`;
|
||
document.getElementById('fallbackChange').textContent = `${(metrics.compression_tokens_saved || 0).toLocaleString()} tokens · ${metrics.compression_operations || 0} ops`;
|
||
|
||
if (metrics.top_models?.length) {
|
||
document.getElementById('topModels').innerHTML = metrics.top_models.map(m => `
|
||
<div class="chip">
|
||
<div class="chip-name">${escapeHtml(m.model)}</div>
|
||
<div class="chip-meta"><span class="num">${m.count}</span> requests</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
document.getElementById('topModels').innerHTML = '<div class="empty-state">no model usage yet</div>';
|
||
}
|
||
|
||
if (metrics.top_callers?.length) {
|
||
document.getElementById('topCallers').innerHTML = metrics.top_callers.map(c => `
|
||
<div class="chip">
|
||
<div class="chip-name">${escapeHtml(c.caller)}</div>
|
||
<div class="chip-meta"><span class="num">${c.count}</span> requests</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
document.getElementById('topCallers').innerHTML = '<div class="empty-state">no callers yet</div>';
|
||
}
|
||
}
|
||
|
||
// Load providers
|
||
async function loadProviders() {
|
||
try {
|
||
const response = await apiFetch(`${API_BASE}/api/dashboard/providers`);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const payload = await response.json();
|
||
if (!payload.success) throw new Error(payload.error || 'failed');
|
||
renderProviders(payload.data.grouped);
|
||
const total = payload.data.summary.totalProviders;
|
||
const cfg = payload.data.summary.configuredCount;
|
||
document.getElementById('providersTabBadge').textContent = `${cfg}/${total}`;
|
||
} catch (error) {
|
||
const msg = `<div class="empty-state">error: ${error.message}</div>`;
|
||
document.getElementById('providersList_local').innerHTML = msg;
|
||
document.getElementById('providersList_subscription').innerHTML = msg;
|
||
document.getElementById('providersList_free').innerHTML = msg;
|
||
}
|
||
}
|
||
|
||
function renderProviders(grouped) {
|
||
const empty = '<div class="empty-state">none configured</div>';
|
||
const renderGroup = (id, items) => {
|
||
const c = document.getElementById(id);
|
||
c.innerHTML = items?.length ? items.map(renderProviderItem).join('') : empty;
|
||
};
|
||
renderGroup('providersList_local', grouped.local);
|
||
renderGroup('providersList_subscription', grouped.subscription);
|
||
renderGroup('providersList_free', grouped.free);
|
||
}
|
||
|
||
function renderProviderItem(provider) {
|
||
const statusClass = provider.status === 'configured' ? 'tag-configured' : 'tag-unconfigured';
|
||
const modelList = provider.models.map(m => m.id).join(', ');
|
||
const displayName = provider.label || provider.name;
|
||
const techName = provider.label && provider.label !== provider.name
|
||
? `<div class="provider-tech-name">${provider.name}</div>` : '';
|
||
const rateLimit = provider.rateLimitRpm > 0
|
||
? `<div class="provider-rate">limit: ${provider.rateLimitRpm} req/min</div>` : '';
|
||
const envHint = provider.status === 'unconfigured' && provider.envKey
|
||
? `<div class="provider-env-hint">set <code>${provider.envKey}</code> to activate</div>` : '';
|
||
const runtimeStatus = provider.runtimeStatus || (provider.status === 'configured' ? 'configured' : '');
|
||
const runtimeClass = provider.runtimeHealthy ? 'runtime-ready'
|
||
: runtimeStatus === 'auth_required' || provider.runtimeDetail ? 'runtime-warn'
|
||
: 'runtime-muted';
|
||
const runtimeLabel = provider.runtimeDetail
|
||
? `${runtimeStatus}: ${provider.runtimeDetail}`
|
||
: runtimeStatus;
|
||
const runtime = runtimeLabel
|
||
? `<div class="provider-runtime ${runtimeClass}"><span class="runtime-dot"></span><span>${escapeHtml(runtimeLabel)}</span></div>`
|
||
: '';
|
||
return `
|
||
<div class="provider-item" data-status="${provider.status}">
|
||
<div class="provider-header">
|
||
<div class="provider-name">${escapeHtml(displayName)}</div>
|
||
<div class="provider-tag ${statusClass}">${provider.status}</div>
|
||
</div>
|
||
${techName}
|
||
${runtime}
|
||
<div class="provider-models">${escapeHtml(modelList)}</div>
|
||
${rateLimit}
|
||
${envHint}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ─── Subscription Auto-Gateway ────────────────────────────────────────
|
||
async function loadSubscriptions() {
|
||
try {
|
||
const response = await apiFetch(`${API_BASE}/api/dashboard/subscriptions`);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const payload = await response.json();
|
||
if (!payload.success) throw new Error(payload.error || 'unknown');
|
||
renderSubscriptions(payload.data);
|
||
} catch (error) {
|
||
document.getElementById('subscriptionsList').innerHTML =
|
||
`<div class="empty-state">discovery failed: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderSubscriptions(data) {
|
||
const { subscriptions, summary } = data;
|
||
const stateEl = document.getElementById('subsAutoState');
|
||
const parts = [];
|
||
if (summary.detected) parts.push(`${summary.detected} detected`);
|
||
if (summary.userDeclared) parts.push(`${summary.userDeclared} declared`);
|
||
if (summary.running) parts.push(`${summary.running} live`);
|
||
const headline = summary.autoGatewayEnabled ? 'active' : 'detection + declaration';
|
||
stateEl.textContent = `${headline} — ${parts.join(' · ') || 'open settings to declare your subscriptions'}`;
|
||
|
||
document.getElementById('subsTabBadge').textContent = `${summary.installed}/${summary.total}`;
|
||
|
||
const list = document.getElementById('subscriptionsList');
|
||
if (!subscriptions.length) {
|
||
list.innerHTML = '<div class="empty-state">no subscriptions in catalog</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = subscriptions.map(renderSubscriptionCard).join('');
|
||
}
|
||
|
||
function renderSubscriptionCard(s) {
|
||
const available = s.installed;
|
||
const cardClass = s.bridgeRunning ? 'running' : (available ? 'installed' : 'missing');
|
||
const stateClass = s.bridgeRunning ? 'running' : (available ? 'installed' : 'missing');
|
||
let stateLabel;
|
||
if (s.bridgeRunning) stateLabel = '● bridge live';
|
||
else if (s.detected && s.userDeclared) stateLabel = '◆ detected+declared';
|
||
else if (s.detected) stateLabel = '◆ detected';
|
||
else if (s.userDeclared) stateLabel = '◇ declared';
|
||
else stateLabel = '○ not configured';
|
||
|
||
const versionLine = s.version
|
||
? `<div class="subs-meta">${s.command} → ${escapeHtml(s.version)}</div>`
|
||
: `<div class="subs-meta">${s.command}${s.userDeclared ? ' (declared)' : ''}</div>`;
|
||
const bridgeBlock = s.bridgeUrl
|
||
? `<div class="subs-bridge-url">bridge: ${s.bridgeUrl}${s.autoSpawned ? ' (auto)' : ''}</div>`
|
||
: '';
|
||
const modelsLine = s.models?.length
|
||
? `<div class="subs-models">${s.models.map(m => m.id).join(', ')}</div>` : '';
|
||
let hint = '';
|
||
if (!s.detected && !s.userDeclared) {
|
||
hint = `<div class="subs-install-hint">install <code>${s.command}</code> on the gateway host, or declare it in settings.</div>`;
|
||
} else if (!s.detected && s.userDeclared) {
|
||
hint = `<div class="subs-install-hint" style="color:#6aa0ff;border-color:rgba(106,160,255,0.25);background:rgba(106,160,255,0.05);">declared — use via your local <code>${s.command}</code> CLI. gateway routes through it.</div>`;
|
||
}
|
||
return `
|
||
<div class="subs-card ${cardClass}">
|
||
<div class="subs-head">
|
||
<div class="subs-label">${escapeHtml(s.label)}</div>
|
||
<span class="subs-state ${stateClass}">${stateLabel}</span>
|
||
</div>
|
||
${versionLine}
|
||
${modelsLine}
|
||
${bridgeBlock}
|
||
${hint}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ─── Full Discovery: CLIs + Local LLMs + API Keys ────────────────────
|
||
document.getElementById('discoverFullBtn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('discoverFullBtn');
|
||
const wrap = document.getElementById('discoverReportWrap');
|
||
const meta = document.getElementById('discoverReportMeta');
|
||
btn.disabled = true;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '⏳ scanning…';
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/discover`, { method: 'POST' });
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || 'discovery failed');
|
||
const r = payload.data.report;
|
||
const spawnedCount = payload.data.spawnedCount;
|
||
|
||
wrap.style.display = 'block';
|
||
meta.textContent = `host: ${r.host} · scanned: ${new Date(r.generatedAt).toLocaleTimeString()} · ${spawnedCount} bridges spawned · ${r.summary.totalProviders} total providers, ${r.summary.totalRoutableModels} models`;
|
||
|
||
// CLI subscriptions
|
||
document.getElementById('discCntSubs').textContent = r.subscriptions.detected;
|
||
document.getElementById('discListSubs').innerHTML = r.subscriptions.items.map(s => `
|
||
<li>
|
||
<span>${s.descriptor.label}</span>
|
||
<span class="${s.installed ? 'disc-ok' : 'disc-no'}">${s.installed ? (s.authenticated === true ? '✓ auth' : (s.authenticated === false ? '⚠ unauth' : '?')) : '—'}</span>
|
||
</li>
|
||
`).join('');
|
||
|
||
// Local LLM servers
|
||
document.getElementById('discCntLocal').textContent = r.localLLMs.detected;
|
||
document.getElementById('discListLocal').innerHTML = r.localLLMs.items.map(l => `
|
||
<li>
|
||
<span>${l.label}<br><span style="font-size:0.66rem;opacity:0.6;">${l.url}</span></span>
|
||
<span class="${l.detected ? 'disc-ok' : 'disc-no'}">${l.detected ? `✓ ${l.models.length} models · ${l.latencyMs}ms` : '— offline'}</span>
|
||
</li>
|
||
`).join('');
|
||
|
||
// API-key providers
|
||
document.getElementById('discCntKeys').textContent = r.apiKeys.configured;
|
||
document.getElementById('discListKeys').innerHTML = r.apiKeys.items.map(k => `
|
||
<li>
|
||
<span>${k.label}<br><span style="font-size:0.66rem;opacity:0.6;">${k.envKey}</span></span>
|
||
<span class="${k.configured ? 'disc-ok' : 'disc-no'}">${k.configured ? '✓ set' : '— missing'}</span>
|
||
</li>
|
||
`).join('');
|
||
|
||
btn.textContent = `✓ found ${r.summary.totalProviders}`;
|
||
await loadSubscriptions();
|
||
} catch (e) {
|
||
btn.textContent = `✗ ${e.message}`;
|
||
} finally {
|
||
setTimeout(() => { btn.disabled = false; btn.textContent = orig; }, 3000);
|
||
}
|
||
});
|
||
|
||
document.getElementById('subsSpawnBtn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('subsSpawnBtn');
|
||
btn.disabled = true;
|
||
const orig = btn.textContent;
|
||
btn.textContent = '⟳ spawning…';
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/subscriptions/spawn`, { method: 'POST' });
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || 'spawn failed');
|
||
btn.textContent = `✓ ${payload.data.spawnedCount} spawned`;
|
||
await loadSubscriptions();
|
||
} catch (e) {
|
||
btn.textContent = `✗ ${e.message}`;
|
||
} finally {
|
||
setTimeout(() => { btn.disabled = false; btn.textContent = orig; }, 2500);
|
||
}
|
||
});
|
||
|
||
// Polling
|
||
function setupPolling() {
|
||
document.getElementById('pollingStatusIndicator').className = 'dot ok';
|
||
document.getElementById('pollingStatus').textContent = 'live';
|
||
document.getElementById('connectionDot').className = 'dot';
|
||
document.getElementById('connectionText').textContent = 'connected';
|
||
|
||
if (metricsIntervalId) clearInterval(metricsIntervalId);
|
||
metricsIntervalId = setInterval(loadMetrics, METRICS_REFRESH_INTERVAL);
|
||
|
||
if (requestsIntervalId) clearInterval(requestsIntervalId);
|
||
requestsIntervalId = setInterval(loadRequests, REQUESTS_REFRESH_INTERVAL);
|
||
|
||
loadMetrics();
|
||
loadRequests();
|
||
}
|
||
|
||
// Filter buttons
|
||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
selectedHours = parseInt(btn.dataset.hours);
|
||
loadRequests();
|
||
loadMetrics();
|
||
});
|
||
});
|
||
|
||
// ─── Settings Modal ───────────────────────────────────────────────────
|
||
const SUBSCRIPTION_LABELS = {
|
||
'claude-code': 'Claude Code (Anthropic)',
|
||
'github-copilot': 'GitHub / Microsoft Copilot',
|
||
'chatgpt': 'OpenAI ChatGPT Plus',
|
||
'gemini': 'Google Gemini Advanced',
|
||
'codex': 'OpenAI Codex CLI',
|
||
'aider': 'Aider Pair Programmer',
|
||
};
|
||
const API_PROVIDER_LABELS = {
|
||
'cerebras': { label: 'Cerebras', envKey: 'CEREBRAS_API_KEY', placeholder: 'csk-...' },
|
||
'groq': { label: 'Groq', envKey: 'GROQ_API_KEY', placeholder: 'gsk_...' },
|
||
'mistral': { label: 'Mistral AI', envKey: 'MISTRAL_API_KEY', placeholder: 'mistral key' },
|
||
'nvidia': { label: 'NVIDIA NIM', envKey: 'NVIDIA_API_KEY', placeholder: 'nvapi-...' },
|
||
'cloudflare': { label: 'Cloudflare Workers AI', envKey: 'CLOUDFLARE_AI_TOKEN', placeholder: 'cf token' },
|
||
'openai-codex': { label: 'OpenAI API (paid)', envKey: 'OPENAI_API_KEY', placeholder: 'sk-...' },
|
||
};
|
||
|
||
let currentSettings = null;
|
||
|
||
function openSettings() {
|
||
document.getElementById('settingsModal').classList.add('open');
|
||
loadSettingsIntoModal();
|
||
}
|
||
function closeSettings() {
|
||
document.getElementById('settingsModal').classList.remove('open');
|
||
const ss = document.getElementById('settingsSaveStatus');
|
||
ss.textContent = ''; ss.className = 'save-status';
|
||
}
|
||
|
||
async function loadSettingsIntoModal() {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/settings`);
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || 'load failed');
|
||
currentSettings = payload.data;
|
||
renderSettingsForm(currentSettings);
|
||
} catch (e) {
|
||
const ss = document.getElementById('settingsSaveStatus');
|
||
ss.textContent = `load error: ${e.message}`;
|
||
ss.className = 'save-status err';
|
||
}
|
||
}
|
||
|
||
function renderSettingsForm(s) {
|
||
document.querySelectorAll('input[name="routingMode"]').forEach(r => {
|
||
r.checked = (r.value === s.routingMode);
|
||
r.closest('.settings-radio').classList.toggle('active', r.checked);
|
||
});
|
||
document.getElementById('routingModeBadge').textContent = s.routingMode;
|
||
|
||
// UI mode toggles
|
||
const ui = s.ui ?? { simpleMode: false, hideEmptyProviders: true, showTooltips: true };
|
||
document.getElementById('uiSimpleMode').checked = !!ui.simpleMode;
|
||
document.getElementById('uiHideEmpty').checked = !!ui.hideEmptyProviders;
|
||
document.getElementById('uiTooltips').checked = !!ui.showTooltips;
|
||
|
||
const subList = document.getElementById('settingsSubscriptionsList');
|
||
subList.innerHTML = Object.entries(SUBSCRIPTION_LABELS).map(([id, label]) => {
|
||
const cfg = s.subscriptions?.[id] ?? { enabled: true, autoSpawn: true, bridgeUrl: '' };
|
||
const bridgeHint = cfg.bridgeUrl
|
||
? `bridge: ${cfg.bridgeUrl}`
|
||
: 'no bridge URL — set one if the CLI runs on another machine';
|
||
return `
|
||
<div class="settings-row">
|
||
<div class="settings-row-info" style="grid-column:1/-1;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;">
|
||
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
|
||
<span class="settings-row-label">${label}</span>
|
||
<span class="settings-row-meta">id: ${id} · ${bridgeHint}</span>
|
||
<input class="settings-input" type="text" data-sub-bridge="${id}" placeholder="https://your-bridge-host:port (leave blank for local auto-spawn)" value="${cfg.bridgeUrl || ''}">
|
||
</div>
|
||
<label class="settings-toggle" style="flex-shrink:0;">
|
||
<input type="checkbox" data-sub="${id}" ${cfg.enabled ? 'checked' : ''}>
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const apiList = document.getElementById('settingsApiList');
|
||
apiList.innerHTML = Object.entries(API_PROVIDER_LABELS).map(([id, info]) => {
|
||
const cfg = s.apiProviders?.[id] ?? { enabled: false, hasKey: false };
|
||
const placeholder = cfg.hasKey ? '••••••• (key on file — leave blank to keep)' : info.placeholder;
|
||
return `
|
||
<div class="settings-row">
|
||
<div class="settings-row-info" style="grid-column:1/-1;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;">
|
||
<div style="display:flex;flex-direction:column;gap:2px;flex:1;">
|
||
<span class="settings-row-label">${info.label}</span>
|
||
<span class="settings-row-meta">${info.envKey} · ${cfg.hasKey ? '✓ key set' : 'no key'}</span>
|
||
<input class="settings-input" type="password" data-api-key="${id}" placeholder="${placeholder}" autocomplete="new-password">
|
||
</div>
|
||
<label class="settings-toggle" style="flex-shrink:0;">
|
||
<input type="checkbox" data-api-enabled="${id}" ${cfg.enabled ? 'checked' : ''}>
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
document.getElementById('ollamaEnabled').checked = !!s.ollama?.enabled;
|
||
document.getElementById('ollamaBaseUrl').value = s.ollama?.baseUrl ?? 'http://localhost:11434';
|
||
}
|
||
|
||
async function saveSettingsFromModal() {
|
||
const ss = document.getElementById('settingsSaveStatus');
|
||
const saveBtn = document.getElementById('settingsSave');
|
||
saveBtn.disabled = true;
|
||
ss.textContent = 'saving…'; ss.className = 'save-status';
|
||
|
||
try {
|
||
const routingMode = document.querySelector('input[name="routingMode"]:checked')?.value ?? 'auto';
|
||
|
||
const subscriptions = {};
|
||
document.querySelectorAll('[data-sub]').forEach(cb => {
|
||
const id = cb.dataset.sub;
|
||
const bridgeInput = document.querySelector(`[data-sub-bridge="${id}"]`);
|
||
const bridgeUrl = bridgeInput?.value?.trim() ?? '';
|
||
subscriptions[id] = {
|
||
enabled: cb.checked,
|
||
autoSpawn: currentSettings?.subscriptions?.[id]?.autoSpawn ?? true,
|
||
bridgeUrl: bridgeUrl, // empty string = no remote bridge, fall back to local auto-spawn
|
||
};
|
||
});
|
||
|
||
const apiProviders = {};
|
||
Object.keys(API_PROVIDER_LABELS).forEach(id => {
|
||
const enabled = document.querySelector(`[data-api-enabled="${id}"]`)?.checked ?? false;
|
||
const newKey = document.querySelector(`[data-api-key="${id}"]`)?.value ?? '';
|
||
const entry = { enabled };
|
||
if (newKey.trim()) entry.apiKey = newKey.trim();
|
||
apiProviders[id] = entry;
|
||
});
|
||
|
||
const ollama = {
|
||
enabled: document.getElementById('ollamaEnabled').checked,
|
||
baseUrl: document.getElementById('ollamaBaseUrl').value.trim() || 'http://localhost:11434',
|
||
};
|
||
|
||
const ui = {
|
||
simpleMode: document.getElementById('uiSimpleMode').checked,
|
||
hideEmptyProviders: document.getElementById('uiHideEmpty').checked,
|
||
showTooltips: document.getElementById('uiTooltips').checked,
|
||
};
|
||
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/settings`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ routingMode, subscriptions, apiProviders, ollama, ui }),
|
||
});
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || `HTTP ${res.status}`);
|
||
currentSettings = payload.data;
|
||
document.getElementById('routingModeBadge').textContent = payload.data.routingMode;
|
||
ss.textContent = `saved · ${new Date().toLocaleTimeString()}`;
|
||
ss.className = 'save-status ok';
|
||
applyUiMode(ui);
|
||
await loadProviders();
|
||
await loadSubscriptions();
|
||
} catch (e) {
|
||
ss.textContent = `error: ${e.message}`;
|
||
ss.className = 'save-status err';
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
document.getElementById('settingsBtn').addEventListener('click', openSettings);
|
||
document.getElementById('settingsClose').addEventListener('click', closeSettings);
|
||
document.getElementById('settingsCancel').addEventListener('click', closeSettings);
|
||
document.getElementById('settingsSave').addEventListener('click', saveSettingsFromModal);
|
||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'settingsModal') closeSettings();
|
||
});
|
||
document.querySelectorAll('input[name="routingMode"]').forEach(r => {
|
||
r.addEventListener('change', () => {
|
||
document.querySelectorAll('.settings-radio').forEach(label => {
|
||
label.classList.toggle('active', label.querySelector('input').checked);
|
||
});
|
||
});
|
||
});
|
||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSettings(); });
|
||
|
||
// ─── Savings Tab ─────────────────────────────────────────────────────
|
||
async function loadSavings() {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/savings?hours=24&bucket_minutes=60`);
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || 'load failed');
|
||
renderSavings(payload.data);
|
||
} catch (e) {
|
||
document.getElementById('savingsCounter').textContent = '$—';
|
||
document.getElementById('savingsSubLine').textContent = `error: ${e.message}`;
|
||
}
|
||
}
|
||
|
||
function renderSavings(data) {
|
||
const s = data.savings;
|
||
const series = data.series || [];
|
||
|
||
const counter = document.getElementById('savingsCounter');
|
||
counter.textContent = formatCost(s.totalCostSaved);
|
||
|
||
document.getElementById('savingsSubLine').textContent =
|
||
`${formatNumber(s.totalTokensSaved)} tokens prevented · ${s.totalHits} cache hits`;
|
||
document.getElementById('savingsHitRate').textContent = `hit rate ${s.hitRatePercent}%`;
|
||
|
||
document.getElementById('cacheEntries').textContent = formatNumber(s.uniqueEntries);
|
||
document.getElementById('tokensPrevented').textContent = formatNumber(s.totalTokensSaved);
|
||
document.getElementById('cacheHitRate').innerHTML = s.hitRatePercent.toFixed(1) + '<span class="metric-unit">%</span>';
|
||
const sr = s.sinceRestart || {};
|
||
document.getElementById('compressedSinceRestart').textContent = formatNumber(sr.tokensSaved || 0);
|
||
const sinceLabel = sr.sinceISO ? new Date(sr.sinceISO).toLocaleString() : '—';
|
||
const pctTxt = (sr.savingsPct || 0).toFixed(1) + '%';
|
||
document.getElementById('compressedSinceRestartMeta').textContent = pctTxt + ' · ' + (sr.operations || 0) + ' ops · since ' + sinceLabel;
|
||
|
||
// Tab badge
|
||
document.getElementById('savingsTabBadge').textContent = s.totalHits > 0 ? formatCost(s.totalCostSaved) : '·';
|
||
|
||
// Top callers
|
||
const tc = document.getElementById('topSavingCallers');
|
||
if (s.topCallers && s.topCallers.length) {
|
||
tc.innerHTML = s.topCallers.map(c => `
|
||
<div class="chip">
|
||
<div class="chip-name">${escapeHtml(c.caller)}</div>
|
||
<div class="chip-meta"><span class="num">${c.hits}</span> hits · <span class="num">${formatCost(c.saved)}</span> saved</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
tc.innerHTML = '<div class="empty-state">no savings yet — send some duplicate prompts to see cache hits</div>';
|
||
}
|
||
|
||
// Sparkline
|
||
const svg = document.getElementById('savingsSparkline');
|
||
if (!series.length) { svg.innerHTML = ''; return; }
|
||
const W = 320, H = 64, PAD = 4;
|
||
const max = Math.max(0.0001, ...series.map(p => p.costSaved));
|
||
const stepX = (W - PAD * 2) / Math.max(1, series.length - 1);
|
||
const points = series.map((p, i) => {
|
||
const x = PAD + i * stepX;
|
||
const y = H - PAD - ((p.costSaved / max) * (H - PAD * 2));
|
||
return [x, y];
|
||
});
|
||
const linePath = points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
|
||
const areaPath = `${linePath} L${points[points.length - 1][0].toFixed(1)},${H - PAD} L${points[0][0].toFixed(1)},${H - PAD} Z`;
|
||
const last = points[points.length - 1];
|
||
svg.innerHTML = `
|
||
<path class="area" d="${areaPath}"></path>
|
||
<path class="line" d="${linePath}"></path>
|
||
<circle class="last" cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.5"></circle>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('cacheClearBtn').addEventListener('click', async () => {
|
||
const caller = document.getElementById('cacheClearCaller').value.trim();
|
||
if (!caller) return alert('enter caller id');
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/cache/clear`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ caller }),
|
||
});
|
||
const p = await res.json();
|
||
alert(p.success ? `removed ${p.data.removed} entries` : p.error);
|
||
loadSavings();
|
||
} catch (e) { alert('error: ' + e.message); }
|
||
});
|
||
|
||
document.getElementById('cachePruneBtn').addEventListener('click', async () => {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/cache/prune`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ max_age_days: 7 }),
|
||
});
|
||
const p = await res.json();
|
||
alert(p.success ? `pruned ${p.data.removed} stale entries` : p.error);
|
||
loadSavings();
|
||
} catch (e) { alert('error: ' + e.message); }
|
||
});
|
||
|
||
// ─── Wallet Tab (UNIQUE feature) ─────────────────────────────────────
|
||
async function loadWallet() {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/wallet`);
|
||
const payload = await res.json();
|
||
if (!payload.success) throw new Error(payload.error || 'load failed');
|
||
renderWallet(payload.data);
|
||
} catch (e) {
|
||
document.getElementById('walletList').innerHTML =
|
||
`<div class="empty-state">error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderWallet(data) {
|
||
const list = document.getElementById('walletList');
|
||
if (!data.wallet?.length) { list.innerHTML = '<div class="empty-state">no subscriptions tracked</div>'; return; }
|
||
|
||
const totalRem = data.totals?.remaining ?? 0;
|
||
// Show units to avoid confusion with token counts elsewhere
|
||
document.getElementById('walletTabBadge').textContent = totalRem > 0 ? `${formatNumber(totalRem)} calls` : '·';
|
||
|
||
list.innerHTML = data.wallet.map(w => {
|
||
const util = w.utilizationPercent ?? 0;
|
||
const fillCls = util >= 90 ? 'err' : util >= 70 ? 'warn' : '';
|
||
const fillW = w.requestQuota ? Math.min(util, 100) : 0;
|
||
const remStr = w.requestQuota
|
||
? `<strong>${w.remaining}</strong> / ${w.requestQuota} calls left`
|
||
: `<strong>—</strong> no quota tracked`;
|
||
const usedStr = `<strong>${w.used}</strong> calls used`;
|
||
const reset = w.resetAt
|
||
? `resets ${new Date(w.resetAt).toLocaleString()}`
|
||
: `window: ${formatDuration(w.windowSeconds)}`;
|
||
const exhaust = w.predictedExhaustionAt
|
||
? `predicted exhaustion: ${new Date(w.predictedExhaustionAt).toLocaleString()}`
|
||
: '';
|
||
return `
|
||
<div class="wallet-card" data-status="${w.recommendation}">
|
||
<div class="wallet-head">
|
||
<div class="wallet-label">${escapeHtml(w.label)}</div>
|
||
<div class="wallet-rec ${w.recommendation}">${w.recommendation.replace('-', ' ')}</div>
|
||
</div>
|
||
<div class="wallet-bar">
|
||
<div class="wallet-bar-fill ${fillCls}" style="width:${fillW}%"></div>
|
||
</div>
|
||
<div class="wallet-meta">
|
||
<span>${usedStr}</span>
|
||
<span>${remStr}</span>
|
||
</div>
|
||
<div class="wallet-reset">${reset}</div>
|
||
${exhaust ? `<div class="wallet-reset">${exhaust}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function formatDuration(secs) {
|
||
if (secs >= 86400) return `${Math.round(secs / 86400)}d`;
|
||
if (secs >= 3600) return `${Math.round(secs / 3600)}h`;
|
||
if (secs >= 60) return `${Math.round(secs / 60)}m`;
|
||
return `${secs}s`;
|
||
}
|
||
|
||
// ─── Memory Tab ──────────────────────────────────────────────────────
|
||
async function loadMemoryFor(caller) {
|
||
if (!caller) return;
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/memory/${encodeURIComponent(caller)}`);
|
||
const p = await res.json();
|
||
if (!p.success) throw new Error(p.error || 'load failed');
|
||
renderMemory(p.data);
|
||
} catch (e) {
|
||
document.getElementById('memList').innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderMemory(data) {
|
||
const list = document.getElementById('memList');
|
||
if (!data.facts?.length) { list.innerHTML = `<div class="empty-state">no facts stored for "${escapeHtml(data.caller)}"</div>`; return; }
|
||
list.innerHTML = data.facts.map(f => `
|
||
<div class="mem-row">
|
||
<div class="mem-key">${escapeHtml(f.factKey)}</div>
|
||
<div class="mem-val">${escapeHtml(f.factValue)}</div>
|
||
<div class="mem-meta">conf=${f.confidence} · ${escapeHtml(f.source)}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
document.getElementById('memLoadBtn').addEventListener('click', () => {
|
||
loadMemoryFor(document.getElementById('memCaller').value.trim());
|
||
});
|
||
|
||
document.getElementById('memSaveBtn').addEventListener('click', async () => {
|
||
const caller = document.getElementById('memCaller').value.trim();
|
||
const fk = document.getElementById('memFactKey').value.trim();
|
||
const fv = document.getElementById('memFactValue').value.trim();
|
||
if (!caller || !fk || !fv) return alert('fill caller, key, value');
|
||
try {
|
||
await apiFetch(`${API_BASE}/api/dashboard/memory/${encodeURIComponent(caller)}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ fact_key: fk, fact_value: fv, confidence: 0.95 }),
|
||
});
|
||
document.getElementById('memFactKey').value = '';
|
||
document.getElementById('memFactValue').value = '';
|
||
loadMemoryFor(caller);
|
||
} catch (e) { alert('error: ' + e.message); }
|
||
});
|
||
|
||
// Auto-refresh savings + wallet every 10s when their tab is visible
|
||
setInterval(() => {
|
||
const active = document.querySelector('.tab-trigger.active')?.dataset.tab;
|
||
if (active === 'savings') loadSavings();
|
||
if (active === 'wallet') loadWallet();
|
||
}, 10_000);
|
||
|
||
// Hook tab switches to lazy-load tab data
|
||
document.querySelectorAll('.tab-trigger').forEach(t => {
|
||
t.addEventListener('click', () => {
|
||
const target = t.dataset.tab;
|
||
if (target === 'savings') loadSavings();
|
||
if (target === 'wallet') loadWallet();
|
||
});
|
||
});
|
||
|
||
// ─── Hero / Buddy / Achievements / Heatmap / Events / Forecast ──────
|
||
async function loadHero() {
|
||
try {
|
||
const [buddy, ach, heatmap, events, forecast, savings] = await Promise.all([
|
||
apiFetch(`${API_BASE}/api/dashboard/buddy`).then(r => r.json()),
|
||
apiFetch(`${API_BASE}/api/dashboard/achievements`).then(r => r.json()),
|
||
apiFetch(`${API_BASE}/api/dashboard/heatmap?days=365`).then(r => r.json()),
|
||
apiFetch(`${API_BASE}/api/dashboard/events?limit=30`).then(r => r.json()),
|
||
apiFetch(`${API_BASE}/api/dashboard/forecast`).then(r => r.json()),
|
||
apiFetch(`${API_BASE}/api/dashboard/savings?hours=8760`).then(r => r.json()),
|
||
]);
|
||
if (buddy.success) renderBuddy(buddy.data);
|
||
if (ach.success) renderAchievements(ach.data);
|
||
if (heatmap.success) renderHeatmap(heatmap.data);
|
||
if (events.success) renderEventsFeed(events.data);
|
||
if (forecast.success) renderForecast(forecast.data);
|
||
if (savings.success) renderHeroSavings(savings.data);
|
||
} catch (e) {
|
||
console.error('hero load failed', e);
|
||
}
|
||
}
|
||
|
||
function renderBuddy(b) {
|
||
const xpPercent = Math.min(100, (b.xp / b.xpForNextLevel) * 100);
|
||
document.getElementById('heroBuddy').innerHTML = `
|
||
<div>
|
||
<span class="buddy-name">${escapeHtml(b.name)}</span>
|
||
<span class="buddy-rarity ${b.rarity}">${b.rarity}</span>
|
||
</div>
|
||
<div class="buddy-meta">${escapeHtml(b.species)} · ${escapeHtml(b.stage)} · Lv.${b.level} · ${b.streakDays}d streak</div>
|
||
<div class="buddy-art">${b.asciiArt.map(escapeHtml).join('\n')}</div>
|
||
<div class="buddy-xp-bar"><div class="buddy-xp-fill" style="width:${xpPercent}%"></div></div>
|
||
<div class="buddy-xp-text"><span>XP ${b.xp.toLocaleString()}</span><span>Next: ${b.xpForNextLevel.toLocaleString()}</span></div>
|
||
<div class="buddy-speech buddy-mood-${b.mood}">${escapeHtml(b.speech)}</div>
|
||
`;
|
||
}
|
||
|
||
// Try to fetch external tool stats from localhost:3333 (legacy compat) (browser-side, not server-side)
|
||
// Returns null if no external tool runs there.
|
||
async function fetchExternalToolStats() {
|
||
try {
|
||
const ctrl = new AbortController();
|
||
setTimeout(() => ctrl.abort(), 1500);
|
||
const res = await fetch('http://localhost:3333/api/stats', { signal: ctrl.signal });
|
||
if (!res.ok) return null;
|
||
const stats = await res.json();
|
||
// The "tokens saved" calculation: input - output (compression delta) summed across commands
|
||
let saved = 0;
|
||
for (const v of Object.values(stats.commands || {})) {
|
||
saved += Math.max(0, (v.input_tokens || 0) - (v.output_tokens || 0));
|
||
}
|
||
return { saved, totalCommands: stats.total_commands || 0 };
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function renderHeroSavings(d) {
|
||
const s = d.savings;
|
||
const c = s.comprehensive || {};
|
||
const gatewayTokens = s.totalTokensSaved || 0;
|
||
document.getElementById('heroTokensSaved').textContent = formatNumber(gatewayTokens);
|
||
document.getElementById('heroCostSaved').textContent = formatCost(s.totalCostSaved);
|
||
document.getElementById('heroCacheHits').textContent = s.totalHits;
|
||
document.getElementById('heroSavingsRate').textContent = `${s.hitRatePercent || 0}%`;
|
||
|
||
// Optional external-tool integration: pull from localhost:3333 if running
|
||
const externalTool = await fetchExternalToolStats();
|
||
const combined = gatewayTokens + (externalTool?.saved || 0);
|
||
document.getElementById('heroTokensSavedCombined').textContent = formatNumber(combined);
|
||
if (externalTool) {
|
||
document.getElementById('heroExternalToolRow').style.display = 'flex';
|
||
document.getElementById('heroExternalToolTokens').textContent = formatNumber(externalTool.saved);
|
||
} else {
|
||
document.getElementById('heroExternalToolRow').style.display = 'none';
|
||
}
|
||
document.getElementById('costWithout').textContent = formatCost(c.costWithoutGateway || 0);
|
||
document.getElementById('costWith').textContent = formatCost(c.costWithGateway || 0);
|
||
const saved = (c.costWithoutGateway || 0) - (c.costWithGateway || 0);
|
||
document.getElementById('costSavedLine').textContent = (saved < 0 ? '-$' : '$') + Math.abs(saved).toFixed(2);
|
||
document.getElementById('costSavedPercent').textContent = `${(c.effectiveSavingsPercent || 0).toFixed(1)}%`;
|
||
|
||
// 5-axis savings
|
||
const axes = [
|
||
{ id: 'cache', label: 'Cache', icon: '⚡', cost: c.bySource?.cache?.cost ?? 0, detail: `${c.bySource?.cache?.hits ?? 0} hits` },
|
||
{ id: 'compression', label: 'Compression', icon: '🗜', cost: c.bySource?.compression?.cost ?? 0, detail: `${formatNumber(c.bySource?.compression?.tokens ?? 0)} tokens` },
|
||
{ id: 'subscriptionBridge', label: 'Sub. Bridges', icon: '🌉', cost: c.bySource?.subscriptionBridge?.cost ?? 0, detail: `${c.bySource?.subscriptionBridge?.calls ?? 0} calls` },
|
||
{ id: 'localRouting', label: 'Local Models', icon: '🏠', cost: c.bySource?.localRouting?.cost ?? 0, detail: `${c.bySource?.localRouting?.calls ?? 0} calls` },
|
||
{ id: 'raceMode', label: 'Race Mode', icon: '🏁', cost: c.bySource?.raceMode?.cost ?? 0, detail: `${c.bySource?.raceMode?.calls ?? 0} races` },
|
||
];
|
||
document.getElementById('savingsAxes').innerHTML = axes.map(a => `
|
||
<div class="axis">
|
||
<span class="axis-icon">${a.icon}</span>
|
||
<span class="axis-label">${a.label}</span>
|
||
<span class="axis-cost">${formatCost(a.cost)}</span>
|
||
<span class="axis-detail">${a.detail}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderAchievements(a) {
|
||
document.getElementById('achievementsProgress').textContent = `${a.unlocked.length}/${a.unlocked.length + a.locked.length} · ${a.progress}%`;
|
||
const all = [...a.unlocked.map(x => ({...x, unlocked: true})), ...a.locked.slice(0, 12).map(x => ({...x, unlocked: false}))];
|
||
document.getElementById('achievementsGrid').innerHTML = all.map(x => `
|
||
<div class="achievement ${x.unlocked ? 'unlocked' : 'locked'}">
|
||
<div class="ach-icon">${x.icon}</div>
|
||
<div class="ach-info">
|
||
<div class="ach-title">${escapeHtml(x.title)}</div>
|
||
<div class="ach-desc">${escapeHtml(x.description)}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderHeatmap(cells) {
|
||
// Lay out cells column-major (Sun→Sat per week column, like GitHub)
|
||
// Total 365 days = ~52 weeks of 7 cells. Pad start so first cell aligns to Sunday.
|
||
if (!cells.length) { document.getElementById('heatmap').innerHTML = '<div class="empty-state">no activity yet</div>'; return; }
|
||
const first = new Date(cells[0].date);
|
||
const padDays = first.getUTCDay(); // 0=Sun, 6=Sat
|
||
const padded = Array(padDays).fill(null).concat(cells);
|
||
let maxStreak = 0;
|
||
let curStreak = 0;
|
||
for (const c of cells) {
|
||
if (c && c.count > 0) { curStreak++; if (curStreak > maxStreak) maxStreak = curStreak; }
|
||
else curStreak = 0;
|
||
}
|
||
// Latest streak from end
|
||
let endStreak = 0;
|
||
for (let i = cells.length - 1; i >= 0; i--) {
|
||
if (cells[i].count > 0) endStreak++; else break;
|
||
}
|
||
document.getElementById('streakBadge').textContent = endStreak;
|
||
|
||
document.getElementById('heatmap').innerHTML = padded.map(c => {
|
||
if (!c) return '<div class="heatmap-cell"></div>';
|
||
const title = `${c.date}: ${c.count} req · ${c.tokensSaved} saved`;
|
||
return `<div class="heatmap-cell l${c.level}" title="${title}"></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderEventsFeed(events) {
|
||
const el = document.getElementById('eventsFeed');
|
||
if (!events.length) { el.innerHTML = '<div class="empty-state">no events yet</div>'; return; }
|
||
el.innerHTML = events.map(e => `
|
||
<div class="event-row">
|
||
<span class="event-icon">${e.icon}</span>
|
||
<div class="event-body">
|
||
<span class="event-caller">${escapeHtml(e.caller)}</span> · ${escapeHtml(e.type)}
|
||
<div class="event-detail">${escapeHtml(e.detail)}</div>
|
||
</div>
|
||
<span class="event-time">${formatTime(e.ts)}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderForecast(f) {
|
||
const trendIcon = f.trend === 'up' ? '↗' : (f.trend === 'down' ? '↘' : '→');
|
||
document.getElementById('forecast').innerHTML = `
|
||
<div class="forecast-row"><span class="forecast-window">next 7 days</span><span class="forecast-amount">${formatCost(f.next7DaysSavings)}</span></div>
|
||
<div class="forecast-row"><span class="forecast-window">next 30 days</span><span class="forecast-amount">${formatCost(f.next30DaysSavings)}</span></div>
|
||
<div class="forecast-row"><span class="forecast-window">next 12 months</span><span class="forecast-amount">${formatCost(f.next365DaysSavings)}</span></div>
|
||
<div class="forecast-trend ${f.trend}">${trendIcon} trend ${f.trend} · daily avg ${formatCost(f.dailyAverage)} · ${f.basedOnDays}d data</div>
|
||
`;
|
||
}
|
||
|
||
function formatTime(iso) {
|
||
try {
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const diffMs = now - d;
|
||
if (diffMs < 60_000) return 'just now';
|
||
if (diffMs < 3600_000) return `${Math.floor(diffMs/60000)}m ago`;
|
||
if (diffMs < 86400_000) return `${Math.floor(diffMs/3600000)}h ago`;
|
||
return d.toISOString().split('T')[0];
|
||
} catch { return iso; }
|
||
}
|
||
|
||
// ─── Simple Mode application ─────────────────────────────────────────
|
||
// Hide tabs / sections / content based on the user's UI preferences.
|
||
// Defaults to Simple Mode = ON for users with few configured subscriptions.
|
||
const ADVANCED_TABS_TO_HIDE_IN_SIMPLE = ['providers', 'memory', 'leaderboard', 'share', 'report'];
|
||
|
||
function applyUiMode(ui) {
|
||
const simpleMode = !!ui?.simpleMode;
|
||
const hideEmpty = !!ui?.hideEmptyProviders;
|
||
const tooltips = !!ui?.showTooltips;
|
||
|
||
// 1) Hide advanced tabs in Simple Mode
|
||
document.querySelectorAll('.tab-trigger').forEach(t => {
|
||
const isAdvanced = ADVANCED_TABS_TO_HIDE_IN_SIMPLE.includes(t.dataset.tab);
|
||
t.style.display = (simpleMode && isAdvanced) ? 'none' : '';
|
||
});
|
||
|
||
// 2) Toggle tooltip attribute
|
||
document.querySelectorAll('.tab-trigger').forEach(t => {
|
||
if (!tooltips && t.title) { t.dataset.savedTitle = t.title; t.title = ''; }
|
||
else if (tooltips && t.dataset.savedTitle) { t.title = t.dataset.savedTitle; }
|
||
});
|
||
|
||
// 3) Body class — used by other CSS-driven simplifications
|
||
document.body.classList.toggle('simple-mode', simpleMode);
|
||
document.body.classList.toggle('hide-empty-providers', hideEmpty);
|
||
|
||
// 4) If currently on a hidden tab, switch to overview
|
||
const activeTab = document.querySelector('.tab-trigger.active')?.dataset.tab;
|
||
if (simpleMode && ADVANCED_TABS_TO_HIDE_IN_SIMPLE.includes(activeTab)) {
|
||
const overview = document.querySelector('.tab-trigger[data-tab="overview"]');
|
||
if (overview) overview.click();
|
||
}
|
||
}
|
||
|
||
// ─── Knowledge Graph (force-directed SVG, no D3 dep) ──────────────
|
||
async function loadMemoryGraph() {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/memory-graph`);
|
||
const p = await res.json();
|
||
if (!p.success) throw new Error(p.error || 'graph failed');
|
||
renderMemoryGraph(p.data);
|
||
} catch (e) {
|
||
document.getElementById('memoryGraph').innerHTML = `<text x="20" y="40" fill="#888">error: ${e.message}</text>`;
|
||
}
|
||
}
|
||
|
||
function renderMemoryGraph(g) {
|
||
const svg = document.getElementById('memoryGraph');
|
||
if (!g.nodes.length) {
|
||
svg.innerHTML = '<text x="440" y="230" text-anchor="middle" font-family="JetBrains Mono" font-size="14" fill="#667684">No facts stored yet — try `remember that …` in any caller</text>';
|
||
return;
|
||
}
|
||
|
||
// Simple force-directed layout: 60 iterations of attraction along edges + repulsion between all nodes
|
||
const W = 880, H = 460;
|
||
const nodes = g.nodes.map((n, i) => ({
|
||
...n,
|
||
x: W/2 + Math.cos(i * 2 * Math.PI / g.nodes.length) * 200,
|
||
y: H/2 + Math.sin(i * 2 * Math.PI / g.nodes.length) * 150,
|
||
r: n.type === 'caller' ? 14 : (n.type === 'fact-key' ? 9 : 6),
|
||
}));
|
||
const idx = new Map(nodes.map(n => [n.id, n]));
|
||
|
||
for (let it = 0; it < 80; it++) {
|
||
// repulsion
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
for (let j = i+1; j < nodes.length; j++) {
|
||
const a = nodes[i], b = nodes[j];
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const d2 = dx*dx + dy*dy + 1;
|
||
const f = 1500 / d2;
|
||
const fx = (dx / Math.sqrt(d2)) * f;
|
||
const fy = (dy / Math.sqrt(d2)) * f;
|
||
a.x -= fx; a.y -= fy;
|
||
b.x += fx; b.y += fy;
|
||
}
|
||
}
|
||
// attraction along edges
|
||
for (const e of g.edges) {
|
||
const a = idx.get(e.source), b = idx.get(e.target);
|
||
if (!a || !b) continue;
|
||
const dx = b.x - a.x, dy = b.y - a.y;
|
||
const f = 0.04;
|
||
a.x += dx * f; a.y += dy * f;
|
||
b.x -= dx * f; b.y -= dy * f;
|
||
}
|
||
// boundary
|
||
for (const n of nodes) {
|
||
n.x = Math.max(20, Math.min(W-20, n.x));
|
||
n.y = Math.max(20, Math.min(H-20, n.y));
|
||
}
|
||
}
|
||
|
||
// Render edges + nodes
|
||
const edgeSvg = g.edges.map(e => {
|
||
const a = idx.get(e.source), b = idx.get(e.target);
|
||
if (!a || !b) return '';
|
||
return `<path class="edge" d="M ${a.x.toFixed(1)} ${a.y.toFixed(1)} L ${b.x.toFixed(1)} ${b.y.toFixed(1)}"/>`;
|
||
}).join('');
|
||
|
||
const nodeSvg = nodes.map(n => {
|
||
const cls = n.type === 'caller' ? 'node-caller' : (n.type === 'fact-key' ? 'node-fact-key' : 'node-fact-value');
|
||
const labelOffset = n.r + 12;
|
||
return `
|
||
<g class="node">
|
||
<title>${escapeHtml(n.label)} (${n.type})</title>
|
||
<circle cx="${n.x.toFixed(1)}" cy="${n.y.toFixed(1)}" r="${n.r}" class="${cls}"/>
|
||
${n.type === 'caller' ? `<text class="label" x="${n.x.toFixed(1)}" y="${(n.y+labelOffset).toFixed(1)}" text-anchor="middle">${escapeHtml(n.label)}</text>` : ''}
|
||
</g>`;
|
||
}).join('');
|
||
|
||
svg.innerHTML = edgeSvg + nodeSvg;
|
||
}
|
||
|
||
// ─── Race Leaderboard ───────────────────────────────────────────────
|
||
async function loadLeaderboard() {
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/race-leaderboard?days=7`);
|
||
const p = await res.json();
|
||
if (!p.success) throw new Error(p.error || 'leaderboard failed');
|
||
renderLeaderboard(p.data);
|
||
} catch (e) {
|
||
document.getElementById('leaderboardTable').innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderLeaderboard(d) {
|
||
document.getElementById('leaderboardTabBadge').textContent = d.totalRaces > 0 ? `${d.totalRaces}` : '·';
|
||
|
||
// Podium
|
||
const top3 = d.entries.slice(0, 3);
|
||
const podium = document.getElementById('leaderboardPodium');
|
||
if (top3.length === 0) {
|
||
podium.innerHTML = '<div class="empty-state" style="grid-column:1/-1;">no races run yet — POST /v1/race to start competing models</div>';
|
||
} else {
|
||
const slots = [];
|
||
const findByBadge = (badge) => top3.find(e => e.badge === badge);
|
||
const gold = findByBadge('gold');
|
||
const silver = findByBadge('silver');
|
||
const bronze = findByBadge('bronze');
|
||
if (silver) slots.push(`<div class="podium-step silver"><div class="podium-medal">🥈</div><div class="podium-rank">2nd</div><div class="podium-model">${escapeHtml(silver.model)}</div><div class="podium-stat">${silver.avgLatencyMs}ms · ${(silver.winRate*100).toFixed(0)}% win</div></div>`);
|
||
else slots.push('<div></div>');
|
||
if (gold) slots.push(`<div class="podium-step gold"><div class="podium-medal">🥇</div><div class="podium-rank">1st</div><div class="podium-model">${escapeHtml(gold.model)}</div><div class="podium-stat">${gold.avgLatencyMs}ms · ${(gold.winRate*100).toFixed(0)}% win</div></div>`);
|
||
else slots.push('<div></div>');
|
||
if (bronze) slots.push(`<div class="podium-step bronze"><div class="podium-medal">🥉</div><div class="podium-rank">3rd</div><div class="podium-model">${escapeHtml(bronze.model)}</div><div class="podium-stat">${bronze.avgLatencyMs}ms · ${(bronze.winRate*100).toFixed(0)}% win</div></div>`);
|
||
else slots.push('<div></div>');
|
||
podium.innerHTML = slots.join('');
|
||
}
|
||
|
||
// Full table
|
||
const tbl = document.getElementById('leaderboardTable');
|
||
const head = `
|
||
<div class="lb-row head">
|
||
<div>#</div><div>model</div><div class="lb-num">latency</div>
|
||
<div class="lb-num">speed</div><div class="lb-num">wins</div><div class="lb-num">races</div>
|
||
</div>`;
|
||
const rows = d.entries.map(e => `
|
||
<div class="lb-row ${e.badge ? 'medal-' + e.badge : ''}">
|
||
<div class="lb-pos">${e.rankPosition}</div>
|
||
<div>${escapeHtml(e.model)}</div>
|
||
<div class="lb-num">${e.avgLatencyMs}ms</div>
|
||
<div class="lb-num">${(e.speedRate*100).toFixed(0)}%</div>
|
||
<div class="lb-num">${e.selectedCount}</div>
|
||
<div class="lb-num">${e.participations}</div>
|
||
</div>
|
||
`).join('');
|
||
tbl.innerHTML = head + (d.entries.length === 0 ? '<div class="empty-state">no race results yet</div>' : rows);
|
||
}
|
||
|
||
// ─── Per-Caller Deep Dive ──────────────────────────────────────────
|
||
async function openCallerDeepDive(caller) {
|
||
document.getElementById('callerModal').classList.add('open');
|
||
document.getElementById('callerModalTitle').textContent = caller;
|
||
document.getElementById('callerModalBody').innerHTML = '<div class="loading">loading</div>';
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/caller/${encodeURIComponent(caller)}`);
|
||
const p = await res.json();
|
||
if (!p.success) throw new Error(p.error || 'load failed');
|
||
renderCallerDeepDive(p.data);
|
||
} catch (e) {
|
||
document.getElementById('callerModalBody').innerHTML = `<div class="empty-state">error: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderCallerDeepDive(d) {
|
||
const maxHourly = Math.max(1, ...d.hourlyHeatmap.map(h => h.count));
|
||
document.getElementById('callerModalBody').innerHTML = `
|
||
<div class="caller-summary">
|
||
<div><div class="label">requests</div><div class="val">${formatNumber(d.totalRequests)}</div></div>
|
||
<div><div class="label">success rate</div><div class="val">${(d.successRate*100).toFixed(1)}%</div></div>
|
||
<div><div class="label">avg latency</div><div class="val">${d.avgLatencyMs}ms</div></div>
|
||
<div><div class="label">p50 / p95</div><div class="val">${d.latencyP50}/${d.latencyP95}ms</div></div>
|
||
<div><div class="label">tokens (in→out)</div><div class="val">${formatNumber(d.totalTokensIn)} → ${formatNumber(d.totalTokensOut)}</div></div>
|
||
<div><div class="label">total cost</div><div class="val">${formatCost(d.totalCost)}</div></div>
|
||
<div><div class="label">cache hits</div><div class="val">${d.cacheHits}</div></div>
|
||
<div><div class="label">tokens saved</div><div class="val">${formatNumber(d.cacheTokensSaved)}</div></div>
|
||
</div>
|
||
|
||
<h2 class="h-section" style="margin-top:18px;">Activity by hour <span class="h-meta">last 7 days, UTC</span></h2>
|
||
<div class="caller-hour-bars">
|
||
${d.hourlyHeatmap.map(h => `<div class="bar" title="${h.hour}:00 — ${h.count} req" style="height:${(h.count/maxHourly*100).toFixed(0)}%;"></div>`).join('')}
|
||
</div>
|
||
<div class="caller-hour-axis">
|
||
${d.hourlyHeatmap.map(h => h.hour % 4 === 0 ? `<span>${h.hour}h</span>` : '<span></span>').join('')}
|
||
</div>
|
||
|
||
<h2 class="h-section" style="margin-top:18px;">Top Models</h2>
|
||
<div class="chip-grid">
|
||
${d.topModels.map(m => `
|
||
<div class="chip" style="cursor:default;">
|
||
<div class="chip-name">${escapeHtml(m.model)}</div>
|
||
<div class="chip-meta"><span class="num">${m.count}</span> · ${m.share}%</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<h2 class="h-section" style="margin-top:18px;">Recent Requests</h2>
|
||
<div class="req-table" style="font-size: 0.74rem;">
|
||
<div class="req-row head">
|
||
<div>id</div><div>model</div><div>status</div><div>tok in</div><div>tok out</div><div>cost</div><div>latency</div>
|
||
</div>
|
||
${d.recentRequests.map(r => `
|
||
<div class="req-row body">
|
||
<div title="${r.request_id}">${r.request_id.substring(0,12)}…</div>
|
||
<div>${escapeHtml(r.model)}</div>
|
||
<div><span class="req-status ${r.status}">${r.status}</span></div>
|
||
<div>${r.tokens_in}</div><div>${r.tokens_out}</div>
|
||
<div>${formatCost(r.cost_usd)}</div><div>${r.latency_ms}ms</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
${d.storedFacts.length ? `
|
||
<h2 class="h-section" style="margin-top:18px;">Stored Facts</h2>
|
||
<div class="mem-list">
|
||
${d.storedFacts.map(f => `
|
||
<div class="mem-row">
|
||
<div class="mem-key">${escapeHtml(f.key)}</div>
|
||
<div class="mem-val">${escapeHtml(f.value)}</div>
|
||
<div class="mem-meta">conf=${f.confidence} · ${escapeHtml(f.source)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>` : ''}
|
||
`;
|
||
}
|
||
|
||
function closeCallerModal() { document.getElementById('callerModal').classList.remove('open'); }
|
||
document.getElementById('callerModalClose').addEventListener('click', closeCallerModal);
|
||
document.getElementById('callerModal').addEventListener('click', (e) => { if (e.target.id === 'callerModal') closeCallerModal(); });
|
||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeCallerModal(); });
|
||
|
||
// Wire click on caller chips (delegated event)
|
||
document.addEventListener('click', (e) => {
|
||
const chip = e.target.closest('#topCallers .chip, #topSavingCallers .chip');
|
||
if (chip) {
|
||
const name = chip.querySelector('.chip-name')?.textContent?.trim();
|
||
if (name) openCallerDeepDive(name);
|
||
}
|
||
});
|
||
|
||
// ─── Share Card ─────────────────────────────────────────────────────
|
||
function buildShareCardUrl() {
|
||
const period = document.getElementById('shareCardPeriod').value;
|
||
const theme = document.getElementById('shareCardTheme').value;
|
||
return `${API_BASE || location.origin}/api/dashboard/share-card?period=${period}&theme=${theme}`;
|
||
}
|
||
function refreshShareCard() {
|
||
const url = buildShareCardUrl();
|
||
document.getElementById('shareCardImg').src = url + '&_t=' + Date.now();
|
||
document.getElementById('shareCardUrl').textContent = url;
|
||
}
|
||
document.getElementById('shareCardRefresh').addEventListener('click', refreshShareCard);
|
||
document.getElementById('shareCardPeriod').addEventListener('change', refreshShareCard);
|
||
document.getElementById('shareCardTheme').addEventListener('change', refreshShareCard);
|
||
document.getElementById('shareCardCopyUrl').addEventListener('click', async () => {
|
||
const url = buildShareCardUrl();
|
||
try { await navigator.clipboard.writeText(url); document.getElementById('shareCardCopyUrl').textContent = '✓ copied'; setTimeout(() => { document.getElementById('shareCardCopyUrl').textContent = 'copy URL'; }, 1500); }
|
||
catch { alert('clipboard write failed — URL: ' + url); }
|
||
});
|
||
document.getElementById('shareCardDownload').addEventListener('click', async () => {
|
||
const url = buildShareCardUrl();
|
||
const r = await fetch(url);
|
||
const svg = await r.text();
|
||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `llm-gateway-${document.getElementById('shareCardPeriod').value}-${document.getElementById('shareCardTheme').value}.svg`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
|
||
// ─── Monthly Report ─────────────────────────────────────────────────
|
||
document.getElementById('reportOpen').addEventListener('click', () => {
|
||
const year = document.getElementById('reportYear').value;
|
||
const month = document.getElementById('reportMonth').value;
|
||
const url = `${API_BASE || location.origin}/api/dashboard/report?year=${year}&month=${month}`;
|
||
// Open in a new tab; report HTML has its own print-friendly styles
|
||
window.open(url, '_blank');
|
||
});
|
||
// Pre-fill current year/month
|
||
(() => {
|
||
const now = new Date();
|
||
document.getElementById('reportYear').value = now.getUTCFullYear();
|
||
document.getElementById('reportMonth').value = now.getUTCMonth() + 1;
|
||
})();
|
||
|
||
// Hook tab switches to load data lazily
|
||
document.querySelectorAll('.tab-trigger').forEach(t => {
|
||
t.addEventListener('click', () => {
|
||
const target = t.dataset.tab;
|
||
if (target === 'memory') loadMemoryGraph();
|
||
if (target === 'leaderboard') loadLeaderboard();
|
||
if (target === 'share') refreshShareCard();
|
||
if (target === 'api') refreshApiBridgeStatus();
|
||
});
|
||
});
|
||
|
||
// ─── API Tab — copy buttons, try-it-out, bridge status ────────────────
|
||
function copyToClipboard(text) {
|
||
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text; document.body.appendChild(ta); ta.select();
|
||
document.execCommand('copy'); document.body.removeChild(ta);
|
||
return Promise.resolve();
|
||
}
|
||
document.querySelectorAll('.api-copy').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const targetId = btn.dataset.target;
|
||
const snippet = document.getElementById(targetId)?.innerText || '';
|
||
await copyToClipboard(snippet);
|
||
const orig = btn.textContent;
|
||
btn.textContent = 'copied ✓';
|
||
setTimeout(() => { btn.textContent = orig; }, 1400);
|
||
});
|
||
});
|
||
|
||
document.getElementById('apiTryRun')?.addEventListener('click', async () => {
|
||
const endpoint = document.getElementById('apiTryEndpoint').value;
|
||
const model = document.getElementById('apiTryModel').value || 'claude-sonnet-4.6';
|
||
const prompt = document.getElementById('apiTryPrompt').value || '';
|
||
const status = document.getElementById('apiTryStatus');
|
||
const meta = document.getElementById('apiTryMeta');
|
||
const wrap = document.getElementById('apiTryResultWrap');
|
||
const out = document.getElementById('apiTryResult');
|
||
if (!prompt.trim()) { status.textContent = 'add a prompt first'; return; }
|
||
|
||
let body;
|
||
if (endpoint === '/v1/completion') {
|
||
body = { caller: 'dashboard-tryout', task_type: 'generic_qa', input: prompt, options: { compression: { enabled: true, mode: 'auto' } } };
|
||
} else if (endpoint === '/v1/chat/completions') {
|
||
body = { model, messages: [{ role: 'user', content: prompt }] };
|
||
} else {
|
||
body = { model, messages: [{ role: 'user', content: prompt }], max_tokens: 1024 };
|
||
}
|
||
|
||
status.textContent = 'sending…';
|
||
const t0 = performance.now();
|
||
try {
|
||
const res = await fetch((API_BASE || location.origin) + endpoint, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const dtMs = Math.round(performance.now() - t0);
|
||
const json = await res.json().catch(() => ({}));
|
||
status.textContent = `${res.status} ${res.statusText} · ${dtMs} ms`;
|
||
const c = json?.compression || (json?.metadata?.compression) || null;
|
||
if (c) {
|
||
meta.textContent = `compression: applied=${c.applied} · method=${c.method} · before=${c.tokens_before} after=${c.tokens_after} saved=${c.tokens_saved}`;
|
||
} else {
|
||
meta.textContent = 'no compression metadata in response';
|
||
}
|
||
out.textContent = JSON.stringify(json, null, 2);
|
||
wrap.style.display = 'block';
|
||
} catch (err) {
|
||
status.textContent = 'error: ' + (err.message || err);
|
||
}
|
||
});
|
||
|
||
async function refreshApiBridgeStatus() {
|
||
try {
|
||
const res = await fetch((API_BASE || location.origin) + '/api/dashboard/providers');
|
||
if (!res.ok) return;
|
||
const json = await res.json();
|
||
const allProviders = [
|
||
...((json?.data?.grouped?.subscription) || []),
|
||
...((json?.data?.grouped?.local) || []),
|
||
];
|
||
document.querySelectorAll('.api-bridge-status').forEach(cell => {
|
||
const name = cell.dataset.bridge;
|
||
const p = allProviders.find(x => x.name === name);
|
||
if (!p) { cell.textContent = 'unknown'; cell.classList.add('err'); return; }
|
||
if (p.enabled && p.status === 'configured') {
|
||
cell.textContent = '✓ online';
|
||
cell.classList.add('ok');
|
||
} else {
|
||
cell.textContent = p.status || 'disabled';
|
||
cell.classList.add('err');
|
||
}
|
||
});
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
}
|
||
|
||
// ─── Init ────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
await checkHealth();
|
||
await loadMetrics();
|
||
await loadRequests();
|
||
await loadProviders();
|
||
await loadSubscriptions();
|
||
await loadSavings();
|
||
await loadWallet();
|
||
await loadHero();
|
||
|
||
try {
|
||
const res = await apiFetch(`${API_BASE}/api/dashboard/settings`);
|
||
const payload = await res.json();
|
||
if (payload.success) {
|
||
document.getElementById('routingModeBadge').textContent = payload.data.routingMode;
|
||
// Apply UI mode (Simple Mode etc.) immediately on load
|
||
applyUiMode(payload.data.ui ?? { simpleMode: false, hideEmptyProviders: true, showTooltips: true });
|
||
}
|
||
} catch (e) { /* non-fatal */ }
|
||
|
||
setupPolling();
|
||
setInterval(checkHealth, HEALTH_CHECK_INTERVAL);
|
||
setInterval(loadSubscriptions, 30000);
|
||
setInterval(loadHero, 30000); // refresh buddy / events / forecast every 30s
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|