llm-gateway/packages/gateway/public/dashboard-v2.html

729 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>llm.gateway Workbench</title>
<style>
:root {
color-scheme: light;
--bg: #f4f7f9;
--paper: #fbfcfd;
--panel: #f8fafb;
--line: #ccd6df;
--line-dark: #aebdc8;
--text: #27323d;
--muted: #718090;
--soft: #8b98a7;
--green: #2f7d71;
--green-soft: #e3f2ef;
--amber: #a05c2b;
--amber-soft: #fff1e7;
--red: #9f3f3a;
--red-soft: #ffe9e7;
--blue-soft: #eaf2f8;
--shadow: 0 16px 50px rgba(43, 61, 74, 0.08);
--mono: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(39, 50, 61, 0.025) 1px, transparent 1px),
linear-gradient(rgba(39, 50, 61, 0.025) 1px, transparent 1px),
var(--bg);
background-size: 24px 24px;
color: var(--text);
font-family: var(--sans);
font-size: 14px;
letter-spacing: 0;
}
.shell {
max-width: 1560px;
margin: 0 auto;
padding: 38px 38px 96px;
}
.mono, .eyebrow, .tab, .badge, th, .status-line, .metric-label {
font-family: var(--mono);
}
header {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 24px;
border-bottom: 1px solid var(--line);
padding-bottom: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.mark {
width: 9px;
height: 9px;
background: var(--green);
margin-left: 2px;
}
h1 {
margin: 0;
font-size: 18px;
line-height: 1;
font-family: var(--mono);
letter-spacing: -0.02em;
}
.crumb {
color: var(--soft);
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
margin-left: 8px;
}
.btn {
border: 1px solid var(--line-dark);
background: transparent;
color: var(--text);
height: 31px;
padding: 0 14px;
font-family: var(--mono);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}
.btn:hover { background: var(--paper); border-color: var(--green); color: var(--green); }
.status-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-bottom: 1px solid var(--line);
padding: 20px 0;
margin-bottom: 18px;
}
.status-group {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
border-right: 1px solid var(--line);
padding-right: 18px;
}
.status-line:last-child { border-right: 0; }
.status-line strong { color: var(--text); font-weight: 700; text-transform: none; letter-spacing: 0; }
.dot {
width: 17px;
height: 17px;
border-radius: 50%;
background: var(--green-soft);
position: relative;
border: 1px solid #c6ddd8;
}
.dot::after {
content: "";
position: absolute;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
left: 4px;
top: 4px;
}
.dot.warn { background: var(--amber-soft); border-color: #eccdb5; }
.dot.warn::after { background: var(--amber); }
.dot.bad { background: var(--red-soft); border-color: #efc1bd; }
.dot.bad::after { background: var(--red); }
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--line);
margin-bottom: 34px;
overflow-x: auto;
}
.tab {
min-width: 128px;
padding: 0 18px 16px;
color: var(--muted);
text-decoration: none;
font-size: 12px;
letter-spacing: 0.08em;
white-space: nowrap;
border-bottom: 1px solid transparent;
}
.tab.active {
color: var(--green);
border-bottom-color: var(--green);
}
.section-head {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
border-bottom: 1px solid var(--line);
margin: 0 0 16px;
min-height: 44px;
}
.section-head::before {
content: "";
width: 20px;
height: 2px;
background: var(--green);
display: block;
align-self: end;
margin-bottom: 13px;
}
.section-title {
grid-column: 1 / -1;
text-align: center;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
letter-spacing: 0.35em;
text-transform: uppercase;
margin-top: -21px;
pointer-events: none;
}
.section-note {
justify-self: end;
font-family: var(--mono);
color: var(--soft);
font-size: 12px;
letter-spacing: 0.08em;
margin-top: -22px;
}
.coverage {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-bottom: 34px;
}
.tile {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.58);
min-height: 120px;
padding: 15px 16px;
box-shadow: var(--shadow);
}
.tile-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 10px;
margin-bottom: 14px;
}
.tile-title {
font-weight: 800;
line-height: 1.25;
word-break: break-word;
}
.badge {
border: 1px solid #dfbda6;
color: var(--amber);
background: var(--amber-soft);
padding: 4px 8px;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
}
.badge.ready {
border-color: #b7d8d1;
color: var(--green);
background: var(--green-soft);
}
.tile-meta {
font-family: var(--mono);
color: #596777;
line-height: 1.45;
font-size: 12px;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin-bottom: 34px;
}
.metric {
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
padding: 13px 15px;
min-height: 86px;
}
.metric-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.13em;
text-transform: uppercase;
margin-bottom: 11px;
}
.metric-value {
font-family: var(--mono);
font-size: 22px;
font-weight: 800;
}
.workbench {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 18px;
margin-bottom: 34px;
}
.panel {
border: 1px solid var(--line);
background: rgba(255,255,255,0.48);
box-shadow: var(--shadow);
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--line);
padding: 12px 16px;
font-family: var(--mono);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 11px;
}
.panel-body { padding: 16px; }
.route-stack {
display: grid;
gap: 10px;
}
.route {
display: grid;
grid-template-columns: 164px 1fr auto;
align-items: center;
gap: 12px;
border: 1px solid var(--line);
background: var(--paper);
padding: 11px 12px;
min-height: 52px;
}
.route-name {
font-weight: 800;
}
.route-desc {
font-family: var(--mono);
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--line);
background: rgba(255,255,255,0.5);
}
th, td {
border-bottom: 1px solid var(--line);
padding: 13px 16px;
text-align: left;
font-size: 12px;
}
th {
color: var(--muted);
background: rgba(39, 50, 61, 0.045);
text-transform: uppercase;
letter-spacing: 0.13em;
font-weight: 600;
}
td {
font-family: var(--mono);
color: #4d5c69;
}
.empty {
height: 112px;
text-align: center;
color: var(--soft);
font-family: var(--mono);
font-size: 13px;
letter-spacing: 0.04em;
}
.fixed-status {
position: fixed;
right: 18px;
bottom: 18px;
border: 1px solid var(--line-dark);
background: var(--paper);
padding: 10px 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
box-shadow: var(--shadow);
}
.fixed-status span {
display: inline-block;
width: 8px;
height: 8px;
background: var(--green);
margin-right: 8px;
}
@media (max-width: 1200px) {
.coverage { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.workbench { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.shell { padding: 22px 16px 80px; }
header, .status-strip { grid-template-columns: 1fr; display: grid; }
.coverage, .metrics { grid-template-columns: 1fr; }
.route { grid-template-columns: 1fr; }
.section-note { display: none; }
}
</style>
</head>
<body>
<div class="shell">
<header>
<div class="brand">
<span class="mark"></span>
<h1>llm.gateway</h1>
<span class="crumb">/ gateway workbench · open source preview</span>
</div>
<button class="btn" id="settingsBtn">⊙ settings</button>
</header>
<div class="status-strip">
<div class="status-group">
<div class="status-line"><span class="dot" id="dbDot"></span> DB <strong id="dbStatus">checking</strong></div>
<div class="status-line"><span class="dot" id="pollDot"></span> Poll <strong>live</strong></div>
<div class="status-line">Interval <strong>15s</strong></div>
</div>
<div class="status-line">Mode <strong id="modeStatus">auto</strong></div>
</div>
<nav class="tabs">
<a class="tab" href="#overview">01 overview</a>
<a class="tab" href="#providers">02 providers</a>
<a class="tab" href="#policies">03 routing</a>
<a class="tab active" href="#activity">04 activity</a>
<a class="tab" href="#savings">05 savings</a>
<a class="tab" href="#memory">06 memory</a>
<a class="tab" href="#doctor">07 doctor</a>
</nav>
<section id="overview">
<div class="section-head">
<div class="section-title">gateway coverage</div>
<div class="section-note">existing adapters plus open-source targets</div>
</div>
<div class="coverage" id="coverage"></div>
</section>
<section id="activity">
<div class="section-head">
<div class="section-title">gateway metrics</div>
<div class="section-note">traffic · providers · savings · readiness</div>
</div>
<div class="metrics" id="metrics"></div>
</section>
<section class="workbench">
<div class="panel" id="policies">
<div class="panel-title">
<span>request pipeline</span>
<span>gateway core</span>
</div>
<div class="panel-body">
<div class="route-stack" id="pipeline"></div>
</div>
</div>
<div class="panel" id="memory">
<div class="panel-title">
<span>open-source extensions</span>
<span>roadmap</span>
</div>
<div class="panel-body">
<div class="route-stack" id="memoryRoutes"></div>
</div>
</div>
</section>
<section>
<div class="section-head">
<div class="section-title">recent requests</div>
<div class="section-note">live polling</div>
</div>
<div style="display:flex; gap:6px; margin-bottom:16px;">
<button class="btn" data-hours="24">last 24h</button>
<button class="btn" data-hours="168">last 7d</button>
<button class="btn" data-hours="720">last 30d</button>
</div>
<table>
<thead>
<tr>
<th>request id</th>
<th>caller</th>
<th>model</th>
<th>status</th>
<th>ctx before</th>
<th>ctx sent</th>
<th>saved</th>
<th>compression</th>
<th>cost</th>
<th>latency</th>
</tr>
</thead>
<tbody id="requests">
<tr><td class="empty" colspan="10">loading gateway traffic</td></tr>
</tbody>
</table>
</section>
</div>
<div class="fixed-status"><span></span><strong id="fixedStatus">connected</strong></div>
<script>
const API = window.location.origin;
let selectedHours = 24;
const clients = [
['OpenAI-compatible API', 'openai-api', 'already usable by most tools'],
['Ollama / Local models', 'ollama', 'local-first provider path'],
['Codex / CLI clients', 'codex', 'planned MCP helper'],
['Claude Code', 'claude-code', 'planned MCP bridge'],
['ChatGPT / OpenAI', 'chatgpt', 'API key or export workflow'],
['Cursor / VS Code', 'cursor', 'OpenAI-compatible base URL'],
];
const metricLabels = {
detectedClients: 'adapters',
localModels: 'local',
providersConfigured: 'providers',
trustPolicies: 'rules',
memoryBackends: 'memory',
plannedModules: 'extensions',
};
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
async function getJson(path) {
const res = await fetch(`${API}${path}`, { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`${path} ${res.status}`);
return res.json();
}
function setDbStatus(status) {
const dot = document.getElementById('dbDot');
const label = document.getElementById('dbStatus');
if (status === 'connected') {
dot.className = 'dot';
label.textContent = 'connected';
} else if (status === 'degraded') {
dot.className = 'dot warn';
label.textContent = 'degraded';
} else {
dot.className = 'dot bad';
label.textContent = 'offline';
}
}
function renderCoverage(topology) {
const configured = new Set((topology.nodes || []).filter((n) => n.status === 'ready' || n.status === 'online').map((n) => n.id));
document.getElementById('coverage').innerHTML = clients.map(([name, key, note]) => {
const ready = configured.has(`client-${key}`) || configured.has(key);
return `
<article class="tile">
<div class="tile-head">
<div class="tile-title">${esc(name)}</div>
<div class="badge ${ready ? 'ready' : ''}">${ready ? 'ready' : 'not connected'}</div>
</div>
<div class="tile-meta">
0 requests · 0 saved<br>
status: discovery pending<br>
route: ${esc(note)}<br>
last: never
</div>
</article>
`;
}).join('');
}
function renderMetrics(summary) {
document.getElementById('metrics').innerHTML = Object.entries(summary).map(([key, value]) => `
<div class="metric">
<div class="metric-label">${esc(metricLabels[key] || key)}</div>
<div class="metric-value">${esc(value)}</div>
</div>
`).join('');
}
function renderPipeline(topology) {
const steps = [
['Client Entry', 'OpenAI-compatible requests from apps, agents, and scripts'],
['Gateway Router', 'model selection, fallback, budgets, latency preference'],
['Provider Layer', 'Ollama, OpenAI, Anthropic, Groq, Mistral, OpenRouter'],
['Compression', 'existing token savings plus semantic cache roadmap'],
['Receipts', 'trace request, route, model, tokens, cost, latency'],
['Memory', 'optional shared project memory for handoff between AI tools'],
];
document.getElementById('pipeline').innerHTML = steps.map(([name, desc], index) => `
<div class="route">
<div class="route-name">${String(index + 1).padStart(2, '0')} ${esc(name)}</div>
<div class="route-desc">${esc(desc)}</div>
<div class="badge ready">${index < 3 ? 'core' : 'next'}</div>
</div>
`).join('');
const extensions = [
['MCP server', 'Expose gateway status, providers, receipts, and memory to Codex, Claude Code, Cursor, and automations.', 'next'],
['Shared memory', 'Optional Git/Gitea-backed project memory for decisions, handoffs, receipts, and reusable context.', 'next'],
['Trust routing', 'Small policy layer for local-first routing, sensitive-data blocking, and provider allowlists.', 'next'],
['Setup doctor', 'Detect local tools, env vars, models, ports, and missing config without changing user files silently.', 'next'],
['Context receipts', 'Human-readable proof of what context was used, compressed, redacted, and routed.', 'planned'],
];
document.getElementById('memoryRoutes').innerHTML = extensions.map(([name, desc, state]) => `
<div class="route">
<div class="route-name">${esc(name)}</div>
<div class="route-desc">${esc(desc)}</div>
<div class="badge ready">${esc(state)}</div>
</div>
`).join('');
}
function renderRequests(rows) {
const body = document.getElementById('requests');
if (!rows || rows.length === 0) {
body.innerHTML = '<tr><td class="empty" colspan="10">no requests in selected timeframe</td></tr>';
return;
}
body.innerHTML = rows.slice(0, 40).map((r) => `
<tr>
<td>${esc((r.request_id || r.id || '').slice(0, 12))}</td>
<td>${esc(r.caller || 'unknown')}</td>
<td>${esc(r.model || 'n/a')}</td>
<td>${esc(r.status || 'n/a')}</td>
<td>${esc(r.tokens_in || 0)}</td>
<td>${esc((r.tokens_in || 0) + (r.tokens_out || 0))}</td>
<td>${esc(r.tokens_saved || 0)}</td>
<td>${esc(r.compression || 'n/a')}</td>
<td>$${Number(r.cost_usd || 0).toFixed(4)}</td>
<td>${esc(r.latency_ms || 0)}ms</td>
</tr>
`).join('');
}
async function loadTopology() {
const data = await getJson('/api/dashboard/topology');
const topology = data.data;
document.getElementById('modeStatus').textContent = topology.mode === 'hybrid-safe' ? 'auto' : topology.mode;
renderCoverage(topology);
renderMetrics(topology.summary);
renderPipeline(topology);
}
async function loadHealth() {
try {
const health = await getJson('/health');
if (health.status === 'ok') setDbStatus('connected');
else if (health.checks?.ollama?.status === 'ok') setDbStatus('degraded');
else setDbStatus('offline');
} catch {
setDbStatus('degraded');
}
}
async function loadRequests() {
try {
const data = await getJson(`/api/dashboard/requests?limit=50&hours=${selectedHours}`);
renderRequests(data.data || []);
} catch {
renderRequests([]);
}
}
async function refreshAll() {
await Promise.all([loadTopology(), loadHealth(), loadRequests()]);
document.getElementById('fixedStatus').textContent = 'connected';
document.getElementById('pollDot').className = 'dot';
}
document.querySelectorAll('[data-hours]').forEach((button) => {
button.addEventListener('click', () => {
selectedHours = Number(button.dataset.hours || 24);
loadRequests();
});
});
document.getElementById('settingsBtn').addEventListener('click', () => {
alert('Settings preview: providers, subscriptions, local models, budgets, memory backend, and OpenAI-compatible base URL.');
});
refreshAll().catch(() => {
document.getElementById('fixedStatus').textContent = 'degraded';
document.getElementById('pollDot').className = 'dot warn';
});
setInterval(refreshAll, 15000);
</script>
</body>
</html>