nuxt-devtools-observatory 0.1.6 → 0.1.8
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/client/dist/assets/index-CVI-arV5.js +17 -0
- package/client/dist/assets/{index-CjQU78-e.css → index-RdqCF5ft.css} +1 -1
- package/client/dist/index.html +2 -2
- package/client/src/stores/observatory.ts +144 -122
- package/client/src/views/ComposableTracker.vue +99 -58
- package/client/src/views/FetchDashboard.vue +102 -90
- package/client/src/views/ProvideInjectGraph.vue +149 -76
- package/client/src/views/RenderHeatmap.vue +194 -46
- package/dist/module.json +1 -1
- package/dist/module.mjs +13 -36
- package/dist/runtime/composables/composable-registry.js +10 -4
- package/dist/runtime/composables/fetch-registry.d.ts +16 -2
- package/dist/runtime/composables/fetch-registry.js +50 -117
- package/dist/runtime/composables/provide-inject-registry.d.ts +4 -0
- package/dist/runtime/composables/provide-inject-registry.js +4 -0
- package/dist/runtime/composables/render-registry.d.ts +1 -2
- package/dist/runtime/composables/render-registry.js +30 -22
- package/dist/runtime/plugin.js +49 -21
- package/package.json +5 -3
- package/client/dist/assets/index-lG5ffIvt.js +0 -17
- package/dist/nitro/fetch-capture.d.mts +0 -3
- package/dist/nitro/fetch-capture.mjs +0 -16
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import { useObservatoryData } from '../stores/observatory'
|
|
3
|
+
import { useObservatoryData, type InjectEntry, type ProvideEntry } from '../stores/observatory'
|
|
4
4
|
|
|
5
5
|
interface TreeNodeData {
|
|
6
6
|
id: string
|
|
@@ -31,40 +31,112 @@ const NODE_H = 32
|
|
|
31
31
|
const V_GAP = 72
|
|
32
32
|
const H_GAP = 18
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
const { provideInject, connected } = useObservatoryData()
|
|
35
|
+
|
|
36
|
+
function nodeColor(node: TreeNodeData): string {
|
|
37
|
+
if (node.injects.some((entry) => !entry.ok)) return 'var(--red)'
|
|
38
|
+
if (node.type === 'both') return 'var(--blue)'
|
|
39
|
+
if (node.type === 'provider') return 'var(--teal)'
|
|
40
|
+
return 'var(--text3)'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesFilter(node: TreeNodeData, filter: string): boolean {
|
|
44
|
+
if (filter === 'all') return true
|
|
45
|
+
if (filter === 'warn') return node.injects.some((entry) => !entry.ok)
|
|
46
|
+
return node.provides.some((entry) => entry.key === filter) || node.injects.some((entry) => entry.key === filter)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function countLeaves(node: TreeNodeData): number {
|
|
50
|
+
return node.children.length === 0 ? 1 : node.children.reduce((sum, child) => sum + countLeaves(child), 0)
|
|
51
|
+
}
|
|
38
52
|
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
function stringifyValue(value: unknown) {
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
return value
|
|
41
56
|
}
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
return
|
|
58
|
+
try {
|
|
59
|
+
return JSON.stringify(value)
|
|
60
|
+
} catch {
|
|
61
|
+
return String(value)
|
|
45
62
|
}
|
|
63
|
+
}
|
|
46
64
|
|
|
47
|
-
|
|
65
|
+
function componentId(entry: ProvideEntry | InjectEntry) {
|
|
66
|
+
return String(entry.componentUid)
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
const nodes = computed<TreeNodeData[]>(() => {
|
|
70
|
+
const nodeMap = new Map<string, TreeNodeData>()
|
|
71
|
+
const parentMap = new Map<string, string | null>()
|
|
72
|
+
|
|
73
|
+
function ensureNode(entry: ProvideEntry | InjectEntry) {
|
|
74
|
+
const id = componentId(entry)
|
|
75
|
+
const existing = nodeMap.get(id)
|
|
76
|
+
|
|
77
|
+
if (existing) {
|
|
78
|
+
return existing
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const created: TreeNodeData = {
|
|
82
|
+
id,
|
|
83
|
+
label: entry.componentFile,
|
|
84
|
+
type: 'consumer',
|
|
85
|
+
provides: [],
|
|
86
|
+
injects: [],
|
|
87
|
+
children: [],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
nodeMap.set(id, created)
|
|
91
|
+
parentMap.set(id, entry.parentUid !== undefined ? String(entry.parentUid) : null)
|
|
92
|
+
return created
|
|
53
93
|
}
|
|
54
94
|
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
for (const entry of provideInject.value.provides) {
|
|
96
|
+
const node = ensureNode(entry)
|
|
97
|
+
node.provides.push({
|
|
98
|
+
key: entry.key,
|
|
99
|
+
val: stringifyValue(entry.valueSnapshot),
|
|
100
|
+
reactive: entry.isReactive,
|
|
101
|
+
})
|
|
57
102
|
}
|
|
58
103
|
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
for (const entry of provideInject.value.injects) {
|
|
105
|
+
const node = ensureNode(entry)
|
|
106
|
+
node.injects.push({
|
|
107
|
+
key: entry.key,
|
|
108
|
+
from: entry.resolvedFromFile ?? null,
|
|
109
|
+
ok: entry.resolved,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
61
112
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
113
|
+
for (const node of nodeMap.values()) {
|
|
114
|
+
if (node.injects.some((entry) => !entry.ok)) {
|
|
115
|
+
node.type = 'error'
|
|
116
|
+
} else if (node.provides.length && node.injects.length) {
|
|
117
|
+
node.type = 'both'
|
|
118
|
+
} else if (node.provides.length) {
|
|
119
|
+
node.type = 'provider'
|
|
120
|
+
} else {
|
|
121
|
+
node.type = 'consumer'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const roots: TreeNodeData[] = []
|
|
65
126
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
127
|
+
for (const [id, node] of nodeMap.entries()) {
|
|
128
|
+
const parentId = parentMap.get(id)
|
|
129
|
+
const parent = parentId ? nodeMap.get(parentId) : undefined
|
|
130
|
+
|
|
131
|
+
if (parent) {
|
|
132
|
+
parent.children.push(node)
|
|
133
|
+
} else {
|
|
134
|
+
roots.push(node)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return roots
|
|
139
|
+
})
|
|
68
140
|
|
|
69
141
|
const activeFilter = ref('all')
|
|
70
142
|
const selectedNode = ref<TreeNodeData | null>(null)
|
|
@@ -72,11 +144,11 @@ const selectedNode = ref<TreeNodeData | null>(null)
|
|
|
72
144
|
const allKeys = computed(() => {
|
|
73
145
|
const keys = new Set<string>()
|
|
74
146
|
|
|
75
|
-
function collect(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
collect(
|
|
147
|
+
function collect(nodesToVisit: TreeNodeData[]) {
|
|
148
|
+
nodesToVisit.forEach((node) => {
|
|
149
|
+
node.provides.forEach((entry) => keys.add(entry.key))
|
|
150
|
+
node.injects.forEach((entry) => keys.add(entry.key))
|
|
151
|
+
collect(node.children)
|
|
80
152
|
})
|
|
81
153
|
}
|
|
82
154
|
|
|
@@ -91,21 +163,21 @@ const layout = computed<LayoutNode[]>(() => {
|
|
|
91
163
|
|
|
92
164
|
function place(node: TreeNodeData, depth: number, slotLeft: number, parentId: string | null) {
|
|
93
165
|
const leaves = countLeaves(node)
|
|
94
|
-
const
|
|
166
|
+
const slotWidth = leaves * (NODE_W + H_GAP) - H_GAP
|
|
95
167
|
|
|
96
168
|
flat.push({
|
|
97
169
|
data: node,
|
|
98
170
|
parentId,
|
|
99
|
-
x: Math.round(slotLeft +
|
|
171
|
+
x: Math.round(slotLeft + slotWidth / 2),
|
|
100
172
|
y: Math.round(pad + depth * (NODE_H + V_GAP) + NODE_H / 2),
|
|
101
173
|
})
|
|
102
174
|
|
|
103
175
|
let childLeft = slotLeft
|
|
104
176
|
|
|
105
177
|
for (const child of node.children) {
|
|
106
|
-
const
|
|
178
|
+
const childLeaves = countLeaves(child)
|
|
107
179
|
place(child, depth + 1, childLeft, node.id)
|
|
108
|
-
childLeft +=
|
|
180
|
+
childLeft += childLeaves * (NODE_W + H_GAP)
|
|
109
181
|
}
|
|
110
182
|
}
|
|
111
183
|
|
|
@@ -120,23 +192,22 @@ const layout = computed<LayoutNode[]>(() => {
|
|
|
120
192
|
return flat
|
|
121
193
|
})
|
|
122
194
|
|
|
123
|
-
const canvasW = computed(() => layout.value.reduce((
|
|
124
|
-
|
|
125
|
-
const canvasH = computed(() => layout.value.reduce((m, n) => Math.max(m, n.y + NODE_H / 2 + 20), 200))
|
|
195
|
+
const canvasW = computed(() => layout.value.reduce((max, node) => Math.max(max, node.x + NODE_W / 2 + 20), 400))
|
|
196
|
+
const canvasH = computed(() => layout.value.reduce((max, node) => Math.max(max, node.y + NODE_H / 2 + 20), 200))
|
|
126
197
|
|
|
127
198
|
const edges = computed<Edge[]>(() => {
|
|
128
|
-
const byId = new Map(layout.value.map((
|
|
199
|
+
const byId = new Map(layout.value.map((node) => [node.data.id, node]))
|
|
129
200
|
|
|
130
201
|
return layout.value
|
|
131
|
-
.filter((
|
|
132
|
-
.map((
|
|
133
|
-
const
|
|
202
|
+
.filter((node) => node.parentId !== null)
|
|
203
|
+
.map((node) => {
|
|
204
|
+
const parent = byId.get(node.parentId!)!
|
|
134
205
|
return {
|
|
135
|
-
id: `${
|
|
136
|
-
x1:
|
|
137
|
-
y1:
|
|
138
|
-
x2:
|
|
139
|
-
y2:
|
|
206
|
+
id: `${parent.data.id}--${node.data.id}`,
|
|
207
|
+
x1: parent.x,
|
|
208
|
+
y1: parent.y + NODE_H / 2,
|
|
209
|
+
x2: node.x,
|
|
210
|
+
y2: node.y - NODE_H / 2,
|
|
140
211
|
}
|
|
141
212
|
})
|
|
142
213
|
})
|
|
@@ -147,13 +218,13 @@ const edges = computed<Edge[]>(() => {
|
|
|
147
218
|
<div class="toolbar">
|
|
148
219
|
<button :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">all keys</button>
|
|
149
220
|
<button
|
|
150
|
-
v-for="
|
|
151
|
-
:key="
|
|
221
|
+
v-for="key in allKeys"
|
|
222
|
+
:key="key"
|
|
152
223
|
style="font-family: var(--mono)"
|
|
153
|
-
:class="{ active: activeFilter ===
|
|
154
|
-
@click="activeFilter =
|
|
224
|
+
:class="{ active: activeFilter === key }"
|
|
225
|
+
@click="activeFilter = key"
|
|
155
226
|
>
|
|
156
|
-
{{
|
|
227
|
+
{{ key }}
|
|
157
228
|
</button>
|
|
158
229
|
<button style="margin-left: auto" :class="{ 'danger-active': activeFilter === 'warn' }" @click="activeFilter = 'warn'">
|
|
159
230
|
warnings only
|
|
@@ -161,7 +232,6 @@ const edges = computed<Edge[]>(() => {
|
|
|
161
232
|
</div>
|
|
162
233
|
|
|
163
234
|
<div class="split">
|
|
164
|
-
<!-- Graph -->
|
|
165
235
|
<div class="graph-area">
|
|
166
236
|
<div class="legend">
|
|
167
237
|
<span class="dot" style="background: var(--teal)"></span>
|
|
@@ -173,41 +243,40 @@ const edges = computed<Edge[]>(() => {
|
|
|
173
243
|
<span class="dot" style="background: var(--red)"></span>
|
|
174
244
|
<span>missing provider</span>
|
|
175
245
|
</div>
|
|
176
|
-
<div class="canvas-wrap" :style="{ width: canvasW
|
|
246
|
+
<div class="canvas-wrap" :style="{ width: `${canvasW}px`, height: `${canvasH}px` }">
|
|
177
247
|
<svg class="edges-svg" :width="canvasW" :height="canvasH" :viewBox="`0 0 ${canvasW} ${canvasH}`">
|
|
178
248
|
<path
|
|
179
|
-
v-for="
|
|
180
|
-
:key="
|
|
181
|
-
:d="`M ${
|
|
249
|
+
v-for="edge in edges"
|
|
250
|
+
:key="edge.id"
|
|
251
|
+
:d="`M ${edge.x1},${edge.y1} C ${edge.x1},${(edge.y1 + edge.y2) / 2} ${edge.x2},${(edge.y1 + edge.y2) / 2} ${edge.x2},${edge.y2}`"
|
|
182
252
|
class="edge"
|
|
183
253
|
fill="none"
|
|
184
254
|
/>
|
|
185
255
|
</svg>
|
|
186
256
|
<div
|
|
187
|
-
v-for="
|
|
188
|
-
:key="
|
|
257
|
+
v-for="layoutNode in layout"
|
|
258
|
+
:key="layoutNode.data.id"
|
|
189
259
|
class="graph-node"
|
|
190
260
|
:class="{
|
|
191
|
-
'is-selected': selectedNode?.id ===
|
|
192
|
-
'is-dimmed': !matchesFilter(
|
|
261
|
+
'is-selected': selectedNode?.id === layoutNode.data.id,
|
|
262
|
+
'is-dimmed': !matchesFilter(layoutNode.data, activeFilter),
|
|
193
263
|
}"
|
|
194
264
|
:style="{
|
|
195
|
-
left:
|
|
196
|
-
top:
|
|
197
|
-
width: NODE_W
|
|
198
|
-
'--node-color': nodeColor(
|
|
265
|
+
left: `${layoutNode.x - NODE_W / 2}px`,
|
|
266
|
+
top: `${layoutNode.y - NODE_H / 2}px`,
|
|
267
|
+
width: `${NODE_W}px`,
|
|
268
|
+
'--node-color': nodeColor(layoutNode.data),
|
|
199
269
|
}"
|
|
200
|
-
@click="selectedNode =
|
|
270
|
+
@click="selectedNode = layoutNode.data"
|
|
201
271
|
>
|
|
202
|
-
<span class="node-dot" :style="{ background: nodeColor(
|
|
203
|
-
<span class="mono node-label">{{
|
|
204
|
-
<span v-if="
|
|
205
|
-
<span v-if="
|
|
272
|
+
<span class="node-dot" :style="{ background: nodeColor(layoutNode.data) }"></span>
|
|
273
|
+
<span class="mono node-label">{{ layoutNode.data.label }}</span>
|
|
274
|
+
<span v-if="layoutNode.data.provides.length" class="badge badge-ok badge-xs">+{{ layoutNode.data.provides.length }}</span>
|
|
275
|
+
<span v-if="layoutNode.data.injects.some((entry) => !entry.ok)" class="badge badge-err badge-xs">!</span>
|
|
206
276
|
</div>
|
|
207
277
|
</div>
|
|
208
278
|
</div>
|
|
209
279
|
|
|
210
|
-
<!-- Detail -->
|
|
211
280
|
<div v-if="selectedNode" class="detail-panel">
|
|
212
281
|
<div class="detail-header">
|
|
213
282
|
<span class="mono bold" style="font-size: 12px">{{ selectedNode.label }}</span>
|
|
@@ -216,22 +285,24 @@ const edges = computed<Edge[]>(() => {
|
|
|
216
285
|
|
|
217
286
|
<div v-if="selectedNode.provides.length">
|
|
218
287
|
<div class="section-label">provides ({{ selectedNode.provides.length }})</div>
|
|
219
|
-
<div v-for="
|
|
220
|
-
<span class="mono text-sm" style="min-width: 100px; color: var(--text2)">{{
|
|
288
|
+
<div v-for="entry in selectedNode.provides" :key="entry.key" class="provide-row">
|
|
289
|
+
<span class="mono text-sm" style="min-width: 100px; color: var(--text2)">{{ entry.key }}</span>
|
|
221
290
|
<span class="mono text-sm muted" style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
|
|
222
|
-
{{
|
|
291
|
+
{{ entry.val }}
|
|
292
|
+
</span>
|
|
293
|
+
<span class="badge" :class="entry.reactive ? 'badge-ok' : 'badge-gray'">
|
|
294
|
+
{{ entry.reactive ? 'reactive' : 'static' }}
|
|
223
295
|
</span>
|
|
224
|
-
<span class="badge" :class="p.reactive ? 'badge-ok' : 'badge-gray'">{{ p.reactive ? 'reactive' : 'static' }}</span>
|
|
225
296
|
</div>
|
|
226
297
|
</div>
|
|
227
298
|
|
|
228
299
|
<div v-if="selectedNode.injects.length" :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }">
|
|
229
300
|
<div class="section-label">injects ({{ selectedNode.injects.length }})</div>
|
|
230
|
-
<div v-for="
|
|
231
|
-
<span class="mono text-sm" style="min-width: 100px">{{
|
|
232
|
-
<span v-if="
|
|
301
|
+
<div v-for="entry in selectedNode.injects" :key="entry.key" class="inject-row" :class="{ 'inject-miss': !entry.ok }">
|
|
302
|
+
<span class="mono text-sm" style="min-width: 100px">{{ entry.key }}</span>
|
|
303
|
+
<span v-if="entry.ok" class="badge badge-ok">resolved</span>
|
|
233
304
|
<span v-else class="badge badge-err">no provider</span>
|
|
234
|
-
<span class="mono muted text-sm" style="margin-left: auto">{{
|
|
305
|
+
<span class="mono muted text-sm" style="margin-left: auto">{{ entry.from ?? 'undefined' }}</span>
|
|
235
306
|
</div>
|
|
236
307
|
</div>
|
|
237
308
|
|
|
@@ -239,7 +310,9 @@ const edges = computed<Edge[]>(() => {
|
|
|
239
310
|
no provide/inject in this component
|
|
240
311
|
</div>
|
|
241
312
|
</div>
|
|
242
|
-
<div v-else class="detail-empty">
|
|
313
|
+
<div v-else class="detail-empty">
|
|
314
|
+
{{ connected ? 'Click a node to inspect.' : 'Waiting for connection to the Nuxt app…' }}
|
|
315
|
+
</div>
|
|
243
316
|
</div>
|
|
244
317
|
</div>
|
|
245
318
|
</template>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, computed,
|
|
3
|
-
import
|
|
4
|
-
import { useObservatoryData } from '../stores/observatory'
|
|
2
|
+
import { ref, computed, defineComponent, h, type VNode } from 'vue'
|
|
3
|
+
import { useObservatoryData, type RenderEntry } from '../stores/observatory'
|
|
5
4
|
|
|
6
5
|
interface ComponentNode {
|
|
7
6
|
id: string
|
|
@@ -13,77 +12,226 @@ interface ComponentNode {
|
|
|
13
12
|
children: ComponentNode[]
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const
|
|
17
|
-
|
|
15
|
+
const ComponentBlock = defineComponent({
|
|
16
|
+
name: 'ComponentBlock',
|
|
17
|
+
props: {
|
|
18
|
+
node: Object as () => ComponentNode,
|
|
19
|
+
mode: String,
|
|
20
|
+
threshold: Number,
|
|
21
|
+
hotOnly: Boolean,
|
|
22
|
+
selected: String,
|
|
23
|
+
},
|
|
24
|
+
emits: ['select'],
|
|
25
|
+
setup(props, { emit }): () => VNode | null {
|
|
26
|
+
function getVal(node: ComponentNode) {
|
|
27
|
+
return props.mode === 'count' ? node.renders : node.avgMs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getMax(): number {
|
|
31
|
+
let max = 1
|
|
32
|
+
|
|
33
|
+
function walk(nodes: ComponentNode[]) {
|
|
34
|
+
nodes.forEach((node) => {
|
|
35
|
+
const value = getVal(node)
|
|
36
|
+
|
|
37
|
+
if (value > max) {
|
|
38
|
+
max = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
walk(node.children)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
walk([props.node!])
|
|
46
|
+
|
|
47
|
+
return Math.max(max, props.mode === 'count' ? 40 : 20)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function heatColor(value: number, max: number) {
|
|
51
|
+
const ratio = Math.min(value / max, 1)
|
|
52
|
+
|
|
53
|
+
if (ratio < 0.25) {
|
|
54
|
+
return { bg: '#EAF3DE', text: '#27500A', border: '#97C459' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (ratio < 0.55) {
|
|
58
|
+
return { bg: '#FAEEDA', text: '#633806', border: '#EF9F27' }
|
|
59
|
+
}
|
|
18
60
|
|
|
19
|
-
|
|
61
|
+
if (ratio < 0.8) {
|
|
62
|
+
return { bg: '#FAECE7', text: '#712B13', border: '#D85A30' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { bg: '#FCEBEB', text: '#791F1F', border: '#E24B4A' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isHot(node: ComponentNode) {
|
|
69
|
+
return (props.mode === 'count' ? node.renders : node.avgMs) >= props.threshold!
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
const node = props.node!
|
|
74
|
+
|
|
75
|
+
if (props.hotOnly && !isHot(node) && !node.children.some((child) => (props.mode === 'count' ? child.renders : child.avgMs) >= props.threshold!)) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const max = getMax()
|
|
80
|
+
const value = getVal(node)
|
|
81
|
+
const colors = heatColor(value, max)
|
|
82
|
+
const isSelected = props.selected === node.id
|
|
83
|
+
const unit = props.mode === 'count' ? 'renders' : 'ms avg'
|
|
84
|
+
const valueLabel = props.mode === 'count' ? String(value) : `${value.toFixed(1)}ms`
|
|
85
|
+
|
|
86
|
+
return h(
|
|
87
|
+
'div',
|
|
88
|
+
{
|
|
89
|
+
style: {
|
|
90
|
+
background: colors.bg,
|
|
91
|
+
border: isSelected ? `2px solid ${colors.border}` : `1px solid ${colors.border}`,
|
|
92
|
+
borderRadius: '6px',
|
|
93
|
+
padding: '6px 9px',
|
|
94
|
+
marginBottom: '5px',
|
|
95
|
+
cursor: 'pointer',
|
|
96
|
+
},
|
|
97
|
+
onClick: () => emit('select', node),
|
|
98
|
+
},
|
|
99
|
+
[
|
|
100
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
|
|
101
|
+
h('span', { style: { fontFamily: 'var(--mono)', fontSize: '11px', fontWeight: '500', color: colors.text } }, node.label),
|
|
102
|
+
h(
|
|
103
|
+
'span',
|
|
104
|
+
{ style: { fontFamily: 'var(--mono)', fontSize: '10px', color: colors.text, opacity: '0.7', marginLeft: 'auto' } },
|
|
105
|
+
`${valueLabel} ${unit}`
|
|
106
|
+
),
|
|
107
|
+
]),
|
|
108
|
+
node.children.length
|
|
109
|
+
? h(
|
|
110
|
+
'div',
|
|
111
|
+
{
|
|
112
|
+
style: {
|
|
113
|
+
marginLeft: '10px',
|
|
114
|
+
borderLeft: `1.5px solid ${colors.border}40`,
|
|
115
|
+
paddingLeft: '8px',
|
|
116
|
+
marginTop: '5px',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
node.children.map((child) =>
|
|
120
|
+
h(ComponentBlock, {
|
|
121
|
+
node: child,
|
|
122
|
+
mode: props.mode,
|
|
123
|
+
threshold: props.threshold,
|
|
124
|
+
hotOnly: props.hotOnly,
|
|
125
|
+
selected: props.selected,
|
|
126
|
+
onSelect: (value: ComponentNode) => emit('select', value),
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
: null,
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const { renders, connected } = useObservatoryData()
|
|
20
138
|
|
|
21
139
|
const activeMode = ref<'count' | 'time'>('count')
|
|
22
140
|
const activeThreshold = ref(5)
|
|
23
141
|
const activeHotOnly = ref(false)
|
|
24
142
|
const frozen = ref(false)
|
|
25
|
-
const
|
|
143
|
+
const activeSelectedId = ref<string | null>(null)
|
|
144
|
+
const frozenSnapshot = ref<ComponentNode[]>([])
|
|
26
145
|
|
|
27
|
-
|
|
146
|
+
function formatTrigger(trigger: RenderEntry['triggers'][number]) {
|
|
147
|
+
return `${trigger.type}: ${trigger.key}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildNodes(entries: RenderEntry[]) {
|
|
151
|
+
const byId = new Map<string, ComponentNode>()
|
|
152
|
+
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
byId.set(String(entry.uid), {
|
|
155
|
+
id: String(entry.uid),
|
|
156
|
+
label: entry.file.split('/').pop() ?? entry.name,
|
|
157
|
+
file: entry.file,
|
|
158
|
+
renders: entry.renders,
|
|
159
|
+
avgMs: entry.avgMs,
|
|
160
|
+
triggers: entry.triggers.map(formatTrigger),
|
|
161
|
+
children: [],
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const roots: ComponentNode[] = []
|
|
166
|
+
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const node = byId.get(String(entry.uid))
|
|
169
|
+
|
|
170
|
+
if (!node) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const parent = entry.parentUid !== undefined ? byId.get(String(entry.parentUid)) : undefined
|
|
175
|
+
|
|
176
|
+
if (parent) {
|
|
177
|
+
parent.children.push(node)
|
|
178
|
+
} else {
|
|
179
|
+
roots.push(node)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return roots
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const liveNodes = computed(() => buildNodes(renders.value))
|
|
187
|
+
const rootNodes = computed(() => (frozen.value ? frozenSnapshot.value : liveNodes.value))
|
|
28
188
|
|
|
29
189
|
const allComponents = computed(() => {
|
|
30
190
|
const all: ComponentNode[] = []
|
|
31
191
|
|
|
32
|
-
function collect(
|
|
33
|
-
|
|
34
|
-
all.push(
|
|
35
|
-
collect(
|
|
192
|
+
function collect(nodes: ComponentNode[]) {
|
|
193
|
+
nodes.forEach((node) => {
|
|
194
|
+
all.push(node)
|
|
195
|
+
collect(node.children)
|
|
36
196
|
})
|
|
37
197
|
}
|
|
38
198
|
|
|
39
|
-
collect(
|
|
199
|
+
collect(rootNodes.value)
|
|
40
200
|
|
|
41
201
|
return all
|
|
42
202
|
})
|
|
43
203
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
204
|
+
const activeSelected = computed(() => allComponents.value.find((node) => node.id === activeSelectedId.value) ?? null)
|
|
205
|
+
const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.renders, 0))
|
|
206
|
+
const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
|
|
46
207
|
const avgTime = computed(() => {
|
|
47
|
-
const
|
|
208
|
+
const components = allComponents.value.filter((node) => node.avgMs > 0)
|
|
48
209
|
|
|
49
|
-
if (!
|
|
210
|
+
if (!components.length) {
|
|
50
211
|
return '0.0'
|
|
51
212
|
}
|
|
52
213
|
|
|
53
|
-
return (
|
|
214
|
+
return (components.reduce((sum, node) => sum + node.avgMs, 0) / components.length).toFixed(1)
|
|
54
215
|
})
|
|
55
216
|
|
|
56
|
-
function isHot(
|
|
57
|
-
return (activeMode.value === 'count' ?
|
|
217
|
+
function isHot(node: ComponentNode) {
|
|
218
|
+
return (activeMode.value === 'count' ? node.renders : node.avgMs) >= activeThreshold.value
|
|
58
219
|
}
|
|
59
220
|
|
|
60
221
|
function toggleFreeze() {
|
|
61
|
-
frozen.value
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (frozen.value) {
|
|
67
|
-
return
|
|
68
|
-
}
|
|
222
|
+
if (frozen.value) {
|
|
223
|
+
frozen.value = false
|
|
224
|
+
frozenSnapshot.value = []
|
|
225
|
+
return
|
|
226
|
+
}
|
|
69
227
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
})
|
|
73
|
-
}, 1800)
|
|
228
|
+
frozenSnapshot.value = JSON.parse(JSON.stringify(liveNodes.value)) as ComponentNode[]
|
|
229
|
+
frozen.value = true
|
|
74
230
|
}
|
|
75
|
-
|
|
76
|
-
startLive()
|
|
77
|
-
onUnmounted(() => {
|
|
78
|
-
if (liveInterval) {
|
|
79
|
-
clearInterval(liveInterval)
|
|
80
|
-
}
|
|
81
|
-
})
|
|
82
231
|
</script>
|
|
83
232
|
|
|
84
233
|
<template>
|
|
85
234
|
<div class="view">
|
|
86
|
-
<!-- Controls -->
|
|
87
235
|
<div class="controls">
|
|
88
236
|
<div class="mode-group">
|
|
89
237
|
<button :class="{ active: activeMode === 'count' }" @click="activeMode = 'count'">render count</button>
|
|
@@ -100,7 +248,6 @@ onUnmounted(() => {
|
|
|
100
248
|
</button>
|
|
101
249
|
</div>
|
|
102
250
|
|
|
103
|
-
<!-- Stats -->
|
|
104
251
|
<div class="stats-row">
|
|
105
252
|
<div class="stat-card">
|
|
106
253
|
<div class="stat-label">components</div>
|
|
@@ -121,7 +268,6 @@ onUnmounted(() => {
|
|
|
121
268
|
</div>
|
|
122
269
|
|
|
123
270
|
<div class="split">
|
|
124
|
-
<!-- Page mockup -->
|
|
125
271
|
<div class="page-frame">
|
|
126
272
|
<div class="legend">
|
|
127
273
|
<div class="swatch-row">
|
|
@@ -140,16 +286,18 @@ onUnmounted(() => {
|
|
|
140
286
|
:threshold="activeThreshold"
|
|
141
287
|
:hot-only="activeHotOnly"
|
|
142
288
|
:selected="activeSelected?.id"
|
|
143
|
-
@select="
|
|
289
|
+
@select="activeSelectedId = $event.id"
|
|
144
290
|
/>
|
|
291
|
+
<div v-if="!rootNodes.length" class="detail-empty" style="height: 180px; margin-top: 12px">
|
|
292
|
+
{{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
|
|
293
|
+
</div>
|
|
145
294
|
</div>
|
|
146
295
|
|
|
147
|
-
<!-- Detail panel -->
|
|
148
296
|
<div class="sidebar">
|
|
149
297
|
<template v-if="activeSelected">
|
|
150
298
|
<div class="detail-header">
|
|
151
299
|
<span class="mono bold" style="font-size: 12px">{{ activeSelected.label }}</span>
|
|
152
|
-
<button @click="
|
|
300
|
+
<button @click="activeSelectedId = null">×</button>
|
|
153
301
|
</div>
|
|
154
302
|
|
|
155
303
|
<div class="meta-grid">
|
|
@@ -166,7 +314,7 @@ onUnmounted(() => {
|
|
|
166
314
|
</div>
|
|
167
315
|
|
|
168
316
|
<div class="section-label">triggers</div>
|
|
169
|
-
<div v-for="
|
|
317
|
+
<div v-for="trigger in activeSelected.triggers" :key="trigger" class="trigger-item mono text-sm">{{ trigger }}</div>
|
|
170
318
|
<div v-if="!activeSelected.triggers.length" class="muted text-sm">no triggers recorded</div>
|
|
171
319
|
</template>
|
|
172
320
|
<div v-else class="detail-empty">click a component to inspect</div>
|