nuxt-devtools-observatory 0.1.32 → 0.1.33

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 (31) hide show
  1. package/README.md +5 -0
  2. package/client/.env.example +1 -0
  3. package/client/dist/assets/index-BqKYgjVB.js +20 -0
  4. package/client/dist/assets/index-bs1JBJ2u.css +1 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +4 -0
  7. package/client/src/components/Flamegraph.vue +4 -4
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/composables/composable-search.ts +3 -0
  10. package/client/src/composables/trace-render-aggregation.ts +11 -2
  11. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  12. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  13. package/client/src/views/ComposableTracker.vue +212 -71
  14. package/client/src/views/FetchDashboard.vue +181 -16
  15. package/client/src/views/ProvideInjectGraph.vue +41 -18
  16. package/client/src/views/RenderHeatmap.vue +329 -75
  17. package/client/src/views/TraceViewer.vue +190 -20
  18. package/client/src/views/TransitionTimeline.vue +112 -19
  19. package/dist/module.d.mts +5 -0
  20. package/dist/module.json +1 -1
  21. package/dist/module.mjs +11 -22
  22. package/dist/runtime/composables/render-registry.js +6 -4
  23. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  24. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  25. package/dist/runtime/instrumentation/fetch.js +22 -1
  26. package/dist/runtime/plugin.js +4 -1
  27. package/dist/runtime/test-bridge.d.ts +18 -0
  28. package/dist/runtime/test-bridge.js +86 -0
  29. package/package.json +14 -3
  30. package/client/dist/assets/index-5Wl1XYRH.js +0 -17
  31. package/client/dist/assets/index-DT_QUiIh.css +0 -1
@@ -1,18 +1,24 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed } from 'vue'
2
+ import { ref, computed, watch } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
3
5
  import { useResizablePane } from '@observatory-client/composables/useResizablePane'
4
6
  import { useObservatoryData } from '@observatory-client/stores/observatory'
5
7
  import type { FetchEntry } from '@observatory/types/snapshot'
6
8
 
7
9
  type FetchViewEntry = FetchEntry & { startOffset: number }
8
10
 
9
- const { fetch, connected } = useObservatoryData()
11
+ const { fetch, connected, features } = useObservatoryData()
10
12
  const { paneWidth: detailWidth, onHandleMouseDown } = useResizablePane(280, 'observatory:fetch:detailWidth')
11
13
 
12
14
  const filter = ref<string>('all')
13
15
  const search = ref('')
14
16
  const selectedId = ref<string | null>(null)
15
17
  const waterfallOpen = ref(true)
18
+ const tableScrollRef = ref<HTMLElement | null>(null)
19
+ const currentPage = ref(1)
20
+
21
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 38, overscan: 6 })
16
22
 
17
23
  const entries = computed<FetchViewEntry[]>(() => {
18
24
  const sorted = [...fetch.value].sort((a, b) => a.startTime - b.startTime)
@@ -24,7 +30,15 @@ const entries = computed<FetchViewEntry[]>(() => {
24
30
  }))
25
31
  })
26
32
 
27
- const selected = computed(() => entries.value.find((entry) => entry.id === selectedId.value) ?? null)
33
+ const entriesById = computed(() => new Map(entries.value.map((entry) => [entry.id, entry] as const)))
34
+
35
+ const selected = computed(() => {
36
+ if (!selectedId.value) {
37
+ return null
38
+ }
39
+
40
+ return entriesById.value.get(selectedId.value) ?? null
41
+ })
28
42
 
29
43
  const counts = computed(() => ({
30
44
  ok: entries.value.filter((entry) => entry.status === 'ok' || entry.status === 'cached').length,
@@ -48,6 +62,94 @@ const filtered = computed(() => {
48
62
  })
49
63
  })
50
64
 
65
+ const fetchPageSize = computed(() => {
66
+ const raw = features.value?.fetchPageSize
67
+
68
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
69
+ return 20
70
+ }
71
+
72
+ return Math.max(1, Math.floor(raw))
73
+ })
74
+
75
+ const pagedFiltered = computed(() => filtered.value.slice(0, currentPage.value * fetchPageSize.value))
76
+ const hasMoreRows = computed(() => pagedFiltered.value.length < filtered.value.length)
77
+
78
+ const virtualizedRowsEnabled = computed(() => true)
79
+
80
+ const rowVirtualizerOptions = computed(() => ({
81
+ count: pagedFiltered.value.length,
82
+ getScrollElement: () => tableScrollRef.value,
83
+ estimateSize: () => virtualizationPreset.value.rowHeight,
84
+ overscan: virtualizationPreset.value.overscan,
85
+ }))
86
+
87
+ const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
88
+
89
+ const virtualItems = computed(() => {
90
+ if (!virtualizedRowsEnabled.value) {
91
+ return []
92
+ }
93
+
94
+ return rowVirtualizer.value.getVirtualItems()
95
+ })
96
+
97
+ const topVirtualPadding = computed(() => {
98
+ if (!virtualizedRowsEnabled.value || virtualItems.value.length === 0) {
99
+ return 0
100
+ }
101
+
102
+ return virtualItems.value[0].start
103
+ })
104
+
105
+ const bottomVirtualPadding = computed(() => {
106
+ if (!virtualizedRowsEnabled.value || virtualItems.value.length === 0) {
107
+ return 0
108
+ }
109
+
110
+ const total = rowVirtualizer.value.getTotalSize()
111
+ const last = virtualItems.value[virtualItems.value.length - 1]
112
+
113
+ return Math.max(0, total - last.end)
114
+ })
115
+
116
+ const visibleRows = computed(() => {
117
+ if (!virtualizedRowsEnabled.value) {
118
+ return pagedFiltered.value
119
+ }
120
+
121
+ return virtualItems.value.map((item) => pagedFiltered.value[item.index]).filter((entry): entry is FetchViewEntry => Boolean(entry))
122
+ })
123
+
124
+ watch([filter, search], () => {
125
+ currentPage.value = 1
126
+ if (tableScrollRef.value) {
127
+ tableScrollRef.value.scrollTop = 0
128
+ }
129
+ })
130
+
131
+ watch(filtered, (nextRows) => {
132
+ const maxPage = Math.max(1, Math.ceil(nextRows.length / fetchPageSize.value))
133
+
134
+ if (currentPage.value > maxPage) {
135
+ currentPage.value = maxPage
136
+ }
137
+ })
138
+
139
+ function onTableScroll() {
140
+ const element = tableScrollRef.value
141
+
142
+ if (!element || !hasMoreRows.value) {
143
+ return
144
+ }
145
+
146
+ const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 32
147
+
148
+ if (nearBottom) {
149
+ currentPage.value += 1
150
+ }
151
+ }
152
+
51
153
  const metaRows = computed(() => {
52
154
  if (!selected.value) {
53
155
  return []
@@ -91,11 +193,22 @@ function barColor(status: string) {
91
193
  return { ok: 'var(--teal)', error: 'var(--red)', pending: 'var(--amber)', cached: 'var(--border)' }[status] ?? 'var(--border)'
92
194
  }
93
195
 
196
+ const maxCompletedMs = computed(() => {
197
+ let maxMs = 1
198
+
199
+ for (const entry of entries.value) {
200
+ if (entry.ms != null && entry.ms > maxMs) {
201
+ maxMs = entry.ms
202
+ }
203
+ }
204
+
205
+ return maxMs
206
+ })
207
+
94
208
  function barWidth(entry: FetchViewEntry) {
95
209
  // Only consider completed entries for the max, so pending entries don't
96
210
  // collapse all bars to a dot while waiting.
97
- const completedMs = entries.value.filter((e) => e.ms != null).map((e) => e.ms!)
98
- const maxMs = completedMs.length > 0 ? Math.max(...completedMs, 1) : 1
211
+ const maxMs = maxCompletedMs.value
99
212
 
100
213
  return entry.ms != null ? Math.max(4, Math.round((entry.ms / maxMs) * 100)) : 4
101
214
  }
@@ -103,15 +216,26 @@ function barWidth(entry: FetchViewEntry) {
103
216
  // Waterfall uses absolute time offsets from the earliest startTime.
104
217
  // maxEnd is computed only from completed entries so that a long-running
105
218
  // pending request doesn't squash all completed bars to invisible.
106
- function waterfallScale() {
107
- const completed = entries.value.filter((e) => e.ms != null)
108
- const maxEnd = completed.length > 0 ? Math.max(...completed.map((e) => e.startOffset + e.ms!), 1) : 1
219
+ const waterfallScale = computed(() => {
220
+ let maxEnd = 1
221
+
222
+ for (const entry of entries.value) {
223
+ if (entry.ms == null) {
224
+ continue
225
+ }
226
+
227
+ const end = entry.startOffset + entry.ms
228
+
229
+ if (end > maxEnd) {
230
+ maxEnd = end
231
+ }
232
+ }
109
233
 
110
234
  return maxEnd
111
- }
235
+ })
112
236
 
113
237
  function wfLeft(entry: FetchViewEntry) {
114
- const scale = waterfallScale()
238
+ const scale = waterfallScale.value
115
239
  return Math.min(98, Math.round((entry.startOffset / scale) * 100))
116
240
  }
117
241
 
@@ -122,7 +246,7 @@ function wfWidth(entry: FetchViewEntry) {
122
246
  return 2
123
247
  }
124
248
 
125
- const scale = waterfallScale()
249
+ const scale = waterfallScale.value
126
250
  const left = wfLeft(entry)
127
251
 
128
252
  // Clamp so bar + left never exceeds 100%
@@ -173,7 +297,7 @@ function formatSize(bytes: number) {
173
297
  </div>
174
298
 
175
299
  <div class="fetch-dashboard__split tracker-split">
176
- <div class="fetch-dashboard__table tracker-table-wrap">
300
+ <div ref="tableScrollRef" class="fetch-dashboard__table tracker-table-wrap" @scroll="onTableScroll">
177
301
  <table class="data-table">
178
302
  <thead>
179
303
  <tr>
@@ -188,7 +312,14 @@ function formatSize(bytes: number) {
188
312
  </thead>
189
313
  <tbody>
190
314
  <tr
191
- v-for="entry in filtered"
315
+ v-if="virtualizedRowsEnabled && topVirtualPadding > 0"
316
+ class="fetch-dashboard__virtual-spacer-row"
317
+ aria-hidden="true"
318
+ >
319
+ <td colspan="7" :style="{ height: `${topVirtualPadding}px` }"></td>
320
+ </tr>
321
+ <tr
322
+ v-for="entry in visibleRows"
192
323
  :key="entry.id"
193
324
  :class="{ selected: selected?.id === entry.id }"
194
325
  @click="selectedId = entry.id"
@@ -221,16 +352,29 @@ function formatSize(bytes: number) {
221
352
  </div>
222
353
  </td>
223
354
  </tr>
355
+ <tr
356
+ v-if="virtualizedRowsEnabled && bottomVirtualPadding > 0"
357
+ class="fetch-dashboard__virtual-spacer-row"
358
+ aria-hidden="true"
359
+ >
360
+ <td colspan="7" :style="{ height: `${bottomVirtualPadding}px` }"></td>
361
+ </tr>
224
362
  <tr v-if="!filtered.length">
225
363
  <td colspan="7" class="tracker-empty-cell">
226
364
  {{ connected ? 'No fetches recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
227
365
  </td>
228
366
  </tr>
367
+ <tr v-else class="fetch-dashboard__pagination-row" aria-live="polite">
368
+ <td colspan="7" class="fetch-dashboard__pagination-cell">
369
+ <span class="mono muted text-sm">showing {{ pagedFiltered.length }} of {{ filtered.length }}</span>
370
+ <span v-if="hasMoreRows" class="mono muted text-sm">scroll to load more</span>
371
+ </td>
372
+ </tr>
229
373
  </tbody>
230
374
  </table>
231
375
  </div>
232
376
 
233
- <div v-if="selected" class="tracker-resize-handle" @mousedown="onHandleMouseDown" />
377
+ <div class="tracker-resize-handle" @mousedown="onHandleMouseDown" />
234
378
 
235
379
  <div v-if="selected" class="fetch-dashboard__detail tracker-detail-panel" :style="{ width: detailWidth + 'px' }">
236
380
  <div class="fetch-dashboard__detail-header">
@@ -253,7 +397,7 @@ function formatSize(bytes: number) {
253
397
  <div class="tracker-section-label fetch-dashboard__section-label fetch-dashboard__section-label--source">source</div>
254
398
  <div class="mono text-sm muted">{{ selected.file }}:{{ selected.line }}</div>
255
399
  </div>
256
- <div v-else class="tracker-detail-empty">select a call to inspect</div>
400
+ <div v-else class="tracker-detail-empty" :style="{ width: detailWidth + 'px' }">select a call to inspect</div>
257
401
  </div>
258
402
 
259
403
  <div class="fetch-dashboard__waterfall">
@@ -297,6 +441,25 @@ function formatSize(bytes: number) {
297
441
  min-width: 80px;
298
442
  }
299
443
 
444
+ .fetch-dashboard__virtual-spacer-row td {
445
+ padding: 0;
446
+ border-bottom: 0;
447
+ background: transparent;
448
+ }
449
+
450
+ .fetch-dashboard__pagination-row td {
451
+ background: transparent;
452
+ }
453
+
454
+ .fetch-dashboard__pagination-cell {
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: space-between;
458
+ gap: var(--tracker-space-2);
459
+ padding-top: var(--tracker-space-2);
460
+ border-bottom: 0;
461
+ }
462
+
300
463
  .fetch-dashboard__url {
301
464
  max-width: 200px;
302
465
  }
@@ -340,7 +503,7 @@ function formatSize(bytes: number) {
340
503
  padding: var(--tracker-space-2) 10px;
341
504
  overflow: auto;
342
505
  white-space: pre;
343
- max-height: 160px;
506
+ min-height: fit-content;
344
507
  }
345
508
 
346
509
  .fetch-dashboard__waterfall {
@@ -349,6 +512,8 @@ function formatSize(bytes: number) {
349
512
  border: var(--tracker-border-width) solid var(--border);
350
513
  border-radius: var(--radius-lg);
351
514
  padding: 10px var(--tracker-space-3);
515
+ max-height: 35%;
516
+ overflow-x: auto;
352
517
  }
353
518
 
354
519
  .fetch-dashboard__waterfall-header {
@@ -97,27 +97,46 @@ function matchesSearch(node: TreeNodeData, query: string): boolean {
97
97
  }
98
98
 
99
99
  /**
100
- * Count leaf nodes in a subtree iteratively to avoid stack overflow on
101
- * pathologically deep provide/inject trees (e.g. every component re-providing
102
- * the same key creates a chain as long as the component tree itself).
103
- * @param {TreeNodeData} root - The root node of the subtree to count leaves for.
104
- * @returns {number} The number of leaf nodes in the subtree.
100
+ * Build a leaf-count lookup for each node in visible trees.
101
+ * Uses iterative post-order traversal to keep deep trees stack-safe.
102
+ * @param {TreeNodeData[]} roots - Visible tree roots.
103
+ * @returns {Map<string, number>} Map of node id to subtree leaf count.
105
104
  */
106
- function countLeaves(root: TreeNodeData): number {
107
- let count = 0
108
- const stack: TreeNodeData[] = [root]
105
+ function buildLeafCountMap(roots: TreeNodeData[]): Map<string, number> {
106
+ const counts = new Map<string, number>()
109
107
 
110
- while (stack.length) {
111
- const node = stack.pop()!
108
+ for (const root of roots) {
109
+ const stack: Array<{ node: TreeNodeData; visited: boolean }> = [{ node: root, visited: false }]
112
110
 
113
- if (node.children.length === 0) {
114
- count++
115
- } else {
116
- stack.push(...node.children)
111
+ while (stack.length) {
112
+ const current = stack.pop()!
113
+
114
+ if (!current.visited) {
115
+ stack.push({ node: current.node, visited: true })
116
+
117
+ for (let i = current.node.children.length - 1; i >= 0; i--) {
118
+ stack.push({ node: current.node.children[i], visited: false })
119
+ }
120
+
121
+ continue
122
+ }
123
+
124
+ if (current.node.children.length === 0) {
125
+ counts.set(current.node.id, 1)
126
+ continue
127
+ }
128
+
129
+ let total = 0
130
+
131
+ for (const child of current.node.children) {
132
+ total += counts.get(child.id) ?? 1
133
+ }
134
+
135
+ counts.set(current.node.id, total)
117
136
  }
118
137
  }
119
138
 
120
- return count
139
+ return counts
121
140
  }
122
141
 
123
142
  function stringifyValue(value: unknown) {
@@ -359,6 +378,8 @@ const visibleNodes = computed<TreeNodeData[]>(() => {
359
378
  return nodes.value.map(pruneIterative).filter(Boolean) as TreeNodeData[]
360
379
  })
361
380
 
381
+ const visibleLeafCountById = computed(() => buildLeafCountMap(visibleNodes.value))
382
+
362
383
  watch([visibleNodes, selectedNode], ([currentNodes, currentSelected]) => {
363
384
  if (!currentSelected) {
364
385
  return
@@ -381,6 +402,8 @@ watch([visibleNodes, selectedNode], ([currentNodes, currentSelected]) => {
381
402
  const layout = computed<LayoutNode[]>(() => {
382
403
  const flat: LayoutNode[] = []
383
404
  const pad = H_GAP
405
+ const leafCountById = visibleLeafCountById.value
406
+ const getLeafCount = (node: TreeNodeData) => leafCountById.get(node.id) ?? 1
384
407
 
385
408
  // Iterative replacement for the recursive place() — avoids stack overflow
386
409
  // on deep component trees. Uses an explicit stack of pending work items.
@@ -398,7 +421,7 @@ const layout = computed<LayoutNode[]>(() => {
398
421
 
399
422
  while (stack.length) {
400
423
  const { node, depth, slotLeft, parentId } = stack.pop()!
401
- const leaves = countLeaves(node)
424
+ const leaves = getLeafCount(node)
402
425
  const slotWidth = leaves * (NODE_W + H_GAP) - H_GAP
403
426
 
404
427
  flat.push({
@@ -413,7 +436,7 @@ const layout = computed<LayoutNode[]>(() => {
413
436
  const childWork: WorkItem[] = []
414
437
 
415
438
  for (const child of node.children) {
416
- const childLeaves = countLeaves(child)
439
+ const childLeaves = getLeafCount(child)
417
440
  childWork.push({ node: child, depth: depth + 1, slotLeft: childLeft, parentId: node.id })
418
441
  childLeft += childLeaves * (NODE_W + H_GAP)
419
442
  }
@@ -423,7 +446,7 @@ const layout = computed<LayoutNode[]>(() => {
423
446
  }
424
447
  }
425
448
 
426
- const rootLeaves = countLeaves(root)
449
+ const rootLeaves = getLeafCount(root)
427
450
  left += rootLeaves * (NODE_W + H_GAP) + H_GAP * 2
428
451
  }
429
452