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,481 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+
4
+ interface TreeNodeData {
5
+ id: string
6
+ label: string
7
+ type: 'provider' | 'consumer' | 'both' | 'error'
8
+ provides: Array<{ key: string; val: string; reactive: boolean }>
9
+ injects: Array<{ key: string; from: string | null; ok: boolean }>
10
+ children: TreeNodeData[]
11
+ }
12
+
13
+ interface LayoutNode {
14
+ data: TreeNodeData
15
+ parentId: string | null
16
+ x: number
17
+ y: number
18
+ }
19
+
20
+ interface Edge {
21
+ id: string
22
+ x1: number
23
+ y1: number
24
+ x2: number
25
+ y2: number
26
+ }
27
+
28
+ const NODE_W = 140
29
+ const NODE_H = 32
30
+ const V_GAP = 72
31
+ const H_GAP = 18
32
+
33
+ function nodeColor(n: TreeNodeData): string {
34
+ if (n.injects.some((i) => !i.ok)) return 'var(--red)'
35
+ if (n.type === 'both') return 'var(--blue)'
36
+ if (n.type === 'provider') return 'var(--teal)'
37
+ return 'var(--text3)'
38
+ }
39
+
40
+ function matchesFilter(n: TreeNodeData, filter: string): boolean {
41
+ if (filter === 'all') return true
42
+ if (filter === 'warn') return n.injects.some((i) => !i.ok)
43
+ return n.provides.some((p) => p.key === filter) || n.injects.some((i) => i.key === filter)
44
+ }
45
+
46
+ function countLeaves(n: TreeNodeData): number {
47
+ return n.children.length === 0 ? 1 : n.children.reduce((s, c) => s + countLeaves(c), 0)
48
+ }
49
+
50
+ const nodes = ref<TreeNodeData[]>([
51
+ {
52
+ id: 'App',
53
+ label: 'App.vue',
54
+ type: 'provider',
55
+ provides: [
56
+ { key: 'authContext', val: '{ user, logout }', reactive: true },
57
+ { key: 'theme', val: '"dark"', reactive: false },
58
+ ],
59
+ injects: [],
60
+ children: [
61
+ {
62
+ id: 'Layout',
63
+ label: 'Layout.vue',
64
+ type: 'both',
65
+ provides: [{ key: 'routerState', val: 'useRoute()', reactive: true }],
66
+ injects: [{ key: 'theme', from: 'App.vue', ok: true }],
67
+ children: [
68
+ {
69
+ id: 'Sidebar',
70
+ label: 'Sidebar.vue',
71
+ type: 'consumer',
72
+ provides: [],
73
+ injects: [
74
+ { key: 'authContext', from: 'App.vue', ok: true },
75
+ { key: 'theme', from: 'App.vue', ok: true },
76
+ ],
77
+ children: [],
78
+ },
79
+ {
80
+ id: 'NavBar',
81
+ label: 'NavBar.vue',
82
+ type: 'consumer',
83
+ provides: [],
84
+ injects: [
85
+ { key: 'authContext', from: 'App.vue', ok: true },
86
+ { key: 'routerState', from: 'Layout.vue', ok: true },
87
+ ],
88
+ children: [],
89
+ },
90
+ {
91
+ id: 'ProductList',
92
+ label: 'ProductList.vue',
93
+ type: 'error',
94
+ provides: [],
95
+ injects: [
96
+ { key: 'cartContext', from: null, ok: false },
97
+ { key: 'theme', from: 'App.vue', ok: true },
98
+ ],
99
+ children: [
100
+ {
101
+ id: 'ProductCard',
102
+ label: 'ProductCard.vue',
103
+ type: 'error',
104
+ provides: [],
105
+ injects: [{ key: 'cartContext', from: null, ok: false }],
106
+ children: [],
107
+ },
108
+ ],
109
+ },
110
+ {
111
+ id: 'UserMenu',
112
+ label: 'UserMenu.vue',
113
+ type: 'consumer',
114
+ provides: [],
115
+ injects: [{ key: 'authContext', from: 'App.vue', ok: true }],
116
+ children: [],
117
+ },
118
+ ],
119
+ },
120
+ ],
121
+ },
122
+ ])
123
+
124
+ const activeFilter = ref('all')
125
+ const selectedNode = ref<TreeNodeData | null>(null)
126
+
127
+ const allKeys = computed(() => {
128
+ const keys = new Set<string>()
129
+
130
+ function collect(ns: TreeNodeData[]) {
131
+ ns.forEach((n) => {
132
+ n.provides.forEach((p) => keys.add(p.key))
133
+ n.injects.forEach((i) => keys.add(i.key))
134
+ collect(n.children)
135
+ })
136
+ }
137
+
138
+ collect(nodes.value)
139
+
140
+ return [...keys]
141
+ })
142
+
143
+ const layout = computed<LayoutNode[]>(() => {
144
+ const flat: LayoutNode[] = []
145
+ const pad = H_GAP
146
+
147
+ function place(node: TreeNodeData, depth: number, slotLeft: number, parentId: string | null) {
148
+ const leaves = countLeaves(node)
149
+ const slotW = leaves * (NODE_W + H_GAP) - H_GAP
150
+ flat.push({
151
+ data: node,
152
+ parentId,
153
+ x: Math.round(slotLeft + slotW / 2),
154
+ y: Math.round(pad + depth * (NODE_H + V_GAP) + NODE_H / 2),
155
+ })
156
+ let childLeft = slotLeft
157
+ for (const child of node.children) {
158
+ const cl = countLeaves(child)
159
+ place(child, depth + 1, childLeft, node.id)
160
+ childLeft += cl * (NODE_W + H_GAP)
161
+ }
162
+ }
163
+
164
+ let left = pad
165
+ for (const root of nodes.value) {
166
+ const leaves = countLeaves(root)
167
+ place(root, 0, left, null)
168
+ left += leaves * (NODE_W + H_GAP) + H_GAP * 2
169
+ }
170
+
171
+ return flat
172
+ })
173
+
174
+ const canvasW = computed(() => layout.value.reduce((m, n) => Math.max(m, n.x + NODE_W / 2 + 20), 400))
175
+
176
+ const canvasH = computed(() => layout.value.reduce((m, n) => Math.max(m, n.y + NODE_H / 2 + 20), 200))
177
+
178
+ const edges = computed<Edge[]>(() => {
179
+ const byId = new Map(layout.value.map((n) => [n.data.id, n]))
180
+ return layout.value
181
+ .filter((n) => n.parentId !== null)
182
+ .map((n) => {
183
+ const p = byId.get(n.parentId!)!
184
+ return {
185
+ id: `${p.data.id}--${n.data.id}`,
186
+ x1: p.x,
187
+ y1: p.y + NODE_H / 2,
188
+ x2: n.x,
189
+ y2: n.y - NODE_H / 2,
190
+ }
191
+ })
192
+ })
193
+ </script>
194
+
195
+ <template>
196
+ <div class="view">
197
+ <div class="toolbar">
198
+ <button :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">all keys</button>
199
+ <button
200
+ v-for="k in allKeys"
201
+ :key="k"
202
+ style="font-family: var(--mono)"
203
+ :class="{ active: activeFilter === k }"
204
+ @click="activeFilter = k"
205
+ >
206
+ {{ k }}
207
+ </button>
208
+ <button style="margin-left: auto" :class="{ 'danger-active': activeFilter === 'warn' }" @click="activeFilter = 'warn'">
209
+ warnings only
210
+ </button>
211
+ </div>
212
+
213
+ <div class="split">
214
+ <!-- Graph -->
215
+ <div class="graph-area">
216
+ <div class="legend">
217
+ <span class="dot" style="background: var(--teal)"></span>
218
+ <span>provides</span>
219
+ <span class="dot" style="background: var(--blue)"></span>
220
+ <span>both</span>
221
+ <span class="dot" style="background: var(--text3)"></span>
222
+ <span>injects</span>
223
+ <span class="dot" style="background: var(--red)"></span>
224
+ <span>missing provider</span>
225
+ </div>
226
+ <div class="canvas-wrap" :style="{ width: canvasW + 'px', height: canvasH + 'px' }">
227
+ <svg class="edges-svg" :width="canvasW" :height="canvasH" :viewBox="`0 0 ${canvasW} ${canvasH}`">
228
+ <path
229
+ v-for="e in edges"
230
+ :key="e.id"
231
+ :d="`M ${e.x1},${e.y1} C ${e.x1},${(e.y1 + e.y2) / 2} ${e.x2},${(e.y1 + e.y2) / 2} ${e.x2},${e.y2}`"
232
+ class="edge"
233
+ fill="none"
234
+ />
235
+ </svg>
236
+ <div
237
+ v-for="ln in layout"
238
+ :key="ln.data.id"
239
+ class="graph-node"
240
+ :class="{
241
+ 'is-selected': selectedNode?.id === ln.data.id,
242
+ 'is-dimmed': !matchesFilter(ln.data, activeFilter),
243
+ }"
244
+ :style="{
245
+ left: ln.x - NODE_W / 2 + 'px',
246
+ top: ln.y - NODE_H / 2 + 'px',
247
+ width: NODE_W + 'px',
248
+ '--node-color': nodeColor(ln.data),
249
+ }"
250
+ @click="selectedNode = ln.data"
251
+ >
252
+ <span class="node-dot" :style="{ background: nodeColor(ln.data) }"></span>
253
+ <span class="mono node-label">{{ ln.data.label }}</span>
254
+ <span v-if="ln.data.provides.length" class="badge badge-ok badge-xs">+{{ ln.data.provides.length }}</span>
255
+ <span v-if="ln.data.injects.some((i) => !i.ok)" class="badge badge-err badge-xs">!</span>
256
+ </div>
257
+ </div>
258
+ </div>
259
+
260
+ <!-- Detail -->
261
+ <div v-if="selectedNode" class="detail-panel">
262
+ <div class="detail-header">
263
+ <span class="mono bold" style="font-size: 12px">{{ selectedNode.label }}</span>
264
+ <button @click="selectedNode = null">×</button>
265
+ </div>
266
+
267
+ <div v-if="selectedNode.provides.length">
268
+ <div class="section-label">provides ({{ selectedNode.provides.length }})</div>
269
+ <div v-for="p in selectedNode.provides" :key="p.key" class="provide-row">
270
+ <span class="mono text-sm" style="min-width: 100px; color: var(--text2)">{{ p.key }}</span>
271
+ <span class="mono text-sm muted" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
272
+ {{ p.val }}
273
+ </span>
274
+ <span class="badge" :class="p.reactive ? 'badge-ok' : 'badge-gray'">{{ p.reactive ? 'reactive' : 'static' }}</span>
275
+ </div>
276
+ </div>
277
+
278
+ <div v-if="selectedNode.injects.length" :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }">
279
+ <div class="section-label">injects ({{ selectedNode.injects.length }})</div>
280
+ <div v-for="inj in selectedNode.injects" :key="inj.key" class="inject-row" :class="{ 'inject-miss': !inj.ok }">
281
+ <span class="mono text-sm" style="min-width: 100px">{{ inj.key }}</span>
282
+ <span v-if="inj.ok" class="badge badge-ok">resolved</span>
283
+ <span v-else class="badge badge-err">no provider</span>
284
+ <span class="mono muted text-sm" style="margin-left: auto">{{ inj.from ?? 'undefined' }}</span>
285
+ </div>
286
+ </div>
287
+
288
+ <div v-if="!selectedNode.provides.length && !selectedNode.injects.length" class="muted text-sm" style="margin-top: 8px">
289
+ no provide/inject in this component
290
+ </div>
291
+ </div>
292
+ <div v-else class="detail-empty">click a node to inspect</div>
293
+ </div>
294
+ </div>
295
+ </template>
296
+
297
+ <style scoped>
298
+ .view {
299
+ display: flex;
300
+ flex-direction: column;
301
+ height: 100%;
302
+ overflow: hidden;
303
+ padding: 12px;
304
+ gap: 10px;
305
+ }
306
+
307
+ .toolbar {
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 6px;
311
+ flex-shrink: 0;
312
+ flex-wrap: wrap;
313
+ }
314
+
315
+ .split {
316
+ display: flex;
317
+ gap: 12px;
318
+ flex: 1;
319
+ overflow: hidden;
320
+ min-height: 0;
321
+ }
322
+
323
+ .graph-area {
324
+ flex: 1;
325
+ overflow: auto;
326
+ border: 0.5px solid var(--border);
327
+ border-radius: var(--radius-lg);
328
+ padding: 12px;
329
+ background: var(--bg3);
330
+ }
331
+
332
+ .legend {
333
+ display: flex;
334
+ align-items: center;
335
+ gap: 12px;
336
+ font-size: 11px;
337
+ color: var(--text2);
338
+ margin-bottom: 12px;
339
+ }
340
+
341
+ .dot {
342
+ width: 8px;
343
+ height: 8px;
344
+ border-radius: 50%;
345
+ display: inline-block;
346
+ margin-right: 2px;
347
+ }
348
+
349
+ .canvas-wrap {
350
+ position: relative;
351
+ }
352
+
353
+ .edges-svg {
354
+ position: absolute;
355
+ top: 0;
356
+ left: 0;
357
+ pointer-events: none;
358
+ }
359
+
360
+ .edge {
361
+ stroke: var(--border);
362
+ stroke-width: 1.5;
363
+ }
364
+
365
+ .graph-node {
366
+ position: absolute;
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 7px;
370
+ padding: 0 10px;
371
+ height: 32px;
372
+ border-radius: var(--radius);
373
+ border: 0.5px solid var(--border);
374
+ background: var(--bg3);
375
+ cursor: pointer;
376
+ transition:
377
+ border-color 0.12s,
378
+ background 0.12s;
379
+ overflow: hidden;
380
+ box-sizing: border-box;
381
+ white-space: nowrap;
382
+ }
383
+
384
+ .graph-node:hover {
385
+ border-color: var(--text3);
386
+ }
387
+
388
+ .graph-node.is-selected {
389
+ border-color: var(--node-color);
390
+ background: color-mix(in srgb, var(--node-color) 8%, transparent);
391
+ }
392
+
393
+ .graph-node.is-dimmed {
394
+ opacity: 0.2;
395
+ pointer-events: none;
396
+ }
397
+
398
+ .node-dot {
399
+ width: 7px;
400
+ height: 7px;
401
+ border-radius: 50%;
402
+ flex-shrink: 0;
403
+ }
404
+
405
+ .node-label {
406
+ font-size: 11px;
407
+ flex: 1;
408
+ overflow: hidden;
409
+ text-overflow: ellipsis;
410
+ }
411
+
412
+ .badge-xs {
413
+ font-size: 9px;
414
+ padding: 1px 4px;
415
+ }
416
+
417
+ .detail-panel {
418
+ width: 280px;
419
+ flex-shrink: 0;
420
+ overflow: auto;
421
+ border: 0.5px solid var(--border);
422
+ border-radius: var(--radius-lg);
423
+ padding: 12px;
424
+ background: var(--bg3);
425
+ display: flex;
426
+ flex-direction: column;
427
+ gap: 4px;
428
+ }
429
+
430
+ .detail-empty {
431
+ width: 280px;
432
+ display: flex;
433
+ align-items: center;
434
+ justify-content: center;
435
+ color: var(--text3);
436
+ font-size: 12px;
437
+ border: 0.5px dashed var(--border);
438
+ border-radius: var(--radius-lg);
439
+ flex-shrink: 0;
440
+ }
441
+
442
+ .detail-header {
443
+ display: flex;
444
+ align-items: center;
445
+ justify-content: space-between;
446
+ margin-bottom: 6px;
447
+ }
448
+
449
+ .section-label {
450
+ font-size: 10px;
451
+ font-weight: 500;
452
+ text-transform: uppercase;
453
+ letter-spacing: 0.4px;
454
+ color: var(--text3);
455
+ margin: 8px 0 5px;
456
+ }
457
+
458
+ .provide-row {
459
+ display: flex;
460
+ align-items: center;
461
+ gap: 8px;
462
+ padding: 5px 8px;
463
+ background: var(--bg2);
464
+ border-radius: var(--radius);
465
+ margin-bottom: 3px;
466
+ }
467
+
468
+ .inject-row {
469
+ display: flex;
470
+ align-items: center;
471
+ gap: 8px;
472
+ padding: 5px 8px;
473
+ background: var(--bg2);
474
+ border-radius: var(--radius);
475
+ margin-bottom: 3px;
476
+ }
477
+
478
+ .inject-miss {
479
+ background: rgb(226 75 74 / 8%);
480
+ }
481
+ </style>