prism-debugger 0.2.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/.env.example +9 -0
- package/README.md +128 -0
- package/bin/prism.js +72 -0
- package/package.json +35 -0
- package/public/app.js +1516 -0
- package/public/index.html +214 -0
- package/public/styles.css +1224 -0
- package/src/broker.js +425 -0
- package/src/config.js +55 -0
- package/src/index.js +100 -0
- package/src/logger.js +72 -0
- package/src/plugins/context-device-event-logger.plugin.js +152 -0
- package/src/plugins/index.js +21 -0
- package/src/plugins/plugin-manager.js +51 -0
- package/src/storage.js +102 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1516 @@
|
|
|
1
|
+
const UI_TOKEN = new URLSearchParams(location.search).get('token') || 'ui-dev-token';
|
|
2
|
+
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/ui?token=${encodeURIComponent(UI_TOKEN)}`;
|
|
3
|
+
|
|
4
|
+
// ── Console elements ──
|
|
5
|
+
const debuggerListEl = document.getElementById('debuggerList');
|
|
6
|
+
const debuggerSearchEl = document.getElementById('debuggerSearch');
|
|
7
|
+
const consoleTitleEl = document.getElementById('consoleTitle');
|
|
8
|
+
const consoleEl = document.getElementById('console');
|
|
9
|
+
const levelFilterEl = document.getElementById('levelFilter');
|
|
10
|
+
const categoryFilterEl = document.getElementById('categoryFilter');
|
|
11
|
+
const contextFilterEl = document.getElementById('contextFilter');
|
|
12
|
+
const textFilterEl = document.getElementById('textFilter');
|
|
13
|
+
const eventNameEl = document.getElementById('eventName');
|
|
14
|
+
const payloadEl = document.getElementById('payload');
|
|
15
|
+
const sendBtnEl = document.getElementById('sendBtn');
|
|
16
|
+
|
|
17
|
+
// ── Timeline elements ──
|
|
18
|
+
const tlScrollEl = document.getElementById('tl-scroll');
|
|
19
|
+
const tlEmptyEl = document.getElementById('tl-empty');
|
|
20
|
+
const tlContextFilterEl = document.getElementById('tl-context-filter');
|
|
21
|
+
const tlLabelInputEl = document.getElementById('tl-label-input');
|
|
22
|
+
const tlLabelTagsEl = document.getElementById('tl-label-tags');
|
|
23
|
+
const tlLabelSuggestionsEl = document.getElementById('tl-label-suggestions');
|
|
24
|
+
const tlScaleEl = document.getElementById('tl-scale');
|
|
25
|
+
const tlScaleLabelEl = document.getElementById('tl-scale-label');
|
|
26
|
+
const tlStatsEl = document.getElementById('tl-stats');
|
|
27
|
+
const tlDetailEl = document.getElementById('tl-detail');
|
|
28
|
+
const tlDetailLabelEl = document.getElementById('tl-detail-label');
|
|
29
|
+
const tlDetailDurEl = document.getElementById('tl-detail-dur');
|
|
30
|
+
const tlDetailBodyEl = document.getElementById('tl-detail-body');
|
|
31
|
+
|
|
32
|
+
// ── Global nav ──
|
|
33
|
+
const mainTabBtns = document.querySelectorAll('.main-tab-btn');
|
|
34
|
+
const mainViews = document.querySelectorAll('.main-view');
|
|
35
|
+
|
|
36
|
+
const LABEL_W = 240; // px — must match --label-w in CSS
|
|
37
|
+
const LABEL_W_PX = `${LABEL_W}px`;
|
|
38
|
+
document.documentElement.style.setProperty('--label-w', LABEL_W_PX);
|
|
39
|
+
|
|
40
|
+
// ── State ──
|
|
41
|
+
const state = {
|
|
42
|
+
selectedDebuggerId: null,
|
|
43
|
+
activeView: 'console',
|
|
44
|
+
debuggers: new Map(),
|
|
45
|
+
messagesByDebugger: new Map(),
|
|
46
|
+
perfpointsByDebugger: new Map(),
|
|
47
|
+
perfPointSpecsByDebugger: new Map(),
|
|
48
|
+
renderedMessageCount: 0,
|
|
49
|
+
lastFilterState: null,
|
|
50
|
+
availableContexts: new Set(),
|
|
51
|
+
tlScale: null,
|
|
52
|
+
tlLabelFilters: new Set(),
|
|
53
|
+
tlRenderKey: null,
|
|
54
|
+
tlRenderedPids: null,
|
|
55
|
+
tlPointMap: null,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── WebSocket ──
|
|
59
|
+
const socket = new WebSocket(wsUrl);
|
|
60
|
+
|
|
61
|
+
socket.addEventListener('open', () => {
|
|
62
|
+
socket.send(JSON.stringify({ action: 'list' }));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const showDisconnectedBanner = () => {
|
|
66
|
+
if (document.getElementById('disconnected-banner')) return;
|
|
67
|
+
const banner = document.createElement('div');
|
|
68
|
+
banner.id = 'disconnected-banner';
|
|
69
|
+
banner.className = 'disconnected-banner';
|
|
70
|
+
banner.textContent = 'Server disconnected';
|
|
71
|
+
document.body.appendChild(banner);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
socket.addEventListener('close', showDisconnectedBanner);
|
|
75
|
+
socket.addEventListener('error', showDisconnectedBanner);
|
|
76
|
+
|
|
77
|
+
socket.addEventListener('message', (evt) => {
|
|
78
|
+
const msg = JSON.parse(evt.data);
|
|
79
|
+
|
|
80
|
+
if (msg.type === 'server.shutdown') {
|
|
81
|
+
showDisconnectedBanner();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msg.type === 'debuggers.snapshot') {
|
|
86
|
+
state.debuggers.clear();
|
|
87
|
+
for (const item of msg.debuggers || []) {
|
|
88
|
+
state.debuggers.set(item.debuggerId, item);
|
|
89
|
+
}
|
|
90
|
+
if (state.selectedDebuggerId && !state.debuggers.has(state.selectedDebuggerId)) {
|
|
91
|
+
state.selectedDebuggerId = null;
|
|
92
|
+
state.messagesByDebugger.delete(state.selectedDebuggerId);
|
|
93
|
+
state.perfpointsByDebugger.delete(state.selectedDebuggerId);
|
|
94
|
+
state.perfPointSpecsByDebugger.delete(state.selectedDebuggerId);
|
|
95
|
+
consoleTitleEl.textContent = 'Console';
|
|
96
|
+
consoleEl.innerHTML = '';
|
|
97
|
+
state.renderedMessageCount = 0;
|
|
98
|
+
}
|
|
99
|
+
renderDebuggers();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (msg.type === 'debugger.update' && msg.debugger) {
|
|
104
|
+
state.debuggers.set(msg.debugger.debuggerId, msg.debugger);
|
|
105
|
+
renderDebuggers();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (msg.type === 'messages.history') {
|
|
110
|
+
state.messagesByDebugger.set(msg.debuggerId, msg.messages || []);
|
|
111
|
+
state.renderedMessageCount = 0;
|
|
112
|
+
renderConsole(true);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (msg.type === 'message' && msg.message) {
|
|
117
|
+
const id = msg.message.debuggerId;
|
|
118
|
+
if (!state.messagesByDebugger.has(id)) {
|
|
119
|
+
state.messagesByDebugger.set(id, []);
|
|
120
|
+
}
|
|
121
|
+
state.messagesByDebugger.get(id).push(msg.message);
|
|
122
|
+
if (state.selectedDebuggerId === id) {
|
|
123
|
+
renderConsole();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (msg.type === 'perfpoints.history') {
|
|
129
|
+
state.perfpointsByDebugger.set(msg.debuggerId, msg.points || []);
|
|
130
|
+
console.log('[ui] perfpoints.history received:', msg.debuggerId, (msg.points || []).length, 'points,', (msg.specs || []).length, 'specs');
|
|
131
|
+
if (msg.specs && msg.specs.length > 0) {
|
|
132
|
+
state.perfPointSpecsByDebugger.set(msg.debuggerId, msg.specs);
|
|
133
|
+
}
|
|
134
|
+
// Reset accumulated contexts so history is the new baseline
|
|
135
|
+
if (!state.tlContextsByDebugger) state.tlContextsByDebugger = new Map();
|
|
136
|
+
state.tlContextsByDebugger.delete(msg.debuggerId);
|
|
137
|
+
if (state.selectedDebuggerId === msg.debuggerId) {
|
|
138
|
+
renderTimeline();
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (msg.type === 'perfpoints.specs' && msg.debuggerId) {
|
|
144
|
+
console.log('[ui] perfpoints.specs received:', msg.debuggerId, (msg.specs || []).length, 'spec(s)', (msg.specs || []).map(s => s.id));
|
|
145
|
+
state.perfPointSpecsByDebugger.set(msg.debuggerId, msg.specs || []);
|
|
146
|
+
if (state.selectedDebuggerId === msg.debuggerId && state.activeView === 'timeline') {
|
|
147
|
+
renderTimeline();
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (msg.type === 'perfpoint' && msg.point) {
|
|
153
|
+
const id = msg.debuggerId;
|
|
154
|
+
if (!state.perfpointsByDebugger.has(id)) {
|
|
155
|
+
state.perfpointsByDebugger.set(id, []);
|
|
156
|
+
}
|
|
157
|
+
const points = state.perfpointsByDebugger.get(id);
|
|
158
|
+
points.push(msg.point);
|
|
159
|
+
if (points.length > 5000) points.shift();
|
|
160
|
+
if (state.selectedDebuggerId === id) {
|
|
161
|
+
scheduleTimelineRender();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Global tab switching ──
|
|
167
|
+
mainTabBtns.forEach(btn => {
|
|
168
|
+
btn.addEventListener('click', () => {
|
|
169
|
+
const view = btn.dataset.view;
|
|
170
|
+
state.activeView = view;
|
|
171
|
+
mainTabBtns.forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
|
172
|
+
mainViews.forEach(v => v.classList.toggle('hidden', v.id !== `view-${view}`));
|
|
173
|
+
if (view === 'timeline') renderTimeline();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Debugger list ──
|
|
178
|
+
const renderDebuggers = () => {
|
|
179
|
+
const term = debuggerSearchEl.value.trim().toLowerCase();
|
|
180
|
+
const rows = [...state.debuggers.values()].filter((item) => {
|
|
181
|
+
const tags = JSON.stringify(item.tags || {}).toLowerCase();
|
|
182
|
+
const haystack = `${item.debuggerId} ${item.name || ''} ${tags}`.toLowerCase();
|
|
183
|
+
return !term || haystack.includes(term);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
debuggerListEl.innerHTML = '';
|
|
187
|
+
for (const item of rows) {
|
|
188
|
+
const li = document.createElement('li');
|
|
189
|
+
if (item.debuggerId === state.selectedDebuggerId) {
|
|
190
|
+
li.classList.add('active');
|
|
191
|
+
}
|
|
192
|
+
const device = item.app?.deviceModel || item.app?.deviceName || '';
|
|
193
|
+
li.innerHTML = `
|
|
194
|
+
<div class="dbg-row"><span class="status-dot ${item.status}"></span><strong>${item.name || item.debuggerId}</strong></div>
|
|
195
|
+
<div class="muted" style="margin-left:15px">${item.debuggerId}${device ? ' · ' + device : ''}</div>
|
|
196
|
+
`;
|
|
197
|
+
li.onclick = () => selectDebugger(item.debuggerId, item.name);
|
|
198
|
+
debuggerListEl.append(li);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const selectDebugger = (debuggerId, name) => {
|
|
203
|
+
state.selectedDebuggerId = debuggerId;
|
|
204
|
+
state.renderedMessageCount = 0;
|
|
205
|
+
state.lastFilterState = null;
|
|
206
|
+
consoleTitleEl.textContent = `Console: ${name || debuggerId}`;
|
|
207
|
+
socket.send(JSON.stringify({ action: 'history', debuggerId }));
|
|
208
|
+
socket.send(JSON.stringify({ action: 'perfpoints.history', debuggerId }));
|
|
209
|
+
renderDebuggers();
|
|
210
|
+
if (state.activeView === 'timeline') renderTimeline();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// ── Console: JSON tree ──
|
|
214
|
+
const getObjectPreview = (obj) => {
|
|
215
|
+
if (obj === null) return 'null';
|
|
216
|
+
if (obj === undefined) return 'undefined';
|
|
217
|
+
const type = typeof obj;
|
|
218
|
+
if (type === 'string') return `"${obj.length > 50 ? obj.substring(0, 50) + '...' : obj}"`;
|
|
219
|
+
if (type === 'number' || type === 'boolean') return String(obj);
|
|
220
|
+
if (Array.isArray(obj)) return obj.length === 0 ? '[]' : `Array(${obj.length})`;
|
|
221
|
+
if (type === 'object') {
|
|
222
|
+
const keys = Object.keys(obj);
|
|
223
|
+
if (keys.length === 0) return '{}';
|
|
224
|
+
const preview = keys.slice(0, 3).join(', ');
|
|
225
|
+
return keys.length > 3 ? `{${preview}, ...}` : `{${preview}}`;
|
|
226
|
+
}
|
|
227
|
+
return String(obj);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const createObjectNode = (value, key = null, depth = 0) => {
|
|
231
|
+
const wrapper = document.createElement('div');
|
|
232
|
+
wrapper.className = 'json-node';
|
|
233
|
+
wrapper.style.paddingLeft = `${depth * 16}px`;
|
|
234
|
+
|
|
235
|
+
const type = typeof value;
|
|
236
|
+
const isExpandable = (value !== null && (type === 'object' || Array.isArray(value))) &&
|
|
237
|
+
(Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0);
|
|
238
|
+
|
|
239
|
+
const line = document.createElement('div');
|
|
240
|
+
line.className = 'json-line';
|
|
241
|
+
|
|
242
|
+
if (isExpandable) {
|
|
243
|
+
const arrow = document.createElement('span');
|
|
244
|
+
arrow.className = 'json-arrow';
|
|
245
|
+
arrow.textContent = '▶';
|
|
246
|
+
line.appendChild(arrow);
|
|
247
|
+
|
|
248
|
+
if (key !== null) {
|
|
249
|
+
const keySpan = document.createElement('span');
|
|
250
|
+
keySpan.className = 'json-key';
|
|
251
|
+
keySpan.textContent = key + ': ';
|
|
252
|
+
line.appendChild(keySpan);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const preview = document.createElement('span');
|
|
256
|
+
preview.className = 'json-preview';
|
|
257
|
+
preview.textContent = getObjectPreview(value);
|
|
258
|
+
line.appendChild(preview);
|
|
259
|
+
|
|
260
|
+
const childrenContainer = document.createElement('div');
|
|
261
|
+
childrenContainer.className = 'json-children collapsed';
|
|
262
|
+
|
|
263
|
+
let expanded = false;
|
|
264
|
+
line.style.cursor = 'pointer';
|
|
265
|
+
line.addEventListener('click', (e) => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
expanded = !expanded;
|
|
268
|
+
arrow.textContent = expanded ? '▼' : '▶';
|
|
269
|
+
childrenContainer.classList.toggle('collapsed', !expanded);
|
|
270
|
+
if (expanded && childrenContainer.children.length === 0) {
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
value.forEach((item, index) => {
|
|
273
|
+
childrenContainer.appendChild(createObjectNode(item, `[${index}]`, depth + 1));
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
Object.entries(value).forEach(([k, v]) => {
|
|
277
|
+
childrenContainer.appendChild(createObjectNode(v, k, depth + 1));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
wrapper.appendChild(line);
|
|
284
|
+
wrapper.appendChild(childrenContainer);
|
|
285
|
+
} else {
|
|
286
|
+
if (key !== null) {
|
|
287
|
+
const keySpan = document.createElement('span');
|
|
288
|
+
keySpan.className = 'json-key';
|
|
289
|
+
keySpan.textContent = key + ': ';
|
|
290
|
+
line.appendChild(keySpan);
|
|
291
|
+
}
|
|
292
|
+
const valueSpan = document.createElement('span');
|
|
293
|
+
if (value === null || value === undefined) {
|
|
294
|
+
valueSpan.className = 'json-null';
|
|
295
|
+
valueSpan.textContent = String(value);
|
|
296
|
+
} else if (type === 'string') {
|
|
297
|
+
valueSpan.className = 'json-string';
|
|
298
|
+
valueSpan.textContent = `"${escapeHtml(value)}"`;
|
|
299
|
+
} else if (type === 'number') {
|
|
300
|
+
valueSpan.className = 'json-number';
|
|
301
|
+
valueSpan.textContent = value;
|
|
302
|
+
} else if (type === 'boolean') {
|
|
303
|
+
valueSpan.className = 'json-boolean';
|
|
304
|
+
valueSpan.textContent = value;
|
|
305
|
+
}
|
|
306
|
+
line.appendChild(valueSpan);
|
|
307
|
+
wrapper.appendChild(line);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return wrapper;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const escapeHtml = (str) => {
|
|
314
|
+
const div = document.createElement('div');
|
|
315
|
+
div.textContent = str;
|
|
316
|
+
return div.innerHTML;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const createConsoleRow = (row) => {
|
|
320
|
+
const div = document.createElement('div');
|
|
321
|
+
div.className = 'console-row';
|
|
322
|
+
|
|
323
|
+
const header = document.createElement('div');
|
|
324
|
+
header.className = 'console-row-header';
|
|
325
|
+
|
|
326
|
+
const timestamp = document.createElement('span');
|
|
327
|
+
timestamp.className = 'console-timestamp';
|
|
328
|
+
timestamp.textContent = row.ts || '';
|
|
329
|
+
|
|
330
|
+
const levelBadge = document.createElement('span');
|
|
331
|
+
levelBadge.className = `console-level ${row.level || 'info'}`;
|
|
332
|
+
levelBadge.textContent = row.level || 'info';
|
|
333
|
+
|
|
334
|
+
const eventName = document.createElement('span');
|
|
335
|
+
eventName.className = 'console-event-name';
|
|
336
|
+
eventName.textContent = row.eventName || '';
|
|
337
|
+
|
|
338
|
+
header.append(timestamp, levelBadge, eventName);
|
|
339
|
+
div.appendChild(header);
|
|
340
|
+
|
|
341
|
+
if (row.payload && Object.keys(row.payload).length > 0) {
|
|
342
|
+
const payloadWrapper = document.createElement('div');
|
|
343
|
+
payloadWrapper.className = 'console-payload';
|
|
344
|
+
payloadWrapper.appendChild(createObjectNode(row.payload, null, 0));
|
|
345
|
+
div.appendChild(payloadWrapper);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return div;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const isScrolledToBottom = (el) =>
|
|
352
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
|
353
|
+
|
|
354
|
+
// ── Console: context filter ──
|
|
355
|
+
const extractContext = (message) => {
|
|
356
|
+
const payload = message?.payload;
|
|
357
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
358
|
+
if (typeof payload.context === 'string') return payload.context;
|
|
359
|
+
if (payload.context && typeof payload.context === 'object') {
|
|
360
|
+
if (typeof payload.context.name === 'string') return payload.context.name;
|
|
361
|
+
if (typeof payload.context.id === 'string') return payload.context.id;
|
|
362
|
+
}
|
|
363
|
+
if (typeof payload.contextId === 'string') return payload.contextId;
|
|
364
|
+
if (typeof payload.screen === 'string') return payload.screen;
|
|
365
|
+
if (typeof payload.module === 'string') return payload.module;
|
|
366
|
+
return '';
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const updateContextList = () => {
|
|
370
|
+
const messages = state.messagesByDebugger.get(state.selectedDebuggerId) || [];
|
|
371
|
+
const contexts = new Set(messages.map(m => extractContext(m)).filter(Boolean));
|
|
372
|
+
state.availableContexts = contexts;
|
|
373
|
+
|
|
374
|
+
const currentValue = contextFilterEl.value;
|
|
375
|
+
contextFilterEl.innerHTML = '<option value="all">All contexts</option>';
|
|
376
|
+
[...contexts].sort().forEach(ctx => {
|
|
377
|
+
const opt = document.createElement('option');
|
|
378
|
+
opt.value = ctx;
|
|
379
|
+
opt.textContent = ctx;
|
|
380
|
+
contextFilterEl.appendChild(opt);
|
|
381
|
+
});
|
|
382
|
+
if (currentValue === 'all' || contexts.has(currentValue)) {
|
|
383
|
+
contextFilterEl.value = currentValue;
|
|
384
|
+
} else {
|
|
385
|
+
contextFilterEl.value = 'all';
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const getFilterState = () => ({
|
|
390
|
+
level: levelFilterEl.value,
|
|
391
|
+
category: categoryFilterEl.value.trim().toLowerCase(),
|
|
392
|
+
context: contextFilterEl.value,
|
|
393
|
+
text: textFilterEl.value.trim().toLowerCase(),
|
|
394
|
+
debuggerId: state.selectedDebuggerId
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const filtersChanged = (a, b) => {
|
|
398
|
+
if (!a || !b) return true;
|
|
399
|
+
return a.level !== b.level || a.category !== b.category ||
|
|
400
|
+
a.context !== b.context || a.text !== b.text || a.debuggerId !== b.debuggerId;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const filterMessages = (messages, fs) =>
|
|
404
|
+
messages.filter((m) => {
|
|
405
|
+
const levelOk = fs.level === 'all' || m.level === fs.level;
|
|
406
|
+
const catOk = !fs.category || String(m.category || '').toLowerCase().includes(fs.category);
|
|
407
|
+
const ctxOk = fs.context === 'all' || extractContext(m) === fs.context;
|
|
408
|
+
const textOk = !fs.text || JSON.stringify(m).toLowerCase().includes(fs.text);
|
|
409
|
+
return levelOk && catOk && ctxOk && textOk;
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ── Console render ──
|
|
413
|
+
const renderConsole = (forceRedraw = false) => {
|
|
414
|
+
const id = state.selectedDebuggerId;
|
|
415
|
+
if (!id) {
|
|
416
|
+
consoleEl.textContent = 'Select debugger instance';
|
|
417
|
+
state.renderedMessageCount = 0;
|
|
418
|
+
state.lastFilterState = null;
|
|
419
|
+
state.availableContexts.clear();
|
|
420
|
+
contextFilterEl.innerHTML = '<option value="all">All contexts</option>';
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
updateContextList();
|
|
425
|
+
|
|
426
|
+
const currentFS = getFilterState();
|
|
427
|
+
const changed = filtersChanged(state.lastFilterState, currentFS);
|
|
428
|
+
const wasAtBottom = isScrolledToBottom(consoleEl);
|
|
429
|
+
const source = state.messagesByDebugger.get(id) || [];
|
|
430
|
+
const filtered = filterMessages(source, currentFS);
|
|
431
|
+
const toDisplay = filtered.slice(-500);
|
|
432
|
+
|
|
433
|
+
if (forceRedraw || changed) {
|
|
434
|
+
consoleEl.innerHTML = '';
|
|
435
|
+
for (const row of toDisplay) consoleEl.appendChild(createConsoleRow(row));
|
|
436
|
+
state.renderedMessageCount = toDisplay.length;
|
|
437
|
+
state.lastFilterState = currentFS;
|
|
438
|
+
} else {
|
|
439
|
+
const delta = toDisplay.length - state.renderedMessageCount;
|
|
440
|
+
if (delta > 0) {
|
|
441
|
+
const newRows = toDisplay.slice(-delta);
|
|
442
|
+
for (const row of newRows) consoleEl.appendChild(createConsoleRow(row));
|
|
443
|
+
state.renderedMessageCount = toDisplay.length;
|
|
444
|
+
while (consoleEl.children.length > 500) consoleEl.removeChild(consoleEl.firstChild);
|
|
445
|
+
} else if (delta < 0) {
|
|
446
|
+
consoleEl.innerHTML = '';
|
|
447
|
+
for (const row of toDisplay) consoleEl.appendChild(createConsoleRow(row));
|
|
448
|
+
state.renderedMessageCount = toDisplay.length;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (wasAtBottom) consoleEl.scrollTop = consoleEl.scrollHeight;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// ── Timeline ──
|
|
456
|
+
const formatDuration = (ms) => {
|
|
457
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
458
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const barClass = (ms) => ms < 300 ? 'fast' : ms < 1000 ? 'medium' : 'slow';
|
|
462
|
+
|
|
463
|
+
// Returns the badge spec for the current debugger by badge id, or null if unknown.
|
|
464
|
+
const getBadgeSpec = (badgeKey) => {
|
|
465
|
+
const specs = state.perfPointSpecsByDebugger.get(state.selectedDebuggerId) || [];
|
|
466
|
+
const spec = specs.find(s => s.id === badgeKey);
|
|
467
|
+
if (!spec) return null;
|
|
468
|
+
return { bg: spec.color, fg: '#fff', name: spec.label };
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Normalize a badge string the same way the iOS SDK does:
|
|
472
|
+
// lowercase, keep only letters/digits, replace runs of other chars with '-'
|
|
473
|
+
const normalizeBadge = (raw) =>
|
|
474
|
+
raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
475
|
+
|
|
476
|
+
// Returns HTML with a colored badge chip + detail text.
|
|
477
|
+
// badge: normalized badge id (e.g. 'http-request'); falls back to colon-split of label.
|
|
478
|
+
const renderLabelHtml = (label, badge = null) => {
|
|
479
|
+
let badgeKey = badge || null;
|
|
480
|
+
let rest = label;
|
|
481
|
+
if (!badgeKey) {
|
|
482
|
+
const colonIdx = label.indexOf(':');
|
|
483
|
+
if (colonIdx !== -1) {
|
|
484
|
+
badgeKey = normalizeBadge(label.slice(0, colonIdx).trim());
|
|
485
|
+
rest = label.slice(colonIdx + 1).trim();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!badgeKey) return escapeHtml(label);
|
|
489
|
+
const spec = getBadgeSpec(badgeKey);
|
|
490
|
+
const style = spec
|
|
491
|
+
? `background:${spec.bg};color:${spec.fg}`
|
|
492
|
+
: `background:#4a5568;color:#e2e8f0`;
|
|
493
|
+
const displayName = spec?.name ?? badgeKey;
|
|
494
|
+
return `<span class="label-badge" style="${style}">${escapeHtml(displayName)}</span><span style="overflow:hidden;text-overflow:ellipsis">${escapeHtml(rest)}</span>`;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const updateTlContextFilter = (points) => {
|
|
498
|
+
// Accumulate all contexts ever seen for the selected debugger
|
|
499
|
+
const id = state.selectedDebuggerId;
|
|
500
|
+
if (!state.tlContextsByDebugger) state.tlContextsByDebugger = new Map();
|
|
501
|
+
if (!state.tlContextsByDebugger.has(id)) state.tlContextsByDebugger.set(id, new Set());
|
|
502
|
+
const knownContexts = state.tlContextsByDebugger.get(id);
|
|
503
|
+
for (const p of points) {
|
|
504
|
+
if (p.contextId) knownContexts.add(p.contextId);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const current = tlContextFilterEl.value;
|
|
508
|
+
tlContextFilterEl.innerHTML = '<option value="all">All contexts</option>';
|
|
509
|
+
[...knownContexts].sort().forEach(ctx => {
|
|
510
|
+
const opt = document.createElement('option');
|
|
511
|
+
opt.value = ctx;
|
|
512
|
+
opt.textContent = ctx;
|
|
513
|
+
tlContextFilterEl.appendChild(opt);
|
|
514
|
+
});
|
|
515
|
+
tlContextFilterEl.value = (current === 'all' || knownContexts.has(current)) ? current : 'all';
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const isScrolledToRight = (el) =>
|
|
519
|
+
el.scrollWidth - el.scrollLeft - el.clientWidth < 40;
|
|
520
|
+
|
|
521
|
+
// ── Label multi-filter ──
|
|
522
|
+
const renderLabelTags = () => {
|
|
523
|
+
tlLabelTagsEl.innerHTML = '';
|
|
524
|
+
for (const label of state.tlLabelFilters) {
|
|
525
|
+
const tag = document.createElement('span');
|
|
526
|
+
tag.className = 'tl-label-tag';
|
|
527
|
+
tag.innerHTML =
|
|
528
|
+
`<span class="tl-label-tag-content">${renderLabelHtml(label)}</span>` +
|
|
529
|
+
`<span class="tl-label-tag-remove" data-label="${escapeHtml(label)}">✕</span>`;
|
|
530
|
+
tlLabelTagsEl.appendChild(tag);
|
|
531
|
+
}
|
|
532
|
+
tlLabelTagsEl.style.display = state.tlLabelFilters.size > 0 ? 'flex' : 'none';
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const addLabelFilter = (label) => {
|
|
536
|
+
if (!label) return;
|
|
537
|
+
state.tlLabelFilters.add(label);
|
|
538
|
+
renderLabelTags();
|
|
539
|
+
renderTimeline();
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const removeLabelFilter = (label) => {
|
|
543
|
+
state.tlLabelFilters.delete(label);
|
|
544
|
+
renderLabelTags();
|
|
545
|
+
renderTimeline();
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const showLabelSuggestions = (query) => {
|
|
549
|
+
const id = state.selectedDebuggerId;
|
|
550
|
+
const allPoints = id ? (state.perfpointsByDebugger.get(id) || []) : [];
|
|
551
|
+
// Deduplicate by badge+label key; each entry carries badge and label separately
|
|
552
|
+
const allEntries = [...new Map(allPoints.map(p => {
|
|
553
|
+
const key = (p.badge ? p.badge + ':' : '') + p.label;
|
|
554
|
+
return [key, { key, badge: p.badge || null, label: p.label }];
|
|
555
|
+
})).values()]
|
|
556
|
+
.filter(e => !state.tlLabelFilters.has(e.key))
|
|
557
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
558
|
+
|
|
559
|
+
const q = query.trim().toLowerCase();
|
|
560
|
+
const matches = q
|
|
561
|
+
? allEntries.filter(e => e.key.toLowerCase().includes(q)).slice(0, 12)
|
|
562
|
+
: allEntries.slice(0, 12);
|
|
563
|
+
|
|
564
|
+
if (matches.length === 0) {
|
|
565
|
+
tlLabelSuggestionsEl.classList.add('hidden');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
tlLabelSuggestionsEl.innerHTML = '';
|
|
570
|
+
for (const entry of matches) {
|
|
571
|
+
const item = document.createElement('div');
|
|
572
|
+
item.className = 'tl-label-suggestion-item';
|
|
573
|
+
item.dataset.label = entry.key;
|
|
574
|
+
item.innerHTML = renderLabelHtml(entry.label, entry.badge);
|
|
575
|
+
item.addEventListener('mousedown', (e) => {
|
|
576
|
+
e.preventDefault();
|
|
577
|
+
addLabelFilter(entry.key);
|
|
578
|
+
tlLabelInputEl.value = '';
|
|
579
|
+
tlLabelSuggestionsEl.classList.add('hidden');
|
|
580
|
+
});
|
|
581
|
+
tlLabelSuggestionsEl.appendChild(item);
|
|
582
|
+
}
|
|
583
|
+
tlLabelSuggestionsEl.classList.remove('hidden');
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
tlLabelInputEl.addEventListener('input', () => showLabelSuggestions(tlLabelInputEl.value));
|
|
587
|
+
tlLabelInputEl.addEventListener('focus', () => showLabelSuggestions(tlLabelInputEl.value));
|
|
588
|
+
tlLabelInputEl.addEventListener('blur', () => setTimeout(() => tlLabelSuggestionsEl.classList.add('hidden'), 150));
|
|
589
|
+
tlLabelInputEl.addEventListener('keydown', (e) => {
|
|
590
|
+
if (e.key === 'Enter') {
|
|
591
|
+
e.preventDefault();
|
|
592
|
+
// If a suggestion is highlighted use it, otherwise use typed value
|
|
593
|
+
const active = tlLabelSuggestionsEl.querySelector('.active');
|
|
594
|
+
const val = active ? active.dataset.label : tlLabelInputEl.value.trim();
|
|
595
|
+
if (val) { addLabelFilter(val); tlLabelInputEl.value = ''; }
|
|
596
|
+
tlLabelSuggestionsEl.classList.add('hidden');
|
|
597
|
+
} else if (e.key === 'ArrowDown') {
|
|
598
|
+
e.preventDefault();
|
|
599
|
+
const items = [...tlLabelSuggestionsEl.querySelectorAll('.tl-label-suggestion-item')];
|
|
600
|
+
const cur = tlLabelSuggestionsEl.querySelector('.active');
|
|
601
|
+
const next = cur ? (items[items.indexOf(cur) + 1] ?? items[0]) : items[0];
|
|
602
|
+
cur?.classList.remove('active');
|
|
603
|
+
next?.classList.add('active');
|
|
604
|
+
} else if (e.key === 'ArrowUp') {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
const items = [...tlLabelSuggestionsEl.querySelectorAll('.tl-label-suggestion-item')];
|
|
607
|
+
const cur = tlLabelSuggestionsEl.querySelector('.active');
|
|
608
|
+
const prev = cur ? (items[items.indexOf(cur) - 1] ?? items[items.length - 1]) : items[items.length - 1];
|
|
609
|
+
cur?.classList.remove('active');
|
|
610
|
+
prev?.classList.add('active');
|
|
611
|
+
} else if (e.key === 'Escape') {
|
|
612
|
+
tlLabelSuggestionsEl.classList.add('hidden');
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
document.getElementById('tl-label-filter-wrap').addEventListener('click', () => tlLabelInputEl.focus());
|
|
617
|
+
|
|
618
|
+
tlLabelTagsEl.addEventListener('click', (e) => {
|
|
619
|
+
const btn = e.target.closest('.tl-label-tag-remove');
|
|
620
|
+
if (btn) removeLabelFilter(btn.dataset.label);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const showPerfPointDetail = (point, clientX, clientY) => {
|
|
624
|
+
tlDetailLabelEl.innerHTML = renderLabelHtml(point.label, point.badge || null);
|
|
625
|
+
tlDetailDurEl.textContent = formatDuration(point.duration ?? 0);
|
|
626
|
+
tlDetailBodyEl.innerHTML = '';
|
|
627
|
+
const info = {
|
|
628
|
+
duration: `${(point.duration ?? 0).toFixed(2)} ms`,
|
|
629
|
+
startTs: point.startTs,
|
|
630
|
+
endTs: point.endTs,
|
|
631
|
+
contextId: point.contextId || undefined,
|
|
632
|
+
...(point.extra ?? {}),
|
|
633
|
+
};
|
|
634
|
+
Object.keys(info).forEach(k => { if (info[k] === undefined) delete info[k]; });
|
|
635
|
+
tlDetailBodyEl.appendChild(createObjectNode(info, null, 0));
|
|
636
|
+
// Авторасхлопнуть первый уровень
|
|
637
|
+
const firstLine = tlDetailBodyEl.querySelector('.json-line');
|
|
638
|
+
if (firstLine) firstLine.click();
|
|
639
|
+
|
|
640
|
+
tlDetailEl.classList.remove('hidden');
|
|
641
|
+
|
|
642
|
+
// Position near click, clamped to viewport.
|
|
643
|
+
// clientX/Y are in unzoomed CSS pixels; fixed elements are in zoomed space — compensate.
|
|
644
|
+
const zoom = parseFloat(getComputedStyle(document.body).zoom) || 1;
|
|
645
|
+
const vw = window.innerWidth / zoom;
|
|
646
|
+
const vh = window.innerHeight / zoom;
|
|
647
|
+
const cx = clientX / zoom;
|
|
648
|
+
const cy = clientY / zoom;
|
|
649
|
+
const W = 360, H = 320;
|
|
650
|
+
let x = cx + 14;
|
|
651
|
+
let y = cy + 14;
|
|
652
|
+
if (x + W > vw - 8) x = cx - W - 14;
|
|
653
|
+
if (x < 8) x = 8;
|
|
654
|
+
if (y + H > vh - 8) y = vh - H - 8;
|
|
655
|
+
if (y < 8) y = 8;
|
|
656
|
+
tlDetailEl.style.left = x + 'px';
|
|
657
|
+
tlDetailEl.style.top = y + 'px';
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
let _tlRenderTimer = null;
|
|
661
|
+
const scheduleTimelineRender = () => {
|
|
662
|
+
if (_tlRenderTimer !== null) return;
|
|
663
|
+
_tlRenderTimer = setTimeout(() => {
|
|
664
|
+
_tlRenderTimer = null;
|
|
665
|
+
requestAnimationFrame(renderTimeline);
|
|
666
|
+
}, 200);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const renderTimeline = () => {
|
|
670
|
+
const id = state.selectedDebuggerId;
|
|
671
|
+
|
|
672
|
+
const allPoints = id ? (state.perfpointsByDebugger.get(id) || []) : [];
|
|
673
|
+
updateTlContextFilter(allPoints);
|
|
674
|
+
|
|
675
|
+
// Apply filters
|
|
676
|
+
const contextFilter = tlContextFilterEl.value;
|
|
677
|
+
const labelFilters = state.tlLabelFilters;
|
|
678
|
+
|
|
679
|
+
let points = allPoints;
|
|
680
|
+
if (contextFilter !== 'all') points = points.filter(p => p.contextId === contextFilter);
|
|
681
|
+
if (labelFilters.size > 0) points = points.filter(p => {
|
|
682
|
+
const pKey = (p.badge ? p.badge + ':' : '') + p.label;
|
|
683
|
+
return [...labelFilters].some(f => pKey.toLowerCase().includes(f.toLowerCase()));
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
if (points.length === 0) {
|
|
687
|
+
tlEmptyEl.style.display = 'flex';
|
|
688
|
+
tlScrollEl.classList.remove('visible');
|
|
689
|
+
tlScrollEl.innerHTML = '';
|
|
690
|
+
tlDetailEl.classList.add('hidden');
|
|
691
|
+
tlStatsEl.innerHTML = allPoints.length > 0 ? 'No results for current filter' : 'No PerfPoints yet';
|
|
692
|
+
state.tlRenderKey = null;
|
|
693
|
+
state.tlRenderedPids = null;
|
|
694
|
+
state.tlPointMap = null;
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
tlEmptyEl.style.display = 'none';
|
|
699
|
+
tlScrollEl.classList.add('visible');
|
|
700
|
+
|
|
701
|
+
// Time range
|
|
702
|
+
const minTs = Math.min(...points.map(p => new Date(p.startTs).getTime()));
|
|
703
|
+
const maxTs = Math.max(...points.map(p => new Date(p.endTs).getTime()));
|
|
704
|
+
const totalMs = maxTs - minTs || 1;
|
|
705
|
+
|
|
706
|
+
// Scale: slider 1–100 maps to 0.02–2 px/ms
|
|
707
|
+
const sliderVal = Number(tlScaleEl.value);
|
|
708
|
+
const autoScale = Math.max(
|
|
709
|
+
0.05,
|
|
710
|
+
(tlScrollEl.clientWidth - LABEL_W - 40) / totalMs
|
|
711
|
+
);
|
|
712
|
+
const manualScale = 0.02 + (sliderVal / 100) * 1.98;
|
|
713
|
+
const scale = sliderVal === 50 ? autoScale : manualScale;
|
|
714
|
+
tlScaleLabelEl.textContent = sliderVal === 50
|
|
715
|
+
? `auto (${(scale * 1000).toFixed(1)}px/s)`
|
|
716
|
+
: `${(scale * 1000).toFixed(1)}px/s`;
|
|
717
|
+
|
|
718
|
+
const PAD = 40;
|
|
719
|
+
const chartW = Math.round(totalMs * scale) + PAD;
|
|
720
|
+
const totalW = chartW + LABEL_W;
|
|
721
|
+
|
|
722
|
+
// Group by context then badge+label
|
|
723
|
+
const contextMap = new Map();
|
|
724
|
+
for (const p of points) {
|
|
725
|
+
const ctx = p.contextId || '(no context)';
|
|
726
|
+
if (!contextMap.has(ctx)) contextMap.set(ctx, new Map());
|
|
727
|
+
const labelMap = contextMap.get(ctx);
|
|
728
|
+
const rowKey = (p.badge ? p.badge + ':' : '') + p.label;
|
|
729
|
+
if (!labelMap.has(rowKey)) labelMap.set(rowKey, []);
|
|
730
|
+
labelMap.get(rowKey).push(p);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const wasAtRight = isScrolledToRight(tlScrollEl);
|
|
734
|
+
|
|
735
|
+
// Compute stats
|
|
736
|
+
let totalPoints = 0;
|
|
737
|
+
let totalDuration = 0;
|
|
738
|
+
let slowest = null;
|
|
739
|
+
for (const [, labelMap] of contextMap) {
|
|
740
|
+
for (const [rowKey, pts] of labelMap) {
|
|
741
|
+
for (const p of pts) {
|
|
742
|
+
const dur = p.duration ?? (new Date(p.endTs) - new Date(p.startTs));
|
|
743
|
+
totalPoints++;
|
|
744
|
+
totalDuration += dur;
|
|
745
|
+
if (!slowest || dur > slowest.dur) slowest = { label: rowKey, dur };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ── Decide: incremental or full rebuild ──
|
|
751
|
+
const renderKey = `${scale}|${contextFilter}|${[...labelFilters].sort()}|${minTs}`;
|
|
752
|
+
const inner = tlScrollEl.querySelector('.tl-inner');
|
|
753
|
+
let didIncrement = false;
|
|
754
|
+
|
|
755
|
+
if (inner && state.tlRenderKey === renderKey && state.tlRenderedPids) {
|
|
756
|
+
didIncrement = tryIncrementalUpdate(inner, contextMap, scale, minTs, chartW, totalW, totalMs);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!didIncrement) {
|
|
760
|
+
state.tlRenderKey = renderKey;
|
|
761
|
+
state.tlRenderedPids = new Set();
|
|
762
|
+
state.tlPointMap = new Map();
|
|
763
|
+
fullTimelineRebuild(contextMap, scale, minTs, chartW, totalW, totalMs);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Stats
|
|
767
|
+
const avgDur = totalPoints > 0 ? totalDuration / totalPoints : 0;
|
|
768
|
+
tlStatsEl.innerHTML = `<strong>${totalPoints}</strong> pts · avg <strong>${formatDuration(avgDur)}</strong> · max <strong>${formatDuration(slowest?.dur ?? 0)}</strong>`;
|
|
769
|
+
|
|
770
|
+
// Restore scroll position
|
|
771
|
+
if (wasAtRight) {
|
|
772
|
+
tlScrollEl.scrollTo({ left: tlScrollEl.scrollWidth, behavior: 'smooth' });
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// ── Full timeline rebuild (innerHTML) ──
|
|
777
|
+
const fullTimelineRebuild = (contextMap, scale, minTs, chartW, totalW, totalMs) => {
|
|
778
|
+
let html = `<div class="tl-inner" style="width:${totalW}px">`;
|
|
779
|
+
|
|
780
|
+
// Time axis
|
|
781
|
+
html += `<div class="tl-time-axis-row">`;
|
|
782
|
+
html += `<div class="tl-axis-corner"></div>`;
|
|
783
|
+
html += `<div class="tl-time-axis" style="width:${chartW}px">`;
|
|
784
|
+
const tickStep = pickTickStep(totalMs, chartW);
|
|
785
|
+
for (let ms = 0; ms <= totalMs + tickStep * 0.5; ms += tickStep) {
|
|
786
|
+
const px = Math.round(ms * scale);
|
|
787
|
+
if (px > chartW) break;
|
|
788
|
+
html += `<span class="tl-tick" style="left:${px}px">${formatDuration(ms)}</span>`;
|
|
789
|
+
}
|
|
790
|
+
html += `</div></div>`;
|
|
791
|
+
|
|
792
|
+
// Context groups
|
|
793
|
+
for (const [ctx, labelMap] of contextMap) {
|
|
794
|
+
html += `<div class="tl-group-header" data-ctx="${escapeHtml(ctx)}">`;
|
|
795
|
+
html += `<div class="tl-group-label" title="${escapeHtml(ctx)}">${escapeHtml(ctx)}</div>`;
|
|
796
|
+
html += `<div class="tl-group-stripe" style="width:${chartW}px"></div>`;
|
|
797
|
+
html += `</div>`;
|
|
798
|
+
|
|
799
|
+
for (const [rowKey, pts] of labelMap) {
|
|
800
|
+
const p0 = pts[0];
|
|
801
|
+
const sorted = [...pts].sort((a, b) => new Date(a.startTs) - new Date(b.startTs));
|
|
802
|
+
|
|
803
|
+
html += `<div class="tl-row" data-row-key="${escapeHtml(rowKey)}">`;
|
|
804
|
+
html += `<div class="tl-row-label" title="${escapeHtml(rowKey)}">${renderLabelHtml(p0.label, p0.badge || null)}</div>`;
|
|
805
|
+
html += `<div class="tl-row-bars" style="width:${chartW}px">`;
|
|
806
|
+
|
|
807
|
+
for (const p of sorted) {
|
|
808
|
+
const startMs = new Date(p.startTs).getTime() - minTs;
|
|
809
|
+
const dur = p.duration ?? (new Date(p.endTs) - new Date(p.startTs));
|
|
810
|
+
const left = Math.round(startMs * scale);
|
|
811
|
+
const width = Math.max(Math.round(dur * scale), 2);
|
|
812
|
+
const cls = barClass(dur);
|
|
813
|
+
const durText = formatDuration(dur);
|
|
814
|
+
const tooltip = `${escapeHtml(rowKey)}\n${durText}${p.contextId ? ' [' + p.contextId + ']' : ''}\n${p.startTs}`;
|
|
815
|
+
|
|
816
|
+
html += `<div class="tl-bar ${cls}" style="left:${left}px;width:${width}px" title="${tooltip}" data-pid="${p.id}">`;
|
|
817
|
+
if (width > 35) html += `<span>${durText}</span>`;
|
|
818
|
+
html += `</div>`;
|
|
819
|
+
|
|
820
|
+
state.tlRenderedPids.add(p.id);
|
|
821
|
+
state.tlPointMap.set(p.id, p);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
html += `</div></div>`;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
html += '</div>';
|
|
829
|
+
tlScrollEl.innerHTML = html;
|
|
830
|
+
|
|
831
|
+
// Attach click handlers
|
|
832
|
+
tlScrollEl.querySelectorAll('[data-pid]').forEach(el => {
|
|
833
|
+
el.addEventListener('click', (e) => {
|
|
834
|
+
e.stopPropagation();
|
|
835
|
+
const point = state.tlPointMap.get(el.dataset.pid);
|
|
836
|
+
if (point) showPerfPointDetail(point, e.clientX, e.clientY);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// ── Incremental update: append only new bars, no DOM rebuild ──
|
|
842
|
+
const tryIncrementalUpdate = (inner, contextMap, scale, minTs, chartW, totalW, totalMs) => {
|
|
843
|
+
// Check if all new points fit into existing rows
|
|
844
|
+
for (const [, labelMap] of contextMap) {
|
|
845
|
+
for (const [rowKey, pts] of labelMap) {
|
|
846
|
+
if (pts.some(p => !state.tlRenderedPids.has(p.id))) {
|
|
847
|
+
if (!inner.querySelector(`[data-row-key="${CSS.escape(rowKey)}"]`)) {
|
|
848
|
+
return false; // new row needed — fall back to full rebuild
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Update container widths
|
|
855
|
+
inner.style.width = totalW + 'px';
|
|
856
|
+
inner.querySelectorAll('.tl-row-bars, .tl-time-axis, .tl-group-stripe').forEach(el => {
|
|
857
|
+
el.style.width = chartW + 'px';
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Rebuild time axis ticks
|
|
861
|
+
const timeAxis = inner.querySelector('.tl-time-axis');
|
|
862
|
+
if (timeAxis) {
|
|
863
|
+
let tickHtml = '';
|
|
864
|
+
const tickStep = pickTickStep(totalMs, chartW);
|
|
865
|
+
for (let ms = 0; ms <= totalMs + tickStep * 0.5; ms += tickStep) {
|
|
866
|
+
const px = Math.round(ms * scale);
|
|
867
|
+
if (px > chartW) break;
|
|
868
|
+
tickHtml += `<span class="tl-tick" style="left:${px}px">${formatDuration(ms)}</span>`;
|
|
869
|
+
}
|
|
870
|
+
timeAxis.innerHTML = tickHtml;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Append new bars to existing rows
|
|
874
|
+
for (const [, labelMap] of contextMap) {
|
|
875
|
+
for (const [rowKey, pts] of labelMap) {
|
|
876
|
+
const newPts = pts.filter(p => !state.tlRenderedPids.has(p.id));
|
|
877
|
+
if (newPts.length === 0) continue;
|
|
878
|
+
|
|
879
|
+
const rowBars = inner.querySelector(`[data-row-key="${CSS.escape(rowKey)}"] .tl-row-bars`);
|
|
880
|
+
if (!rowBars) continue;
|
|
881
|
+
|
|
882
|
+
for (const p of newPts) {
|
|
883
|
+
const startMs = new Date(p.startTs).getTime() - minTs;
|
|
884
|
+
const dur = p.duration ?? (new Date(p.endTs) - new Date(p.startTs));
|
|
885
|
+
const left = Math.round(startMs * scale);
|
|
886
|
+
const width = Math.max(Math.round(dur * scale), 2);
|
|
887
|
+
const cls = barClass(dur);
|
|
888
|
+
const durText = formatDuration(dur);
|
|
889
|
+
const tooltip = `${rowKey}\n${durText}${p.contextId ? ' [' + p.contextId + ']' : ''}\n${p.startTs}`;
|
|
890
|
+
|
|
891
|
+
const bar = document.createElement('div');
|
|
892
|
+
bar.className = `tl-bar ${cls} tl-bar-new`;
|
|
893
|
+
bar.style.cssText = `left:${left}px;width:${width}px`;
|
|
894
|
+
bar.title = tooltip;
|
|
895
|
+
bar.dataset.pid = p.id;
|
|
896
|
+
if (width > 35) bar.innerHTML = `<span>${durText}</span>`;
|
|
897
|
+
bar.addEventListener('click', (e) => {
|
|
898
|
+
e.stopPropagation();
|
|
899
|
+
showPerfPointDetail(p, e.clientX, e.clientY);
|
|
900
|
+
});
|
|
901
|
+
rowBars.appendChild(bar);
|
|
902
|
+
|
|
903
|
+
state.tlRenderedPids.add(p.id);
|
|
904
|
+
state.tlPointMap.set(p.id, p);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return true;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// Pick a human-friendly tick interval
|
|
913
|
+
const pickTickStep = (totalMs, widthPx) => {
|
|
914
|
+
const candidates = [10, 25, 50, 100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000];
|
|
915
|
+
const minPxPerTick = 60;
|
|
916
|
+
for (const step of candidates) {
|
|
917
|
+
const ticks = totalMs / step;
|
|
918
|
+
if (widthPx / ticks >= minPxPerTick) return step;
|
|
919
|
+
}
|
|
920
|
+
return candidates[candidates.length - 1];
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
// ── Clear inactive ──
|
|
924
|
+
document.getElementById('clear-inactive-btn').addEventListener('click', () => {
|
|
925
|
+
const offlineCount = [...state.debuggers.values()].filter(d => d.status === 'offline').length;
|
|
926
|
+
if (offlineCount === 0) return;
|
|
927
|
+
if (!confirm(`Remove ${offlineCount} offline debugger(s) and their data?`)) return;
|
|
928
|
+
socket.send(JSON.stringify({ action: 'clear-inactive' }));
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// ── Console event listeners ──
|
|
932
|
+
debuggerSearchEl.addEventListener('input', renderDebuggers);
|
|
933
|
+
levelFilterEl.addEventListener('change', () => renderConsole(true));
|
|
934
|
+
categoryFilterEl.addEventListener('input', () => renderConsole(true));
|
|
935
|
+
contextFilterEl.addEventListener('change', () => renderConsole(true));
|
|
936
|
+
textFilterEl.addEventListener('input', () => renderConsole(true));
|
|
937
|
+
|
|
938
|
+
// ── Report elements ──
|
|
939
|
+
const reportPeriodEl = document.getElementById('report-period');
|
|
940
|
+
const reportFormatEl = document.getElementById('report-format');
|
|
941
|
+
const reportBtnEl = document.getElementById('report-btn');
|
|
942
|
+
|
|
943
|
+
// ── Timeline event listeners ──
|
|
944
|
+
tlContextFilterEl.addEventListener('change', renderTimeline);
|
|
945
|
+
tlScaleEl.addEventListener('input', renderTimeline);
|
|
946
|
+
|
|
947
|
+
document.getElementById('tl-btn-start').addEventListener('click', () => { tlScrollEl.scrollLeft = 0; });
|
|
948
|
+
document.getElementById('tl-btn-end').addEventListener('click', () => { tlScrollEl.scrollLeft = tlScrollEl.scrollWidth; });
|
|
949
|
+
|
|
950
|
+
document.getElementById('tl-detail-close').addEventListener('click', () => tlDetailEl.classList.add('hidden'));
|
|
951
|
+
|
|
952
|
+
// Close popover on click outside
|
|
953
|
+
document.addEventListener('click', (e) => {
|
|
954
|
+
if (!tlDetailEl.classList.contains('hidden') &&
|
|
955
|
+
!tlDetailEl.contains(e.target) &&
|
|
956
|
+
!e.target.closest('[data-pid]')) {
|
|
957
|
+
tlDetailEl.classList.add('hidden');
|
|
958
|
+
}
|
|
959
|
+
// Close context menu on click outside
|
|
960
|
+
const ctxMenu = document.getElementById('tl-ctx-menu');
|
|
961
|
+
if (ctxMenu && !ctxMenu.contains(e.target) &&
|
|
962
|
+
!e.target.closest('.tl-row-label') && !e.target.closest('.tl-group-label')) {
|
|
963
|
+
ctxMenu.classList.add('hidden');
|
|
964
|
+
}
|
|
965
|
+
// Close dropdowns on click outside
|
|
966
|
+
if (!e.target.closest('.tl-dropdown-wrap')) {
|
|
967
|
+
document.querySelectorAll('.tl-dropdown-menu').forEach(m => m.classList.add('hidden'));
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ── Timeline context menu ──
|
|
972
|
+
const TL_ICON_FILTER_ADD = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3h12L9 8.5V12l-2 1V8.5L2 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M12 10h4M14 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
|
973
|
+
const TL_ICON_FILTER_DEL = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 3h12L9 8.5V12l-2 1V8.5L2 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M11.5 10l3 3M14.5 10l-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
|
974
|
+
|
|
975
|
+
const showTlContextMenu = (x, y, items) => {
|
|
976
|
+
let menu = document.getElementById('tl-ctx-menu');
|
|
977
|
+
if (!menu) {
|
|
978
|
+
menu = document.createElement('div');
|
|
979
|
+
menu.id = 'tl-ctx-menu';
|
|
980
|
+
menu.className = 'tl-ctx-menu hidden';
|
|
981
|
+
document.body.appendChild(menu);
|
|
982
|
+
}
|
|
983
|
+
menu.innerHTML = '';
|
|
984
|
+
for (const item of items) {
|
|
985
|
+
const el = document.createElement('div');
|
|
986
|
+
el.className = 'tl-ctx-menu-item';
|
|
987
|
+
el.innerHTML = `<span class="tl-ctx-menu-item-icon">${item.icon}</span>${escapeHtml(item.text)}`;
|
|
988
|
+
el.addEventListener('click', (e) => {
|
|
989
|
+
e.stopPropagation();
|
|
990
|
+
menu.classList.add('hidden');
|
|
991
|
+
item.action();
|
|
992
|
+
});
|
|
993
|
+
menu.appendChild(el);
|
|
994
|
+
}
|
|
995
|
+
const zoom = parseFloat(getComputedStyle(document.body).zoom) || 1;
|
|
996
|
+
menu.style.left = (x / zoom) + 'px';
|
|
997
|
+
menu.style.top = (y / zoom) + 'px';
|
|
998
|
+
menu.classList.remove('hidden');
|
|
999
|
+
requestAnimationFrame(() => {
|
|
1000
|
+
const rect = menu.getBoundingClientRect();
|
|
1001
|
+
if (rect.right > window.innerWidth) menu.style.left = ((x / zoom) - rect.width) + 'px';
|
|
1002
|
+
if (rect.bottom > window.innerHeight) menu.style.top = ((y / zoom) - rect.height) + 'px';
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// Click on row label → context menu for badge filter
|
|
1007
|
+
tlScrollEl.addEventListener('click', (e) => {
|
|
1008
|
+
const rowLabel = e.target.closest('.tl-row-label');
|
|
1009
|
+
if (rowLabel) {
|
|
1010
|
+
const row = rowLabel.closest('.tl-row');
|
|
1011
|
+
const rowKey = row?.dataset.rowKey;
|
|
1012
|
+
if (!rowKey) return;
|
|
1013
|
+
const ci = rowKey.indexOf(':');
|
|
1014
|
+
const badge = ci !== -1 ? rowKey.slice(0, ci) : rowKey;
|
|
1015
|
+
e.stopPropagation();
|
|
1016
|
+
const isFiltered = state.tlLabelFilters.has(badge);
|
|
1017
|
+
const items = isFiltered
|
|
1018
|
+
? [{ icon: TL_ICON_FILTER_DEL, text: `Remove "${badge}"`, action: () => removeLabelFilter(badge) }]
|
|
1019
|
+
: [{ icon: TL_ICON_FILTER_ADD, text: `Filter by "${badge}"`, action: () => addLabelFilter(badge) }];
|
|
1020
|
+
showTlContextMenu(e.clientX, e.clientY, items);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const groupLabel = e.target.closest('.tl-group-label');
|
|
1025
|
+
if (groupLabel) {
|
|
1026
|
+
const header = groupLabel.closest('.tl-group-header');
|
|
1027
|
+
const ctx = header?.dataset.ctx;
|
|
1028
|
+
if (!ctx) return;
|
|
1029
|
+
e.stopPropagation();
|
|
1030
|
+
const current = tlContextFilterEl.value;
|
|
1031
|
+
const isFiltered = current === ctx;
|
|
1032
|
+
const items = isFiltered
|
|
1033
|
+
? [{ icon: TL_ICON_FILTER_DEL, text: `Show all contexts`, action: () => { tlContextFilterEl.value = 'all'; renderTimeline(); } }]
|
|
1034
|
+
: [{ icon: TL_ICON_FILTER_ADD, text: `Filter by "${ctx}"`, action: () => { tlContextFilterEl.value = ctx; renderTimeline(); } }];
|
|
1035
|
+
showTlContextMenu(e.clientX, e.clientY, items);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// ── Report generation ──
|
|
1041
|
+
|
|
1042
|
+
const percentile = (arr, p) => {
|
|
1043
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
1044
|
+
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
|
1045
|
+
return sorted[idx];
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const durClass = (ms) => ms < 300 ? 'fast' : ms < 1000 ? 'medium' : 'slow';
|
|
1049
|
+
|
|
1050
|
+
const PERIOD_LABELS = { '5': 'First 5 seconds', '60': 'First 60 seconds', 'all': 'Full period' };
|
|
1051
|
+
|
|
1052
|
+
const buildReportData = (points, dbg, contextFilter, period) => {
|
|
1053
|
+
// Group by badge+label
|
|
1054
|
+
const byLabel = new Map();
|
|
1055
|
+
for (const p of points) {
|
|
1056
|
+
const key = (p.badge ? p.badge + ':' : '') + p.label;
|
|
1057
|
+
if (!byLabel.has(key)) byLabel.set(key, []);
|
|
1058
|
+
byLabel.get(key).push(p);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Top slow: max duration per label, sorted desc
|
|
1062
|
+
const topSlow = [...byLabel.entries()].map(([label, pts]) => {
|
|
1063
|
+
const durations = pts.map(p => p.duration ?? 0);
|
|
1064
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
1065
|
+
return {
|
|
1066
|
+
label,
|
|
1067
|
+
count: pts.length,
|
|
1068
|
+
total: sum,
|
|
1069
|
+
max: Math.max(...durations),
|
|
1070
|
+
min: Math.min(...durations),
|
|
1071
|
+
avg: sum / durations.length,
|
|
1072
|
+
p95: percentile(durations, 95),
|
|
1073
|
+
};
|
|
1074
|
+
}).sort((a, b) => b.max - a.max).slice(0, 30);
|
|
1075
|
+
|
|
1076
|
+
// Most frequent: sorted by count desc
|
|
1077
|
+
const mostFrequent = [...byLabel.entries()].map(([label, pts]) => {
|
|
1078
|
+
const durations = pts.map(p => p.duration ?? 0);
|
|
1079
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
1080
|
+
return {
|
|
1081
|
+
label,
|
|
1082
|
+
count: pts.length,
|
|
1083
|
+
total: sum,
|
|
1084
|
+
avg: sum / durations.length,
|
|
1085
|
+
max: Math.max(...durations),
|
|
1086
|
+
};
|
|
1087
|
+
}).sort((a, b) => b.count - a.count).slice(0, 30);
|
|
1088
|
+
|
|
1089
|
+
// Repeated: 3+ calls within any 10s window (sliding window)
|
|
1090
|
+
const repeated = [];
|
|
1091
|
+
for (const [label, pts] of byLabel) {
|
|
1092
|
+
const sorted = [...pts].sort((a, b) => new Date(a.startTs) - new Date(b.startTs));
|
|
1093
|
+
let maxCount = 0, maxWindowStart = null;
|
|
1094
|
+
let left = 0;
|
|
1095
|
+
for (let right = 0; right < sorted.length; right++) {
|
|
1096
|
+
const rTs = new Date(sorted[right].startTs).getTime();
|
|
1097
|
+
while (new Date(sorted[left].startTs).getTime() < rTs - 10000) left++;
|
|
1098
|
+
const count = right - left + 1;
|
|
1099
|
+
if (count > maxCount) {
|
|
1100
|
+
maxCount = count;
|
|
1101
|
+
maxWindowStart = sorted[left].startTs;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (maxCount >= 3) {
|
|
1105
|
+
repeated.push({ label, maxInWindow: maxCount, windowStart: maxWindowStart, total: pts.length });
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
repeated.sort((a, b) => b.maxInWindow - a.maxInWindow);
|
|
1109
|
+
|
|
1110
|
+
const allTs = points.flatMap(p => [new Date(p.startTs).getTime(), new Date(p.endTs).getTime()]);
|
|
1111
|
+
return {
|
|
1112
|
+
meta: {
|
|
1113
|
+
debugger: dbg?.name || dbg?.debuggerId || 'Unknown',
|
|
1114
|
+
debuggerId: dbg?.debuggerId || '',
|
|
1115
|
+
context: contextFilter,
|
|
1116
|
+
period,
|
|
1117
|
+
periodLabel: PERIOD_LABELS[period] || period,
|
|
1118
|
+
generatedAt: new Date().toISOString(),
|
|
1119
|
+
totalPoints: points.length,
|
|
1120
|
+
from: new Date(Math.min(...allTs)).toISOString(),
|
|
1121
|
+
to: new Date(Math.max(...allTs)).toISOString(),
|
|
1122
|
+
},
|
|
1123
|
+
topSlow,
|
|
1124
|
+
mostFrequent,
|
|
1125
|
+
repeated,
|
|
1126
|
+
};
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
const fmtDate = (iso) => {
|
|
1130
|
+
const d = new Date(iso);
|
|
1131
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1132
|
+
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
const fmtTime = (iso) => {
|
|
1136
|
+
const d = new Date(iso);
|
|
1137
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1138
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
const durHtml = (ms) =>
|
|
1142
|
+
`<span class="dur-${durClass(ms)}">${formatDuration(ms)}</span>`;
|
|
1143
|
+
|
|
1144
|
+
const labelBadgeReportHtml = (label) => {
|
|
1145
|
+
const ci = label.indexOf(':');
|
|
1146
|
+
if (ci === -1) return `<code>${escHtml(label)}</code>`;
|
|
1147
|
+
const badgeKey = normalizeBadge(label.slice(0, ci).trim());
|
|
1148
|
+
const rest = label.slice(ci + 1).trim();
|
|
1149
|
+
const spec = getBadgeSpec(badgeKey);
|
|
1150
|
+
const bg = spec?.bg || '#4a5568';
|
|
1151
|
+
const name = spec?.name ?? badgeKey;
|
|
1152
|
+
return `<span style="display:inline-block;padding:2px 7px;border-radius:3px;background:${bg};color:#fff;font-size:11px;font-weight:600;vertical-align:middle;margin-right:6px">${escHtml(name)}</span><span style="vertical-align:middle">${escHtml(rest)}</span>`;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const buildHtmlReport = (d) => {
|
|
1156
|
+
const { meta, topSlow, mostFrequent, repeated } = d;
|
|
1157
|
+
|
|
1158
|
+
const tableHead = (...cols) =>
|
|
1159
|
+
`<tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr>`;
|
|
1160
|
+
|
|
1161
|
+
const topSlowRows = topSlow.map((r, i) => `
|
|
1162
|
+
<tr>
|
|
1163
|
+
<td>${i + 1}</td>
|
|
1164
|
+
<td class="label-cell">${labelBadgeReportHtml(r.label)}</td>
|
|
1165
|
+
<td>${r.count}</td>
|
|
1166
|
+
<td>${durHtml(r.total)}</td>
|
|
1167
|
+
<td>${durHtml(r.max)}</td>
|
|
1168
|
+
<td>${durHtml(r.avg)}</td>
|
|
1169
|
+
<td>${durHtml(r.p95)}</td>
|
|
1170
|
+
<td>${durHtml(r.min)}</td>
|
|
1171
|
+
</tr>`).join('');
|
|
1172
|
+
|
|
1173
|
+
const freqRows = mostFrequent.map((r, i) => `
|
|
1174
|
+
<tr>
|
|
1175
|
+
<td>${i + 1}</td>
|
|
1176
|
+
<td class="label-cell">${labelBadgeReportHtml(r.label)}</td>
|
|
1177
|
+
<td><strong>${r.count}</strong></td>
|
|
1178
|
+
<td>${durHtml(r.total)}</td>
|
|
1179
|
+
<td>${durHtml(r.avg)}</td>
|
|
1180
|
+
<td>${durHtml(r.max)}</td>
|
|
1181
|
+
</tr>`).join('');
|
|
1182
|
+
|
|
1183
|
+
const repeatRows = repeated.length === 0
|
|
1184
|
+
? `<tr><td colspan="4" class="empty">No repeated bursts detected</td></tr>`
|
|
1185
|
+
: repeated.map(r => `
|
|
1186
|
+
<tr>
|
|
1187
|
+
<td class="label-cell">${labelBadgeReportHtml(r.label)}</td>
|
|
1188
|
+
<td><span class="badge badge-danger">${r.maxInWindow}×</span></td>
|
|
1189
|
+
<td>${r.total}</td>
|
|
1190
|
+
<td class="ts-cell">${fmtTime(r.windowStart)}</td>
|
|
1191
|
+
</tr>`).join('');
|
|
1192
|
+
|
|
1193
|
+
return `<!DOCTYPE html>
|
|
1194
|
+
<html lang="en">
|
|
1195
|
+
<head>
|
|
1196
|
+
<meta charset="UTF-8">
|
|
1197
|
+
<title>PerfPoint Report — ${escHtml(meta.debugger)}</title>
|
|
1198
|
+
<style>
|
|
1199
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
1200
|
+
:root {
|
|
1201
|
+
--bg: #f4f4f5; --card: #ffffff; --ink: #18181b; --muted: #71717a;
|
|
1202
|
+
--border: #e4e4e7; --row-hover: #fafafa; --th-bg: #fafafa;
|
|
1203
|
+
--section-border: #e4e4e7; --meta-border: #e4e4e7;
|
|
1204
|
+
--h2-border: #e4e4e7; --ts-color: #71717a;
|
|
1205
|
+
--badge-danger-bg: #fef2f2; --badge-danger-fg: #dc2626; --badge-danger-border: #fecaca;
|
|
1206
|
+
}
|
|
1207
|
+
[data-theme="dark"] {
|
|
1208
|
+
--bg: #09090b; --card: #18181b; --ink: #fafafa; --muted: #a1a1aa;
|
|
1209
|
+
--border: #27272a; --row-hover: #1c1c1e; --th-bg: #1c1c1e;
|
|
1210
|
+
--section-border: #27272a; --meta-border: #27272a;
|
|
1211
|
+
--h2-border: #27272a; --ts-color: #a1a1aa;
|
|
1212
|
+
--badge-danger-bg: #2d1117; --badge-danger-fg: #f87171; --badge-danger-border: #6b1414;
|
|
1213
|
+
}
|
|
1214
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1215
|
+
body { font-family: 'Inter', -apple-system, "Segoe UI", sans-serif; margin: 0; padding: 40px 56px; background: var(--bg); color: var(--ink); font-size: 14px; line-height: 1.6; transition: background 0.2s, color 0.2s; }
|
|
1216
|
+
h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; color: var(--ink); letter-spacing: -0.3px; }
|
|
1217
|
+
.meta-bar { font-size: 13px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 32px; padding-bottom: 16px; border-bottom: 1px solid var(--meta-border); }
|
|
1218
|
+
.meta-bar span strong { color: var(--ink); }
|
|
1219
|
+
section { background: var(--card); border: 1px solid var(--section-border); border-radius: 0; padding: 24px 28px; margin-bottom: 24px; }
|
|
1220
|
+
h2 { margin: 0 0 16px; font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 1px solid var(--h2-border); padding-bottom: 10px; }
|
|
1221
|
+
h2 small { font-weight: 400; color: var(--muted); font-size: 12px; margin-left: 8px; text-transform: none; letter-spacing: 0; }
|
|
1222
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1223
|
+
th { text-align: left; padding: 9px 12px; background: var(--th-bg); font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
1224
|
+
td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; font-size: 13px; }
|
|
1225
|
+
tr:last-child td { border-bottom: none; }
|
|
1226
|
+
tr:hover td { background: var(--row-hover); }
|
|
1227
|
+
.label-cell { font-size: 13px; max-width: 500px; }
|
|
1228
|
+
.label-cell code { font-family: 'JetBrains Mono', Menlo, Monaco, monospace; font-size: 12px; }
|
|
1229
|
+
.ts-cell { font-family: 'JetBrains Mono', Menlo, Monaco, monospace; font-size: 12px; color: var(--ts-color); white-space: nowrap; }
|
|
1230
|
+
.dur-fast { color: #16a34a; font-weight: 600; }
|
|
1231
|
+
.dur-medium { color: #d97706; font-weight: 600; }
|
|
1232
|
+
.dur-slow { color: #dc2626; font-weight: 600; }
|
|
1233
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
|
1234
|
+
.badge-danger { background: var(--badge-danger-bg); color: var(--badge-danger-fg); border: 1px solid var(--badge-danger-border); }
|
|
1235
|
+
.empty { color: var(--muted); font-style: italic; text-align: center; padding: 20px 0; font-size: 13px; }
|
|
1236
|
+
.theme-btn { position: fixed; top: 20px; right: 24px; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 6px 12px; font-size: 16px; cursor: pointer; line-height: 1; transition: background 0.2s, border-color 0.2s; }
|
|
1237
|
+
.theme-btn:hover { background: var(--bg); border-color: var(--muted); }
|
|
1238
|
+
</style>
|
|
1239
|
+
</head>
|
|
1240
|
+
<body>
|
|
1241
|
+
<button class="theme-btn" id="rpt-theme-btn" title="Toggle theme">🌙</button>
|
|
1242
|
+
<script>
|
|
1243
|
+
(function() {
|
|
1244
|
+
var btn = document.getElementById('rpt-theme-btn');
|
|
1245
|
+
var dark = false;
|
|
1246
|
+
try {
|
|
1247
|
+
var saved = localStorage.getItem('prism-report-theme');
|
|
1248
|
+
if (saved) { dark = saved === 'dark'; }
|
|
1249
|
+
else { dark = window.matchMedia('(prefers-color-scheme: dark)').matches; }
|
|
1250
|
+
} catch(e) {}
|
|
1251
|
+
function apply(d) {
|
|
1252
|
+
document.documentElement.setAttribute('data-theme', d ? 'dark' : '');
|
|
1253
|
+
btn.textContent = d ? '☀️' : '🌙';
|
|
1254
|
+
}
|
|
1255
|
+
apply(dark);
|
|
1256
|
+
btn.addEventListener('click', function() {
|
|
1257
|
+
dark = !dark;
|
|
1258
|
+
apply(dark);
|
|
1259
|
+
try { localStorage.setItem('prism-report-theme', dark ? 'dark' : 'light'); } catch(e) {}
|
|
1260
|
+
});
|
|
1261
|
+
})();
|
|
1262
|
+
</script>
|
|
1263
|
+
<h1>Performance Report</h1>
|
|
1264
|
+
<div class="meta-bar">
|
|
1265
|
+
<span>Debugger: <strong>${escHtml(meta.debugger)}</strong></span>
|
|
1266
|
+
<span>Context: <strong>${escHtml(meta.context)}</strong></span>
|
|
1267
|
+
<span>Period: <strong>${escHtml(meta.periodLabel)}</strong></span>
|
|
1268
|
+
<span>Points: <strong>${meta.totalPoints}</strong></span>
|
|
1269
|
+
<span>From: <strong>${fmtDate(meta.from)}</strong></span>
|
|
1270
|
+
<span>To: <strong>${fmtDate(meta.to)}</strong></span>
|
|
1271
|
+
</div>
|
|
1272
|
+
|
|
1273
|
+
<section>
|
|
1274
|
+
<h2>Top Slow Operations <small>by max duration, top 30</small></h2>
|
|
1275
|
+
<table>
|
|
1276
|
+
<thead>${tableHead('#', 'Label', 'Count', 'Total', 'Max', 'Avg', 'p95', 'Min')}</thead>
|
|
1277
|
+
<tbody>${topSlowRows || '<tr><td colspan="8" class="empty">No data</td></tr>'}</tbody>
|
|
1278
|
+
</table>
|
|
1279
|
+
</section>
|
|
1280
|
+
|
|
1281
|
+
<section>
|
|
1282
|
+
<h2>Frequently Repeated Operations <small>≥3 calls in any 10-second window</small></h2>
|
|
1283
|
+
<table>
|
|
1284
|
+
<thead>${tableHead('Label', 'Max burst', 'Total calls', 'First burst at')}</thead>
|
|
1285
|
+
<tbody>${repeatRows}</tbody>
|
|
1286
|
+
</table>
|
|
1287
|
+
</section>
|
|
1288
|
+
|
|
1289
|
+
<section>
|
|
1290
|
+
<h2>Most Frequent Operations <small>by call count, top 30</small></h2>
|
|
1291
|
+
<table>
|
|
1292
|
+
<thead>${tableHead('#', 'Label', 'Count', 'Total', 'Avg', 'Max')}</thead>
|
|
1293
|
+
<tbody>${freqRows || '<tr><td colspan="6" class="empty">No data</td></tr>'}</tbody>
|
|
1294
|
+
</table>
|
|
1295
|
+
</section>
|
|
1296
|
+
|
|
1297
|
+
</body></html>`;
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
const escHtml = (str) => String(str ?? '')
|
|
1301
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1302
|
+
|
|
1303
|
+
const buildCsvReport = (d) => {
|
|
1304
|
+
const { meta, topSlow, mostFrequent, repeated } = d;
|
|
1305
|
+
const lines = [];
|
|
1306
|
+
|
|
1307
|
+
lines.push(`"PerfPoint Report"`);
|
|
1308
|
+
lines.push(`"Debugger","${meta.debugger}"`);
|
|
1309
|
+
lines.push(`"Context","${meta.context}"`);
|
|
1310
|
+
lines.push(`"Period","${meta.periodLabel}"`);
|
|
1311
|
+
lines.push(`"Total points","${meta.totalPoints}"`);
|
|
1312
|
+
lines.push(`"From","${meta.from}"`);
|
|
1313
|
+
lines.push(`"To","${meta.to}"`);
|
|
1314
|
+
lines.push(`"Generated","${meta.generatedAt}"`);
|
|
1315
|
+
lines.push('');
|
|
1316
|
+
|
|
1317
|
+
lines.push('"TOP SLOW OPERATIONS"');
|
|
1318
|
+
lines.push('"#","Label","Count","Max ms","Avg ms","p95 ms","Min ms"');
|
|
1319
|
+
topSlow.forEach((r, i) =>
|
|
1320
|
+
lines.push(`"${i + 1}","${r.label.replace(/"/g, '""')}","${r.count}","${r.max.toFixed(2)}","${r.avg.toFixed(2)}","${r.p95.toFixed(2)}","${r.min.toFixed(2)}"`)
|
|
1321
|
+
);
|
|
1322
|
+
lines.push('');
|
|
1323
|
+
|
|
1324
|
+
lines.push('"FREQUENTLY REPEATED OPERATIONS (3+ in 10s)"');
|
|
1325
|
+
lines.push('"Label","Max burst","Total calls","First burst at"');
|
|
1326
|
+
repeated.forEach(r =>
|
|
1327
|
+
lines.push(`"${r.label.replace(/"/g, '""')}","${r.maxInWindow}","${r.total}","${r.windowStart}"`)
|
|
1328
|
+
);
|
|
1329
|
+
if (repeated.length === 0) lines.push('"No repeated bursts detected"');
|
|
1330
|
+
lines.push('');
|
|
1331
|
+
|
|
1332
|
+
lines.push('"MOST FREQUENT OPERATIONS"');
|
|
1333
|
+
lines.push('"#","Label","Count","Avg ms","Max ms"');
|
|
1334
|
+
mostFrequent.forEach((r, i) =>
|
|
1335
|
+
lines.push(`"${i + 1}","${r.label.replace(/"/g, '""')}","${r.count}","${r.avg.toFixed(2)}","${r.max.toFixed(2)}"`)
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
return lines.join('\r\n');
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
const downloadFile = (content, mimeType, filename) => {
|
|
1342
|
+
const blob = new Blob([content], { type: mimeType });
|
|
1343
|
+
const url = URL.createObjectURL(blob);
|
|
1344
|
+
const a = document.createElement('a');
|
|
1345
|
+
a.href = url;
|
|
1346
|
+
a.download = filename;
|
|
1347
|
+
document.body.appendChild(a);
|
|
1348
|
+
a.click();
|
|
1349
|
+
document.body.removeChild(a);
|
|
1350
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
const generateReport = () => {
|
|
1354
|
+
const id = state.selectedDebuggerId;
|
|
1355
|
+
if (!id) { alert('Select a debugger first'); return; }
|
|
1356
|
+
|
|
1357
|
+
const allPoints = state.perfpointsByDebugger.get(id) || [];
|
|
1358
|
+
if (allPoints.length === 0) { alert('No PerfPoints to report'); return; }
|
|
1359
|
+
|
|
1360
|
+
const contextFilter = tlContextFilterEl.value;
|
|
1361
|
+
const labelFilters = state.tlLabelFilters;
|
|
1362
|
+
const period = reportPeriodEl.value;
|
|
1363
|
+
const format = reportFormatEl.value;
|
|
1364
|
+
|
|
1365
|
+
let points = contextFilter === 'all'
|
|
1366
|
+
? allPoints
|
|
1367
|
+
: allPoints.filter(p => p.contextId === contextFilter);
|
|
1368
|
+
|
|
1369
|
+
if (labelFilters.size > 0) {
|
|
1370
|
+
points = points.filter(p => {
|
|
1371
|
+
const pKey = (p.badge ? p.badge + ':' : '') + p.label;
|
|
1372
|
+
return [...labelFilters].some(f => pKey.toLowerCase().includes(f.toLowerCase()));
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (period !== 'all') {
|
|
1377
|
+
const sorted = [...points].sort((a, b) => new Date(a.startTs) - new Date(b.startTs));
|
|
1378
|
+
if (sorted.length > 0) {
|
|
1379
|
+
const cutoff = new Date(sorted[0].startTs).getTime() + Number(period) * 1000;
|
|
1380
|
+
points = points.filter(p => new Date(p.startTs).getTime() <= cutoff);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (points.length === 0) { alert('No data for the selected period / context'); return; }
|
|
1385
|
+
|
|
1386
|
+
const dbg = state.debuggers.get(id) ?? null;
|
|
1387
|
+
const data = buildReportData(points, dbg, contextFilter, period);
|
|
1388
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
1389
|
+
|
|
1390
|
+
if (format === 'html') {
|
|
1391
|
+
const html = buildHtmlReport(data);
|
|
1392
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
1393
|
+
window.open(URL.createObjectURL(blob), '_blank');
|
|
1394
|
+
} else if (format === 'csv') {
|
|
1395
|
+
downloadFile(buildCsvReport(data), 'text/csv;charset=utf-8', `perf-report-${ts}.csv`);
|
|
1396
|
+
} else if (format === 'json') {
|
|
1397
|
+
downloadFile(JSON.stringify(data, null, 2), 'application/json', `perf-report-${ts}.json`);
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
reportBtnEl.addEventListener('click', () => {
|
|
1402
|
+
generateReport();
|
|
1403
|
+
document.getElementById('tl-report-menu')?.classList.add('hidden');
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// ── Dropdown toggles ──
|
|
1407
|
+
const toggleDropdown = (menuId) => {
|
|
1408
|
+
const menu = document.getElementById(menuId);
|
|
1409
|
+
if (!menu) return;
|
|
1410
|
+
// Close all other dropdowns first
|
|
1411
|
+
document.querySelectorAll('.tl-dropdown-menu').forEach(m => {
|
|
1412
|
+
if (m.id !== menuId) m.classList.add('hidden');
|
|
1413
|
+
});
|
|
1414
|
+
menu.classList.toggle('hidden');
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
document.getElementById('tl-legend-btn')?.addEventListener('click', (e) => {
|
|
1418
|
+
e.stopPropagation();
|
|
1419
|
+
toggleDropdown('tl-legend-popup');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
document.getElementById('tl-report-btn')?.addEventListener('click', (e) => {
|
|
1423
|
+
e.stopPropagation();
|
|
1424
|
+
toggleDropdown('tl-report-menu');
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// ── Theme toggle ──
|
|
1428
|
+
const themeToggleEl = document.getElementById('theme-toggle');
|
|
1429
|
+
const applyTheme = (dark) => {
|
|
1430
|
+
document.documentElement.setAttribute('data-theme', dark ? 'dark' : '');
|
|
1431
|
+
themeToggleEl.textContent = dark ? '☀️' : '🌙';
|
|
1432
|
+
try { localStorage.setItem('prism-theme', dark ? 'dark' : 'light'); } catch {}
|
|
1433
|
+
};
|
|
1434
|
+
themeToggleEl.addEventListener('click', () => {
|
|
1435
|
+
applyTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
|
|
1436
|
+
});
|
|
1437
|
+
// Restore saved theme
|
|
1438
|
+
try {
|
|
1439
|
+
const saved = localStorage.getItem('prism-theme');
|
|
1440
|
+
if (saved === 'dark') applyTheme(true);
|
|
1441
|
+
else if (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) applyTheme(true);
|
|
1442
|
+
} catch {}
|
|
1443
|
+
|
|
1444
|
+
// ── Send command ──
|
|
1445
|
+
sendBtnEl.addEventListener('click', () => {
|
|
1446
|
+
if (!state.selectedDebuggerId) {
|
|
1447
|
+
alert('Select debugger first');
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
let payload;
|
|
1451
|
+
try {
|
|
1452
|
+
payload = JSON.parse(payloadEl.value || '{}');
|
|
1453
|
+
} catch {
|
|
1454
|
+
alert('Payload must be valid JSON');
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
socket.send(JSON.stringify({
|
|
1458
|
+
action: 'send',
|
|
1459
|
+
debuggerId: state.selectedDebuggerId,
|
|
1460
|
+
eventName: eventNameEl.value || 'command.execute',
|
|
1461
|
+
payload
|
|
1462
|
+
}));
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
// ── Connect modal ──
|
|
1466
|
+
const connectBtnEl = document.getElementById('connect-btn');
|
|
1467
|
+
const connectModalEl = document.getElementById('connect-modal');
|
|
1468
|
+
const connectModalClose = document.getElementById('connect-modal-close');
|
|
1469
|
+
const connectQrEl = document.getElementById('connect-qr');
|
|
1470
|
+
const connectUrlEl = document.getElementById('connect-url');
|
|
1471
|
+
const connectCopyEl = document.getElementById('connect-copy');
|
|
1472
|
+
|
|
1473
|
+
function renderConnectUrl(wsLink) {
|
|
1474
|
+
const url = `http://prism.connect/?wslink=${encodeURIComponent(wsLink)}`;
|
|
1475
|
+
connectUrlEl.value = url;
|
|
1476
|
+
|
|
1477
|
+
connectQrEl.innerHTML = '';
|
|
1478
|
+
if (typeof qrcode !== 'undefined') {
|
|
1479
|
+
const qr = qrcode(0, 'M');
|
|
1480
|
+
qr.addData(url);
|
|
1481
|
+
qr.make();
|
|
1482
|
+
connectQrEl.innerHTML = qr.createImgTag(5, 8);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function showConnectModal() {
|
|
1487
|
+
connectModalEl.classList.remove('hidden');
|
|
1488
|
+
connectQrEl.innerHTML = '<span class="muted">Loading...</span>';
|
|
1489
|
+
connectUrlEl.value = '';
|
|
1490
|
+
|
|
1491
|
+
fetch('/api/connect-info')
|
|
1492
|
+
.then(r => r.json())
|
|
1493
|
+
.then(data => renderConnectUrl(data.wsLink))
|
|
1494
|
+
.catch(() => {
|
|
1495
|
+
const fallback = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
|
|
1496
|
+
renderConnectUrl(fallback);
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function hideConnectModal() {
|
|
1501
|
+
connectModalEl.classList.add('hidden');
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
connectBtnEl.addEventListener('click', showConnectModal);
|
|
1505
|
+
connectModalClose.addEventListener('click', hideConnectModal);
|
|
1506
|
+
connectModalEl.addEventListener('click', (e) => {
|
|
1507
|
+
if (e.target === connectModalEl) hideConnectModal();
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
connectCopyEl.addEventListener('click', () => {
|
|
1511
|
+
navigator.clipboard.writeText(connectUrlEl.value).then(() => {
|
|
1512
|
+
const prev = connectCopyEl.textContent;
|
|
1513
|
+
connectCopyEl.textContent = 'Copied!';
|
|
1514
|
+
setTimeout(() => { connectCopyEl.textContent = prev; }, 1500);
|
|
1515
|
+
});
|
|
1516
|
+
});
|