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.
- package/README.md +46 -1815
- package/dist/{analyzer-BqIe1p0R.js → analyzer-BkNQHWj4.js} +3 -8
- package/dist/{analyzer-BqIe1p0R.js.map → analyzer-BkNQHWj4.js.map} +1 -1
- package/dist/{analyzer-CKLGLFtx.cjs → analyzer-DM-OlRq8.cjs} +2 -7
- package/dist/{analyzer-CKLGLFtx.cjs.map → analyzer-DM-OlRq8.cjs.map} +1 -1
- package/dist/{analyzer.impl-D9Yi1Hax.cjs → analyzer.impl-CVJ8zfGQ.cjs} +596 -42
- package/dist/analyzer.impl-CVJ8zfGQ.cjs.map +1 -0
- package/dist/{analyzer.impl-CV6W1Eq7.js → analyzer.impl-CsA1bS_s.js} +596 -42
- package/dist/analyzer.impl-CsA1bS_s.js.map +1 -0
- package/dist/cli.cjs +206 -18
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +206 -18
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +46 -9
- package/dist/index.cjs +3239 -1173
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3236 -1171
- package/dist/index.js.map +1 -1
- package/dist/plugins/application/api-explorer/static/explorer-client.mjs +375 -29
- package/dist/plugins/application/api-explorer/static/style.css +327 -8
- package/dist/plugins/application/api-explorer/static/theme.css +11 -2
- package/dist/plugins/application/asyncapi/generator.d.ts +4 -0
- package/dist/plugins/application/asyncapi/static/asyncapi-client.mjs +154 -22
- package/dist/plugins/application/asyncapi/static/style.css +24 -8
- package/dist/plugins/application/auth.d.ts +5 -0
- package/dist/plugins/application/dashboard/fetch-interceptor.d.ts +119 -0
- package/dist/plugins/application/dashboard/metrics-collector.d.ts +38 -2
- package/dist/plugins/application/dashboard/plugin.d.ts +53 -1
- package/dist/plugins/application/dashboard/static/charts.js +127 -62
- package/dist/plugins/application/dashboard/static/client.js +160 -0
- package/dist/plugins/application/dashboard/static/graph.mjs +167 -56
- package/dist/plugins/application/dashboard/static/reactflow.css +20 -10
- package/dist/plugins/application/dashboard/static/registry.js +112 -8
- package/dist/plugins/application/dashboard/static/requests.js +1167 -71
- package/dist/plugins/application/dashboard/static/styles.css +186 -14
- package/dist/plugins/application/dashboard/static/tabs.js +44 -9
- package/dist/plugins/application/dashboard/static/tabulator.css +23 -3
- package/dist/plugins/application/dashboard/static/theme.css +11 -2
- package/dist/plugins/application/mcp-server/plugin.d.ts +39 -0
- package/dist/plugins/application/openapi/analyzer.impl.d.ts +65 -1
- package/dist/plugins/application/openapi/openapi.d.ts +3 -0
- package/dist/plugins/application/shared/ast-utils.d.ts +7 -0
- package/dist/plugins/middleware/compression.d.ts +12 -2
- package/dist/plugins/middleware/rate-limit.d.ts +5 -0
- package/dist/router.d.ts +59 -19
- package/dist/server.d.ts +22 -0
- package/dist/shokupan.d.ts +31 -3
- package/dist/util/adapter/bun.d.ts +8 -0
- package/dist/util/adapter/filesystem.d.ts +20 -0
- package/dist/util/adapter/index.d.ts +4 -0
- package/dist/util/adapter/interface.d.ts +12 -0
- package/dist/util/adapter/node.d.ts +8 -0
- package/dist/util/adapter/wintercg.d.ts +5 -0
- package/dist/util/body-parser.d.ts +30 -0
- package/dist/util/controller-scanner.d.ts +4 -0
- package/dist/util/cpu-monitor.d.ts +2 -0
- package/dist/util/decorators.d.ts +20 -3
- package/dist/util/di.d.ts +3 -8
- package/dist/util/metadata.d.ts +18 -0
- package/dist/util/middleware-tracker.d.ts +10 -0
- package/dist/util/request.d.ts +1 -0
- package/dist/util/symbol.d.ts +1 -0
- package/dist/util/types.d.ts +167 -1
- package/package.json +7 -5
- package/dist/analyzer.impl-CV6W1Eq7.js.map +0 -1
- package/dist/analyzer.impl-D9Yi1Hax.cjs.map +0 -1
- package/dist/http-server-BEMPIs33.cjs +0 -85
- package/dist/http-server-BEMPIs33.cjs.map +0 -1
- package/dist/http-server-CCeagTyU.js +0 -68
- package/dist/http-server-CCeagTyU.js.map +0 -1
- package/dist/plugins/application/dashboard/static/failures.js +0 -85
- package/dist/plugins/application/dashboard/static/poll.js +0 -146
- package/dist/plugins/application/http-server.d.ts +0 -13
|
@@ -1,118 +1,1214 @@
|
|
|
1
1
|
|
|
2
2
|
// Initialize Requests Table
|
|
3
|
-
|
|
3
|
+
window.requestsTable = null;
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
701
|
+
fetch(url, { headers })
|
|
58
702
|
.then(res => res.json())
|
|
59
703
|
.then(data => {
|
|
60
|
-
if (requestsTable) {
|
|
61
|
-
|
|
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 =>
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
1033
|
+
container.innerHTML = html;
|
|
112
1034
|
} else {
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
+
|