flow-debugger 1.9.6 → 1.9.8

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/dashboard/app.js CHANGED
@@ -1,415 +1,505 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // flow-debugger — Dashboard App
3
- // Fetches analytics from /__debugger API and renders live UI
4
- // ─────────────────────────────────────────────────────────────
5
-
6
- const API_URL = '/__debugger';
7
- let autoRefresh = true;
8
- let refreshInterval = null;
9
-
10
- // ─── Init ─────────────────────────────────────────────────
11
- document.addEventListener('DOMContentLoaded', () => {
12
- refreshData();
13
- startAutoRefresh();
14
- });
15
-
16
- function startAutoRefresh() {
17
- if (refreshInterval) clearInterval(refreshInterval);
18
- refreshInterval = setInterval(refreshData, 3000);
19
- }
20
-
21
- function toggleAutoRefresh() {
22
- autoRefresh = !autoRefresh;
23
- const btn = document.getElementById('autoRefreshBtn');
24
- if (autoRefresh) {
25
- btn.textContent = 'Auto: ON';
26
- startAutoRefresh();
27
- } else {
28
- btn.textContent = 'Auto: OFF';
29
- if (refreshInterval) clearInterval(refreshInterval);
30
- }
31
- }
32
-
33
- async function refreshData() {
34
- try {
35
- const res = await fetch(API_URL);
36
- if (!res.ok) throw new Error('API error');
37
- const data = await res.json();
38
-
39
- // Seed sample data if empty for a better first-look experience
40
- if (!data.totalRequests || data.totalRequests === 0) {
41
- const seededData = seedSampleData(data);
42
- renderDashboard(seededData);
43
- } else {
44
- renderDashboard(data);
45
- }
46
- } catch (err) {
47
- document.getElementById('statusBadge').textContent = '● OFFLINE';
48
- document.getElementById('statusBadge').className = 'badge badge-offline';
49
- }
50
- }
51
-
52
- // ─── Render ───────────────────────────────────────────────
53
- function renderDashboard(data) {
54
- // Status
55
- document.getElementById('statusBadge').textContent = '● LIVE';
56
- document.getElementById('statusBadge').className = 'badge badge-live';
57
-
58
- // Uptime
59
- const uptimeMs = data.uptime || 0;
60
- document.getElementById('uptime').textContent = `Uptime: ${formatDuration(uptimeMs)}`;
61
-
62
- // Stats
63
- document.getElementById('totalRequests').textContent = formatNumber(data.totalRequests || 0);
64
- document.getElementById('totalErrors').textContent = formatNumber(data.totalErrors || 0);
65
- document.getElementById('totalSlow').textContent = formatNumber(data.totalSlow || 0);
66
-
67
- const total = data.totalRequests || 0;
68
- const errors = data.totalErrors || 0;
69
- const rate = total > 0 ? Math.round(((total - errors) / total) * 100) : 100;
70
- document.getElementById('successRate').textContent = rate + '%';
71
-
72
- // Panels
73
- renderHealthPanel(data.serviceHealth || []);
74
- renderFailuresPanel(data.topFailures || []);
75
- renderEndpointsTable(data.endpoints || []);
76
- renderSlowQueries(data.recentTraces || []);
77
- renderRecentTraces(data.recentTraces || []);
78
- }
79
-
80
- // ─── Health Panel ─────────────────────────────────────────
81
- function renderHealthPanel(health) {
82
- const panel = document.getElementById('healthPanel');
83
- if (!health.length) {
84
- panel.innerHTML = '<div class="empty-state">No services detected yet</div>';
85
- return;
86
- }
87
-
88
- panel.innerHTML = `<div class="health-grid">${health.map(h => `
89
- <div class="health-pill">
90
- <div class="health-dot ${h.status}"></div>
91
- <div class="health-info">
92
- <span class="health-name">${escapeHtml(h.name)}</span>
93
- <span class="health-rate">${h.successRate}% success · ${h.totalChecks} checks</span>
94
- </div>
95
- </div>
96
- `).join('')}</div>`;
97
- }
98
-
99
- // ─── Failures Panel ───────────────────────────────────────
100
- function renderFailuresPanel(failures) {
101
- const panel = document.getElementById('failuresPanel');
102
- if (!failures.length) {
103
- panel.innerHTML = '<div class="empty-state">No failures recorded</div>';
104
- return;
105
- }
106
-
107
- const maxPct = Math.max(...failures.map(f => f.percentage), 1);
108
-
109
- panel.innerHTML = `<div class="failure-bar-group">${failures.map(f => `
110
- <div class="failure-item">
111
- <span class="failure-label">${escapeHtml(f.service)}</span>
112
- <div class="failure-bar-bg">
113
- <div class="failure-bar" style="width: ${(f.percentage / maxPct) * 100}%"></div>
114
- </div>
115
- <span class="failure-pct">${f.percentage}%</span>
116
- </div>
117
- `).join('')}</div>`;
118
- }
119
-
120
- // ─── Endpoints Table ──────────────────────────────────────
121
- function renderEndpointsTable(endpoints) {
122
- const tbody = document.getElementById('endpointsTable');
123
- if (!endpoints.length) {
124
- tbody.innerHTML = '<tr><td colspan="9" class="empty-state">No endpoints tracked yet</td></tr>';
125
- return;
126
- }
127
-
128
- tbody.innerHTML = endpoints.map(ep => `
129
- <tr>
130
- <td><span class="method-badge method-${ep.method}">${ep.method}</span></td>
131
- <td><span class="endpoint-path">${escapeHtml(ep.path)}</span></td>
132
- <td class="td-mono">${formatNumber(ep.totalRequests)}</td>
133
- <td class="td-mono" style="color: ${ep.errorCount > 0 ? 'var(--red)' : 'inherit'}">${ep.errorCount}</td>
134
- <td class="td-mono" style="color: ${ep.slowCount > 0 ? 'var(--yellow)' : 'inherit'}">${ep.slowCount}</td>
135
- <td class="td-mono">${Math.round(ep.avgDuration)}</td>
136
- <td class="td-mono">${Math.round(ep.p95Duration)}</td>
137
- <td class="td-mono">${Math.round(ep.maxDuration)}</td>
138
- <td>${(ep.commonIssues || []).slice(0, 2).map(i => `<span class="issue-tag">${escapeHtml(i)}</span>`).join('') || '—'}</td>
139
- </tr>
140
- `).join('');
141
- }
142
-
143
- // ─── Slow Queries ─────────────────────────────────────────
144
- function renderSlowQueries(traces) {
145
- const container = document.getElementById('slowQueries');
146
- const slowSteps = [];
147
-
148
- for (const trace of traces) {
149
- for (const step of (trace.steps || [])) {
150
- if (step.classification === 'WARN' && step.duration > 300 && step.metadata?.sql) {
151
- slowSteps.push({
152
- sql: step.metadata.sql,
153
- duration: step.duration,
154
- service: step.service,
155
- endpoint: trace.endpoint,
156
- });
157
- }
158
- }
159
- }
160
-
161
- if (!slowSteps.length) {
162
- container.innerHTML = '<div class="empty-state">No slow queries detected</div>';
163
- return;
164
- }
165
-
166
- container.innerHTML = `<div class="slow-query-list">${slowSteps.slice(0, 10).map(sq => `
167
- <div class="slow-query-item">
168
- <span class="slow-query-icon">⚠</span>
169
- <span class="slow-query-sql">${escapeHtml(sq.sql)}</span>
170
- <span class="slow-query-dur">${Math.round(sq.duration)}ms</span>
171
- </div>
172
- `).join('')}</div>`;
173
- }
174
-
175
- // ─── Recent Traces ────────────────────────────────────────
176
- function renderRecentTraces(traces) {
177
- const container = document.getElementById('recentTraces');
178
- if (!traces.length) {
179
- container.innerHTML = '<div class="empty-state">No traces recorded yet</div>';
180
- return;
181
- }
182
-
183
- container.innerHTML = `<div class="trace-list">${traces.slice(0, 15).map((trace, idx) => `
184
- <div class="trace-item">
185
- <div class="trace-header" onclick="toggleTrace(${idx})">
186
- <div class="trace-header-left">
187
- <span class="method-badge method-${trace.method}">${trace.method}</span>
188
- <span class="trace-endpoint">${escapeHtml(trace.endpoint)}</span>
189
- <span class="trace-id">${trace.traceId}</span>
190
- <span class="badge" style="background: rgba(124, 58, 237, 0.1); color: var(--accent-light); font-size: 10px; border: 1px solid var(--border)">${trace.language === 'python' ? 'Python' : 'Node'}</span>
191
- </div>
192
- <div class="trace-header-right">
193
- <span class="classification-badge classification-${trace.classification}">${trace.classification}</span>
194
- <span class="trace-dur">${Math.round(trace.totalDuration)}ms</span>
195
- <span style="color: var(--text-dim); font-size: 12px">▼</span>
196
- </div>
197
- </div>
198
- <div class="trace-steps" id="traceSteps-${idx}">
199
- <div class="step-timeline">
200
- ${(trace.steps || []).map(step => {
201
- const cls = step.status === 'error' ? 'step-error' :
202
- step.status === 'timeout' ? 'step-timeout' :
203
- step.classification === 'WARN' ? 'step-slow' : '';
204
- const icon = step.status === 'success' ? '✔' : step.status === 'timeout' ? '⏱' : '❌';
205
- return `
206
- <div class="step-item ${cls}">
207
- <span class="step-offset">[${Math.round(step.startTime)}ms]</span>
208
- <span class="step-name">${escapeHtml(step.name)}</span>
209
- <div class="waterfall-container">
210
- <div class="waterfall-bar classification-${step.classification}"
211
- style="left: ${(step.offset / trace.totalDuration) * 100}%; width: ${Math.max((step.duration / trace.totalDuration) * 100, 0.5)}%">
212
- </div>
213
- </div>
214
- <span class="step-status-icon">${icon}</span>
215
- <span class="step-dur">${Math.round(step.duration)}ms</span>
216
- ${step.service !== 'internal' ? `<span class="step-service-tag">${step.service}</span>` : ''}
217
- </div>
218
- ${step.error ? `<div class="step-item" style="padding-left: 92px; color: var(--red); font-size: 11px">└─ ${escapeHtml(step.error)}</div>` : ''}
219
- ${step.errorFile ? `<div class="step-item" style="padding-left: 92px; color: var(--purple); font-size: 10px; font-family: monospace"> 📄 ${escapeHtml(step.errorFile)}${step.errorLine ? `:${step.errorLine}` : ''}</div>` : ''}
220
- `;
221
- }).join('')}
222
- </div>
223
- ${trace.rootCause ? `
224
- <div class="root-cause-box">
225
- <div class="rc-label">🔍 Root Cause</div>
226
- <div class="rc-detail">${escapeHtml(trace.rootCause.cause)} · Service: ${trace.rootCause.service} · Confidence: ${trace.rootCause.confidence}%</div>
227
- </div>
228
- ` : ''}
229
- ${trace.requestData ? `
230
- <div class="replay-section">
231
- <button class="replay-btn" onclick="replayRequest('${trace.traceId}')">🔄 Replay Request</button>
232
- <span class="replay-hint">Captured body & headers available</span>
233
- </div>
234
- ` : ''}
235
- </div>
236
- </div>
237
- `).join('')}</div>`;
238
- }
239
-
240
- async function replayRequest(traceId) {
241
- if (!confirm(`Replay request ${traceId}? This will re-trigger the endpoint on the server.`)) return;
242
-
243
- try {
244
- const res = await fetch(`/__debugger/replay?traceId=${traceId}`);
245
- const data = await res.json();
246
- alert(`Replay status: ${data.status}\nTarget: ${data.target}\n${data.info}`);
247
- } catch (err) {
248
- alert('Replay failed: ' + err.message);
249
- }
250
- }
251
-
252
- function toggleTrace(idx) {
253
- const el = document.getElementById(`traceSteps-${idx}`);
254
- if (el) el.classList.toggle('open');
255
- }
256
-
257
- // ─── Utilities ────────────────────────────────────────────
258
- function escapeHtml(str) {
259
- if (!str) return '';
260
- return String(str)
261
- .replace(/&/g, '&amp;')
262
- .replace(/</g, '&lt;')
263
- .replace(/>/g, '&gt;')
264
- .replace(/"/g, '&quot;');
265
- }
266
-
267
- function formatNumber(n) {
268
- if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
269
- if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
270
- return String(n);
271
- }
272
-
273
- async function performSearch() {
274
- const query = document.getElementById('searchInput').value.trim();
275
- const env = document.getElementById('envFilter').value;
276
- const resultsDiv = document.getElementById('searchResults');
277
-
278
- if (!query) {
279
- resultsDiv.innerHTML = '<div class="empty-state">Enter a search term</div>';
280
- return;
281
- }
282
-
283
- try {
284
- const url = `/__debugger/search?q=${encodeURIComponent(query)}${env ? `&env=${env}` : ''}`;
285
- const res = await fetch(url);
286
- const data = await res.json();
287
-
288
- if (data.error) {
289
- resultsDiv.innerHTML = `<div class="empty-state">${data.error}</div>`;
290
- return;
291
- }
292
-
293
- if (!data.results || data.results.length === 0) {
294
- resultsDiv.innerHTML = '<div class="empty-state">No results found</div>';
295
- return;
296
- }
297
-
298
- // Render results using same trace rendering logic
299
- resultsDiv.innerHTML = `
300
- <div style="margin-bottom: 12px; color: var(--text-dim); font-size: 13px;">
301
- Found ${data.count} result${data.count !== 1 ? 's' : ''} for "${escapeHtml(query)}"
302
- </div>
303
- <div class="trace-list">${data.results.map((trace, idx) => {
304
- const hasPayload = trace.payloadSize && trace.payloadSize > 0;
305
- const payloadMB = hasPayload ? (trace.payloadSize / (1024 * 1024)).toFixed(2) : null;
306
- return `
307
- <div class="trace-item">
308
- <div class="trace-header" onclick="toggleSearchTrace(${idx})">
309
- <div class="trace-header-left">
310
- <span class="method-badge method-${trace.method}">${trace.method}</span>
311
- <span class="trace-endpoint">${escapeHtml(trace.endpoint)}</span>
312
- <span class="trace-id">${trace.traceId}</span>
313
- <span class="badge" style="background: rgba(124, 58, 237, 0.1); color: var(--accent-light); font-size: 10px; border: 1px solid var(--border)">${trace.language === 'python' ? 'Python' : 'Node'}</span>
314
- ${trace.environment ? `<span class="badge" style="background: rgba(124,58,237,0.2); color: var(--purple); font-size: 10px; padding: 2px 6px;">${trace.environment}</span>` : ''}
315
- ${hasPayload && payloadMB > 1 ? `<span class="badge" style="background: rgba(251,191,36,0.2); color: var(--yellow); font-size: 10px; padding: 2px 6px;">📦 ${payloadMB}MB</span>` : ''}
316
- </div>
317
- <div class="trace-header-right">
318
- <span class="classification-badge classification-${trace.classification}">${trace.classification}</span>
319
- <span class="trace-dur">${Math.round(trace.totalDuration)}ms</span>
320
- <span style="color: var(--text-dim); font-size: 12px">▼</span>
321
- </div>
322
- </div>
323
- <div class="trace-steps" id="searchTraceSteps-${idx}">
324
- <div class="step-timeline">
325
- ${(trace.steps || []).map(step => {
326
- const cls = step.status === 'error' ? 'step-error' :
327
- step.status === 'timeout' ? 'step-timeout' :
328
- step.classification === 'WARN' ? 'step-slow' : '';
329
- const icon = step.status === 'success' ? '✔' : step.status === 'timeout' ? '⏱' : '❌';
330
- return `
331
- <div class="step-item ${cls}">
332
- <span class="step-offset">[${Math.round(step.startTime)}ms]</span>
333
- <span class="step-name">${escapeHtml(step.name)}</span>
334
- <div class="waterfall-container">
335
- <div class="waterfall-bar classification-${step.classification}"
336
- style="left: ${(step.offset / trace.totalDuration) * 100}%; width: ${Math.max((step.duration / trace.totalDuration) * 100, 0.5)}%">
337
- </div>
338
- </div>
339
- <span class="step-status-icon">${icon}</span>
340
- <span class="step-dur">${Math.round(step.duration)}ms</span>
341
- ${step.service !== 'internal' ? `<span class="step-service-tag">${step.service}</span>` : ''}
342
- </div>
343
- ${step.error ? `<div class="step-item" style="padding-left: 92px; color: var(--red); font-size: 11px">└─ ${escapeHtml(step.error)}</div>` : ''}
344
- ${step.errorFile ? `<div class="step-item" style="padding-left: 92px; color: var(--purple); font-size: 10px; font-family: monospace"> 📄 ${escapeHtml(step.errorFile)}${step.errorLine ? `:${step.errorLine}` : ''}</div>` : ''}
345
- `;
346
- }).join('')}
347
- </div>
348
- ${trace.rootCause ? `
349
- <div class="root-cause-box">
350
- <div class="rc-label">🔍 Root Cause</div>
351
- <div class="rc-detail">${escapeHtml(trace.rootCause.cause)} · Service: ${trace.rootCause.service} · Confidence: ${trace.rootCause.confidence}%</div>
352
- </div>
353
- ` : ''}
354
- ${trace.requestData ? `
355
- <div class="replay-section">
356
- <button class="replay-btn" onclick="replayRequest('${trace.traceId}')">🔄 Replay Request</button>
357
- </div>
358
- ` : ''}
359
- </div>
360
- </div>
361
- `;
362
- }).join('')}</div>
363
- `;
364
- } catch (err) {
365
- resultsDiv.innerHTML = '<div class="empty-state">Search failed</div>';
366
- }
367
- }
368
-
369
- function toggleSearchTrace(idx) {
370
- const el = document.getElementById(`searchTraceSteps-${idx}`);
371
- if (el) el.classList.toggle('open');
372
- }
373
-
374
- function formatDuration(ms) {
375
- const secs = Math.floor(ms / 1000);
376
- if (secs < 60) return secs + 's';
377
- const mins = Math.floor(secs / 60);
378
- if (mins < 60) return mins + 'm ' + (secs % 60) + 's';
379
- const hrs = Math.floor(mins / 60);
380
- return hrs + 'h ' + (mins % 60) + 'm';
381
- }
382
-
383
- function seedSampleData(data) {
384
- return {
385
- ...data,
386
- uptime: 124000,
387
- totalRequests: 1,
388
- totalErrors: 0,
389
- totalSlow: 0,
390
- serviceHealth: [
391
- { name: 'mongo', status: 'healthy', successRate: 100, totalChecks: 12 },
392
- { name: 'redis', status: 'degraded', successRate: 92, totalChecks: 45 },
393
- { name: 'postgres', status: 'healthy', successRate: 100, totalChecks: 5 }
394
- ],
395
- endpoints: [
396
- { method: 'GET', path: '/api/users', totalRequests: 1, errorCount: 0, slowCount: 0, avgDuration: 42, p95Duration: 42, maxDuration: 42, commonIssues: [] }
397
- ],
398
- recentTraces: [
399
- {
400
- traceId: 'sample_trace_123',
401
- endpoint: '/api/users',
402
- method: 'GET',
403
- totalDuration: 156,
404
- classification: 'INFO',
405
- language: 'node',
406
- steps: [
407
- { name: 'Auth Check', service: 'internal', status: 'success', classification: 'INFO', startTime: 2, endTime: 12, duration: 10, offset: 2 },
408
- { name: 'DB Find User', service: 'mongo', status: 'success', classification: 'INFO', startTime: 15, endTime: 145, duration: 130, offset: 15 },
409
- { name: 'Redis Cache', service: 'redis', status: 'success', classification: 'INFO', startTime: 148, endTime: 155, duration: 7, offset: 148 }
410
- ]
411
- }
412
- ]
413
- };
414
- }
415
-
1
+ // ─────────────────────────────────────────────────────────────
2
+ // flow-debugger — Dashboard App
3
+ // Fetches analytics from /__debugger API and renders live UI
4
+ // ─────────────────────────────────────────────────────────────
5
+
6
+ const API_URL = '/__debugger';
7
+ let autoRefresh = true;
8
+ let refreshInterval = null;
9
+
10
+ // ─── Init ─────────────────────────────────────────────────
11
+ document.addEventListener('DOMContentLoaded', () => {
12
+ refreshData();
13
+ startAutoRefresh();
14
+ });
15
+
16
+ function startAutoRefresh() {
17
+ if (refreshInterval) clearInterval(refreshInterval);
18
+ refreshInterval = setInterval(refreshData, 3000);
19
+ }
20
+
21
+ function toggleAutoRefresh() {
22
+ autoRefresh = !autoRefresh;
23
+ const btn = document.getElementById('autoRefreshBtn');
24
+ if (autoRefresh) {
25
+ btn.textContent = 'Auto: ON';
26
+ startAutoRefresh();
27
+ } else {
28
+ btn.textContent = 'Auto: OFF';
29
+ if (refreshInterval) clearInterval(refreshInterval);
30
+ }
31
+ }
32
+
33
+ async function refreshData() {
34
+ try {
35
+ const res = await fetch(API_URL);
36
+ if (!res.ok) throw new Error('API error');
37
+ const data = await res.json();
38
+
39
+ // Seed sample data if empty for a better first-look experience
40
+ if (!data.totalRequests || data.totalRequests === 0) {
41
+ const seededData = seedSampleData(data);
42
+ renderDashboard(seededData);
43
+ } else {
44
+ renderDashboard(data);
45
+ }
46
+ } catch (err) {
47
+ document.getElementById('statusBadge').textContent = '● OFFLINE';
48
+ document.getElementById('statusBadge').className = 'badge badge-offline';
49
+ }
50
+ }
51
+
52
+ // ─── Render ───────────────────────────────────────────────
53
+ function renderDashboard(data) {
54
+ // Status
55
+ document.getElementById('statusBadge').textContent = '● LIVE';
56
+ document.getElementById('statusBadge').className = 'badge badge-live';
57
+
58
+ // Uptime
59
+ const uptimeMs = data.uptime || 0;
60
+ document.getElementById('uptime').textContent = `Uptime: ${formatDuration(uptimeMs)}`;
61
+
62
+ // Stats
63
+ document.getElementById('totalRequests').textContent = formatNumber(data.totalRequests || 0);
64
+ document.getElementById('totalErrors').textContent = formatNumber(data.totalErrors || 0);
65
+ document.getElementById('totalSlow').textContent = formatNumber(data.totalSlow || 0);
66
+
67
+ const total = data.totalRequests || 0;
68
+ const errors = data.totalErrors || 0;
69
+ const rate = total > 0 ? Math.round(((total - errors) / total) * 100) : 100;
70
+ document.getElementById('successRate').textContent = rate + '%';
71
+
72
+ // Panels
73
+ renderHealthPanel(data.serviceHealth || []);
74
+ renderFailuresPanel(data.topFailures || []);
75
+ renderEndpointsTable(data.endpoints || []);
76
+ renderSlowQueries(data.recentTraces || []);
77
+ renderRecentTraces(data.recentTraces || []);
78
+ }
79
+
80
+ // ─── Health Panel ─────────────────────────────────────────
81
+ function renderHealthPanel(health) {
82
+ const panel = document.getElementById('healthPanel');
83
+ if (!health.length) {
84
+ panel.innerHTML = '<div class="empty-state">No services detected yet</div>';
85
+ return;
86
+ }
87
+
88
+ panel.innerHTML = `<div class="health-grid">${health.map(h => `
89
+ <div class="health-pill">
90
+ <div class="health-dot ${h.status}"></div>
91
+ <div class="health-info">
92
+ <span class="health-name">${escapeHtml(h.name)}</span>
93
+ <span class="health-rate">${h.successRate}% success · ${h.totalChecks} checks</span>
94
+ </div>
95
+ </div>
96
+ `).join('')}</div>`;
97
+ }
98
+
99
+ // ─── Failures Panel ───────────────────────────────────────
100
+ function renderFailuresPanel(failures) {
101
+ const panel = document.getElementById('failuresPanel');
102
+ if (!failures.length) {
103
+ panel.innerHTML = '<div class="empty-state">No failures recorded</div>';
104
+ return;
105
+ }
106
+
107
+ const maxPct = Math.max(...failures.map(f => f.percentage), 1);
108
+
109
+ panel.innerHTML = `<div class="failure-bar-group">${failures.map(f => `
110
+ <div class="failure-item">
111
+ <span class="failure-label">${escapeHtml(f.service)}</span>
112
+ <div class="failure-bar-bg">
113
+ <div class="failure-bar" style="width: ${(f.percentage / maxPct) * 100}%"></div>
114
+ </div>
115
+ <span class="failure-pct">${f.percentage}%</span>
116
+ </div>
117
+ `).join('')}</div>`;
118
+ }
119
+
120
+ // ─── Endpoints Table ──────────────────────────────────────
121
+ function renderEndpointsTable(endpoints) {
122
+ const tbody = document.getElementById('endpointsTable');
123
+ if (!endpoints.length) {
124
+ tbody.innerHTML = '<tr><td colspan="9" class="empty-state">No endpoints tracked yet</td></tr>';
125
+ return;
126
+ }
127
+
128
+ tbody.innerHTML = endpoints.map(ep => `
129
+ <tr>
130
+ <td><span class="method-badge method-${ep.method}">${ep.method}</span></td>
131
+ <td><span class="endpoint-path">${escapeHtml(ep.path)}</span></td>
132
+ <td class="td-mono">${formatNumber(ep.totalRequests)}</td>
133
+ <td class="td-mono" style="color: ${ep.errorCount > 0 ? 'var(--red)' : 'inherit'}">${ep.errorCount}</td>
134
+ <td class="td-mono" style="color: ${ep.slowCount > 0 ? 'var(--yellow)' : 'inherit'}">${ep.slowCount}</td>
135
+ <td class="td-mono">${Math.round(ep.avgDuration)}</td>
136
+ <td class="td-mono">${Math.round(ep.p95Duration)}</td>
137
+ <td class="td-mono">${Math.round(ep.maxDuration)}</td>
138
+ <td>${(ep.commonIssues || []).slice(0, 2).map(i => `<span class="issue-tag">${escapeHtml(i)}</span>`).join('') || '—'}</td>
139
+ </tr>
140
+ `).join('');
141
+ }
142
+
143
+ // ─── Slow Queries ─────────────────────────────────────────
144
+ function renderSlowQueries(traces) {
145
+ const container = document.getElementById('slowQueries');
146
+ const slowSteps = [];
147
+
148
+ for (const trace of traces) {
149
+ for (const step of (trace.steps || [])) {
150
+ if (step.classification === 'WARN' && step.duration > 300 && step.metadata?.sql) {
151
+ slowSteps.push({
152
+ sql: step.metadata.sql,
153
+ duration: step.duration,
154
+ service: step.service,
155
+ endpoint: trace.endpoint,
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ if (!slowSteps.length) {
162
+ container.innerHTML = '<div class="empty-state">No slow queries detected</div>';
163
+ return;
164
+ }
165
+
166
+ container.innerHTML = `<div class="slow-query-list">${slowSteps.slice(0, 10).map(sq => `
167
+ <div class="slow-query-item">
168
+ <span class="slow-query-icon">⚠</span>
169
+ <span class="slow-query-sql">${escapeHtml(sq.sql)}</span>
170
+ <span class="slow-query-dur">${Math.round(sq.duration)}ms</span>
171
+ </div>
172
+ `).join('')}</div>`;
173
+ }
174
+
175
+ // ─── Recent Traces ────────────────────────────────────────
176
+ function renderRecentTraces(traces) {
177
+ const container = document.getElementById('recentTraces');
178
+ if (!traces.length) {
179
+ container.innerHTML = '<div class="empty-state">No traces recorded yet</div>';
180
+ return;
181
+ }
182
+
183
+ container.innerHTML = `<div class="trace-list">${traces.slice(0, 15).map((trace, idx) => `
184
+ <div class="trace-item">
185
+ <div class="trace-header" onclick="toggleTrace(${idx})">
186
+ <div class="trace-header-left">
187
+ <span class="method-badge method-${trace.method}">${trace.method}</span>
188
+ <span class="trace-endpoint">${escapeHtml(trace.endpoint)}</span>
189
+ <span class="trace-id">${trace.traceId}</span>
190
+ <span class="badge" style="background: rgba(124, 58, 237, 0.1); color: var(--accent-light); font-size: 10px; border: 1px solid var(--border)">${trace.language === 'python' ? 'Python' : 'Node'}</span>
191
+ </div>
192
+ <div class="trace-header-right">
193
+ <span class="classification-badge classification-${trace.classification}">${trace.classification}</span>
194
+ <span class="trace-dur">${Math.round(trace.totalDuration)}ms</span>
195
+ <span style="color: var(--text-dim); font-size: 12px">▼</span>
196
+ </div>
197
+ </div>
198
+ <div class="trace-steps" id="traceSteps-${idx}">
199
+ <div class="step-timeline">
200
+ ${(trace.steps || []).map(step => {
201
+ const cls = step.status === 'error' ? 'step-error' :
202
+ step.status === 'timeout' ? 'step-timeout' :
203
+ step.classification === 'WARN' ? 'step-slow' : '';
204
+ const icon = step.status === 'success' ? '✔' : step.status === 'timeout' ? '⏱' : '❌';
205
+ return `
206
+ <div class="step-item ${cls}">
207
+ <span class="step-offset">[${Math.round(step.startTime)}ms]</span>
208
+ <span class="step-name">${escapeHtml(step.name)}</span>
209
+ <div class="waterfall-container">
210
+ <div class="waterfall-bar classification-${step.classification}"
211
+ style="left: ${(step.offset / trace.totalDuration) * 100}%; width: ${Math.max((step.duration / trace.totalDuration) * 100, 0.5)}%">
212
+ </div>
213
+ </div>
214
+ <span class="step-status-icon">${icon}</span>
215
+ <span class="step-dur">${Math.round(step.duration)}ms</span>
216
+ ${step.service !== 'internal' ? `<span class="step-service-tag">${step.service}</span>` : ''}
217
+ </div>
218
+ ${step.error ? `<div class="step-item" style="padding-left: 92px; color: var(--red); font-size: 11px">└─ ${escapeHtml(step.error)}</div>` : ''}
219
+ ${step.errorFile ? `<div class="step-item" style="padding-left: 92px; color: var(--purple); font-size: 10px; font-family: monospace"> 📄 ${escapeHtml(step.errorFile)}${step.errorLine ? `:${step.errorLine}` : ''}</div>` : ''}
220
+ `;
221
+ }).join('')}
222
+ </div>
223
+ ${trace.rootCause ? `
224
+ <div class="root-cause-box">
225
+ <div class="rc-label">🔍 Root Cause</div>
226
+ <div class="rc-detail">${escapeHtml(trace.rootCause.cause)} · Service: ${trace.rootCause.service} · Confidence: ${trace.rootCause.confidence}%</div>
227
+ ${trace.rootCause.suggestions ? `
228
+ <div class="rc-suggestions" style="margin-top: 8px; font-size: 11px; color: var(--blue);">
229
+ <strong>💡 Suggestions:</strong>
230
+ <ul style="margin: 4px 0 0 16px; padding: 0;">
231
+ ${trace.rootCause.suggestions.map(s => `<li>${escapeHtml(s)}</li>`).join('')}
232
+ </ul>
233
+ </div>
234
+ ` : ''}
235
+ </div>
236
+ ` : ''}
237
+ ${trace.requestData ? `
238
+ <div class="replay-section">
239
+ <button class="replay-btn" onclick="replayRequest('${trace.traceId}')">🔄 Replay Request</button>
240
+ <span class="replay-hint">Captured body & headers available</span>
241
+ </div>
242
+ ` : ''}
243
+ ${trace.responseData || (trace.steps || []).some(s => s.name === 'Response Body') ? `
244
+ <div class="response-viewer-section">
245
+ ${renderResponseBody(trace)}
246
+ </div>
247
+ ` : ''}
248
+ ${trace.classification === 'ERROR' || trace.classification === 'CRITICAL' ? `
249
+ <div class="error-viewer-section">
250
+ ${renderErrorDetails(trace)}
251
+ </div>
252
+ ` : ''}
253
+ </div>
254
+ </div>
255
+ `).join('')}</div>`;
256
+ }
257
+
258
+ async function replayRequest(traceId) {
259
+ if (!confirm(`Replay request ${traceId}? This will re-trigger the endpoint on the server.`)) return;
260
+
261
+ try {
262
+ const res = await fetch(`/__debugger/replay?traceId=${traceId}`);
263
+ const data = await res.json();
264
+ alert(`Replay status: ${data.status}\nTarget: ${data.target}\n${data.info}`);
265
+ } catch (err) {
266
+ alert('Replay failed: ' + err.message);
267
+ }
268
+ }
269
+
270
+ function toggleTrace(idx) {
271
+ const el = document.getElementById(`traceSteps-${idx}`);
272
+ if (el) el.classList.toggle('open');
273
+ }
274
+
275
+ // ─── Utilities ────────────────────────────────────────────
276
+ function escapeHtml(str) {
277
+ if (!str) return '';
278
+ return String(str)
279
+ .replace(/&/g, '&amp;')
280
+ .replace(/</g, '&lt;')
281
+ .replace(/>/g, '&gt;')
282
+ .replace(/"/g, '&quot;');
283
+ }
284
+
285
+ function formatNumber(n) {
286
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
287
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
288
+ return String(n);
289
+ }
290
+
291
+ // ✅ NEW: Render response body viewer (Phase 3)
292
+ function renderResponseBody(trace) {
293
+ // Look for response body in steps
294
+ const responseStep = (trace.steps || []).find(s => s.name === 'Response Body');
295
+ if (!responseStep) return '';
296
+
297
+ const body = responseStep.metadata?.body || responseStep.body;
298
+ if (!body) return '';
299
+
300
+ const size = responseStep.size || JSON.stringify(body).length;
301
+ const statusCode = responseStep.statusCode || trace.statusCode;
302
+
303
+ return `
304
+ <div class="response-body-section" style="margin-top: 16px; padding: 16px; background: rgba(16, 185, 129, 0.05); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px;">
305
+ <h4 style="margin: 0 0 12px 0; color: var(--green); font-size: 13px; font-weight: 600;">
306
+ 📤 Response Body
307
+ </h4>
308
+ <div style="display: flex; gap: 16px; margin-bottom: 12px; font-size: 12px; color: var(--text-dim);">
309
+ <span>Status: <strong style="color: var(--green)">${statusCode}</strong></span>
310
+ <span>Size: <strong>${size} bytes</strong></span>
311
+ </div>
312
+ <pre style="background: rgba(0,0,0,0.3); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 11px; color: var(--green); max-height: 400px; overflow-y: auto;">${escapeHtml(JSON.stringify(body, null, 2))}</pre>
313
+ </div>
314
+ `;
315
+ }
316
+
317
+ // ✅ NEW: Render error details with suggestions (Phase 3)
318
+ function renderErrorDetails(trace) {
319
+ const errorStep = (trace.steps || []).find(s => s.status === 'error' || s.status === 'timeout');
320
+ if (!errorStep) return '';
321
+
322
+ const errorDetails = errorStep.metadata?.errorDetails;
323
+
324
+ return `
325
+ <div class="error-details-section" style="margin-top: 16px; padding: 16px; background: rgba(239, 68, 68, 0.05); border: 1px solid rgba(239, 68, 68, 0.2); border-radius: 8px;">
326
+ <h4 style="margin: 0 0 12px 0; color: var(--red); font-size: 13px; font-weight: 600;">
327
+ 🔴 Error Details
328
+ </h4>
329
+ <div style="margin-bottom: 12px;">
330
+ <div style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Error:</div>
331
+ <div style="font-size: 13px; color: var(--red); font-family: monospace;">${escapeHtml(errorStep.error || 'Unknown error')}</div>
332
+ </div>
333
+ ${errorDetails ? `
334
+ <div style="margin-bottom: 12px;">
335
+ <div style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Type:</div>
336
+ <div style="font-size: 13px; color: var(--text);">${escapeHtml(errorDetails.type || 'Error')}</div>
337
+ </div>
338
+ ${errorDetails.code ? `
339
+ <div style="margin-bottom: 12px;">
340
+ <div style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Code:</div>
341
+ <div style="font-size: 13px; color: var(--yellow); font-family: monospace;">${escapeHtml(errorDetails.code)}</div>
342
+ </div>
343
+ ` : ''}
344
+ ${errorDetails.stack ? `
345
+ <div style="margin-bottom: 12px;">
346
+ <div style="font-size: 12px; color: var(--text-dim); margin-bottom: 4px;">Stack Trace:</div>
347
+ <pre style="background: rgba(0,0,0,0.3); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 10px; color: var(--red); max-height: 200px; overflow-y: auto; font-family: monospace;">${escapeHtml(errorDetails.stack)}</pre>
348
+ </div>
349
+ ` : ''}
350
+ ` : ''}
351
+ ${trace.rootCause?.suggestions ? `
352
+ <div style="margin-top: 16px; padding: 12px; background: rgba(59, 130, 246, 0.05); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 6px;">
353
+ <div style="font-size: 12px; color: var(--blue); font-weight: 600; margin-bottom: 8px;">💡 Suggestions:</div>
354
+ <ul style="margin: 0; padding-left: 20px; font-size: 12px; color: var(--text);">
355
+ ${trace.rootCause.suggestions.map(s => `<li style="margin-bottom: 4px;">${escapeHtml(s)}</li>`).join('')}
356
+ </ul>
357
+ </div>
358
+ ` : ''}
359
+ </div>
360
+ `;
361
+ }
362
+
363
+ async function performSearch() {
364
+ const query = document.getElementById('searchInput').value.trim();
365
+ const env = document.getElementById('envFilter').value;
366
+ const resultsDiv = document.getElementById('searchResults');
367
+
368
+ if (!query) {
369
+ resultsDiv.innerHTML = '<div class="empty-state">Enter a search term</div>';
370
+ return;
371
+ }
372
+
373
+ try {
374
+ const url = `/__debugger/search?q=${encodeURIComponent(query)}${env ? `&env=${env}` : ''}`;
375
+ const res = await fetch(url);
376
+ const data = await res.json();
377
+
378
+ if (data.error) {
379
+ resultsDiv.innerHTML = `<div class="empty-state">${data.error}</div>`;
380
+ return;
381
+ }
382
+
383
+ if (!data.results || data.results.length === 0) {
384
+ resultsDiv.innerHTML = '<div class="empty-state">No results found</div>';
385
+ return;
386
+ }
387
+
388
+ // Render results using same trace rendering logic
389
+ resultsDiv.innerHTML = `
390
+ <div style="margin-bottom: 12px; color: var(--text-dim); font-size: 13px;">
391
+ Found ${data.count} result${data.count !== 1 ? 's' : ''} for "${escapeHtml(query)}"
392
+ </div>
393
+ <div class="trace-list">${data.results.map((trace, idx) => {
394
+ const hasPayload = trace.payloadSize && trace.payloadSize > 0;
395
+ const payloadMB = hasPayload ? (trace.payloadSize / (1024 * 1024)).toFixed(2) : null;
396
+ return `
397
+ <div class="trace-item">
398
+ <div class="trace-header" onclick="toggleSearchTrace(${idx})">
399
+ <div class="trace-header-left">
400
+ <span class="method-badge method-${trace.method}">${trace.method}</span>
401
+ <span class="trace-endpoint">${escapeHtml(trace.endpoint)}</span>
402
+ <span class="trace-id">${trace.traceId}</span>
403
+ <span class="badge" style="background: rgba(124, 58, 237, 0.1); color: var(--accent-light); font-size: 10px; border: 1px solid var(--border)">${trace.language === 'python' ? 'Python' : 'Node'}</span>
404
+ ${trace.environment ? `<span class="badge" style="background: rgba(124,58,237,0.2); color: var(--purple); font-size: 10px; padding: 2px 6px;">${trace.environment}</span>` : ''}
405
+ ${hasPayload && payloadMB > 1 ? `<span class="badge" style="background: rgba(251,191,36,0.2); color: var(--yellow); font-size: 10px; padding: 2px 6px;">📦 ${payloadMB}MB</span>` : ''}
406
+ </div>
407
+ <div class="trace-header-right">
408
+ <span class="classification-badge classification-${trace.classification}">${trace.classification}</span>
409
+ <span class="trace-dur">${Math.round(trace.totalDuration)}ms</span>
410
+ <span style="color: var(--text-dim); font-size: 12px">▼</span>
411
+ </div>
412
+ </div>
413
+ <div class="trace-steps" id="searchTraceSteps-${idx}">
414
+ <div class="step-timeline">
415
+ ${(trace.steps || []).map(step => {
416
+ const cls = step.status === 'error' ? 'step-error' :
417
+ step.status === 'timeout' ? 'step-timeout' :
418
+ step.classification === 'WARN' ? 'step-slow' : '';
419
+ const icon = step.status === 'success' ? '✔' : step.status === 'timeout' ? '⏱' : '❌';
420
+ return `
421
+ <div class="step-item ${cls}">
422
+ <span class="step-offset">[${Math.round(step.startTime)}ms]</span>
423
+ <span class="step-name">${escapeHtml(step.name)}</span>
424
+ <div class="waterfall-container">
425
+ <div class="waterfall-bar classification-${step.classification}"
426
+ style="left: ${(step.offset / trace.totalDuration) * 100}%; width: ${Math.max((step.duration / trace.totalDuration) * 100, 0.5)}%">
427
+ </div>
428
+ </div>
429
+ <span class="step-status-icon">${icon}</span>
430
+ <span class="step-dur">${Math.round(step.duration)}ms</span>
431
+ ${step.service !== 'internal' ? `<span class="step-service-tag">${step.service}</span>` : ''}
432
+ </div>
433
+ ${step.error ? `<div class="step-item" style="padding-left: 92px; color: var(--red); font-size: 11px">└─ ${escapeHtml(step.error)}</div>` : ''}
434
+ ${step.errorFile ? `<div class="step-item" style="padding-left: 92px; color: var(--purple); font-size: 10px; font-family: monospace"> 📄 ${escapeHtml(step.errorFile)}${step.errorLine ? `:${step.errorLine}` : ''}</div>` : ''}
435
+ `;
436
+ }).join('')}
437
+ </div>
438
+ ${trace.rootCause ? `
439
+ <div class="root-cause-box">
440
+ <div class="rc-label">🔍 Root Cause</div>
441
+ <div class="rc-detail">${escapeHtml(trace.rootCause.cause)} · Service: ${trace.rootCause.service} · Confidence: ${trace.rootCause.confidence}%</div>
442
+ </div>
443
+ ` : ''}
444
+ ${trace.requestData ? `
445
+ <div class="replay-section">
446
+ <button class="replay-btn" onclick="replayRequest('${trace.traceId}')">🔄 Replay Request</button>
447
+ </div>
448
+ ` : ''}
449
+ </div>
450
+ </div>
451
+ `;
452
+ }).join('')}</div>
453
+ `;
454
+ } catch (err) {
455
+ resultsDiv.innerHTML = '<div class="empty-state">Search failed</div>';
456
+ }
457
+ }
458
+
459
+ function toggleSearchTrace(idx) {
460
+ const el = document.getElementById(`searchTraceSteps-${idx}`);
461
+ if (el) el.classList.toggle('open');
462
+ }
463
+
464
+ function formatDuration(ms) {
465
+ const secs = Math.floor(ms / 1000);
466
+ if (secs < 60) return secs + 's';
467
+ const mins = Math.floor(secs / 60);
468
+ if (mins < 60) return mins + 'm ' + (secs % 60) + 's';
469
+ const hrs = Math.floor(mins / 60);
470
+ return hrs + 'h ' + (mins % 60) + 'm';
471
+ }
472
+
473
+ function seedSampleData(data) {
474
+ return {
475
+ ...data,
476
+ uptime: 124000,
477
+ totalRequests: 1,
478
+ totalErrors: 0,
479
+ totalSlow: 0,
480
+ serviceHealth: [
481
+ { name: 'mongo', status: 'healthy', successRate: 100, totalChecks: 12 },
482
+ { name: 'redis', status: 'degraded', successRate: 92, totalChecks: 45 },
483
+ { name: 'postgres', status: 'healthy', successRate: 100, totalChecks: 5 }
484
+ ],
485
+ endpoints: [
486
+ { method: 'GET', path: '/api/users', totalRequests: 1, errorCount: 0, slowCount: 0, avgDuration: 42, p95Duration: 42, maxDuration: 42, commonIssues: [] }
487
+ ],
488
+ recentTraces: [
489
+ {
490
+ traceId: 'sample_trace_123',
491
+ endpoint: '/api/users',
492
+ method: 'GET',
493
+ totalDuration: 156,
494
+ classification: 'INFO',
495
+ language: 'node',
496
+ steps: [
497
+ { name: 'Auth Check', service: 'internal', status: 'success', classification: 'INFO', startTime: 2, endTime: 12, duration: 10, offset: 2 },
498
+ { name: 'DB Find User', service: 'mongo', status: 'success', classification: 'INFO', startTime: 15, endTime: 145, duration: 130, offset: 15 },
499
+ { name: 'Redis Cache', service: 'redis', status: 'success', classification: 'INFO', startTime: 148, endTime: 155, duration: 7, offset: 148 }
500
+ ]
501
+ }
502
+ ]
503
+ };
504
+ }
505
+