nuxt-devtools-observatory 0.1.31 → 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 +79 -46
- 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 +7 -8
- package/client/src/components/SpanInspector.vue +1 -1
- package/client/src/components/TraceFilter.vue +0 -2
- package/client/src/components/WaterfallView.vue +1 -1
- package/client/src/composables/composable-search.ts +127 -0
- package/client/src/composables/trace-render-aggregation.ts +263 -0
- package/client/src/composables/useExportImport.ts +63 -0
- package/client/src/composables/useTraceFilter.ts +1 -5
- package/client/src/composables/useVirtualizationConfig.ts +40 -0
- package/client/src/composables/useVirtualizationFlags.ts +129 -0
- package/client/src/stores/observatory.ts +9 -1
- package/client/src/views/ComposableTracker.vue +273 -97
- package/client/src/views/FetchDashboard.vue +181 -16
- package/client/src/views/ProvideInjectGraph.vue +41 -18
- package/client/src/views/RenderHeatmap.vue +392 -76
- package/client/src/views/TraceViewer.vue +797 -14
- 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 +12 -23
- package/dist/runtime/composables/composable-registry.d.ts +19 -0
- package/dist/runtime/composables/composable-registry.js +63 -5
- package/dist/runtime/composables/render-registry.js +23 -13
- 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/nitro/fetch-capture.d.ts +1 -2
- package/dist/runtime/nitro/fetch-capture.js +85 -7
- package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
- package/dist/runtime/nitro/ssr-trace-store.js +84 -0
- package/dist/runtime/plugin.js +48 -1
- package/dist/runtime/test-bridge.d.ts +18 -0
- package/dist/runtime/test-bridge.js +86 -0
- package/dist/runtime/tracing/trace.d.ts +1 -1
- package/package.json +18 -3
- package/client/.env +0 -17
- package/client/dist/assets/index-BuMXDBO9.js +0 -17
- package/client/dist/assets/index-CwcspZ6w.css +0 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, defineComponent, h, ref, watch, type VNode } from 'vue'
|
|
3
|
+
import { useVirtualizer } from '@tanstack/vue-virtual'
|
|
4
|
+
import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
|
|
5
|
+
import { useVirtualizationFlags } from '@observatory-client/composables/useVirtualizationFlags'
|
|
3
6
|
import { useResizablePane } from '@observatory-client/composables/useResizablePane'
|
|
4
7
|
import { useObservatoryData, openInEditor as openInEditorFromStore } from '@observatory-client/stores/observatory'
|
|
8
|
+
import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
|
|
9
|
+
import type { ObservatoryExportFile } from '@observatory-client/composables/useExportImport'
|
|
5
10
|
import type { RenderEntry, RenderEvent } from '@observatory/types/snapshot'
|
|
6
11
|
|
|
7
12
|
interface ComponentNode {
|
|
@@ -24,6 +29,37 @@ interface ComponentNode {
|
|
|
24
29
|
route: string
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
interface VisibleTreeRow {
|
|
33
|
+
node: ComponentNode
|
|
34
|
+
primaryBadge: string | null
|
|
35
|
+
metricValue: string
|
|
36
|
+
metricLabel: string
|
|
37
|
+
hot: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nodeBadges(node: ComponentNode): string[] {
|
|
41
|
+
const badges: string[] = []
|
|
42
|
+
const normalizedElement = node.element?.toLowerCase()
|
|
43
|
+
|
|
44
|
+
if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
|
|
45
|
+
badges.push(node.element)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (node.file !== 'unknown') {
|
|
49
|
+
const fileBadge =
|
|
50
|
+
node.file
|
|
51
|
+
.split('/')
|
|
52
|
+
.pop()
|
|
53
|
+
?.replace(/\.vue$/i, '') ?? node.file
|
|
54
|
+
|
|
55
|
+
if (fileBadge !== node.label && !badges.includes(fileBadge)) {
|
|
56
|
+
badges.push(fileBadge)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return badges
|
|
61
|
+
}
|
|
62
|
+
|
|
27
63
|
const TreeNode = defineComponent({
|
|
28
64
|
name: 'TreeNode',
|
|
29
65
|
props: {
|
|
@@ -56,24 +92,7 @@ const TreeNode = defineComponent({
|
|
|
56
92
|
const canExpand = node.children.length > 0
|
|
57
93
|
const metric = props.mode === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`
|
|
58
94
|
const metricLabel = props.mode === 'count' ? 'renders' : 'avg'
|
|
59
|
-
const badges =
|
|
60
|
-
const normalizedElement = node.element?.toLowerCase()
|
|
61
|
-
|
|
62
|
-
if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
|
|
63
|
-
badges.push(node.element)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (node.file !== 'unknown') {
|
|
67
|
-
const fileBadge =
|
|
68
|
-
node.file
|
|
69
|
-
.split('/')
|
|
70
|
-
.pop()
|
|
71
|
-
?.replace(/\.vue$/i, '') ?? node.file
|
|
72
|
-
|
|
73
|
-
if (fileBadge !== node.label && !badges.includes(fileBadge)) {
|
|
74
|
-
badges.push(fileBadge)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
95
|
+
const badges = nodeBadges(node)
|
|
77
96
|
|
|
78
97
|
return h('div', { class: 'tree-node' }, [
|
|
79
98
|
h(
|
|
@@ -197,12 +216,17 @@ const activeThreshold = computed({
|
|
|
197
216
|
})
|
|
198
217
|
const activeHotOnly = ref(false)
|
|
199
218
|
const frozen = ref(false)
|
|
219
|
+
const isImportedSnapshot = ref(false)
|
|
200
220
|
const search = ref('')
|
|
201
221
|
const activeSelectedId = ref<string | null>(null)
|
|
202
222
|
const activeRootId = ref<string | null>(null)
|
|
203
223
|
const expandedIds = ref<Set<string>>(new Set())
|
|
204
224
|
const frozenSnapshot = ref<RenderEntry[]>([])
|
|
205
225
|
const expansionReady = ref(false)
|
|
226
|
+
const treeFrameRef = ref<HTMLElement | null>(null)
|
|
227
|
+
|
|
228
|
+
const { effective: virtualizationFlags } = useVirtualizationFlags()
|
|
229
|
+
const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 34, overscan: 6 })
|
|
206
230
|
|
|
207
231
|
function displayLabel(entry: RenderEntry) {
|
|
208
232
|
if (entry.name && entry.name !== 'unknown' && !/^Component#\d+$/.test(entry.name)) {
|
|
@@ -300,33 +324,55 @@ function flatten(nodes: ComponentNode[]) {
|
|
|
300
324
|
return flat
|
|
301
325
|
}
|
|
302
326
|
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
}
|
|
327
|
+
function buildSubtreeSizeMap(roots: ComponentNode[]) {
|
|
328
|
+
const sizes = new Map<string, number>()
|
|
306
329
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
node.children.forEach((child) => collectIds(child, target))
|
|
330
|
+
for (const root of roots) {
|
|
331
|
+
const stack: Array<{ node: ComponentNode; visited: boolean }> = [{ node: root, visited: false }]
|
|
310
332
|
|
|
311
|
-
|
|
312
|
-
|
|
333
|
+
while (stack.length) {
|
|
334
|
+
const current = stack.pop()!
|
|
335
|
+
|
|
336
|
+
if (!current.visited) {
|
|
337
|
+
stack.push({ node: current.node, visited: true })
|
|
338
|
+
|
|
339
|
+
for (let i = current.node.children.length - 1; i >= 0; i--) {
|
|
340
|
+
stack.push({ node: current.node.children[i], visited: false })
|
|
341
|
+
}
|
|
313
342
|
|
|
314
|
-
|
|
315
|
-
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let size = 1
|
|
347
|
+
|
|
348
|
+
for (const child of current.node.children) {
|
|
349
|
+
size += sizes.get(child.id) ?? 1
|
|
350
|
+
}
|
|
316
351
|
|
|
317
|
-
|
|
318
|
-
|
|
352
|
+
sizes.set(current.node.id, size)
|
|
353
|
+
}
|
|
319
354
|
}
|
|
320
355
|
|
|
321
|
-
|
|
322
|
-
|
|
356
|
+
return sizes
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function buildRootLookup(roots: ComponentNode[]) {
|
|
360
|
+
const lookup = new Map<string, string>()
|
|
323
361
|
|
|
324
|
-
|
|
325
|
-
|
|
362
|
+
for (const root of roots) {
|
|
363
|
+
const stack: ComponentNode[] = [root]
|
|
364
|
+
|
|
365
|
+
while (stack.length) {
|
|
366
|
+
const node = stack.pop()!
|
|
367
|
+
lookup.set(node.id, root.id)
|
|
368
|
+
|
|
369
|
+
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
370
|
+
stack.push(node.children[i])
|
|
371
|
+
}
|
|
326
372
|
}
|
|
327
373
|
}
|
|
328
374
|
|
|
329
|
-
return
|
|
375
|
+
return lookup
|
|
330
376
|
}
|
|
331
377
|
|
|
332
378
|
function findFirstHotNode(node: ComponentNode): ComponentNode | null {
|
|
@@ -473,6 +519,37 @@ const displayEntries = computed(() => (frozen.value ? frozenSnapshot.value : ren
|
|
|
473
519
|
const rootNodes = computed(() => buildNodes(displayEntries.value))
|
|
474
520
|
const rootMap = computed(() => new Map(rootNodes.value.map((node) => [node.id, node])))
|
|
475
521
|
const allComponents = computed(() => flatten(rootNodes.value))
|
|
522
|
+
const allComponentsById = computed(() => new Map(allComponents.value.map((node) => [node.id, node])))
|
|
523
|
+
const allComponentIds = computed(() => new Set(allComponents.value.map((node) => node.id)))
|
|
524
|
+
const subtreeSizeById = computed(() => buildSubtreeSizeMap(rootNodes.value))
|
|
525
|
+
const rootIdByNodeId = computed(() => buildRootLookup(rootNodes.value))
|
|
526
|
+
|
|
527
|
+
function pathToNodeWithinRoot(targetId: string, rootId?: string | null): string[] {
|
|
528
|
+
const path: string[] = []
|
|
529
|
+
let current = allComponentsById.value.get(targetId)
|
|
530
|
+
|
|
531
|
+
while (current) {
|
|
532
|
+
path.push(current.id)
|
|
533
|
+
|
|
534
|
+
if (rootId && current.id === rootId) {
|
|
535
|
+
break
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (!current.parentId) {
|
|
539
|
+
break
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
current = allComponentsById.value.get(current.parentId)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
path.reverse()
|
|
546
|
+
|
|
547
|
+
if (rootId && path[0] !== rootId) {
|
|
548
|
+
return []
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return path
|
|
552
|
+
}
|
|
476
553
|
|
|
477
554
|
const filteredRoots = computed(() => {
|
|
478
555
|
const term = search.value.trim()
|
|
@@ -480,9 +557,11 @@ const filteredRoots = computed(() => {
|
|
|
480
557
|
return rootNodes.value.filter((root) => isVisibleRoot(root, term))
|
|
481
558
|
})
|
|
482
559
|
|
|
560
|
+
const filteredRootById = computed(() => new Map(filteredRoots.value.map((node) => [node.id, node])))
|
|
561
|
+
|
|
483
562
|
const activeRoot = computed(() => {
|
|
484
563
|
if (activeRootId.value) {
|
|
485
|
-
return
|
|
564
|
+
return filteredRootById.value.get(activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
|
|
486
565
|
}
|
|
487
566
|
|
|
488
567
|
return filteredRoots.value[0] ?? rootNodes.value[0] ?? null
|
|
@@ -502,11 +581,99 @@ const visibleTreeRoots = computed(() => {
|
|
|
502
581
|
return [visibleActiveRoot.value]
|
|
503
582
|
})
|
|
504
583
|
|
|
584
|
+
const virtualizedTreeEnabled = computed(() => virtualizationFlags.value.heatmap)
|
|
585
|
+
|
|
586
|
+
function flattenVisibleTree(root: ComponentNode | null, expanded: Set<string>) {
|
|
587
|
+
if (!root) {
|
|
588
|
+
return [] as ComponentNode[]
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const rows: ComponentNode[] = []
|
|
592
|
+
|
|
593
|
+
function walk(node: ComponentNode) {
|
|
594
|
+
rows.push(node)
|
|
595
|
+
|
|
596
|
+
if (!expanded.has(node.id)) {
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
node.children.forEach(walk)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
walk(root)
|
|
604
|
+
|
|
605
|
+
return rows
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const expandedVisibleNodes = computed(() => flattenVisibleTree(visibleActiveRoot.value, expandedIds.value))
|
|
609
|
+
|
|
610
|
+
const treeVirtualizerOptions = computed(() => ({
|
|
611
|
+
count: expandedVisibleNodes.value.length,
|
|
612
|
+
getScrollElement: () => treeFrameRef.value,
|
|
613
|
+
estimateSize: () => virtualizationPreset.value.rowHeight,
|
|
614
|
+
overscan: virtualizationPreset.value.overscan,
|
|
615
|
+
}))
|
|
616
|
+
|
|
617
|
+
const treeVirtualizer = useVirtualizer(treeVirtualizerOptions)
|
|
618
|
+
|
|
619
|
+
const treeVirtualItems = computed(() => {
|
|
620
|
+
if (!virtualizedTreeEnabled.value) {
|
|
621
|
+
return []
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return treeVirtualizer.value.getVirtualItems()
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const topTreePadding = computed(() => {
|
|
628
|
+
if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
|
|
629
|
+
return 0
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return treeVirtualItems.value[0].start
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const bottomTreePadding = computed(() => {
|
|
636
|
+
if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
|
|
637
|
+
return 0
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const total = treeVirtualizer.value.getTotalSize()
|
|
641
|
+
const last = treeVirtualItems.value[treeVirtualItems.value.length - 1]
|
|
642
|
+
|
|
643
|
+
return Math.max(0, total - last.end)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
const visibleTreeRows = computed(() => {
|
|
647
|
+
if (!virtualizedTreeEnabled.value) {
|
|
648
|
+
return expandedVisibleNodes.value
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return treeVirtualItems.value
|
|
652
|
+
.map((item) => expandedVisibleNodes.value[item.index])
|
|
653
|
+
.filter((node): node is ComponentNode => Boolean(node))
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
const visibleTreeRowItems = computed<VisibleTreeRow[]>(() => {
|
|
657
|
+
const metricLabel = activeMode.value === 'count' ? 'renders' : 'avg'
|
|
658
|
+
|
|
659
|
+
return visibleTreeRows.value.map((node) => {
|
|
660
|
+
const badges = nodeBadges(node)
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
node,
|
|
664
|
+
primaryBadge: badges[0] ?? null,
|
|
665
|
+
metricValue: activeMode.value === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`,
|
|
666
|
+
metricLabel,
|
|
667
|
+
hot: isHot(node),
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
|
|
505
672
|
const appEntries = computed(() =>
|
|
506
673
|
filteredRoots.value.map((root, index) => ({
|
|
507
674
|
id: root.id,
|
|
508
675
|
label: `App ${index + 1}`,
|
|
509
|
-
meta: `${
|
|
676
|
+
meta: `${subtreeSizeById.value.get(root.id) ?? 1} nodes`,
|
|
510
677
|
root,
|
|
511
678
|
}))
|
|
512
679
|
)
|
|
@@ -526,7 +693,31 @@ const knownRoutes = computed(() => {
|
|
|
526
693
|
return [...routes].sort()
|
|
527
694
|
})
|
|
528
695
|
|
|
529
|
-
const activeSelected = computed(() =>
|
|
696
|
+
const activeSelected = computed(() => {
|
|
697
|
+
if (!activeSelectedId.value) {
|
|
698
|
+
return null
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return allComponentsById.value.get(activeSelectedId.value) ?? null
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const activeSelectedTimelineRecent = computed(() => {
|
|
705
|
+
const timeline = activeSelected.value?.timeline ?? []
|
|
706
|
+
|
|
707
|
+
if (!timeline.length) {
|
|
708
|
+
return [] as Array<{ key: string; event: RenderEvent }>
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const recent: Array<{ key: string; event: RenderEvent }> = []
|
|
712
|
+
const end = Math.max(timeline.length - 30, 0)
|
|
713
|
+
|
|
714
|
+
for (let i = timeline.length - 1; i >= end; i--) {
|
|
715
|
+
const event = timeline[i]
|
|
716
|
+
recent.push({ key: `${event.kind}-${event.t}-${i}`, event })
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return recent
|
|
720
|
+
})
|
|
530
721
|
const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.rerenders + node.mountCount, 0))
|
|
531
722
|
const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
|
|
532
723
|
const avgTime = computed(() => {
|
|
@@ -557,12 +748,11 @@ watch(
|
|
|
557
748
|
activeRootId.value = roots[0].id
|
|
558
749
|
}
|
|
559
750
|
|
|
560
|
-
if (activeSelectedId.value && !
|
|
751
|
+
if (activeSelectedId.value && !allComponentIds.value.has(activeSelectedId.value)) {
|
|
561
752
|
activeSelectedId.value = null
|
|
562
753
|
}
|
|
563
754
|
|
|
564
|
-
const
|
|
565
|
-
const preserved = new Set([...expandedIds.value].filter((id) => validIds.has(id)))
|
|
755
|
+
const preserved = new Set([...expandedIds.value].filter((id) => allComponentIds.value.has(id)))
|
|
566
756
|
|
|
567
757
|
if (!expansionReady.value) {
|
|
568
758
|
expandedIds.value = defaultExpandedIds(activeRoot.value)
|
|
@@ -572,7 +762,7 @@ watch(
|
|
|
572
762
|
}
|
|
573
763
|
|
|
574
764
|
if (!search.value.trim() && activeSelectedId.value && activeRoot.value) {
|
|
575
|
-
const selectedPath =
|
|
765
|
+
const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
|
|
576
766
|
selectedPath.forEach((id) => preserved.add(id))
|
|
577
767
|
}
|
|
578
768
|
|
|
@@ -595,8 +785,8 @@ watch(search, (term) => {
|
|
|
595
785
|
}
|
|
596
786
|
|
|
597
787
|
if (activeSelectedId.value) {
|
|
598
|
-
const selectedPath =
|
|
599
|
-
expandedIds.value = selectedPath ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
|
|
788
|
+
const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
|
|
789
|
+
expandedIds.value = selectedPath.length ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
|
|
600
790
|
|
|
601
791
|
return
|
|
602
792
|
}
|
|
@@ -631,17 +821,18 @@ watch([activeHotOnly, activeThreshold, activeMode, filteredRoots], () => {
|
|
|
631
821
|
activeSelectedId.value = firstHot.id
|
|
632
822
|
}
|
|
633
823
|
|
|
634
|
-
expandedIds.value = new Set(
|
|
824
|
+
expandedIds.value = new Set(pathToNodeWithinRoot(firstHot.id, topLevelRoot.id))
|
|
635
825
|
})
|
|
636
826
|
|
|
637
827
|
function selectNode(node: ComponentNode) {
|
|
638
828
|
activeSelectedId.value = node.id
|
|
639
829
|
|
|
640
|
-
const
|
|
830
|
+
const rootId = rootIdByNodeId.value.get(node.id)
|
|
831
|
+
const topLevelRoot = rootId ? rootMap.value.get(rootId) : null
|
|
641
832
|
|
|
642
833
|
if (topLevelRoot) {
|
|
643
834
|
activeRootId.value = topLevelRoot.id
|
|
644
|
-
expandedIds.value = new Set(
|
|
835
|
+
expandedIds.value = new Set(pathToNodeWithinRoot(node.id, topLevelRoot.id))
|
|
645
836
|
}
|
|
646
837
|
}
|
|
647
838
|
|
|
@@ -671,6 +862,7 @@ function updateSearch(event: Event) {
|
|
|
671
862
|
function toggleFreeze() {
|
|
672
863
|
if (frozen.value) {
|
|
673
864
|
frozen.value = false
|
|
865
|
+
isImportedSnapshot.value = false
|
|
674
866
|
frozenSnapshot.value = []
|
|
675
867
|
|
|
676
868
|
return
|
|
@@ -680,6 +872,45 @@ function toggleFreeze() {
|
|
|
680
872
|
frozen.value = true
|
|
681
873
|
}
|
|
682
874
|
|
|
875
|
+
function handleExport() {
|
|
876
|
+
exportJson(`observatory-renders-${Date.now()}.json`, {
|
|
877
|
+
type: 'observatory-renders',
|
|
878
|
+
version: '1',
|
|
879
|
+
exportedAt: Date.now(),
|
|
880
|
+
count: displayEntries.value.length,
|
|
881
|
+
data: displayEntries.value,
|
|
882
|
+
})
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function handleImport() {
|
|
886
|
+
let parsed: unknown
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
parsed = await importJson()
|
|
890
|
+
} catch (err) {
|
|
891
|
+
if (err instanceof Error && err.message !== 'cancelled') {
|
|
892
|
+
alert(`Import failed: ${err.message}`)
|
|
893
|
+
}
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const file = parsed as ObservatoryExportFile<RenderEntry>
|
|
898
|
+
|
|
899
|
+
if (
|
|
900
|
+
file?.type !== 'observatory-renders' ||
|
|
901
|
+
file?.version !== '1' ||
|
|
902
|
+
!Array.isArray(file?.data) ||
|
|
903
|
+
(file.data.length > 0 && (file.data[0]?.uid === undefined || !file.data[0]?.name || !file.data[0]?.file))
|
|
904
|
+
) {
|
|
905
|
+
alert('Invalid observatory renders file.')
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
frozenSnapshot.value = file.data
|
|
910
|
+
frozen.value = true
|
|
911
|
+
isImportedSnapshot.value = true
|
|
912
|
+
}
|
|
913
|
+
|
|
683
914
|
function basename(file: string) {
|
|
684
915
|
return (
|
|
685
916
|
file
|
|
@@ -732,8 +963,10 @@ function formatTimestamp(t: number): string {
|
|
|
732
963
|
<option v-for="r in knownRoutes" :key="r" :value="r">{{ r }}</option>
|
|
733
964
|
</select>
|
|
734
965
|
<button :class="{ active: frozen }" class="render-heatmap__freeze tracker-toolbar__spacer" @click="toggleFreeze">
|
|
735
|
-
{{ frozen ? 'unfreeze' : 'freeze snapshot' }}
|
|
966
|
+
{{ frozen && isImportedSnapshot ? 'unfreeze (imported)' : frozen ? 'unfreeze' : 'freeze snapshot' }}
|
|
736
967
|
</button>
|
|
968
|
+
<button class="render-heatmap__action-btn" title="Export render data as JSON" @click="handleExport">↓ export</button>
|
|
969
|
+
<button class="render-heatmap__action-btn" title="Import render data from JSON file" @click="handleImport">↑ import</button>
|
|
737
970
|
</div>
|
|
738
971
|
|
|
739
972
|
<div class="render-heatmap__stats tracker-stats-row">
|
|
@@ -784,21 +1017,89 @@ function formatTimestamp(t: number): string {
|
|
|
784
1017
|
/>
|
|
785
1018
|
</div>
|
|
786
1019
|
|
|
787
|
-
<div class="render-heatmap__tree-frame">
|
|
1020
|
+
<div ref="treeFrameRef" class="render-heatmap__tree-frame">
|
|
788
1021
|
<div class="render-heatmap__tree-canvas tree-canvas">
|
|
789
|
-
<
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1022
|
+
<template v-if="virtualizedTreeEnabled">
|
|
1023
|
+
<div
|
|
1024
|
+
v-if="topTreePadding > 0"
|
|
1025
|
+
class="render-heatmap__tree-spacer"
|
|
1026
|
+
:style="{ height: `${topTreePadding}px` }"
|
|
1027
|
+
aria-hidden="true"
|
|
1028
|
+
/>
|
|
1029
|
+
<div
|
|
1030
|
+
v-for="row in visibleTreeRowItems"
|
|
1031
|
+
:key="row.node.id"
|
|
1032
|
+
class="tree-row"
|
|
1033
|
+
:class="{ selected: activeSelected?.id === row.node.id, hot: row.hot }"
|
|
1034
|
+
:style="{ '--tree-depth': String(row.node.depth) }"
|
|
1035
|
+
@click="selectNode(row.node)"
|
|
1036
|
+
>
|
|
1037
|
+
<span class="tree-rail" aria-hidden="true" />
|
|
1038
|
+
<button
|
|
1039
|
+
class="tree-toggle"
|
|
1040
|
+
:class="{ empty: !row.node.children.length }"
|
|
1041
|
+
:disabled="!row.node.children.length"
|
|
1042
|
+
@click.stop="row.node.children.length ? toggleNode(row.node.id) : undefined"
|
|
1043
|
+
>
|
|
1044
|
+
{{ row.node.children.length ? (expandedIds.has(row.node.id) ? '⌄' : '›') : '' }}
|
|
1045
|
+
</button>
|
|
1046
|
+
<div class="tree-copy">
|
|
1047
|
+
<span class="tree-name mono" :title="row.node.label">{{ row.node.label }}</span>
|
|
1048
|
+
<div v-if="row.primaryBadge" class="tree-badges">
|
|
1049
|
+
<span class="tree-badge mono" :title="row.primaryBadge">{{ row.primaryBadge }}</span>
|
|
1050
|
+
</div>
|
|
1051
|
+
</div>
|
|
1052
|
+
<div class="tree-metrics mono">
|
|
1053
|
+
<span
|
|
1054
|
+
v-if="row.node.isPersistent"
|
|
1055
|
+
class="tree-persistent-pill"
|
|
1056
|
+
title="Layout / persistent component — survives navigation"
|
|
1057
|
+
>
|
|
1058
|
+
persistent
|
|
1059
|
+
</span>
|
|
1060
|
+
<span
|
|
1061
|
+
v-if="row.node.isHydrationMount"
|
|
1062
|
+
class="tree-hydration-pill"
|
|
1063
|
+
title="First mount was SSR hydration — not a user-triggered render"
|
|
1064
|
+
>
|
|
1065
|
+
hydrated
|
|
1066
|
+
</span>
|
|
1067
|
+
<span class="tree-metric-pill">
|
|
1068
|
+
{{ row.metricValue }}
|
|
1069
|
+
{{ row.metricLabel }}
|
|
1070
|
+
</span>
|
|
1071
|
+
<button
|
|
1072
|
+
v-if="row.node.file && row.node.file !== 'unknown'"
|
|
1073
|
+
class="tree-jump-btn"
|
|
1074
|
+
:title="`Open ${row.node.file} in editor`"
|
|
1075
|
+
@click.stop="openInEditor(row.node.file)"
|
|
1076
|
+
>
|
|
1077
|
+
↗
|
|
1078
|
+
</button>
|
|
1079
|
+
</div>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div
|
|
1082
|
+
v-if="bottomTreePadding > 0"
|
|
1083
|
+
class="render-heatmap__tree-spacer"
|
|
1084
|
+
:style="{ height: `${bottomTreePadding}px` }"
|
|
1085
|
+
aria-hidden="true"
|
|
1086
|
+
/>
|
|
1087
|
+
</template>
|
|
1088
|
+
<template v-else>
|
|
1089
|
+
<TreeNode
|
|
1090
|
+
v-for="root in visibleTreeRoots"
|
|
1091
|
+
:key="root.id"
|
|
1092
|
+
:node="root"
|
|
1093
|
+
:mode="activeMode"
|
|
1094
|
+
:threshold="activeThreshold"
|
|
1095
|
+
:selected="activeSelected?.id"
|
|
1096
|
+
:expanded-ids="expandedIds"
|
|
1097
|
+
@select="selectNode"
|
|
1098
|
+
@toggle="toggleNode"
|
|
1099
|
+
/>
|
|
1100
|
+
</template>
|
|
800
1101
|
</div>
|
|
801
|
-
<div v-if="!
|
|
1102
|
+
<div v-if="!expandedVisibleNodes.length" class="render-heatmap__detail-empty">
|
|
802
1103
|
{{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
|
|
803
1104
|
</div>
|
|
804
1105
|
</div>
|
|
@@ -906,16 +1207,14 @@ function formatTimestamp(t: number): string {
|
|
|
906
1207
|
</div>
|
|
907
1208
|
<div v-if="!activeSelected.timeline.length" class="muted text-sm">no timeline events yet</div>
|
|
908
1209
|
<div v-else class="render-heatmap__timeline-list">
|
|
909
|
-
<div
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
class="render-heatmap__timeline-row
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
<span class="render-heatmap__timeline-
|
|
917
|
-
<span v-if="event.triggerKey" class="render-heatmap__timeline-trigger mono muted">{{ event.triggerKey }}</span>
|
|
918
|
-
<span class="render-heatmap__timeline-route mono muted">{{ event.route }}</span>
|
|
1210
|
+
<div v-for="row in activeSelectedTimelineRecent" :key="row.key" class="render-heatmap__timeline-row">
|
|
1211
|
+
<span class="render-heatmap__timeline-kind mono" :class="row.event.kind">{{ row.event.kind }}</span>
|
|
1212
|
+
<span class="render-heatmap__timeline-time mono muted">{{ formatTimestamp(row.event.t) }}</span>
|
|
1213
|
+
<span class="render-heatmap__timeline-dur mono">{{ formatMs(row.event.durationMs) }}</span>
|
|
1214
|
+
<span v-if="row.event.triggerKey" class="render-heatmap__timeline-trigger mono muted">
|
|
1215
|
+
{{ row.event.triggerKey }}
|
|
1216
|
+
</span>
|
|
1217
|
+
<span class="render-heatmap__timeline-route mono muted">{{ row.event.route }}</span>
|
|
919
1218
|
</div>
|
|
920
1219
|
<div v-if="activeSelected.timeline.length > 30" class="render-heatmap__compact-muted muted text-sm">
|
|
921
1220
|
… {{ activeSelected.timeline.length - 30 }} earlier events
|
|
@@ -952,6 +1251,23 @@ function formatTimestamp(t: number): string {
|
|
|
952
1251
|
width: 90px;
|
|
953
1252
|
}
|
|
954
1253
|
|
|
1254
|
+
.render-heatmap__action-btn {
|
|
1255
|
+
padding: 3px 8px;
|
|
1256
|
+
background: none;
|
|
1257
|
+
border: 1px solid var(--border);
|
|
1258
|
+
color: var(--text-secondary);
|
|
1259
|
+
cursor: pointer;
|
|
1260
|
+
font-size: 11px;
|
|
1261
|
+
border-radius: 3px;
|
|
1262
|
+
transition: all 0.12s;
|
|
1263
|
+
font-family: var(--mono);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.render-heatmap__action-btn:hover {
|
|
1267
|
+
background: var(--bg-secondary);
|
|
1268
|
+
color: var(--text);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
955
1271
|
.stat-sub {
|
|
956
1272
|
margin-top: var(--tracker-space-1);
|
|
957
1273
|
font-size: var(--tracker-font-size-sm);
|
|
@@ -965,11 +1281,6 @@ function formatTimestamp(t: number): string {
|
|
|
965
1281
|
min-height: 0;
|
|
966
1282
|
}
|
|
967
1283
|
|
|
968
|
-
.render-heatmap__roots-panel,
|
|
969
|
-
.render-heatmap__detail-panel {
|
|
970
|
-
flex-shrink: 0;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
1284
|
.render-heatmap__roots-panel {
|
|
974
1285
|
width: 240px;
|
|
975
1286
|
margin-right: 12px;
|
|
@@ -986,6 +1297,7 @@ function formatTimestamp(t: number): string {
|
|
|
986
1297
|
|
|
987
1298
|
.render-heatmap__roots-panel,
|
|
988
1299
|
.render-heatmap__detail-panel {
|
|
1300
|
+
flex-shrink: 0;
|
|
989
1301
|
display: flex;
|
|
990
1302
|
flex-direction: column;
|
|
991
1303
|
overflow: auto;
|
|
@@ -1072,6 +1384,10 @@ function formatTimestamp(t: number): string {
|
|
|
1072
1384
|
width: max-content;
|
|
1073
1385
|
}
|
|
1074
1386
|
|
|
1387
|
+
.render-heatmap__tree-spacer {
|
|
1388
|
+
width: 100%;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1075
1391
|
:deep(.tree-node) {
|
|
1076
1392
|
margin-bottom: 4px;
|
|
1077
1393
|
}
|