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 } from 'vue'
3
+
4
+ interface FetchEntry {
5
+ id: string
6
+ key: string
7
+ url: string
8
+ status: 'pending' | 'ok' | 'error' | 'cached'
9
+ origin: 'ssr' | 'csr'
10
+ ms?: number
11
+ size?: number
12
+ cached: boolean
13
+ payload?: unknown
14
+ file?: string
15
+ line?: number
16
+ startOffset?: number
17
+ }
18
+
19
+ // Mock data — in production this comes from the WS registry
20
+ const entries = ref<FetchEntry[]>([
21
+ {
22
+ id: '1',
23
+ key: 'product-detail',
24
+ url: '/api/products/42',
25
+ status: 'ok',
26
+ origin: 'ssr',
27
+ ms: 48,
28
+ size: 3276,
29
+ cached: false,
30
+ startOffset: 0,
31
+ file: 'pages/products/[id].vue',
32
+ line: 8,
33
+ },
34
+ {
35
+ id: '2',
36
+ key: 'related-products',
37
+ url: '/api/products?related=42',
38
+ status: 'ok',
39
+ origin: 'ssr',
40
+ ms: 112,
41
+ size: 19148,
42
+ cached: false,
43
+ startOffset: 10,
44
+ file: 'pages/products/[id].vue',
45
+ line: 14,
46
+ },
47
+ {
48
+ id: '3',
49
+ key: 'user-session',
50
+ url: '/api/auth/session',
51
+ status: 'cached',
52
+ origin: 'csr',
53
+ ms: 0,
54
+ size: 819,
55
+ cached: true,
56
+ startOffset: 0,
57
+ file: 'layouts/default.vue',
58
+ line: 5,
59
+ },
60
+ {
61
+ id: '4',
62
+ key: 'cart-summary',
63
+ url: '/api/cart',
64
+ status: 'ok',
65
+ origin: 'csr',
66
+ ms: 67,
67
+ size: 2150,
68
+ cached: false,
69
+ startOffset: 5,
70
+ file: 'components/CartDrawer.vue',
71
+ line: 3,
72
+ },
73
+ {
74
+ id: '5',
75
+ key: 'product-reviews',
76
+ url: '/api/products/42/reviews',
77
+ status: 'pending',
78
+ origin: 'csr',
79
+ ms: undefined,
80
+ size: undefined,
81
+ cached: false,
82
+ startOffset: 120,
83
+ file: 'components/ReviewList.vue',
84
+ line: 6,
85
+ },
86
+ {
87
+ id: '6',
88
+ key: 'recommendations',
89
+ url: '/api/recommend?u=u_9x3k',
90
+ status: 'ok',
91
+ origin: 'csr',
92
+ ms: 201,
93
+ size: 9626,
94
+ cached: false,
95
+ startOffset: 30,
96
+ file: 'components/Recommendations.vue',
97
+ line: 4,
98
+ },
99
+ {
100
+ id: '7',
101
+ key: 'inventory-check',
102
+ url: '/api/inventory/42',
103
+ status: 'error',
104
+ origin: 'csr',
105
+ ms: 503,
106
+ size: undefined,
107
+ cached: false,
108
+ startOffset: 55,
109
+ file: 'components/StockBadge.vue',
110
+ line: 7,
111
+ },
112
+ {
113
+ id: '8',
114
+ key: 'nav-links',
115
+ url: '/api/nav',
116
+ status: 'cached',
117
+ origin: 'ssr',
118
+ ms: 0,
119
+ size: 1126,
120
+ cached: true,
121
+ startOffset: 0,
122
+ file: 'layouts/default.vue',
123
+ line: 9,
124
+ },
125
+ ])
126
+
127
+ const filter = ref<string>('all')
128
+ const search = ref('')
129
+ const selected = ref<FetchEntry | null>(null)
130
+
131
+ const counts = computed(() => ({
132
+ ok: entries.value.filter((e) => e.status === 'ok').length,
133
+ pending: entries.value.filter((e) => e.status === 'pending').length,
134
+ error: entries.value.filter((e) => e.status === 'error').length,
135
+ }))
136
+
137
+ const filtered = computed(() => {
138
+ return entries.value.filter((e) => {
139
+ if (filter.value !== 'all' && e.status !== filter.value) return false
140
+ const q = search.value.toLowerCase()
141
+ if (q && !e.key.includes(q) && !e.url.includes(q)) return false
142
+ return true
143
+ })
144
+ })
145
+
146
+ const metaRows = computed(() => {
147
+ if (!selected.value) return []
148
+ const e = selected.value
149
+ return [
150
+ ['url', e.url],
151
+ ['status', e.status],
152
+ ['origin', e.origin],
153
+ ['duration', e.ms != null ? e.ms + 'ms' : '—'],
154
+ ['size', e.size ? formatSize(e.size) : '—'],
155
+ ['cached', e.cached ? 'yes' : 'no'],
156
+ ]
157
+ })
158
+
159
+ const payloadStr = computed(() => {
160
+ if (!selected.value) return ''
161
+ const p = selected.value.payload
162
+ if (!p) return '(no payload captured yet)'
163
+ try {
164
+ return JSON.stringify(p, null, 2)
165
+ } catch {
166
+ return String(p)
167
+ }
168
+ })
169
+
170
+ function statusClass(s: string) {
171
+ return { ok: 'badge-ok', error: 'badge-err', pending: 'badge-warn', cached: 'badge-gray' }[s] ?? 'badge-gray'
172
+ }
173
+
174
+ function barColor(s: string) {
175
+ return { ok: 'var(--teal)', error: 'var(--red)', pending: 'var(--amber)', cached: 'var(--border)' }[s] ?? 'var(--border)'
176
+ }
177
+
178
+ function barWidth(e: FetchEntry) {
179
+ const maxMs = Math.max(...entries.value.filter((x) => x.ms).map((x) => x.ms!), 1)
180
+ return e.ms != null ? Math.max(4, Math.round((e.ms / maxMs) * 100)) : 4
181
+ }
182
+
183
+ function wfLeft(e: FetchEntry) {
184
+ const maxEnd = Math.max(...entries.value.map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
185
+ return Math.round(((e.startOffset ?? 0) / maxEnd) * 100)
186
+ }
187
+ function wfWidth(e: FetchEntry) {
188
+ const maxEnd = Math.max(...entries.value.map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
189
+ return e.ms != null ? Math.round((e.ms / maxEnd) * 100) : 2
190
+ }
191
+
192
+ function formatSize(bytes: number) {
193
+ if (bytes < 1024) return bytes + 'B'
194
+ return (bytes / 1024).toFixed(1) + 'KB'
195
+ }
196
+
197
+ function replayFetch() {
198
+ if (!selected.value) return
199
+ const e = selected.value
200
+ e.status = 'pending'
201
+ e.ms = undefined
202
+ setTimeout(() => {
203
+ e.status = 'ok'
204
+ e.ms = Math.floor(Math.random() * 150 + 20)
205
+ }, 700)
206
+ }
207
+
208
+ function clearAll() {
209
+ entries.value = []
210
+ selected.value = null
211
+ }
212
+ </script>
213
+
214
+ <template>
215
+ <div class="view">
216
+ <!-- Stats row -->
217
+ <div class="stats-row">
218
+ <div class="stat-card">
219
+ <div class="stat-label">total</div>
220
+ <div class="stat-val">{{ entries.length }}</div>
221
+ </div>
222
+ <div class="stat-card">
223
+ <div class="stat-label">success</div>
224
+ <div class="stat-val" style="color: var(--teal)">{{ counts.ok }}</div>
225
+ </div>
226
+ <div class="stat-card">
227
+ <div class="stat-label">pending</div>
228
+ <div class="stat-val" style="color: var(--amber)">{{ counts.pending }}</div>
229
+ </div>
230
+ <div class="stat-card">
231
+ <div class="stat-label">error</div>
232
+ <div class="stat-val" style="color: var(--red)">{{ counts.error }}</div>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Toolbar -->
237
+ <div class="toolbar">
238
+ <button :class="{ active: filter === 'all' }" @click="filter = 'all'">all</button>
239
+ <button :class="{ 'danger-active': filter === 'error' }" @click="filter = 'error'">errors</button>
240
+ <button :class="{ active: filter === 'pending' }" @click="filter = 'pending'">pending</button>
241
+ <button :class="{ active: filter === 'cached' }" @click="filter = 'cached'">cached</button>
242
+ <input v-model="search" type="search" placeholder="search key or url…" style="max-width: 240px; margin-left: auto" />
243
+ <button @click="clearAll">clear</button>
244
+ </div>
245
+
246
+ <!-- Split view -->
247
+ <div class="split">
248
+ <!-- Table -->
249
+ <div class="table-wrap">
250
+ <table class="data-table">
251
+ <thead>
252
+ <tr>
253
+ <th>key</th>
254
+ <th>url</th>
255
+ <th>status</th>
256
+ <th>origin</th>
257
+ <th>size</th>
258
+ <th>time</th>
259
+ <th style="min-width: 80px">bar</th>
260
+ </tr>
261
+ </thead>
262
+ <tbody>
263
+ <tr v-for="e in filtered" :key="e.id" :class="{ selected: selected?.id === e.id }" @click="selected = e">
264
+ <td>
265
+ <span class="mono" style="font-size: 11px; color: var(--text2)">{{ e.key }}</span>
266
+ </td>
267
+ <td>
268
+ <span
269
+ class="mono"
270
+ style="
271
+ font-size: 11px;
272
+ max-width: 200px;
273
+ display: block;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ "
278
+ :title="e.url"
279
+ >
280
+ {{ e.url }}
281
+ </span>
282
+ </td>
283
+ <td>
284
+ <span class="badge" :class="statusClass(e.status)">{{ e.status }}</span>
285
+ </td>
286
+ <td>
287
+ <span class="badge" :class="e.origin === 'ssr' ? 'badge-info' : 'badge-gray'">{{ e.origin }}</span>
288
+ </td>
289
+ <td class="muted text-sm">{{ e.size ? formatSize(e.size) : '—' }}</td>
290
+ <td class="mono text-sm">{{ e.ms != null ? e.ms + 'ms' : '—' }}</td>
291
+ <td>
292
+ <div style="height: 4px; background: var(--bg2); border-radius: 2px; overflow: hidden">
293
+ <div
294
+ :style="{
295
+ width: barWidth(e) + '%',
296
+ background: barColor(e.status),
297
+ height: '100%',
298
+ borderRadius: '2px',
299
+ }"
300
+ ></div>
301
+ </div>
302
+ </td>
303
+ </tr>
304
+ </tbody>
305
+ </table>
306
+ </div>
307
+
308
+ <!-- Detail panel -->
309
+ <div v-if="selected" class="detail-panel">
310
+ <div class="detail-header">
311
+ <span class="mono bold" style="font-size: 12px">{{ selected.key }}</span>
312
+ <div class="flex gap-2">
313
+ <button @click="replayFetch">↺ replay</button>
314
+ <button @click="selected = null">×</button>
315
+ </div>
316
+ </div>
317
+
318
+ <div class="meta-grid">
319
+ <template v-for="[k, v] in metaRows" :key="k">
320
+ <span class="muted text-sm">{{ k }}</span>
321
+ <span class="mono text-sm" style="word-break: break-all">{{ v }}</span>
322
+ </template>
323
+ </div>
324
+
325
+ <div class="section-label">payload</div>
326
+ <pre class="payload-box">{{ payloadStr }}</pre>
327
+
328
+ <div class="section-label" style="margin-top: 10px">source</div>
329
+ <div class="mono text-sm muted">{{ selected.file }}:{{ selected.line }}</div>
330
+ </div>
331
+ <div v-else class="detail-empty">select a call to inspect</div>
332
+ </div>
333
+
334
+ <!-- Waterfall -->
335
+ <div class="waterfall">
336
+ <div class="section-label" style="margin-bottom: 6px">waterfall</div>
337
+ <div v-for="e in entries" :key="e.id" class="wf-row">
338
+ <span
339
+ class="mono muted text-sm"
340
+ style="width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0"
341
+ >
342
+ {{ e.key }}
343
+ </span>
344
+ <div class="wf-track">
345
+ <div
346
+ class="wf-bar"
347
+ :style="{
348
+ left: wfLeft(e) + '%',
349
+ width: Math.max(2, wfWidth(e)) + '%',
350
+ background: barColor(e.status),
351
+ }"
352
+ ></div>
353
+ </div>
354
+ <span class="mono muted text-sm" style="width: 44px; text-align: right; flex-shrink: 0">
355
+ {{ e.ms != null ? e.ms + 'ms' : '—' }}
356
+ </span>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </template>
361
+
362
+ <style scoped>
363
+ .view {
364
+ display: flex;
365
+ flex-direction: column;
366
+ height: 100%;
367
+ overflow: hidden;
368
+ padding: 12px;
369
+ gap: 10px;
370
+ }
371
+
372
+ .stats-row {
373
+ display: grid;
374
+ grid-template-columns: repeat(4, minmax(0, 1fr));
375
+ gap: 8px;
376
+ flex-shrink: 0;
377
+ }
378
+
379
+ .toolbar {
380
+ display: flex;
381
+ align-items: center;
382
+ gap: 6px;
383
+ flex-shrink: 0;
384
+ flex-wrap: wrap;
385
+ }
386
+
387
+ .split {
388
+ display: flex;
389
+ gap: 12px;
390
+ flex: 1;
391
+ overflow: hidden;
392
+ min-height: 0;
393
+ }
394
+
395
+ .table-wrap {
396
+ flex: 1;
397
+ overflow: auto;
398
+ border: 0.5px solid var(--border);
399
+ border-radius: var(--radius-lg);
400
+ }
401
+
402
+ .detail-panel {
403
+ width: 280px;
404
+ flex-shrink: 0;
405
+ display: flex;
406
+ flex-direction: column;
407
+ gap: 8px;
408
+ overflow: auto;
409
+ border: 0.5px solid var(--border);
410
+ border-radius: var(--radius-lg);
411
+ padding: 12px;
412
+ background: var(--bg3);
413
+ }
414
+
415
+ .detail-empty {
416
+ width: 280px;
417
+ flex-shrink: 0;
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ color: var(--text3);
422
+ font-size: 12px;
423
+ border: 0.5px dashed var(--border);
424
+ border-radius: var(--radius-lg);
425
+ }
426
+
427
+ .detail-header {
428
+ display: flex;
429
+ align-items: center;
430
+ justify-content: space-between;
431
+ }
432
+
433
+ .meta-grid {
434
+ display: grid;
435
+ grid-template-columns: auto 1fr;
436
+ gap: 4px 12px;
437
+ font-size: 11px;
438
+ }
439
+
440
+ .section-label {
441
+ font-size: 10px;
442
+ font-weight: 500;
443
+ text-transform: uppercase;
444
+ letter-spacing: 0.4px;
445
+ color: var(--text3);
446
+ margin-top: 6px;
447
+ }
448
+
449
+ .payload-box {
450
+ font-family: var(--mono);
451
+ font-size: 11px;
452
+ color: var(--text2);
453
+ background: var(--bg2);
454
+ border-radius: var(--radius);
455
+ padding: 8px 10px;
456
+ overflow: auto;
457
+ white-space: pre;
458
+ max-height: 160px;
459
+ }
460
+
461
+ .waterfall {
462
+ flex-shrink: 0;
463
+ background: var(--bg3);
464
+ border: 0.5px solid var(--border);
465
+ border-radius: var(--radius-lg);
466
+ padding: 10px 12px;
467
+ }
468
+
469
+ .wf-row {
470
+ display: flex;
471
+ align-items: center;
472
+ gap: 8px;
473
+ margin-bottom: 4px;
474
+ }
475
+
476
+ .wf-track {
477
+ flex: 1;
478
+ position: relative;
479
+ height: 8px;
480
+ background: var(--bg2);
481
+ border-radius: 2px;
482
+ overflow: hidden;
483
+ }
484
+
485
+ .wf-bar {
486
+ position: absolute;
487
+ top: 0;
488
+ height: 100%;
489
+ border-radius: 2px;
490
+ opacity: 0.8;
491
+ }
492
+ </style>