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/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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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
+ });