ddapm-test-agent 1.36.0__py3-none-any.whl → 1.38.0__py3-none-any.whl

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.
@@ -0,0 +1,1925 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="requests-page">
5
+ <div class="page-header">
6
+ <h2>Requests</h2>
7
+ </div>
8
+
9
+ <div class="requests-container">
10
+ <div class="requests-controls">
11
+ <div class="filter-row">
12
+ <input type="text" id="filter-query" placeholder="Filter: method:POST -path:/info headers.user-agent:curl status:200..." class="filter-query-input">
13
+ <div class="tooltip-wrapper">
14
+ <button class="action-button small" onclick="clearAllFilters()">Clear</button>
15
+ <span class="tooltip-text">Clear all filters</span>
16
+ </div>
17
+ <div class="tooltip-wrapper">
18
+ <button class="action-button small" onclick="showFilterHelp()">Help</button>
19
+ <span class="tooltip-text">Show filter syntax guide</span>
20
+ </div>
21
+ </div>
22
+ <div class="stats-row">
23
+ <div class="request-stats">
24
+ <span class="stat-item">Total: <span id="total-count">{{ requests|length }}</span></span>
25
+ <span class="stat-item">Showing: <span id="visible-count">{{ requests|length }}</span></span>
26
+ </div>
27
+ <div class="action-controls">
28
+ <div class="tooltip-wrapper">
29
+ <button class="action-button small" onclick="refreshRequests()">Refresh</button>
30
+ <span class="tooltip-text">Reload all requests from server</span>
31
+ </div>
32
+ <div class="tooltip-wrapper">
33
+ <button id="pause-sse-btn" class="action-button small" onclick="toggleSSE()">Pause</button>
34
+ <span class="tooltip-text">Pause/resume live request streaming</span>
35
+ </div>
36
+ <div class="tooltip-wrapper">
37
+ <button class="action-button small" onclick="downloadRequests()">Download</button>
38
+ <span class="tooltip-text">Download visible requests as JSON</span>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ {% if requests %}
45
+ <div class="requests-table-container">
46
+ <table class="requests-table">
47
+ <thead>
48
+ <tr>
49
+ <th>Time</th>
50
+ <th>Method</th>
51
+ <th>Path</th>
52
+ <th>Size</th>
53
+ </tr>
54
+ </thead>
55
+ <tbody>
56
+ {% for req in requests %}
57
+ <tr class="request-row" onclick="toggleRequestDetails('{{ loop.index0 }}')" style="cursor: pointer;">
58
+ <td class="timestamp-cell">
59
+ {{ req.timestamp | timestamp_format if req.timestamp }}
60
+ </td>
61
+ <td class="method-cell">
62
+ <span class="method-badge method-{{ req.method.lower() }}">{{ req.method }}</span>
63
+ </td>
64
+ <td class="path-cell">
65
+ <div class="path-info">
66
+ <span class="path">{{ req.path }}</span>
67
+ {% if req.query_string %}
68
+ <span class="query-string">?{{ req.query_string }}</span>
69
+ {% endif %}
70
+ {% if req.session_token %}
71
+ <span class="session-token">session: {{ req.session_token }}</span>
72
+ {% endif %}
73
+ </div>
74
+ </td>
75
+ <td class="size-cell">
76
+ {{ req.content_length }} bytes
77
+ </td>
78
+ </tr>
79
+ <tr class="request-details" id="details-{{ loop.index0 }}" style="display: none;">
80
+ <td colspan="4">
81
+ <div class="request-details-content">
82
+ <div class="details-tabs">
83
+ <button class="tab-button active" onclick="switchTab('{{ loop.index0 }}', 'request')">Request</button>
84
+ <button class="tab-button" onclick="switchTab('{{ loop.index0 }}', 'response')">Response</button>
85
+ {% if req.trace_data %}
86
+ <button class="tab-button" onclick="switchTab('{{ loop.index0 }}', 'traces')">Traces</button>
87
+ {% endif %}
88
+ </div>
89
+
90
+ <div class="tab-content" id="request-tab-{{ loop.index0 }}">
91
+ <div class="details-section">
92
+ <h4>Headers</h4>
93
+ <div class="metadata-grid">
94
+ {% for header, value in req.headers.items() %}
95
+ <div class="metadata-item">
96
+ <span class="metadata-label">{{ header }}:</span>
97
+ <span class="metadata-value">{{ value }}</span>
98
+ </div>
99
+ {% endfor %}
100
+ </div>
101
+ </div>
102
+ {% if req.query_params %}
103
+ <div class="details-section">
104
+ <h4>Query Parameters</h4>
105
+ <div class="metadata-grid">
106
+ {% for param_name, param_values in req.query_params.items() %}
107
+ <div class="metadata-item">
108
+ <span class="metadata-label">{{ param_name }}:</span>
109
+ <span class="metadata-value">{{ param_values | join(', ') }}</span>
110
+ </div>
111
+ {% endfor %}
112
+ </div>
113
+ </div>
114
+ {% endif %}
115
+ {% if req.body %}
116
+ <div class="details-section">
117
+ <h4>Body</h4>
118
+ <div class="body-container">
119
+ {% if req.multipart_data %}
120
+ <div class="body-section">
121
+ <h5>Multipart Form Data</h5>
122
+ <div class="multipart-data">
123
+ {% for key, value in req.multipart_data.items() %}
124
+ <div class="multipart-field">
125
+ <div class="field-header">
126
+ <strong>{{ key }}:</strong>
127
+ {% if key == 'flare_file' %}
128
+ <span class="field-type">[ZIP file, {{ value|length }} chars base64]</span>
129
+ {% else %}
130
+ <span class="field-type">[text]</span>
131
+ {% endif %}
132
+ </div>
133
+ <div class="field-value">
134
+ <pre><code>{% if key == 'flare_file' %}{{ value[:500] }}{% if value|length > 500 %}...{% endif %}{% else %}{{ value }}{% endif %}</code></pre>
135
+ </div>
136
+ </div>
137
+ {% endfor %}
138
+ </div>
139
+ </div>
140
+ {% else %}
141
+ {% if not (req.content_type and 'multipart' in req.content_type.lower()) %}
142
+ <div class="body-section">
143
+ <h5>Raw</h5>
144
+ <div class="request-body">
145
+ {% if req.content_type and 'msgpack' in req.content_type.lower() %}
146
+ <pre><code class="raw-bytes" data-body="{{ req.body }}">Loading hex view...</code></pre>
147
+ {% else %}
148
+ <pre><code>{{ req.body }}</code></pre>
149
+ {% endif %}
150
+ </div>
151
+ </div>
152
+ {% endif %}
153
+ {% if req.content_type and ('json' in req.content_type.lower() or 'msgpack' in req.content_type.lower() or 'multipart' in req.content_type.lower()) %}
154
+ <div class="body-section">
155
+ <h5>Decoded</h5>
156
+ <div class="decoded-body" data-content-type="{{ req.content_type }}" data-body="{{ req.body }}">
157
+ <!-- Decoded content will be inserted here by JavaScript -->
158
+ </div>
159
+ </div>
160
+ {% endif %}
161
+ {% endif %}
162
+ </div>
163
+ </div>
164
+ {% endif %}
165
+ </div>
166
+
167
+ <div class="tab-content" id="response-tab-{{ loop.index0 }}" style="display: none;">
168
+ <div class="details-section">
169
+ <h4>Response Headers</h4>
170
+ <div class="metadata-grid">
171
+ <div class="metadata-item">
172
+ <span class="metadata-label">Status:</span>
173
+ <span class="metadata-value">{{ req.response.status }}</span>
174
+ </div>
175
+ <div class="metadata-item">
176
+ <span class="metadata-label">Content-Type:</span>
177
+ <span class="metadata-value">{{ req.response.content_type or 'text/plain' }}</span>
178
+ </div>
179
+ {% for header, value in req.response.headers.items() %}
180
+ <div class="metadata-item">
181
+ <span class="metadata-label">{{ header }}:</span>
182
+ <span class="metadata-value">{{ value }}</span>
183
+ </div>
184
+ {% endfor %}
185
+ </div>
186
+ </div>
187
+ <div class="details-section">
188
+ <h4>Response Body</h4>
189
+ <div class="body-container">
190
+ {% if not (req.response.content_type and 'multipart' in req.response.content_type.lower()) %}
191
+ <div class="body-section">
192
+ <h5>Raw</h5>
193
+ <div class="response-body">
194
+ {% if req.response.body_is_binary %}
195
+ <pre><code class="raw-bytes" data-body="{{ req.response.body }}">Loading hex view...</code></pre>
196
+ {% else %}
197
+ <pre><code>{{ req.response.body or 'Empty response body' }}</code></pre>
198
+ {% endif %}
199
+ </div>
200
+ </div>
201
+ {% endif %}
202
+ {% if req.response.content_type and ('json' in req.response.content_type.lower() or 'msgpack' in req.response.content_type.lower() or 'multipart' in req.response.content_type.lower()) %}
203
+ <div class="body-section">
204
+ <h5>Decoded</h5>
205
+ {% if req.response.body_is_binary %}
206
+ <div class="decoded-body" data-content-type="{{ req.response.content_type }}" data-body="{{ req.response.body }}" data-is-binary="true">
207
+ {% else %}
208
+ <div class="decoded-body" data-content-type="{{ req.response.content_type }}" data-body="{{ req.response.body }}">
209
+ {% endif %}
210
+ <pre><code>Loading response...</code></pre>
211
+ </div>
212
+ </div>
213
+ {% endif %}
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ {% if req.trace_data %}
219
+ <div class="tab-content" id="traces-tab-{{ loop.index0 }}" style="display: none;">
220
+ <!-- Hidden element containing trace data for JavaScript access -->
221
+ <div class="trace-data-container" data-trace-data-b64="{{ req.trace_data.trace_data_b64 }}" style="display: none;"></div>
222
+
223
+ <div class="details-section">
224
+ <h4>Trace Information</h4>
225
+ <div class="metadata-grid">
226
+ <div class="metadata-item">
227
+ <span class="metadata-label">Traces:</span>
228
+ <span class="metadata-value">{{ req.trace_data.trace_count }}</span>
229
+ </div>
230
+ <div class="metadata-item">
231
+ <span class="metadata-label">Spans:</span>
232
+ <span class="metadata-value">{{ req.trace_data.span_count }}</span>
233
+ </div>
234
+ </div>
235
+ </div>
236
+
237
+ <div class="details-section">
238
+ <h4>Trace Visualization</h4>
239
+ <div class="trace-container">
240
+ <div class="trace-controls">
241
+ <button class="action-button small" onclick="toggleTraceView('{{ loop.index0 }}')">JSON View</button>
242
+ </div>
243
+
244
+ <div id="waterfall-view-{{ loop.index0 }}" class="waterfall-container">
245
+ <!-- Waterfall visualization will be generated here -->
246
+ </div>
247
+
248
+ <div id="json-view-{{ loop.index0 }}" class="code-container" style="display: none;">
249
+ <pre class="code-block"><code>{{ req.trace_data.traces | tojson(indent=2) }}</code></pre>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ {% endif %}
255
+
256
+ </div>
257
+ </td>
258
+ </tr>
259
+ {% endfor %}
260
+ </tbody>
261
+ </table>
262
+ </div>
263
+ {% else %}
264
+ <div class="empty-state">
265
+ <p>No requests received yet</p>
266
+ <p class="empty-subtitle">Requests will appear here as the test agent receives them</p>
267
+ </div>
268
+ {% endif %}
269
+ </div>
270
+ </div>
271
+
272
+ <script src="https://msgpack.org/js/msgpack.js"></script>
273
+ <script>
274
+ // SSE state management (global scope)
275
+ let sseEventSource = null;
276
+ let ssePaused = false;
277
+ let sseQueuedRequests = [];
278
+
279
+ // Real-time filtering
280
+ document.addEventListener('DOMContentLoaded', function() {
281
+ const filterQuery = document.getElementById('filter-query');
282
+ // Store full request objects for filtering
283
+ window.allRequests = [
284
+ {% for req in requests %}
285
+ {
286
+ method: {{ req.method | tojson }},
287
+ path: {{ req.path | tojson }},
288
+ session_token: {{ req.session_token | tojson }},
289
+ content_length: {{ req.content_length or 0 }},
290
+ timestamp: {{ req.timestamp or 0 }},
291
+ headers: {{ req.headers | tojson }},
292
+ query_string: {{ req.query_string | tojson }},
293
+ body: {{ req.body | tojson }},
294
+ response: {
295
+ status: {{ req.response.status if req.response else 'null' }},
296
+ headers: {{ req.response.headers | tojson if req.response else '{}' }},
297
+ body: {{ req.response.body | tojson if req.response else 'null' }}
298
+ }
299
+ }{% if not loop.last %},{% endif %}
300
+ {% endfor %}
301
+ ];
302
+ const visibleCount = document.getElementById('visible-count');
303
+
304
+ // Parse URL parameters for filter persistence
305
+ const urlParams = new URLSearchParams(window.location.search);
306
+ const filterParam = urlParams.get('filter');
307
+ if (filterParam) {
308
+ filterQuery.value = decodeURIComponent(filterParam);
309
+ }
310
+
311
+ function parseFilterQuery(queryString) {
312
+ const filters = [];
313
+ if (!queryString.trim()) return filters;
314
+
315
+ // Split by space but handle quoted strings
316
+ const tokens = queryString.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
317
+
318
+ tokens.forEach(token => {
319
+ const isExclude = token.startsWith('-');
320
+ const cleanToken = isExclude ? token.slice(1) : token;
321
+ const colonIndex = cleanToken.indexOf(':');
322
+
323
+ if (colonIndex > 0) {
324
+ const key = cleanToken.slice(0, colonIndex).trim();
325
+ let value = cleanToken.slice(colonIndex + 1).trim();
326
+
327
+ // Remove quotes if present
328
+ if (value.startsWith('"') && value.endsWith('"')) {
329
+ value = value.slice(1, -1);
330
+ }
331
+
332
+ filters.push({ key, value, exclude: isExclude });
333
+ }
334
+ });
335
+
336
+ return filters;
337
+ }
338
+
339
+ function getNestedValue(obj, path) {
340
+ return path.split('.').reduce((current, key) => {
341
+ return current && current[key] !== undefined ? current[key] : undefined;
342
+ }, obj);
343
+ }
344
+
345
+ function matchesFilter(request, filter) {
346
+ const { key, value, exclude } = filter;
347
+ let actualValue;
348
+
349
+
350
+ // Handle different filter keys
351
+ switch (key.toLowerCase()) {
352
+ case 'method':
353
+ actualValue = request.method?.toLowerCase();
354
+ break;
355
+ case 'path':
356
+ actualValue = request.path?.toLowerCase();
357
+ break;
358
+ case 'status':
359
+ actualValue = request.response?.status?.toString();
360
+ break;
361
+ case 'session':
362
+ actualValue = request.session_token?.toLowerCase();
363
+ break;
364
+ case 'size':
365
+ return matchNumericFilter(request.content_length || 0, value);
366
+ case 'duration':
367
+ return matchNumericFilter(request.duration_ms || 0, value);
368
+ case 'body':
369
+ actualValue = request.body?.toLowerCase();
370
+ break;
371
+ default:
372
+ // Handle nested keys like headers.user-agent or response.headers.content-type
373
+ if (key.startsWith('headers.')) {
374
+ const headerName = key.slice(8).toLowerCase();
375
+ const headers = getNestedValue(request, 'headers');
376
+ // Case-insensitive header lookup
377
+ if (headers) {
378
+ const headerKey = Object.keys(headers).find(k => k.toLowerCase() === headerName);
379
+ actualValue = headerKey ? headers[headerKey]?.toLowerCase() : undefined;
380
+ }
381
+ } else if (key.startsWith('response.headers.')) {
382
+ const headerName = key.slice(17).toLowerCase();
383
+ const headers = getNestedValue(request, 'response.headers');
384
+ // Case-insensitive header lookup
385
+ if (headers) {
386
+ const headerKey = Object.keys(headers).find(k => k.toLowerCase() === headerName);
387
+ actualValue = headerKey ? headers[headerKey]?.toLowerCase() : undefined;
388
+ }
389
+ } else if (key.startsWith('response.body')) {
390
+ actualValue = getNestedValue(request, 'response.body')?.toLowerCase();
391
+ } else if (key.startsWith('response.')) {
392
+ const respKey = key.slice(9);
393
+ actualValue = getNestedValue(request, `response.${respKey}`)?.toString()?.toLowerCase();
394
+ } else {
395
+ actualValue = getNestedValue(request, key)?.toString()?.toLowerCase();
396
+ }
397
+ break;
398
+ }
399
+
400
+ if (actualValue === undefined || actualValue === null) {
401
+ return exclude; // If field doesn't exist, exclude filters pass, include filters fail
402
+ }
403
+
404
+ const searchValue = value.toLowerCase();
405
+ let matches = false;
406
+
407
+ if (searchValue === '*') {
408
+ matches = actualValue !== '';
409
+ } else if (searchValue.includes('*')) {
410
+ // Simple wildcard matching
411
+ const regex = new RegExp('^' + searchValue.replace(/\*/g, '.*') + '$');
412
+ matches = regex.test(actualValue);
413
+ } else {
414
+ matches = actualValue.includes(searchValue);
415
+ }
416
+
417
+ const result = exclude ? !matches : matches;
418
+ if (key === 'path' && exclude) {
419
+ console.log('Exclusion debug:', { key, value, exclude, actualValue, matches, result });
420
+ }
421
+ return result;
422
+ }
423
+
424
+ function matchNumericFilter(actualValue, filterValue) {
425
+ const num = parseFloat(actualValue);
426
+ if (isNaN(num)) return false;
427
+
428
+ if (filterValue.startsWith('>')) {
429
+ return num > parseFloat(filterValue.slice(1));
430
+ } else if (filterValue.startsWith('<')) {
431
+ return num < parseFloat(filterValue.slice(1));
432
+ } else if (filterValue.startsWith('>=')) {
433
+ return num >= parseFloat(filterValue.slice(2));
434
+ } else if (filterValue.startsWith('<=')) {
435
+ return num <= parseFloat(filterValue.slice(2));
436
+ } else {
437
+ return num === parseFloat(filterValue);
438
+ }
439
+ }
440
+
441
+ function filterRequests() {
442
+ const queryString = filterQuery.value;
443
+ const filters = parseFilterQuery(queryString);
444
+
445
+ // Update URL with filter parameter
446
+ const url = new URL(window.location);
447
+ if (queryString.trim()) {
448
+ url.searchParams.set('filter', encodeURIComponent(queryString));
449
+ } else {
450
+ url.searchParams.delete('filter');
451
+ }
452
+ window.history.replaceState({}, '', url);
453
+
454
+ const rows = document.querySelectorAll('.request-row');
455
+ let visibleRows = 0;
456
+
457
+ rows.forEach((row, index) => {
458
+ // Get the full request object from our global array
459
+ const request = window.allRequests[index];
460
+
461
+ if (!request) {
462
+ // Fallback to showing the row if we don't have request data
463
+ row.style.display = '';
464
+ visibleRows++;
465
+ return;
466
+ }
467
+
468
+ // Apply all filters (AND logic)
469
+ let shouldShow = true;
470
+ for (const filter of filters) {
471
+ const filterResult = matchesFilter(request, filter);
472
+ if (!filterResult) {
473
+ shouldShow = false;
474
+ break;
475
+ }
476
+ }
477
+
478
+ // Debug for exclusion filters
479
+ if (filters.some(f => f.exclude && f.key === 'path')) {
480
+ console.log('Filter decision:', { path: request.path, shouldShow, filters });
481
+ }
482
+
483
+ if (shouldShow) {
484
+ row.style.display = '';
485
+ const detailsRow = row.nextElementSibling;
486
+ if (detailsRow && detailsRow.classList.contains('request-details') && detailsRow.style.display !== 'none') {
487
+ detailsRow.style.display = '';
488
+ }
489
+ visibleRows++;
490
+ } else {
491
+ row.style.display = 'none';
492
+ const detailsRow = row.nextElementSibling;
493
+ if (detailsRow && detailsRow.classList.contains('request-details')) {
494
+ detailsRow.style.display = 'none';
495
+ }
496
+ }
497
+ });
498
+
499
+ visibleCount.textContent = visibleRows;
500
+ }
501
+
502
+ // Set up Server-Sent Events for real-time updates
503
+ function setupSSE() {
504
+ sseEventSource = new EventSource('/requests/stream');
505
+
506
+ sseEventSource.onmessage = function(event) {
507
+ const data = JSON.parse(event.data);
508
+
509
+ if (data.type === 'new_request') {
510
+ if (ssePaused) {
511
+ // Queue the request for later processing
512
+ sseQueuedRequests.push(data);
513
+ updatePauseButton();
514
+ } else {
515
+ addNewRequestToTable(data.request);
516
+ updateRequestCount(data.total_count);
517
+ }
518
+ } else if (data.type === 'count') {
519
+ updateRequestCount(data.count);
520
+ }
521
+ };
522
+
523
+ sseEventSource.onerror = function(event) {
524
+ console.log('SSE connection error:', event);
525
+ // Reconnect after 5 seconds
526
+ setTimeout(() => {
527
+ sseEventSource.close();
528
+ setupSSE();
529
+ }, 5000);
530
+ };
531
+ }
532
+
533
+
534
+ function addNewRequestToTable(request) {
535
+ // Add to beginning of array to match DOM insertion order (newest first)
536
+ window.allRequests.unshift(request);
537
+
538
+ let tbody = document.querySelector('.requests-table tbody');
539
+
540
+ // If no table exists (empty state), create the table structure
541
+ if (!tbody) {
542
+ // Remove empty state if it exists
543
+ const emptyState = document.querySelector('.empty-state');
544
+ if (emptyState) {
545
+ emptyState.remove();
546
+ console.log('Removed empty state');
547
+ }
548
+
549
+ const requestsContainer = document.querySelector('.requests-table-container') ||
550
+ document.querySelector('.requests-container');
551
+
552
+ // Create the table structure
553
+ const tableHTML = `
554
+ <div class="requests-table-container">
555
+ <table class="requests-table">
556
+ <thead>
557
+ <tr>
558
+ <th>Time</th>
559
+ <th>Method</th>
560
+ <th>Path</th>
561
+ <th>Size</th>
562
+ </tr>
563
+ </thead>
564
+ <tbody>
565
+ </tbody>
566
+ </table>
567
+ </div>
568
+ `;
569
+
570
+ if (requestsContainer && requestsContainer.classList.contains('requests-container')) {
571
+ requestsContainer.insertAdjacentHTML('beforeend', tableHTML);
572
+ } else {
573
+ document.querySelector('.requests-container').insertAdjacentHTML('beforeend', tableHTML);
574
+ }
575
+
576
+ tbody = document.querySelector('.requests-table tbody');
577
+ }
578
+
579
+ // Create new row HTML - new row will be at index 0 since we insert at top
580
+ const newRow = createRequestRow(request, 0);
581
+
582
+ // Insert at the top (most recent first)
583
+ tbody.insertAdjacentHTML('afterbegin', newRow);
584
+
585
+ // Update indices for all existing rows since we pushed them down
586
+ updateAllRowIndices();
587
+
588
+ // Process body decoding for the new request - get only the newly added elements
589
+ const lastAddedRow = tbody.querySelector('.request-row');
590
+ if (lastAddedRow) {
591
+ // The details row is the next sibling of the request row
592
+ const detailsRow = lastAddedRow.nextElementSibling;
593
+ if (detailsRow && detailsRow.classList.contains('request-details')) {
594
+ const newDecodedBodyElements = detailsRow.querySelectorAll('.decoded-body[data-body]');
595
+
596
+ console.log('Found decoded body elements:', newDecodedBodyElements.length);
597
+ newDecodedBodyElements.forEach(element => {
598
+ const contentType = element.getAttribute('data-content-type');
599
+ const bodyData = element.getAttribute('data-body');
600
+ console.log('Processing body:', {contentType, bodyDataLength: bodyData ? bodyData.length : 0});
601
+
602
+ if (bodyData && bodyData.trim()) {
603
+ try {
604
+ processBodyDecoding(element, contentType, bodyData);
605
+ } catch (error) {
606
+ console.error('Error processing body decoding:', error);
607
+ // Continue processing other elements even if one fails
608
+ }
609
+ }
610
+ });
611
+ }
612
+ }
613
+
614
+ // Process raw bytes display for msgpack in new requests - get only the newly added elements
615
+ if (lastAddedRow && lastAddedRow.nextElementSibling) {
616
+ const newRawBytesElements = lastAddedRow.nextElementSibling.querySelectorAll('.raw-bytes[data-body]');
617
+ newRawBytesElements.forEach(element => {
618
+ const bodyData = element.getAttribute('data-body');
619
+ if (bodyData) {
620
+ try {
621
+ // Decode base64 to get actual bytes
622
+ const binaryString = atob(bodyData);
623
+ const uint8Array = new Uint8Array(binaryString.length);
624
+ for (let i = 0; i < binaryString.length; i++) {
625
+ uint8Array[i] = binaryString.charCodeAt(i);
626
+ }
627
+ const hexString = Array.from(uint8Array)
628
+ .map(b => b.toString(16).padStart(2, '0'))
629
+ .join(' ');
630
+ element.textContent = hexString;
631
+ } catch (e) {
632
+ element.textContent = 'Error decoding base64: ' + e.message;
633
+ }
634
+ }
635
+ });
636
+
637
+
638
+ // Re-apply filters to include the new request
639
+ filterRequests();
640
+ }
641
+ }
642
+
643
+ function updateAllRowIndices() {
644
+ // Update onclick handlers and IDs for all rows to match their new positions
645
+ const rows = document.querySelectorAll('.request-row');
646
+ rows.forEach((row, index) => {
647
+ // Update onclick handler
648
+ row.setAttribute('onclick', `toggleRequestDetails('${index}')`);
649
+
650
+ // Update the details row ID if it exists
651
+ const detailsRow = row.nextElementSibling;
652
+ if (detailsRow && detailsRow.classList.contains('request-details')) {
653
+ detailsRow.id = `details-${index}`;
654
+
655
+ // Update all tab IDs within the details row
656
+ const tabIds = ['info-tab', 'headers-tab', 'body-tab', 'response-tab', 'traces-tab'];
657
+ tabIds.forEach(tabId => {
658
+ const tab = detailsRow.querySelector(`[id^="${tabId}"]`);
659
+ if (tab) {
660
+ tab.id = `${tabId}-${index}`;
661
+ }
662
+ });
663
+ }
664
+ });
665
+ }
666
+
667
+ function createRequestRow(req, index) {
668
+ console.log('Creating request row with data:', { timestamp: req.timestamp, method: req.method, path: req.path });
669
+ const sessionToken = req.session_token ? `<span class="session-token">session: ${req.session_token}</span>` : '';
670
+ const queryString = req.query_string ? `<span class="query-string">?${req.query_string}</span>` : '';
671
+ const body = req.body || '';
672
+
673
+ let headersHTML = '';
674
+ for (const [header, value] of Object.entries(req.headers)) {
675
+ headersHTML += `
676
+ <div class="metadata-item">
677
+ <span class="metadata-label">${header}:</span>
678
+ <span class="metadata-value">${value}</span>
679
+ </div>
680
+ `;
681
+ }
682
+
683
+ return `
684
+ <tr class="request-row" onclick="toggleRequestDetails('${index}')" style="cursor: pointer;">
685
+ <td class="timestamp-cell">
686
+ ${req.timestamp ? formatTimestamp(req.timestamp) : ''}
687
+ </td>
688
+ <td class="method-cell">
689
+ <span class="method-badge method-${req.method.toLowerCase()}">${req.method}</span>
690
+ </td>
691
+ <td class="path-cell">
692
+ <div class="path-info">
693
+ <span class="path">${req.path}</span>
694
+ ${queryString}
695
+ ${sessionToken}
696
+ </div>
697
+ </td>
698
+ <td class="size-cell">
699
+ ${req.content_length} bytes
700
+ </td>
701
+ </tr>
702
+ <tr class="request-details" id="details-${index}" style="display: none;">
703
+ <td colspan="4">
704
+ <div class="request-details-content">
705
+ <div class="details-tabs">
706
+ <button class="tab-button active" onclick="switchTab('${index}', 'request')">Request</button>
707
+ <button class="tab-button" onclick="switchTab('${index}', 'response')">Response</button>
708
+ ${req.trace_data ? `<button class="tab-button" onclick="switchTab('${index}', 'traces')">Traces</button>` : ''}
709
+ </div>
710
+
711
+ <div class="tab-content" id="request-tab-${index}">
712
+ <div class="details-section">
713
+ <h4>Headers</h4>
714
+ <div class="metadata-grid">
715
+ ${headersHTML}
716
+ </div>
717
+ </div>
718
+ ${req.query_params && Object.keys(req.query_params).length > 0 ? `
719
+ <div class="details-section">
720
+ <h4>Query Parameters</h4>
721
+ <div class="metadata-grid">
722
+ ${Object.entries(req.query_params).map(([name, values]) =>
723
+ `<div class="metadata-item">
724
+ <span class="metadata-label">${name}:</span>
725
+ <span class="metadata-value">${Array.isArray(values) ? values.join(', ') : values}</span>
726
+ </div>`
727
+ ).join('')}
728
+ </div>
729
+ </div>
730
+ ` : ''}
731
+ ${body ? `
732
+ <div class="details-section">
733
+ <h4>Body</h4>
734
+ <div class="body-container">
735
+ ${!(req.content_type && req.content_type.toLowerCase().includes('multipart')) ? `<div class="body-section">
736
+ <h5>Raw</h5>
737
+ <div class="request-body">
738
+ ${(req.content_type && req.content_type.toLowerCase().includes('msgpack')) ?
739
+ `<pre><code class="raw-bytes" data-body="${body ? body.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : ''}">Loading hex view...</code></pre>` :
740
+ `<pre><code>${body}</code></pre>`
741
+ }
742
+ </div>
743
+ </div>` : ''}
744
+ ${(req.content_type && (req.content_type.toLowerCase().includes('json') || req.content_type.toLowerCase().includes('msgpack') || req.content_type.toLowerCase().includes('multipart'))) ?
745
+ `<div class="body-section">
746
+ <h5>Decoded</h5>
747
+ <div class="decoded-body" data-content-type="${req.content_type || ''}" data-body="${body ? body.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : ''}">
748
+ <!-- Decoded content will be inserted here by JavaScript -->
749
+ </div>
750
+ </div>` : ''}
751
+ </div>
752
+ </div>
753
+ ` : ''}
754
+ </div>
755
+
756
+ <div class="tab-content" id="response-tab-${index}" style="display: none;">
757
+ <div class="details-section">
758
+ <h4>Response Headers</h4>
759
+ <div class="metadata-grid">
760
+ <div class="metadata-item">
761
+ <span class="metadata-label">Status:</span>
762
+ <span class="metadata-value">${req.response ? req.response.status : '200'}</span>
763
+ </div>
764
+ <div class="metadata-item">
765
+ <span class="metadata-label">Content-Type:</span>
766
+ <span class="metadata-value">${req.response ? (req.response.content_type || 'text/plain') : 'text/plain'}</span>
767
+ </div>
768
+ ${req.response && req.response.headers ? Object.entries(req.response.headers).map(([header, value]) => `
769
+ <div class="metadata-item">
770
+ <span class="metadata-label">${header}:</span>
771
+ <span class="metadata-value">${value}</span>
772
+ </div>
773
+ `).join('') : ''}
774
+ </div>
775
+ </div>
776
+ <div class="details-section">
777
+ <h4>Response Body</h4>
778
+ <div class="body-container">
779
+ ${!(req.response && req.response.content_type && req.response.content_type.toLowerCase().includes('multipart')) ? `<div class="body-section">
780
+ <h5>Raw</h5>
781
+ <div class="response-body">
782
+ ${req.response && req.response.body_is_binary ?
783
+ `<pre><code class="raw-bytes" data-body="${req.response.body ? req.response.body.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : ''}">Loading hex view...</code></pre>` :
784
+ `<pre><code>${req.response && req.response.body ? req.response.body : 'Empty response body'}</code></pre>`
785
+ }
786
+ </div>
787
+ </div>` : ''}
788
+ ${(req.response && req.response.content_type && (req.response.content_type.toLowerCase().includes('json') || req.response.content_type.toLowerCase().includes('msgpack') || req.response.content_type.toLowerCase().includes('multipart'))) ?
789
+ `<div class="body-section">
790
+ <h5>Decoded</h5>
791
+ <div class="decoded-body" data-content-type="${req.response.content_type || ''}" data-body="${req.response.body ? req.response.body.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;') : ''}" ${req.response.body_is_binary ? 'data-is-binary="true"' : ''}>
792
+ <pre><code>Loading response...</code></pre>
793
+ </div>
794
+ </div>` : ''}
795
+ </div>
796
+ </div>
797
+ </div>
798
+
799
+ ${req.trace_data ? `
800
+ <div class="tab-content" id="traces-tab-${index}" style="display: none;">
801
+ <!-- Hidden element containing trace data for JavaScript access -->
802
+ <div class="trace-data-container" data-trace-data-b64="${req.trace_data.trace_data_b64 || ''}" style="display: none;"></div>
803
+
804
+ <div class="details-section">
805
+ <h4>Trace Information</h4>
806
+ <div class="metadata-grid">
807
+ <div class="metadata-item">
808
+ <span class="metadata-label">Traces:</span>
809
+ <span class="metadata-value">${req.trace_data.trace_count}</span>
810
+ </div>
811
+ <div class="metadata-item">
812
+ <span class="metadata-label">Spans:</span>
813
+ <span class="metadata-value">${req.trace_data.span_count}</span>
814
+ </div>
815
+ </div>
816
+ </div>
817
+
818
+ <div class="details-section">
819
+ <h4>Trace Visualization</h4>
820
+ <div class="trace-container">
821
+ <div class="trace-controls">
822
+ <button class="action-button small" onclick="toggleTraceView('${index}')">JSON View</button>
823
+ </div>
824
+
825
+ <div id="waterfall-view-${index}" class="waterfall-container">
826
+ <!-- Waterfall visualization will be generated here -->
827
+ </div>
828
+
829
+ <div id="json-view-${index}" class="code-container" style="display: none;">
830
+ <pre class="code-block"><code>${JSON.stringify(req.trace_data.traces, null, 2)}</code></pre>
831
+ </div>
832
+ </div>
833
+ </div>
834
+ </div>
835
+ ` : ''}
836
+ </div>
837
+ </td>
838
+ </tr>
839
+ `;
840
+ }
841
+
842
+ function updateRequestCount(count) {
843
+ const totalCountSpan = document.getElementById('total-count');
844
+ if (totalCountSpan) {
845
+ totalCountSpan.textContent = count;
846
+ }
847
+ }
848
+
849
+ // Make functions globally accessible
850
+ window.addNewRequestToTable = addNewRequestToTable;
851
+ window.updateRequestCount = updateRequestCount;
852
+
853
+ // Debug utility to verify array-DOM sync
854
+ window.debugFilterSync = function() {
855
+ const rows = document.querySelectorAll('.request-row');
856
+ const requests = window.allRequests;
857
+
858
+ console.log(`Array length: ${requests.length}, DOM rows: ${rows.length}`);
859
+
860
+ // Check first few entries
861
+ for (let i = 0; i < Math.min(5, rows.length); i++) {
862
+ const row = rows[i];
863
+ const request = requests[i];
864
+
865
+ // Extract method and path from DOM
866
+ const domMethod = row.querySelector('.method-badge')?.textContent;
867
+ const domPath = row.querySelector('.path-cell')?.textContent?.trim();
868
+
869
+ console.log(`Index ${i}:`, {
870
+ array: request ? `${request.method} ${request.path}` : 'undefined',
871
+ dom: `${domMethod} ${domPath}`
872
+ });
873
+ }
874
+ };
875
+
876
+ // Start SSE connection
877
+ setupSSE();
878
+
879
+ // Add event listeners for filters
880
+ filterQuery.addEventListener('input', filterRequests);
881
+
882
+ // Apply initial filter if loaded from URL
883
+ if (filterQuery.value.trim()) {
884
+ filterRequests();
885
+ }
886
+
887
+ // Process existing request bodies for decoding
888
+ processAllRequestBodies();
889
+ });
890
+
891
+ // SSE control functions (global scope)
892
+ function pauseSSE() {
893
+ ssePaused = true;
894
+ updatePauseButton();
895
+ console.log('SSE paused');
896
+ }
897
+
898
+ function resumeSSE() {
899
+ ssePaused = false;
900
+
901
+ // Process queued requests in order
902
+ const processedCount = sseQueuedRequests.length;
903
+ while (sseQueuedRequests.length > 0) {
904
+ const data = sseQueuedRequests.shift();
905
+ addNewRequestToTable(data.request);
906
+ updateRequestCount(data.total_count);
907
+ }
908
+
909
+ updatePauseButton();
910
+ console.log('SSE resumed, processed', processedCount, 'queued requests');
911
+ }
912
+
913
+ function toggleSSE() {
914
+ if (ssePaused) {
915
+ resumeSSE();
916
+ } else {
917
+ pauseSSE();
918
+ }
919
+ }
920
+
921
+ function updatePauseButton() {
922
+ const pauseBtn = document.getElementById('pause-sse-btn');
923
+ if (pauseBtn) {
924
+ if (ssePaused) {
925
+ pauseBtn.textContent = `Resume (${sseQueuedRequests.length} queued)`;
926
+ pauseBtn.classList.add('paused');
927
+ } else {
928
+ pauseBtn.textContent = 'Pause';
929
+ pauseBtn.classList.remove('paused');
930
+ }
931
+ }
932
+ }
933
+
934
+ // Global helper functions for SSE
935
+ function addNewRequestToTable(request) {
936
+ if (window.addNewRequestToTable) {
937
+ return window.addNewRequestToTable(request);
938
+ }
939
+ }
940
+
941
+ function updateRequestCount(count) {
942
+ if (window.updateRequestCount) {
943
+ return window.updateRequestCount(count);
944
+ }
945
+ const totalCountSpan = document.getElementById('total-count');
946
+ if (totalCountSpan) {
947
+ totalCountSpan.textContent = count;
948
+ }
949
+ }
950
+
951
+ // Function to process all request bodies on page load
952
+ function processAllRequestBodies() {
953
+ // Process decoded bodies with size-based loading
954
+ const decodedBodyElements = document.querySelectorAll('.decoded-body');
955
+ decodedBodyElements.forEach((element, index) => {
956
+ const contentType = element.getAttribute('data-content-type');
957
+ const bodyData = element.getAttribute('data-body');
958
+
959
+ if (bodyData && bodyData.trim()) {
960
+ const isLargePayload = estimatePayloadSize(bodyData) > AUTO_DECODE_THRESHOLD_KB;
961
+
962
+ if (isLargePayload) {
963
+ // Show "Click to decode" button for large payloads
964
+ showClickToDecodeButton(element, contentType, bodyData, 'page', index);
965
+ } else {
966
+ // Process small payloads immediately with error handling
967
+ try {
968
+ processBodyDecoding(element, contentType, bodyData);
969
+ } catch (error) {
970
+ showDecodingError(element, error.message);
971
+ }
972
+ }
973
+ }
974
+ });
975
+
976
+ // Process raw bytes display for msgpack
977
+ const rawBytesElements = document.querySelectorAll('.raw-bytes[data-body]');
978
+ rawBytesElements.forEach(element => {
979
+ const bodyData = element.getAttribute('data-body');
980
+ if (bodyData) {
981
+ try {
982
+ // Decode base64 to get actual bytes
983
+ const binaryString = atob(bodyData);
984
+ const uint8Array = new Uint8Array(binaryString.length);
985
+ for (let i = 0; i < binaryString.length; i++) {
986
+ uint8Array[i] = binaryString.charCodeAt(i);
987
+ }
988
+ const hexString = Array.from(uint8Array)
989
+ .map(b => b.toString(16).padStart(2, '0'))
990
+ .join(' ');
991
+ element.textContent = hexString;
992
+ } catch (e) {
993
+ element.textContent = 'Error decoding base64: ' + e.message;
994
+ }
995
+ }
996
+ });
997
+ }
998
+
999
+ // Function to process body decoding based on content type
1000
+ function processBodyDecoding(element, contentType, bodyData) {
1001
+ console.log('processBodyDecoding called with:', {contentType, bodyData: bodyData ? bodyData.substring(0, 100) + '...' : 'null'});
1002
+ try {
1003
+ if (!bodyData || !bodyData.trim()) {
1004
+ element.innerHTML = `
1005
+ <div class="empty-body">
1006
+ <pre><code>Empty or no response body</code></pre>
1007
+ </div>
1008
+ `;
1009
+ return;
1010
+ }
1011
+
1012
+ if (contentType && contentType.toLowerCase().includes('application/json')) {
1013
+ // Handle HTML entities that might be in the data
1014
+ let cleanBodyData = bodyData;
1015
+ if (bodyData.includes('&quot;')) {
1016
+ cleanBodyData = bodyData.replace(/&quot;/g, '"')
1017
+ .replace(/&amp;/g, '&')
1018
+ .replace(/&lt;/g, '<')
1019
+ .replace(/&gt;/g, '>')
1020
+ .replace(/&#x27;/g, "'");
1021
+ }
1022
+
1023
+ // Try to parse and pretty-print JSON
1024
+ try {
1025
+ const jsonData = JSON.parse(cleanBodyData);
1026
+ const prettyJson = JSON.stringify(jsonData, null, 2);
1027
+ element.innerHTML = `
1028
+ <div class="json-body">
1029
+ <pre><code>${prettyJson}</code></pre>
1030
+ </div>
1031
+ `;
1032
+ } catch (jsonError) {
1033
+ console.log('JSON parse error for data:', bodyData.substring(0, 200));
1034
+ console.log('JSON parse error:', jsonError);
1035
+ console.log('Full bodyData length:', bodyData.length);
1036
+ console.log('CleanBodyData equals bodyData:', cleanBodyData === bodyData);
1037
+
1038
+ element.innerHTML = `
1039
+ <div class="error-body">
1040
+ <pre><code>Invalid JSON: ${jsonError.message}
1041
+
1042
+ Data length: ${bodyData.length}
1043
+ Clean data length: ${cleanBodyData.length}
1044
+ First 100 chars: ${cleanBodyData.substring(0, 100)}
1045
+ Last 50 chars: ${cleanBodyData.slice(-50)}</code></pre>
1046
+ </div>
1047
+ `;
1048
+ }
1049
+ } else if (contentType && contentType.toLowerCase().includes('application/msgpack')) {
1050
+ // Try to decode msgpack
1051
+ try {
1052
+ // Decode base64 to get actual bytes for msgpack
1053
+ const binaryString = atob(bodyData);
1054
+ const uint8Array = new Uint8Array(binaryString.length);
1055
+ for (let i = 0; i < binaryString.length; i++) {
1056
+ uint8Array[i] = binaryString.charCodeAt(i);
1057
+ }
1058
+
1059
+ // Decode using official msgpack.js library
1060
+ let decodedData;
1061
+ if (typeof msgpack !== 'undefined' && msgpack.unpack) {
1062
+ decodedData = msgpack.unpack(uint8Array);
1063
+ } else if (typeof msgpack !== 'undefined' && msgpack.decode) {
1064
+ decodedData = msgpack.decode(uint8Array);
1065
+ } else if (typeof MessagePack !== 'undefined') {
1066
+ decodedData = MessagePack.unpack(uint8Array);
1067
+ } else {
1068
+ throw new Error('MessagePack library not found');
1069
+ }
1070
+ const prettyData = JSON.stringify(decodedData, null, 2);
1071
+
1072
+ element.innerHTML = `
1073
+ <div class="msgpack-body">
1074
+ <pre><code>${prettyData}</code></pre>
1075
+ </div>
1076
+ `;
1077
+ } catch (msgpackError) {
1078
+ element.innerHTML = `
1079
+ <div class="msgpack-body">
1080
+ <pre><code>MessagePack Error: ${msgpackError.message}
1081
+ Base64 length: ${bodyData.length} chars
1082
+ Available libraries: ${Object.keys(window).filter(k => k.toLowerCase().includes('msgpack')).join(', ') || 'none'}</code></pre>
1083
+ </div>
1084
+ `;
1085
+ }
1086
+ } else if (contentType && contentType.toLowerCase().includes('multipart/form-data')) {
1087
+ // Handle multipart form data
1088
+ try {
1089
+ // Decode base64 to get raw multipart data
1090
+ const rawData = atob(bodyData);
1091
+
1092
+ // Extract boundary from content type
1093
+ const boundaryMatch = contentType.match(/boundary=([^;]+)/);
1094
+ if (!boundaryMatch) {
1095
+ throw new Error('No boundary found in multipart content type');
1096
+ }
1097
+ const boundary = '--' + boundaryMatch[1];
1098
+
1099
+ // Parse multipart data
1100
+ const parts = rawData.split(boundary);
1101
+ const formData = {};
1102
+ let fileCount = 0;
1103
+
1104
+ for (let i = 1; i < parts.length - 1; i++) { // Skip first (empty) and last (closing) parts
1105
+ const part = parts[i];
1106
+ if (!part.trim()) continue;
1107
+
1108
+ // Split headers and body
1109
+ const headerBodySplit = part.indexOf('\r\n\r\n');
1110
+ if (headerBodySplit === -1) continue;
1111
+
1112
+ const headers = part.substring(0, headerBodySplit);
1113
+ const body = part.substring(headerBodySplit + 4);
1114
+
1115
+ // Extract field name from Content-Disposition header
1116
+ const nameMatch = headers.match(/name="([^"]+)"/);
1117
+ if (!nameMatch) continue;
1118
+
1119
+ const fieldName = nameMatch[1];
1120
+
1121
+ // Check if it's a file field
1122
+ const filenameMatch = headers.match(/filename="([^"]*)"/);
1123
+ if (filenameMatch) {
1124
+ // It's a file upload
1125
+ const filename = filenameMatch[1];
1126
+ const cleanBody = body.replace(/\r\n$/, ''); // Remove trailing CRLF
1127
+ formData[fieldName] = {
1128
+ type: 'file',
1129
+ filename: filename,
1130
+ size: cleanBody.length,
1131
+ content: `[Binary file data: ${cleanBody.length} bytes]`
1132
+ };
1133
+ fileCount++;
1134
+ } else {
1135
+ // It's a regular form field
1136
+ const cleanBody = body.replace(/\r\n$/, ''); // Remove trailing CRLF
1137
+ formData[fieldName] = {
1138
+ type: 'text',
1139
+ value: cleanBody
1140
+ };
1141
+ }
1142
+ }
1143
+
1144
+ // Format for display
1145
+ let displayContent = `Form Data (${Object.keys(formData).length} fields, ${fileCount} files):\n\n`;
1146
+ for (const [key, data] of Object.entries(formData)) {
1147
+ if (data.type === 'file') {
1148
+ displayContent += `${key}: [FILE] ${data.filename} (${data.size} bytes)\n`;
1149
+ } else {
1150
+ displayContent += `${key}: ${data.value}\n`;
1151
+ }
1152
+ }
1153
+
1154
+ element.innerHTML = `
1155
+ <div class="multipart-body">
1156
+ <pre><code>${displayContent}</code></pre>
1157
+ </div>
1158
+ `;
1159
+ } catch (multipartError) {
1160
+ element.innerHTML = `
1161
+ <div class="multipart-body">
1162
+ <pre><code>Multipart parsing error: ${multipartError.message}
1163
+
1164
+ Content-Type: ${contentType}
1165
+ Data length: ${bodyData.length} chars</code></pre>
1166
+ </div>
1167
+ `;
1168
+ }
1169
+ } else {
1170
+ // For other content types, try to show as text if it looks reasonable
1171
+ try {
1172
+ // Decode base64 to see if it's readable text
1173
+ const rawData = atob(bodyData);
1174
+ if (rawData.length > 0 && rawData.length < 10000) { // Only show reasonable-sized text
1175
+ // Check if it contains mostly printable characters
1176
+ const printableRatio = (rawData.match(/[\x20-\x7E\r\n\t]/g) || []).length / rawData.length;
1177
+ if (printableRatio > 0.8) { // 80% printable characters
1178
+ element.innerHTML = `
1179
+ <div class="text-body">
1180
+ <pre><code>${rawData}</code></pre>
1181
+ </div>
1182
+ `;
1183
+ return;
1184
+ }
1185
+ }
1186
+ } catch (e) {
1187
+ // Not valid base64 or not readable text, fall through to hide
1188
+ }
1189
+ // For binary or other unreadable content types, hide the decoded section
1190
+ element.style.display = 'none';
1191
+ }
1192
+ } catch (error) {
1193
+ console.error('Error processing body:', error);
1194
+ element.innerHTML = `
1195
+ <div class="error-body">
1196
+ <pre><code>Error processing response: ${error.message}</code></pre>
1197
+ </div>
1198
+ `;
1199
+ }
1200
+ }
1201
+
1202
+ function switchTab(index, tab) {
1203
+ console.log('switchTab called:', index, tab);
1204
+ // Update tab buttons
1205
+ const tabButtons = document.querySelectorAll(`#details-${index} .tab-button`);
1206
+ tabButtons.forEach(button => button.classList.remove('active'));
1207
+ event.target.classList.add('active');
1208
+
1209
+ // Show/hide tab content
1210
+ const requestTab = document.getElementById(`request-tab-${index}`);
1211
+ const responseTab = document.getElementById(`response-tab-${index}`);
1212
+ const tracesTab = document.getElementById(`traces-tab-${index}`);
1213
+
1214
+ console.log('Found tabs:', {requestTab, responseTab, tracesTab});
1215
+
1216
+ // Hide all tabs first
1217
+ requestTab.style.display = 'none';
1218
+ responseTab.style.display = 'none';
1219
+ if (tracesTab) tracesTab.style.display = 'none';
1220
+
1221
+ // Show selected tab
1222
+ if (tab === 'request') {
1223
+ requestTab.style.display = 'block';
1224
+ } else if (tab === 'response') {
1225
+ responseTab.style.display = 'block';
1226
+ } else if (tab === 'traces' && tracesTab) {
1227
+ console.log('Showing traces tab and generating waterfall');
1228
+ tracesTab.style.display = 'block';
1229
+ // Generate waterfall view if not already generated
1230
+ try {
1231
+ generateWaterfallViewForRequest(index);
1232
+ } catch (error) {
1233
+ console.error('Error generating waterfall view:', error);
1234
+ }
1235
+ }
1236
+ }
1237
+
1238
+ // Size threshold for automatic decoding (in KB)
1239
+ const AUTO_DECODE_THRESHOLD_KB = 50;
1240
+
1241
+ function toggleRequestDetails(index) {
1242
+ const detailsRow = document.getElementById(`details-${index}`);
1243
+
1244
+ if (detailsRow.style.display === 'none' || !detailsRow.style.display) {
1245
+ detailsRow.style.display = 'table-row';
1246
+
1247
+ // Trigger deferred decoding for this request
1248
+ deferredDecodeRequest(index);
1249
+ } else {
1250
+ detailsRow.style.display = 'none';
1251
+ }
1252
+ }
1253
+
1254
+ function deferredDecodeRequest(index) {
1255
+ // Use a small timeout to allow the UI to render the details row first
1256
+ setTimeout(() => {
1257
+ processRequestDecoding(index);
1258
+ }, 10);
1259
+ }
1260
+
1261
+ function processRequestDecoding(index) {
1262
+ console.log('Processing deferred decoding for request:', index);
1263
+
1264
+ // Find all decoded-body elements in this request's details
1265
+ const detailsRow = document.getElementById(`details-${index}`);
1266
+ if (!detailsRow) return;
1267
+
1268
+ const decodedBodyElements = detailsRow.querySelectorAll('.decoded-body[data-content-type]');
1269
+
1270
+ decodedBodyElements.forEach((element, elementIndex) => {
1271
+ const contentType = element.getAttribute('data-content-type');
1272
+ const bodyData = element.getAttribute('data-body');
1273
+ const isLargePayload = estimatePayloadSize(bodyData) > AUTO_DECODE_THRESHOLD_KB;
1274
+
1275
+ if (isLargePayload) {
1276
+ // Show "Click to decode" button for large payloads
1277
+ showClickToDecodeButton(element, contentType, bodyData, index, elementIndex);
1278
+ } else {
1279
+ // Show loading state then decode automatically for small payloads
1280
+ showLoadingState(element);
1281
+ setTimeout(() => {
1282
+ try {
1283
+ processBodyDecoding(element, contentType, bodyData);
1284
+ } catch (error) {
1285
+ showDecodingError(element, error.message);
1286
+ }
1287
+ }, 50);
1288
+ }
1289
+ });
1290
+
1291
+ // Also process raw bytes elements
1292
+ const rawBytesElements = detailsRow.querySelectorAll('.raw-bytes[data-body]');
1293
+ rawBytesElements.forEach(element => {
1294
+ const bodyData = element.getAttribute('data-body');
1295
+ if (bodyData) {
1296
+ setTimeout(() => {
1297
+ try {
1298
+ // Decode base64 to get actual bytes
1299
+ const binaryString = atob(bodyData);
1300
+ const uint8Array = new Uint8Array(binaryString.length);
1301
+ for (let i = 0; i < binaryString.length; i++) {
1302
+ uint8Array[i] = binaryString.charCodeAt(i);
1303
+ }
1304
+ const hexString = Array.from(uint8Array)
1305
+ .map(b => b.toString(16).padStart(2, '0'))
1306
+ .join(' ');
1307
+ element.textContent = hexString;
1308
+ } catch (e) {
1309
+ element.textContent = 'Error decoding base64: ' + e.message;
1310
+ }
1311
+ }, 25);
1312
+ }
1313
+ });
1314
+ }
1315
+
1316
+ function estimatePayloadSize(base64Data) {
1317
+ if (!base64Data) return 0;
1318
+ // Base64 encoding increases size by ~33%, so decode length gives rough byte size
1319
+ return (base64Data.length * 3 / 4) / 1024; // Convert to KB
1320
+ }
1321
+
1322
+ function showLoadingState(element) {
1323
+ element.innerHTML = `
1324
+ <div class="loading-state">
1325
+ <div class="loading-spinner"></div>
1326
+ <span>Decoding payload...</span>
1327
+ </div>
1328
+ `;
1329
+ }
1330
+
1331
+ function showClickToDecodeButton(element, contentType, bodyData, requestIndex, elementIndex) {
1332
+ const payloadSizeKB = estimatePayloadSize(bodyData);
1333
+ const elementId = `decode-btn-${requestIndex}-${elementIndex}`;
1334
+
1335
+ element.innerHTML = `
1336
+ <div class="large-payload-notice">
1337
+ <div class="payload-info">
1338
+ <strong>Large Payload Detected</strong>
1339
+ <p>Size: ~${payloadSizeKB.toFixed(1)} KB</p>
1340
+ <p>Content-Type: ${contentType}</p>
1341
+ </div>
1342
+ <button id="${elementId}" class="decode-button" onclick="decodeOnDemand('${elementId}', '${requestIndex}', '${elementIndex}')">
1343
+ Click to Decode
1344
+ </button>
1345
+ </div>
1346
+ `;
1347
+ }
1348
+
1349
+ function decodeOnDemand(buttonId, requestIndex, elementIndex) {
1350
+ const button = document.getElementById(buttonId);
1351
+ const element = button.closest('.decoded-body');
1352
+ const contentType = element.getAttribute('data-content-type');
1353
+ const bodyData = element.getAttribute('data-body');
1354
+
1355
+ // Show loading state
1356
+ showLoadingState(element);
1357
+
1358
+ // Decode asynchronously to prevent UI blocking
1359
+ setTimeout(() => {
1360
+ try {
1361
+ processBodyDecoding(element, contentType, bodyData);
1362
+ } catch (error) {
1363
+ showDecodingError(element, error.message);
1364
+ }
1365
+ }, 50);
1366
+ }
1367
+
1368
+ function showDecodingError(element, errorMessage) {
1369
+ element.innerHTML = `
1370
+ <div class="decoding-error">
1371
+ <strong>Decoding Error:</strong>
1372
+ <pre>${errorMessage}</pre>
1373
+ </div>
1374
+ `;
1375
+ }
1376
+
1377
+ function refreshRequests() {
1378
+ location.reload();
1379
+ }
1380
+
1381
+ async function clearRequests() {
1382
+ if (!confirm('Are you sure you want to clear all requests? This cannot be undone.')) {
1383
+ return;
1384
+ }
1385
+
1386
+ try {
1387
+ const response = await fetch('/requests/clear', {
1388
+ method: 'POST',
1389
+ headers: {
1390
+ 'Content-Type': 'application/json',
1391
+ }
1392
+ });
1393
+
1394
+ if (response.ok) {
1395
+ // Clear the global requests array
1396
+ window.allRequests = [];
1397
+ // Clear the UI
1398
+ const requestsTable = document.querySelector('.requests-table-container');
1399
+ const requestsContainer = document.querySelector('.requests-container');
1400
+
1401
+ if (requestsTable) {
1402
+ requestsTable.remove();
1403
+ }
1404
+
1405
+ // Show empty state
1406
+ const emptyStateHTML = `
1407
+ <div class="empty-state">
1408
+ <p>No requests received yet</p>
1409
+ <p class="empty-subtitle">Requests will appear here as the test agent receives them</p>
1410
+ </div>
1411
+ `;
1412
+ if (requestsContainer) {
1413
+ requestsContainer.insertAdjacentHTML('beforeend', emptyStateHTML);
1414
+ }
1415
+
1416
+ // Update counter (if it exists)
1417
+ const statBadge = document.querySelector('.stat-badge');
1418
+ if (statBadge) {
1419
+ statBadge.textContent = '0 total requests';
1420
+ }
1421
+ } else {
1422
+ const errorText = await response.text();
1423
+ console.error('Clear requests failed:', response.status, errorText);
1424
+ alert(`Failed to clear requests: ${response.status}`);
1425
+ }
1426
+ } catch (error) {
1427
+ console.error('Error clearing requests:', error);
1428
+ alert('Error clearing requests');
1429
+ }
1430
+ }
1431
+
1432
+ function downloadRequests() {
1433
+ // Get currently visible requests (respecting filters)
1434
+ const visibleRequests = [];
1435
+ const rows = document.querySelectorAll('.request-row');
1436
+
1437
+ rows.forEach((row, index) => {
1438
+ // Check if row is visible (not filtered out)
1439
+ if (row.style.display !== 'none') {
1440
+ if (window.allRequests[index]) {
1441
+ visibleRequests.push(window.allRequests[index]);
1442
+ }
1443
+ }
1444
+ });
1445
+
1446
+ // Create a blob with the filtered data (minified JSON)
1447
+ const jsonData = JSON.stringify(visibleRequests);
1448
+ const blob = new Blob([jsonData], { type: 'application/json' });
1449
+
1450
+ // Create download link
1451
+ const link = document.createElement('a');
1452
+ link.href = URL.createObjectURL(blob);
1453
+ link.download = `requests_filtered_${new Date().toISOString().slice(0,19).replace(/[:-]/g, '')}.json`;
1454
+ document.body.appendChild(link);
1455
+ link.click();
1456
+ document.body.removeChild(link);
1457
+ URL.revokeObjectURL(link.href);
1458
+ }
1459
+
1460
+ function formatTimestamp(timestamp) {
1461
+ if (!timestamp) return '';
1462
+ const date = new Date(timestamp * 1000); // Convert from seconds to milliseconds
1463
+ return date.getFullYear() + '-' +
1464
+ String(date.getMonth() + 1).padStart(2, '0') + '-' +
1465
+ String(date.getDate()).padStart(2, '0') + ' ' +
1466
+ String(date.getHours()).padStart(2, '0') + ':' +
1467
+ String(date.getMinutes()).padStart(2, '0') + ':' +
1468
+ String(date.getSeconds()).padStart(2, '0');
1469
+ }
1470
+
1471
+
1472
+ function applyQuickFilter(filterType, value) {
1473
+ const filterQuery = document.getElementById('filter-query');
1474
+
1475
+ // Create filter string based on type
1476
+ let filterString = '';
1477
+ switch(filterType) {
1478
+ case 'method':
1479
+ filterString = `method:${value}`;
1480
+ break;
1481
+ case 'path':
1482
+ filterString = `path:${value}`;
1483
+ break;
1484
+ case 'session':
1485
+ filterString = `session:${value}`;
1486
+ break;
1487
+ }
1488
+
1489
+ // Add to existing filter or replace
1490
+ const currentFilter = filterQuery.value.trim();
1491
+ if (currentFilter) {
1492
+ filterQuery.value = `${currentFilter} ${filterString}`;
1493
+ } else {
1494
+ filterQuery.value = filterString;
1495
+ }
1496
+
1497
+ // Trigger the filter update
1498
+ const filterEvent = new Event('input', { bubbles: true });
1499
+ filterQuery.dispatchEvent(filterEvent);
1500
+ }
1501
+
1502
+ function clearAllFilters() {
1503
+ const filterQuery = document.getElementById('filter-query');
1504
+ filterQuery.value = '';
1505
+
1506
+ // Trigger filter update
1507
+ const filterEvent = new Event('input', { bubbles: true });
1508
+ filterQuery.dispatchEvent(filterEvent);
1509
+ }
1510
+
1511
+ function showFilterHelp() {
1512
+ const helpText = `Filter Syntax:
1513
+ • key:value - Include requests matching key=value
1514
+ • -key:value - Exclude requests matching key=value
1515
+ • Multiple filters with spaces (AND logic)
1516
+
1517
+ Supported keys:
1518
+ • method:POST - HTTP method
1519
+ • path:/api/traces - Request path (partial match)
1520
+ • status:200 - Response status code
1521
+ • session:abc123 - Session token (partial match)
1522
+ • headers.user-agent:curl - Request header
1523
+ • response.headers.content-type:json - Response header
1524
+ • size:>1000 or size:<500 - Content size comparison
1525
+ • body:contains - Request body contains text
1526
+ • response.body:error - Response body contains text
1527
+
1528
+ Examples:
1529
+ • method:POST -path:/info
1530
+ • headers.user-agent:curl status:200
1531
+ • size:>1000 -headers.authorization:*
1532
+ • method:POST path:/traces response.status:200`;
1533
+
1534
+ alert(helpText);
1535
+ }
1536
+
1537
+ function downloadFlareFile(sessionToken, requestIndex) {
1538
+ // Find the multipart data for this request
1539
+ const requestRows = document.querySelectorAll('.request-row');
1540
+ if (requestIndex >= requestRows.length) {
1541
+ alert('Request not found');
1542
+ return;
1543
+ }
1544
+
1545
+ // Get the flare_file data from the DOM
1546
+ const detailsRow = document.getElementById(`details-${requestIndex}`);
1547
+ if (!detailsRow) {
1548
+ alert('Request details not found');
1549
+ return;
1550
+ }
1551
+
1552
+ const flareFields = detailsRow.querySelectorAll('.multipart-field');
1553
+ let flareFileData = null;
1554
+
1555
+ flareFields.forEach(field => {
1556
+ const headerText = field.querySelector('.field-header strong').textContent;
1557
+ if (headerText === 'flare_file:') {
1558
+ const codeElement = field.querySelector('pre code');
1559
+ if (codeElement) {
1560
+ // Get the full data from the data attribute, fallback to text content
1561
+ flareFileData = codeElement.getAttribute('data-full-data') || codeElement.textContent.replace(/\.\.\.$/, '');
1562
+ }
1563
+ }
1564
+ });
1565
+
1566
+ if (!flareFileData) {
1567
+ alert('Flare file data not found');
1568
+ return;
1569
+ }
1570
+
1571
+ try {
1572
+ // Convert base64 to binary data
1573
+ const binaryString = atob(flareFileData);
1574
+ const uint8Array = new Uint8Array(binaryString.length);
1575
+ for (let i = 0; i < binaryString.length; i++) {
1576
+ uint8Array[i] = binaryString.charCodeAt(i);
1577
+ }
1578
+
1579
+ // Create blob and download
1580
+ const blob = new Blob([uint8Array], { type: 'application/zip' });
1581
+ const url = URL.createObjectURL(blob);
1582
+ const link = document.createElement('a');
1583
+ link.href = url;
1584
+ link.download = `tracer_flare_${sessionToken || 'default'}_${Date.now()}.zip`;
1585
+ document.body.appendChild(link);
1586
+ link.click();
1587
+ document.body.removeChild(link);
1588
+ URL.revokeObjectURL(url);
1589
+ } catch (e) {
1590
+ alert('Error downloading file: ' + e.message);
1591
+ }
1592
+ }
1593
+
1594
+ // Waterfall visualization functions for traces tab
1595
+ const requestTraceData = new Map(); // Cache trace data per request index
1596
+ const serviceColorMap = new Map();
1597
+ let nextColorIndex = 0;
1598
+
1599
+ function getServiceColor(serviceName) {
1600
+ if (!serviceColorMap.has(serviceName)) {
1601
+ serviceColorMap.set(serviceName, nextColorIndex % 8);
1602
+ nextColorIndex++;
1603
+ }
1604
+ return serviceColorMap.get(serviceName);
1605
+ }
1606
+
1607
+ function generateWaterfallViewForRequest(index) {
1608
+ console.log('generateWaterfallViewForRequest called with index:', index);
1609
+ try {
1610
+ const container = document.getElementById(`waterfall-view-${index}`);
1611
+
1612
+ if (!container) {
1613
+ console.error(`Waterfall container not found for request ${index}`);
1614
+ return;
1615
+ }
1616
+
1617
+ console.log('Found waterfall container:', container);
1618
+
1619
+ // Check if already generated (ignore HTML comments and whitespace)
1620
+ const hasActualContent = Array.from(container.childNodes).some(node =>
1621
+ node.nodeType === Node.ELEMENT_NODE ||
1622
+ (node.nodeType === Node.TEXT_NODE && node.textContent.trim())
1623
+ );
1624
+ if (hasActualContent) {
1625
+ console.log('Container already has actual content, skipping generation');
1626
+ return;
1627
+ }
1628
+
1629
+ console.log('About to get trace data for request:', index);
1630
+ // Get trace data from the request
1631
+ const traceData = getTraceDataForRequest(index);
1632
+
1633
+ console.log('Generating waterfall view for request', index, 'traceData:', traceData);
1634
+
1635
+ if (!traceData || !Array.isArray(traceData) || traceData.length === 0) {
1636
+ container.innerHTML = '<p class="empty-state">No trace data available for waterfall view</p>';
1637
+ return;
1638
+ }
1639
+
1640
+ const traces = traceData;
1641
+ let html = '<div class="waterfall-traces">';
1642
+
1643
+ traces.forEach((trace, traceIndex) => {
1644
+ console.log('Processing trace:', trace);
1645
+
1646
+ let spans = [];
1647
+
1648
+ // Handle different trace formats
1649
+ if (Array.isArray(trace)) {
1650
+ if (trace.length === 0) return;
1651
+ spans = trace;
1652
+ } else if (typeof trace === 'object' && trace.span_id) {
1653
+ // Single span
1654
+ spans = [trace];
1655
+ } else {
1656
+ console.log('Skipping trace with unsupported format:', trace);
1657
+ return;
1658
+ }
1659
+
1660
+ // Find the earliest start time and calculate total duration for this trace
1661
+ const validSpans = spans.filter(span => span && typeof span === 'object');
1662
+ if (validSpans.length === 0) return;
1663
+
1664
+ let minStart = Math.min(...validSpans.map(span => span.start || 0));
1665
+ let maxEnd = Math.max(...validSpans.map(span => (span.start || 0) + (span.duration || 0)));
1666
+ let totalDuration = maxEnd - minStart;
1667
+
1668
+ // Handle case where all spans have 0 duration
1669
+ if (totalDuration === 0) {
1670
+ totalDuration = Math.max(...validSpans.map(span => span.duration || 0));
1671
+ }
1672
+
1673
+ const traceId = validSpans[0].trace_id !== undefined ? validSpans[0].trace_id : traceIndex;
1674
+
1675
+ html += `<div class="waterfall-trace">
1676
+ <div class="trace-header" onclick="toggleTraceForRequest(${index}, ${traceIndex})">
1677
+ <div class="trace-header-left">
1678
+ <span class="trace-toggle" id="trace-toggle-${index}-${traceIndex}">▼</span>
1679
+ <h4>Trace ${traceId}</h4>
1680
+ </div>
1681
+ <span class="trace-duration">${formatDuration(totalDuration)}</span>
1682
+ </div>
1683
+ <div class="spans-timeline" id="trace-spans-${index}-${traceIndex}">`;
1684
+
1685
+ // Sort spans by start time and build hierarchy
1686
+ const sortedSpans = [...validSpans].sort((a, b) => (a.start || 0) - (b.start || 0));
1687
+ const spanHierarchy = buildSpanHierarchy(sortedSpans);
1688
+
1689
+ spanHierarchy.forEach(spanInfo => {
1690
+ html += renderSpanForRequest(spanInfo, minStart, totalDuration, 0, index, traceIndex);
1691
+ });
1692
+
1693
+ html += '</div></div>';
1694
+ });
1695
+
1696
+ html += '</div>';
1697
+ container.innerHTML = html;
1698
+ } catch (error) {
1699
+ console.error('Error in generateWaterfallViewForRequest:', error);
1700
+ const container = document.getElementById(`waterfall-view-${index}`);
1701
+ if (container) {
1702
+ container.innerHTML = '<p class="error-message">Error generating waterfall view: ' + error.message + '</p>';
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ function getTraceDataForRequest(index) {
1708
+ console.log('getTraceDataForRequest called with index:', index);
1709
+ // Get trace data from DOM or cache
1710
+ const tracesTab = document.getElementById(`traces-tab-${index}`);
1711
+ console.log('Found traces tab:', tracesTab);
1712
+ if (tracesTab) {
1713
+ // Try base64 encoded data first (for streaming requests)
1714
+ let dataElement = tracesTab.querySelector('.trace-data-container[data-trace-data-b64]');
1715
+ if (dataElement) {
1716
+ try {
1717
+ const traceDataB64 = dataElement.getAttribute('data-trace-data-b64');
1718
+ console.log('Found base64 trace data, length:', traceDataB64 ? traceDataB64.length : 'null');
1719
+ const traceDataStr = atob(traceDataB64);
1720
+ return JSON.parse(traceDataStr);
1721
+ } catch (e) {
1722
+ console.error('Failed to parse base64 trace data:', e);
1723
+ }
1724
+ }
1725
+
1726
+ // Fallback to regular data attribute (for non-streaming requests)
1727
+ dataElement = tracesTab.querySelector('.trace-data-container[data-trace-data]');
1728
+ console.log('Found regular data element:', dataElement);
1729
+ if (dataElement) {
1730
+ try {
1731
+ const traceDataStr = dataElement.getAttribute('data-trace-data');
1732
+ console.log('Raw trace data attribute:', traceDataStr ? traceDataStr.substring(0, 200) + '...' : 'null');
1733
+ return JSON.parse(traceDataStr);
1734
+ } catch (e) {
1735
+ console.error('Failed to parse trace data:', e);
1736
+ }
1737
+ }
1738
+ }
1739
+ return null;
1740
+ }
1741
+
1742
+ function buildSpanHierarchy(spans) {
1743
+ const spanMap = new Map();
1744
+ const rootSpans = [];
1745
+
1746
+ // Create span objects with children arrays
1747
+ spans.forEach(span => {
1748
+ spanMap.set(span.span_id, { ...span, children: [] });
1749
+ });
1750
+
1751
+ // Build parent-child relationships
1752
+ spans.forEach(span => {
1753
+ if (span.parent_id && span.parent_id !== 0 && spanMap.has(span.parent_id)) {
1754
+ spanMap.get(span.parent_id).children.push(spanMap.get(span.span_id));
1755
+ } else {
1756
+ rootSpans.push(spanMap.get(span.span_id));
1757
+ }
1758
+ });
1759
+
1760
+ return rootSpans;
1761
+ }
1762
+
1763
+ function renderSpanForRequest(span, traceStart, totalDuration, depth, requestIndex, traceIndex) {
1764
+ const start = span.start || 0;
1765
+ const duration = span.duration || 0;
1766
+ const relativeStart = start - traceStart;
1767
+ const startPercent = totalDuration > 0 ? (relativeStart / totalDuration) * 100 : 0;
1768
+ const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0;
1769
+
1770
+ const service = span.service || 'unknown';
1771
+ const resource = span.resource || span.name || 'unknown';
1772
+ const spanClass = span.error ? 'span-error' : 'span-normal';
1773
+ const serviceColorClass = `service-color-${getServiceColor(service)}`;
1774
+ const indentStyle = `margin-left: ${depth * 20}px;`;
1775
+
1776
+ const spanId = `span-${requestIndex}-${traceIndex}-${span.span_id || Math.random()}`;
1777
+
1778
+ let html = `
1779
+ <div class="waterfall-span ${spanClass}" style="${indentStyle}">
1780
+ <div class="span-row">
1781
+ <div class="span-info" onclick="toggleSpanForRequest('${spanId}')">
1782
+ <div class="span-label">
1783
+ <span class="span-toggle" id="toggle-${spanId}">▶</span>
1784
+ <div class="span-names">
1785
+ <span class="service-name">${service}</span>
1786
+ <span class="operation-name">${resource}</span>
1787
+ </div>
1788
+ </div>
1789
+ <div class="span-timing">
1790
+ <span class="span-duration">${formatDuration(duration)}</span>
1791
+ </div>
1792
+ </div>
1793
+ <div class="span-bar-container">
1794
+ <div class="span-bar ${spanClass} ${serviceColorClass}"
1795
+ style="left: ${startPercent}%; width: ${Math.max(widthPercent, 0.1)}%;"
1796
+ title="${service}.${span.resource || span.name} - ${formatDuration(duration)}">
1797
+ </div>
1798
+ </div>
1799
+ </div>
1800
+ <div class="span-details" id="details-${spanId}" style="display: none;">
1801
+ ${renderSpanMetadata(span)}
1802
+ </div>
1803
+ </div>`;
1804
+
1805
+ // Render children recursively
1806
+ span.children.forEach(child => {
1807
+ html += renderSpanForRequest(child, traceStart, totalDuration, depth + 1, requestIndex, traceIndex);
1808
+ });
1809
+
1810
+ return html;
1811
+ }
1812
+
1813
+ function formatDuration(nanoseconds) {
1814
+ if (nanoseconds < 1000) return `${nanoseconds}ns`;
1815
+ if (nanoseconds < 1000000) return `${(nanoseconds / 1000).toFixed(1)}μs`;
1816
+ if (nanoseconds < 1000000000) return `${(nanoseconds / 1000000).toFixed(1)}ms`;
1817
+ return `${(nanoseconds / 1000000000).toFixed(2)}s`;
1818
+ }
1819
+
1820
+ function toggleTraceForRequest(requestIndex, traceIndex) {
1821
+ const toggle = document.getElementById(`trace-toggle-${requestIndex}-${traceIndex}`);
1822
+ const spans = document.getElementById(`trace-spans-${requestIndex}-${traceIndex}`);
1823
+
1824
+ if (spans.style.display === 'none') {
1825
+ spans.style.display = 'block';
1826
+ toggle.textContent = '▼';
1827
+ } else {
1828
+ spans.style.display = 'none';
1829
+ toggle.textContent = '▶';
1830
+ }
1831
+ }
1832
+
1833
+ function toggleSpanForRequest(spanId) {
1834
+ const toggle = document.getElementById(`toggle-${spanId}`);
1835
+ const details = document.getElementById(`details-${spanId}`);
1836
+
1837
+ if (details.style.display === 'none') {
1838
+ details.style.display = 'block';
1839
+ toggle.textContent = '▼';
1840
+ } else {
1841
+ details.style.display = 'none';
1842
+ toggle.textContent = '▶';
1843
+ }
1844
+ }
1845
+
1846
+ function renderSpanMetadata(span) {
1847
+ const fields = [
1848
+ { label: 'Span ID', value: span.span_id },
1849
+ { label: 'Trace ID', value: span.trace_id },
1850
+ { label: 'Parent ID', value: span.parent_id || 'None' },
1851
+ { label: 'Service', value: span.service || 'Not set' },
1852
+ { label: 'Name', value: span.name },
1853
+ { label: 'Resource', value: span.resource },
1854
+ { label: 'Type', value: span.type || 'Not set' },
1855
+ { label: 'Start', value: span.start ? new Date(span.start / 1000000).toISOString() : 'Not set' },
1856
+ { label: 'Duration', value: span.duration ? formatDuration(span.duration) : 'Not set' },
1857
+ { label: 'Error', value: span.error ? 'Yes' : 'No' }
1858
+ ];
1859
+
1860
+ let html = '<div class="span-metadata">';
1861
+ html += '<h5>Span Details</h5>';
1862
+ html += '<div class="metadata-grid">';
1863
+
1864
+ fields.forEach(field => {
1865
+ if (field.value !== undefined && field.value !== null && field.value !== '') {
1866
+ html += `
1867
+ <div class="metadata-item">
1868
+ <span class="metadata-label">${field.label}:</span>
1869
+ <span class="metadata-value">${field.value}</span>
1870
+ </div>`;
1871
+ }
1872
+ });
1873
+
1874
+ // Add meta tags if present
1875
+ if (span.meta && Object.keys(span.meta).length > 0) {
1876
+ html += '<div class="metadata-section"><h6>Meta</h6>';
1877
+ for (const [key, value] of Object.entries(span.meta)) {
1878
+ html += `
1879
+ <div class="metadata-item">
1880
+ <span class="metadata-label">${key}:</span>
1881
+ <span class="metadata-value">${value}</span>
1882
+ </div>`;
1883
+ }
1884
+ html += '</div>';
1885
+ }
1886
+
1887
+ // Add metrics if present
1888
+ if (span.metrics && Object.keys(span.metrics).length > 0) {
1889
+ html += '<div class="metadata-section"><h6>Metrics</h6>';
1890
+ for (const [key, value] of Object.entries(span.metrics)) {
1891
+ html += `
1892
+ <div class="metadata-item">
1893
+ <span class="metadata-label">${key}:</span>
1894
+ <span class="metadata-value">${value}</span>
1895
+ </div>`;
1896
+ }
1897
+ html += '</div>';
1898
+ }
1899
+
1900
+ html += '</div></div>';
1901
+ return html;
1902
+ }
1903
+
1904
+ function toggleTraceView(index) {
1905
+ const toggleButton = event.target;
1906
+ const waterfallView = document.getElementById(`waterfall-view-${index}`);
1907
+ const jsonView = document.getElementById(`json-view-${index}`);
1908
+
1909
+ if (toggleButton.textContent === 'JSON View') {
1910
+ // Switch to JSON view
1911
+ waterfallView.style.display = 'none';
1912
+ jsonView.style.display = 'block';
1913
+ toggleButton.textContent = 'Waterfall View';
1914
+ } else {
1915
+ // Switch to waterfall view
1916
+ waterfallView.style.display = 'block';
1917
+ jsonView.style.display = 'none';
1918
+ toggleButton.textContent = 'JSON View';
1919
+ // Regenerate waterfall if needed
1920
+ generateWaterfallViewForRequest(index);
1921
+ }
1922
+ }
1923
+
1924
+ </script>
1925
+ {% endblock %}