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.
- package/README.md +167 -5
- package/collectors/event-collector.d.ts +18 -0
- package/collectors/event-collector.js +254 -0
- package/collectors/event-collector.js.map +1 -0
- package/common/profiler-options.interface.d.ts +8 -0
- package/common/profiler.model.d.ts +25 -0
- package/controllers/profiler.controller.d.ts +22 -1
- package/controllers/profiler.controller.js +161 -15
- package/controllers/profiler.controller.js.map +1 -1
- package/middleware/profiler-auth.middleware.d.ts +10 -0
- package/middleware/profiler-auth.middleware.js +110 -0
- package/middleware/profiler-auth.middleware.js.map +1 -0
- package/package.json +2 -2
- package/profiler.module.js +82 -0
- package/profiler.module.js.map +1 -1
- package/services/code-quality.service.d.ts +65 -0
- package/services/code-quality.service.js +216 -0
- package/services/code-quality.service.js.map +1 -0
- package/services/entity-explorer.service.d.ts +1 -1
- package/services/entity-explorer.service.js +7 -7
- package/services/entity-explorer.service.js.map +1 -1
- package/services/health.service.d.ts +59 -0
- package/services/health.service.js +190 -0
- package/services/health.service.js.map +1 -0
- package/services/profiler.service.d.ts +10 -1
- package/services/profiler.service.js +103 -35
- package/services/profiler.service.js.map +1 -1
- package/services/route-explorer.service.d.ts +23 -1
- package/services/route-explorer.service.js +136 -10
- package/services/route-explorer.service.js.map +1 -1
- package/services/template-builder.service.d.ts +3 -0
- package/services/template-builder.service.js +191 -84
- package/services/template-builder.service.js.map +1 -1
- package/services/view.service.d.ts +1 -1
- package/services/view.service.js +34 -23
- package/services/view.service.js.map +1 -1
- package/views/code-quality.html +573 -0
- package/views/events.html +763 -0
- package/views/health.html +468 -0
- package/views/layout.html +50 -10
- package/views/login.html +277 -0
- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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>
|