llmflow 0.3.1

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/public/app.js ADDED
@@ -0,0 +1,1484 @@
1
+ // State
2
+ let currentTab = 'timeline';
3
+ let traces = [];
4
+ let logs = [];
5
+ let stats = {};
6
+ let selectedTraceId = null;
7
+ let selectedLogId = null;
8
+ let selectedTimelineItem = null;
9
+ let timelineItems = [];
10
+ let timelineFilters = {
11
+ q: '',
12
+ tool: '',
13
+ type: '',
14
+ dateRange: '',
15
+ date_from: null
16
+ };
17
+ let filters = {
18
+ q: '',
19
+ model: '',
20
+ status: '',
21
+ dateRange: '',
22
+ date_from: null,
23
+ date_to: null
24
+ };
25
+ let logFilters = {
26
+ q: '',
27
+ service_name: '',
28
+ event_name: '',
29
+ severity_min: null
30
+ };
31
+ let metrics = [];
32
+ let metricsSummary = [];
33
+ let metricFilters = {
34
+ name: '',
35
+ service_name: '',
36
+ metric_type: ''
37
+ };
38
+
39
+ // WebSocket state
40
+ let ws = null;
41
+ let wsRetryDelay = 1000;
42
+ const WS_MAX_RETRY = 30000;
43
+
44
+ // Theme
45
+ function initTheme() {
46
+ const savedTheme = localStorage.getItem('llmflow-theme');
47
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
48
+ const theme = savedTheme || (prefersDark ? 'dark' : 'light');
49
+
50
+ if (theme === 'dark') {
51
+ document.documentElement.setAttribute('data-theme', 'dark');
52
+ }
53
+ }
54
+
55
+ function toggleTheme() {
56
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
57
+
58
+ if (isDark) {
59
+ document.documentElement.removeAttribute('data-theme');
60
+ localStorage.setItem('llmflow-theme', 'light');
61
+ } else {
62
+ document.documentElement.setAttribute('data-theme', 'dark');
63
+ localStorage.setItem('llmflow-theme', 'dark');
64
+ }
65
+ }
66
+
67
+ // Apply theme immediately (before DOMContentLoaded)
68
+ initTheme();
69
+
70
+ // Initialize
71
+ function init() {
72
+ initFiltersFromUrl();
73
+ setupFilters();
74
+ setupLogFilters();
75
+ setupMetricFilters();
76
+ setupTimelineFilters();
77
+ setupKeyboardShortcuts();
78
+ loadModels();
79
+ loadStats();
80
+ loadTimeline();
81
+ loadLogFilterOptions();
82
+ loadMetricFilterOptions();
83
+ initWebSocket();
84
+
85
+ // Polling as fallback (less frequent since we have WebSocket)
86
+ setInterval(loadStats, 30000);
87
+ setInterval(() => {
88
+ if (currentTab === 'timeline') loadTimeline();
89
+ else if (currentTab === 'traces') loadTraces();
90
+ }, 30000);
91
+ }
92
+
93
+ if (document.readyState === 'loading') {
94
+ document.addEventListener('DOMContentLoaded', init);
95
+ } else {
96
+ init();
97
+ }
98
+
99
+ // Keyboard shortcuts
100
+ function setupKeyboardShortcuts() {
101
+ window.addEventListener('keydown', (e) => {
102
+ if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
103
+ e.preventDefault();
104
+ document.getElementById('searchInput')?.focus();
105
+ }
106
+ if (e.key === 'Escape') {
107
+ document.activeElement?.blur();
108
+ }
109
+ });
110
+ }
111
+
112
+ // Filters
113
+ function initFiltersFromUrl() {
114
+ const params = new URLSearchParams(window.location.search);
115
+ filters.q = params.get('q') || '';
116
+ filters.model = params.get('model') || '';
117
+ filters.status = params.get('status') || '';
118
+ filters.dateRange = params.get('date') || '';
119
+ applyDateRange(filters.dateRange);
120
+
121
+ const searchInput = document.getElementById('searchInput');
122
+ if (searchInput) searchInput.value = filters.q;
123
+ const modelFilter = document.getElementById('modelFilter');
124
+ if (modelFilter) modelFilter.value = filters.model;
125
+ const statusFilter = document.getElementById('statusFilter');
126
+ if (statusFilter) statusFilter.value = filters.status;
127
+ const dateFilter = document.getElementById('dateFilter');
128
+ if (dateFilter) dateFilter.value = filters.dateRange;
129
+ }
130
+
131
+ function setupFilters() {
132
+ let searchTimeout;
133
+ document.getElementById('searchInput')?.addEventListener('input', (e) => {
134
+ clearTimeout(searchTimeout);
135
+ searchTimeout = setTimeout(() => {
136
+ filters.q = e.target.value;
137
+ updateUrl();
138
+ loadTraces();
139
+ }, 300);
140
+ });
141
+
142
+ document.getElementById('modelFilter')?.addEventListener('change', (e) => {
143
+ filters.model = e.target.value;
144
+ updateUrl();
145
+ loadTraces();
146
+ });
147
+
148
+ document.getElementById('statusFilter')?.addEventListener('change', (e) => {
149
+ filters.status = e.target.value;
150
+ updateUrl();
151
+ loadTraces();
152
+ });
153
+
154
+ document.getElementById('dateFilter')?.addEventListener('change', (e) => {
155
+ filters.dateRange = e.target.value;
156
+ applyDateRange(filters.dateRange);
157
+ updateUrl();
158
+ loadTraces();
159
+ });
160
+
161
+ document.getElementById('clearFilters')?.addEventListener('click', clearFilters);
162
+ }
163
+
164
+ function applyDateRange(range) {
165
+ const now = Date.now();
166
+ switch (range) {
167
+ case '1h': filters.date_from = now - 3600000; break;
168
+ case '24h': filters.date_from = now - 86400000; break;
169
+ case '7d': filters.date_from = now - 604800000; break;
170
+ default: filters.date_from = null;
171
+ }
172
+ filters.date_to = null;
173
+ }
174
+
175
+ function updateUrl() {
176
+ const params = new URLSearchParams();
177
+ if (filters.q) params.set('q', filters.q);
178
+ if (filters.model) params.set('model', filters.model);
179
+ if (filters.status) params.set('status', filters.status);
180
+ if (filters.dateRange) params.set('date', filters.dateRange);
181
+ const qs = params.toString();
182
+ window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : ''));
183
+ }
184
+
185
+ function clearFilters() {
186
+ filters = { q: '', model: '', status: '', dateRange: '', date_from: null, date_to: null };
187
+ document.getElementById('searchInput').value = '';
188
+ document.getElementById('modelFilter').value = '';
189
+ document.getElementById('statusFilter').value = '';
190
+ document.getElementById('dateFilter').value = '';
191
+ updateUrl();
192
+ loadTraces();
193
+ }
194
+
195
+ // Tab switching
196
+ function showTab(tab) {
197
+ currentTab = tab;
198
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
199
+ event.target.classList.add('active');
200
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
201
+
202
+ if (tab === 'timeline') {
203
+ document.getElementById('timelineTab').classList.add('active');
204
+ loadTimeline();
205
+ } else if (tab === 'traces') {
206
+ document.getElementById('tracesTab').classList.add('active');
207
+ loadTraces();
208
+ } else if (tab === 'logs') {
209
+ document.getElementById('logsTab').classList.add('active');
210
+ loadLogs();
211
+ } else if (tab === 'metrics') {
212
+ document.getElementById('metricsTab').classList.add('active');
213
+ loadMetrics();
214
+ loadMetricsSummary();
215
+ } else if (tab === 'models') {
216
+ document.getElementById('modelsTab').classList.add('active');
217
+ loadModelStats();
218
+ } else if (tab === 'analytics') {
219
+ document.getElementById('analyticsTab').classList.add('active');
220
+ loadAnalytics();
221
+ }
222
+ }
223
+
224
+ // Load models for filter
225
+ async function loadModels() {
226
+ try {
227
+ const response = await fetch('/api/models');
228
+ const models = await response.json();
229
+ const select = document.getElementById('modelFilter');
230
+ if (!select) return;
231
+ select.innerHTML = '<option value="">All Models</option>';
232
+ models.forEach(model => {
233
+ const opt = document.createElement('option');
234
+ opt.value = model;
235
+ opt.textContent = model;
236
+ if (model === filters.model) opt.selected = true;
237
+ select.appendChild(opt);
238
+ });
239
+ } catch (e) {
240
+ console.error('Failed to load models:', e);
241
+ }
242
+ }
243
+
244
+ // Load stats
245
+ async function loadStats() {
246
+ try {
247
+ const response = await fetch('/api/stats');
248
+ stats = await response.json();
249
+ document.getElementById('totalRequests').textContent = stats.total_requests || 0;
250
+ document.getElementById('totalTokens').textContent = formatNumber(stats.total_tokens || 0);
251
+ document.getElementById('totalCost').textContent = '$' + (stats.total_cost || 0).toFixed(2);
252
+ document.getElementById('avgLatency').textContent = Math.round(stats.avg_duration || 0) + 'ms';
253
+ } catch (e) {
254
+ console.error('Failed to load stats:', e);
255
+ }
256
+ }
257
+
258
+ // Load traces
259
+ async function loadTraces() {
260
+ if (currentTab !== 'traces') return;
261
+
262
+ try {
263
+ const params = new URLSearchParams({ limit: '100' });
264
+ if (filters.q) params.set('q', filters.q);
265
+ if (filters.model) params.set('model', filters.model);
266
+ if (filters.status) params.set('status', filters.status);
267
+ if (filters.date_from) params.set('date_from', filters.date_from);
268
+ if (filters.date_to) params.set('date_to', filters.date_to);
269
+
270
+ const response = await fetch('/api/traces?' + params.toString());
271
+ traces = await response.json();
272
+
273
+ const tbody = document.getElementById('tracesBody');
274
+ if (traces.length === 0) {
275
+ tbody.innerHTML = `<tr><td colspan="8" class="empty-state">No traces found. Run: npm run demo</td></tr>`;
276
+ return;
277
+ }
278
+
279
+ tbody.innerHTML = traces.map(t => `
280
+ <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" onclick="selectTrace('${t.id}', this)">
281
+ <td>${formatTime(t.timestamp)}</td>
282
+ <td><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
283
+ <td>${escapeHtml(t.span_name || '-')}</td>
284
+ <td>${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
285
+ <td>${formatNumber(t.total_tokens || 0)}</td>
286
+ <td>$${(t.estimated_cost || 0).toFixed(4)}</td>
287
+ <td>${t.duration_ms || 0}ms</td>
288
+ <td class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
289
+ </tr>
290
+ `).join('');
291
+ } catch (e) {
292
+ console.error('Failed to load traces:', e);
293
+ document.getElementById('tracesBody').innerHTML =
294
+ '<tr><td colspan="8" class="empty-state">Failed to load traces</td></tr>';
295
+ }
296
+ }
297
+
298
+ // Select trace and show in detail panel
299
+ async function selectTrace(traceId, rowEl) {
300
+ selectedTraceId = traceId;
301
+
302
+ document.querySelectorAll('.trace-row').forEach(r => r.classList.remove('selected'));
303
+ if (rowEl) rowEl.classList.add('selected');
304
+
305
+ const titleEl = document.getElementById('detailTitle');
306
+ const metaEl = document.getElementById('detailMeta');
307
+ const infoEl = document.getElementById('traceInfo');
308
+ const spanTreeEl = document.getElementById('spanTree');
309
+ const ioEl = document.getElementById('traceIO');
310
+
311
+ try {
312
+ const treeRes = await fetch(`/api/traces/${traceId}/tree`);
313
+
314
+ if (treeRes.ok) {
315
+ const data = await treeRes.json();
316
+ const t = data.trace || {};
317
+
318
+ titleEl.textContent = data.spans?.[0]?.span_name || 'Trace';
319
+ metaEl.textContent = [
320
+ t.span_count ? `${t.span_count} spans` : null,
321
+ t.duration_ms ? `${t.duration_ms}ms` : null,
322
+ t.total_tokens ? `${t.total_tokens} tokens` : null,
323
+ t.total_cost ? `$${t.total_cost.toFixed(4)}` : null
324
+ ].filter(Boolean).join(' · ');
325
+
326
+ infoEl.textContent = JSON.stringify({
327
+ trace_id: t.trace_id,
328
+ duration: `${t.duration_ms}ms`,
329
+ tokens: t.total_tokens,
330
+ cost: `$${(t.total_cost || 0).toFixed(4)}`,
331
+ spans: t.span_count
332
+ }, null, 2);
333
+
334
+ spanTreeEl.innerHTML = (data.spans || []).map(s => renderSpanNode(s)).join('')
335
+ || '<span class="empty-state">No spans</span>';
336
+
337
+ const firstSpan = data.spans?.[0];
338
+ const { input, output } = firstSpan ? extractIO(firstSpan) : { input: null, output: null };
339
+ ioEl.textContent = JSON.stringify({ input, output }, null, 2);
340
+ } else {
341
+ const res = await fetch(`/api/traces/${traceId}`);
342
+ const data = await res.json();
343
+ const t = data.trace;
344
+
345
+ titleEl.textContent = t.span_name || t.model || 'Trace';
346
+ metaEl.textContent = [
347
+ t.duration_ms ? `${t.duration_ms}ms` : null,
348
+ t.total_tokens ? `${t.total_tokens} tokens` : null
349
+ ].filter(Boolean).join(' · ');
350
+
351
+ infoEl.textContent = JSON.stringify(t, null, 2);
352
+ spanTreeEl.innerHTML = '<span class="empty-state">Single span trace</span>';
353
+
354
+ const spanLike = {
355
+ input: t.input,
356
+ output: t.output,
357
+ request_body: data.request?.body,
358
+ response_body: data.response?.body,
359
+ };
360
+ const { input, output } = extractIO(spanLike);
361
+ ioEl.textContent = JSON.stringify({ input, output }, null, 2);
362
+ }
363
+ } catch (e) {
364
+ console.error('Failed to load trace:', e);
365
+ titleEl.textContent = 'Error';
366
+ metaEl.textContent = '';
367
+ infoEl.textContent = '{}';
368
+ spanTreeEl.innerHTML = '<span class="empty-state">Failed to load</span>';
369
+ ioEl.textContent = '{}';
370
+ }
371
+ }
372
+
373
+ // Render span node recursively
374
+ function renderSpanNode(span, depth = 0) {
375
+ const children = (span.children || []).map(c => renderSpanNode(c, depth + 1)).join('');
376
+ return `
377
+ <div class="span-node">
378
+ <div class="span-node-header">
379
+ <span class="span-badge span-${span.span_type || 'custom'}">${span.span_type || 'span'}</span>
380
+ <strong>${escapeHtml(span.span_name || span.id?.slice(0, 8) || 'span')}</strong>
381
+ <span class="span-node-meta">
382
+ ${span.duration_ms || 0}ms
383
+ ${span.model ? `· ${span.model}` : ''}
384
+ </span>
385
+ </div>
386
+ ${children ? `<div class="span-node-children">${children}</div>` : ''}
387
+ </div>
388
+ `;
389
+ }
390
+
391
+ // Load model stats
392
+ async function loadModelStats() {
393
+ if (currentTab !== 'models') return;
394
+
395
+ const container = document.getElementById('modelStats');
396
+ if (!stats.models || stats.models.length === 0) {
397
+ container.innerHTML = '<p class="empty-state">No model usage data yet</p>';
398
+ return;
399
+ }
400
+
401
+ container.innerHTML = stats.models.map(m => `
402
+ <div class="model-card">
403
+ <h4>${escapeHtml(m.model || 'unknown')}</h4>
404
+ <div class="model-stat">
405
+ <span class="model-stat-label">Requests</span>
406
+ <span class="model-stat-value">${m.count}</span>
407
+ </div>
408
+ <div class="model-stat">
409
+ <span class="model-stat-label">Tokens</span>
410
+ <span class="model-stat-value">${formatNumber(m.tokens)}</span>
411
+ </div>
412
+ <div class="model-stat">
413
+ <span class="model-stat-label">Cost</span>
414
+ <span class="model-stat-value">$${(m.cost || 0).toFixed(4)}</span>
415
+ </div>
416
+ </div>
417
+ `).join('');
418
+ }
419
+
420
+ // ==================== Logs Functions ====================
421
+
422
+ function setupLogFilters() {
423
+ let searchTimeout;
424
+ document.getElementById('logSearchInput')?.addEventListener('input', (e) => {
425
+ clearTimeout(searchTimeout);
426
+ searchTimeout = setTimeout(() => {
427
+ logFilters.q = e.target.value;
428
+ loadLogs();
429
+ }, 300);
430
+ });
431
+
432
+ document.getElementById('logServiceFilter')?.addEventListener('change', (e) => {
433
+ logFilters.service_name = e.target.value;
434
+ loadLogs();
435
+ });
436
+
437
+ document.getElementById('logEventFilter')?.addEventListener('change', (e) => {
438
+ logFilters.event_name = e.target.value;
439
+ loadLogs();
440
+ });
441
+
442
+ document.getElementById('logSeverityFilter')?.addEventListener('change', (e) => {
443
+ logFilters.severity_min = e.target.value ? parseInt(e.target.value, 10) : null;
444
+ loadLogs();
445
+ });
446
+
447
+ document.getElementById('clearLogFilters')?.addEventListener('click', clearLogFilters);
448
+ }
449
+
450
+ function clearLogFilters() {
451
+ logFilters = { q: '', service_name: '', event_name: '', severity_min: null };
452
+ document.getElementById('logSearchInput').value = '';
453
+ document.getElementById('logServiceFilter').value = '';
454
+ document.getElementById('logEventFilter').value = '';
455
+ document.getElementById('logSeverityFilter').value = '';
456
+ loadLogs();
457
+ }
458
+
459
+ async function loadLogFilterOptions() {
460
+ try {
461
+ const response = await fetch('/api/logs/filters');
462
+ const data = await response.json();
463
+
464
+ const serviceSelect = document.getElementById('logServiceFilter');
465
+ if (serviceSelect && data.services) {
466
+ serviceSelect.innerHTML = '<option value="">All Services</option>';
467
+ data.services.forEach(svc => {
468
+ const opt = document.createElement('option');
469
+ opt.value = svc;
470
+ opt.textContent = svc;
471
+ serviceSelect.appendChild(opt);
472
+ });
473
+ }
474
+
475
+ const eventSelect = document.getElementById('logEventFilter');
476
+ if (eventSelect && data.event_names) {
477
+ eventSelect.innerHTML = '<option value="">All Events</option>';
478
+ data.event_names.forEach(evt => {
479
+ const opt = document.createElement('option');
480
+ opt.value = evt;
481
+ opt.textContent = evt;
482
+ eventSelect.appendChild(opt);
483
+ });
484
+ }
485
+ } catch (e) {
486
+ console.error('Failed to load log filter options:', e);
487
+ }
488
+ }
489
+
490
+ async function loadLogs() {
491
+ if (currentTab !== 'logs') return;
492
+
493
+ try {
494
+ const params = new URLSearchParams({ limit: '100' });
495
+ if (logFilters.q) params.set('q', logFilters.q);
496
+ if (logFilters.service_name) params.set('service_name', logFilters.service_name);
497
+ if (logFilters.event_name) params.set('event_name', logFilters.event_name);
498
+ if (logFilters.severity_min != null) params.set('severity_min', logFilters.severity_min);
499
+
500
+ const response = await fetch('/api/logs?' + params.toString());
501
+ const data = await response.json();
502
+ logs = data.logs || [];
503
+
504
+ renderLogsTable();
505
+ } catch (e) {
506
+ console.error('Failed to load logs:', e);
507
+ document.getElementById('logsBody').innerHTML =
508
+ '<tr><td colspan="5" class="empty-state">Failed to load logs</td></tr>';
509
+ }
510
+ }
511
+
512
+ function renderLogsTable() {
513
+ const tbody = document.getElementById('logsBody');
514
+ if (!tbody) return;
515
+
516
+ if (!logs || logs.length === 0) {
517
+ tbody.innerHTML = `<tr><td colspan="5" class="empty-state">No logs found. Send OTLP logs to /v1/logs</td></tr>`;
518
+ return;
519
+ }
520
+
521
+ tbody.innerHTML = logs.map(l => `
522
+ <tr class="trace-row ${l.id === selectedLogId ? 'selected' : ''}" onclick="selectLog('${l.id}', this)">
523
+ <td>${formatTime(l.timestamp)}</td>
524
+ <td><span class="severity-badge severity-${getSeverityClass(l.severity_text)}">${l.severity_text || 'INFO'}</span></td>
525
+ <td>${l.service_name ? `<span class="service-badge">${escapeHtml(l.service_name)}</span>` : '-'}</td>
526
+ <td>${l.event_name ? `<span class="event-badge">${escapeHtml(l.event_name)}</span>` : '-'}</td>
527
+ <td><span class="log-body-preview">${escapeHtml(l.body || '-')}</span></td>
528
+ </tr>
529
+ `).join('');
530
+ }
531
+
532
+ function getSeverityClass(severityText) {
533
+ if (!severityText) return 'info';
534
+ const s = severityText.toLowerCase();
535
+ if (s.includes('fatal')) return 'fatal';
536
+ if (s.includes('error')) return 'error';
537
+ if (s.includes('warn')) return 'warn';
538
+ if (s.includes('debug')) return 'debug';
539
+ if (s.includes('trace')) return 'trace';
540
+ return 'info';
541
+ }
542
+
543
+ async function selectLog(logId, rowEl) {
544
+ selectedLogId = logId;
545
+
546
+ document.querySelectorAll('#logsBody .trace-row').forEach(r => r.classList.remove('selected'));
547
+ if (rowEl) rowEl.classList.add('selected');
548
+
549
+ const titleEl = document.getElementById('logDetailTitle');
550
+ const metaEl = document.getElementById('logDetailMeta');
551
+ const bodyEl = document.getElementById('logBody');
552
+ const attrsEl = document.getElementById('logAttributes');
553
+ const resourceEl = document.getElementById('logResource');
554
+
555
+ try {
556
+ const response = await fetch(`/api/logs/${logId}`);
557
+ if (!response.ok) throw new Error('Log not found');
558
+
559
+ const log = await response.json();
560
+
561
+ titleEl.textContent = log.event_name || log.service_name || 'Log';
562
+ metaEl.textContent = [
563
+ log.severity_text,
564
+ log.service_name,
565
+ log.trace_id ? `trace: ${log.trace_id.slice(0, 8)}...` : null
566
+ ].filter(Boolean).join(' · ');
567
+
568
+ bodyEl.textContent = log.body || '-';
569
+ attrsEl.textContent = JSON.stringify(log.attributes || {}, null, 2);
570
+ resourceEl.textContent = JSON.stringify(log.resource_attributes || {}, null, 2);
571
+ } catch (e) {
572
+ console.error('Failed to load log:', e);
573
+ titleEl.textContent = 'Error';
574
+ metaEl.textContent = '';
575
+ bodyEl.textContent = 'Failed to load log';
576
+ attrsEl.textContent = '{}';
577
+ resourceEl.textContent = '{}';
578
+ }
579
+ }
580
+
581
+ function handleNewLog(log) {
582
+ if (currentTab !== 'logs') return;
583
+ if (!logMatchesFilters(log)) return;
584
+
585
+ if (!logs.find(l => l.id === log.id)) {
586
+ logs.unshift(log);
587
+ if (logs.length > 100) {
588
+ logs.length = 100;
589
+ }
590
+ renderLogsTable();
591
+ }
592
+ }
593
+
594
+ function logMatchesFilters(log) {
595
+ if (logFilters.service_name && log.service_name !== logFilters.service_name) return false;
596
+ if (logFilters.event_name && log.event_name !== logFilters.event_name) return false;
597
+ if (logFilters.q) return false; // Text search requires server
598
+ return true;
599
+ }
600
+
601
+ // ==================== Metrics Functions ====================
602
+
603
+ function setupMetricFilters() {
604
+ document.getElementById('metricNameFilter')?.addEventListener('change', (e) => {
605
+ metricFilters.name = e.target.value;
606
+ loadMetrics();
607
+ });
608
+
609
+ document.getElementById('metricServiceFilter')?.addEventListener('change', (e) => {
610
+ metricFilters.service_name = e.target.value;
611
+ loadMetrics();
612
+ });
613
+
614
+ document.getElementById('metricTypeFilter')?.addEventListener('change', (e) => {
615
+ metricFilters.metric_type = e.target.value;
616
+ loadMetrics();
617
+ });
618
+
619
+ document.getElementById('clearMetricFilters')?.addEventListener('click', clearMetricFilters);
620
+ }
621
+
622
+ function clearMetricFilters() {
623
+ metricFilters = { name: '', service_name: '', metric_type: '' };
624
+ document.getElementById('metricNameFilter').value = '';
625
+ document.getElementById('metricServiceFilter').value = '';
626
+ document.getElementById('metricTypeFilter').value = '';
627
+ loadMetrics();
628
+ }
629
+
630
+ async function loadMetricFilterOptions() {
631
+ try {
632
+ const response = await fetch('/api/metrics/filters');
633
+ const data = await response.json();
634
+
635
+ const nameSelect = document.getElementById('metricNameFilter');
636
+ if (nameSelect && data.names) {
637
+ nameSelect.innerHTML = '<option value="">All Metrics</option>';
638
+ data.names.forEach(name => {
639
+ const opt = document.createElement('option');
640
+ opt.value = name;
641
+ opt.textContent = name;
642
+ nameSelect.appendChild(opt);
643
+ });
644
+ }
645
+
646
+ const serviceSelect = document.getElementById('metricServiceFilter');
647
+ if (serviceSelect && data.services) {
648
+ serviceSelect.innerHTML = '<option value="">All Services</option>';
649
+ data.services.forEach(svc => {
650
+ const opt = document.createElement('option');
651
+ opt.value = svc;
652
+ opt.textContent = svc;
653
+ serviceSelect.appendChild(opt);
654
+ });
655
+ }
656
+ } catch (e) {
657
+ console.error('Failed to load metric filter options:', e);
658
+ }
659
+ }
660
+
661
+ async function loadMetricsSummary() {
662
+ if (currentTab !== 'metrics') return;
663
+
664
+ try {
665
+ const response = await fetch('/api/metrics?aggregation=summary');
666
+ const data = await response.json();
667
+ metricsSummary = data.summary || [];
668
+
669
+ const container = document.getElementById('metricsSummary');
670
+ if (!metricsSummary || metricsSummary.length === 0) {
671
+ container.innerHTML = '<p class="empty-state">No metrics yet. Send OTLP metrics to /v1/metrics</p>';
672
+ return;
673
+ }
674
+
675
+ container.innerHTML = metricsSummary.slice(0, 8).map(m => `
676
+ <div class="metric-card">
677
+ <div class="metric-card-header">
678
+ <span class="metric-card-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</span>
679
+ <span class="metric-badge metric-${m.metric_type || 'gauge'}">${m.metric_type || 'gauge'}</span>
680
+ </div>
681
+ <div class="metric-card-value">${formatMetricValue(m)}</div>
682
+ <div class="metric-card-meta">
683
+ <span>${m.data_points} data points</span>
684
+ <span>${m.service_name || 'unknown'}</span>
685
+ </div>
686
+ </div>
687
+ `).join('');
688
+ } catch (e) {
689
+ console.error('Failed to load metrics summary:', e);
690
+ document.getElementById('metricsSummary').innerHTML =
691
+ '<p class="empty-state">Failed to load metrics</p>';
692
+ }
693
+ }
694
+
695
+ function formatMetricValue(m) {
696
+ if (m.sum_int != null && m.sum_int !== 0) {
697
+ return formatNumber(m.sum_int);
698
+ }
699
+ if (m.avg_double != null) {
700
+ return m.avg_double.toFixed(2);
701
+ }
702
+ if (m.max_int != null) {
703
+ return formatNumber(m.max_int);
704
+ }
705
+ return '-';
706
+ }
707
+
708
+ async function loadMetrics() {
709
+ if (currentTab !== 'metrics') return;
710
+
711
+ try {
712
+ const params = new URLSearchParams({ limit: '100' });
713
+ if (metricFilters.name) params.set('name', metricFilters.name);
714
+ if (metricFilters.service_name) params.set('service_name', metricFilters.service_name);
715
+ if (metricFilters.metric_type) params.set('metric_type', metricFilters.metric_type);
716
+
717
+ const response = await fetch('/api/metrics?' + params.toString());
718
+ const data = await response.json();
719
+ metrics = data.metrics || [];
720
+
721
+ renderMetricsTable();
722
+ } catch (e) {
723
+ console.error('Failed to load metrics:', e);
724
+ document.getElementById('metricsBody').innerHTML =
725
+ '<tr><td colspan="5" class="empty-state">Failed to load metrics</td></tr>';
726
+ }
727
+ }
728
+
729
+ function renderMetricsTable() {
730
+ const tbody = document.getElementById('metricsBody');
731
+ if (!tbody) return;
732
+
733
+ if (!metrics || metrics.length === 0) {
734
+ tbody.innerHTML = `<tr><td colspan="5" class="empty-state">No metrics found. Send OTLP metrics to /v1/metrics</td></tr>`;
735
+ return;
736
+ }
737
+
738
+ tbody.innerHTML = metrics.map(m => `
739
+ <tr class="trace-row">
740
+ <td>${formatTime(m.timestamp)}</td>
741
+ <td><span class="metric-badge metric-${m.metric_type || 'gauge'}">${m.metric_type || 'gauge'}</span></td>
742
+ <td class="metric-name-cell">${escapeHtml(m.name)}</td>
743
+ <td class="metric-value">${m.value_int != null ? formatNumber(m.value_int) : (m.value_double != null ? m.value_double.toFixed(2) : '-')}</td>
744
+ <td>${m.service_name ? `<span class="service-badge">${escapeHtml(m.service_name)}</span>` : '-'}</td>
745
+ </tr>
746
+ `).join('');
747
+ }
748
+
749
+ function handleNewMetric(metric) {
750
+ if (currentTab !== 'metrics') return;
751
+
752
+ if (!metrics.find(m => m.id === metric.id)) {
753
+ metrics.unshift(metric);
754
+ if (metrics.length > 100) {
755
+ metrics.length = 100;
756
+ }
757
+ renderMetricsTable();
758
+ }
759
+ }
760
+
761
+ // ==================== Timeline Functions ====================
762
+
763
+ function setupTimelineFilters() {
764
+ let searchTimeout;
765
+ document.getElementById('timelineSearchInput')?.addEventListener('input', (e) => {
766
+ clearTimeout(searchTimeout);
767
+ searchTimeout = setTimeout(() => {
768
+ timelineFilters.q = e.target.value;
769
+ loadTimeline();
770
+ }, 300);
771
+ });
772
+
773
+ document.getElementById('toolFilter')?.addEventListener('change', (e) => {
774
+ timelineFilters.tool = e.target.value;
775
+ loadTimeline();
776
+ });
777
+
778
+ document.getElementById('timelineTypeFilter')?.addEventListener('change', (e) => {
779
+ timelineFilters.type = e.target.value;
780
+ loadTimeline();
781
+ });
782
+
783
+ document.getElementById('timelineDateFilter')?.addEventListener('change', (e) => {
784
+ timelineFilters.dateRange = e.target.value;
785
+ applyTimelineDateRange(timelineFilters.dateRange);
786
+ loadTimeline();
787
+ });
788
+
789
+ document.getElementById('clearTimelineFilters')?.addEventListener('click', clearTimelineFilters);
790
+ }
791
+
792
+ function applyTimelineDateRange(range) {
793
+ const now = Date.now();
794
+ switch (range) {
795
+ case '1h': timelineFilters.date_from = now - 3600000; break;
796
+ case '24h': timelineFilters.date_from = now - 86400000; break;
797
+ case '7d': timelineFilters.date_from = now - 604800000; break;
798
+ default: timelineFilters.date_from = null;
799
+ }
800
+ }
801
+
802
+ function clearTimelineFilters() {
803
+ timelineFilters = { q: '', tool: '', type: '', dateRange: '', date_from: null };
804
+ document.getElementById('timelineSearchInput').value = '';
805
+ document.getElementById('toolFilter').value = '';
806
+ document.getElementById('timelineTypeFilter').value = '';
807
+ document.getElementById('timelineDateFilter').value = '';
808
+ loadTimeline();
809
+ }
810
+
811
+ async function loadTimeline() {
812
+ if (currentTab !== 'timeline') return;
813
+
814
+ try {
815
+ // Load traces and logs in parallel
816
+ const [tracesRes, logsRes] = await Promise.all([
817
+ fetch('/api/traces?limit=50'),
818
+ fetch('/api/logs?limit=50')
819
+ ]);
820
+
821
+ const tracesData = await tracesRes.json();
822
+ const logsData = await logsRes.json();
823
+
824
+ // Combine and normalize
825
+ const traceItems = (tracesData || []).map(t => ({
826
+ id: t.id,
827
+ type: 'trace',
828
+ timestamp: t.timestamp,
829
+ title: t.span_name || t.model || 'Trace',
830
+ body: t.model ? `Model: ${t.model}` : '',
831
+ tool: detectTool(t),
832
+ tokens: t.total_tokens,
833
+ cost: t.estimated_cost,
834
+ duration: t.duration_ms,
835
+ status: t.status,
836
+ trace_id: t.trace_id || t.id,
837
+ raw: t
838
+ }));
839
+
840
+ const logItems = (logsData.logs || []).map(l => ({
841
+ id: l.id,
842
+ type: 'log',
843
+ timestamp: l.timestamp,
844
+ title: l.event_name || l.service_name || 'Log',
845
+ body: l.body || '',
846
+ tool: detectToolFromLog(l),
847
+ severity: l.severity_text,
848
+ trace_id: l.trace_id,
849
+ raw: l
850
+ }));
851
+
852
+ // Combine and sort by timestamp
853
+ timelineItems = [...traceItems, ...logItems]
854
+ .sort((a, b) => b.timestamp - a.timestamp);
855
+
856
+ // Apply filters
857
+ let filtered = timelineItems;
858
+
859
+ if (timelineFilters.tool) {
860
+ filtered = filtered.filter(i => i.tool === timelineFilters.tool);
861
+ }
862
+
863
+ if (timelineFilters.type) {
864
+ filtered = filtered.filter(i => i.type === timelineFilters.type);
865
+ }
866
+
867
+ if (timelineFilters.date_from) {
868
+ filtered = filtered.filter(i => i.timestamp >= timelineFilters.date_from);
869
+ }
870
+
871
+ if (timelineFilters.q) {
872
+ const q = timelineFilters.q.toLowerCase();
873
+ filtered = filtered.filter(i =>
874
+ (i.title && i.title.toLowerCase().includes(q)) ||
875
+ (i.body && i.body.toLowerCase().includes(q))
876
+ );
877
+ }
878
+
879
+ renderTimeline(filtered.slice(0, 100));
880
+ } catch (e) {
881
+ console.error('Failed to load timeline:', e);
882
+ document.getElementById('timelineList').innerHTML =
883
+ '<div class="empty-state">Failed to load timeline</div>';
884
+ }
885
+ }
886
+
887
+ function detectTool(trace) {
888
+ const provider = (trace.provider || '').toLowerCase();
889
+ const serviceName = (trace.service_name || '').toLowerCase();
890
+
891
+ if (provider.includes('anthropic-passthrough') || serviceName.includes('claude')) {
892
+ return 'claude-code';
893
+ }
894
+ if (serviceName.includes('codex') || serviceName.includes('openai-codex')) {
895
+ return 'codex-cli';
896
+ }
897
+ if (provider.includes('gemini-passthrough') || serviceName.includes('gemini')) {
898
+ return 'gemini-cli';
899
+ }
900
+ if (serviceName.includes('aider')) {
901
+ return 'aider';
902
+ }
903
+ return 'proxy';
904
+ }
905
+
906
+ function detectToolFromLog(log) {
907
+ const serviceName = (log.service_name || '').toLowerCase();
908
+ const eventName = (log.event_name || '').toLowerCase();
909
+
910
+ if (serviceName.includes('claude') || eventName.includes('claude')) {
911
+ return 'claude-code';
912
+ }
913
+ if (serviceName.includes('codex') || eventName.includes('codex')) {
914
+ return 'codex-cli';
915
+ }
916
+ if (serviceName.includes('gemini') || eventName.includes('gemini')) {
917
+ return 'gemini-cli';
918
+ }
919
+ if (serviceName.includes('aider')) {
920
+ return 'aider';
921
+ }
922
+ return 'proxy';
923
+ }
924
+
925
+ function getToolIcon(tool) {
926
+ switch (tool) {
927
+ case 'claude-code': return '🟣';
928
+ case 'codex-cli': return '🟢';
929
+ case 'gemini-cli': return '🔵';
930
+ case 'aider': return '🟠';
931
+ default: return '⚪';
932
+ }
933
+ }
934
+
935
+ function getToolLabel(tool) {
936
+ switch (tool) {
937
+ case 'claude-code': return 'Claude';
938
+ case 'codex-cli': return 'Codex';
939
+ case 'gemini-cli': return 'Gemini';
940
+ case 'aider': return 'Aider';
941
+ default: return 'Proxy';
942
+ }
943
+ }
944
+
945
+ function renderTimeline(items) {
946
+ const container = document.getElementById('timelineList');
947
+ if (!container) return;
948
+
949
+ if (!items || items.length === 0) {
950
+ container.innerHTML = '<div class="empty-state">No activity yet. Run an AI CLI tool to see the timeline.</div>';
951
+ return;
952
+ }
953
+
954
+ container.innerHTML = items.map(item => `
955
+ <div class="timeline-item ${selectedTimelineItem?.id === item.id ? 'selected' : ''}"
956
+ onclick="selectTimelineItem('${item.id}', '${item.type}', this)">
957
+ <div class="timeline-item-icon tool-${item.tool}">
958
+ ${getToolIcon(item.tool)}
959
+ </div>
960
+ <div class="timeline-item-content">
961
+ <div class="timeline-item-header">
962
+ <span class="type-badge type-${item.type}">${item.type}</span>
963
+ <span class="timeline-item-title">${escapeHtml(item.title)}</span>
964
+ <span class="timeline-item-time">${formatTime(item.timestamp)}</span>
965
+ </div>
966
+ <div class="timeline-item-body">${escapeHtml(item.body)}</div>
967
+ <div class="timeline-item-meta">
968
+ <span class="tool-badge tool-${item.tool}">${getToolLabel(item.tool)}</span>
969
+ ${item.tokens ? `<span>${formatNumber(item.tokens)} tokens</span>` : ''}
970
+ ${item.cost ? `<span>$${item.cost.toFixed(4)}</span>` : ''}
971
+ ${item.duration ? `<span>${item.duration}ms</span>` : ''}
972
+ ${item.severity ? `<span class="severity-badge severity-${getSeverityClass(item.severity)}">${item.severity}</span>` : ''}
973
+ </div>
974
+ </div>
975
+ </div>
976
+ `).join('');
977
+ }
978
+
979
+ async function selectTimelineItem(id, type, rowEl) {
980
+ selectedTimelineItem = { id, type };
981
+
982
+ document.querySelectorAll('.timeline-item').forEach(r => r.classList.remove('selected'));
983
+ if (rowEl) rowEl.classList.add('selected');
984
+
985
+ const titleEl = document.getElementById('timelineDetailTitle');
986
+ const metaEl = document.getElementById('timelineDetailMeta');
987
+ const dataEl = document.getElementById('timelineDetailData');
988
+ const relatedSection = document.getElementById('relatedLogsSection');
989
+ const relatedLogsEl = document.getElementById('relatedLogs');
990
+
991
+ try {
992
+ if (type === 'trace') {
993
+ const response = await fetch(`/api/traces/${id}`);
994
+ const data = await response.json();
995
+ const t = data.trace || data;
996
+
997
+ titleEl.textContent = t.span_name || t.model || 'Trace';
998
+ metaEl.textContent = [
999
+ t.provider,
1000
+ t.duration_ms ? `${t.duration_ms}ms` : null,
1001
+ t.total_tokens ? `${t.total_tokens} tokens` : null
1002
+ ].filter(Boolean).join(' · ');
1003
+
1004
+ dataEl.textContent = JSON.stringify(t, null, 2);
1005
+
1006
+ // Load related logs
1007
+ if (t.trace_id) {
1008
+ const logsRes = await fetch(`/api/logs?trace_id=${t.trace_id}&limit=10`);
1009
+ const logsData = await logsRes.json();
1010
+ const relatedLogs = logsData.logs || [];
1011
+
1012
+ if (relatedLogs.length > 0) {
1013
+ relatedSection.style.display = 'block';
1014
+ relatedLogsEl.innerHTML = relatedLogs.map(l => `
1015
+ <div class="related-log-item">
1016
+ <div class="related-log-header">
1017
+ <span class="severity-badge severity-${getSeverityClass(l.severity_text)}">${l.severity_text || 'INFO'}</span>
1018
+ <span>${formatTime(l.timestamp)}</span>
1019
+ </div>
1020
+ <div class="related-log-body">${escapeHtml(l.body || '-')}</div>
1021
+ </div>
1022
+ `).join('');
1023
+ } else {
1024
+ relatedSection.style.display = 'none';
1025
+ }
1026
+ } else {
1027
+ relatedSection.style.display = 'none';
1028
+ }
1029
+ } else if (type === 'log') {
1030
+ const response = await fetch(`/api/logs/${id}`);
1031
+ const log = await response.json();
1032
+
1033
+ titleEl.textContent = log.event_name || log.service_name || 'Log';
1034
+ metaEl.textContent = [
1035
+ log.severity_text,
1036
+ log.service_name,
1037
+ log.trace_id ? `trace: ${log.trace_id.slice(0, 8)}...` : null
1038
+ ].filter(Boolean).join(' · ');
1039
+
1040
+ dataEl.textContent = JSON.stringify(log, null, 2);
1041
+ relatedSection.style.display = 'none';
1042
+ }
1043
+ } catch (e) {
1044
+ console.error('Failed to load timeline item:', e);
1045
+ titleEl.textContent = 'Error';
1046
+ metaEl.textContent = '';
1047
+ dataEl.textContent = 'Failed to load';
1048
+ relatedSection.style.display = 'none';
1049
+ }
1050
+ }
1051
+
1052
+ function handleTimelineUpdate(item) {
1053
+ if (currentTab !== 'timeline') return;
1054
+ loadTimeline(); // Reload for now - could optimize later
1055
+ }
1056
+
1057
+ // Utils
1058
+ function formatTime(ts) {
1059
+ if (!ts) return '-';
1060
+ const d = new Date(ts);
1061
+ const now = new Date();
1062
+ const diff = now - d;
1063
+ if (diff < 60000) return 'just now';
1064
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
1065
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
1066
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1067
+ }
1068
+
1069
+ function formatNumber(n) {
1070
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1071
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1072
+ return String(n);
1073
+ }
1074
+
1075
+ function escapeHtml(str) {
1076
+ if (!str) return '';
1077
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1078
+ }
1079
+
1080
+ // Extract input/output from span - handles both SDK spans and proxy traces
1081
+ function extractIO(spanLike) {
1082
+ // 1. Prefer explicit SDK span fields
1083
+ let input = spanLike.input ?? null;
1084
+ let output = spanLike.output ?? null;
1085
+
1086
+ const reqBody = spanLike.request_body || spanLike.requestBody || spanLike.request?.body || {};
1087
+ const resBody = spanLike.response_body || spanLike.responseBody || spanLike.response?.body || {};
1088
+
1089
+ // 2. If missing, try OpenAI-style / proxy request body
1090
+ if (input == null) {
1091
+ if (Array.isArray(reqBody.messages)) {
1092
+ input = reqBody.messages;
1093
+ } else if (reqBody.prompt != null) {
1094
+ input = reqBody.prompt;
1095
+ } else if (reqBody.input != null) {
1096
+ input = reqBody.input;
1097
+ } else if (reqBody.contents != null) {
1098
+ // Gemini format
1099
+ input = reqBody.contents;
1100
+ }
1101
+ }
1102
+
1103
+ // 3. If missing, try OpenAI-style / proxy response body
1104
+ if (output == null) {
1105
+ if (Array.isArray(resBody.choices) && resBody.choices.length > 0) {
1106
+ const contents = resBody.choices
1107
+ .map(c => (c.message && c.message.content) || c.text || null)
1108
+ .filter(Boolean);
1109
+
1110
+ if (contents.length === 1) {
1111
+ output = contents[0];
1112
+ } else if (contents.length > 1) {
1113
+ output = contents;
1114
+ }
1115
+ } else if (resBody.output != null) {
1116
+ output = resBody.output;
1117
+ } else if (resBody.output_text != null) {
1118
+ // OpenAI Responses API
1119
+ output = resBody.output_text;
1120
+ }
1121
+ }
1122
+
1123
+ return { input, output };
1124
+ }
1125
+
1126
+ // WebSocket for real-time updates
1127
+ function initWebSocket() {
1128
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1129
+ const wsUrl = protocol + '//' + window.location.host + '/ws';
1130
+
1131
+ try {
1132
+ ws = new WebSocket(wsUrl);
1133
+
1134
+ ws.onopen = () => {
1135
+ console.log('[LLMFlow] WebSocket connected');
1136
+ wsRetryDelay = 1000; // Reset backoff
1137
+ updateConnectionStatus(true);
1138
+ };
1139
+
1140
+ ws.onmessage = (event) => {
1141
+ try {
1142
+ const msg = JSON.parse(event.data);
1143
+ handleWsMessage(msg);
1144
+ } catch (e) {
1145
+ console.error('[LLMFlow] Invalid WS message', e);
1146
+ }
1147
+ };
1148
+
1149
+ ws.onclose = () => {
1150
+ console.log('[LLMFlow] WebSocket closed, reconnecting...');
1151
+ updateConnectionStatus(false);
1152
+ scheduleReconnect();
1153
+ };
1154
+
1155
+ ws.onerror = (err) => {
1156
+ console.error('[LLMFlow] WebSocket error', err);
1157
+ updateConnectionStatus(false);
1158
+ ws.close();
1159
+ };
1160
+ } catch (e) {
1161
+ console.error('[LLMFlow] Failed to create WebSocket', e);
1162
+ scheduleReconnect();
1163
+ }
1164
+ }
1165
+
1166
+ function scheduleReconnect() {
1167
+ setTimeout(() => {
1168
+ wsRetryDelay = Math.min(wsRetryDelay * 2, WS_MAX_RETRY);
1169
+ initWebSocket();
1170
+ }, wsRetryDelay);
1171
+ }
1172
+
1173
+ function updateConnectionStatus(connected) {
1174
+ const indicator = document.getElementById('connectionStatus');
1175
+ if (indicator) {
1176
+ indicator.className = connected ? 'status-dot connected' : 'status-dot disconnected';
1177
+ indicator.title = connected ? 'Real-time updates active' : 'Reconnecting...';
1178
+ }
1179
+ }
1180
+
1181
+ function handleWsMessage(msg) {
1182
+ switch (msg.type) {
1183
+ case 'new_span':
1184
+ handleNewSpan(msg.payload);
1185
+ break;
1186
+ case 'new_trace':
1187
+ // Could highlight or scroll to top
1188
+ break;
1189
+ case 'new_log':
1190
+ handleNewLog(msg.payload);
1191
+ break;
1192
+ case 'new_metric':
1193
+ handleNewMetric(msg.payload);
1194
+ break;
1195
+ case 'stats_update':
1196
+ handleStatsUpdate(msg.payload);
1197
+ break;
1198
+ case 'hello':
1199
+ console.log('[LLMFlow] Server hello:', msg.time);
1200
+ break;
1201
+ default:
1202
+ break;
1203
+ }
1204
+ }
1205
+
1206
+ function handleStatsUpdate(newStats) {
1207
+ stats = newStats;
1208
+ const elTotalRequests = document.getElementById('totalRequests');
1209
+ if (!elTotalRequests) return;
1210
+
1211
+ elTotalRequests.textContent = stats.total_requests || 0;
1212
+ document.getElementById('totalTokens').textContent = formatNumber(stats.total_tokens || 0);
1213
+ document.getElementById('totalCost').textContent = '$' + (stats.total_cost || 0).toFixed(2);
1214
+ document.getElementById('avgLatency').textContent = Math.round(stats.avg_duration || 0) + 'ms';
1215
+
1216
+ // Update models tab if visible
1217
+ if (currentTab === 'models') {
1218
+ loadModelStats();
1219
+ }
1220
+ }
1221
+
1222
+ function handleNewSpan(span) {
1223
+ // Only update if on traces tab and span matches filters
1224
+ if (currentTab !== 'traces') return;
1225
+ if (!spanMatchesFilters(span)) return;
1226
+
1227
+ // Prepend if not already in list
1228
+ if (!traces.find(t => t.id === span.id)) {
1229
+ traces.unshift(span);
1230
+ if (traces.length > 100) {
1231
+ traces.length = 100;
1232
+ }
1233
+ renderTracesTable();
1234
+ }
1235
+ }
1236
+
1237
+ function spanMatchesFilters(span) {
1238
+ if (filters.model && span.model !== filters.model) return false;
1239
+
1240
+ if (filters.status) {
1241
+ const status = span.status || 200;
1242
+ if (filters.status === 'error' && status < 400) return false;
1243
+ if (filters.status === 'success' && status >= 400) return false;
1244
+ }
1245
+
1246
+ if (filters.date_from && span.timestamp < filters.date_from) return false;
1247
+ if (filters.date_to && span.timestamp > filters.date_to) return false;
1248
+
1249
+ // Text search requires server - skip live updates for q filter
1250
+ if (filters.q) return false;
1251
+
1252
+ return true;
1253
+ }
1254
+
1255
+ function renderTracesTable() {
1256
+ const tbody = document.getElementById('tracesBody');
1257
+ if (!tbody) return;
1258
+
1259
+ if (!traces || traces.length === 0) {
1260
+ tbody.innerHTML = `<tr><td colspan="8" class="empty-state">No traces found. Run: npm run demo</td></tr>`;
1261
+ return;
1262
+ }
1263
+
1264
+ tbody.innerHTML = traces.map(t => `
1265
+ <tr class="trace-row ${t.id === selectedTraceId ? 'selected' : ''}" onclick="selectTrace('${t.id}', this)">
1266
+ <td>${formatTime(t.timestamp)}</td>
1267
+ <td><span class="span-badge span-${t.span_type || 'llm'}">${t.span_type || 'llm'}</span></td>
1268
+ <td>${escapeHtml(t.span_name || '-')}</td>
1269
+ <td>${t.model ? `<span class="model-badge">${escapeHtml(t.model)}</span>` : '-'}</td>
1270
+ <td>${formatNumber(t.total_tokens || 0)}</td>
1271
+ <td>$${(t.estimated_cost || 0).toFixed(4)}</td>
1272
+ <td>${t.duration_ms || 0}ms</td>
1273
+ <td class="${(t.status || 200) < 400 ? 'status-success' : 'status-error'}">${t.status || 200}</td>
1274
+ </tr>
1275
+ `).join('');
1276
+ }
1277
+
1278
+ // ==================== Analytics Functions ====================
1279
+
1280
+ let analyticsDays = 30;
1281
+
1282
+ function setupAnalyticsFilters() {
1283
+ document.getElementById('analyticsDaysFilter')?.addEventListener('change', (e) => {
1284
+ analyticsDays = parseInt(e.target.value) || 30;
1285
+ loadAnalytics();
1286
+ });
1287
+
1288
+ document.getElementById('refreshAnalytics')?.addEventListener('click', loadAnalytics);
1289
+ }
1290
+
1291
+ async function loadAnalytics() {
1292
+ if (currentTab !== 'analytics') return;
1293
+
1294
+ try {
1295
+ const [trendsRes, toolRes, modelRes, dailyRes] = await Promise.all([
1296
+ fetch(`/api/analytics/token-trends?interval=day&days=${analyticsDays}`),
1297
+ fetch(`/api/analytics/cost-by-tool?days=${analyticsDays}`),
1298
+ fetch(`/api/analytics/cost-by-model?days=${analyticsDays}`),
1299
+ fetch(`/api/analytics/daily?days=${analyticsDays}`)
1300
+ ]);
1301
+
1302
+ const [trendsData, toolData, modelData, dailyData] = await Promise.all([
1303
+ trendsRes.json(),
1304
+ toolRes.json(),
1305
+ modelRes.json(),
1306
+ dailyRes.json()
1307
+ ]);
1308
+
1309
+ renderTokenTrendsChart(trendsData.trends || []);
1310
+ renderCostByToolChart(toolData.by_tool || []);
1311
+ renderCostByModelChart(modelData.by_model || []);
1312
+ renderDailySummary(dailyData.daily || []);
1313
+ } catch (e) {
1314
+ console.error('Failed to load analytics:', e);
1315
+ }
1316
+ }
1317
+
1318
+ function renderTokenTrendsChart(trends) {
1319
+ const container = document.getElementById('tokenTrendsChart');
1320
+ if (!container) return;
1321
+
1322
+ if (!trends || trends.length === 0) {
1323
+ container.innerHTML = '<p class="empty-state">No data yet. Generate some traces to see trends.</p>';
1324
+ return;
1325
+ }
1326
+
1327
+ const maxTokens = Math.max(...trends.map(t => t.total_tokens || 0));
1328
+ const barWidth = Math.max(8, Math.floor((container.clientWidth - 60) / trends.length) - 2);
1329
+
1330
+ container.innerHTML = `
1331
+ <div class="bar-chart">
1332
+ <div class="bar-chart-bars">
1333
+ ${trends.map((t, i) => {
1334
+ const height = maxTokens > 0 ? ((t.total_tokens || 0) / maxTokens * 100) : 0;
1335
+ const promptHeight = maxTokens > 0 ? ((t.prompt_tokens || 0) / maxTokens * 100) : 0;
1336
+ return `
1337
+ <div class="bar-group" style="width: ${barWidth}px" title="${t.label}\nTokens: ${formatNumber(t.total_tokens || 0)}\nCost: $${(t.total_cost || 0).toFixed(4)}">
1338
+ <div class="bar bar-total" style="height: ${height}%"></div>
1339
+ <div class="bar bar-prompt" style="height: ${promptHeight}%"></div>
1340
+ </div>
1341
+ `;
1342
+ }).join('')}
1343
+ </div>
1344
+ <div class="bar-chart-legend">
1345
+ <span class="legend-item"><span class="legend-dot legend-total"></span>Total</span>
1346
+ <span class="legend-item"><span class="legend-dot legend-prompt"></span>Prompt</span>
1347
+ </div>
1348
+ </div>
1349
+ `;
1350
+ }
1351
+
1352
+ function renderCostByToolChart(byTool) {
1353
+ const container = document.getElementById('costByToolChart');
1354
+ if (!container) return;
1355
+
1356
+ if (!byTool || byTool.length === 0) {
1357
+ container.innerHTML = '<p class="empty-state">No data yet.</p>';
1358
+ return;
1359
+ }
1360
+
1361
+ const totalCost = byTool.reduce((sum, t) => sum + (t.total_cost || 0), 0);
1362
+
1363
+ container.innerHTML = `
1364
+ <div class="horizontal-bar-chart">
1365
+ ${byTool.slice(0, 8).map(t => {
1366
+ const toolName = getToolDisplayName(t.provider, t.service_name);
1367
+ const percentage = totalCost > 0 ? ((t.total_cost || 0) / totalCost * 100) : 0;
1368
+ const toolClass = getToolClass(t.provider, t.service_name);
1369
+ return `
1370
+ <div class="h-bar-row">
1371
+ <div class="h-bar-label">
1372
+ <span class="tool-badge ${toolClass}">${escapeHtml(toolName)}</span>
1373
+ </div>
1374
+ <div class="h-bar-track">
1375
+ <div class="h-bar-fill ${toolClass}" style="width: ${percentage}%"></div>
1376
+ </div>
1377
+ <div class="h-bar-value">$${(t.total_cost || 0).toFixed(2)}</div>
1378
+ </div>
1379
+ `;
1380
+ }).join('')}
1381
+ </div>
1382
+ <div class="chart-total">Total: $${totalCost.toFixed(2)}</div>
1383
+ `;
1384
+ }
1385
+
1386
+ function renderCostByModelChart(byModel) {
1387
+ const container = document.getElementById('costByModelChart');
1388
+ if (!container) return;
1389
+
1390
+ if (!byModel || byModel.length === 0) {
1391
+ container.innerHTML = '<p class="empty-state">No data yet.</p>';
1392
+ return;
1393
+ }
1394
+
1395
+ const totalCost = byModel.reduce((sum, m) => sum + (m.total_cost || 0), 0);
1396
+
1397
+ container.innerHTML = `
1398
+ <div class="horizontal-bar-chart">
1399
+ ${byModel.slice(0, 8).map(m => {
1400
+ const percentage = totalCost > 0 ? ((m.total_cost || 0) / totalCost * 100) : 0;
1401
+ return `
1402
+ <div class="h-bar-row">
1403
+ <div class="h-bar-label">
1404
+ <span class="model-badge">${escapeHtml(m.model || 'unknown')}</span>
1405
+ </div>
1406
+ <div class="h-bar-track">
1407
+ <div class="h-bar-fill" style="width: ${percentage}%"></div>
1408
+ </div>
1409
+ <div class="h-bar-value">$${(m.total_cost || 0).toFixed(2)}</div>
1410
+ </div>
1411
+ `;
1412
+ }).join('')}
1413
+ </div>
1414
+ <div class="chart-total">Total: $${totalCost.toFixed(2)}</div>
1415
+ `;
1416
+ }
1417
+
1418
+ function renderDailySummary(daily) {
1419
+ const container = document.getElementById('dailySummaryTable');
1420
+ if (!container) return;
1421
+
1422
+ if (!daily || daily.length === 0) {
1423
+ container.innerHTML = '<p class="empty-state">No data yet.</p>';
1424
+ return;
1425
+ }
1426
+
1427
+ const reversed = [...daily].reverse();
1428
+
1429
+ container.innerHTML = `
1430
+ <table class="summary-table">
1431
+ <thead>
1432
+ <tr>
1433
+ <th>Date</th>
1434
+ <th>Requests</th>
1435
+ <th>Tokens</th>
1436
+ <th>Cost</th>
1437
+ </tr>
1438
+ </thead>
1439
+ <tbody>
1440
+ ${reversed.slice(0, 14).map(d => `
1441
+ <tr>
1442
+ <td>${d.date}</td>
1443
+ <td>${formatNumber(d.requests || 0)}</td>
1444
+ <td>${formatNumber(d.tokens || 0)}</td>
1445
+ <td>$${(d.cost || 0).toFixed(2)}</td>
1446
+ </tr>
1447
+ `).join('')}
1448
+ </tbody>
1449
+ </table>
1450
+ `;
1451
+ }
1452
+
1453
+ function getToolDisplayName(provider, serviceName) {
1454
+ const p = (provider || '').toLowerCase();
1455
+ const s = (serviceName || '').toLowerCase();
1456
+
1457
+ if (p.includes('anthropic') || s.includes('claude')) return 'Claude Code';
1458
+ if (s.includes('codex') || p.includes('codex')) return 'Codex CLI';
1459
+ if (p.includes('gemini') || s.includes('gemini')) return 'Gemini CLI';
1460
+ if (s.includes('aider')) return 'Aider';
1461
+ if (p.includes('openai')) return 'OpenAI';
1462
+ if (p.includes('ollama')) return 'Ollama';
1463
+
1464
+ return provider || serviceName || 'Other';
1465
+ }
1466
+
1467
+ function getToolClass(provider, serviceName) {
1468
+ const p = (provider || '').toLowerCase();
1469
+ const s = (serviceName || '').toLowerCase();
1470
+
1471
+ if (p.includes('anthropic') || s.includes('claude')) return 'tool-claude-code';
1472
+ if (s.includes('codex') || p.includes('codex')) return 'tool-codex-cli';
1473
+ if (p.includes('gemini') || s.includes('gemini')) return 'tool-gemini-cli';
1474
+ if (s.includes('aider')) return 'tool-aider';
1475
+
1476
+ return 'tool-proxy';
1477
+ }
1478
+
1479
+ // Initialize analytics filters when DOM is ready
1480
+ if (document.readyState === 'loading') {
1481
+ document.addEventListener('DOMContentLoaded', setupAnalyticsFilters);
1482
+ } else {
1483
+ setupAnalyticsFilters();
1484
+ }