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,492 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, defineComponent, h, onUnmounted, type VNode } from 'vue'
3
+
4
+ interface ComponentNode {
5
+ id: string
6
+ label: string
7
+ file: string
8
+ renders: number
9
+ avgMs: number
10
+ triggers: string[]
11
+ children: ComponentNode[]
12
+ }
13
+
14
+ // ComponentBlock — recursive inline component
15
+ const ComponentBlock = defineComponent({
16
+ name: 'ComponentBlock',
17
+ props: {
18
+ node: Object as () => ComponentNode,
19
+ mode: String,
20
+ threshold: Number,
21
+ hotOnly: Boolean,
22
+ selected: String,
23
+ },
24
+ emits: ['select'],
25
+ setup(props, { emit }): () => VNode | null {
26
+ function getVal(n: ComponentNode) {
27
+ return props.mode === 'count' ? n.renders : n.avgMs
28
+ }
29
+
30
+ function getMax(): number {
31
+ let max = 1
32
+
33
+ function walk(ns: ComponentNode[]) {
34
+ ns.forEach((n) => {
35
+ const v = getVal(n)
36
+
37
+ if (v > max) {
38
+ max = v
39
+ }
40
+
41
+ walk(n.children)
42
+ })
43
+ }
44
+
45
+ walk([props.node!])
46
+
47
+ return Math.max(max, props.mode === 'count' ? 40 : 20)
48
+ }
49
+ function heatColor(val: number, max: number) {
50
+ const r = Math.min(val / max, 1)
51
+
52
+ if (r < 0.25) {
53
+ return { bg: '#EAF3DE', text: '#27500A', border: '#97C459' }
54
+ }
55
+
56
+ if (r < 0.55) {
57
+ return { bg: '#FAEEDA', text: '#633806', border: '#EF9F27' }
58
+ }
59
+
60
+ if (r < 0.8) {
61
+ return { bg: '#FAECE7', text: '#712B13', border: '#D85A30' }
62
+ }
63
+
64
+ return { bg: '#FCEBEB', text: '#791F1F', border: '#E24B4A' }
65
+ }
66
+ function isHot(n: ComponentNode) {
67
+ return (props.mode === 'count' ? n.renders : n.avgMs) >= props.threshold!
68
+ }
69
+
70
+ return () => {
71
+ const n = props.node!
72
+
73
+ if (props.hotOnly && !isHot(n) && !n.children.some((c) => (props.mode === 'count' ? c.renders : c.avgMs) >= props.threshold!)) {
74
+ return null
75
+ }
76
+
77
+ const max = getMax()
78
+ const val = getVal(n)
79
+ const col = heatColor(val, max)
80
+ const isSel = props.selected === n.id
81
+ const unit = props.mode === 'count' ? 'renders' : 'ms avg'
82
+ const valStr = props.mode === 'count' ? String(val) : val.toFixed(1) + 'ms'
83
+
84
+ return h(
85
+ 'div',
86
+ {
87
+ style: {
88
+ background: col.bg,
89
+ border: isSel ? `2px solid ${col.border}` : `1px solid ${col.border}`,
90
+ borderRadius: '6px',
91
+ padding: '6px 9px',
92
+ marginBottom: '5px',
93
+ cursor: 'pointer',
94
+ },
95
+ onClick: () => emit('select', n),
96
+ },
97
+ [
98
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
99
+ h('span', { style: { fontFamily: 'var(--mono)', fontSize: '11px', fontWeight: '500', color: col.text } }, n.label),
100
+ h(
101
+ 'span',
102
+ { style: { fontFamily: 'var(--mono)', fontSize: '10px', color: col.text, opacity: '0.7', marginLeft: 'auto' } },
103
+ `${valStr} ${unit}`
104
+ ),
105
+ ]),
106
+ n.children.length
107
+ ? h(
108
+ 'div',
109
+ {
110
+ style: {
111
+ marginLeft: '10px',
112
+ borderLeft: `1.5px solid ${col.border}40`,
113
+ paddingLeft: '8px',
114
+ marginTop: '5px',
115
+ },
116
+ },
117
+ n.children.map((child) =>
118
+ h(ComponentBlock, {
119
+ node: child,
120
+ mode: props.mode,
121
+ threshold: props.threshold,
122
+ hotOnly: props.hotOnly,
123
+ selected: props.selected,
124
+ onSelect: (v: ComponentNode) => emit('select', v),
125
+ })
126
+ )
127
+ )
128
+ : null,
129
+ ]
130
+ )
131
+ }
132
+ },
133
+ })
134
+
135
+ // Mock data
136
+ const baseNodes = ref<ComponentNode[]>([
137
+ {
138
+ id: 'NavBar',
139
+ label: 'NavBar.vue',
140
+ file: 'components/NavBar.vue',
141
+ renders: 3,
142
+ avgMs: 1.2,
143
+ triggers: ['props.user changed'],
144
+ children: [],
145
+ },
146
+ {
147
+ id: 'Sidebar',
148
+ label: 'Sidebar.vue',
149
+ file: 'components/Sidebar.vue',
150
+ renders: 2,
151
+ avgMs: 0.8,
152
+ triggers: ['parent re-render'],
153
+ children: [],
154
+ },
155
+ {
156
+ id: 'ProductGrid',
157
+ label: 'ProductGrid.vue',
158
+ file: 'components/ProductGrid.vue',
159
+ renders: 18,
160
+ avgMs: 14.3,
161
+ triggers: ['store: products updated', 'props.filter changed', 'parent re-render (×16)'],
162
+ children: [
163
+ {
164
+ id: 'ProductCard',
165
+ label: 'ProductCard.vue ×12',
166
+ file: 'components/ProductCard.vue',
167
+ renders: 24,
168
+ avgMs: 3.1,
169
+ triggers: ['parent re-render (×24)'],
170
+ children: [],
171
+ },
172
+ {
173
+ id: 'PriceTag',
174
+ label: 'PriceTag.vue ×12',
175
+ file: 'components/PriceTag.vue',
176
+ renders: 36,
177
+ avgMs: 0.4,
178
+ triggers: ['props.price changed (×36)'],
179
+ children: [],
180
+ },
181
+ ],
182
+ },
183
+ {
184
+ id: 'CartSummary',
185
+ label: 'CartSummary.vue',
186
+ file: 'components/CartSummary.vue',
187
+ renders: 9,
188
+ avgMs: 5.7,
189
+ triggers: ['store: cart updated (×9)'],
190
+ children: [
191
+ {
192
+ id: 'CartItem',
193
+ label: 'CartItem.vue ×3',
194
+ file: 'components/CartItem.vue',
195
+ renders: 12,
196
+ avgMs: 1.8,
197
+ triggers: ['parent re-render (×12)'],
198
+ children: [],
199
+ },
200
+ ],
201
+ },
202
+ {
203
+ id: 'FilterBar',
204
+ label: 'FilterBar.vue',
205
+ file: 'components/FilterBar.vue',
206
+ renders: 7,
207
+ avgMs: 2.1,
208
+ triggers: ['store: filters changed (×7)'],
209
+ children: [],
210
+ },
211
+ { id: 'Footer', label: 'Footer.vue', file: 'components/Footer.vue', renders: 1, avgMs: 0.3, triggers: ['initial mount'], children: [] },
212
+ ])
213
+
214
+ const activeMode = ref<'count' | 'time'>('count')
215
+ const activeThreshold = ref(5)
216
+ const activeHotOnly = ref(false)
217
+ const frozen = ref(false)
218
+ const activeSelected = ref<ComponentNode | null>(null)
219
+ let liveInterval: ReturnType<typeof setInterval> | null = null
220
+
221
+ const rootNodes = computed(() => baseNodes.value)
222
+
223
+ const allComponents = computed(() => {
224
+ const all: ComponentNode[] = []
225
+
226
+ function collect(ns: ComponentNode[]) {
227
+ ns.forEach((n) => {
228
+ all.push(n)
229
+ collect(n.children)
230
+ })
231
+ }
232
+
233
+ collect(baseNodes.value)
234
+
235
+ return all
236
+ })
237
+
238
+ const totalRenders = computed(() => allComponents.value.reduce((a, n) => a + n.renders, 0))
239
+ const hotCount = computed(() => allComponents.value.filter((n) => isHot(n)).length)
240
+ const avgTime = computed(() => {
241
+ const comps = allComponents.value.filter((n) => n.avgMs > 0)
242
+
243
+ if (!comps.length) {
244
+ return '0.0'
245
+ }
246
+
247
+ return (comps.reduce((a, n) => a + n.avgMs, 0) / comps.length).toFixed(1)
248
+ })
249
+
250
+ function isHot(n: ComponentNode) {
251
+ return (activeMode.value === 'count' ? n.renders : n.avgMs) >= activeThreshold.value
252
+ }
253
+
254
+ function startLive() {
255
+ liveInterval = setInterval(() => {
256
+ if (frozen.value) {
257
+ return
258
+ }
259
+
260
+ allComponents.value.forEach((n) => {
261
+ if (Math.random() < 0.3) n.renders += Math.floor(Math.random() * 3) + 1
262
+ })
263
+ }, 1800)
264
+ }
265
+
266
+ function toggleFreeze() {
267
+ frozen.value = !frozen.value
268
+ }
269
+
270
+ startLive()
271
+ onUnmounted(() => {
272
+ if (liveInterval) clearInterval(liveInterval)
273
+ })
274
+ </script>
275
+
276
+ <template>
277
+ <div class="view">
278
+ <!-- Controls -->
279
+ <div class="controls">
280
+ <div class="mode-group">
281
+ <button :class="{ active: activeMode === 'count' }" @click="activeMode = 'count'">render count</button>
282
+ <button :class="{ active: activeMode === 'time' }" @click="activeMode = 'time'">render time</button>
283
+ </div>
284
+ <div class="threshold-group">
285
+ <span class="muted text-sm">threshold</span>
286
+ <input v-model.number="activeThreshold" type="range" min="1" max="30" step="1" style="width: 90px" />
287
+ <span class="mono text-sm">{{ activeThreshold }}+</span>
288
+ </div>
289
+ <button :class="{ active: activeHotOnly }" @click="activeHotOnly = !activeHotOnly">hot only</button>
290
+ <button :class="{ active: frozen }" style="margin-left: auto" @click="toggleFreeze">
291
+ {{ frozen ? 'unfreeze' : 'freeze snapshot' }}
292
+ </button>
293
+ </div>
294
+
295
+ <!-- Stats -->
296
+ <div class="stats-row">
297
+ <div class="stat-card">
298
+ <div class="stat-label">components</div>
299
+ <div class="stat-val">{{ allComponents.length }}</div>
300
+ </div>
301
+ <div class="stat-card">
302
+ <div class="stat-label">total renders</div>
303
+ <div class="stat-val">{{ totalRenders }}</div>
304
+ </div>
305
+ <div class="stat-card">
306
+ <div class="stat-label">hot</div>
307
+ <div class="stat-val" style="color: var(--red)">{{ hotCount }}</div>
308
+ </div>
309
+ <div class="stat-card">
310
+ <div class="stat-label">avg time</div>
311
+ <div class="stat-val">{{ avgTime }}ms</div>
312
+ </div>
313
+ </div>
314
+
315
+ <div class="split">
316
+ <!-- Page mockup -->
317
+ <div class="page-frame">
318
+ <div class="legend">
319
+ <div class="swatch-row">
320
+ <span class="swatch" style="background: #eaf3de"></span>
321
+ <span class="swatch" style="background: #97c459"></span>
322
+ <span class="swatch" style="background: #ef9f27"></span>
323
+ <span class="swatch" style="background: #e24b4a"></span>
324
+ </div>
325
+ <span class="muted text-sm">cool → hot</span>
326
+ </div>
327
+ <ComponentBlock
328
+ v-for="rootNode in rootNodes"
329
+ :key="rootNode.id"
330
+ :node="rootNode"
331
+ :mode="activeMode"
332
+ :threshold="activeThreshold"
333
+ :hot-only="activeHotOnly"
334
+ :selected="activeSelected?.id"
335
+ @select="activeSelected = $event"
336
+ />
337
+ </div>
338
+
339
+ <!-- Detail panel -->
340
+ <div class="sidebar">
341
+ <template v-if="activeSelected">
342
+ <div class="detail-header">
343
+ <span class="mono bold" style="font-size: 12px">{{ activeSelected.label }}</span>
344
+ <button @click="activeSelected = null">×</button>
345
+ </div>
346
+
347
+ <div class="meta-grid">
348
+ <span class="muted text-sm">renders</span>
349
+ <span class="mono text-sm">{{ activeSelected.renders }}</span>
350
+ <span class="muted text-sm">avg time</span>
351
+ <span class="mono text-sm">{{ activeSelected.avgMs.toFixed(1) }}ms</span>
352
+ <span class="muted text-sm">hot?</span>
353
+ <span class="text-sm" :style="{ color: isHot(activeSelected) ? 'var(--red)' : 'var(--teal)' }">
354
+ {{ isHot(activeSelected) ? 'yes' : 'no' }}
355
+ </span>
356
+ <span class="muted text-sm">file</span>
357
+ <span class="mono text-sm muted">{{ activeSelected.file }}</span>
358
+ </div>
359
+
360
+ <div class="section-label">triggers</div>
361
+ <div v-for="t in activeSelected.triggers" :key="t" class="trigger-item mono text-sm">{{ t }}</div>
362
+ <div v-if="!activeSelected.triggers.length" class="muted text-sm">no triggers recorded</div>
363
+ </template>
364
+ <div v-else class="detail-empty">click a component to inspect</div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </template>
369
+
370
+ <style scoped>
371
+ .view {
372
+ display: flex;
373
+ flex-direction: column;
374
+ height: 100%;
375
+ overflow: hidden;
376
+ padding: 12px;
377
+ gap: 10px;
378
+ }
379
+
380
+ .controls {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 8px;
384
+ flex-shrink: 0;
385
+ flex-wrap: wrap;
386
+ }
387
+
388
+ .mode-group {
389
+ display: flex;
390
+ gap: 2px;
391
+ }
392
+
393
+ .threshold-group {
394
+ display: flex;
395
+ align-items: center;
396
+ gap: 6px;
397
+ }
398
+
399
+ .stats-row {
400
+ display: grid;
401
+ grid-template-columns: repeat(4, minmax(0, 1fr));
402
+ gap: 8px;
403
+ flex-shrink: 0;
404
+ }
405
+
406
+ .split {
407
+ display: flex;
408
+ gap: 12px;
409
+ flex: 1;
410
+ overflow: hidden;
411
+ min-height: 0;
412
+ }
413
+
414
+ .page-frame {
415
+ flex: 1;
416
+ overflow: auto;
417
+ border: 0.5px solid var(--border);
418
+ border-radius: var(--radius-lg);
419
+ padding: 12px;
420
+ background: var(--bg3);
421
+ }
422
+
423
+ .legend {
424
+ display: flex;
425
+ align-items: center;
426
+ gap: 8px;
427
+ margin-bottom: 10px;
428
+ }
429
+
430
+ .swatch-row {
431
+ display: flex;
432
+ gap: 2px;
433
+ }
434
+
435
+ .swatch {
436
+ width: 16px;
437
+ height: 8px;
438
+ border-radius: 2px;
439
+ }
440
+
441
+ .sidebar {
442
+ width: 260px;
443
+ flex-shrink: 0;
444
+ overflow: auto;
445
+ border: 0.5px solid var(--border);
446
+ border-radius: var(--radius-lg);
447
+ padding: 12px;
448
+ background: var(--bg3);
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 6px;
452
+ }
453
+
454
+ .detail-empty {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: center;
458
+ height: 100%;
459
+ color: var(--text3);
460
+ font-size: 12px;
461
+ }
462
+
463
+ .detail-header {
464
+ display: flex;
465
+ align-items: center;
466
+ justify-content: space-between;
467
+ }
468
+
469
+ .meta-grid {
470
+ display: grid;
471
+ grid-template-columns: auto 1fr;
472
+ gap: 4px 12px;
473
+ }
474
+
475
+ .section-label {
476
+ font-size: 10px;
477
+ font-weight: 500;
478
+ text-transform: uppercase;
479
+ letter-spacing: 0.4px;
480
+ color: var(--text3);
481
+ margin-top: 8px;
482
+ margin-bottom: 4px;
483
+ }
484
+
485
+ .trigger-item {
486
+ background: var(--bg2);
487
+ border-radius: var(--radius);
488
+ padding: 4px 8px;
489
+ margin-bottom: 3px;
490
+ color: var(--text2);
491
+ }
492
+ </style>