lynkr 9.0.2 → 9.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +18 -1
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/package.json +2 -2
- package/public/dashboard.html +665 -0
- package/src/api/files-router.js +6 -6
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +1 -1
- package/src/api/router.js +185 -47
- package/src/clients/databricks.js +9 -5
- package/src/clients/openai-format.js +31 -5
- package/src/config/index.js +7 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +62 -5
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +61 -0
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +7 -0
- package/src/server.js +3 -0
- package/src/stores/file-store.js +42 -7
- package/src/tools/smart-selection.js +11 -2
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Lynkr Dashboard</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
9
|
+
<style>
|
|
10
|
+
body { font-family: 'Inter', system-ui, sans-serif; }
|
|
11
|
+
.fade-in { animation: fadeIn 0.15s ease-out; }
|
|
12
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
|
13
|
+
.ring-tab { transition: color 0.15s, border-color 0.15s; }
|
|
14
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
15
|
+
::-webkit-scrollbar-track { background: #1e293b; }
|
|
16
|
+
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
|
17
|
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
18
|
+
.table-row:hover { background: rgba(59,130,246,0.07); }
|
|
19
|
+
.badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; }
|
|
20
|
+
</style>
|
|
21
|
+
</head>
|
|
22
|
+
<body class="bg-slate-900 text-slate-100 min-h-screen">
|
|
23
|
+
|
|
24
|
+
<!-- Top Nav -->
|
|
25
|
+
<header class="bg-slate-800 border-b border-slate-700 sticky top-0 z-50">
|
|
26
|
+
<div class="max-w-7xl mx-auto px-4 flex items-center h-14 gap-6">
|
|
27
|
+
<div class="flex items-center gap-2 mr-2">
|
|
28
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
29
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
30
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
31
|
+
</svg>
|
|
32
|
+
<span class="font-bold text-white text-base tracking-tight">Lynkr</span>
|
|
33
|
+
<span id="nav-version" class="text-xs text-slate-500 ml-1"></span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<nav class="flex gap-1">
|
|
37
|
+
<button onclick="App.navigate('overview')" data-page="overview"
|
|
38
|
+
class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
|
|
39
|
+
Overview
|
|
40
|
+
</button>
|
|
41
|
+
<button onclick="App.navigate('usage')" data-page="usage"
|
|
42
|
+
class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
|
|
43
|
+
Usage
|
|
44
|
+
</button>
|
|
45
|
+
<button onclick="App.navigate('routing')" data-page="routing"
|
|
46
|
+
class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
|
|
47
|
+
Routing
|
|
48
|
+
</button>
|
|
49
|
+
<button onclick="App.navigate('logs')" data-page="logs"
|
|
50
|
+
class="ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700">
|
|
51
|
+
Logs
|
|
52
|
+
</button>
|
|
53
|
+
</nav>
|
|
54
|
+
|
|
55
|
+
<div class="ml-auto flex items-center gap-3">
|
|
56
|
+
<div id="refresh-indicator" class="flex items-center gap-2 text-xs text-slate-500">
|
|
57
|
+
<div class="status-dot bg-green-500" id="live-dot"></div>
|
|
58
|
+
<span id="refresh-countdown">30s</span>
|
|
59
|
+
</div>
|
|
60
|
+
<button onclick="App.refresh()" class="text-xs text-slate-500 hover:text-slate-300 px-2 py-1 rounded hover:bg-slate-700">
|
|
61
|
+
↺ Refresh
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
<!-- Page Content -->
|
|
68
|
+
<main id="content" class="max-w-7xl mx-auto px-4 py-6"></main>
|
|
69
|
+
|
|
70
|
+
<script>
|
|
71
|
+
/* ─────────────────────────────────────────────
|
|
72
|
+
Helpers
|
|
73
|
+
───────────────────────────────────────────── */
|
|
74
|
+
const fmt = {
|
|
75
|
+
num: n => (n ?? 0).toLocaleString(),
|
|
76
|
+
tok: n => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n||0),
|
|
77
|
+
usd: n => '$'+(n||0).toFixed(4),
|
|
78
|
+
usd2: n => '$'+(n||0).toFixed(2),
|
|
79
|
+
pct: n => (n||0).toFixed(1)+'%',
|
|
80
|
+
ms: n => n == null ? '—' : n < 1000 ? n+'ms' : (n/1000).toFixed(1)+'s',
|
|
81
|
+
ago: ts => {
|
|
82
|
+
if (!ts) return '—';
|
|
83
|
+
const s = Math.floor((Date.now()-ts)/1000);
|
|
84
|
+
if (s < 60) return s+'s ago';
|
|
85
|
+
if (s < 3600) return Math.floor(s/60)+'m ago';
|
|
86
|
+
return Math.floor(s/3600)+'h ago';
|
|
87
|
+
},
|
|
88
|
+
time: ts => ts ? new Date(ts).toLocaleTimeString() : '—',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function tierColor(tier) {
|
|
92
|
+
return { SIMPLE:'text-green-400', MEDIUM:'text-blue-400', COMPLEX:'text-amber-400', REASONING:'text-purple-400' }[tier] || 'text-slate-400';
|
|
93
|
+
}
|
|
94
|
+
function tierBg(tier) {
|
|
95
|
+
return { SIMPLE:'bg-green-900/40 text-green-300', MEDIUM:'bg-blue-900/40 text-blue-300', COMPLEX:'bg-amber-900/40 text-amber-300', REASONING:'bg-purple-900/40 text-purple-300' }[tier] || 'bg-slate-700 text-slate-300';
|
|
96
|
+
}
|
|
97
|
+
function statusBadge(code) {
|
|
98
|
+
if (!code) return '<span class="badge bg-slate-700 text-slate-400">—</span>';
|
|
99
|
+
if (code < 300) return `<span class="badge bg-green-900/50 text-green-300">${code}</span>`;
|
|
100
|
+
if (code < 500) return `<span class="badge bg-amber-900/50 text-amber-300">${code}</span>`;
|
|
101
|
+
return `<span class="badge bg-red-900/50 text-red-300">${code}</span>`;
|
|
102
|
+
}
|
|
103
|
+
function providerDot(type) {
|
|
104
|
+
return type === 'local' ? 'bg-green-500' : 'bg-blue-500';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function card(inner, extra='') {
|
|
108
|
+
return `<div class="bg-slate-800 border border-slate-700 rounded-xl p-5 ${extra}">${inner}</div>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function statCard(label, value, sub='', color='text-white') {
|
|
112
|
+
return card(`
|
|
113
|
+
<div class="text-xs text-slate-500 uppercase tracking-wider mb-1">${label}</div>
|
|
114
|
+
<div class="text-2xl font-bold ${color}">${value}</div>
|
|
115
|
+
${sub ? `<div class="text-xs text-slate-500 mt-1">${sub}</div>` : ''}
|
|
116
|
+
`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function emptyState(msg) {
|
|
120
|
+
return `<div class="text-center py-16 text-slate-500">
|
|
121
|
+
<svg class="mx-auto mb-3 opacity-30" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
122
|
+
<p class="text-sm">${msg}</p>
|
|
123
|
+
</div>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadingState() {
|
|
127
|
+
return `<div class="text-center py-16 text-slate-600">
|
|
128
|
+
<div class="inline-block w-6 h-6 border-2 border-slate-600 border-t-blue-500 rounded-full animate-spin"></div>
|
|
129
|
+
</div>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ─────────────────────────────────────────────
|
|
133
|
+
Main App
|
|
134
|
+
───────────────────────────────────────────── */
|
|
135
|
+
const App = {
|
|
136
|
+
page: 'overview',
|
|
137
|
+
data: {},
|
|
138
|
+
usageWindow: '7d',
|
|
139
|
+
logFilters: { provider: '', tier: '', error: false },
|
|
140
|
+
_refreshTimer: null,
|
|
141
|
+
_countdownTimer: null,
|
|
142
|
+
_countdown: 30,
|
|
143
|
+
_charts: {},
|
|
144
|
+
|
|
145
|
+
init() {
|
|
146
|
+
const hash = location.hash.slice(1) || 'overview';
|
|
147
|
+
this.navigate(hash, true);
|
|
148
|
+
this._startCountdown();
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
navigate(page, initial=false) {
|
|
152
|
+
this.page = page;
|
|
153
|
+
location.hash = page;
|
|
154
|
+
this._updateTabs();
|
|
155
|
+
document.getElementById('content').innerHTML = loadingState();
|
|
156
|
+
this._destroyCharts();
|
|
157
|
+
this.refresh();
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
_updateTabs() {
|
|
161
|
+
document.querySelectorAll('[data-page]').forEach(btn => {
|
|
162
|
+
const active = btn.dataset.page === this.page;
|
|
163
|
+
btn.className = active
|
|
164
|
+
? 'ring-tab px-4 py-2 rounded-md text-sm font-medium text-white bg-slate-700'
|
|
165
|
+
: 'ring-tab px-4 py-2 rounded-md text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-700';
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
_startCountdown() {
|
|
170
|
+
this._countdown = 30;
|
|
171
|
+
clearInterval(this._countdownTimer);
|
|
172
|
+
this._countdownTimer = setInterval(() => {
|
|
173
|
+
this._countdown--;
|
|
174
|
+
const el = document.getElementById('refresh-countdown');
|
|
175
|
+
if (el) el.textContent = this._countdown + 's';
|
|
176
|
+
if (this._countdown <= 0) {
|
|
177
|
+
this._countdown = 30;
|
|
178
|
+
this.refresh(true); // silent refresh
|
|
179
|
+
}
|
|
180
|
+
}, 1000);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async refresh(silent=false) {
|
|
184
|
+
this._countdown = 30;
|
|
185
|
+
if (!silent) document.getElementById('content').innerHTML = loadingState();
|
|
186
|
+
const dot = document.getElementById('live-dot');
|
|
187
|
+
if (dot) { dot.className = 'status-dot bg-yellow-500'; }
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
let data;
|
|
191
|
+
if (this.page === 'overview') data = await this._fetch('/dashboard/api/overview');
|
|
192
|
+
if (this.page === 'usage') data = await this._fetch(`/dashboard/api/usage?window=${this.usageWindow}`);
|
|
193
|
+
if (this.page === 'routing') data = await this._fetch('/dashboard/api/routing');
|
|
194
|
+
if (this.page === 'logs') {
|
|
195
|
+
const q = new URLSearchParams({ limit: 100 });
|
|
196
|
+
if (this.logFilters.provider) q.set('provider', this.logFilters.provider);
|
|
197
|
+
if (this.logFilters.tier) q.set('tier', this.logFilters.tier);
|
|
198
|
+
if (this.logFilters.error) q.set('error', 'true');
|
|
199
|
+
data = await this._fetch('/dashboard/api/logs?' + q);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.data[this.page] = data;
|
|
203
|
+
this._render(data);
|
|
204
|
+
if (dot) dot.className = 'status-dot bg-green-500';
|
|
205
|
+
|
|
206
|
+
// update version/port in nav
|
|
207
|
+
if (this.page === 'overview' && data) {
|
|
208
|
+
const el = document.getElementById('nav-version');
|
|
209
|
+
if (el) el.textContent = `v${data.version} · :${data.port}`;
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
document.getElementById('content').innerHTML =
|
|
213
|
+
card(`<div class="text-red-400 text-sm">Failed to load: ${err.message}</div>`);
|
|
214
|
+
if (dot) dot.className = 'status-dot bg-red-500';
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async _fetch(url) {
|
|
219
|
+
const r = await fetch(url);
|
|
220
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
221
|
+
return r.json();
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
_render(data) {
|
|
225
|
+
this._destroyCharts();
|
|
226
|
+
const html = {
|
|
227
|
+
overview: () => this._renderOverview(data),
|
|
228
|
+
usage: () => this._renderUsage(data),
|
|
229
|
+
routing: () => this._renderRouting(data),
|
|
230
|
+
logs: () => this._renderLogs(data),
|
|
231
|
+
}[this.page]?.() || '';
|
|
232
|
+
document.getElementById('content').innerHTML = `<div class="fade-in">${html}</div>`;
|
|
233
|
+
this._afterRender(data);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
_destroyCharts() {
|
|
237
|
+
Object.values(this._charts).forEach(c => { try { c.destroy(); } catch {} });
|
|
238
|
+
this._charts = {};
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
/* ── OVERVIEW ────────────────────────────── */
|
|
242
|
+
_renderOverview(d) {
|
|
243
|
+
if (!d) return emptyState('No data');
|
|
244
|
+
const t = d.today;
|
|
245
|
+
const s = d.stats;
|
|
246
|
+
|
|
247
|
+
const providerCards = d.providers.length === 0
|
|
248
|
+
? `<p class="text-slate-500 text-sm">No providers configured</p>`
|
|
249
|
+
: d.providers.map(p => `
|
|
250
|
+
<div class="flex items-center justify-between bg-slate-700/50 rounded-lg px-4 py-3">
|
|
251
|
+
<div class="flex items-center gap-2">
|
|
252
|
+
<span class="status-dot ${providerDot(p.type)}"></span>
|
|
253
|
+
<span class="text-sm font-medium text-slate-200">${p.name}</span>
|
|
254
|
+
</div>
|
|
255
|
+
<span class="text-xs ${p.type === 'local' ? 'text-green-400' : 'text-blue-400'}">${p.type}</span>
|
|
256
|
+
</div>`).join('');
|
|
257
|
+
|
|
258
|
+
const recentRows = (d.recentRequests || []).map(r => `
|
|
259
|
+
<tr class="table-row border-b border-slate-700/50">
|
|
260
|
+
<td class="py-2 px-3 text-xs text-slate-500">${fmt.ago(r.timestamp)}</td>
|
|
261
|
+
<td class="py-2 px-3 text-xs font-mono text-slate-300">${r.provider || '—'}</td>
|
|
262
|
+
<td class="py-2 px-3 text-xs text-slate-400 max-w-[160px] truncate">${r.model || '—'}</td>
|
|
263
|
+
<td class="py-2 px-3"><span class="badge ${tierBg(r.tier)}">${r.tier || '—'}</span></td>
|
|
264
|
+
<td class="py-2 px-3 text-xs text-slate-400">${fmt.ms(r.latency_ms)}</td>
|
|
265
|
+
<td class="py-2 px-3">${statusBadge(r.status_code)}</td>
|
|
266
|
+
<td class="py-2 px-3 text-xs text-slate-400">${r.error_type ? `<span class="text-red-400">${r.error_type}</span>` : '—'}</td>
|
|
267
|
+
</tr>`).join('');
|
|
268
|
+
|
|
269
|
+
return `
|
|
270
|
+
<!-- Stat cards -->
|
|
271
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
272
|
+
${statCard('Requests Today', fmt.num(t.requests), `${fmt.num(d.metrics.requestsTotal)} total lifetime`)}
|
|
273
|
+
${statCard('Tokens Today', fmt.tok(t.totalTokens), `${fmt.num(d.metrics.responsesError)} errors lifetime`, 'text-white')}
|
|
274
|
+
${statCard('Cost Today', fmt.usd(t.cost), `${fmt.usd(t.flagshipCost||0)} flagship equiv.`, 'text-amber-300')}
|
|
275
|
+
${statCard('Saved Today', fmt.usd(t.saved), `${fmt.pct(t.savedPercent)} vs flagship`, 'text-green-400')}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
279
|
+
<!-- Providers -->
|
|
280
|
+
${card(`
|
|
281
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Configured Providers</h3>
|
|
282
|
+
<div class="flex flex-col gap-2">${providerCards}</div>
|
|
283
|
+
`)}
|
|
284
|
+
|
|
285
|
+
<!-- 24h Stats -->
|
|
286
|
+
${card(`
|
|
287
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Last ${d.statsWindow || '24h'}</h3>
|
|
288
|
+
${s ? `
|
|
289
|
+
<div class="grid grid-cols-2 gap-3">
|
|
290
|
+
<div><p class="text-xs text-slate-500">Requests</p><p class="text-lg font-bold">${fmt.num(s.totalRequests)}</p></div>
|
|
291
|
+
<div><p class="text-xs text-slate-500">Error Rate</p><p class="text-lg font-bold ${s.errorRate > 5 ? 'text-red-400' : 'text-green-400'}">${fmt.pct(s.errorRate)}</p></div>
|
|
292
|
+
<div><p class="text-xs text-slate-500">Over-provisioned</p><p class="text-lg font-bold text-amber-400">${fmt.pct(s.overProvisionedPct)}</p></div>
|
|
293
|
+
<div><p class="text-xs text-slate-500">Under-provisioned</p><p class="text-lg font-bold text-red-400">${fmt.pct(s.underProvisionedPct)}</p></div>
|
|
294
|
+
</div>
|
|
295
|
+
` : emptyState('No requests yet')}
|
|
296
|
+
`, 'col-span-2')}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<!-- Recent Requests -->
|
|
300
|
+
${card(`
|
|
301
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Recent Requests</h3>
|
|
302
|
+
${recentRows.length === 0 ? emptyState('No requests recorded yet') : `
|
|
303
|
+
<div class="overflow-x-auto">
|
|
304
|
+
<table class="w-full text-left">
|
|
305
|
+
<thead>
|
|
306
|
+
<tr class="border-b border-slate-700">
|
|
307
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">When</th>
|
|
308
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Provider</th>
|
|
309
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Model</th>
|
|
310
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Tier</th>
|
|
311
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Latency</th>
|
|
312
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Status</th>
|
|
313
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium">Error</th>
|
|
314
|
+
</tr>
|
|
315
|
+
</thead>
|
|
316
|
+
<tbody>${recentRows}</tbody>
|
|
317
|
+
</table>
|
|
318
|
+
</div>
|
|
319
|
+
`}
|
|
320
|
+
`)}
|
|
321
|
+
`;
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/* ── USAGE ───────────────────────────────── */
|
|
325
|
+
_renderUsage(d) {
|
|
326
|
+
if (!d) return emptyState('No data');
|
|
327
|
+
const t = d.totals;
|
|
328
|
+
const hasData = t?.requests > 0;
|
|
329
|
+
|
|
330
|
+
const windowBtns = ['1d','7d','30d','all'].map(w =>
|
|
331
|
+
`<button onclick="App.setUsageWindow('${w}')" class="px-3 py-1.5 text-xs rounded-md font-medium ${this.usageWindow===w ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white hover:bg-slate-700'}">${w}</button>`
|
|
332
|
+
).join('');
|
|
333
|
+
|
|
334
|
+
const tierRows = Object.entries(d.byTier || {}).sort((a,b)=>b[1].requests-a[1].requests).map(([tier, b]) => `
|
|
335
|
+
<tr class="table-row border-b border-slate-700/50">
|
|
336
|
+
<td class="py-2 px-4"><span class="badge ${tierBg(tier)}">${tier}</span></td>
|
|
337
|
+
<td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td>
|
|
338
|
+
<td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td>
|
|
339
|
+
<td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td>
|
|
340
|
+
<td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td>
|
|
341
|
+
<td class="py-2 px-4 text-xs text-slate-500">${fmt.pct(b.savedPercent)}</td>
|
|
342
|
+
</tr>`).join('');
|
|
343
|
+
|
|
344
|
+
const providerRows = Object.entries(d.byProvider || {}).sort((a,b)=>b[1].requests-a[1].requests).map(([prov, b]) => `
|
|
345
|
+
<tr class="table-row border-b border-slate-700/50">
|
|
346
|
+
<td class="py-2 px-4 text-sm font-mono text-slate-300">${prov}</td>
|
|
347
|
+
<td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td>
|
|
348
|
+
<td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td>
|
|
349
|
+
<td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td>
|
|
350
|
+
<td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td>
|
|
351
|
+
<td class="py-2 px-4 text-xs text-slate-500">${fmt.pct(b.savedPercent)}</td>
|
|
352
|
+
</tr>`).join('');
|
|
353
|
+
|
|
354
|
+
const modelRows = Object.entries(d.byModel || {}).sort((a,b)=>b[1].requests-a[1].requests).slice(0,20).map(([model, b]) => `
|
|
355
|
+
<tr class="table-row border-b border-slate-700/50">
|
|
356
|
+
<td class="py-2 px-4 text-xs font-mono text-slate-300 max-w-[220px] truncate">${model}</td>
|
|
357
|
+
<td class="py-2 px-4 text-sm">${fmt.num(b.requests)}</td>
|
|
358
|
+
<td class="py-2 px-4 text-sm text-slate-400">${fmt.tok(b.totalTokens)}</td>
|
|
359
|
+
<td class="py-2 px-4 text-sm text-amber-300">${fmt.usd(b.actualCost)}</td>
|
|
360
|
+
<td class="py-2 px-4 text-sm text-green-400">${fmt.usd(b.saved)}</td>
|
|
361
|
+
</tr>`).join('');
|
|
362
|
+
|
|
363
|
+
const tableHead = `
|
|
364
|
+
<thead><tr class="border-b border-slate-700">
|
|
365
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Name</th>
|
|
366
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Requests</th>
|
|
367
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Tokens</th>
|
|
368
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Cost</th>
|
|
369
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Saved</th>
|
|
370
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Saved%</th>
|
|
371
|
+
</tr></thead>`;
|
|
372
|
+
|
|
373
|
+
return `
|
|
374
|
+
<div class="flex items-center justify-between mb-5">
|
|
375
|
+
<h2 class="text-lg font-semibold">Usage</h2>
|
|
376
|
+
<div class="flex gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">${windowBtns}</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<!-- Summary -->
|
|
380
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
381
|
+
${statCard('Requests', fmt.num(t?.requests), d.since ? `since ${new Date(d.since).toLocaleDateString()}` : 'all time')}
|
|
382
|
+
${statCard('Total Tokens', fmt.tok(t?.totalTokens), `${fmt.tok(t?.inputTokens)} in / ${fmt.tok(t?.outputTokens)} out`)}
|
|
383
|
+
${statCard('Actual Cost', fmt.usd2(t?.actualCost), `vs ${fmt.usd2(t?.flagshipCost)} flagship`, 'text-amber-300')}
|
|
384
|
+
${statCard('Total Saved', fmt.usd2(t?.saved), `${fmt.pct(t?.savedPercent)} savings rate`, 'text-green-400')}
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
${!hasData ? card(emptyState('No usage data for this window. Make some requests first.')) : `
|
|
388
|
+
<!-- Chart -->
|
|
389
|
+
${card(`
|
|
390
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-4">Daily Requests by Tier</h3>
|
|
391
|
+
<div style="height:220px"><canvas id="usage-chart"></canvas></div>
|
|
392
|
+
`, 'mb-6')}
|
|
393
|
+
|
|
394
|
+
<!-- By Tier -->
|
|
395
|
+
${card(`
|
|
396
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">By Tier</h3>
|
|
397
|
+
${tierRows ? `<table class="w-full">${tableHead}<tbody>${tierRows}</tbody></table>` : emptyState('No tier data')}
|
|
398
|
+
`, 'mb-4')}
|
|
399
|
+
|
|
400
|
+
<!-- By Provider -->
|
|
401
|
+
${card(`
|
|
402
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">By Provider</h3>
|
|
403
|
+
${providerRows ? `<table class="w-full">${tableHead}<tbody>${providerRows}</tbody></table>` : emptyState('No provider data')}
|
|
404
|
+
`, 'mb-4')}
|
|
405
|
+
|
|
406
|
+
<!-- By Model -->
|
|
407
|
+
${card(`
|
|
408
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">By Model <span class="text-slate-500 font-normal text-xs">(top 20)</span></h3>
|
|
409
|
+
${modelRows ? `<div class="overflow-x-auto"><table class="w-full">${tableHead}<tbody>${modelRows}</tbody></table></div>` : emptyState('No model data')}
|
|
410
|
+
`)}
|
|
411
|
+
`}
|
|
412
|
+
`;
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
/* ── ROUTING ─────────────────────────────── */
|
|
416
|
+
_renderRouting(d) {
|
|
417
|
+
if (!d) return emptyState('No data');
|
|
418
|
+
|
|
419
|
+
const tierColors = { SIMPLE:'border-green-700/60 bg-green-900/20', MEDIUM:'border-blue-700/60 bg-blue-900/20', COMPLEX:'border-amber-700/60 bg-amber-900/20', REASONING:'border-purple-700/60 bg-purple-900/20' };
|
|
420
|
+
const tierTextColors = { SIMPLE:'text-green-300', MEDIUM:'text-blue-300', COMPLEX:'text-amber-300', REASONING:'text-purple-300' };
|
|
421
|
+
|
|
422
|
+
const tierCards = Object.entries(d.tierDefinitions || {}).map(([name, def]) => `
|
|
423
|
+
<div class="border rounded-xl p-4 ${tierColors[name] || 'border-slate-700 bg-slate-800'}">
|
|
424
|
+
<div class="flex items-center justify-between mb-2">
|
|
425
|
+
<span class="font-semibold text-sm ${tierTextColors[name] || 'text-slate-300'}">${name}</span>
|
|
426
|
+
<span class="text-xs text-slate-500">${def.range[0]}–${def.range[1]}</span>
|
|
427
|
+
</div>
|
|
428
|
+
<p class="text-xs text-slate-400">${def.description}</p>
|
|
429
|
+
</div>`).join('');
|
|
430
|
+
|
|
431
|
+
const acc = d.accuracy;
|
|
432
|
+
const providerRows = Object.entries(d.providerStats || {}).map(([name, s]) => `
|
|
433
|
+
<tr class="table-row border-b border-slate-700/50">
|
|
434
|
+
<td class="py-2.5 px-4 text-sm font-mono text-slate-300">${name}</td>
|
|
435
|
+
<td class="py-2.5 px-4 text-sm">${fmt.num(s.total)}</td>
|
|
436
|
+
<td class="py-2.5 px-4 text-sm">${fmt.ms(s.avgLatency)}</td>
|
|
437
|
+
<td class="py-2.5 px-4 text-sm ${s.errorRate > 5 ? 'text-red-400' : 'text-green-400'}">${fmt.pct(s.errorRate)}</td>
|
|
438
|
+
<td class="py-2.5 px-4 text-sm text-slate-400">${fmt.pct(s.fallbackRate)}</td>
|
|
439
|
+
<td class="py-2.5 px-4 text-sm text-slate-400">${s.avgTokensPerSecond != null ? s.avgTokensPerSecond+' t/s' : '—'}</td>
|
|
440
|
+
<td class="py-2.5 px-4 text-sm text-amber-300">${fmt.usd(s.totalCost)}</td>
|
|
441
|
+
</tr>`).join('');
|
|
442
|
+
|
|
443
|
+
const cbEntries = Object.entries(d.circuitBreakers || {});
|
|
444
|
+
const cbRows = cbEntries.map(([name, cb]) => {
|
|
445
|
+
const state = cb.state || 'unknown';
|
|
446
|
+
const stateColor = state === 'open' ? 'text-red-400' : state === 'half-open' ? 'text-amber-400' : 'text-green-400';
|
|
447
|
+
return `<tr class="table-row border-b border-slate-700/50">
|
|
448
|
+
<td class="py-2.5 px-4 text-sm font-mono text-slate-300">${name}</td>
|
|
449
|
+
<td class="py-2.5 px-4 text-sm font-semibold ${stateColor}">${state}</td>
|
|
450
|
+
<td class="py-2.5 px-4 text-sm text-slate-400">${cb.failures ?? '—'}</td>
|
|
451
|
+
<td class="py-2.5 px-4 text-xs text-slate-500">${cb.lastFailure ? fmt.ago(cb.lastFailure) : '—'}</td>
|
|
452
|
+
</tr>`;
|
|
453
|
+
}).join('');
|
|
454
|
+
|
|
455
|
+
return `
|
|
456
|
+
<h2 class="text-lg font-semibold mb-5">Routing</h2>
|
|
457
|
+
|
|
458
|
+
<!-- Tiers -->
|
|
459
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">${tierCards}</div>
|
|
460
|
+
|
|
461
|
+
<!-- Accuracy -->
|
|
462
|
+
${card(`
|
|
463
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-4">Routing Accuracy (last ${d.window || '24h'})</h3>
|
|
464
|
+
${acc ? `
|
|
465
|
+
<div class="grid grid-cols-3 gap-4">
|
|
466
|
+
<div class="text-center">
|
|
467
|
+
<p class="text-2xl font-bold">${fmt.num(acc.totalRequests)}</p>
|
|
468
|
+
<p class="text-xs text-slate-500 mt-1">Total Requests</p>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="text-center">
|
|
471
|
+
<p class="text-2xl font-bold text-amber-400">${fmt.pct(acc.overProvisionedPct)}</p>
|
|
472
|
+
<p class="text-xs text-slate-500 mt-1">Over-provisioned</p>
|
|
473
|
+
<p class="text-xs text-slate-600">(used REASONING for simple task)</p>
|
|
474
|
+
</div>
|
|
475
|
+
<div class="text-center">
|
|
476
|
+
<p class="text-2xl font-bold text-red-400">${fmt.pct(acc.underProvisionedPct)}</p>
|
|
477
|
+
<p class="text-xs text-slate-500 mt-1">Under-provisioned</p>
|
|
478
|
+
<p class="text-xs text-slate-600">(low quality on SIMPLE tier)</p>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
` : emptyState('No routing data for last 24h')}
|
|
482
|
+
`, 'mb-6')}
|
|
483
|
+
|
|
484
|
+
<!-- Provider Stats -->
|
|
485
|
+
${card(`
|
|
486
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
|
|
487
|
+
${providerRows ? `
|
|
488
|
+
<div class="overflow-x-auto">
|
|
489
|
+
<table class="w-full">
|
|
490
|
+
<thead><tr class="border-b border-slate-700">
|
|
491
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Provider</th>
|
|
492
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Requests</th>
|
|
493
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Avg Latency</th>
|
|
494
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Error Rate</th>
|
|
495
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Fallback Rate</th>
|
|
496
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Avg t/s</th>
|
|
497
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Cost</th>
|
|
498
|
+
</tr></thead>
|
|
499
|
+
<tbody>${providerRows}</tbody>
|
|
500
|
+
</table>
|
|
501
|
+
</div>
|
|
502
|
+
` : emptyState('No provider stats yet')}
|
|
503
|
+
`, 'mb-6')}
|
|
504
|
+
|
|
505
|
+
<!-- Circuit Breakers -->
|
|
506
|
+
${card(`
|
|
507
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Circuit Breakers</h3>
|
|
508
|
+
${cbRows ? `
|
|
509
|
+
<table class="w-full">
|
|
510
|
+
<thead><tr class="border-b border-slate-700">
|
|
511
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Name</th>
|
|
512
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">State</th>
|
|
513
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Failures</th>
|
|
514
|
+
<th class="py-2 px-4 text-xs text-slate-500 font-medium text-left">Last Failure</th>
|
|
515
|
+
</tr></thead>
|
|
516
|
+
<tbody>${cbRows}</tbody>
|
|
517
|
+
</table>
|
|
518
|
+
` : emptyState('No circuit breakers active')}
|
|
519
|
+
`)}
|
|
520
|
+
`;
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
/* ── LOGS ────────────────────────────────── */
|
|
524
|
+
_renderLogs(rows) {
|
|
525
|
+
rows = rows || [];
|
|
526
|
+
|
|
527
|
+
const providers = [...new Set(rows.map(r => r.provider).filter(Boolean))];
|
|
528
|
+
const tiers = ['SIMPLE','MEDIUM','COMPLEX','REASONING'];
|
|
529
|
+
|
|
530
|
+
const providerOpts = ['', ...providers].map(p =>
|
|
531
|
+
`<option value="${p}" ${this.logFilters.provider===p?'selected':''}>${p||'All providers'}</option>`
|
|
532
|
+
).join('');
|
|
533
|
+
|
|
534
|
+
const tierOpts = ['', ...tiers].map(t =>
|
|
535
|
+
`<option value="${t}" ${this.logFilters.tier===t?'selected':''}>${t||'All tiers'}</option>`
|
|
536
|
+
).join('');
|
|
537
|
+
|
|
538
|
+
const tableRows = rows.map(r => `
|
|
539
|
+
<tr class="table-row border-b border-slate-700/40 text-sm">
|
|
540
|
+
<td class="py-2 px-3 text-xs text-slate-500 whitespace-nowrap">${fmt.time(r.timestamp)}</td>
|
|
541
|
+
<td class="py-2 px-3 text-xs font-mono text-slate-300">${r.provider||'—'}</td>
|
|
542
|
+
<td class="py-2 px-3 text-xs text-slate-400 max-w-[160px] truncate" title="${r.model||''}">${r.model||'—'}</td>
|
|
543
|
+
<td class="py-2 px-3"><span class="badge ${tierBg(r.tier)}">${r.tier||'—'}</span></td>
|
|
544
|
+
<td class="py-2 px-3 text-xs text-slate-400">${r.input_tokens!=null?fmt.tok(r.input_tokens):'—'}</td>
|
|
545
|
+
<td class="py-2 px-3 text-xs text-slate-400">${r.output_tokens!=null?fmt.tok(r.output_tokens):'—'}</td>
|
|
546
|
+
<td class="py-2 px-3 text-xs text-slate-400">${fmt.ms(r.latency_ms)}</td>
|
|
547
|
+
<td class="py-2 px-3">${statusBadge(r.status_code)}</td>
|
|
548
|
+
<td class="py-2 px-3 text-xs ${r.error_type?'text-red-400':'text-slate-500'}">${r.error_type||'—'}</td>
|
|
549
|
+
<td class="py-2 px-3 text-xs text-amber-300">${r.cost_usd!=null?fmt.usd(r.cost_usd):'—'}</td>
|
|
550
|
+
<td class="py-2 px-3">${r.was_fallback?'<span class="badge bg-amber-900/40 text-amber-300">fallback</span>':'—'}</td>
|
|
551
|
+
</tr>`).join('');
|
|
552
|
+
|
|
553
|
+
return `
|
|
554
|
+
<div class="flex items-center justify-between mb-5">
|
|
555
|
+
<h2 class="text-lg font-semibold">Request Logs</h2>
|
|
556
|
+
<span class="text-xs text-slate-500">${rows.length} rows</span>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<!-- Filters -->
|
|
560
|
+
${card(`
|
|
561
|
+
<div class="flex flex-wrap gap-3 items-center">
|
|
562
|
+
<select onchange="App.setLogFilter('provider', this.value)"
|
|
563
|
+
class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500">
|
|
564
|
+
${providerOpts}
|
|
565
|
+
</select>
|
|
566
|
+
<select onchange="App.setLogFilter('tier', this.value)"
|
|
567
|
+
class="bg-slate-700 border border-slate-600 text-slate-300 text-sm rounded-lg px-3 py-1.5 focus:ring-blue-500 focus:border-blue-500">
|
|
568
|
+
${tierOpts}
|
|
569
|
+
</select>
|
|
570
|
+
<label class="flex items-center gap-2 text-sm text-slate-400 cursor-pointer">
|
|
571
|
+
<input type="checkbox" ${this.logFilters.error?'checked':''} onchange="App.setLogFilter('error', this.checked)"
|
|
572
|
+
class="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500">
|
|
573
|
+
Errors only
|
|
574
|
+
</label>
|
|
575
|
+
</div>
|
|
576
|
+
`, 'mb-4')}
|
|
577
|
+
|
|
578
|
+
<!-- Table -->
|
|
579
|
+
${rows.length === 0 ? card(emptyState('No log entries match your filters')) : card(`
|
|
580
|
+
<div class="overflow-x-auto">
|
|
581
|
+
<table class="w-full min-w-[900px]">
|
|
582
|
+
<thead><tr class="border-b border-slate-700">
|
|
583
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Time</th>
|
|
584
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Provider</th>
|
|
585
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Model</th>
|
|
586
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Tier</th>
|
|
587
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">In</th>
|
|
588
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Out</th>
|
|
589
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Latency</th>
|
|
590
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Status</th>
|
|
591
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Error</th>
|
|
592
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Cost</th>
|
|
593
|
+
<th class="py-2 px-3 text-xs text-slate-500 font-medium text-left">Flags</th>
|
|
594
|
+
</tr></thead>
|
|
595
|
+
<tbody>${tableRows}</tbody>
|
|
596
|
+
</table>
|
|
597
|
+
</div>
|
|
598
|
+
`)}
|
|
599
|
+
`;
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
/* ── After render hooks (charts) ─────────── */
|
|
603
|
+
_afterRender(data) {
|
|
604
|
+
if (this.page === 'usage' && data?.daily?.length && data.totals?.requests > 0) {
|
|
605
|
+
this._drawUsageChart(data.daily);
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
_drawUsageChart(daily) {
|
|
610
|
+
const canvas = document.getElementById('usage-chart');
|
|
611
|
+
if (!canvas || typeof Chart === 'undefined') return;
|
|
612
|
+
|
|
613
|
+
const TIER_COLORS = {
|
|
614
|
+
SIMPLE: 'rgba(34,197,94,0.8)',
|
|
615
|
+
MEDIUM: 'rgba(59,130,246,0.8)',
|
|
616
|
+
COMPLEX: 'rgba(245,158,11,0.8)',
|
|
617
|
+
REASONING: 'rgba(168,85,247,0.8)',
|
|
618
|
+
UNKNOWN: 'rgba(100,116,139,0.5)',
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const allTiers = [...new Set(daily.flatMap(d => Object.keys(d.byTier)))];
|
|
622
|
+
|
|
623
|
+
const datasets = allTiers.map(tier => ({
|
|
624
|
+
label: tier,
|
|
625
|
+
data: daily.map(d => d.byTier[tier] || 0),
|
|
626
|
+
backgroundColor: TIER_COLORS[tier] || TIER_COLORS.UNKNOWN,
|
|
627
|
+
borderRadius: 3,
|
|
628
|
+
borderSkipped: false,
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
this._charts.usage = new Chart(canvas, {
|
|
632
|
+
type: 'bar',
|
|
633
|
+
data: { labels: daily.map(d => d.label), datasets },
|
|
634
|
+
options: {
|
|
635
|
+
responsive: true,
|
|
636
|
+
maintainAspectRatio: false,
|
|
637
|
+
plugins: {
|
|
638
|
+
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
|
|
639
|
+
tooltip: { backgroundColor: '#1e293b', titleColor: '#f1f5f9', bodyColor: '#94a3b8', borderColor: '#334155', borderWidth: 1 },
|
|
640
|
+
},
|
|
641
|
+
scales: {
|
|
642
|
+
x: { stacked: true, ticks: { color: '#64748b', font: { size: 11 } }, grid: { color: '#1e293b' } },
|
|
643
|
+
y: { stacked: true, ticks: { color: '#64748b', font: { size: 11 } }, grid: { color: '#334155' }, beginAtZero: true },
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
/* ── Public actions ──────────────────────── */
|
|
650
|
+
setUsageWindow(w) {
|
|
651
|
+
this.usageWindow = w;
|
|
652
|
+
this.refresh();
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
setLogFilter(key, value) {
|
|
656
|
+
this.logFilters[key] = value;
|
|
657
|
+
this.refresh();
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
window.addEventListener('hashchange', () => App.navigate(location.hash.slice(1) || 'overview'));
|
|
662
|
+
App.init();
|
|
663
|
+
</script>
|
|
664
|
+
</body>
|
|
665
|
+
</html>
|