agentflow-dashboard 0.1.4 → 0.3.0

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.
@@ -1,342 +1,1540 @@
1
+ /**
2
+ * AgentFlow Dashboard — Production monitoring UI for AI agent infrastructure.
3
+ * Connects to the Express backend via REST + WebSocket.
4
+ * Handles 860+ traces efficiently with DOM limiting and lazy loading.
5
+ */
6
+
7
+ function escapeHtml(str) {
8
+ if (typeof str !== 'string') return str == null ? '' : String(str);
9
+ return str
10
+ .replace(/&/g, '&')
11
+ .replace(/</g, '&lt;')
12
+ .replace(/>/g, '&gt;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/'/g, '&#39;');
15
+ }
16
+
1
17
  class AgentFlowDashboard {
2
- constructor() {
3
- this.ws = null;
4
- this.reconnectAttempts = 0;
5
- this.maxReconnectAttempts = 10;
6
- this.reconnectDelay = 1000;
7
- this.selectedAgent = null;
8
- this.traces = [];
9
- this.agents = [];
10
- this.stats = null;
11
-
12
- this.init();
13
- }
14
-
15
- init() {
16
- this.connectWebSocket();
17
- this.setupEventListeners();
18
- this.loadInitialData();
19
- }
20
-
21
- connectWebSocket() {
22
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
23
- const wsUrl = `${protocol}//${window.location.host}`;
24
-
25
- this.ws = new WebSocket(wsUrl);
26
-
27
- this.ws.onopen = () => {
28
- console.log('Connected to AgentFlow Dashboard');
29
- this.reconnectAttempts = 0;
30
- this.updateConnectionStatus(true);
31
- };
32
-
33
- this.ws.onmessage = (event) => {
34
- try {
35
- const message = JSON.parse(event.data);
36
- this.handleWebSocketMessage(message);
37
- } catch (error) {
38
- console.error('Error parsing WebSocket message:', error);
39
- }
40
- };
41
-
42
- this.ws.onclose = () => {
43
- console.log('Disconnected from AgentFlow Dashboard');
44
- this.updateConnectionStatus(false);
45
- this.attemptReconnect();
46
- };
47
-
48
- this.ws.onerror = (error) => {
49
- console.error('WebSocket error:', error);
50
- this.updateConnectionStatus(false);
51
- };
52
- }
53
-
54
- attemptReconnect() {
55
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
56
- console.log('Max reconnection attempts reached');
57
- return;
18
+ constructor() {
19
+ this.ws = null;
20
+ this.reconnectAttempts = 0;
21
+ this.maxReconnectAttempts = 20;
22
+ this.reconnectDelay = 1000;
23
+
24
+ this.traces = [];
25
+ this.stats = null;
26
+ this.processHealth = null;
27
+ this.selectedTrace = null;
28
+ this.selectedTraceData = null;
29
+ this.activeTab = 'timeline';
30
+ this.searchFilter = '';
31
+ this.statusFilter = 'all';
32
+ this.timeRangeFilter = 'all';
33
+ this.isLive = true;
34
+
35
+ this.cy = null;
36
+
37
+ this.init();
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Initialization
42
+ // ---------------------------------------------------------------------------
43
+ init() {
44
+ this.connectWebSocket();
45
+ this.loadInitialData();
46
+ this.loadProcessHealth();
47
+ this.setupEventListeners();
48
+ this._healthInterval = setInterval(() => this.loadProcessHealth(), 10000);
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // WebSocket with auto-reconnect + exponential backoff
53
+ // ---------------------------------------------------------------------------
54
+ connectWebSocket() {
55
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
56
+ const wsUrl = protocol + '//' + window.location.host;
57
+
58
+ try {
59
+ this.ws = new WebSocket(wsUrl);
60
+ } catch (e) {
61
+ console.error('WebSocket creation failed:', e);
62
+ this.updateConnectionStatus(false);
63
+ this.attemptReconnect();
64
+ return;
65
+ }
66
+
67
+ this.ws.onopen = () => {
68
+ this.reconnectAttempts = 0;
69
+ this.updateConnectionStatus(true);
70
+ };
71
+
72
+ this.ws.onmessage = (event) => {
73
+ try {
74
+ var message = JSON.parse(event.data);
75
+ this.handleWebSocketMessage(message);
76
+ } catch (e) {
77
+ console.error('WS parse error:', e);
78
+ }
79
+ };
80
+
81
+ this.ws.onclose = () => {
82
+ this.updateConnectionStatus(false);
83
+ this.attemptReconnect();
84
+ };
85
+
86
+ this.ws.onerror = () => {
87
+ this.updateConnectionStatus(false);
88
+ };
89
+ }
90
+
91
+ attemptReconnect() {
92
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
93
+ this.reconnectAttempts++;
94
+ var delay = this.reconnectDelay * Math.min(Math.pow(1.5, this.reconnectAttempts - 1), 30);
95
+ setTimeout(() => this.connectWebSocket(), delay);
96
+ }
97
+
98
+ handleWebSocketMessage(msg) {
99
+ switch (msg.type) {
100
+ case 'init':
101
+ if (msg.data && msg.data.traces) this.traces = msg.data.traces;
102
+ if (msg.data && msg.data.stats) this.stats = msg.data.stats;
103
+ this.renderTraceList();
104
+ this.renderStatsOverview();
105
+ if (this.traces.length > 0 && !this.selectedTrace) {
106
+ this.selectTrace(this.traces[0].filename);
107
+ }
108
+ break;
109
+ case 'trace-added':
110
+ if (this.isLive) {
111
+ this.traces.unshift(msg.data);
112
+ this.renderTraceList();
113
+ this.refreshStats();
114
+ }
115
+ break;
116
+ case 'trace-updated': {
117
+ var idx = this.traces.findIndex(function(t) { return t.filename === msg.data.filename; });
118
+ if (idx >= 0) this.traces[idx] = msg.data;
119
+ this.renderTraceList();
120
+ if (this.selectedTrace && this.selectedTrace.filename === msg.data.filename) {
121
+ this.selectedTrace = msg.data;
122
+ this.selectedTraceData = msg.data;
123
+ this.renderActiveTab();
58
124
  }
125
+ break;
126
+ }
127
+ case 'stats-updated':
128
+ this.stats = msg.data;
129
+ this.renderStatsOverview();
130
+ break;
131
+ }
132
+ }
133
+
134
+ updateConnectionStatus(connected) {
135
+ var dot = document.getElementById('connectionDot');
136
+ var text = document.getElementById('connectionText');
137
+ var liveInd = document.getElementById('liveIndicator');
138
+ if (connected) {
139
+ dot.className = 'status-dot connected';
140
+ text.textContent = 'Connected';
141
+ if (liveInd) liveInd.className = 'live-indicator active';
142
+ } else {
143
+ dot.className = 'status-dot';
144
+ text.textContent = 'Disconnected';
145
+ if (liveInd) liveInd.className = 'live-indicator';
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Data loading
151
+ // ---------------------------------------------------------------------------
152
+ async loadInitialData() {
153
+ try {
154
+ var results = await Promise.all([
155
+ fetch('/api/traces'),
156
+ fetch('/api/stats')
157
+ ]);
158
+ if (results[0].ok) this.traces = await results[0].json();
159
+ if (results[1].ok) this.stats = await results[1].json();
160
+ this.renderTraceList();
161
+ this.renderStatsOverview();
162
+ // Auto-select first trace
163
+ if (this.traces.length > 0 && !this.selectedTrace) {
164
+ this.selectTrace(this.traces[0].filename);
165
+ }
166
+ } catch (e) {
167
+ console.error('Initial data load failed:', e);
168
+ }
169
+ }
170
+
171
+ async refreshStats() {
172
+ try {
173
+ var res = await fetch('/api/stats');
174
+ if (res.ok) {
175
+ this.stats = await res.json();
176
+ this.renderStatsOverview();
177
+ }
178
+ } catch (e) {
179
+ console.error('Stats refresh failed:', e);
180
+ }
181
+ }
182
+
183
+ async loadTraceDetail(filename) {
184
+ try {
185
+ var res = await fetch('/api/traces/' + encodeURIComponent(filename));
186
+ if (res.ok) {
187
+ this.selectedTraceData = await res.json();
188
+ this.renderActiveTab();
189
+ }
190
+ } catch (e) {
191
+ console.error('Trace detail load failed:', e);
192
+ }
193
+ }
194
+
195
+ async loadProcessHealth() {
196
+ try {
197
+ var res = await fetch('/api/process-health');
198
+ if (!res.ok) return;
199
+ this.processHealth = await res.json();
200
+ this.renderProcessHealth();
201
+ } catch (e) {
202
+ // silent — endpoint may not always be available
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Event listeners
208
+ // ---------------------------------------------------------------------------
209
+ setupEventListeners() {
210
+ var self = this;
211
+
212
+ // Tab switching
213
+ document.querySelectorAll('.tab').forEach(function(tab) {
214
+ tab.addEventListener('click', function() {
215
+ self.activeTab = tab.dataset.tab;
216
+ document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
217
+ tab.classList.add('active');
218
+ document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
219
+ document.getElementById('panel-' + self.activeTab).classList.add('active');
220
+ self.renderActiveTab();
221
+ });
222
+ });
223
+
224
+ // Search
225
+ document.getElementById('traceSearch').addEventListener('input', function(e) {
226
+ self.searchFilter = e.target.value.toLowerCase();
227
+ self.renderTraceList();
228
+ });
229
+
230
+ // Status filter dropdown
231
+ document.getElementById('statusFilter').addEventListener('change', function(e) {
232
+ self.statusFilter = e.target.value;
233
+ self.renderTraceList();
234
+ });
235
+
236
+ // Time range filter dropdown
237
+ document.getElementById('timeRangeFilter').addEventListener('change', function(e) {
238
+ self.timeRangeFilter = e.target.value;
239
+ self.renderTraceList();
240
+ });
241
+
242
+ // Toolbar buttons
243
+ document.getElementById('btnFit').addEventListener('click', function() {
244
+ if (self.cy) self.cy.fit(50);
245
+ });
246
+ document.getElementById('btnLayout').addEventListener('click', function() {
247
+ self.runCytoscapeLayout();
248
+ });
249
+ document.getElementById('btnExportPng').addEventListener('click', function() {
250
+ self.exportGraphPNG();
251
+ });
252
+ document.getElementById('btnRefresh').addEventListener('click', function() {
253
+ self.loadInitialData();
254
+ self.loadProcessHealth();
255
+ });
256
+ document.getElementById('btnPlayPause').addEventListener('click', function() {
257
+ self.isLive = !self.isLive;
258
+ var btn = document.getElementById('btnPlayPause');
259
+ btn.innerHTML = self.isLive ? '&#9208;' : '&#9654;';
260
+ btn.title = self.isLive ? 'Pause live tail' : 'Resume live tail';
261
+ var liveInd = document.getElementById('liveIndicator');
262
+ if (self.isLive && self.ws && self.ws.readyState === WebSocket.OPEN) {
263
+ liveInd.className = 'live-indicator active';
264
+ } else {
265
+ liveInd.className = 'live-indicator';
266
+ }
267
+ });
268
+
269
+ // Node detail close
270
+ document.getElementById('nodeDetailClose').addEventListener('click', function() {
271
+ document.getElementById('nodeDetailPanel').classList.remove('active');
272
+ });
273
+
274
+ // Trace list click delegation
275
+ document.getElementById('traceList').addEventListener('click', function(e) {
276
+ var item = e.target.closest('.session-item');
277
+ if (!item) return;
278
+ var filename = item.dataset.filename;
279
+ self.selectTrace(filename);
280
+ });
281
+
282
+ // Auto-refresh stats every 30s
283
+ setInterval(function() {
284
+ if (self.ws && self.ws.readyState === WebSocket.OPEN) {
285
+ self.refreshStats();
286
+ }
287
+ }, 30000);
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Trace selection
292
+ // ---------------------------------------------------------------------------
293
+ selectTrace(filename) {
294
+ var trace = this.traces.find(function(t) { return t.filename === filename; });
295
+ if (!trace) return;
296
+
297
+ this.selectedTrace = trace;
298
+ this.selectedTraceData = trace;
299
+
300
+ // Update sidebar selection
301
+ document.querySelectorAll('.session-item').forEach(function(el) { el.classList.remove('active'); });
302
+ var activeEl = document.querySelector('.session-item[data-filename="' + CSS.escape(filename) + '"]');
303
+ if (activeEl) {
304
+ activeEl.classList.add('active');
305
+ // Scroll into view if needed
306
+ activeEl.scrollIntoView({ block: 'nearest' });
307
+ }
308
+
309
+ // Load full detail
310
+ this.loadTraceDetail(filename);
311
+
312
+ // Render current tab immediately with list data
313
+ this.renderActiveTab();
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Rendering: Stats overview bar
318
+ // ---------------------------------------------------------------------------
319
+ renderStatsOverview() {
320
+ if (!this.stats) return;
321
+ var s = this.stats;
322
+ document.getElementById('statAgents').textContent = s.totalAgents || 0;
323
+ document.getElementById('statExecutions').textContent = (s.totalExecutions || 0).toLocaleString();
324
+ var rate = Math.round((s.globalSuccessRate || 0) * 10) / 10;
325
+ var rateEl = document.getElementById('statSuccessRate');
326
+ rateEl.textContent = rate + '%';
327
+ rateEl.className = 'metric-value ' + (rate >= 90 ? 'success' : rate >= 70 ? 'warning' : 'error');
328
+ document.getElementById('statActive').textContent = s.activeAgents || 0;
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Rendering: Process Health (above metrics, not a tab)
333
+ // ---------------------------------------------------------------------------
334
+ renderProcessHealth() {
335
+ var section = document.getElementById('processHealthSection');
336
+ if (!this.processHealth) {
337
+ section.style.display = 'none';
338
+ return;
339
+ }
340
+
341
+ var r = this.processHealth;
342
+ var hasContent = r.pidFile || r.systemd || r.workers || (r.orphans && r.orphans.length > 0) || (r.problems && r.problems.length > 0);
343
+ if (!hasContent) {
344
+ section.style.display = 'none';
345
+ return;
346
+ }
347
+
348
+ section.style.display = '';
349
+ var html = '<h4>Process Health</h4>';
350
+
351
+ if (r.pidFile) {
352
+ var pf = r.pidFile;
353
+ var cls = pf.alive && pf.matchesProcess ? 'ok' : pf.stale ? 'bad' : 'warn';
354
+ html += '<div class="ph-row">';
355
+ html += '<span class="ph-label">PID File</span>';
356
+ html += '<span class="ph-value ' + cls + '">';
357
+ html += pf.pid ? ('PID ' + pf.pid + (pf.alive ? ' (alive)' : ' (dead)')) : 'No PID';
358
+ html += '</span></div>';
359
+ }
360
+
361
+ if (r.systemd) {
362
+ var sd = r.systemd;
363
+ var sdCls = sd.activeState === 'active' ? 'ok' : sd.failed ? 'bad' : 'warn';
364
+ html += '<div class="ph-row">';
365
+ html += '<span class="ph-label">Systemd</span>';
366
+ html += '<span class="ph-value ' + sdCls + '">';
367
+ html += escapeHtml(sd.unit) + ' \u2014 ' + escapeHtml(sd.activeState) + ' (' + escapeHtml(sd.subState) + ')';
368
+ if (sd.restarts > 0) html += ' [' + sd.restarts + ' restarts]';
369
+ html += '</span></div>';
370
+ }
371
+
372
+ if (r.workers && r.workers.workers) {
373
+ html += '<div class="ph-row">';
374
+ html += '<span class="ph-label">Workers</span>';
375
+ html += '<div class="worker-dots">';
376
+ for (var i = 0; i < r.workers.workers.length; i++) {
377
+ var worker = r.workers.workers[i];
378
+ var dotCls = worker.alive ? 'alive' : worker.stale ? 'stale' : 'unknown';
379
+ html += '<span class="worker-dot ' + dotCls + '" title="' + escapeHtml(worker.name) + ' (pid ' + (worker.pid || '-') + ') \u2014 ' + escapeHtml(worker.declaredStatus) + '"></span>';
380
+ html += '<span class="worker-dot-label">' + escapeHtml(worker.name) + '</span>';
381
+ }
382
+ html += '</div></div>';
383
+ }
384
+
385
+ if (r.orphans && r.orphans.length > 0) {
386
+ html += '<div class="ph-row" style="flex-direction:column;align-items:flex-start;gap:0.3rem;">';
387
+ html += '<span class="ph-label">Orphans (' + r.orphans.length + ')</span>';
388
+ html += '<table class="orphan-table"><thead><tr>';
389
+ html += '<th>PID</th><th>CPU%</th><th>MEM%</th><th>Uptime</th><th>Command</th>';
390
+ html += '</tr></thead><tbody>';
391
+ for (var j = 0; j < r.orphans.length; j++) {
392
+ var o = r.orphans[j];
393
+ html += '<tr>';
394
+ html += '<td>' + o.pid + '</td>';
395
+ html += '<td>' + escapeHtml(o.cpu) + '</td>';
396
+ html += '<td>' + escapeHtml(o.mem) + '</td>';
397
+ html += '<td>' + escapeHtml(o.elapsed) + '</td>';
398
+ html += '<td title="' + escapeHtml(o.cmdline || o.command) + '">' + escapeHtml(o.command) + '</td>';
399
+ html += '</tr>';
400
+ }
401
+ html += '</tbody></table></div>';
402
+ }
403
+
404
+ if (r.problems && r.problems.length > 0) {
405
+ html += '<ul class="problems-list">';
406
+ for (var k = 0; k < r.problems.length; k++) {
407
+ html += '<li>' + escapeHtml(r.problems[k]) + '</li>';
408
+ }
409
+ html += '</ul>';
410
+ }
411
+
412
+ section.innerHTML = html;
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Rendering: Trace list (limit to 100 most recent for perf)
417
+ // ---------------------------------------------------------------------------
418
+ renderTraceList() {
419
+ var container = document.getElementById('traceList');
420
+ var countEl = document.getElementById('traceCount');
421
+ var self = this;
422
+
423
+ var filtered = this.traces;
424
+
425
+ // Search filter
426
+ if (this.searchFilter) {
427
+ var sf = this.searchFilter;
428
+ filtered = filtered.filter(function(t) {
429
+ return (t.agentId || '').toLowerCase().indexOf(sf) >= 0 ||
430
+ (t.name || '').toLowerCase().indexOf(sf) >= 0 ||
431
+ (t.filename || '').toLowerCase().indexOf(sf) >= 0;
432
+ });
433
+ }
434
+
435
+ // Time range filter
436
+ if (this.timeRangeFilter !== 'all') {
437
+ var now = Date.now();
438
+ var cutoff;
439
+ switch (this.timeRangeFilter) {
440
+ case '1h': cutoff = now - 3600000; break;
441
+ case '24h': cutoff = now - 86400000; break;
442
+ case '7d': cutoff = now - 604800000; break;
443
+ default: cutoff = 0;
444
+ }
445
+ filtered = filtered.filter(function(t) {
446
+ var ts = t.timestamp ? new Date(t.timestamp).getTime() : (t.startTime || t.lastModified || 0);
447
+ return ts >= cutoff;
448
+ });
449
+ }
450
+
451
+ // Status filter
452
+ if (this.statusFilter !== 'all') {
453
+ var statusTarget = this.statusFilter;
454
+ filtered = filtered.filter(function(t) {
455
+ return self.getTraceStatus(t) === statusTarget;
456
+ });
457
+ }
59
458
 
60
- this.reconnectAttempts++;
61
- const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
62
-
63
- setTimeout(() => {
64
- console.log(`Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`);
65
- this.connectWebSocket();
66
- }, delay);
67
- }
68
-
69
- handleWebSocketMessage(message) {
70
- switch (message.type) {
71
- case 'init':
72
- this.traces = message.data.traces || [];
73
- this.stats = message.data.stats || null;
74
- this.updateUI();
75
- break;
76
-
77
- case 'trace-added':
78
- this.traces.unshift(message.data);
79
- this.updateTraces();
80
- this.refreshStats();
81
- break;
82
-
83
- case 'trace-updated':
84
- const index = this.traces.findIndex(t => t.filename === message.data.filename);
85
- if (index >= 0) {
86
- this.traces[index] = message.data;
87
- this.updateTraces();
88
- }
89
- break;
90
-
91
- case 'stats-updated':
92
- this.stats = message.data;
93
- this.updateStats();
94
- this.updateAgents();
95
- break;
96
-
97
- default:
98
- console.log('Unknown message type:', message.type);
459
+ countEl.textContent = filtered.length + ' of ' + this.traces.length + ' traces';
460
+
461
+ // Render max 100 items for performance
462
+ var visible = filtered.slice(0, 100);
463
+
464
+ if (visible.length === 0) {
465
+ container.innerHTML = '<div class="empty-state" style="height:120px;"><div class="empty-state-text">No traces match the filter.</div></div>';
466
+ return;
467
+ }
468
+
469
+ var html = '';
470
+ for (var i = 0; i < visible.length; i++) {
471
+ var trace = visible[i];
472
+ var status = this.getTraceStatus(trace);
473
+ var isActive = this.selectedTrace && this.selectedTrace.filename === trace.filename;
474
+ var name = trace.name || trace.agentId || trace.filename;
475
+ var ts = this.formatTimestamp(trace.timestamp || trace.startTime || trace.lastModified);
476
+ var badgeClass = status === 'success' ? 'badge-success' : status === 'failure' ? 'badge-error' : status === 'running' ? 'badge-running' : 'badge-unknown';
477
+ var badgeText = status === 'success' ? 'OK' : status === 'failure' ? 'FAIL' : status === 'running' ? 'LIVE' : '?';
478
+
479
+ // Compute node stats for this trace
480
+ var traceNodes = this.getNodesArray(trace);
481
+ var nodeCount = traceNodes.length;
482
+ var agentCount = 0, toolCount = 0, subagentCount = 0, otherCount = 0;
483
+ for (var j = 0; j < traceNodes.length; j++) {
484
+ var nt = traceNodes[j].type;
485
+ if (nt === 'agent') agentCount++;
486
+ else if (nt === 'tool') toolCount++;
487
+ else if (nt === 'subagent') subagentCount++;
488
+ else otherCount++;
489
+ }
490
+ var traceDuration = this.computeDuration(trace.startTime, traceNodes.length > 0 ? Math.max.apply(null, traceNodes.map(function(n) { return n.endTime ? new Date(n.endTime).getTime() : 0; }).filter(function(v) { return v > 0; })) || null : null);
491
+ var sourceLabel = trace.sourceType === 'session' ? 'session' : 'trace';
492
+
493
+ html += '<div class="session-item' + (isActive ? ' active' : '') + '" data-filename="' + escapeHtml(trace.filename) + '">';
494
+ html += '<div class="session-id" title="' + escapeHtml(trace.filename) + '">' + escapeHtml(name.length > 45 ? name.substring(0, 42) + '...' : name) + '</div>';
495
+ html += '<div class="session-meta">';
496
+ html += '<span class="session-agent">' + escapeHtml(trace.agentId || '') + '</span>';
497
+ html += '<span>' + escapeHtml(ts) + '</span>';
498
+ html += '<span class="badge ' + badgeClass + '">' + badgeText + '</span>';
499
+ html += '</div>';
500
+ // Node type breakdown + duration
501
+ html += '<div class="session-meta" style="margin-top:3px;">';
502
+ html += '<span style="font-size:0.7rem;color:var(--accent-primary);">' + nodeCount + ' nodes</span>';
503
+ if (agentCount > 0) html += '<span class="badge badge-type badge-agent">' + agentCount + ' agent</span>';
504
+ if (toolCount > 0) html += '<span class="badge badge-type badge-tool">' + toolCount + ' tool</span>';
505
+ if (subagentCount > 0) html += '<span class="badge badge-type badge-subagent">' + subagentCount + ' sub</span>';
506
+ if (otherCount > 0) html += '<span class="badge badge-type badge-other">' + otherCount + ' other</span>';
507
+ if (traceDuration !== '--') html += '<span style="font-size:0.7rem;color:var(--text-secondary);">' + escapeHtml(traceDuration) + '</span>';
508
+ if (trace.tokenUsage && trace.tokenUsage.total > 0) {
509
+ html += '<span style="font-size:0.7rem;color:#bc8cff;">' + (trace.tokenUsage.total > 1000 ? Math.round(trace.tokenUsage.total/1000) + 'k' : trace.tokenUsage.total) + ' tok</span>';
510
+ if (trace.tokenUsage.cost > 0) {
511
+ html += '<span style="font-size:0.7rem;color:#f0883e;">$' + trace.tokenUsage.cost.toFixed(4) + '</span>';
99
512
  }
513
+ }
514
+ html += '</div>';
515
+ html += '</div>';
516
+ }
517
+
518
+ container.innerHTML = html;
519
+ }
520
+
521
+ getTraceStatus(trace) {
522
+ if (!trace.nodes) return 'unknown';
523
+ var nodes = this.getNodesArray(trace);
524
+ if (nodes.length === 0) return 'unknown';
525
+ var hasFailed = nodes.some(function(n) { return n.status === 'failed' || (n.metadata && n.metadata.error); });
526
+ if (hasFailed) return 'failure';
527
+ var hasRunning = nodes.some(function(n) { return n.status === 'running'; });
528
+ if (hasRunning) return 'running';
529
+ var hasCompleted = nodes.some(function(n) { return n.status === 'completed' || n.endTime; });
530
+ if (hasCompleted) return 'success';
531
+ return 'unknown';
532
+ }
533
+
534
+ getNodesArray(trace) {
535
+ if (!trace.nodes) return [];
536
+ if (Array.isArray(trace.nodes)) {
537
+ return trace.nodes.map(function(entry) { return Array.isArray(entry) ? entry[1] : entry; });
100
538
  }
539
+ if (trace.nodes instanceof Map) return Array.from(trace.nodes.values());
540
+ return Object.values(trace.nodes);
541
+ }
542
+
543
+ formatTimestamp(ts) {
544
+ if (!ts) return '';
545
+ var d = new Date(ts);
546
+ if (isNaN(d.getTime())) return String(ts);
547
+ var now = new Date();
548
+ var diffMs = now - d;
549
+ if (diffMs < 60000) return 'just now';
550
+ if (diffMs < 3600000) return Math.floor(diffMs / 60000) + 'm ago';
551
+ if (diffMs < 86400000) return Math.floor(diffMs / 3600000) + 'h ago';
552
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
553
+ }
101
554
 
102
- async loadInitialData() {
103
- try {
104
- const [tracesRes, statsRes] = await Promise.all([
105
- fetch('/api/traces'),
106
- fetch('/api/stats')
107
- ]);
555
+ computeDuration(startTime, endTime) {
556
+ if (!startTime || !endTime) return '--';
557
+ var ms = new Date(endTime).getTime() - new Date(startTime).getTime();
558
+ if (isNaN(ms) || ms < 0) return '--';
559
+ if (ms < 1000) return ms + 'ms';
560
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
561
+ return (ms / 60000).toFixed(1) + 'm';
562
+ }
108
563
 
109
- if (tracesRes.ok) {
110
- this.traces = await tracesRes.json();
111
- }
564
+ formatDuration(ms) {
565
+ if (!ms || ms <= 0) return '--';
566
+ if (ms < 1000) return Math.round(ms) + 'ms';
567
+ if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
568
+ return (ms / 60000).toFixed(1) + 'm';
569
+ }
112
570
 
113
- if (statsRes.ok) {
114
- this.stats = await statsRes.json();
115
- }
571
+ // ---------------------------------------------------------------------------
572
+ // Render active tab
573
+ // ---------------------------------------------------------------------------
574
+ renderActiveTab() {
575
+ switch (this.activeTab) {
576
+ case 'timeline': this.renderTimeline(); break;
577
+ case 'metrics': this.renderMetrics(); break;
578
+ case 'graph': this.renderGraph(); break;
579
+ case 'heatmap': this.renderHeatmap(); break;
580
+ case 'state': this.renderStateMachine(); break;
581
+ case 'summary': this.renderSummary(); break;
582
+ case 'transcript': this.renderTranscript(); break;
583
+ }
584
+ this.updateToolbarInfo();
585
+ }
586
+
587
+ updateToolbarInfo() {
588
+ var info = document.getElementById('toolbarInfo');
589
+ var trace = this.selectedTraceData || this.selectedTrace;
590
+ if (!trace) {
591
+ info.textContent = '';
592
+ return;
593
+ }
594
+ var nodes = this.getNodesArray(trace);
595
+ info.textContent = nodes.length + ' nodes' + (trace.agentId ? ' | ' + trace.agentId : '');
596
+ }
116
597
 
117
- this.updateUI();
118
- } catch (error) {
119
- console.error('Error loading initial data:', error);
598
+ // ---------------------------------------------------------------------------
599
+ // Tab 1: Timeline
600
+ // ---------------------------------------------------------------------------
601
+ renderTimeline() {
602
+ var container = document.getElementById('timelineContent');
603
+ var trace = this.selectedTraceData || this.selectedTrace;
604
+ if (!trace || !trace.nodes) {
605
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">&#9776;</div><div class="empty-state-title">Select a trace</div><div class="empty-state-text">Choose a trace from the sidebar to view its execution timeline.</div></div>';
606
+ return;
607
+ }
608
+
609
+ // For session traces, render rich session timeline if available
610
+ if (trace.sourceType === 'session') {
611
+ this.renderSessionTimeline(trace, container);
612
+ return;
613
+ }
614
+
615
+ var nodes = this.getNodesArray(trace);
616
+ if (nodes.length === 0) {
617
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">No nodes in this trace.</div></div>';
618
+ return;
619
+ }
620
+
621
+ // Build depth map for tree indentation
622
+ var nodeMap = {};
623
+ for (var j = 0; j < nodes.length; j++) {
624
+ if (nodes[j].id) nodeMap[nodes[j].id] = nodes[j];
625
+ }
626
+ var depthCache = {};
627
+ var getDepth = function(nid, visited) {
628
+ if (!nid || (visited && visited.has(nid))) return 0;
629
+ if (depthCache[nid] !== undefined) return depthCache[nid];
630
+ var nd = nodeMap[nid];
631
+ if (!nd || !nd.parentId) { depthCache[nid] = 0; return 0; }
632
+ var vis = visited || new Set();
633
+ vis.add(nid);
634
+ depthCache[nid] = 1 + getDepth(nd.parentId, vis);
635
+ return depthCache[nid];
636
+ };
637
+ for (var k = 0; k < nodes.length; k++) getDepth(nodes[k].id);
638
+
639
+ // Compute timeline range for duration bars
640
+ var allStarts = nodes.map(function(n) { return n.startTime ? new Date(n.startTime).getTime() : Infinity; }).filter(function(v) { return isFinite(v); });
641
+ var allEnds = nodes.map(function(n) { return n.endTime ? new Date(n.endTime).getTime() : 0; }).filter(function(v) { return v > 0; });
642
+ var timelineStart = allStarts.length > 0 ? Math.min.apply(null, allStarts) : 0;
643
+ var timelineEnd = allEnds.length > 0 ? Math.max.apply(null, allEnds) : 0;
644
+ var timelineSpan = timelineEnd - timelineStart || 1;
645
+
646
+ // Sort by startTime then depth
647
+ var sorted = nodes.slice().sort(function(a, b) {
648
+ var sa = a.startTime ? new Date(a.startTime).getTime() : Infinity;
649
+ var sb = b.startTime ? new Date(b.startTime).getTime() : Infinity;
650
+ if (sa !== sb) return sa - sb;
651
+ return (depthCache[a.id] || 0) - (depthCache[b.id] || 0);
652
+ });
653
+
654
+ // Type icons
655
+ var typeIcons = { agent: '\ud83e\udd16', tool: '\ud83d\udee0\ufe0f', subagent: '\ud83d\udc64', wait: '\u23f3', decision: '\ud83d\udd00', custom: '\u2b50', exec: '\u25b6\ufe0f' };
656
+ var statusIcons = { completed: '\u2705', failed: '\u274c', running: '\ud83d\udfe2', hung: '\u26a0\ufe0f', timeout: '\u23f0' };
657
+
658
+ var html = '';
659
+ // Summary header
660
+ html += '<div style="display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap;">';
661
+ var typeCounts = {};
662
+ for (var m = 0; m < sorted.length; m++) { var tt = sorted[m].type || 'unknown'; typeCounts[tt] = (typeCounts[tt] || 0) + 1; }
663
+ html += '<span style="font-size:0.85rem;color:var(--text-secondary);">' + sorted.length + ' nodes</span>';
664
+ var typeEntries = Object.entries(typeCounts);
665
+ for (var p = 0; p < typeEntries.length; p++) {
666
+ var tIcon = typeIcons[typeEntries[p][0]] || '\u25cf';
667
+ html += '<span class="badge badge-type badge-' + escapeHtml(typeEntries[p][0]) + '">' + tIcon + ' ' + typeEntries[p][1] + ' ' + escapeHtml(typeEntries[p][0]) + '</span>';
668
+ }
669
+ if (timelineSpan > 1) {
670
+ html += '<span style="font-size:0.85rem;color:var(--text-secondary);">Total: ' + this.formatDuration(timelineSpan) + '</span>';
671
+ }
672
+ html += '</div>';
673
+
674
+ for (var i = 0; i < sorted.length; i++) {
675
+ var n = sorted[i];
676
+ var depth = depthCache[n.id] || 0;
677
+ var markerClass = n.status === 'failed' ? 'failed' :
678
+ n.status === 'completed' ? 'completed' :
679
+ n.status === 'running' ? 'running' :
680
+ n.status === 'hung' || n.status === 'timeout' ? 'hung' :
681
+ n.type === 'agent' ? 'agent' :
682
+ n.type === 'tool' ? 'tool' :
683
+ n.type === 'subagent' ? 'subagent' : 'agent';
684
+
685
+ var typeIcon = typeIcons[n.type] || '\u25cf';
686
+ var statusIcon = statusIcons[n.status] || '';
687
+ var eventName = escapeHtml(n.name || n.id || 'unnamed');
688
+ var eventTs = n.startTime ? new Date(n.startTime).toLocaleTimeString() : '--';
689
+ var dur = this.computeDuration(n.startTime, n.endTime);
690
+ var durMs = (n.startTime && n.endTime) ? new Date(n.endTime).getTime() - new Date(n.startTime).getTime() : 0;
691
+
692
+ // Duration bar width proportional to timeline
693
+ var barLeft = 0, barWidth = 0;
694
+ if (n.startTime && timelineSpan > 1) {
695
+ barLeft = ((new Date(n.startTime).getTime() - timelineStart) / timelineSpan) * 100;
696
+ barWidth = Math.max(1, (durMs / timelineSpan) * 100);
697
+ }
698
+
699
+ var details = '';
700
+ if (n.metadata) {
701
+ var showKeys = Object.keys(n.metadata).filter(function(k) {
702
+ return k !== 'error' && typeof n.metadata[k] !== 'object';
703
+ });
704
+ if (showKeys.length > 0) {
705
+ details = showKeys.slice(0, 4).map(function(k) {
706
+ return escapeHtml(k) + ': ' + escapeHtml(String(n.metadata[k]).substring(0, 50));
707
+ }).join(' \u00b7 ');
120
708
  }
709
+ }
710
+
711
+ var indent = depth * 24;
712
+ html += '<div class="timeline-item" style="margin-left:' + indent + 'px;">';
713
+ html += '<div class="timeline-marker ' + markerClass + '"></div>';
714
+ html += '<div class="timeline-content">';
715
+ html += '<div class="timeline-header">';
716
+ html += '<span class="event-type">' + typeIcon + ' <span class="badge badge-type badge-' + escapeHtml(n.type || 'unknown') + '" style="font-size:0.7rem;">' + escapeHtml(n.type || 'node') + '</span> ' + eventName + ' ' + statusIcon + '</span>';
717
+ html += '<span class="event-time">' + eventTs;
718
+ if (dur !== '--') html += ' \u00b7 <strong>' + escapeHtml(dur) + '</strong>';
719
+ html += '</span></div>';
720
+ // Duration bar
721
+ if (barWidth > 0) {
722
+ var barColor = n.status === 'failed' ? 'var(--accent-error)' : n.status === 'completed' ? 'var(--accent-success)' : n.status === 'running' ? 'var(--accent-primary)' : 'var(--accent-warning)';
723
+ html += '<div style="position:relative;height:6px;background:var(--bg-tertiary);border-radius:3px;margin:4px 0;">';
724
+ html += '<div style="position:absolute;left:' + barLeft.toFixed(1) + '%;width:' + barWidth.toFixed(1) + '%;height:100%;background:' + barColor + ';border-radius:3px;"></div>';
725
+ html += '</div>';
726
+ }
727
+ if (details) {
728
+ html += '<div class="event-details">' + details + '</div>';
729
+ }
730
+ if (n.metadata && n.metadata.error) {
731
+ html += '<div class="event-details" style="color:var(--accent-error);">\u274c ' + escapeHtml(String(n.metadata.error).substring(0, 120)) + '</div>';
732
+ }
733
+ html += '</div></div>';
121
734
  }
122
735
 
123
- async refreshStats() {
124
- try {
125
- const response = await fetch('/api/stats');
126
- if (response.ok) {
127
- this.stats = await response.json();
128
- this.updateStats();
129
- this.updateAgents();
130
- }
131
- } catch (error) {
132
- console.error('Error refreshing stats:', error);
736
+ container.innerHTML = html;
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Tab 2: Metrics
741
+ // ---------------------------------------------------------------------------
742
+ renderMetrics() {
743
+ var container = document.getElementById('metricsContent');
744
+ var trace = this.selectedTraceData || this.selectedTrace;
745
+ if (!trace || !trace.nodes) {
746
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a trace to view metrics.</div></div>';
747
+ return;
748
+ }
749
+
750
+ var nodes = this.getNodesArray(trace);
751
+ var totalNodes = nodes.length;
752
+ var completedNodes = nodes.filter(function(n) { return n.status === 'completed'; }).length;
753
+ var failedNodes = nodes.filter(function(n) { return n.status === 'failed'; }).length;
754
+ var runningNodes = nodes.filter(function(n) { return n.status === 'running'; }).length;
755
+ var hungNodes = nodes.filter(function(n) { return n.status === 'hung' || n.status === 'timeout'; }).length;
756
+ var successRate = totalNodes > 0 ? Math.round(completedNodes / totalNodes * 1000) / 10 : 0;
757
+
758
+ // Compute average and max duration
759
+ var totalDur = 0, durCount = 0, maxDur = 0;
760
+ for (var i = 0; i < nodes.length; i++) {
761
+ var n = nodes[i];
762
+ if (n.startTime && n.endTime) {
763
+ var ms = new Date(n.endTime).getTime() - new Date(n.startTime).getTime();
764
+ if (!isNaN(ms) && ms >= 0) {
765
+ totalDur += ms;
766
+ durCount++;
767
+ if (ms > maxDur) maxDur = ms;
133
768
  }
769
+ }
770
+ }
771
+ var avgDur = durCount > 0 ? totalDur / durCount : 0;
772
+
773
+ // Compute max depth
774
+ var nodeMap = {};
775
+ for (var j = 0; j < nodes.length; j++) {
776
+ if (nodes[j].id) nodeMap[nodes[j].id] = nodes[j];
777
+ }
778
+ var maxDepth = 0;
779
+ var depthOf = function(nid, visited) {
780
+ if (!nid || visited.has(nid)) return 0;
781
+ visited.add(nid);
782
+ var nd = nodeMap[nid];
783
+ if (!nd || !nd.parentId) return 0;
784
+ return 1 + depthOf(nd.parentId, visited);
785
+ };
786
+ for (var k = 0; k < nodes.length; k++) {
787
+ maxDepth = Math.max(maxDepth, depthOf(nodes[k].id, new Set()));
788
+ }
789
+
790
+ // Type breakdown
791
+ var typeCounts = {};
792
+ for (var m = 0; m < nodes.length; m++) {
793
+ var t = nodes[m].type || 'unknown';
794
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
795
+ }
796
+
797
+ var html = '<div class="metrics-grid">';
798
+ html += this.metricCard('Total Nodes', totalNodes, 'primary');
799
+ html += this.metricCard('Success Rate', successRate + '%', successRate >= 90 ? 'success' : successRate >= 70 ? 'warning' : 'error');
800
+ html += this.metricCard('Avg Duration', this.formatDuration(avgDur), 'primary', durCount > 0 ? 'across ' + durCount + ' nodes' : 'no timing data');
801
+ html += this.metricCard('Max Duration', this.formatDuration(maxDur), 'primary', 'tool execution time');
802
+ html += this.metricCard('Max Depth', maxDepth, 'primary');
803
+ html += this.metricCard('Failures', failedNodes, failedNodes > 0 ? 'error' : 'success');
804
+ html += this.metricCard('Running/Active', runningNodes, runningNodes > 0 ? 'warning' : 'primary');
805
+ html += this.metricCard('Completed', completedNodes, 'success');
806
+ html += '</div>';
807
+
808
+ // Token/cost metrics for session traces
809
+ if (trace.tokenUsage && trace.tokenUsage.total > 0) {
810
+ html += '<h4 style="margin:1.5rem 0 0.75rem;font-size:0.85rem;color:var(--text-secondary);">Token Usage</h4>';
811
+ html += '<div class="metrics-grid">';
812
+ html += this.metricCard('Total Tokens', trace.tokenUsage.total > 1000 ? Math.round(trace.tokenUsage.total / 1000) + 'k' : trace.tokenUsage.total, 'primary');
813
+ html += this.metricCard('Input Tokens', trace.tokenUsage.input > 1000 ? Math.round(trace.tokenUsage.input / 1000) + 'k' : trace.tokenUsage.input, 'primary');
814
+ html += this.metricCard('Output Tokens', trace.tokenUsage.output > 1000 ? Math.round(trace.tokenUsage.output / 1000) + 'k' : trace.tokenUsage.output, 'primary');
815
+ html += this.metricCard('Estimated Cost', trace.tokenUsage.cost > 0 ? '$' + trace.tokenUsage.cost.toFixed(4) : '$0', trace.tokenUsage.cost > 0.10 ? 'warning' : 'success');
816
+ if (totalNodes > 0) html += this.metricCard('Tokens/Node', Math.round(trace.tokenUsage.total / totalNodes), 'primary');
817
+ var modelName = (trace.metadata && trace.metadata.model) || '';
818
+ if (modelName) html += this.metricCard('Model', modelName.length > 20 ? modelName.slice(0, 18) + '..' : modelName, 'primary', (trace.metadata && trace.metadata.provider) || '');
819
+ html += '</div>';
820
+ }
821
+
822
+ // Type breakdown
823
+ html += '<h4 style="margin:1.5rem 0 0.75rem;font-size:0.85rem;color:var(--text-secondary);">Node Type Breakdown</h4>';
824
+ html += '<div class="metrics-grid">';
825
+ var typeEntries = Object.entries(typeCounts).sort(function(a, b) { return b[1] - a[1]; });
826
+ for (var p = 0; p < typeEntries.length; p++) {
827
+ html += this.metricCard(typeEntries[p][0], typeEntries[p][1], 'primary');
828
+ }
829
+ html += '</div>';
830
+
831
+ container.innerHTML = html;
832
+ }
833
+
834
+ metricCard(label, value, colorClass, sub) {
835
+ var html = '<div class="metric-card"><div class="metric-label">' + escapeHtml(label) + '</div><div class="metric-value ' + colorClass + '">' + escapeHtml(String(value)) + '</div>';
836
+ if (sub) html += '<div class="metric-sub">' + escapeHtml(sub) + '</div>';
837
+ html += '</div>';
838
+ return html;
839
+ }
840
+
841
+ // ---------------------------------------------------------------------------
842
+ // Tab 3: Dependency Graph (Cytoscape.js)
843
+ // ---------------------------------------------------------------------------
844
+ renderGraph() {
845
+ var trace = this.selectedTraceData || this.selectedTrace;
846
+ if (!trace || !trace.nodes) {
847
+ document.getElementById('graphEmpty').style.display = '';
848
+ if (this.cy) { this.cy.destroy(); this.cy = null; }
849
+ return;
134
850
  }
135
851
 
136
- updateUI() {
137
- this.updateStats();
138
- this.updateAgents();
139
- this.updateTraces();
852
+ document.getElementById('graphEmpty').style.display = 'none';
853
+
854
+ var nodes = this.getNodesArray(trace);
855
+ if (nodes.length === 0) {
856
+ document.getElementById('graphEmpty').style.display = '';
857
+ return;
858
+ }
859
+
860
+ // Build cytoscape elements
861
+ var elements = [];
862
+ var nodeIds = new Set();
863
+
864
+ // Collect valid IDs
865
+ if (typeof trace.nodes === 'object' && !Array.isArray(trace.nodes)) {
866
+ Object.keys(trace.nodes).forEach(function(key) { nodeIds.add(key); });
140
867
  }
868
+ nodes.forEach(function(n) { if (n.id) nodeIds.add(n.id); });
141
869
 
142
- updateConnectionStatus(connected) {
143
- const statusEl = document.getElementById('connectionStatus');
144
- if (connected) {
145
- statusEl.textContent = 'Connected';
146
- statusEl.className = 'connection-status connected';
147
- } else {
148
- statusEl.textContent = 'Disconnected';
149
- statusEl.className = 'connection-status disconnected';
870
+ // Add nodes
871
+ for (var i = 0; i < nodes.length; i++) {
872
+ var node = nodes[i];
873
+ var id = node.id || ('n-' + i);
874
+ elements.push({
875
+ group: 'nodes',
876
+ data: {
877
+ id: id,
878
+ label: node.name || node.type || id,
879
+ status: node.status || 'unknown',
880
+ nodeType: node.type || 'custom',
881
+ fullData: node
150
882
  }
883
+ });
151
884
  }
152
885
 
153
- updateStats() {
154
- const statsGrid = document.getElementById('statsGrid');
886
+ // Build edges from parentId relationships
887
+ for (var j = 0; j < nodes.length; j++) {
888
+ var n = nodes[j];
889
+ if (n.parentId && nodeIds.has(n.parentId) && n.id) {
890
+ elements.push({
891
+ group: 'edges',
892
+ data: {
893
+ source: n.parentId,
894
+ target: n.id,
895
+ id: 'e-' + n.parentId + '-' + n.id
896
+ }
897
+ });
898
+ }
899
+ }
155
900
 
156
- if (!this.stats) {
157
- statsGrid.innerHTML = '<div class="loading">Loading statistics...</div>';
158
- return;
901
+ // Also add explicit trace edges if present
902
+ if (trace.edges && Array.isArray(trace.edges)) {
903
+ for (var k = 0; k < trace.edges.length; k++) {
904
+ var edge = trace.edges[k];
905
+ var src = edge.source || edge.from;
906
+ var tgt = edge.target || edge.to;
907
+ if (src && tgt && nodeIds.has(src) && nodeIds.has(tgt)) {
908
+ var eid = 'e-' + src + '-' + tgt;
909
+ if (!elements.some(function(el) { return el.data && el.data.id === eid; })) {
910
+ elements.push({
911
+ group: 'edges',
912
+ data: { source: src, target: tgt, id: eid, edgeType: edge.type || '' }
913
+ });
914
+ }
159
915
  }
916
+ }
917
+ }
918
+
919
+ // Destroy previous instance
920
+ if (this.cy) { this.cy.destroy(); this.cy = null; }
160
921
 
161
- const successRate = Math.round(this.stats.globalSuccessRate * 10) / 10;
162
-
163
- statsGrid.innerHTML = `
164
- <div class="stat-card">
165
- <h3>Total Agents</h3>
166
- <div class="value">${this.stats.totalAgents}</div>
167
- </div>
168
- <div class="stat-card">
169
- <h3>Total Executions</h3>
170
- <div class="value">${this.stats.totalExecutions.toLocaleString()}</div>
171
- </div>
172
- <div class="stat-card">
173
- <h3>Success Rate</h3>
174
- <div class="value">${successRate}%</div>
175
- </div>
176
- <div class="stat-card">
177
- <h3>Active Agents</h3>
178
- <div class="value">${this.stats.activeAgents}</div>
179
- </div>
180
- `;
181
- }
182
-
183
- updateAgents() {
184
- const agentList = document.getElementById('agentList');
185
-
186
- if (!this.stats || !this.stats.topAgents) {
187
- agentList.innerHTML = '<div class="loading">Loading agents...</div>';
188
- return;
922
+ var cyContainer = document.getElementById('cy');
923
+
924
+ this.cy = cytoscape({
925
+ container: cyContainer,
926
+ elements: elements,
927
+ style: [
928
+ {
929
+ selector: 'node',
930
+ style: {
931
+ 'label': 'data(label)',
932
+ 'width': 45,
933
+ 'height': 45,
934
+ 'font-size': '10px',
935
+ 'text-valign': 'bottom',
936
+ 'text-halign': 'center',
937
+ 'text-margin-y': 6,
938
+ 'color': '#c9d1d9',
939
+ 'text-outline-color': '#0d1117',
940
+ 'text-outline-width': 2,
941
+ 'border-width': 2,
942
+ 'border-color': '#30363d',
943
+ 'background-color': '#3b82f6'
944
+ }
945
+ },
946
+ { selector: 'node[status="completed"]', style: { 'background-color': '#10b981', 'border-color': '#2ea043' } },
947
+ { selector: 'node[status="failed"]', style: { 'background-color': '#ef4444', 'border-color': '#f85149', 'shape': 'diamond' } },
948
+ { selector: 'node[status="running"]', style: { 'background-color': '#3b82f6', 'border-color': '#79b8ff' } },
949
+ { selector: 'node[status="hung"]', style: { 'background-color': '#f0883e', 'border-color': '#f5a623' } },
950
+ { selector: 'node[status="timeout"]', style: { 'background-color': '#f0883e', 'border-color': '#f5a623' } },
951
+ // Shape by type
952
+ { selector: 'node[nodeType="agent"]', style: { 'shape': 'ellipse', 'width': 50, 'height': 50 } },
953
+ { selector: 'node[nodeType="tool"]', style: { 'shape': 'round-rectangle', 'width': 50, 'height': 35 } },
954
+ { selector: 'node[nodeType="subagent"]', style: { 'shape': 'ellipse', 'width': 38, 'height': 38 } },
955
+ { selector: 'node[nodeType="wait"]', style: { 'shape': 'round-rectangle', 'width': 40, 'height': 30 } },
956
+ { selector: 'node[nodeType="decision"]', style: { 'shape': 'diamond', 'width': 45, 'height': 45 } },
957
+ { selector: 'node[nodeType="custom"]', style: { 'shape': 'diamond', 'width': 40, 'height': 40 } },
958
+ // Selected node — gold border
959
+ { selector: ':selected', style: { 'border-width': 4, 'border-color': '#f59e0b', 'overlay-opacity': 0.08 } },
960
+ // Edges
961
+ {
962
+ selector: 'edge',
963
+ style: {
964
+ 'width': 2,
965
+ 'line-color': '#6b7280',
966
+ 'target-arrow-color': '#6b7280',
967
+ 'target-arrow-shape': 'triangle',
968
+ 'curve-style': 'bezier',
969
+ 'arrow-scale': 0.8
970
+ }
971
+ },
972
+ // Dashed edges for specific types
973
+ {
974
+ selector: 'edge[edgeType]',
975
+ style: {
976
+ 'line-style': 'dashed',
977
+ 'line-color': '#f0883e',
978
+ 'target-arrow-color': '#f0883e'
979
+ }
189
980
  }
981
+ ],
982
+ layout: { name: 'breadthfirst', directed: true, padding: 40, spacingFactor: 1.4, animate: true, animationDuration: 300 },
983
+ minZoom: 0.2,
984
+ maxZoom: 4,
985
+ wheelSensitivity: 0.3
986
+ });
987
+
988
+ var self = this;
989
+
990
+ // Node tap -> detail panel
991
+ this.cy.on('tap', 'node', function(e) {
992
+ var data = e.target.data();
993
+ self.showNodeDetail(data.fullData);
994
+ });
190
995
 
191
- const agentItems = this.stats.topAgents.map(agent => {
192
- const successRate = Math.round(agent.successRate * 10) / 10;
193
- let rateClass = 'success-rate';
194
- if (successRate < 50) rateClass += ' critical';
195
- else if (successRate < 80) rateClass += ' low';
996
+ // Background tap -> close panel
997
+ this.cy.on('tap', function(e) {
998
+ if (e.target === self.cy) {
999
+ document.getElementById('nodeDetailPanel').classList.remove('active');
1000
+ }
1001
+ });
1002
+ }
196
1003
 
197
- return `
198
- <div class="agent-item" data-agent-id="${agent.agentId}">
199
- <div class="agent-name">${agent.agentId}</div>
200
- <div class="agent-stats">
201
- <span>${agent.executionCount} executions</span>
202
- <span class="${rateClass}">${successRate}%</span>
203
- </div>
204
- </div>
205
- `;
206
- }).join('');
1004
+ runCytoscapeLayout() {
1005
+ if (!this.cy) return;
1006
+ this.cy.layout({
1007
+ name: 'breadthfirst',
1008
+ directed: true,
1009
+ padding: 40,
1010
+ spacingFactor: 1.4,
1011
+ animate: true,
1012
+ animationDuration: 400
1013
+ }).run();
1014
+ }
207
1015
 
208
- agentList.innerHTML = agentItems;
1016
+ showNodeDetail(node) {
1017
+ var panel = document.getElementById('nodeDetailPanel');
1018
+ var body = document.getElementById('nodeDetailBody');
1019
+ var title = document.getElementById('nodeDetailTitle');
1020
+
1021
+ title.textContent = node.name || node.id || 'Node';
1022
+
1023
+ var duration = this.computeDuration(node.startTime, node.endTime);
1024
+
1025
+ var html = '';
1026
+ html += this.detailRow('ID', node.id);
1027
+ html += this.detailRow('Type', node.type);
1028
+ html += '<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value status-' + escapeHtml(node.status || '') + '">' + escapeHtml(node.status || 'unknown') + '</span></div>';
1029
+ html += this.detailRow('Duration', duration);
1030
+ if (node.startTime) html += this.detailRow('Start', new Date(node.startTime).toLocaleString());
1031
+ if (node.endTime) html += this.detailRow('End', new Date(node.endTime).toLocaleString());
1032
+ if (node.parentId) html += this.detailRow('Parent', node.parentId);
1033
+ if (node.children && node.children.length) html += this.detailRow('Children', node.children.length);
1034
+
1035
+ if (node.metadata && Object.keys(node.metadata).length > 0) {
1036
+ html += '<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.3px;">Metadata</div>';
1037
+ html += '<div class="detail-metadata">' + escapeHtml(JSON.stringify(node.metadata, null, 2)) + '</div>';
209
1038
  }
210
1039
 
211
- updateTraces() {
212
- const tracesList = document.getElementById('tracesList');
1040
+ body.innerHTML = html;
1041
+ panel.classList.add('active');
1042
+ }
213
1043
 
214
- if (!this.traces || this.traces.length === 0) {
215
- tracesList.innerHTML = '<div class="loading">No traces available</div>';
216
- return;
217
- }
1044
+ detailRow(label, value) {
1045
+ if (value === undefined || value === null || value === '') return '';
1046
+ return '<div class="detail-row"><span class="detail-label">' + escapeHtml(label) + '</span><span class="detail-value">' + escapeHtml(String(value)) + '</span></div>';
1047
+ }
218
1048
 
219
- // Filter traces if an agent is selected
220
- const filteredTraces = this.selectedAgent ?
221
- this.traces.filter(trace => trace.agentId === this.selectedAgent) :
222
- this.traces;
223
-
224
- const traceItems = filteredTraces.slice(0, 50).map(trace => {
225
- const timestamp = new Date(trace.timestamp).toLocaleString();
226
- const statusClass = this.getTraceStatusClass(trace);
227
-
228
- return `
229
- <div class="trace-item">
230
- <div class="trace-header">
231
- <div class="trace-name">
232
- <span class="status-indicator ${statusClass}"></span>
233
- ${trace.name || `${trace.agentId} execution`}
234
- </div>
235
- <div class="trace-timestamp">${timestamp}</div>
236
- </div>
237
- <div class="trace-details">
238
- <div class="trace-agent">${trace.agentId}</div>
239
- <div class="trace-trigger">${trace.trigger}</div>
240
- </div>
241
- </div>
242
- `;
243
- }).join('');
244
-
245
- tracesList.innerHTML = traceItems || '<div class="loading">No traces found</div>';
246
- }
247
-
248
- getTraceStatusClass(trace) {
249
- // Try to determine status from the trace structure
250
- if (trace.nodes) {
251
- const nodes = Array.isArray(trace.nodes) ?
252
- trace.nodes.map(([, node]) => node) :
253
- trace.nodes instanceof Map ?
254
- Array.from(trace.nodes.values()) :
255
- Object.values(trace.nodes);
256
-
257
- const hasFailures = nodes.some(node =>
258
- node.status === 'failed' ||
259
- node.error ||
260
- (node.metadata && node.metadata.error)
261
- );
262
-
263
- if (hasFailures) return 'status-failure';
264
-
265
- const hasCompleted = nodes.some(node =>
266
- node.status === 'completed' ||
267
- node.endTime ||
268
- (node.metadata && node.metadata.status === 'success')
269
- );
270
-
271
- if (hasCompleted) return 'status-success';
272
- }
1049
+ exportGraphPNG() {
1050
+ if (!this.cy) return;
1051
+ var png = this.cy.png({ bg: '#0d1117', full: true, maxWidth: 4000, maxHeight: 4000 });
1052
+ var link = document.createElement('a');
1053
+ var traceName = this.selectedTrace ? this.selectedTrace.filename.replace(/\.json$/, '') : 'graph';
1054
+ link.download = 'agentflow-' + traceName + '.png';
1055
+ link.href = png;
1056
+ link.click();
1057
+ }
1058
+
1059
+ // ---------------------------------------------------------------------------
1060
+ // Tab 4: Error Heatmap
1061
+ // ---------------------------------------------------------------------------
1062
+ renderHeatmap() {
1063
+ var container = document.getElementById('heatmapContent');
1064
+ var trace = this.selectedTraceData || this.selectedTrace;
273
1065
 
274
- return 'status-unknown';
1066
+ // Build heatmap from recent traces (not just selected trace)
1067
+ var tracesToUse = this.traces.slice(0, 100);
1068
+ if (tracesToUse.length === 0) {
1069
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">No traces available for heatmap.</div></div>';
1070
+ return;
275
1071
  }
276
1072
 
277
- setupEventListeners() {
278
- // Agent selection
279
- document.addEventListener('click', (event) => {
280
- const agentItem = event.target.closest('.agent-item');
281
- if (agentItem) {
282
- const agentId = agentItem.dataset.agentId;
283
- this.selectAgent(agentId);
284
- }
285
- });
1073
+ var html = '<h3 class="heatmap-header">Error Distribution Across Recent Traces</h3>';
1074
+ html += '<div class="heatmap-grid">';
1075
+
1076
+ for (var i = 0; i < Math.min(tracesToUse.length, 100); i++) {
1077
+ var tr = tracesToUse[i];
1078
+ var nodes = this.getNodesArray(tr);
1079
+ var failCount = 0;
1080
+ var warnCount = 0;
1081
+ for (var j = 0; j < nodes.length; j++) {
1082
+ if (nodes[j].status === 'failed') failCount++;
1083
+ if (nodes[j].status === 'hung' || nodes[j].status === 'timeout') warnCount++;
1084
+ }
1085
+
1086
+ var color;
1087
+ if (failCount > 2) color = 'rgba(218, 54, 51, 0.9)';
1088
+ else if (failCount > 0) color = 'rgba(218, 54, 51, 0.5)';
1089
+ else if (warnCount > 0) color = 'rgba(240, 136, 62, 0.5)';
1090
+ else color = 'rgba(35, 134, 54, 0.3)';
1091
+
1092
+ var cellLabel = failCount > 0 ? failCount : '';
1093
+ var agentName = escapeHtml(tr.agentId || tr.name || 'unknown');
1094
+ var tooltipText = escapeHtml((tr.name || tr.filename || '').substring(0, 30)) + ' | ' + agentName + ' | ' + failCount + ' errors, ' + warnCount + ' warnings';
286
1095
 
287
- // Auto-refresh every 30 seconds
288
- setInterval(() => {
289
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
290
- this.refreshStats();
291
- }
292
- }, 30000);
1096
+ html += '<div class="heatmap-cell" style="background:' + color + ';" title="' + tooltipText + '">';
1097
+ html += cellLabel;
1098
+ html += '<div class="heatmap-tooltip">' + tooltipText + '</div>';
1099
+ html += '</div>';
293
1100
  }
294
1101
 
295
- selectAgent(agentId) {
296
- // Update UI selection
297
- document.querySelectorAll('.agent-item').forEach(item => {
298
- item.classList.remove('active');
299
- });
1102
+ html += '</div>';
1103
+
1104
+ // Legend
1105
+ html += '<div style="display:flex;gap:1.5rem;font-size:0.75rem;color:var(--text-secondary);margin-top:0.5rem;">';
1106
+ html += '<span><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:rgba(35,134,54,0.3);vertical-align:middle;margin-right:4px;"></span>No errors</span>';
1107
+ html += '<span><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:rgba(240,136,62,0.5);vertical-align:middle;margin-right:4px;"></span>Warnings</span>';
1108
+ html += '<span><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:rgba(218,54,51,0.5);vertical-align:middle;margin-right:4px;"></span>1-2 failures</span>';
1109
+ html += '<span><span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:rgba(218,54,51,0.9);vertical-align:middle;margin-right:4px;"></span>3+ failures</span>';
1110
+ html += '</div>';
1111
+
1112
+ container.innerHTML = html;
1113
+ }
1114
+
1115
+ // ---------------------------------------------------------------------------
1116
+ // Tab 5: State Machine
1117
+ // ---------------------------------------------------------------------------
1118
+ renderStateMachine() {
1119
+ var container = document.getElementById('stateContent');
1120
+ var trace = this.selectedTraceData || this.selectedTrace;
1121
+ if (!trace || !trace.nodes) {
1122
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a trace to view state machine.</div></div>';
1123
+ return;
1124
+ }
1125
+
1126
+ var nodes = this.getNodesArray(trace);
1127
+ var pendingCount = 0, runningCount = 0, completedCount = 0, failedCount = 0;
1128
+
1129
+ for (var i = 0; i < nodes.length; i++) {
1130
+ var s = nodes[i].status;
1131
+ if (s === 'completed') completedCount++;
1132
+ else if (s === 'failed') failedCount++;
1133
+ else if (s === 'running') runningCount++;
1134
+ else pendingCount++;
1135
+ }
1136
+
1137
+ // Determine which states are "active" (have nodes)
1138
+ var pendingActive = pendingCount > 0 ? ' pending' : '';
1139
+ var runningActive = runningCount > 0 ? ' running' : '';
1140
+ var completedActive = completedCount > 0 ? ' completed' : '';
1141
+ var failedActive = failedCount > 0 ? ' failed' : '';
1142
+
1143
+ var html = '<div class="state-machine">';
1144
+
1145
+ html += '<div class="state">';
1146
+ html += '<div class="state-circle' + pendingActive + '"><span class="state-count">' + pendingCount + '</span>PENDING</div>';
1147
+ html += '<span class="state-label">Queued</span>';
1148
+ html += '</div>';
1149
+
1150
+ html += '<div class="state-arrow">&rarr;</div>';
1151
+
1152
+ html += '<div class="state">';
1153
+ html += '<div class="state-circle' + runningActive + '"><span class="state-count">' + runningCount + '</span>RUNNING</div>';
1154
+ html += '<span class="state-label">Active</span>';
1155
+ html += '</div>';
1156
+
1157
+ html += '<div class="state-arrow">&rarr;</div>';
1158
+
1159
+ html += '<div class="state">';
1160
+ html += '<div class="state-circle' + completedActive + '"><span class="state-count">' + completedCount + '</span>COMPLETED</div>';
1161
+ html += '<span class="state-label">Success</span>';
1162
+ html += '</div>';
1163
+
1164
+ html += '<div class="state-arrow">&harr;</div>';
1165
+
1166
+ html += '<div class="state">';
1167
+ html += '<div class="state-circle' + failedActive + '"><span class="state-count">' + failedCount + '</span>FAILED</div>';
1168
+ html += '<span class="state-label">Error</span>';
1169
+ html += '</div>';
1170
+
1171
+ html += '</div>';
1172
+
1173
+ // State details
1174
+ html += '<div style="padding:1rem;">';
1175
+ html += '<div class="metrics-grid">';
1176
+ html += this.metricCard('Pending', pendingCount, 'primary');
1177
+ html += this.metricCard('Running', runningCount, runningCount > 0 ? 'warning' : 'primary');
1178
+ html += this.metricCard('Completed', completedCount, 'success');
1179
+ html += this.metricCard('Failed', failedCount, failedCount > 0 ? 'error' : 'success');
1180
+ html += '</div></div>';
1181
+
1182
+ container.innerHTML = html;
1183
+ }
1184
+
1185
+ // ---------------------------------------------------------------------------
1186
+ // Tab 6: Summary
1187
+ // ---------------------------------------------------------------------------
1188
+ renderSummary() {
1189
+ var container = document.getElementById('summaryContent');
1190
+ var trace = this.selectedTraceData || this.selectedTrace;
1191
+ if (!trace || !trace.nodes) {
1192
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a trace to view summary.</div></div>';
1193
+ return;
1194
+ }
1195
+
1196
+ // Show spinner briefly then generate
1197
+ container.innerHTML = '<div class="empty-state"><div class="spinner"></div><div class="empty-state-text">Generating summary...</div></div>';
300
1198
 
301
- const selectedItem = document.querySelector(`[data-agent-id="${agentId}"]`);
302
- if (selectedItem) {
303
- selectedItem.classList.add('active');
304
- this.selectedAgent = agentId;
305
- } else {
306
- this.selectedAgent = null;
1199
+ var self = this;
1200
+ // Use setTimeout to avoid blocking render
1201
+ setTimeout(function() { self.generateSummary(trace, container); }, 50);
1202
+ }
1203
+
1204
+ generateSummary(trace, container) {
1205
+ var nodes = this.getNodesArray(trace);
1206
+ var totalNodes = nodes.length;
1207
+ var completedCount = 0, failedCount = 0, runningCount = 0;
1208
+ var agentNames = new Set();
1209
+ var totalDur = 0, durCount = 0;
1210
+
1211
+ for (var i = 0; i < nodes.length; i++) {
1212
+ var n = nodes[i];
1213
+ if (n.status === 'completed') completedCount++;
1214
+ else if (n.status === 'failed') failedCount++;
1215
+ else if (n.status === 'running') runningCount++;
1216
+
1217
+ if (n.type === 'agent' || n.type === 'subagent') {
1218
+ agentNames.add(n.name || n.id || 'unnamed');
1219
+ }
1220
+
1221
+ if (n.startTime && n.endTime) {
1222
+ var ms = new Date(n.endTime).getTime() - new Date(n.startTime).getTime();
1223
+ if (!isNaN(ms) && ms >= 0) { totalDur += ms; durCount++; }
1224
+ }
1225
+ }
1226
+
1227
+ var successRate = totalNodes > 0 ? Math.round(completedCount / totalNodes * 100) : 0;
1228
+ var agentList = Array.from(agentNames);
1229
+
1230
+ // Build summary title
1231
+ var titleText = 'Trace: ' + escapeHtml(trace.name || trace.agentId || trace.filename || 'Unknown');
1232
+
1233
+ // Build summary text
1234
+ var summaryText = 'This trace contains ' + totalNodes + ' node' + (totalNodes !== 1 ? 's' : '') + '. ';
1235
+ summaryText += completedCount + ' completed successfully, ' + failedCount + ' failed';
1236
+ if (runningCount > 0) summaryText += ', and ' + runningCount + ' are still running';
1237
+ summaryText += '. ';
1238
+ if (durCount > 0) {
1239
+ summaryText += 'Average node duration was ' + this.formatDuration(totalDur / durCount) + '. ';
1240
+ summaryText += 'Total execution time: ' + this.formatDuration(totalDur) + '.';
1241
+ }
1242
+
1243
+ // Build details list
1244
+ var details = [];
1245
+ details.push('Total nodes: ' + totalNodes);
1246
+ details.push('Completed: ' + completedCount);
1247
+ details.push('Failed: ' + failedCount);
1248
+ if (runningCount > 0) details.push('Running: ' + runningCount);
1249
+ if (agentList.length > 0) details.push('Agents involved: ' + agentList.join(', '));
1250
+ if (trace.trigger) details.push('Trigger: ' + trace.trigger);
1251
+
1252
+ // Recommendations
1253
+ var recommendations = '';
1254
+ if (failedCount === 0 && runningCount === 0) {
1255
+ recommendations = '<strong>Status:</strong> All tasks completed successfully. No issues detected.';
1256
+ } else if (failedCount > 0) {
1257
+ recommendations = '<strong>Action needed:</strong> ' + failedCount + ' node' + (failedCount !== 1 ? 's' : '') + ' failed. Investigate the failed nodes in the Timeline or Dependency Graph tabs for error details.';
1258
+ }
1259
+ if (runningCount > 0) {
1260
+ recommendations += (recommendations ? ' ' : '') + '<strong>Note:</strong> ' + runningCount + ' node' + (runningCount !== 1 ? 's are' : ' is') + ' still running. The trace may not be complete yet.';
1261
+ }
1262
+
1263
+ var html = '<div class="summary-card">';
1264
+ html += '<h3 class="summary-title">' + titleText + '</h3>';
1265
+ html += '<p class="summary-text">' + escapeHtml(summaryText) + '</p>';
1266
+ html += '<ul class="summary-details">';
1267
+ for (var j = 0; j < details.length; j++) {
1268
+ html += '<li>' + escapeHtml(details[j]) + '</li>';
1269
+ }
1270
+ html += '</ul>';
1271
+
1272
+ if (recommendations) {
1273
+ html += '<div class="summary-recommendations">' + recommendations + '</div>';
1274
+ }
1275
+
1276
+ // Confidence bar based on success rate
1277
+ html += '<div class="confidence-bar">';
1278
+ html += '<span>Confidence:</span>';
1279
+ html += '<div class="bar"><div class="bar-fill" style="width:' + successRate + '%;"></div></div>';
1280
+ html += '<span>' + successRate + '%</span>';
1281
+ html += '</div>';
1282
+
1283
+ html += '</div>';
1284
+
1285
+ container.innerHTML = html;
1286
+ }
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // Session Timeline (rich event-based timeline for JSONL sessions)
1290
+ // ---------------------------------------------------------------------------
1291
+ async renderSessionTimeline(trace, container) {
1292
+ var filename = trace.filename;
1293
+ var html = '';
1294
+
1295
+ // Try to fetch session events from the API
1296
+ var events = trace.sessionEvents || [];
1297
+ var tokenUsage = trace.tokenUsage || null;
1298
+
1299
+ if (events.length === 0 && filename) {
1300
+ try {
1301
+ var res = await fetch('/api/traces/' + encodeURIComponent(filename) + '/events');
1302
+ if (res.ok) {
1303
+ var data = await res.json();
1304
+ events = data.events || [];
1305
+ tokenUsage = data.tokenUsage || null;
307
1306
  }
1307
+ } catch (e) {
1308
+ // fall through to node-based rendering
1309
+ }
1310
+ }
308
1311
 
309
- // Update traces view
310
- this.updateTraces();
1312
+ if (events.length === 0) {
1313
+ // Fallback: render nodes like a normal trace
1314
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">No session events found. Try the node-based timeline.</div></div>';
1315
+ return;
1316
+ }
311
1317
 
312
- // Update page title
313
- document.title = this.selectedAgent ?
314
- `AgentFlow Dashboard - ${this.selectedAgent}` :
315
- 'AgentFlow Dashboard';
1318
+ // Token usage summary at top
1319
+ if (tokenUsage && tokenUsage.total > 0) {
1320
+ html += '<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;">';
1321
+ html += '<span style="font-size:0.8rem;color:#bc8cff;">Tokens: ' + (tokenUsage.total > 1000 ? Math.round(tokenUsage.total / 1000) + 'k' : tokenUsage.total) + '</span>';
1322
+ html += '<span style="font-size:0.8rem;color:var(--text-secondary);">In: ' + (tokenUsage.input > 1000 ? Math.round(tokenUsage.input / 1000) + 'k' : tokenUsage.input) + '</span>';
1323
+ html += '<span style="font-size:0.8rem;color:var(--text-secondary);">Out: ' + (tokenUsage.output > 1000 ? Math.round(tokenUsage.output / 1000) + 'k' : tokenUsage.output) + '</span>';
1324
+ if (tokenUsage.cost > 0) html += '<span style="font-size:0.8rem;color:#f0883e;">Cost: $' + tokenUsage.cost.toFixed(4) + '</span>';
1325
+ html += '</div>';
316
1326
  }
317
1327
 
318
- // Public methods for debugging
319
- getStats() {
320
- return this.stats;
1328
+ // Summary badges
1329
+ var userCount = 0, assistantCount = 0, toolCount = 0, thinkCount = 0, spawnCount = 0;
1330
+ for (var i = 0; i < events.length; i++) {
1331
+ switch (events[i].type) {
1332
+ case 'user': userCount++; break;
1333
+ case 'assistant': assistantCount++; break;
1334
+ case 'tool_call': toolCount++; break;
1335
+ case 'thinking': thinkCount++; break;
1336
+ case 'spawn': spawnCount++; break;
1337
+ }
321
1338
  }
1339
+ html += '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">';
1340
+ html += '<span style="font-size:0.8rem;color:var(--text-secondary);">' + events.length + ' events</span>';
1341
+ if (userCount) html += '<span class="badge" style="background:rgba(88,166,255,0.15);color:#58a6ff;">' + userCount + ' user</span>';
1342
+ if (assistantCount) html += '<span class="badge" style="background:rgba(35,134,54,0.15);color:#3fb950;">' + assistantCount + ' assistant</span>';
1343
+ if (toolCount) html += '<span class="badge" style="background:rgba(240,136,62,0.15);color:#f0883e;">' + toolCount + ' tools</span>';
1344
+ if (thinkCount) html += '<span class="badge" style="background:rgba(188,140,255,0.15);color:#bc8cff;">' + thinkCount + ' thinking</span>';
1345
+ if (spawnCount) html += '<span class="badge" style="background:rgba(0,200,200,0.15);color:#00c8c8;">' + spawnCount + ' spawns</span>';
1346
+ html += '</div>';
1347
+
1348
+ // Render events
1349
+ var typeMarkers = {
1350
+ user: { icon: '\ud83e\uddd1', color: '#58a6ff', label: 'User' },
1351
+ assistant: { icon: '\ud83e\udd16', color: '#3fb950', label: 'Assistant' },
1352
+ thinking: { icon: '\ud83d\udcad', color: '#bc8cff', label: 'Thinking' },
1353
+ tool_call: { icon: '\ud83d\udee0\ufe0f', color: '#f0883e', label: 'Tool Call' },
1354
+ tool_result: { icon: '\u2705', color: '#3fb950', label: 'Tool Result' },
1355
+ spawn: { icon: '\ud83d\udc64', color: '#00c8c8', label: 'Subagent' },
1356
+ model_change: { icon: '\u2699\ufe0f', color: '#8b949e', label: 'Model' },
1357
+ system: { icon: '\u2139\ufe0f', color: '#6e7681', label: 'System' },
1358
+ };
1359
+
1360
+ for (var j = 0; j < events.length; j++) {
1361
+ var evt = events[j];
1362
+ var marker = typeMarkers[evt.type] || typeMarkers.system;
1363
+ var evtTime = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '';
1364
+ var contentPreview = escapeHtml((evt.content || '').substring(0, 300));
1365
+ if ((evt.content || '').length > 300) contentPreview += '...';
1366
+
1367
+ // Tool result with error gets red marker
1368
+ if (evt.type === 'tool_result' && evt.toolError) {
1369
+ marker = { icon: '\u274c', color: '#f85149', label: 'Tool Error' };
1370
+ }
322
1371
 
323
- getTraces() {
324
- return this.traces;
1372
+ html += '<div class="timeline-item">';
1373
+ html += '<div class="timeline-marker" style="background:' + marker.color + ';"></div>';
1374
+ html += '<div class="timeline-content">';
1375
+ html += '<div class="timeline-header">';
1376
+ html += '<span class="event-type">' + marker.icon + ' <strong>' + escapeHtml(evt.name || marker.label) + '</strong>';
1377
+ if (evt.type === 'tool_call' && evt.toolName) html += ' <code style="font-size:0.75rem;color:#f0883e;">' + escapeHtml(evt.toolName) + '</code>';
1378
+ html += '</span>';
1379
+ html += '<span class="event-time">' + evtTime;
1380
+ if (evt.duration) html += ' &middot; ' + this.formatDuration(evt.duration);
1381
+ if (evt.tokens && evt.tokens.total) html += ' &middot; <span style="color:#bc8cff;">' + (evt.tokens.total > 1000 ? Math.round(evt.tokens.total / 1000) + 'k' : evt.tokens.total) + ' tok</span>';
1382
+ html += '</span></div>';
1383
+
1384
+ if (contentPreview) {
1385
+ html += '<div class="event-details" style="margin-top:4px;">' + contentPreview + '</div>';
1386
+ }
1387
+
1388
+ if (evt.type === 'tool_call' && evt.toolArgs) {
1389
+ var argsStr = typeof evt.toolArgs === 'string' ? evt.toolArgs : JSON.stringify(evt.toolArgs);
1390
+ html += '<div class="event-details" style="margin-top:2px;font-family:monospace;font-size:0.7rem;color:var(--text-secondary);max-height:60px;overflow:hidden;">' + escapeHtml(argsStr.substring(0, 200)) + '</div>';
1391
+ }
1392
+
1393
+ if (evt.type === 'tool_result' && evt.toolResult) {
1394
+ var resultColor = evt.toolError ? 'var(--accent-error)' : 'var(--text-secondary)';
1395
+ html += '<div class="event-details" style="margin-top:2px;font-family:monospace;font-size:0.7rem;color:' + resultColor + ';max-height:80px;overflow:hidden;">' + escapeHtml(evt.toolResult.substring(0, 300)) + '</div>';
1396
+ }
1397
+
1398
+ html += '</div></div>';
1399
+ }
1400
+
1401
+ container.innerHTML = html;
1402
+ }
1403
+
1404
+ // ---------------------------------------------------------------------------
1405
+ // Tab 7: Transcript (chat bubble UI for session traces)
1406
+ // ---------------------------------------------------------------------------
1407
+ async renderTranscript() {
1408
+ var container = document.getElementById('transcriptContent');
1409
+ var trace = this.selectedTraceData || this.selectedTrace;
1410
+
1411
+ if (!trace) {
1412
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">Select a trace to view transcript.</div></div>';
1413
+ return;
1414
+ }
1415
+
1416
+ if (trace.sourceType !== 'session') {
1417
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">Transcript view is only available for session traces (JSONL files).</div></div>';
1418
+ return;
325
1419
  }
326
1420
 
327
- reconnect() {
328
- if (this.ws) {
329
- this.ws.close();
1421
+ var events = trace.sessionEvents || [];
1422
+ if (events.length === 0 && trace.filename) {
1423
+ try {
1424
+ var res = await fetch('/api/traces/' + encodeURIComponent(trace.filename) + '/events');
1425
+ if (res.ok) {
1426
+ var data = await res.json();
1427
+ events = data.events || [];
330
1428
  }
331
- this.reconnectAttempts = 0;
332
- this.connectWebSocket();
1429
+ } catch (e) { /* ignore */ }
333
1430
  }
1431
+
1432
+ if (events.length === 0) {
1433
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-text">No session events found.</div></div>';
1434
+ return;
1435
+ }
1436
+
1437
+ var html = '<div style="display:flex;flex-direction:column;gap:4px;padding:0.5rem;">';
1438
+
1439
+ var thinkingIdx = 0;
1440
+ for (var i = 0; i < events.length; i++) {
1441
+ var evt = events[i];
1442
+ var evtTime = evt.timestamp ? new Date(evt.timestamp).toLocaleTimeString() : '';
1443
+
1444
+ if (evt.type === 'user') {
1445
+ html += '<div class="chat-bubble chat-user">';
1446
+ html += escapeHtml(evt.content || '');
1447
+ html += '<div class="chat-meta">' + evtTime + '</div>';
1448
+ html += '</div>';
1449
+ }
1450
+
1451
+ if (evt.type === 'assistant') {
1452
+ html += '<div class="chat-bubble chat-assistant">';
1453
+ html += escapeHtml(evt.content || '');
1454
+ html += '<div class="chat-meta">' + evtTime;
1455
+ if (evt.tokens && evt.tokens.total) {
1456
+ html += ' &middot; <span class="chat-tokens">' + (evt.tokens.total > 1000 ? Math.round(evt.tokens.total / 1000) + 'k' : evt.tokens.total) + ' tokens';
1457
+ if (evt.tokens.cost) html += ' ($' + evt.tokens.cost.toFixed(4) + ')';
1458
+ html += '</span>';
1459
+ }
1460
+ if (evt.model) html += ' &middot; ' + escapeHtml(evt.model);
1461
+ html += '</div></div>';
1462
+ }
1463
+
1464
+ if (evt.type === 'thinking') {
1465
+ thinkingIdx++;
1466
+ var tId = 'thinking-toggle-' + thinkingIdx;
1467
+ html += '<div class="chat-bubble chat-thinking">';
1468
+ html += '<span class="chat-thinking-toggle" onclick="var b=document.getElementById(\'' + tId + '\');b.classList.toggle(\'open\');">\ud83d\udcad Thinking (click to expand)</span>';
1469
+ html += '<div class="chat-thinking-body" id="' + tId + '">' + escapeHtml(evt.content || '') + '</div>';
1470
+ html += '<div class="chat-meta">' + evtTime + '</div>';
1471
+ html += '</div>';
1472
+ }
1473
+
1474
+ if (evt.type === 'tool_call') {
1475
+ html += '<div class="chat-bubble chat-tool">';
1476
+ html += '<strong>\ud83d\udee0\ufe0f ' + escapeHtml(evt.toolName || evt.name || 'Tool') + '</strong>';
1477
+ if (evt.toolArgs) {
1478
+ var argsStr = typeof evt.toolArgs === 'string' ? evt.toolArgs : JSON.stringify(evt.toolArgs, null, 2);
1479
+ html += '<div style="margin-top:4px;max-height:100px;overflow:hidden;font-size:0.75rem;color:var(--text-secondary);">' + escapeHtml(argsStr.substring(0, 300)) + '</div>';
1480
+ }
1481
+ html += '<div class="chat-meta">' + evtTime;
1482
+ if (evt.duration) html += ' &middot; ' + this.formatDuration(evt.duration);
1483
+ html += '</div></div>';
1484
+ }
1485
+
1486
+ if (evt.type === 'tool_result') {
1487
+ var isError = !!evt.toolError;
1488
+ html += '<div class="chat-bubble chat-tool" style="' + (isError ? 'border-color:var(--accent-error);' : 'border-color:rgba(35,134,54,0.3);') + '">';
1489
+ html += '<strong>' + (isError ? '\u274c' : '\u2705') + ' Result</strong>';
1490
+ var resultText = evt.toolError || evt.toolResult || '';
1491
+ html += '<div style="margin-top:4px;max-height:120px;overflow:hidden;font-size:0.75rem;color:' + (isError ? 'var(--accent-error)' : 'var(--text-secondary)') + ';">' + escapeHtml(resultText.substring(0, 400)) + '</div>';
1492
+ html += '<div class="chat-meta">' + evtTime + '</div>';
1493
+ html += '</div>';
1494
+ }
1495
+
1496
+ if (evt.type === 'spawn') {
1497
+ html += '<div class="chat-bubble" style="margin:0 auto;max-width:70%;background:rgba(0,200,200,0.08);border:1px solid rgba(0,200,200,0.25);text-align:center;">';
1498
+ html += '\ud83d\udc64 Subagent spawned';
1499
+ if (evt.content) html += ': <code>' + escapeHtml(evt.content.substring(0, 40)) + '</code>';
1500
+ html += '<div class="chat-meta">' + evtTime + '</div>';
1501
+ html += '</div>';
1502
+ }
1503
+ }
1504
+
1505
+ html += '</div>';
1506
+ container.innerHTML = html;
1507
+ }
1508
+
1509
+ // ---------------------------------------------------------------------------
1510
+ // Alert panel
1511
+ // ---------------------------------------------------------------------------
1512
+ showAlert(messages) {
1513
+ var panel = document.getElementById('alertPanel');
1514
+ var list = document.getElementById('alertList');
1515
+ if (!messages || messages.length === 0) {
1516
+ panel.classList.remove('show');
1517
+ return;
1518
+ }
1519
+ list.innerHTML = messages.map(function(m) { return '<li>' + escapeHtml(m) + '</li>'; }).join('');
1520
+ panel.classList.add('show');
1521
+ }
1522
+
1523
+ // ---------------------------------------------------------------------------
1524
+ // Public / debug
1525
+ // ---------------------------------------------------------------------------
1526
+ getStats() { return this.stats; }
1527
+ getTraces() { return this.traces; }
1528
+ reconnect() {
1529
+ if (this.ws) this.ws.close();
1530
+ this.reconnectAttempts = 0;
1531
+ this.connectWebSocket();
1532
+ }
334
1533
  }
335
1534
 
336
- // Initialize dashboard when page loads
337
- document.addEventListener('DOMContentLoaded', () => {
338
- window.dashboard = new AgentFlowDashboard();
1535
+ // Initialize
1536
+ document.addEventListener('DOMContentLoaded', function() {
1537
+ window.dashboard = new AgentFlowDashboard();
339
1538
  });
340
1539
 
341
- // Expose dashboard for debugging
342
- window.AgentFlowDashboard = AgentFlowDashboard;
1540
+ window.AgentFlowDashboard = AgentFlowDashboard;