nestjs-profiler 1.0.24 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +167 -5
  2. package/collectors/event-collector.d.ts +18 -0
  3. package/collectors/event-collector.js +254 -0
  4. package/collectors/event-collector.js.map +1 -0
  5. package/common/profiler-options.interface.d.ts +8 -0
  6. package/common/profiler.model.d.ts +25 -0
  7. package/controllers/profiler.controller.d.ts +22 -1
  8. package/controllers/profiler.controller.js +161 -15
  9. package/controllers/profiler.controller.js.map +1 -1
  10. package/middleware/profiler-auth.middleware.d.ts +10 -0
  11. package/middleware/profiler-auth.middleware.js +110 -0
  12. package/middleware/profiler-auth.middleware.js.map +1 -0
  13. package/package.json +2 -2
  14. package/profiler.module.js +82 -0
  15. package/profiler.module.js.map +1 -1
  16. package/services/code-quality.service.d.ts +65 -0
  17. package/services/code-quality.service.js +216 -0
  18. package/services/code-quality.service.js.map +1 -0
  19. package/services/entity-explorer.service.d.ts +1 -1
  20. package/services/entity-explorer.service.js +7 -7
  21. package/services/entity-explorer.service.js.map +1 -1
  22. package/services/health.service.d.ts +59 -0
  23. package/services/health.service.js +190 -0
  24. package/services/health.service.js.map +1 -0
  25. package/services/profiler.service.d.ts +10 -1
  26. package/services/profiler.service.js +103 -35
  27. package/services/profiler.service.js.map +1 -1
  28. package/services/route-explorer.service.d.ts +23 -1
  29. package/services/route-explorer.service.js +136 -10
  30. package/services/route-explorer.service.js.map +1 -1
  31. package/services/template-builder.service.d.ts +3 -0
  32. package/services/template-builder.service.js +191 -84
  33. package/services/template-builder.service.js.map +1 -1
  34. package/services/view.service.d.ts +1 -1
  35. package/services/view.service.js +34 -23
  36. package/services/view.service.js.map +1 -1
  37. package/views/code-quality.html +573 -0
  38. package/views/events.html +763 -0
  39. package/views/health.html +468 -0
  40. package/views/layout.html +50 -10
  41. package/views/login.html +277 -0
  42. package/views/routes.html +405 -37
@@ -0,0 +1,763 @@
1
+ <style>
2
+ /* Tree connector lines */
3
+ .ev-children { margin-left: 1.5rem; border-left: 2px solid #e2e8f0; padding-left: 0; }
4
+ .ev-branch { position: relative; }
5
+ .ev-branch::before {
6
+ content: '';
7
+ position: absolute;
8
+ left: -1rem; top: 1.1rem;
9
+ width: 1rem;
10
+ border-top: 2px solid #e2e8f0;
11
+ }
12
+ /* Copy button */
13
+ .ev-copy-btn {
14
+ display: inline-flex; align-items: center; justify-content: center;
15
+ width: 26px; height: 26px; border-radius: 6px;
16
+ border: 1px solid transparent;
17
+ color: #9ca3af; cursor: pointer; background: transparent;
18
+ transition: color .15s, background .15s, border-color .15s;
19
+ flex-shrink: 0;
20
+ }
21
+ .ev-copy-btn:hover { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
22
+ .ev-copy-btn.copied { color: #16a34a; background: #f0fdf4; border-color: #bbf7d0; }
23
+ </style>
24
+
25
+ <div id="evRoot">
26
+
27
+ <!-- Header -->
28
+ <div class="mb-6 flex items-start justify-between">
29
+ <div>
30
+ <h1 class="text-2xl font-bold text-gray-900">Event Tracking</h1>
31
+ <p class="mt-1 text-sm text-gray-500">Captured <code class="bg-gray-100 px-1 py-0.5 rounded text-xs">EventEmitter2</code> emissions with listener timing and cascading chains.</p>
32
+ </div>
33
+ <div class="flex items-center gap-2 ml-4 mt-1">
34
+ <div id="evFilterWrap" class="hidden flex items-center gap-2">
35
+ <input id="evFilter" type="text" placeholder="Search event…"
36
+ class="border border-gray-300 rounded-md px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 w-44"
37
+ oninput="renderFilteredEvents()" />
38
+ <select id="evStatusFilter" onchange="renderFilteredEvents()"
39
+ class="border border-gray-300 rounded-md px-2 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
40
+ <option value="">All</option>
41
+ <option value="success">Success</option>
42
+ <option value="error">Error</option>
43
+ </select>
44
+ </div>
45
+ <button id="evAutoBtn" onclick="toggleAuto()"
46
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium border border-gray-300 bg-white text-gray-700 rounded-md hover:bg-gray-50 transition-colors">
47
+ <span id="evAutoDot" class="w-2 h-2 rounded-full bg-gray-300"></span>
48
+ Auto
49
+ </button>
50
+ <button onclick="loadEvents(true)"
51
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-md transition-colors">
52
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
53
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
54
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
55
+ </svg>
56
+ Refresh
57
+ </button>
58
+ </div>
59
+ </div>
60
+
61
+ <!-- Stats strip -->
62
+ <div id="evStats" class="hidden grid grid-cols-4 gap-4 mb-6">
63
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
64
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Events</p>
65
+ <p id="statTotal" class="mt-1 text-3xl font-bold text-gray-900">—</p>
66
+ </div>
67
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
68
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Root Chains</p>
69
+ <p id="statRoot" class="mt-1 text-3xl font-bold text-gray-900">—</p>
70
+ </div>
71
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
72
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Errors</p>
73
+ <p id="statErrors" class="mt-1 text-3xl font-bold text-red-600">—</p>
74
+ </div>
75
+ <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
76
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Avg Duration</p>
77
+ <p id="statAvg" class="mt-1 text-3xl font-bold text-gray-900">—</p>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Idle -->
82
+ <div id="evIdle" class="bg-white rounded-xl border border-gray-200 shadow-sm p-10">
83
+ <div class="max-w-md mx-auto text-center">
84
+ <div class="flex justify-center mb-5">
85
+ <div class="w-14 h-14 rounded-full bg-indigo-50 flex items-center justify-center">
86
+ <svg class="w-7 h-7 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
87
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
88
+ d="M3 18v-6a9 9 0 0 1 18 0v6M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
89
+ </svg>
90
+ </div>
91
+ </div>
92
+ <h2 class="text-lg font-semibold text-gray-800 mb-1">No events captured yet</h2>
93
+ <p class="text-sm text-gray-500 mb-6">Events fired via <code class="bg-gray-100 px-1 rounded">EventEmitter2</code> will appear here automatically.</p>
94
+ <button onclick="loadEvents(true)"
95
+ class="px-4 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors">
96
+ Check for events
97
+ </button>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Loading -->
102
+ <div id="evLoading" class="hidden bg-white rounded-xl border border-gray-200 shadow-sm p-10">
103
+ <div class="max-w-md mx-auto text-center">
104
+ <div class="flex justify-center mb-5">
105
+ <div class="w-14 h-14 rounded-full bg-indigo-50 flex items-center justify-center">
106
+ <svg class="w-7 h-7 text-indigo-500 animate-spin" fill="none" viewBox="0 0 24 24">
107
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
108
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
109
+ </svg>
110
+ </div>
111
+ </div>
112
+ <h2 class="text-lg font-semibold text-gray-800">Loading events…</h2>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Error -->
117
+ <div id="evError" class="hidden bg-red-50 border border-red-200 rounded-xl p-8 text-center">
118
+ <svg class="w-10 h-10 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
119
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
120
+ </svg>
121
+ <p class="text-red-700 font-semibold mb-1">Failed to load events</p>
122
+ <p id="evErrorMsg" class="text-red-500 text-sm mb-4"></p>
123
+ <button onclick="loadEvents(true)" class="px-4 py-2 text-sm font-medium rounded-lg bg-red-100 hover:bg-red-200 text-red-700">Try again</button>
124
+ </div>
125
+
126
+ <!-- Results -->
127
+ <div id="evResults" class="hidden">
128
+ <div id="evTreeContainer" class="space-y-4"></div>
129
+ </div>
130
+
131
+ </div>
132
+
133
+ <!-- ── Flow Modal ─────────────────────────────────────────────────────────── -->
134
+ <div id="evFlowModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
135
+ <!-- Backdrop -->
136
+ <div class="absolute inset-0 bg-black/50 backdrop-blur-sm" onclick="closeFlowModal()"></div>
137
+ <!-- Panel -->
138
+ <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[88vh] flex flex-col">
139
+ <!-- Modal header -->
140
+ <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 flex-shrink-0">
141
+ <div class="flex items-center gap-3">
142
+ <div class="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center flex-shrink-0">
143
+ <svg class="w-4 h-4 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
144
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
145
+ d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
146
+ </svg>
147
+ </div>
148
+ <h2 id="evFlowTitle" class="text-base font-semibold text-gray-900">Event Flow</h2>
149
+ </div>
150
+ <div class="flex items-center gap-1">
151
+ <!-- Expand / Shrink toggle -->
152
+ <button id="evFlowExpandBtn" onclick="toggleFlowExpand()"
153
+ class="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
154
+ title="Expand to full screen">
155
+ <!-- Expand icon (shown when normal) -->
156
+ <svg id="evFlowExpandIcon" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
157
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
158
+ d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
159
+ </svg>
160
+ <!-- Shrink icon (shown when expanded) -->
161
+ <svg id="evFlowShrinkIcon" class="w-5 h-5 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
162
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
163
+ d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/>
164
+ </svg>
165
+ </button>
166
+ <button onclick="closeFlowModal()"
167
+ class="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"
168
+ title="Close">
169
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
170
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
171
+ </svg>
172
+ </button>
173
+ </div>
174
+ </div>
175
+ <!-- Modal content -->
176
+ <div id="evFlowContent" class="flex-1 overflow-auto p-6 min-h-0 bg-gray-50"></div>
177
+ </div>
178
+ </div>
179
+
180
+ <script>
181
+ (function () {
182
+ let allEvents = [];
183
+ let autoTimer = null;
184
+ let autoEnabled = false;
185
+ if (!window.__evCache) window.__evCache = null;
186
+ if (!window.__evAbort) window.__evAbort = null;
187
+
188
+ // ── State helpers ──────────────────────────────────────────────────────────
189
+ function setState(s) {
190
+ ['evIdle','evLoading','evError','evResults'].forEach(id => {
191
+ const el = document.getElementById(id);
192
+ if (el) el.classList.toggle('hidden', id !== s);
193
+ });
194
+ const stats = document.getElementById('evStats');
195
+ if (stats) stats.classList.toggle('hidden', s !== 'evResults');
196
+ const fw = document.getElementById('evFilterWrap');
197
+ if (fw) fw.classList.toggle('hidden', s !== 'evResults');
198
+ }
199
+
200
+ // ── Data loading ───────────────────────────────────────────────────────────
201
+ async function loadEvents(force) {
202
+ if (!force && window.__evCache) { allEvents = window.__evCache; return renderAll(); }
203
+ if (window.__evAbort) window.__evAbort.abort();
204
+ window.__evAbort = new AbortController();
205
+ setState('evLoading');
206
+ try {
207
+ const res = await fetch('/__profiler/api/events', { signal: window.__evAbort.signal });
208
+ if (!res.ok) throw new Error('HTTP ' + res.status);
209
+ allEvents = await res.json();
210
+ window.__evCache = allEvents;
211
+ renderAll();
212
+ } catch(e) {
213
+ if (e.name === 'AbortError') return;
214
+ document.getElementById('evErrorMsg').textContent = e.message;
215
+ setState('evError');
216
+ }
217
+ }
218
+
219
+ function renderAll() {
220
+ updateStats(allEvents);
221
+ renderFilteredEvents();
222
+ }
223
+
224
+ function renderFilteredEvents() {
225
+ const filter = (document.getElementById('evFilter')?.value || '').toLowerCase();
226
+ const statusFilter = document.getElementById('evStatusFilter')?.value || '';
227
+ const byId = {};
228
+ allEvents.forEach(e => byId[e.id] = e);
229
+ let roots = allEvents.filter(e => !e.parentEventId);
230
+ if (roots.length === 0 && allEvents.length === 0) return setState('evIdle');
231
+ if (filter || statusFilter) {
232
+ const matches = allEvents.filter(e =>
233
+ (!filter || e.eventName.toLowerCase().includes(filter)) &&
234
+ (!statusFilter || e.status === statusFilter)
235
+ );
236
+ if (matches.length === 0) { renderEmpty('No events match your filter.'); return; }
237
+ renderList(matches, byId);
238
+ return;
239
+ }
240
+ if (roots.length === 0) return setState('evIdle');
241
+ roots.sort((a, b) => b.emittedAt - a.emittedAt);
242
+ const container = document.getElementById('evTreeContainer');
243
+ container.innerHTML = '';
244
+ roots.forEach(root => container.appendChild(buildTree(root, byId)));
245
+ setState('evResults');
246
+ }
247
+
248
+ function renderEmpty(msg) {
249
+ const c = document.getElementById('evTreeContainer');
250
+ c.innerHTML = `<div class="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-500 text-sm">${msg}</div>`;
251
+ setState('evResults');
252
+ }
253
+
254
+ function renderList(events, byId) {
255
+ const c = document.getElementById('evTreeContainer');
256
+ c.innerHTML = '';
257
+ events.sort((a, b) => b.emittedAt - a.emittedAt);
258
+ events.forEach(e => c.appendChild(buildEventCard(e, byId)));
259
+ setState('evResults');
260
+ }
261
+
262
+ // ── Tree builder ───────────────────────────────────────────────────────────
263
+ function buildTree(ev, byId) {
264
+ const wrap = document.createElement('div');
265
+ const card = buildEventCard(ev, byId);
266
+ wrap.appendChild(card);
267
+
268
+ if (ev.childEventIds && ev.childEventIds.length > 0) {
269
+ const children = document.createElement('div');
270
+ children.className = 'ev-children mt-2';
271
+ ev.childEventIds.forEach(childId => {
272
+ const child = byId[childId];
273
+ if (!child) return;
274
+ const branch = document.createElement('div');
275
+ branch.className = 'ev-branch mt-2';
276
+ branch.appendChild(buildTree(child, byId));
277
+ children.appendChild(branch);
278
+ });
279
+ wrap.appendChild(children);
280
+
281
+ // Collapse children together with the parent card
282
+ const header = card.firstElementChild;
283
+ header.addEventListener('click', () => {
284
+ children.classList.toggle('hidden', card.dataset.collapsed === 'true');
285
+ });
286
+ }
287
+ return wrap;
288
+ }
289
+
290
+ // ── Copy helper ────────────────────────────────────────────────────────────
291
+ function makeCopyBtn(text, title) {
292
+ const btn = document.createElement('button');
293
+ btn.className = 'ev-copy-btn';
294
+ btn.title = title || 'Copy';
295
+ btn.innerHTML = `<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
296
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
297
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
298
+ </svg>`;
299
+ btn.addEventListener('click', e => {
300
+ e.stopPropagation();
301
+ navigator.clipboard.writeText(text || '').then(() => {
302
+ btn.classList.add('copied');
303
+ btn.innerHTML = `<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
304
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
305
+ </svg>`;
306
+ setTimeout(() => {
307
+ btn.classList.remove('copied');
308
+ btn.innerHTML = `<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
309
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
310
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
311
+ </svg>`;
312
+ }, 1500);
313
+ }).catch(() => {});
314
+ });
315
+ return btn;
316
+ }
317
+
318
+ // ── Headset SVG icon ───────────────────────────────────────────────────────
319
+ const HEADSET_SVG = `<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
320
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
321
+ d="M3 18v-6a9 9 0 0 1 18 0v6M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
322
+ </svg>`;
323
+
324
+ // ── Event card builder ─────────────────────────────────────────────────────
325
+ function buildEventCard(ev, byId) {
326
+ const isErr = ev.status === 'error';
327
+ const isPending = ev.status === 'pending';
328
+
329
+ const card = document.createElement('div');
330
+ const borderColor = isErr ? 'border-red-200' : isPending ? 'border-yellow-200' : 'border-gray-200';
331
+ card.className = `bg-white rounded-xl border ${borderColor} shadow-sm overflow-hidden`;
332
+
333
+ // ── Header ──────────────────────────────────────────────────────────────
334
+ const headerBg = isErr ? 'bg-red-50' : isPending ? 'bg-yellow-50' : 'bg-white';
335
+ const header = document.createElement('div');
336
+ header.className = `${headerBg} px-4 py-3 flex items-center gap-2 cursor-pointer select-none border-b border-gray-100`;
337
+
338
+ const statusIcon = isErr
339
+ ? `<span class="flex-shrink-0 w-6 h-6 rounded-full bg-red-100 flex items-center justify-center">
340
+ <svg class="w-3.5 h-3.5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
341
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
342
+ </svg></span>`
343
+ : isPending
344
+ ? `<span class="flex-shrink-0 w-6 h-6 rounded-full bg-yellow-100 flex items-center justify-center">
345
+ <svg class="w-3.5 h-3.5 text-yellow-600 animate-spin" fill="none" viewBox="0 0 24 24">
346
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
347
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
348
+ </svg></span>`
349
+ : `<span class="flex-shrink-0 w-6 h-6 rounded-full bg-green-100 flex items-center justify-center">
350
+ <svg class="w-3.5 h-3.5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
351
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
352
+ </svg></span>`;
353
+
354
+ const asyncBadge = ev.isAsync
355
+ ? `<span class="text-xs px-2 py-0.5 rounded-full bg-violet-100 text-violet-700 font-medium">async</span>`
356
+ : `<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 font-medium">sync</span>`;
357
+
358
+ // "event" type tag
359
+ const typeBadge = `<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 font-semibold uppercase tracking-wide">event</span>`;
360
+
361
+ const depthBadge = ev.depth > 0
362
+ ? `<span class="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">↳ depth ${ev.depth}</span>`
363
+ : '';
364
+
365
+ const durText = ev.totalDuration
366
+ ? `<span class="text-xs font-mono ${ev.totalDuration > 500 ? 'text-red-600' : ev.totalDuration > 100 ? 'text-amber-600' : 'text-gray-400'}">${ev.totalDuration}ms</span>`
367
+ : '';
368
+
369
+ const time = new Date(ev.emittedAt).toLocaleTimeString('en', {
370
+ hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3
371
+ });
372
+
373
+ header.innerHTML = `
374
+ ${statusIcon}
375
+ <div class="flex-1 min-w-0">
376
+ <div class="flex items-center gap-2 flex-wrap">
377
+ ${typeBadge}
378
+ <span class="font-semibold text-gray-900 text-sm">🔔 ${escHtml(ev.eventName)}</span>
379
+ ${asyncBadge}${depthBadge}
380
+ ${ev.childEventIds?.length ? `<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium">↳ ${ev.childEventIds.length} child${ev.childEventIds.length > 1 ? 's' : ''}</span>` : ''}
381
+ <span class="ml-auto flex items-center gap-1">${durText}</span>
382
+ </div>
383
+ <div class="flex items-center gap-4 mt-0.5 text-xs text-gray-400">
384
+ <span title="${escHtml(ev.emitterFile || '')}">⬆ <span class="font-mono">${escHtml(ev.emitterLocation)}</span></span>
385
+ <span>${time}</span>
386
+ <span>${(ev.listeners || []).length} listener${ev.listeners?.length !== 1 ? 's' : ''}</span>
387
+ </div>
388
+ </div>
389
+ <svg id="chevron-${ev.id}" class="w-4 h-4 text-gray-400 flex-shrink-0 transition-transform duration-150" fill="none" viewBox="0 0 24 24" stroke="currentColor">
390
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
391
+ </svg>
392
+ `;
393
+
394
+ // Copy button (emitter file, falls back to location)
395
+ const copyText = ev.emitterFile || ev.emitterLocation || '';
396
+ const copyBtn = makeCopyBtn(copyText, copyText ? `Copy: ${copyText}` : 'Copy location');
397
+ copyBtn.style.marginRight = '4px';
398
+
399
+ // Show button → flowchart modal
400
+ const showBtn = document.createElement('button');
401
+ showBtn.className = 'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-indigo-200 bg-indigo-50 text-indigo-700 rounded-md hover:bg-indigo-100 transition-colors flex-shrink-0';
402
+ showBtn.innerHTML = `<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
403
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/>
404
+ </svg>Show`;
405
+ showBtn.addEventListener('click', e => { e.stopPropagation(); showFlowModal(ev.id); });
406
+
407
+ // Insert copy + show buttons before the chevron
408
+ const chevron = header.querySelector('#chevron-' + ev.id);
409
+ header.insertBefore(showBtn, chevron);
410
+ header.insertBefore(copyBtn, showBtn);
411
+
412
+ // ── Body ────────────────────────────────────────────────────────────────
413
+ const body = document.createElement('div');
414
+ body.className = 'divide-y divide-gray-50';
415
+
416
+ // Payload
417
+ if (ev.payloadPreview) {
418
+ const p = document.createElement('div');
419
+ p.className = 'px-5 py-3';
420
+ p.innerHTML = `
421
+ <p class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1.5">Payload</p>
422
+ <pre class="text-xs font-mono text-emerald-700 bg-emerald-50 rounded-lg px-3 py-2 overflow-x-auto whitespace-pre-wrap break-all">${escHtml(ev.payloadPreview)}</pre>`;
423
+ body.appendChild(p);
424
+ }
425
+
426
+ // Error
427
+ if (ev.error) {
428
+ const e = document.createElement('div');
429
+ e.className = 'px-5 py-3';
430
+ e.innerHTML = `
431
+ <p class="text-xs font-medium text-red-400 uppercase tracking-wide mb-1.5">Error</p>
432
+ <pre class="text-xs font-mono text-red-700 bg-red-50 rounded-lg px-3 py-2 overflow-x-auto">${escHtml(ev.error)}</pre>`;
433
+ body.appendChild(e);
434
+ }
435
+
436
+ // Listeners
437
+ if (ev.listeners && ev.listeners.length > 0) {
438
+ const lw = document.createElement('div');
439
+ lw.className = 'px-5 py-3';
440
+ lw.innerHTML = `<p class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">Listeners</p>`;
441
+
442
+ const list = document.createElement('div');
443
+ list.className = 'space-y-1.5 ml-2 border-l-2 border-gray-100 pl-4';
444
+
445
+ ev.listeners.forEach(l => {
446
+ const lIsErr = l.status === 'error';
447
+ const durColor = l.duration > 200 ? 'text-red-600' : l.duration > 50 ? 'text-amber-600' : 'text-green-600';
448
+
449
+ // Headset status icon
450
+ const headsetColor = lIsErr ? 'text-red-500' : 'text-indigo-500';
451
+ const headsetBg = lIsErr ? 'bg-red-50 border-red-100' : 'bg-indigo-50 border-indigo-100';
452
+
453
+ // "listener" type tag
454
+ const lTypeBadge = `<span class="text-xs px-1.5 py-0.5 rounded-full bg-violet-100 text-violet-700 font-semibold uppercase tracking-wide" style="font-size:10px">listener</span>`;
455
+
456
+ const row = document.createElement('div');
457
+ row.className = `flex items-center gap-2 px-3 py-1.5 rounded-lg border ${lIsErr ? 'border-red-100 bg-red-50' : 'border-gray-100 bg-gray-50'} text-sm`;
458
+
459
+ // Headset icon wrapper
460
+ const iconWrap = document.createElement('span');
461
+ iconWrap.className = `flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center ${headsetBg} ${headsetColor}`;
462
+ iconWrap.innerHTML = HEADSET_SVG;
463
+
464
+ row.appendChild(iconWrap);
465
+ row.insertAdjacentHTML('beforeend', `
466
+ ${lTypeBadge}
467
+ <span class="font-mono text-xs text-gray-700 truncate" title="${escHtml(l.name)}">${escHtml(l.name)}</span>
468
+ <span class="ml-auto font-mono text-xs ${durColor} flex-shrink-0">${l.duration}ms</span>
469
+ ${l.error ? `<span class="text-xs text-red-500 truncate max-w-xs flex-shrink-0" title="${escHtml(l.error)}">${escHtml(l.error)}</span>` : ''}
470
+ `);
471
+
472
+ // Copy button: copies file path (preferred) or name
473
+ const lCopyText = l.file || l.name || '';
474
+ row.appendChild(makeCopyBtn(lCopyText, lCopyText ? `Copy: ${lCopyText}` : 'Copy name'));
475
+
476
+ list.appendChild(row);
477
+ });
478
+
479
+ lw.appendChild(list);
480
+ body.appendChild(lw);
481
+ }
482
+
483
+ card.appendChild(header);
484
+ card.appendChild(body);
485
+
486
+ // Toggle collapse — also expose state via data-collapsed so buildTree
487
+ // can sync the children container on the same click.
488
+ card.dataset.collapsed = 'false';
489
+ let collapsed = false;
490
+ header.addEventListener('click', () => {
491
+ collapsed = !collapsed;
492
+ card.dataset.collapsed = String(collapsed);
493
+ body.classList.toggle('hidden', collapsed);
494
+ const ch = document.getElementById('chevron-' + ev.id);
495
+ if (ch) ch.style.transform = collapsed ? 'rotate(-90deg)' : '';
496
+ });
497
+
498
+ return card;
499
+ }
500
+
501
+ // ── Stats ──────────────────────────────────────────────────────────────────
502
+ function updateStats(events) {
503
+ const roots = events.filter(e => !e.parentEventId).length;
504
+ const errors = events.filter(e => e.status === 'error').length;
505
+ const done = events.filter(e => e.totalDuration > 0);
506
+ const avg = done.length ? Math.round(done.reduce((s,e) => s + e.totalDuration, 0) / done.length) : 0;
507
+ setText('statTotal', events.length);
508
+ setText('statRoot', roots);
509
+ setText('statErrors', errors);
510
+ setText('statAvg', avg ? avg + 'ms' : '—');
511
+ }
512
+
513
+ function setText(id, val) {
514
+ const el = document.getElementById(id);
515
+ if (el) el.textContent = val;
516
+ }
517
+
518
+ function escHtml(str) {
519
+ return String(str ?? '')
520
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
521
+ }
522
+
523
+ // ── Auto-refresh ───────────────────────────────────────────────────────────
524
+ function toggleAuto() {
525
+ autoEnabled = !autoEnabled;
526
+ const dot = document.getElementById('evAutoDot');
527
+ const btn = document.getElementById('evAutoBtn');
528
+ if (autoEnabled) {
529
+ if (dot) dot.className = 'w-2 h-2 rounded-full bg-green-500 animate-pulse';
530
+ if (btn) { btn.classList.add('border-green-400','text-green-700'); btn.classList.remove('border-gray-300','text-gray-700'); }
531
+ autoTimer = setInterval(() => loadEvents(true), 3000);
532
+ } else {
533
+ if (dot) dot.className = 'w-2 h-2 rounded-full bg-gray-300';
534
+ if (btn) { btn.classList.remove('border-green-400','text-green-700'); btn.classList.add('border-gray-300','text-gray-700'); }
535
+ clearInterval(autoTimer);
536
+ }
537
+ }
538
+
539
+ // ════════════════════════════════════════════════════════════════════════════
540
+ // FLOWCHART MODAL
541
+ // ════════════════════════════════════════════════════════════════════════════
542
+
543
+ function toggleFlowExpand() {
544
+ const panel = document.querySelector('#evFlowModal > div.relative');
545
+ const expandIcon = document.getElementById('evFlowExpandIcon');
546
+ const shrinkIcon = document.getElementById('evFlowShrinkIcon');
547
+ const btn = document.getElementById('evFlowExpandBtn');
548
+ const isExpanded = panel.dataset.expanded === 'true';
549
+
550
+ if (!isExpanded) {
551
+ // Go fullscreen
552
+ panel.dataset.expanded = 'true';
553
+ panel.classList.remove('max-w-5xl', 'max-h-[88vh]', 'rounded-2xl');
554
+ panel.classList.add('!max-w-none', '!max-h-none', 'w-screen', 'h-screen', 'rounded-none');
555
+ expandIcon.classList.add('hidden');
556
+ shrinkIcon.classList.remove('hidden');
557
+ btn.title = 'Exit full screen';
558
+ } else {
559
+ // Restore
560
+ panel.dataset.expanded = 'false';
561
+ panel.classList.add('max-w-5xl', 'max-h-[88vh]', 'rounded-2xl');
562
+ panel.classList.remove('!max-w-none', '!max-h-none', 'w-screen', 'h-screen', 'rounded-none');
563
+ expandIcon.classList.remove('hidden');
564
+ shrinkIcon.classList.add('hidden');
565
+ btn.title = 'Expand to full screen';
566
+ }
567
+ }
568
+
569
+ function closeFlowModal() {
570
+ // Reset expanded state before hiding
571
+ const panel = document.querySelector('#evFlowModal > div.relative');
572
+ if (panel && panel.dataset.expanded === 'true') toggleFlowExpand();
573
+ const m = document.getElementById('evFlowModal');
574
+ if (m) m.classList.add('hidden');
575
+ }
576
+
577
+ function showFlowModal(evId) {
578
+ const byId = {};
579
+ allEvents.forEach(e => byId[e.id] = e);
580
+ const rootEv = byId[evId];
581
+ if (!rootEv) return;
582
+
583
+ const title = document.getElementById('evFlowTitle');
584
+ const content = document.getElementById('evFlowContent');
585
+ if (title) title.textContent = 'Flow: ' + rootEv.eventName;
586
+ if (content) content.innerHTML = buildFlowSVG(rootEv, byId);
587
+
588
+ const modal = document.getElementById('evFlowModal');
589
+ if (modal) modal.classList.remove('hidden');
590
+ }
591
+
592
+ // ── Layout constants ───────────────────────────────────────────────────────
593
+ const FC = {
594
+ EW: 220, EH: 80, // event node width / height
595
+ LW: 240, LH: 78, // listener node width / height
596
+ HGAP: 18, // gap between sibling columns
597
+ VGAP: 48, // gap between levels
598
+ PAD: 24, // canvas padding
599
+ };
600
+
601
+ // ── Build tree structure ───────────────────────────────────────────────────
602
+ // Returns {type:'event', ev, children:[listenerNode...]}
603
+ // listenerNode: {type:'listener', listener, index, child: eventNode|null}
604
+ function buildFlowTree(ev, byId) {
605
+ const listeners = (ev.listeners || []).map((l, i) => {
606
+ const lNode = { type: 'listener', listener: l, index: i, child: null };
607
+
608
+ // Match a child event to this listener via timestamp overlap
609
+ if (ev.childEventIds && ev.childEventIds.length > 0 && l.startTime > 0) {
610
+ const childEv = ev.childEventIds
611
+ .map(id => byId[id]).filter(Boolean)
612
+ .find(child =>
613
+ child.emittedAt >= l.startTime &&
614
+ child.emittedAt <= (l.startTime + l.duration + 50)
615
+ );
616
+ if (childEv) lNode.child = buildFlowTree(childEv, byId);
617
+ }
618
+ return lNode;
619
+ });
620
+ return { type: 'event', ev, children: listeners };
621
+ }
622
+
623
+ // ── Subtree width ──────────────────────────────────────────────────────────
624
+ function subtreeWidth(node) {
625
+ if (node.type === 'listener') {
626
+ return node.child
627
+ ? Math.max(FC.LW, subtreeWidth(node.child))
628
+ : FC.LW;
629
+ }
630
+ // event
631
+ if (!node.children.length) return FC.EW;
632
+ const childrenW = node.children.reduce((s, c) => s + subtreeWidth(c) + FC.HGAP, -FC.HGAP);
633
+ return Math.max(FC.EW, childrenW);
634
+ }
635
+
636
+ // ── Assign x/y positions (mutates nodes in-place) ─────────────────────────
637
+ function assignPos(node, left, top, nodes, edges) {
638
+ const sw = subtreeWidth(node);
639
+ const w = node.type === 'listener' ? FC.LW : FC.EW;
640
+ const h = node.type === 'listener' ? FC.LH : FC.EH;
641
+ const cx = left + sw / 2;
642
+
643
+ Object.assign(node, {
644
+ x: cx - w / 2, y: top, w, h,
645
+ cx, cy: top + h / 2,
646
+ bx: cx, by: top + h,
647
+ tx: cx, ty: top,
648
+ });
649
+ nodes.push(node);
650
+
651
+ if (node.type === 'event') {
652
+ let lx = left;
653
+ node.children.forEach(child => {
654
+ const cw = subtreeWidth(child);
655
+ assignPos(child, lx, top + FC.EH + FC.VGAP, nodes, edges);
656
+ edges.push({ from: node, to: child });
657
+ lx += cw + FC.HGAP;
658
+ });
659
+ } else {
660
+ if (node.child) {
661
+ assignPos(node.child, left, top + FC.LH + FC.VGAP, nodes, edges);
662
+ edges.push({ from: node, to: node.child });
663
+ }
664
+ }
665
+ }
666
+
667
+ // ── SVG escape ─────────────────────────────────────────────────────────────
668
+ function escSVG(s) {
669
+ return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
670
+ }
671
+
672
+ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
673
+
674
+ // ── Build the SVG string ───────────────────────────────────────────────────
675
+ function buildFlowSVG(rootEv, byId) {
676
+ const tree = buildFlowTree(rootEv, byId);
677
+ const nodes = [], edges = [];
678
+ assignPos(tree, 0, 0, nodes, edges);
679
+
680
+ const maxX = Math.max(...nodes.map(n => n.x + n.w));
681
+ const maxY = Math.max(...nodes.map(n => n.y + n.h));
682
+ const W = maxX + FC.PAD * 2;
683
+ const H = maxY + FC.PAD * 2;
684
+
685
+ let inner = '';
686
+
687
+ // ── Edges (bezier with arrow) ──────────────────────────────────────────
688
+ edges.forEach(({ from, to }) => {
689
+ const midY = (from.by + to.ty) / 2;
690
+ inner += `<path d="M${from.bx},${from.by} C${from.bx},${midY} ${to.tx},${midY} ${to.tx},${to.ty}"
691
+ fill="none" stroke="#cbd5e1" stroke-width="2"/>
692
+ <polygon points="${to.tx-4},${to.ty-7} ${to.tx+4},${to.ty-7} ${to.tx},${to.ty}" fill="#cbd5e1"/>`;
693
+ });
694
+
695
+ // ── Nodes ─────────────────────────────────────────────────────────────
696
+ nodes.forEach(n => {
697
+ if (n.type === 'event') {
698
+ const ev = n.ev;
699
+ const isErr = ev.status === 'error';
700
+ const fill = isErr ? '#fff1f2' : '#eff6ff';
701
+ const stroke = isErr ? '#fca5a5' : '#93c5fd';
702
+ const nameClr = isErr ? '#be123c' : '#1d4ed8';
703
+ const badge = ev.isAsync ? 'async' : 'sync';
704
+ const dur = ev.totalDuration ? `${ev.totalDuration}ms` : '…';
705
+ const tick = ev.status === 'error' ? '✗' : ev.status === 'pending' ? '…' : '✓';
706
+ const name = truncate(ev.eventName, 22);
707
+ const loc = truncate(ev.emitterLocation || '', 28);
708
+
709
+ inner += `
710
+ <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="12"
711
+ fill="${fill}" stroke="${stroke}" stroke-width="2"/>
712
+ <rect x="${n.x+6}" y="${n.y+6}" width="38" height="16" rx="4" fill="${stroke}" opacity="0.4"/>
713
+ <text x="${n.x+25}" y="${n.y+17.5}" text-anchor="middle" font-size="9" font-weight="700"
714
+ fill="${nameClr}" letter-spacing="0.5">EVENT</text>
715
+ <text x="${n.cx}" y="${n.y+35}" text-anchor="middle" font-size="12" font-weight="700"
716
+ fill="${nameClr}">${escSVG(name)}</text>
717
+ <text x="${n.cx}" y="${n.y+52}" text-anchor="middle" font-size="10" fill="#64748b">${escSVG(badge)} · ${escSVG(dur)} · ${escSVG(tick)}</text>
718
+ <text x="${n.cx}" y="${n.y+67}" text-anchor="middle" font-size="9" fill="#94a3b8">${escSVG(loc)}</text>`;
719
+ } else {
720
+ const l = n.listener;
721
+ const isErr = l.status === 'error';
722
+ const fill = isErr ? '#fff1f2' : '#faf5ff';
723
+ const stroke = isErr ? '#fca5a5' : '#c4b5fd';
724
+ const clr = isErr ? '#be123c' : '#4338ca';
725
+ const durClr = l.duration > 200 ? '#dc2626' : l.duration > 50 ? '#d97706' : '#16a34a';
726
+ const tick = l.status === 'error' ? '✗' : '✓';
727
+ const name = truncate(l.name, 30);
728
+ const file = l.file ? truncate(l.file.split('/').pop() || '', 34) : '';
729
+
730
+ inner += `
731
+ <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10"
732
+ fill="${fill}" stroke="${stroke}" stroke-width="2"/>
733
+ <rect x="${n.x+6}" y="${n.y+6}" width="52" height="16" rx="4" fill="${stroke}" opacity="0.4"/>
734
+ <text x="${n.x+32}" y="${n.y+18}" text-anchor="middle" font-size="9" font-weight="700"
735
+ fill="${clr}" letter-spacing="0.5">LISTENER</text>
736
+ <text x="${n.cx}" y="${n.y+37}" text-anchor="middle" font-size="11" font-weight="600"
737
+ fill="${clr}">${escSVG(name)}</text>
738
+ <text x="${n.cx}" y="${n.y+54}" text-anchor="middle" font-size="10" fill="${durClr}">${l.duration}ms ${escSVG(tick)}</text>
739
+ ${file ? `<text x="${n.cx}" y="${n.y+69}" text-anchor="middle" font-size="9" fill="#94a3b8">${escSVG(file)}</text>` : ''}`;
740
+ }
741
+ });
742
+
743
+ return `<div style="overflow:auto">
744
+ <svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg"
745
+ style="min-width:${W}px;min-height:${H}px;display:block"
746
+ font-family="ui-monospace,SFMono-Regular,Menlo,monospace">
747
+ <g transform="translate(${FC.PAD},${FC.PAD})">${inner}</g>
748
+ </svg></div>`;
749
+ }
750
+
751
+ // ── Expose globals ─────────────────────────────────────────────────────────
752
+ window.loadEvents = loadEvents;
753
+ window.renderFilteredEvents = renderFilteredEvents;
754
+ window.toggleAuto = toggleAuto;
755
+ window.showFlowModal = showFlowModal;
756
+ window.closeFlowModal = closeFlowModal;
757
+ window.toggleFlowExpand = toggleFlowExpand;
758
+
759
+ loadEvents();
760
+
761
+ window.addEventListener('profilerNavigate', () => { clearInterval(autoTimer); autoEnabled = false; }, { once: true });
762
+ })();
763
+ </script>