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.
- package/README.md +5 -0
- package/client/.env.example +1 -0
- package/client/dist/assets/index-BqKYgjVB.js +20 -0
- package/client/dist/assets/index-bs1JBJ2u.css +1 -0
- package/client/dist/index.html +2 -2
- package/client/src/App.vue +4 -0
- package/client/src/components/Flamegraph.vue +4 -4
- package/client/src/components/SpanInspector.vue +1 -1
- package/client/src/composables/composable-search.ts +3 -0
- package/client/src/composables/trace-render-aggregation.ts +11 -2
- package/client/src/composables/useVirtualizationConfig.ts +40 -0
- package/client/src/composables/useVirtualizationFlags.ts +129 -0
- package/client/src/views/ComposableTracker.vue +212 -71
- package/client/src/views/FetchDashboard.vue +181 -16
- package/client/src/views/ProvideInjectGraph.vue +41 -18
- package/client/src/views/RenderHeatmap.vue +329 -75
- package/client/src/views/TraceViewer.vue +190 -20
- package/client/src/views/TransitionTimeline.vue +112 -19
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +11 -22
- package/dist/runtime/composables/render-registry.js +6 -4
- package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
- package/dist/runtime/instrumentation/fetch.d.ts +7 -1
- package/dist/runtime/instrumentation/fetch.js +22 -1
- package/dist/runtime/plugin.js +4 -1
- package/dist/runtime/test-bridge.d.ts +18 -0
- package/dist/runtime/test-bridge.js +86 -0
- package/package.json +14 -3
- package/client/dist/assets/index-5Wl1XYRH.js +0 -17
- 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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* @
|
|
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
|
|
107
|
-
|
|
108
|
-
const stack: TreeNodeData[] = [root]
|
|
105
|
+
function buildLeafCountMap(roots: TreeNodeData[]): Map<string, number> {
|
|
106
|
+
const counts = new Map<string, number>()
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
const node =
|
|
108
|
+
for (const root of roots) {
|
|
109
|
+
const stack: Array<{ node: TreeNodeData; visited: boolean }> = [{ node: root, visited: false }]
|
|
112
110
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
449
|
+
const rootLeaves = getLeafCount(root)
|
|
427
450
|
left += rootLeaves * (NODE_W + H_GAP) + H_GAP * 2
|
|
428
451
|
}
|
|
429
452
|
|