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.
@@ -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
- function nodeColor(n: TreeNodeData): string {
35
- if (n.injects.some((i) => !i.ok)) {
36
- return 'var(--red)'
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
- if (n.type === 'both') {
40
- return 'var(--blue)'
53
+ function stringifyValue(value: unknown) {
54
+ if (typeof value === 'string') {
55
+ return value
41
56
  }
42
57
 
43
- if (n.type === 'provider') {
44
- return 'var(--teal)'
58
+ try {
59
+ return JSON.stringify(value)
60
+ } catch {
61
+ return String(value)
45
62
  }
63
+ }
46
64
 
47
- return 'var(--text3)'
65
+ function componentId(entry: ProvideEntry | InjectEntry) {
66
+ return String(entry.componentUid)
48
67
  }
49
68
 
50
- function matchesFilter(n: TreeNodeData, filter: string): boolean {
51
- if (filter === 'all') {
52
- return true
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
- if (filter === 'warn') {
56
- return n.injects.some((i) => !i.ok)
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
- return n.provides.some((p) => p.key === filter) || n.injects.some((i) => i.key === filter)
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
- function countLeaves(n: TreeNodeData): number {
63
- return n.children.length === 0 ? 1 : n.children.reduce((s, c) => s + countLeaves(c), 0)
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 { provideInject } = useObservatoryData()
67
- const nodes = provideInject
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(ns: TreeNodeData[]) {
76
- ns.forEach((n) => {
77
- n.provides.forEach((p) => keys.add(p.key))
78
- n.injects.forEach((i) => keys.add(i.key))
79
- collect(n.children)
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 slotW = leaves * (NODE_W + H_GAP) - H_GAP
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 + slotW / 2),
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 cl = countLeaves(child)
178
+ const childLeaves = countLeaves(child)
107
179
  place(child, depth + 1, childLeft, node.id)
108
- childLeft += cl * (NODE_W + H_GAP)
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((m, n) => Math.max(m, n.x + NODE_W / 2 + 20), 400))
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((n) => [n.data.id, n]))
199
+ const byId = new Map(layout.value.map((node) => [node.data.id, node]))
129
200
 
130
201
  return layout.value
131
- .filter((n) => n.parentId !== null)
132
- .map((n) => {
133
- const p = byId.get(n.parentId!)!
202
+ .filter((node) => node.parentId !== null)
203
+ .map((node) => {
204
+ const parent = byId.get(node.parentId!)!
134
205
  return {
135
- id: `${p.data.id}--${n.data.id}`,
136
- x1: p.x,
137
- y1: p.y + NODE_H / 2,
138
- x2: n.x,
139
- y2: n.y - NODE_H / 2,
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="k in allKeys"
151
- :key="k"
221
+ v-for="key in allKeys"
222
+ :key="key"
152
223
  style="font-family: var(--mono)"
153
- :class="{ active: activeFilter === k }"
154
- @click="activeFilter = k"
224
+ :class="{ active: activeFilter === key }"
225
+ @click="activeFilter = key"
155
226
  >
156
- {{ k }}
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 + 'px', height: canvasH + 'px' }">
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="e in edges"
180
- :key="e.id"
181
- :d="`M ${e.x1},${e.y1} C ${e.x1},${(e.y1 + e.y2) / 2} ${e.x2},${(e.y1 + e.y2) / 2} ${e.x2},${e.y2}`"
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="ln in layout"
188
- :key="ln.data.id"
257
+ v-for="layoutNode in layout"
258
+ :key="layoutNode.data.id"
189
259
  class="graph-node"
190
260
  :class="{
191
- 'is-selected': selectedNode?.id === ln.data.id,
192
- 'is-dimmed': !matchesFilter(ln.data, activeFilter),
261
+ 'is-selected': selectedNode?.id === layoutNode.data.id,
262
+ 'is-dimmed': !matchesFilter(layoutNode.data, activeFilter),
193
263
  }"
194
264
  :style="{
195
- left: ln.x - NODE_W / 2 + 'px',
196
- top: ln.y - NODE_H / 2 + 'px',
197
- width: NODE_W + 'px',
198
- '--node-color': nodeColor(ln.data),
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 = ln.data"
270
+ @click="selectedNode = layoutNode.data"
201
271
  >
202
- <span class="node-dot" :style="{ background: nodeColor(ln.data) }"></span>
203
- <span class="mono node-label">{{ ln.data.label }}</span>
204
- <span v-if="ln.data.provides.length" class="badge badge-ok badge-xs">+{{ ln.data.provides.length }}</span>
205
- <span v-if="ln.data.injects.some((i) => !i.ok)" class="badge badge-err badge-xs">!</span>
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="p in selectedNode.provides" :key="p.key" class="provide-row">
220
- <span class="mono text-sm" style="min-width: 100px; color: var(--text2)">{{ p.key }}</span>
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
- {{ p.val }}
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="inj in selectedNode.injects" :key="inj.key" class="inject-row" :class="{ 'inject-miss': !inj.ok }">
231
- <span class="mono text-sm" style="min-width: 100px">{{ inj.key }}</span>
232
- <span v-if="inj.ok" class="badge badge-ok">resolved</span>
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">{{ inj.from ?? 'undefined' }}</span>
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">click a node to inspect</div>
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, onUnmounted } from 'vue'
3
- import ComponentBlock from './ComponentBlock.vue'
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 { renders } = useObservatoryData()
17
- const baseNodes = renders
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
- let liveInterval: ReturnType<typeof setInterval> | undefined
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 activeSelected = ref<ComponentNode | null>(null)
143
+ const activeSelectedId = ref<string | null>(null)
144
+ const frozenSnapshot = ref<ComponentNode[]>([])
26
145
 
27
- const rootNodes = computed(() => baseNodes.value)
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(ns: ComponentNode[]) {
33
- ns.forEach((n) => {
34
- all.push(n)
35
- collect(n.children)
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(baseNodes.value)
199
+ collect(rootNodes.value)
40
200
 
41
201
  return all
42
202
  })
43
203
 
44
- const totalRenders = computed(() => allComponents.value.reduce((a, n) => a + n.renders, 0))
45
- const hotCount = computed(() => allComponents.value.filter((n) => isHot(n)).length)
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 comps = allComponents.value.filter((n) => n.avgMs > 0)
208
+ const components = allComponents.value.filter((node) => node.avgMs > 0)
48
209
 
49
- if (!comps.length) {
210
+ if (!components.length) {
50
211
  return '0.0'
51
212
  }
52
213
 
53
- return (comps.reduce((a, n) => a + n.avgMs, 0) / comps.length).toFixed(1)
214
+ return (components.reduce((sum, node) => sum + node.avgMs, 0) / components.length).toFixed(1)
54
215
  })
55
216
 
56
- function isHot(n: ComponentNode) {
57
- return (activeMode.value === 'count' ? n.renders : n.avgMs) >= activeThreshold.value
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 = !frozen.value
62
- }
63
-
64
- function startLive() {
65
- liveInterval = setInterval(() => {
66
- if (frozen.value) {
67
- return
68
- }
222
+ if (frozen.value) {
223
+ frozen.value = false
224
+ frozenSnapshot.value = []
225
+ return
226
+ }
69
227
 
70
- allComponents.value.forEach((n) => {
71
- if (Math.random() < 0.3) n.renders += Math.floor(Math.random() * 3) + 1
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="activeSelected = $event"
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="activeSelected = null">×</button>
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="t in activeSelected.triggers" :key="t" class="trigger-item mono text-sm">{{ t }}</div>
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>