729 lines
20 KiB
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) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[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>
|