shokupan 0.10.5 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +46 -1815
  2. package/dist/{analyzer-BqIe1p0R.js → analyzer-BkNQHWj4.js} +3 -8
  3. package/dist/{analyzer-BqIe1p0R.js.map → analyzer-BkNQHWj4.js.map} +1 -1
  4. package/dist/{analyzer-CKLGLFtx.cjs → analyzer-DM-OlRq8.cjs} +2 -7
  5. package/dist/{analyzer-CKLGLFtx.cjs.map → analyzer-DM-OlRq8.cjs.map} +1 -1
  6. package/dist/{analyzer.impl-D9Yi1Hax.cjs → analyzer.impl-CVJ8zfGQ.cjs} +596 -42
  7. package/dist/analyzer.impl-CVJ8zfGQ.cjs.map +1 -0
  8. package/dist/{analyzer.impl-CV6W1Eq7.js → analyzer.impl-CsA1bS_s.js} +596 -42
  9. package/dist/analyzer.impl-CsA1bS_s.js.map +1 -0
  10. package/dist/cli.cjs +206 -18
  11. package/dist/cli.cjs.map +1 -1
  12. package/dist/cli.js +206 -18
  13. package/dist/cli.js.map +1 -1
  14. package/dist/context.d.ts +46 -9
  15. package/dist/index.cjs +3239 -1173
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.js +3236 -1171
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/application/api-explorer/static/explorer-client.mjs +375 -29
  20. package/dist/plugins/application/api-explorer/static/style.css +327 -8
  21. package/dist/plugins/application/api-explorer/static/theme.css +11 -2
  22. package/dist/plugins/application/asyncapi/generator.d.ts +4 -0
  23. package/dist/plugins/application/asyncapi/static/asyncapi-client.mjs +154 -22
  24. package/dist/plugins/application/asyncapi/static/style.css +24 -8
  25. package/dist/plugins/application/auth.d.ts +5 -0
  26. package/dist/plugins/application/dashboard/fetch-interceptor.d.ts +119 -0
  27. package/dist/plugins/application/dashboard/metrics-collector.d.ts +38 -2
  28. package/dist/plugins/application/dashboard/plugin.d.ts +53 -1
  29. package/dist/plugins/application/dashboard/static/charts.js +127 -62
  30. package/dist/plugins/application/dashboard/static/client.js +160 -0
  31. package/dist/plugins/application/dashboard/static/graph.mjs +167 -56
  32. package/dist/plugins/application/dashboard/static/reactflow.css +20 -10
  33. package/dist/plugins/application/dashboard/static/registry.js +112 -8
  34. package/dist/plugins/application/dashboard/static/requests.js +1167 -71
  35. package/dist/plugins/application/dashboard/static/styles.css +186 -14
  36. package/dist/plugins/application/dashboard/static/tabs.js +44 -9
  37. package/dist/plugins/application/dashboard/static/tabulator.css +23 -3
  38. package/dist/plugins/application/dashboard/static/theme.css +11 -2
  39. package/dist/plugins/application/mcp-server/plugin.d.ts +39 -0
  40. package/dist/plugins/application/openapi/analyzer.impl.d.ts +65 -1
  41. package/dist/plugins/application/openapi/openapi.d.ts +3 -0
  42. package/dist/plugins/application/shared/ast-utils.d.ts +7 -0
  43. package/dist/plugins/middleware/compression.d.ts +12 -2
  44. package/dist/plugins/middleware/rate-limit.d.ts +5 -0
  45. package/dist/router.d.ts +59 -19
  46. package/dist/server.d.ts +22 -0
  47. package/dist/shokupan.d.ts +31 -3
  48. package/dist/util/adapter/bun.d.ts +8 -0
  49. package/dist/util/adapter/filesystem.d.ts +20 -0
  50. package/dist/util/adapter/index.d.ts +4 -0
  51. package/dist/util/adapter/interface.d.ts +12 -0
  52. package/dist/util/adapter/node.d.ts +8 -0
  53. package/dist/util/adapter/wintercg.d.ts +5 -0
  54. package/dist/util/body-parser.d.ts +30 -0
  55. package/dist/util/controller-scanner.d.ts +4 -0
  56. package/dist/util/cpu-monitor.d.ts +2 -0
  57. package/dist/util/decorators.d.ts +20 -3
  58. package/dist/util/di.d.ts +3 -8
  59. package/dist/util/metadata.d.ts +18 -0
  60. package/dist/util/middleware-tracker.d.ts +10 -0
  61. package/dist/util/request.d.ts +1 -0
  62. package/dist/util/symbol.d.ts +1 -0
  63. package/dist/util/types.d.ts +167 -1
  64. package/package.json +7 -5
  65. package/dist/analyzer.impl-CV6W1Eq7.js.map +0 -1
  66. package/dist/analyzer.impl-D9Yi1Hax.cjs.map +0 -1
  67. package/dist/http-server-BEMPIs33.cjs +0 -85
  68. package/dist/http-server-BEMPIs33.cjs.map +0 -1
  69. package/dist/http-server-CCeagTyU.js +0 -68
  70. package/dist/http-server-CCeagTyU.js.map +0 -1
  71. package/dist/plugins/application/dashboard/static/failures.js +0 -85
  72. package/dist/plugins/application/dashboard/static/poll.js +0 -146
  73. package/dist/plugins/application/http-server.d.ts +0 -13
@@ -1,118 +1,1214 @@
1
1
 
2
2
  // Initialize Requests Table
3
- let requestsTable;
3
+ window.requestsTable = null;
4
4
 
5
- document.addEventListener('DOMContentLoaded', () => {
6
- requestsTable = new Tabulator("#requests-list-container", {
5
+ // Filter State
6
+ // Initialize Filter State
7
+ let filterText = '';
8
+ let filterType = 'all';
9
+ let filterDirection = 'all';
10
+ let filterIgnore = true;
11
+ let ignoreRegexes = [];
12
+
13
+ function globToRegex(pattern) {
14
+ // Escape special regex chars except *
15
+ let escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
16
+ // Convert * to .*
17
+ // For ** support, we can just treat * as .* for now, or distinguish.
18
+ // Simple approach: replace * with .*
19
+ // Note: This is a loose approximation of glob
20
+ const re = escaped.replace(/\*/g, '.*');
21
+ return new RegExp(`^${re}$`);
22
+ }
23
+
24
+ // Waterfall State
25
+ let minRequestTime = Infinity;
26
+ let maxRequestTime = 0;
27
+
28
+ function initRequests() {
29
+ console.log('[requests.js] Initializing...');
30
+
31
+ // Initialize Filter Listeners
32
+ const txtFilter = document.getElementById('network-filter-text');
33
+ const typeFilter = document.getElementById('network-filter-type');
34
+ const ignoreFilter = document.getElementById('network-filter-ignore');
35
+ const directionButtons = document.querySelectorAll('.filter-direction');
36
+
37
+ // Compile regexes
38
+ if (window.SHOKUPAN_CONFIG && window.SHOKUPAN_CONFIG.ignorePaths) {
39
+ ignoreRegexes = window.SHOKUPAN_CONFIG.ignorePaths.map(globToRegex);
40
+ }
41
+
42
+ if (directionButtons) {
43
+ directionButtons.forEach(btn => {
44
+ btn.onclick = () => {
45
+ // Update active state
46
+ directionButtons.forEach(b => {
47
+ b.style.background = 'transparent';
48
+ b.style.color = 'var(--text-secondary)';
49
+ b.classList.remove('active');
50
+ });
51
+ btn.style.background = 'var(--bg-primary)';
52
+ btn.style.color = 'var(--text-primary)';
53
+ btn.classList.add('active');
54
+
55
+ filterDirection = btn.dataset.value;
56
+ if (window.requestsTable) window.requestsTable.setFilter(customFilter);
57
+ };
58
+ });
59
+ }
60
+
61
+ if (txtFilter) {
62
+ txtFilter.addEventListener('keyup', (e) => {
63
+ filterText = e.target.value.toLowerCase();
64
+ window.requestsTable.setFilter(customFilter);
65
+ });
66
+ }
67
+
68
+ if (typeFilter) {
69
+ typeFilter.addEventListener('change', (e) => {
70
+ filterType = e.target.value;
71
+ window.requestsTable.setFilter(customFilter);
72
+ });
73
+ }
74
+
75
+ if (ignoreFilter) {
76
+ // specific listener
77
+ ignoreFilter.addEventListener('change', (e) => {
78
+ filterIgnore = e.target.checked;
79
+ window.requestsTable.setFilter(customFilter);
80
+ });
81
+ filterIgnore = ignoreFilter.checked;
82
+ }
83
+
84
+ // specific check for Tabulator
85
+ if (typeof Tabulator === 'undefined') {
86
+ console.error('Tabulator is not defined. Ensure it is loaded before requests.js');
87
+ return;
88
+ }
89
+
90
+ // Load saved column state
91
+ let savedColumns = {};
92
+ try {
93
+ const stored = localStorage.getItem('shokupan_dashboard_columns');
94
+ if (stored) savedColumns = JSON.parse(stored);
95
+ } catch (e) {
96
+ console.error("Failed to load column state", e);
97
+ }
98
+
99
+ function saveColumnState() {
100
+ if (!window.requestsTable) return;
101
+ const cols = window.requestsTable.getColumns();
102
+ const state = {};
103
+ cols.forEach(c => {
104
+ state[c.getField()] = c.isVisible();
105
+ });
106
+ localStorage.setItem('shokupan_dashboard_columns', JSON.stringify(state));
107
+ }
108
+
109
+ const headerMenu = [
110
+ {
111
+ label: "Hide Column",
112
+ action: function (e, column) {
113
+ column.hide();
114
+ saveColumnState();
115
+ }
116
+ },
117
+ {
118
+ separator: true,
119
+ },
120
+ {
121
+ label: "Select Columns",
122
+ menu: []
123
+ }
124
+ ];
125
+
126
+ const columns = [
127
+ {
128
+ title: "Status",
129
+ field: "status",
130
+ width: 100,
131
+ visible: savedColumns['status'] !== undefined ? savedColumns['status'] : true,
132
+ formatter: function (cell) {
133
+ const status = cell.getValue();
134
+ if (!status) return '<span style="color: var(--text-secondary)">Pending</span>';
135
+ const color = status >= 500 ? '#ef4444' : status >= 400 ? '#f59e0b' : '#10b981';
136
+ return `<span style="display: inline-block; width: 10px; height: 10px; background: ${color}; border-radius: 50%; margin-right: 6px;"></span>${status}`;
137
+ },
138
+ headerContextMenu: headerMenu
139
+ },
140
+ {
141
+ title: "Method",
142
+ field: "method",
143
+ width: 90,
144
+ headerSort: false,
145
+ visible: savedColumns['method'] !== undefined ? savedColumns['method'] : true,
146
+ headerContextMenu: headerMenu
147
+ },
148
+ {
149
+ title: "Name",
150
+ field: "url",
151
+ widthGrow: 2, // Take up more space
152
+ visible: savedColumns['url'] !== undefined ? savedColumns['url'] : true,
153
+ formatter: function (cell) {
154
+ const url = cell.getValue();
155
+ // Extract name from URL
156
+ let name = url;
157
+ try {
158
+ const u = new URL(url, 'http://localhost');
159
+ name = u.pathname;
160
+ if (name === '/') name = 'localhost';
161
+ const parts = name.split('/');
162
+ const last = parts[parts.length - 1];
163
+ if (last) name = last;
164
+ } catch (e) { }
165
+
166
+ return `<div style="display: flex; flex-direction: column; line-height: 1.2;">
167
+ <span style="color: var(--text-secondary);">${name}</span>
168
+ </div>`;
169
+ },
170
+ headerContextMenu: headerMenu
171
+ },
172
+ {
173
+ title: "Domain",
174
+ field: "domain",
175
+ width: 80,
176
+ visible: savedColumns['domain'] !== undefined ? savedColumns['domain'] : false,
177
+ headerContextMenu: headerMenu
178
+ },
179
+ {
180
+ title: "Path",
181
+ field: "path",
182
+ width: 80,
183
+ visible: savedColumns['path'] !== undefined ? savedColumns['path'] : true,
184
+ headerContextMenu: headerMenu
185
+ },
186
+ {
187
+ title: "URL",
188
+ field: "url",
189
+ width: 80,
190
+ visible: savedColumns['url'] !== undefined ? savedColumns['url'] : false,
191
+ headerContextMenu: headerMenu
192
+ },
193
+ {
194
+ title: "Protocol",
195
+ field: "protocol",
196
+ width: 80,
197
+ visible: savedColumns['protocol'] !== undefined ? savedColumns['protocol'] : false,
198
+ formatter: function (cell) {
199
+ const row = cell.getData();
200
+ // Prefer explicit protocol version (e.g. 1.1, h2) if available
201
+ if (row.protocol && row.protocol !== 'http' && row.protocol !== 'https') return row.protocol;
202
+ return row.scheme || row.protocol || '-';
203
+ },
204
+ headerContextMenu: headerMenu
205
+ },
206
+ {
207
+ title: "Scheme",
208
+ field: "scheme",
209
+ width: 80,
210
+ visible: savedColumns['scheme'] !== undefined ? savedColumns['scheme'] : false,
211
+ headerContextMenu: headerMenu
212
+ },
213
+ {
214
+ title: "Remote IP",
215
+ field: "remoteIP",
216
+ width: 80,
217
+ visible: savedColumns['remoteIP'] !== undefined ? savedColumns['remoteIP'] : true,
218
+ headerContextMenu: headerMenu
219
+ },
220
+ {
221
+ title: "Initiator",
222
+ field: "direction",
223
+ width: 80,
224
+ visible: savedColumns['direction'] !== undefined ? savedColumns['direction'] : false,
225
+ formatter: (cell) => {
226
+ const dir = cell.getValue();
227
+ return dir === 'outbound' ? 'Server' : 'Client';
228
+ },
229
+ headerContextMenu: headerMenu
230
+ },
231
+ {
232
+ title: "Type",
233
+ field: "type",
234
+ width: 80,
235
+ visible: savedColumns['type'] !== undefined ? savedColumns['type'] : false,
236
+ formatter: (cell) => {
237
+ const r = cell.getData();
238
+ if (r.type === 'fetch') return 'fetch';
239
+ if (r.type === 'xhr') return 'xhr';
240
+ if (r.type === 'ws') return 'ws';
241
+ return r.contentType || 'document';
242
+ },
243
+ headerContextMenu: headerMenu
244
+ },
245
+ {
246
+ title: "Cookies",
247
+ field: "cookies",
248
+ width: 80,
249
+ visible: savedColumns['cookies'] !== undefined ? savedColumns['cookies'] : false,
250
+ headerContextMenu: headerMenu
251
+ },
252
+ {
253
+ title: "Transferred",
254
+ field: "transferred",
255
+ width: 80,
256
+ visible: savedColumns['transferred'] !== undefined ? savedColumns['transferred'] : false,
257
+ headerContextMenu: headerMenu
258
+ },
259
+ {
260
+ title: "Size",
261
+ field: "size",
262
+ width: 110,
263
+ visible: savedColumns['size'] !== undefined ? savedColumns['size'] : true,
264
+ formatter: (cell) => formatBytes(cell.getValue()),
265
+ headerContextMenu: headerMenu
266
+ },
267
+ {
268
+ title: "Time",
269
+ field: "duration",
270
+ width: 90,
271
+ visible: savedColumns['duration'] !== undefined ? savedColumns['duration'] : true,
272
+ formatter: (cell) => cell.getValue() ? Math.round(cell.getValue()) + ' ms' : 'Pending',
273
+ headerContextMenu: headerMenu
274
+ },
275
+ {
276
+ title: "Waterfall",
277
+ field: "timestamp",
278
+ widthGrow: 1,
279
+ visible: savedColumns['timestamp'] !== undefined ? savedColumns['timestamp'] : true,
280
+ formatter: waterfallFormatter,
281
+ headerSort: true,
282
+ headerContextMenu: headerMenu
283
+ }
284
+ ];
285
+
286
+ const checkMark = `<svg fill="currentColor" width="16px" height="16px" style="padding: 2px; margin-right: 2px" viewBox="0 0 1024 1024"><path d="M351.605 663.268l481.761-481.761c28.677-28.677 75.171-28.677 103.847 0s28.677 75.171 0 103.847L455.452 767.115l.539.539-58.592 58.592c-24.994 24.994-65.516 24.994-90.51 0L85.507 604.864c-28.677-28.677-28.677-75.171 0-103.847s75.171-28.677 103.847 0l162.25 162.25z"/></svg>`;
287
+ const uncheckMark = `<span style="width: 18px; display: inline-block"></span>`;
288
+
289
+ const subMenu = [];
290
+ columns.forEach((col, idx) => {
291
+ subMenu.push({
292
+ label: (col.visible ? checkMark : uncheckMark) + " " + col.title,
293
+ action: function (e) {
294
+ // const cols = window.requestsTable.getColumns();
295
+ // cols.forEach((c, i) => {
296
+ // if (idx === i) c.toggle();
297
+ // });
298
+ columns[idx].visible = !columns[idx].visible;
299
+ subMenu[idx].label = (columns[idx].visible ? checkMark : uncheckMark) + " " + columns[idx].title;
300
+ if (window.requestsTable) {
301
+ window.requestsTable.redraw();
302
+ window.requestsTable.setColumns(columns);
303
+ saveColumnState();
304
+ };
305
+ }
306
+ });
307
+ });
308
+ headerMenu[2].menu = subMenu;
309
+
310
+ window.requestsTable = new Tabulator("#requests-list-container", {
7
311
  layout: "fitColumns",
312
+ responsiveLayout: true,
313
+ resizableColumnGuide: true,
314
+ resizableColumnFit: true,
8
315
  placeholder: "No requests found",
9
- selectable: 1,
10
- columns: [
11
- { title: "Method", field: "method", width: 100 },
12
- { title: "URL", field: "url" },
316
+ selectableRows: 1,
317
+ height: "100%", // Fill container
318
+ index: "id",
319
+ rowHeight: 32, // Dense rows
320
+ initialSort: [
321
+ { column: "timestamp", dir: "desc" }
322
+ ],
323
+ columns: columns,
324
+ data: [],
325
+ rowContextMenu: [
13
326
  {
14
- title: "Status",
15
- field: "status",
16
- width: 100,
17
- formatter: function (cell) {
18
- const status = cell.getValue();
19
- const color = status >= 500 ? 'red' : status >= 400 ? 'orange' : 'green';
20
- return `<span style="color: ${color}; font-weight: bold;">${status}</span>`;
327
+ label: "Replay Request",
328
+ action: function (e, row) {
329
+ const data = row.getData();
330
+ const basePath = window.location.pathname.endsWith('/') ? window.location.pathname.slice(0, -1) : window.location.pathname;
331
+
332
+ // Determine direction if not explicit
333
+ const direction = data.direction || 'inbound';
334
+
335
+ fetch(basePath + '/replay', {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({
339
+ method: data.method,
340
+ url: data.url,
341
+ headers: data.requestHeaders,
342
+ body: data.requestBody,
343
+ direction: direction
344
+ })
345
+ })
346
+ .then(res => res.json())
347
+ .then(result => {
348
+ if (result.error) {
349
+ alert('Replay Failed: ' + result.error);
350
+ } else {
351
+ // Show result in a simplified details view or just alert success?
352
+ // User requirement: "presents the response data to the user"
353
+ // Let's create a temporary object mimicking a request log and show it in details view
354
+ const replayLog = {
355
+ ...data,
356
+ id: 'replay-' + Date.now(),
357
+ status: result.status,
358
+ duration: result.duration || 0,
359
+ timestamp: Date.now(),
360
+ responseHeaders: result.headers,
361
+ responseBody: result.data,
362
+ size: result.data ? result.data.length : 0
363
+ };
364
+ showRequestDetails(replayLog);
365
+ }
366
+ })
367
+ .catch(err => console.error("Replay fetch failed", err));
21
368
  }
22
369
  },
23
- { title: "Duration (ms)", field: "duration", width: 150, formatter: (cell) => printDuration(cell.getValue()) },
24
370
  {
25
- title: "Time",
26
- field: "timestamp",
27
- width: 200,
28
- formatter: function (cell) {
29
- return new Date(cell.getValue()).toLocaleString();
371
+ label: "Copy as fetch",
372
+ action: function (e, row) {
373
+ const data = row.getData();
374
+ const fetchCode = generateFetchCode(data);
375
+ copyToClipboard(fetchCode);
30
376
  }
31
377
  },
32
378
  {
33
- title: "",
34
- width: 80,
35
- formatter: function (cell) {
36
- const el = document.createElement("div");
37
- el.onclick = () => showRequestDetails(cell.getData());
38
- el.innerHTML = "View";
39
- return el;
379
+ label: "Export as HAR",
380
+ action: function (e, row) {
381
+ const data = row.getData();
382
+ const har = generateHAR([data]);
383
+ downloadString(JSON.stringify(har, null, 2), `request-${data.id}.har`);
384
+ }
385
+ },
386
+ {
387
+ label: "Export All as HAR",
388
+ action: function (e, row) {
389
+ const allData = window.requestsTable.getData("active"); // get filtered data
390
+ const har = generateHAR(allData);
391
+ downloadString(JSON.stringify(har, null, 2), `requests-export.har`);
40
392
  }
41
393
  }
42
- ],
43
- data: []
394
+ ]
44
395
  });
45
396
 
46
- // Auto-fetch on load if tab is active (or just fetch initially)
397
+ // Row selection handler
398
+ window.requestsTable.on("rowClick", function (e, row) {
399
+ showRequestDetails(row.getData());
400
+ });
401
+
402
+ // Auto-fetch on load
47
403
  fetchRequests();
48
- });
49
404
 
50
- function fetchRequests() {
405
+ // Resize Logic
406
+ initResizeHandle();
407
+ }
408
+
409
+ function initResizeHandle() {
410
+ const handle = document.getElementById('details-drag-handle');
411
+ const container = document.getElementById('request-details-container');
412
+
413
+ if (!handle || !container) return;
414
+
415
+ let isResizing = false;
416
+ let startX, startWidth;
417
+
418
+ handle.addEventListener('mousedown', (e) => {
419
+ isResizing = true;
420
+ startX = e.clientX;
421
+ startWidth = container.offsetWidth;
422
+ document.body.style.cursor = 'col-resize';
423
+ e.preventDefault();
424
+ });
425
+
426
+ document.addEventListener('mousemove', (e) => {
427
+ if (!isResizing) return;
428
+ // Calculate new width: It's expanding to the left, so moving mouse left increases width
429
+ const dx = startX - e.clientX;
430
+ const newWidth = Math.max(300, Math.min(window.innerWidth - 100, startWidth + dx));
431
+ container.style.width = `${newWidth}px`;
432
+
433
+ // Optional: trigger tabulator redraw if list container size changed significantly (it flexes)
434
+ if (window.requestsTable) window.requestsTable.redraw();
435
+ });
436
+
437
+ document.addEventListener('mouseup', () => {
438
+ if (isResizing) {
439
+ isResizing = false;
440
+ document.body.style.cursor = '';
441
+ // Save width preference?
442
+ if (window.requestsTable) window.requestsTable.redraw();
443
+ }
444
+ });
445
+
446
+ }
447
+
448
+ function generateFetchCode(req) {
449
+ const headers = req.requestHeaders || {};
450
+ let code = `fetch("${req.url}", {\n`;
451
+ code += ` "method": "${req.method}",\n`;
452
+ code += ` "headers": ${JSON.stringify(headers, null, 2).replace(/\n/g, '\n ')},\n`;
453
+
454
+ if (req.requestBody) {
455
+ if (typeof req.requestBody === 'object') {
456
+ code += ` "body": JSON.stringify(${JSON.stringify(req.requestBody)}),\n`;
457
+ } else {
458
+ code += ` "body": ${JSON.stringify(req.requestBody)},\n`;
459
+ }
460
+ }
461
+ code += `});`;
462
+ return code;
463
+ }
464
+
465
+ function generateHAR(requests) {
466
+ return {
467
+ log: {
468
+ version: "1.2",
469
+ creator: { name: "Shokupan Dashboard", version: "1.0" },
470
+ entries: requests.map(req => ({
471
+ startedDateTime: new Date(req.timestamp).toISOString(),
472
+ time: req.duration,
473
+ request: {
474
+ method: req.method,
475
+ url: req.url,
476
+ httpVersion: req.protocol || "HTTP/1.1",
477
+ cookies: [], // Todo parse
478
+ headers: Object.entries(req.requestHeaders || {}).map(([name, value]) => ({ name, value })),
479
+ queryString: [], // Todo parse from url
480
+ postData: req.requestBody ? { mimeType: req.contentType || "application/json", text: JSON.stringify(req.requestBody) } : undefined,
481
+ headersSize: -1,
482
+ bodySize: -1
483
+ },
484
+ response: {
485
+ status: req.status,
486
+ statusText: "",
487
+ httpVersion: req.protocol || "HTTP/1.1",
488
+ cookies: [],
489
+ headers: Object.entries(req.responseHeaders || {}).map(([name, value]) => ({ name, value })),
490
+ content: {
491
+ size: req.size || 0,
492
+ mimeType: req.contentType || "",
493
+ text: typeof req.body === 'string' ? req.body : JSON.stringify(req.body)
494
+ },
495
+ redirectURL: "",
496
+ headersSize: -1,
497
+ bodySize: -1
498
+ },
499
+ cache: {},
500
+ timings: {
501
+ send: 0,
502
+ wait: req.duration,
503
+ receive: 0
504
+ }
505
+ }))
506
+ }
507
+ };
508
+ }
509
+
510
+ function purgeRequests() {
511
+ if (!confirm("Are you sure you want to purge all captured requests?")) return;
512
+
51
513
  const headers = typeof getRequestHeaders !== 'undefined' ? getRequestHeaders() : {};
514
+ const basePath = window.location.pathname.endsWith('/') ? window.location.pathname.slice(0, -1) : window.location.pathname;
515
+ // Need to handle if we are mounted at /dashboard vs /dashboard/ so stripping slice(-1) might be wrong if it wasn't there
516
+ // Safer:
517
+ let base = window.location.pathname;
518
+ if (base.endsWith('/')) base = base.slice(0, -1);
519
+
520
+ fetch(base + '/requests', {
521
+ method: 'DELETE',
522
+ headers
523
+ })
524
+ .then(res => res.json())
525
+ .then(data => {
526
+ if (data.success) {
527
+ console.log("Purge successful");
528
+ if (window.requestsTable) window.requestsTable.clearData();
529
+ closeRequestDetails();
530
+ }
531
+ })
532
+ .catch(console.error);
533
+ }
52
534
 
53
- // Determine base path for API requests
535
+ // Robust initialization
536
+ let initAttempts = 0;
537
+ function tryInit() {
538
+ if (document.getElementById('requests-list-container') && typeof Tabulator !== 'undefined') {
539
+ try {
540
+ initRequests();
541
+ } catch (e) {
542
+ console.error('Failed to initialize requests table:', e);
543
+ const el = document.getElementById('requests-list-container');
544
+ if (el) el.innerHTML = `<div style="padding: 2rem; color: #ef4444;">Failed to initialize: ${e.message}</div>`;
545
+ }
546
+ } else {
547
+ initAttempts++;
548
+ if (initAttempts > 50) { // 5 seconds timeout
549
+ console.error('Request table initialization timed out. Tabulator is:', typeof Tabulator);
550
+ const el = document.getElementById('requests-list-container');
551
+ if (el) el.innerHTML = `<div style="padding: 2rem; color: #ef4444;">
552
+ Failed to load dependencies. <br>
553
+ Tabulator: ${typeof Tabulator}
554
+ </div>`;
555
+ return;
556
+ }
557
+ setTimeout(tryInit, 100);
558
+ }
559
+ }
560
+
561
+ tryInit();
562
+
563
+
564
+ function customFilter(data) {
565
+ // Type Filter
566
+ if (filterType !== 'all') {
567
+ const type = data.type || 'xhr'; // default to xhr if missing
568
+ if (filterType === 'fetch' && type !== 'fetch') return false;
569
+ if (filterType === 'xhr' && type !== 'xhr') return false;
570
+ if (filterType === 'ws' && type !== 'ws') return false;
571
+ }
572
+
573
+ // Direction Filter
574
+ if (filterDirection !== 'all') {
575
+ const dir = data.direction || 'inbound';
576
+ if (filterDirection !== dir) return false;
577
+ }
578
+
579
+ // Ignore Filter
580
+ if (filterIgnore && ignoreRegexes.length > 0) {
581
+ // check against regexes
582
+ // We match against URL or Path?
583
+ // Usually path.
584
+ // data.url might be full URL. data.path is path.
585
+ const path = data.path || data.url; // Fallback
586
+ // Also check full URL just in case glob is absolute?
587
+ // Let's assume glob matches against path.
588
+ for (const re of ignoreRegexes) {
589
+ if (re.test(path)) return false;
590
+ }
591
+ }
592
+
593
+ // Text Filter (Regex-ish)
594
+ if (filterText) {
595
+ const text = (data.url + ' ' + data.method).toLowerCase();
596
+ return text.includes(filterText);
597
+ }
598
+
599
+ return true;
600
+ }
601
+
602
+ function waterfallFormatter(cell) {
603
+ const data = cell.getData();
604
+ // Default to duration bar if no range yet
605
+ const duration = data.duration || 0;
606
+
607
+ // Safety check
608
+ if (minRequestTime === Infinity || maxRequestTime === 0) {
609
+ // Just show a simple bar based on some 2s default
610
+ const pct = Math.min(100, (duration / 2000) * 100);
611
+ const color = duration > 1000 ? '#ef4444' : duration > 500 ? '#f59e0b' : '#3b82f6';
612
+ return `<div style="width: 100%; height: 100%; display: flex; align-items: center;">
613
+ <div style="height: calc(100% - 4px); width: ${pct}%; background: ${color}; border-radius: 3px; min-width: 2px;"></div>
614
+ </div>`;
615
+ }
616
+
617
+ const totalRange = maxRequestTime - minRequestTime;
618
+ // Prevent divide by zero
619
+ const safeRange = totalRange <= 0 ? 1 : totalRange;
620
+
621
+ // Calculate start offset relative to minRequestTime
622
+ // We treat minRequestTime as 0%
623
+ // If a request started before minRequestTime (unlikely given logic), clamp to 0
624
+ const startTimeResult = data.timestamp - minRequestTime;
625
+ const startPct = Math.max(0, (startTimeResult / safeRange) * 100);
626
+
627
+ // Calculate width relative to totalRange
628
+ // Use a min width of 0.5% so it's visible
629
+ const widthPct = Math.max(0.5, (duration / safeRange) * 100);
630
+
631
+ // Color
632
+ const color = duration > 1000 ? '#ef4444' : duration > 500 ? '#f59e0b' : '#3b82f6';
633
+
634
+ return `<div style="width: 100%; height: 100%; display: flex; align-items: center; position: relative;">
635
+ <div style="
636
+ position: absolute;
637
+ left: min(${startPct}%, calc(100% - 2px));
638
+ width: ${widthPct}%;
639
+ height: calc(100% - 4px);
640
+ background: ${color};
641
+ border-radius: 3px;
642
+ min-width: 2px;
643
+ " title="Start: +${Math.round(startTimeResult)}ms, Duration: ${Math.round(duration)}ms"></div>
644
+ </div>`;
645
+ }
646
+
647
+ // Utility to track time range
648
+ function updateTimestamps(requests) {
649
+ if (!requests || !requests.length) return;
650
+ let changed = false;
651
+ requests.forEach(r => {
652
+ const start = r.timestamp;
653
+ const end = start + (r.duration || 0);
654
+ if (start < minRequestTime) {
655
+ minRequestTime = start;
656
+ changed = true;
657
+ }
658
+ // Also extend max if needed, but generally max is max(end)
659
+ // However, waterfall usually shows relative to session start.
660
+ // If we want "waterfall of current view", we care about min/max of visible.
661
+ // But for simplicity, we track global session range.
662
+ if (end > maxRequestTime) {
663
+ maxRequestTime = end;
664
+ changed = true;
665
+ }
666
+ // Also handle if start > maxRequestTime (e.g. first request)
667
+ if (start > maxRequestTime) {
668
+ maxRequestTime = end; // start + duration
669
+ changed = true;
670
+ }
671
+ });
672
+ return changed;
673
+ }
674
+
675
+ // Global handler for Client.js
676
+ window.updateRequestsList = function (newRequests) {
677
+ if (!window.requestsTable || !newRequests || !newRequests.length) return;
678
+
679
+ // Update Timestamps
680
+ const changed = updateTimestamps(newRequests);
681
+
682
+ // Add or Update data (true = add to top if new)
683
+ // using updateOrAddData to be safe if IDs exist
684
+ window.requestsTable.updateOrAddData(newRequests)
685
+ .then(() => {
686
+ // If range expanded significantly, or just always for safety to update relative bars
687
+ if (changed) {
688
+ // redraw(true) forces full re-render of rows
689
+ window.requestsTable.redraw(true);
690
+ }
691
+ });
692
+ };
693
+
694
+
695
+
696
+ function fetchRequests() {
697
+ const headers = typeof getRequestHeaders !== 'undefined' ? getRequestHeaders() : {};
54
698
  const basePath = window.location.pathname.endsWith('/') ? window.location.pathname.slice(0, -1) : window.location.pathname;
55
- const url = basePath + '/';
699
+ const url = basePath + '/requests';
56
700
 
57
- fetch(url + 'requests', { headers })
701
+ fetch(url, { headers })
58
702
  .then(res => res.json())
59
703
  .then(data => {
60
- if (requestsTable) {
61
- requestsTable.setData(data.requests);
704
+ if (window.requestsTable) {
705
+ const reqs = data.requests || [];
706
+ // Reset timestamps on full load/reload
707
+ minRequestTime = Infinity;
708
+ maxRequestTime = 0;
709
+ updateTimestamps(reqs);
710
+
711
+ window.requestsTable.setData(reqs);
712
+ window.requestsTable.setFilter(customFilter);
62
713
  }
63
714
  })
64
- .catch(err => console.error("Failed to fetch requests", err));
715
+ .catch(err => {
716
+ console.error("Failed to fetch requests", err);
717
+ });
65
718
  }
66
719
 
720
+
721
+
67
722
  function showRequestDetails(request) {
68
723
  const container = document.getElementById('request-details-container');
69
724
  const content = document.getElementById('request-details-content');
70
- const traceContainer = document.getElementById('middleware-trace-container');
71
-
72
- container.style.display = 'block';
73
-
74
- // Render Summary
75
- content.innerHTML = `
76
- <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
77
- <div><strong>Method:</strong> ${request.method}</div>
78
- <div><strong>URL:</strong> ${request.url}</div>
79
- <div><strong>Status:</strong> ${request.status}</div>
80
- <div><strong>Duration:</strong> ${printDuration(request.duration)} ms</div>
81
- <div><strong>Timestamp:</strong> ${new Date(request.timestamp).toLocaleString()}</div>
725
+
726
+ container.style.display = 'flex';
727
+ if (window.requestsTable) window.requestsTable.redraw();
728
+
729
+ // Tab Headers
730
+ const tabs = [
731
+ { id: 'headers', label: 'Headers' },
732
+ { id: 'cookies', label: 'Cookies' },
733
+ { id: 'request', label: 'Request' },
734
+ { id: 'response', label: 'Response' },
735
+ { id: 'timings', label: 'Timings' },
736
+ // { id: 'security', label: 'Security' } // Enable if we have data
737
+ ];
738
+
739
+ if (request.scheme === 'https' || request.scheme === 'wss') {
740
+ tabs.push({ id: 'security', label: 'Security' });
741
+ }
742
+
743
+ let activeTab = 'headers';
744
+
745
+ function renderTabs() {
746
+ return `
747
+ <div class="tabs-header" style="display: flex; border-bottom: 1px solid var(--border-color)">
748
+ ${tabs.map(tab => `
749
+ <div class="tab-item ${tab.id === activeTab ? 'active' : ''}"
750
+ data-tab="${tab.id}"
751
+ style="padding: 8px 16px; cursor: pointer; border-bottom: 2px solid ${tab.id === activeTab ? 'var(--primary-color, #3b82f6)' : 'transparent'}; color: ${tab.id === activeTab ? 'var(--text-primary)' : 'var(--text-secondary)'};">
752
+ ${tab.label}
753
+ </div>
754
+ `).join('')}
755
+ </div>
756
+ <div id="tab-content" style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; padding: 1rem">
757
+ ${renderTabContent(activeTab, request)}
758
+ </div>
759
+ `;
760
+ }
761
+
762
+ content.innerHTML = renderTabs();
763
+
764
+ // Event Delegation for Tabs
765
+ content.onclick = (e) => {
766
+ const tabItem = e.target.closest('.tab-item');
767
+ if (tabItem) {
768
+ const newTab = tabItem.dataset.tab;
769
+ if (newTab !== activeTab) {
770
+ activeTab = newTab;
771
+ content.innerHTML = renderTabs();
772
+ document.querySelector("#tab-content").style.padding = "1rem";
773
+
774
+ if (activeTab === "timings") {
775
+ const traceContainer = document.getElementById('middleware-trace-container');
776
+ renderTrace(request, traceContainer);
777
+ }
778
+
779
+ // Re-initialize editors if needed
780
+ if (activeTab === 'response') {
781
+ document.querySelector("#tab-content").style.padding = "0";
782
+ initResponseEditor(request);
783
+ };
784
+ if (activeTab === 'request') {
785
+ document.querySelector("#tab-content").style.padding = "0";
786
+ initRequestEditor(request);
787
+ };
788
+ }
789
+ }
790
+ };
791
+
792
+ if (activeTab === "timings") {
793
+ const traceContainer = document.getElementById('middleware-trace-container');
794
+ renderTrace(request, traceContainer);
795
+ }
796
+ // Initial Editor Load
797
+ if (activeTab === 'response') initResponseEditor(request);
798
+ }
799
+
800
+ function renderTabContent(tabId, request) {
801
+ switch (tabId) {
802
+ case 'headers':
803
+ return renderHeadersTab(request);
804
+ case 'cookies':
805
+ return renderCookiesTab(request);
806
+ case 'request':
807
+ return renderRequestTab(request);
808
+ case 'response':
809
+ return renderResponseTab(request);
810
+ case 'timings':
811
+ return renderTimingsTab(request);
812
+ case 'security':
813
+ return renderSecurityTab(request);
814
+ default:
815
+ return '';
816
+ }
817
+ }
818
+
819
+ function renderNameValueTable(items, emptyMessage = 'No items found') {
820
+ if (!items || !items.length) return `<div style="padding: 8px; color: var(--text-secondary);">${emptyMessage}</div>`;
821
+ return `
822
+ <table style="width: 100%; text-align: left; border-collapse: collapse; font-size: 0.9em;">
823
+ <thead>
824
+ <tr style="border-bottom: 1px solid var(--border-color);">
825
+ <th style="padding: 4px 8px;">Name</th>
826
+ <th style="padding: 4px 8px;">Value</th>
827
+ </tr>
828
+ </thead>
829
+ <tbody>
830
+ ${items.map(c => `
831
+ <tr style="border-bottom: 1px solid var(--border-color-dim, #33333333);">
832
+ <td style="padding: 4px 8px; font-weight: 500;">${c.name}</td>
833
+ <td style="padding: 4px 8px; word-break: break-all;">${c.value}</td>
834
+ </tr>
835
+ `).join('')}
836
+ </tbody>
837
+ </table>
838
+ `;
839
+ }
840
+
841
+ function renderHeadersTab(request) {
842
+ const formatHeaderSection = (title, headers) => {
843
+ if (!headers || Object.keys(headers).length === 0) return '';
844
+ const rows = Object.entries(headers).map(([k, v]) => `
845
+ <tr>
846
+ <td style="font-weight: 500; color: var(--text-flavor); padding: 4px 8px; vertical-align: top;">${k}:</td>
847
+ <td style="word-break: break-all; padding: 4px 8px;">${v}</td>
848
+ </tr>
849
+ `).join('');
850
+ return `
851
+ <details open style="margin-bottom: 1rem;">
852
+ <summary style="font-weight: bold; padding: 4px 0; cursor: pointer; color: var(--text-primary);">${title}</summary>
853
+ <table style="width: 100%; border-collapse: collapse; font-size: 0.9em;">
854
+ ${rows}
855
+ </table>
856
+ </details>
857
+ `;
858
+ };
859
+
860
+ return `
861
+ <div style="padding: 0 0.5rem;">
862
+ <details open style="margin-bottom: 1rem;">
863
+ <summary style="font-weight: bold; padding: 4px 0; cursor: pointer; color: var(--text-primary);">General</summary>
864
+ <div style="display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 0.9em; padding-left: 8px;">
865
+ <div style="color: var(--text-flavor);">Request URL:</div><div style="word-break: break-all;">${request.url}</div>
866
+ <div style="color: var(--text-flavor);">Request Method:</div><div>${request.method}</div>
867
+ <div style="color: var(--text-flavor);">Status Code:</div><div>${request.status}</div>
868
+ <div style="color: var(--text-flavor);">Remote Address:</div><div>${request.remoteIP || '-'}</div>
869
+ <div style="color: var(--text-flavor);">Referrer Policy:</div><div>${request.requestHeaders?.['referrer-policy'] || 'strict-origin-when-cross-origin'}</div>
870
+ </div>
871
+ </details>
872
+ ${formatHeaderSection('Response Headers', request.responseHeaders)}
873
+ ${formatHeaderSection('Request Headers', request.requestHeaders)}
874
+ </div>
875
+ `;
876
+ }
877
+
878
+ function renderCookiesTab(request) {
879
+ // Parse Cookies
880
+ const reqCookies = request.requestHeaders?.['cookie']
881
+ ? request.requestHeaders['cookie'].split(';').map(c => {
882
+ const [k, v] = c.trim().split('=');
883
+ return { name: k, value: v };
884
+ })
885
+ : [];
886
+
887
+ // Naive Set-Cookie parsing (often an array, but we might have it merged or as single string depending on collection)
888
+ // If headers are just Record<string, string>, Set-Cookie might be joined by comma, which is bad for automated parsing if values contain commas.
889
+ // For now, let's assume one or basic parsing.
890
+ let resCookies = [];
891
+ if (request.responseHeaders?.['set-cookie']) {
892
+ // This is tricky if multiple set-cookies are merged.
893
+ // Assuming a simple array or single string for now.
894
+ // If generic Record<string,string> was used, multiple set-cookies might be lost or merged.
895
+ // We'll display what we have.
896
+ resCookies = [{ name: 'Set-Cookie', value: request.responseHeaders['set-cookie'] }];
897
+ }
898
+
899
+ return `
900
+ <div style="padding: 0 0.5rem; display: flex; flex-direction: column; gap: 1rem;">
901
+ <div>
902
+ <div style="font-weight: bold; margin-bottom: 0.5rem;">Request Cookies</div>
903
+ ${renderNameValueTable(reqCookies, 'No cookies found')}
904
+ </div>
905
+ <div>
906
+ <div style="font-weight: bold; margin-bottom: 0.5rem;">Response Cookies</div>
907
+ ${renderNameValueTable(resCookies, 'No cookies found')}
908
+ </div>
909
+ </div>
910
+ `;
911
+ }
912
+
913
+ function renderRequestTab(request) {
914
+ let queryParamsHtml = '';
915
+ try {
916
+ const url = new URL(request.url.startsWith('http') ? request.url : `http://${request.domain || 'localhost'}${request.url}`);
917
+ const params = [];
918
+ for (const [key, value] of url.searchParams) {
919
+ params.push({ name: key, value: value });
920
+ }
921
+
922
+ if (params.length > 0) {
923
+ queryParamsHtml = `
924
+ <div style="margin-bottom: 1rem;">
925
+ <div style="font-weight: bold; margin-bottom: 0.5rem; color: var(--text-primary);">Query Parameters</div>
926
+ ${renderNameValueTable(params)}
927
+ </div>
928
+ `;
929
+ }
930
+ } catch (e) {
931
+ console.error("Failed to parse URL for query params", e);
932
+ }
933
+
934
+ const hasBody = request.requestBody || request.body || (typeof request.requestBody === 'string' && request.requestBody.length > 0);
935
+
936
+ if (!hasBody && !queryParamsHtml) return '<div style="padding: 1rem; color: var(--text-secondary);">No payload or query parameters</div>';
937
+
938
+ return `
939
+ <div style="display: flex; flex-direction: column; height: 100%;">
940
+ ${queryParamsHtml}
941
+ <div style="display: flex; justify-content: flex-end; padding: 4px; gap: 8px;">
942
+ <div style="font-size: 0.8em; color: var(--text-secondary); display: flex; align-items: center;">${request.requestBody ? formatBytes(request.requestBody.length || 0) : ''}</div>
943
+ <button class="btn-action" id="btn-copy-req-body">Copy</button>
944
+ </div>
945
+ <div id="request-body-editor" style="flex: 1; border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; min-height: 200px;"></div>
946
+ </div>
947
+ `;
948
+ }
949
+
950
+ function renderResponseTab(request) {
951
+ if (!request.responseBody && !request.body) return '<div style="padding: 1rem; color: var(--text-secondary);">No content</div>';
952
+
953
+ return `
954
+ <div style="display: flex; flex-direction: column; height: 100%">
955
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 4px; border-bottom: 1px solid var(--border-color);">
956
+ <div style="font-size: 0.8em; color: var(--text-secondary);">${formatBytes(request.size || 0)}</div>
957
+ <div style="display: flex; gap: 8px; align-items: center;">
958
+ <label style="display: flex; align-items: center; gap: 4px; font-size: 0.8rem; cursor: pointer; user-select: none;">
959
+ <input type="checkbox" id="auto-format-check" ${window.autoFormatEnabled !== false ? 'checked' : ''}> Format
960
+ </label>
961
+ <div style="width: 1px; height: 16px; background: var(--border-color); margin: 0 4px;"></div>
962
+ <button id="btn-copy-body" class="btn-action" title="Copy Body">Copy</button>
963
+ <button id="btn-download-body" class="btn-action" title="Download Body">Download</button>
964
+ </div>
965
+ </div>
966
+ <div id="response-body-editor" style="flex: 1; border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; min-height: 200px;"></div>
967
+ </div>
968
+ `;
969
+ }
970
+
971
+ function renderTimingsTab(request) {
972
+ // Placeholder for timings visualization
973
+ return `
974
+ <div style="padding: 1rem;">
975
+ <div style="display: grid; grid-template-columns: 1fr auto; gap: 8px; max-width: 400px; font-size: 0.9em;">
976
+ <div>Started At:</div><div>${new Date(request.timestamp).toLocaleString()}</div>
977
+ <div>Duration:</div><div>${request.duration.toFixed(2)} ms</div>
978
+ <div style="border-top: 1px solid var(--border-color); margin-top:8px; padding-top:8px; font-weight:bold;">Total Transferred:</div><div style="border-top: 1px solid var(--border-color); margin-top:8px; padding-top:8px; font-weight:bold;">${formatBytes(request.transferred || request.size || 0)}</div>
979
+ </div>
980
+ <div class="card-title" style="margin-top: 1rem; padding: 0">Middleware Trace</div>
981
+ <div id="middleware-trace-container"></div>
982
+ </div>
983
+ `;
984
+ }
985
+
986
+ function renderSecurityTab(request) {
987
+ return `
988
+ <div style="padding: 1rem;">
989
+ <div style="margin-bottom: 1rem; font-weight: bold;">Connection</div>
990
+ <div style="display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 0.9em;">
991
+ <div style="color: var(--text-flavor);">Protocol:</div><div>${request.protocol || request.scheme || 'tls'}</div>
992
+ <div style="color: var(--text-flavor);">Remote Address:</div><div>${request.remoteIP || 'Unknown'}</div>
993
+ </div>
994
+ <div style="margin-top: 1rem; color: var(--text-secondary); font-style: italic;">
995
+ Detailed certificate information is not currently captured by the interceptor.
996
+ </div>
82
997
  </div>
83
998
  `;
999
+ }
1000
+
1001
+ function closeRequestDetails() {
1002
+ document.getElementById('request-details-container').style.display = 'none';
1003
+ if (window.requestsTable) window.requestsTable.redraw();
1004
+ }
1005
+ window.closeRequestDetails = closeRequestDetails;
84
1006
 
85
- // Render Trace
1007
+ function renderTrace(request, container) {
86
1008
  if (request.handlerStack && request.handlerStack.length > 0) {
1009
+ const totalDuration = request.duration || 1;
87
1010
  let html = '<div style="display: flex; flex-direction: column; gap: 4px;">';
88
1011
 
89
1012
  request.handlerStack.forEach((item, index) => {
90
- const duration = item.duration || 0;
91
-
92
- if (index !== 0) {
93
- html += `<div style="align-self: center">⬇︎</div>`;
94
- }
1013
+ const duration = item.duration > 0 ? item.duration : 0.01;
1014
+ const percent = Math.min(100, Math.max(1, (duration / totalDuration) * 100));
1015
+ const isSlow = percent > 15;
95
1016
 
96
1017
  html += `
97
- <div style="padding: 8px; border-radius: 4px; background: var(--bg-secondary);">
98
- <div style="display: flex; justify-content: space-between;">
99
- <span style="font-weight: bold;">${item.name}</span>
100
- <span>${printDuration(duration)}</span>
101
- </div>
102
- <div style="font-size: 0.8rem; color: var(--text-secondary);">
103
- ${item.file}:${item.line}
104
- </div>
105
- ${item.stateChanges ? `<div style="font-size: 0.8rem; margin-top: 4px; color: #aaa;">State Changes: ${Object.keys(item.stateChanges).join(', ')}</div>` : ''}
1018
+ <div style="padding: 8px; border-radius: 4px; background: var(--bg-primary); border-left: 3px solid ${isSlow ? 'var(--color-warning)' : 'var(--color-success)'};">
1019
+ <div style="display: flex; justify-content: space-between; font-size: 0.9em;">
1020
+ <span style="font-weight: 500;">${item.name}</span>
1021
+ <span style="font-family: monospace;">${printDuration(duration)}</span>
106
1022
  </div>
107
- `;
108
- });
1023
+ <div style="height: 3px; background: var(--bg-secondary); margin-top: 4px; border-radius: 2px; overflow: hidden;">
1024
+ <div style="height: 100%; width: ${percent}%; background: ${isSlow ? 'var(--color-warning)' : 'var(--color-success)'}; opacity: 0.8;"></div>
1025
+ </div>
1026
+ </div>`;
109
1027
 
1028
+ if (index < request.handlerStack.length - 1) {
1029
+ html += `<div style="display: flex; justify-content: center; height: 10px;"><div style="width: 1px; background: var(--border-color); opacity: 0.5;"></div></div>`;
1030
+ }
1031
+ });
110
1032
  html += '</div>';
111
- traceContainer.innerHTML = html;
1033
+ container.innerHTML = html;
112
1034
  } else {
113
- traceContainer.innerHTML = '<div style="color: var(--text-secondary);">No middleware trace available.</div>';
1035
+ container.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--text-secondary);">No trace data</div>`;
114
1036
  }
1037
+ }
1038
+
1039
+ function getExtension(contentType) {
1040
+ if (!contentType) return 'txt';
1041
+ if (contentType.includes('json')) return 'json';
1042
+ if (contentType.includes('html')) return 'html';
1043
+ if (contentType.includes('xml')) return 'xml';
1044
+ if (contentType.includes('javascript')) return 'javascript';
1045
+ if (contentType.includes('css')) return 'css';
1046
+ if (contentType.includes('image/png')) return 'png';
1047
+ if (contentType.includes('image/jpeg')) return 'jpeg';
1048
+ if (contentType.includes('image/gif')) return 'gif';
1049
+ if (contentType.includes('image/svg+xml')) return 'svg';
1050
+ if (contentType.includes('application/pdf')) return 'pdf';
1051
+ if (contentType.includes('application/zip')) return 'zip';
1052
+ if (contentType.includes('application/octet-stream')) return 'bin';
1053
+ return 'txt';
1054
+ }
115
1055
 
116
- // Scroll to details
117
- container.scrollIntoView({ behavior: 'smooth' });
1056
+ function getContentType(headers) {
1057
+ if (!headers) return '';
1058
+ const output = headers['content-type'] || headers['Content-Type'] || '';
1059
+ return output.toLowerCase();
118
1060
  }
1061
+
1062
+ function getBodyContent(body) {
1063
+ let value = body || '';
1064
+ if (typeof value === 'object') {
1065
+ try {
1066
+ value = JSON.stringify(value, null, 2);
1067
+ } catch (e) {
1068
+ value = String(value);
1069
+ }
1070
+ } else {
1071
+ value = String(value);
1072
+ }
1073
+ return value;
1074
+ }
1075
+
1076
+ function initRequestEditor(request) {
1077
+ const el = document.getElementById('request-body-editor');
1078
+ if (!el) return;
1079
+
1080
+ let content = request.requestBody || '';
1081
+ const contentType = getContentType(request.requestHeaders);
1082
+ let language = getExtension(contentType);
1083
+
1084
+ if (typeof content === 'object') {
1085
+ content = JSON.stringify(content, null, 2);
1086
+ language = 'json';
1087
+ } else if (typeof content === 'string') {
1088
+ // Auto-detect JSON if content looks like JSON but header is wrong
1089
+ if (language === 'plaintext' && (content.trim().startsWith('{') || content.trim().startsWith('['))) {
1090
+ try {
1091
+ JSON.parse(content);
1092
+ language = 'json';
1093
+ } catch (e) { /* not json */ }
1094
+ }
1095
+ }
1096
+
1097
+ // Handle binary
1098
+ if (content === '[Binary or Unreadable Body]') {
1099
+ el.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">Binary Content</div>';
1100
+ return;
1101
+ }
1102
+
1103
+ renderMonacoEditor(el, content, language, false);
1104
+
1105
+ const btnCopy = document.getElementById('btn-copy-req-body');
1106
+ if (btnCopy) btnCopy.onclick = () => copyToClipboard(getBodyContent(request.requestBody));
1107
+ }
1108
+
1109
+ function initResponseEditor(request) {
1110
+ const el = document.getElementById('response-body-editor');
1111
+ if (!el) return;
1112
+
1113
+ let content = request.body || request.responseBody; // fallback to responseBody property if mapped
1114
+ if (!content) content = '';
1115
+
1116
+ const contentType = getContentType(request.responseHeaders);
1117
+ let language = getExtension(contentType);
1118
+
1119
+ if (typeof content === 'object') {
1120
+ content = JSON.stringify(content, null, 2);
1121
+ language = 'json';
1122
+ } else if (window.autoFormatEnabled !== false && typeof content === 'string') {
1123
+ // Try auto-detect JSON if string
1124
+ if ((content.trim().startsWith('{') || content.trim().startsWith('[')) && content.length < 524288) {
1125
+ try {
1126
+ const parsed = JSON.parse(content);
1127
+ content = JSON.stringify(parsed, null, 2);
1128
+ language = 'json';
1129
+ } catch (e) { /* not json */ }
1130
+ }
1131
+ }
1132
+
1133
+ // Handle binary
1134
+ if (content === '[Binary or Unreadable Body]') {
1135
+ el.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">Binary Content</div>';
1136
+ // Ensure download button works
1137
+ const btnDownload = document.getElementById('btn-download-body');
1138
+ // We can't easily download the *actual* binary if we didn't store it.
1139
+ // But if we have it in memory or if backend serving it via separate endpoint...
1140
+ // For now, download the text placeholder is all we can do unless we fetch raw.
1141
+ if (btnDownload) btnDownload.onclick = () => alert("Original binary content not stored in dashboard history.");
1142
+ return;
1143
+ }
1144
+
1145
+ renderMonacoEditor(el, content, language, window.autoFormatEnabled !== false);
1146
+
1147
+ // Attach button listeners
1148
+ const btnCopy = document.getElementById('btn-copy-body');
1149
+ const btnDownload = document.getElementById('btn-download-body');
1150
+ if (btnCopy) btnCopy.onclick = () => copyToClipboard(getBodyContent(content));
1151
+ // TODO: replace with filename
1152
+ if (btnDownload) btnDownload.onclick = () => downloadString(getBodyContent(content), `body-${request.timestamp}.${getExtension(request.contentType)}`);
1153
+ }
1154
+
1155
+ function renderMonacoEditor(containerElement, value, language, shouldFormat = false) {
1156
+ if (!window.monaco) {
1157
+ require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.44.0/min/vs' } });
1158
+ require(['vs/editor/editor.main'], function () { renderMonacoEditor(containerElement, value, language, shouldFormat); });
1159
+ return;
1160
+ }
1161
+
1162
+ console.log({ language });
1163
+ window.currentEditor?.dispose();
1164
+ window.currentEditor = monaco.editor.create(containerElement, {
1165
+ value: value,
1166
+ language: language,
1167
+ theme: 'vs-dark',
1168
+ readOnly: true,
1169
+ minimap: { enabled: false },
1170
+ scrollBeyondLastLine: false,
1171
+ automaticLayout: true,
1172
+ wordWrap: 'on'
1173
+ });
1174
+
1175
+ if (shouldFormat) {
1176
+ setTimeout(() => {
1177
+ if (window.currentEditor) {
1178
+ window.currentEditor.getAction('editor.action.formatDocument').run();
1179
+ }
1180
+ }, 100);
1181
+ }
1182
+ }
1183
+
1184
+ function copyToClipboard(text) {
1185
+ navigator.clipboard.writeText(text).then(() => {
1186
+ const btn = document.activeElement;
1187
+ if (btn && btn.tagName === 'BUTTON') {
1188
+ const original = btn.innerText;
1189
+ btn.innerText = 'Copied!';
1190
+ setTimeout(() => btn.innerText = original, 1500);
1191
+ }
1192
+ }).catch(err => console.error('Failed to copy', err));
1193
+ }
1194
+
1195
+ function downloadString(text, filename) {
1196
+ const blob = new Blob([text], { type: 'text/plain' });
1197
+ const url = URL.createObjectURL(blob);
1198
+ const a = document.createElement('a');
1199
+ a.href = url;
1200
+ a.download = filename;
1201
+ a.click();
1202
+ URL.revokeObjectURL(url);
1203
+ }
1204
+
1205
+ function formatBytes(bytes, decimals = 2) {
1206
+ if (!+bytes) return '0 B';
1207
+ const k = 1024;
1208
+ const dm = decimals < 0 ? 0 : decimals;
1209
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1210
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1211
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
1212
+ }
1213
+
1214
+