nuxt-devtools-observatory 0.1.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.
Files changed (33) hide show
  1. package/README.md +209 -0
  2. package/client/dist/assets/index-C76d764s.js +17 -0
  3. package/client/dist/assets/index-yIuOV1_N.css +1 -0
  4. package/client/dist/index.html +47 -0
  5. package/client/index.html +46 -0
  6. package/client/src/App.vue +114 -0
  7. package/client/src/main.ts +5 -0
  8. package/client/src/stores/observatory.ts +65 -0
  9. package/client/src/style.css +261 -0
  10. package/client/src/views/ComposableTracker.vue +347 -0
  11. package/client/src/views/FetchDashboard.vue +492 -0
  12. package/client/src/views/ProvideInjectGraph.vue +481 -0
  13. package/client/src/views/RenderHeatmap.vue +492 -0
  14. package/client/src/views/TransitionTimeline.vue +527 -0
  15. package/client/tsconfig.json +16 -0
  16. package/client/vite.config.ts +12 -0
  17. package/dist/module.d.mts +38 -0
  18. package/dist/module.json +12 -0
  19. package/dist/module.mjs +562 -0
  20. package/dist/runtime/composables/composable-registry.d.ts +40 -0
  21. package/dist/runtime/composables/composable-registry.js +135 -0
  22. package/dist/runtime/composables/fetch-registry.d.ts +63 -0
  23. package/dist/runtime/composables/fetch-registry.js +83 -0
  24. package/dist/runtime/composables/provide-inject-registry.d.ts +57 -0
  25. package/dist/runtime/composables/provide-inject-registry.js +96 -0
  26. package/dist/runtime/composables/render-registry.d.ts +36 -0
  27. package/dist/runtime/composables/render-registry.js +85 -0
  28. package/dist/runtime/composables/transition-registry.d.ts +21 -0
  29. package/dist/runtime/composables/transition-registry.js +125 -0
  30. package/dist/runtime/plugin.d.ts +2 -0
  31. package/dist/runtime/plugin.js +66 -0
  32. package/dist/types.d.mts +3 -0
  33. package/package.json +89 -0
@@ -0,0 +1,527 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import { useObservatoryData, type TransitionEntry } from '../stores/observatory'
4
+
5
+ const { transitions: entries, connected } = useObservatoryData()
6
+
7
+ type FilterMode = 'all' | 'cancelled' | 'active' | 'completed'
8
+ const filter = ref<FilterMode>('all')
9
+ const search = ref('')
10
+ const selected = ref<TransitionEntry | null>(null)
11
+
12
+ const filtered = computed(() => {
13
+ let list = entries.value
14
+
15
+ if (search.value) {
16
+ const q = search.value.toLowerCase()
17
+ list = list.filter((e) => e.transitionName.toLowerCase().includes(q) || e.parentComponent.toLowerCase().includes(q))
18
+ }
19
+
20
+ if (filter.value === 'cancelled') {
21
+ list = list.filter((e) => e.cancelled || e.phase === 'interrupted')
22
+ } else if (filter.value === 'active') {
23
+ list = list.filter((e) => e.phase === 'entering' || e.phase === 'leaving')
24
+ } else if (filter.value === 'completed') {
25
+ list = list.filter((e) => e.phase === 'entered' || e.phase === 'left')
26
+ }
27
+
28
+ return list
29
+ })
30
+
31
+ const stats = computed(() => ({
32
+ total: entries.value.length,
33
+ active: entries.value.filter((e) => e.phase === 'entering' || e.phase === 'leaving').length,
34
+ cancelled: entries.value.filter((e) => e.cancelled || e.phase === 'interrupted').length,
35
+ avgMs: (() => {
36
+ const completed = entries.value.filter((e) => e.durationMs !== undefined)
37
+
38
+ if (!completed.length) {
39
+ return 0
40
+ }
41
+
42
+ return Math.round(completed.reduce((s, e) => s + (e.durationMs ?? 0), 0) / completed.length)
43
+ })(),
44
+ }))
45
+
46
+ // Timeline bar geometry — relative to the earliest startTime
47
+ const timelineGeometry = computed(() => {
48
+ const all = filtered.value
49
+
50
+ if (!all.length) {
51
+ return []
52
+ }
53
+
54
+ const minT = Math.min(...all.map((e) => e.startTime))
55
+ const maxT = Math.max(...all.map((e) => e.endTime ?? e.startTime + 400))
56
+ const span = Math.max(maxT - minT, 1)
57
+
58
+ return all.map((e) => ({
59
+ left: ((e.startTime - minT) / span) * 100,
60
+ width: (((e.endTime ?? e.startTime + 80) - e.startTime) / span) * 100,
61
+ }))
62
+ })
63
+
64
+ function phaseColor(phase: TransitionEntry['phase'], direction: TransitionEntry['direction']): string {
65
+ if (phase === 'entering' || phase === 'leaving') {
66
+ return '#7f77dd'
67
+ }
68
+
69
+ if (phase === 'entered') {
70
+ return '#1d9e75'
71
+ }
72
+
73
+ if (phase === 'left') {
74
+ return '#378add'
75
+ }
76
+
77
+ if (phase === 'enter-cancelled' || phase === 'leave-cancelled') {
78
+ return '#e24b4a'
79
+ }
80
+
81
+ if (phase === 'interrupted') {
82
+ return '#e09a3a'
83
+ }
84
+
85
+ return '#888'
86
+ }
87
+
88
+ function phaseBadgeClass(phase: TransitionEntry['phase']): string {
89
+ if (phase === 'entering' || phase === 'leaving') {
90
+ return 'badge-purple'
91
+ }
92
+
93
+ if (phase === 'entered' || phase === 'left') {
94
+ return 'badge-ok'
95
+ }
96
+
97
+ if (phase.includes('cancelled')) {
98
+ return 'badge-err'
99
+ }
100
+
101
+ if (phase === 'interrupted') {
102
+ return 'badge-warn'
103
+ }
104
+
105
+ return 'badge-gray'
106
+ }
107
+
108
+ function directionLabel(e: TransitionEntry): string {
109
+ if (e.appear) {
110
+ return '✦ appear'
111
+ }
112
+
113
+ return e.direction === 'enter' ? '→ enter' : '← leave'
114
+ }
115
+
116
+ function directionColor(e: TransitionEntry): string {
117
+ if (e.appear) {
118
+ return 'var(--amber)'
119
+ }
120
+
121
+ return e.direction === 'enter' ? 'var(--teal)' : 'var(--blue)'
122
+ }
123
+ </script>
124
+
125
+ <template>
126
+ <div class="timeline-root">
127
+ <!-- Stats bar -->
128
+ <div class="stats-row">
129
+ <div class="stat-card">
130
+ <div class="stat-val">{{ stats.total }}</div>
131
+ <div class="stat-label">total</div>
132
+ </div>
133
+ <div class="stat-card">
134
+ <div class="stat-val" style="color: var(--purple)">{{ stats.active }}</div>
135
+ <div class="stat-label">active</div>
136
+ </div>
137
+ <div class="stat-card">
138
+ <div class="stat-val" style="color: var(--red)">{{ stats.cancelled }}</div>
139
+ <div class="stat-label">cancelled</div>
140
+ </div>
141
+ <div class="stat-card">
142
+ <div class="stat-val">
143
+ {{ stats.avgMs }}
144
+ <span class="stat-unit">ms</span>
145
+ </div>
146
+ <div class="stat-label">avg duration</div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Toolbar -->
151
+ <div class="toolbar">
152
+ <input v-model="search" type="search" placeholder="filter by name or component…" class="search-input" />
153
+ <div class="filter-group">
154
+ <button :class="{ active: filter === 'all' }" @click="filter = 'all'">All</button>
155
+ <button :class="{ active: filter === 'active' }" @click="filter = 'active'">Active</button>
156
+ <button :class="{ active: filter === 'completed' }" @click="filter = 'completed'">Completed</button>
157
+ <button :class="{ active: filter === 'cancelled', 'danger-active': filter === 'cancelled' }" @click="filter = 'cancelled'">
158
+ Cancelled
159
+ </button>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Main content -->
164
+ <div class="content-area">
165
+ <!-- Timeline table -->
166
+ <div class="table-pane" :class="{ 'has-panel': selected }">
167
+ <table class="data-table">
168
+ <thead>
169
+ <tr>
170
+ <th style="width: 110px">NAME</th>
171
+ <th style="width: 80px">DIR</th>
172
+ <th style="width: 90px">PHASE</th>
173
+ <th style="width: 70px">DURATION</th>
174
+ <th>COMPONENT</th>
175
+ <th>TIMELINE</th>
176
+ </tr>
177
+ </thead>
178
+ <tbody>
179
+ <tr
180
+ v-for="(entry, i) in filtered"
181
+ :key="entry.id"
182
+ :class="{ selected: selected?.id === entry.id }"
183
+ @click="selected = selected?.id === entry.id ? null : entry"
184
+ >
185
+ <td>
186
+ <span class="mono" style="font-size: 11px; font-weight: 500">{{ entry.transitionName }}</span>
187
+ </td>
188
+ <td>
189
+ <span class="mono" style="font-size: 11px" :style="{ color: directionColor(entry) }">
190
+ {{ directionLabel(entry) }}
191
+ </span>
192
+ </td>
193
+ <td>
194
+ <span class="badge" :class="phaseBadgeClass(entry.phase)">{{ entry.phase }}</span>
195
+ </td>
196
+ <td class="mono" style="font-size: 11px; color: var(--text2)">
197
+ {{ entry.durationMs !== undefined ? entry.durationMs + 'ms' : '—' }}
198
+ </td>
199
+ <td class="muted" style="font-size: 11px">{{ entry.parentComponent }}</td>
200
+ <td class="bar-cell">
201
+ <div class="bar-track">
202
+ <div
203
+ class="bar-fill"
204
+ :style="{
205
+ left: timelineGeometry[i]?.left + '%',
206
+ width: Math.max(timelineGeometry[i]?.width ?? 1, 1) + '%',
207
+ background: phaseColor(entry.phase, entry.direction),
208
+ opacity: entry.phase === 'entering' || entry.phase === 'leaving' ? '0.55' : '1',
209
+ }"
210
+ />
211
+ </div>
212
+ </td>
213
+ </tr>
214
+
215
+ <tr v-if="!filtered.length">
216
+ <td colspan="6" style="text-align: center; color: var(--text3); padding: 24px">
217
+ {{
218
+ connected
219
+ ? 'No transitions recorded yet — trigger one on the page.'
220
+ : 'Waiting for connection to the Nuxt app…'
221
+ }}
222
+ </td>
223
+ </tr>
224
+ </tbody>
225
+ </table>
226
+ </div>
227
+
228
+ <!-- Detail panel -->
229
+ <transition name="panel-slide">
230
+ <aside v-if="selected" class="detail-panel">
231
+ <div class="panel-header">
232
+ <span class="panel-title">{{ selected.transitionName }}</span>
233
+ <button class="close-btn" @click="selected = null">✕</button>
234
+ </div>
235
+
236
+ <div class="panel-section">
237
+ <div class="panel-row">
238
+ <span class="panel-key">Direction</span>
239
+ <span class="panel-val" :style="{ color: directionColor(selected) }">{{ directionLabel(selected) }}</span>
240
+ </div>
241
+ <div class="panel-row">
242
+ <span class="panel-key">Phase</span>
243
+ <span class="badge" :class="phaseBadgeClass(selected.phase)">{{ selected.phase }}</span>
244
+ </div>
245
+ <div class="panel-row">
246
+ <span class="panel-key">Component</span>
247
+ <span class="panel-val mono">{{ selected.parentComponent }}</span>
248
+ </div>
249
+ <div v-if="selected.mode" class="panel-row">
250
+ <span class="panel-key">Mode</span>
251
+ <span class="panel-val mono">{{ selected.mode }}</span>
252
+ </div>
253
+ </div>
254
+
255
+ <div class="panel-section">
256
+ <div class="panel-section-title">Timing</div>
257
+ <div class="panel-row">
258
+ <span class="panel-key">Start</span>
259
+ <span class="panel-val mono">{{ selected.startTime.toFixed(2) }}ms</span>
260
+ </div>
261
+ <div class="panel-row">
262
+ <span class="panel-key">End</span>
263
+ <span class="panel-val mono">
264
+ {{ selected.endTime !== undefined ? selected.endTime.toFixed(2) + 'ms' : '—' }}
265
+ </span>
266
+ </div>
267
+ <div class="panel-row">
268
+ <span class="panel-key">Duration</span>
269
+ <span class="panel-val mono" style="font-weight: 500">
270
+ {{ selected.durationMs !== undefined ? selected.durationMs + 'ms' : selected.phase === 'interrupted' ? 'interrupted' : 'in progress' }}
271
+ </span>
272
+ </div>
273
+ </div>
274
+
275
+ <div class="panel-section">
276
+ <div class="panel-section-title">Flags</div>
277
+ <div class="panel-row">
278
+ <span class="panel-key">Appear</span>
279
+ <span class="panel-val" :style="{ color: selected.appear ? 'var(--amber)' : 'var(--text3)' }">
280
+ {{ selected.appear ? 'yes' : 'no' }}
281
+ </span>
282
+ </div>
283
+ <div class="panel-row">
284
+ <span class="panel-key">Cancelled</span>
285
+ <span class="panel-val" :style="{ color: selected.cancelled ? 'var(--red)' : 'var(--text3)' }">
286
+ {{ selected.cancelled ? 'yes' : 'no' }}
287
+ </span>
288
+ </div>
289
+ </div>
290
+
291
+ <div v-if="selected.cancelled" class="cancel-notice">
292
+ This transition was cancelled mid-flight. The element may be stuck in a partial animation state if the interruption
293
+ was not handled with
294
+ <code>onEnterCancelled</code>
295
+ /
296
+ <code>onLeaveCancelled</code>
297
+ .
298
+ </div>
299
+
300
+ <div v-if="selected.phase === 'entering' || selected.phase === 'leaving'" class="active-notice">
301
+ Transition is currently in progress. If it stays in this state longer than expected, the
302
+ <code>done()</code>
303
+ callback may not be getting called (JS-mode transition stall).
304
+ </div>
305
+ </aside>
306
+ </transition>
307
+ </div>
308
+ </div>
309
+ </template>
310
+
311
+ <style scoped>
312
+ .timeline-root {
313
+ display: flex;
314
+ flex-direction: column;
315
+ height: 100%;
316
+ overflow: hidden;
317
+ }
318
+
319
+ /* ── Stats ───────────────────────────────────────────────────────────────── */
320
+ .stats-row {
321
+ display: flex;
322
+ gap: 10px;
323
+ padding: 12px 14px 0;
324
+ flex-shrink: 0;
325
+ }
326
+
327
+ .stat-card {
328
+ background: var(--bg2);
329
+ border: 0.5px solid var(--border);
330
+ border-radius: var(--radius);
331
+ padding: 8px 14px;
332
+ min-width: 72px;
333
+ text-align: center;
334
+ }
335
+
336
+ .stat-val {
337
+ font-size: 20px;
338
+ font-weight: 600;
339
+ font-family: var(--mono);
340
+ line-height: 1.1;
341
+ }
342
+
343
+ .stat-unit {
344
+ font-size: 12px;
345
+ opacity: 0.6;
346
+ margin-left: 1px;
347
+ }
348
+
349
+ .stat-label {
350
+ font-size: 10px;
351
+ color: var(--text3);
352
+ margin-top: 2px;
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.4px;
355
+ }
356
+
357
+ /* ── Toolbar ─────────────────────────────────────────────────────────────── */
358
+ .toolbar {
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 8px;
362
+ padding: 10px 14px;
363
+ flex-shrink: 0;
364
+ border-bottom: 0.5px solid var(--border);
365
+ }
366
+
367
+ .search-input {
368
+ flex: 1;
369
+ max-width: 260px;
370
+ }
371
+
372
+ .filter-group {
373
+ display: flex;
374
+ gap: 4px;
375
+ }
376
+
377
+ /* ── Content ─────────────────────────────────────────────────────────────── */
378
+ .content-area {
379
+ display: flex;
380
+ flex: 1;
381
+ overflow: hidden;
382
+ min-height: 0;
383
+ }
384
+
385
+ .table-pane {
386
+ flex: 1;
387
+ overflow: hidden auto;
388
+ min-width: 0;
389
+ }
390
+
391
+ /* ── Timeline bar ────────────────────────────────────────────────────────── */
392
+ .bar-cell {
393
+ width: 200px;
394
+ padding: 4px 8px;
395
+ }
396
+
397
+ .bar-track {
398
+ position: relative;
399
+ height: 8px;
400
+ background: var(--bg2);
401
+ border-radius: 4px;
402
+ overflow: hidden;
403
+ }
404
+
405
+ .bar-fill {
406
+ position: absolute;
407
+ top: 0;
408
+ height: 100%;
409
+ min-width: 3px;
410
+ border-radius: 4px;
411
+ transition: width 0.15s;
412
+ }
413
+
414
+ /* ── Detail panel ────────────────────────────────────────────────────────── */
415
+ .detail-panel {
416
+ width: 260px;
417
+ flex-shrink: 0;
418
+ border-left: 0.5px solid var(--border);
419
+ overflow-y: auto;
420
+ background: var(--bg3);
421
+ padding: 0 0 16px;
422
+ }
423
+
424
+ .panel-header {
425
+ display: flex;
426
+ align-items: center;
427
+ justify-content: space-between;
428
+ padding: 10px 14px 8px;
429
+ border-bottom: 0.5px solid var(--border);
430
+ position: sticky;
431
+ top: 0;
432
+ background: var(--bg3);
433
+ z-index: 1;
434
+ }
435
+
436
+ .panel-title {
437
+ font-family: var(--mono);
438
+ font-size: 13px;
439
+ font-weight: 500;
440
+ }
441
+
442
+ .close-btn {
443
+ border: none;
444
+ background: transparent;
445
+ color: var(--text3);
446
+ font-size: 11px;
447
+ padding: 2px 6px;
448
+ cursor: pointer;
449
+ }
450
+
451
+ .panel-section {
452
+ padding: 10px 14px 6px;
453
+ border-bottom: 0.5px solid var(--border);
454
+ }
455
+
456
+ .panel-section-title {
457
+ font-size: 10px;
458
+ font-weight: 500;
459
+ color: var(--text3);
460
+ text-transform: uppercase;
461
+ letter-spacing: 0.4px;
462
+ margin-bottom: 8px;
463
+ }
464
+
465
+ .panel-row {
466
+ display: flex;
467
+ justify-content: space-between;
468
+ align-items: center;
469
+ gap: 8px;
470
+ padding: 3px 0;
471
+ font-size: 12px;
472
+ }
473
+
474
+ .panel-key {
475
+ color: var(--text3);
476
+ flex-shrink: 0;
477
+ }
478
+
479
+ .panel-val {
480
+ color: var(--text);
481
+ text-align: right;
482
+ word-break: break-all;
483
+ }
484
+
485
+ .cancel-notice,
486
+ .active-notice {
487
+ margin: 10px 14px 0;
488
+ font-size: 11px;
489
+ line-height: 1.6;
490
+ padding: 8px 10px;
491
+ border-radius: var(--radius);
492
+ }
493
+
494
+ .cancel-notice {
495
+ background: rgb(226 75 74 / 10%);
496
+ color: var(--red);
497
+ border: 0.5px solid rgb(226 75 74 / 30%);
498
+ }
499
+
500
+ .active-notice {
501
+ background: rgb(127 119 221 / 10%);
502
+ color: var(--purple);
503
+ border: 0.5px solid rgb(127 119 221 / 30%);
504
+ }
505
+
506
+ code {
507
+ font-family: var(--mono);
508
+ font-size: 10px;
509
+ background: rgb(0 0 0 / 15%);
510
+ padding: 1px 4px;
511
+ border-radius: 3px;
512
+ }
513
+
514
+ /* Panel slide transition */
515
+ .panel-slide-enter-active,
516
+ .panel-slide-leave-active {
517
+ transition:
518
+ transform 0.18s ease,
519
+ opacity 0.18s ease;
520
+ }
521
+
522
+ .panel-slide-enter-from,
523
+ .panel-slide-leave-to {
524
+ transform: translateX(12px);
525
+ opacity: 0;
526
+ }
527
+ </style>
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "jsx": "preserve",
8
+ "lib": ["ESNext", "DOM"],
9
+ "baseUrl": ".",
10
+ "forceConsistentCasingInFileNames": true,
11
+ "paths": {
12
+ "*": ["../node_modules/*"]
13
+ }
14
+ },
15
+ "include": ["**/*.ts", "**/*.vue"]
16
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ export default defineConfig({
5
+ root: new URL('.', import.meta.url).pathname,
6
+ base: '/',
7
+ plugins: [vue()],
8
+ build: {
9
+ outDir: './dist',
10
+ emptyOutDir: true,
11
+ },
12
+ })
@@ -0,0 +1,38 @@
1
+ import * as nuxt_schema from 'nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ /**
5
+ * Enable the useFetch / useAsyncData dashboard tab
6
+ * @default true
7
+ */
8
+ fetchDashboard?: boolean;
9
+ /**
10
+ * Enable the provide/inject graph tab
11
+ * @default true
12
+ */
13
+ provideInjectGraph?: boolean;
14
+ /**
15
+ * Enable the composable tracker tab
16
+ * @default true
17
+ */
18
+ composableTracker?: boolean;
19
+ /**
20
+ * Enable the render heatmap tab
21
+ * @default true
22
+ */
23
+ renderHeatmap?: boolean;
24
+ /**
25
+ * Enable the transition tracker tab
26
+ * @default true
27
+ */
28
+ transitionTracker?: boolean;
29
+ /**
30
+ * Minimum render count / ms threshold to highlight in the heatmap
31
+ * @default 5
32
+ */
33
+ heatmapThreshold?: number;
34
+ }
35
+ declare const _default: nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
36
+
37
+ export { _default as default };
38
+ export type { ModuleOptions };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "nuxt-devtools-observatory",
3
+ "configKey": "observatory",
4
+ "compatibility": {
5
+ "nuxt": "^3.0.0"
6
+ },
7
+ "version": "0.1.0",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.2",
10
+ "unbuild": "3.6.1"
11
+ }
12
+ }