nuxt-devtools-observatory 0.1.4 → 0.1.6

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.
@@ -0,0 +1,162 @@
1
+ <script setup lang="ts">
2
+ interface ComponentNode {
3
+ id: string
4
+ label: string
5
+ file: string
6
+ renders: number
7
+ avgMs: number
8
+ triggers: string[]
9
+ children: ComponentNode[]
10
+ }
11
+
12
+ const props = defineProps<{
13
+ node: ComponentNode
14
+ mode: string
15
+ threshold: number
16
+ hotOnly: boolean
17
+ selected?: string
18
+ }>()
19
+
20
+ const emit = defineEmits(['select'])
21
+
22
+ function getVal(n: ComponentNode) {
23
+ return props.mode === 'count' ? n.renders : n.avgMs
24
+ }
25
+
26
+ function getMax(n: ComponentNode): number {
27
+ let max = 1
28
+
29
+ function walk(ns: ComponentNode[]) {
30
+ ns.forEach((n) => {
31
+ const v = getVal(n)
32
+
33
+ if (v > max) {
34
+ max = v
35
+ }
36
+
37
+ walk(n.children)
38
+ })
39
+ }
40
+
41
+ walk([n])
42
+
43
+ return Math.max(max, props.mode === 'count' ? 40 : 20)
44
+ }
45
+
46
+ function heatColor(val: number, max: number) {
47
+ const r = Math.min(val / max, 1)
48
+
49
+ if (r < 0.25) {
50
+ return { bg: '#EAF3DE', text: '#27500A', border: '#97C459' }
51
+ } else {
52
+ if (r < 0.55) {
53
+ return { bg: '#FAEEDA', text: '#633806', border: '#EF9F27' }
54
+ } else {
55
+ if (r < 0.8) {
56
+ return { bg: '#FAECE7', text: '#712B13', border: '#D85A30' }
57
+ } else {
58
+ return { bg: '#FCEBEB', text: '#791F1F', border: '#E24B4A' }
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ function isHotNode(n: ComponentNode) {
65
+ let value: number
66
+
67
+ if (props.mode === 'count') {
68
+ value = n.renders
69
+ } else {
70
+ value = n.avgMs
71
+ }
72
+
73
+ if (value >= props.threshold) {
74
+ return true
75
+ } else {
76
+ return false
77
+ }
78
+ }
79
+
80
+ function shouldShow(n: ComponentNode): boolean {
81
+ if (!props.hotOnly) {
82
+ return true
83
+ } else {
84
+ if (isHotNode(n)) {
85
+ return true
86
+ } else {
87
+ if (n.children.some(isHotNode)) {
88
+ return true
89
+ } else {
90
+ return false
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ function handleSelect(n: ComponentNode) {
97
+ emit('select', n)
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <div
103
+ v-if="shouldShow(props.node)"
104
+ :style="{
105
+ background: heatColor(getVal(props.node), getMax(props.node)).bg,
106
+ border:
107
+ props.selected === props.node.id
108
+ ? `2px solid ${heatColor(getVal(props.node), getMax(props.node)).border}`
109
+ : `1px solid ${heatColor(getVal(props.node), getMax(props.node)).border}`,
110
+ borderRadius: '6px',
111
+ padding: '6px 9px',
112
+ marginBottom: '5px',
113
+ cursor: 'pointer',
114
+ }"
115
+ @click="handleSelect(props.node)"
116
+ >
117
+ <div style="display: flex; align-items: center; gap: 6px">
118
+ <span
119
+ :style="{
120
+ fontFamily: 'var(--mono)',
121
+ fontSize: '11px',
122
+ fontWeight: 500,
123
+ color: heatColor(getVal(props.node), getMax(props.node)).text,
124
+ }"
125
+ >
126
+ {{ props.node.label }}
127
+ </span>
128
+ <span
129
+ :style="{
130
+ fontFamily: 'var(--mono)',
131
+ fontSize: '10px',
132
+ color: heatColor(getVal(props.node), getMax(props.node)).text,
133
+ opacity: 0.7,
134
+ marginLeft: 'auto',
135
+ }"
136
+ >
137
+ {{ props.mode === 'count' ? getVal(props.node) : getVal(props.node).toFixed(1) + 'ms' }}
138
+ {{ props.mode === 'count' ? 'renders' : 'ms avg' }}
139
+ </span>
140
+ </div>
141
+ <div
142
+ v-if="props.node.children && props.node.children.length"
143
+ :style="{
144
+ marginLeft: '10px',
145
+ borderLeft: `1.5px solid ${heatColor(getVal(props.node), getMax(props.node)).border}40`,
146
+ paddingLeft: '8px',
147
+ marginTop: '5px',
148
+ }"
149
+ >
150
+ <ComponentBlock
151
+ v-for="child in props.node.children"
152
+ :key="child.id"
153
+ :node="child"
154
+ :mode="props.mode"
155
+ :threshold="props.threshold"
156
+ :hot-only="props.hotOnly"
157
+ :selected="props.selected"
158
+ @select="handleSelect"
159
+ />
160
+ </div>
161
+ </div>
162
+ </template>
@@ -1,133 +1,57 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed } from 'vue'
2
+ import { ref, computed, type Ref } from 'vue'
3
+ import { useObservatoryData } from '../stores/observatory'
3
4
 
4
- interface RefEntry {
5
- key: string
6
- type: string
7
- val: string
8
- }
9
5
  interface ComposableEntry {
10
6
  id: string
11
7
  name: string
12
8
  component: string
13
9
  instances: number
14
- status: 'mounted' | 'unmounted'
10
+ status: string
15
11
  leak: boolean
16
12
  leakReason?: string
17
- refs: RefEntry[]
13
+ refs: Array<{ key: string; type: string; val: string }>
18
14
  watchers: number
19
15
  intervals: number
20
- lifecycle: { onMounted: boolean; onUnmounted: boolean; watchersCleaned: boolean; intervalsCleaned: boolean }
16
+ lifecycle: {
17
+ onMounted: boolean
18
+ onUnmounted: boolean
19
+ watchersCleaned: boolean
20
+ intervalsCleaned: boolean
21
+ }
21
22
  }
22
23
 
23
- const entries = ref<ComposableEntry[]>([
24
- {
25
- id: '1',
26
- name: 'useAuth',
27
- component: 'App.vue',
28
- instances: 1,
29
- status: 'mounted',
30
- leak: false,
31
- refs: [
32
- { key: 'user', type: 'ref', val: '{ id: "u_9x3k", role: "admin" }' },
33
- { key: 'isLoggedIn', type: 'computed', val: 'true' },
34
- ],
35
- watchers: 1,
36
- intervals: 0,
37
- lifecycle: { onMounted: true, onUnmounted: true, watchersCleaned: true, intervalsCleaned: true },
38
- },
39
- {
40
- id: '2',
41
- name: 'useWebSocket',
42
- component: 'Dashboard.vue',
43
- instances: 1,
44
- status: 'unmounted',
45
- leak: true,
46
- leakReason: 'socket.close() never called — 2 watchers still running after unmount',
47
- refs: [
48
- { key: 'socket', type: 'ref', val: 'WebSocket { readyState: 1 }' },
49
- { key: 'messages', type: 'ref', val: 'Array(47)' },
50
- ],
51
- watchers: 2,
52
- intervals: 0,
53
- lifecycle: { onMounted: true, onUnmounted: false, watchersCleaned: false, intervalsCleaned: true },
54
- },
55
- {
56
- id: '3',
57
- name: 'usePoller',
58
- component: 'StockTicker.vue',
59
- instances: 2,
60
- status: 'unmounted',
61
- leak: true,
62
- leakReason: 'setInterval #37 never cleared — still firing every 2000ms',
63
- refs: [
64
- { key: 'data', type: 'ref', val: '{ price: 142.5 }' },
65
- { key: 'intervalId', type: 'ref', val: '37' },
66
- ],
67
- watchers: 0,
68
- intervals: 1,
69
- lifecycle: { onMounted: true, onUnmounted: false, watchersCleaned: true, intervalsCleaned: false },
70
- },
71
- {
72
- id: '4',
73
- name: 'useCart',
74
- component: 'CartDrawer.vue',
75
- instances: 1,
76
- status: 'mounted',
77
- leak: false,
78
- refs: [
79
- { key: 'items', type: 'ref', val: 'Array(3)' },
80
- { key: 'total', type: 'computed', val: '248.50' },
81
- ],
82
- watchers: 0,
83
- intervals: 0,
84
- lifecycle: { onMounted: false, onUnmounted: false, watchersCleaned: true, intervalsCleaned: true },
85
- },
86
- {
87
- id: '5',
88
- name: 'useBreakpoint',
89
- component: 'Layout.vue',
90
- instances: 1,
91
- status: 'mounted',
92
- leak: false,
93
- refs: [
94
- { key: 'isMobile', type: 'computed', val: 'false' },
95
- { key: 'width', type: 'ref', val: '1280' },
96
- ],
97
- watchers: 0,
98
- intervals: 0,
99
- lifecycle: { onMounted: true, onUnmounted: true, watchersCleaned: true, intervalsCleaned: true },
100
- },
101
- {
102
- id: '6',
103
- name: 'useIntersectionObserver',
104
- component: 'LazyImage.vue',
105
- instances: 4,
106
- status: 'mounted',
107
- leak: false,
108
- refs: [{ key: 'isVisible', type: 'ref', val: 'true' }],
109
- watchers: 0,
110
- intervals: 0,
111
- lifecycle: { onMounted: true, onUnmounted: true, watchersCleaned: true, intervalsCleaned: true },
112
- },
113
- ])
24
+ const { composables } = useObservatoryData()
25
+ const entries = composables as Ref<ComposableEntry[]>
114
26
 
115
27
  const filter = ref('all')
116
28
  const search = ref('')
117
29
  const expanded = ref<string | null>(null)
118
30
 
119
- const counts = computed(() => ({
120
- mounted: entries.value.filter((e) => e.status === 'mounted').length,
121
- leaks: entries.value.filter((e) => e.leak).length,
122
- }))
31
+ const counts = computed<{ mounted: number; leaks: number }>(() => {
32
+ return {
33
+ mounted: entries.value.filter((e) => e.status === 'mounted').length,
34
+ leaks: entries.value.filter((e) => e.leak).length,
35
+ }
36
+ })
123
37
 
124
- const filtered = computed(() => {
38
+ const filtered = computed<ComposableEntry[]>(() => {
125
39
  return entries.value.filter((e) => {
126
- if (filter.value === 'leak' && !e.leak) return false
127
- if (filter.value === 'unmounted' && e.status !== 'unmounted') return false
128
- const q = search.value.toLowerCase()
129
- if (q && !e.name.toLowerCase().includes(q) && !e.component.toLowerCase().includes(q)) return false
130
- return true
40
+ if (filter.value === 'leak' && !e.leak) {
41
+ return false
42
+ } else {
43
+ if (filter.value === 'unmounted' && e.status !== 'unmounted') {
44
+ return false
45
+ } else {
46
+ const q = search.value.toLowerCase()
47
+
48
+ if (q && !e.name.toLowerCase().includes(q) && !e.component.toLowerCase().includes(q)) {
49
+ return false
50
+ } else {
51
+ return true
52
+ }
53
+ }
54
+ }
131
55
  })
132
56
  })
133
57
 
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from 'vue'
3
+ import { useObservatoryData } from '../stores/observatory'
3
4
 
4
5
  interface FetchEntry {
5
6
  id: string
@@ -16,126 +17,21 @@ interface FetchEntry {
16
17
  startOffset?: number
17
18
  }
18
19
 
19
- // Mock data in production this comes from the WS registry
20
- const entries = ref<FetchEntry[]>([
21
- {
22
- id: '1',
23
- key: 'product-detail',
24
- url: '/api/products/42',
25
- status: 'ok',
26
- origin: 'ssr',
27
- ms: 48,
28
- size: 3276,
29
- cached: false,
30
- startOffset: 0,
31
- file: 'pages/products/[id].vue',
32
- line: 8,
33
- },
34
- {
35
- id: '2',
36
- key: 'related-products',
37
- url: '/api/products?related=42',
38
- status: 'ok',
39
- origin: 'ssr',
40
- ms: 112,
41
- size: 19148,
42
- cached: false,
43
- startOffset: 10,
44
- file: 'pages/products/[id].vue',
45
- line: 14,
46
- },
47
- {
48
- id: '3',
49
- key: 'user-session',
50
- url: '/api/auth/session',
51
- status: 'cached',
52
- origin: 'csr',
53
- ms: 0,
54
- size: 819,
55
- cached: true,
56
- startOffset: 0,
57
- file: 'layouts/default.vue',
58
- line: 5,
59
- },
60
- {
61
- id: '4',
62
- key: 'cart-summary',
63
- url: '/api/cart',
64
- status: 'ok',
65
- origin: 'csr',
66
- ms: 67,
67
- size: 2150,
68
- cached: false,
69
- startOffset: 5,
70
- file: 'components/CartDrawer.vue',
71
- line: 3,
72
- },
73
- {
74
- id: '5',
75
- key: 'product-reviews',
76
- url: '/api/products/42/reviews',
77
- status: 'pending',
78
- origin: 'csr',
79
- ms: undefined,
80
- size: undefined,
81
- cached: false,
82
- startOffset: 120,
83
- file: 'components/ReviewList.vue',
84
- line: 6,
85
- },
86
- {
87
- id: '6',
88
- key: 'recommendations',
89
- url: '/api/recommend?u=u_9x3k',
90
- status: 'ok',
91
- origin: 'csr',
92
- ms: 201,
93
- size: 9626,
94
- cached: false,
95
- startOffset: 30,
96
- file: 'components/Recommendations.vue',
97
- line: 4,
98
- },
99
- {
100
- id: '7',
101
- key: 'inventory-check',
102
- url: '/api/inventory/42',
103
- status: 'error',
104
- origin: 'csr',
105
- ms: 503,
106
- size: undefined,
107
- cached: false,
108
- startOffset: 55,
109
- file: 'components/StockBadge.vue',
110
- line: 7,
111
- },
112
- {
113
- id: '8',
114
- key: 'nav-links',
115
- url: '/api/nav',
116
- status: 'cached',
117
- origin: 'ssr',
118
- ms: 0,
119
- size: 1126,
120
- cached: true,
121
- startOffset: 0,
122
- file: 'layouts/default.vue',
123
- line: 9,
124
- },
125
- ])
20
+ // Use live fetch data from the Nuxt registry bridge
21
+ const { fetches: entries } = useObservatoryData()
126
22
 
127
23
  const filter = ref<string>('all')
128
24
  const search = ref('')
129
25
  const selected = ref<FetchEntry | null>(null)
130
26
 
131
27
  const counts = computed(() => ({
132
- ok: entries.value.filter((e) => e.status === 'ok').length,
133
- pending: entries.value.filter((e) => e.status === 'pending').length,
134
- error: entries.value.filter((e) => e.status === 'error').length,
28
+ ok: entries.value?.filter((e) => e.status === 'ok').length ?? 0,
29
+ pending: entries.value?.filter((e) => e.status === 'pending').length ?? 0,
30
+ error: entries.value?.filter((e) => e.status === 'error').length ?? 0,
135
31
  }))
136
32
 
137
33
  const filtered = computed(() => {
138
- return entries.value.filter((e) => {
34
+ return (entries.value ?? []).filter((e) => {
139
35
  if (filter.value !== 'all' && e.status !== filter.value) return false
140
36
  const q = search.value.toLowerCase()
141
37
  if (q && !e.key.includes(q) && !e.url.includes(q)) return false
@@ -176,16 +72,16 @@ function barColor(s: string) {
176
72
  }
177
73
 
178
74
  function barWidth(e: FetchEntry) {
179
- const maxMs = Math.max(...entries.value.filter((x) => x.ms).map((x) => x.ms!), 1)
75
+ const maxMs = Math.max(...(entries.value ?? []).filter((x) => x.ms).map((x) => x.ms!), 1)
180
76
  return e.ms != null ? Math.max(4, Math.round((e.ms / maxMs) * 100)) : 4
181
77
  }
182
78
 
183
79
  function wfLeft(e: FetchEntry) {
184
- const maxEnd = Math.max(...entries.value.map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
80
+ const maxEnd = Math.max(...(entries.value ?? []).map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
185
81
  return Math.round(((e.startOffset ?? 0) / maxEnd) * 100)
186
82
  }
187
83
  function wfWidth(e: FetchEntry) {
188
- const maxEnd = Math.max(...entries.value.map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
84
+ const maxEnd = Math.max(...(entries.value ?? []).map((x) => (x.startOffset ?? 0) + (x.ms ?? 0)), 1)
189
85
  return e.ms != null ? Math.round((e.ms / maxEnd) * 100) : 2
190
86
  }
191
87
 
@@ -195,18 +91,12 @@ function formatSize(bytes: number) {
195
91
  }
196
92
 
197
93
  function replayFetch() {
198
- if (!selected.value) return
199
- const e = selected.value
200
- e.status = 'pending'
201
- e.ms = undefined
202
- setTimeout(() => {
203
- e.status = 'ok'
204
- e.ms = Math.floor(Math.random() * 150 + 20)
205
- }, 700)
94
+ // No-op: cannot replay fetch from devtools UI (see Nuxt docs)
95
+ // You should call the original refresh() from useFetch in-app, not here
206
96
  }
207
97
 
208
98
  function clearAll() {
209
- entries.value = []
99
+ // No-op: cannot clear live registry from client
210
100
  selected.value = null
211
101
  }
212
102
  </script>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed } from 'vue'
3
+ import { useObservatoryData } from '../stores/observatory'
3
4
 
4
5
  interface TreeNodeData {
5
6
  id: string
@@ -31,15 +32,30 @@ const V_GAP = 72
31
32
  const H_GAP = 18
32
33
 
33
34
  function nodeColor(n: TreeNodeData): string {
34
- if (n.injects.some((i) => !i.ok)) return 'var(--red)'
35
- if (n.type === 'both') return 'var(--blue)'
36
- if (n.type === 'provider') return 'var(--teal)'
35
+ if (n.injects.some((i) => !i.ok)) {
36
+ return 'var(--red)'
37
+ }
38
+
39
+ if (n.type === 'both') {
40
+ return 'var(--blue)'
41
+ }
42
+
43
+ if (n.type === 'provider') {
44
+ return 'var(--teal)'
45
+ }
46
+
37
47
  return 'var(--text3)'
38
48
  }
39
49
 
40
50
  function matchesFilter(n: TreeNodeData, filter: string): boolean {
41
- if (filter === 'all') return true
42
- if (filter === 'warn') return n.injects.some((i) => !i.ok)
51
+ if (filter === 'all') {
52
+ return true
53
+ }
54
+
55
+ if (filter === 'warn') {
56
+ return n.injects.some((i) => !i.ok)
57
+ }
58
+
43
59
  return n.provides.some((p) => p.key === filter) || n.injects.some((i) => i.key === filter)
44
60
  }
45
61
 
@@ -47,79 +63,8 @@ function countLeaves(n: TreeNodeData): number {
47
63
  return n.children.length === 0 ? 1 : n.children.reduce((s, c) => s + countLeaves(c), 0)
48
64
  }
49
65
 
50
- const nodes = ref<TreeNodeData[]>([
51
- {
52
- id: 'App',
53
- label: 'App.vue',
54
- type: 'provider',
55
- provides: [
56
- { key: 'authContext', val: '{ user, logout }', reactive: true },
57
- { key: 'theme', val: '"dark"', reactive: false },
58
- ],
59
- injects: [],
60
- children: [
61
- {
62
- id: 'Layout',
63
- label: 'Layout.vue',
64
- type: 'both',
65
- provides: [{ key: 'routerState', val: 'useRoute()', reactive: true }],
66
- injects: [{ key: 'theme', from: 'App.vue', ok: true }],
67
- children: [
68
- {
69
- id: 'Sidebar',
70
- label: 'Sidebar.vue',
71
- type: 'consumer',
72
- provides: [],
73
- injects: [
74
- { key: 'authContext', from: 'App.vue', ok: true },
75
- { key: 'theme', from: 'App.vue', ok: true },
76
- ],
77
- children: [],
78
- },
79
- {
80
- id: 'NavBar',
81
- label: 'NavBar.vue',
82
- type: 'consumer',
83
- provides: [],
84
- injects: [
85
- { key: 'authContext', from: 'App.vue', ok: true },
86
- { key: 'routerState', from: 'Layout.vue', ok: true },
87
- ],
88
- children: [],
89
- },
90
- {
91
- id: 'ProductList',
92
- label: 'ProductList.vue',
93
- type: 'error',
94
- provides: [],
95
- injects: [
96
- { key: 'cartContext', from: null, ok: false },
97
- { key: 'theme', from: 'App.vue', ok: true },
98
- ],
99
- children: [
100
- {
101
- id: 'ProductCard',
102
- label: 'ProductCard.vue',
103
- type: 'error',
104
- provides: [],
105
- injects: [{ key: 'cartContext', from: null, ok: false }],
106
- children: [],
107
- },
108
- ],
109
- },
110
- {
111
- id: 'UserMenu',
112
- label: 'UserMenu.vue',
113
- type: 'consumer',
114
- provides: [],
115
- injects: [{ key: 'authContext', from: 'App.vue', ok: true }],
116
- children: [],
117
- },
118
- ],
119
- },
120
- ],
121
- },
122
- ])
66
+ const { provideInject } = useObservatoryData()
67
+ const nodes = provideInject
123
68
 
124
69
  const activeFilter = ref('all')
125
70
  const selectedNode = ref<TreeNodeData | null>(null)
@@ -147,13 +92,16 @@ const layout = computed<LayoutNode[]>(() => {
147
92
  function place(node: TreeNodeData, depth: number, slotLeft: number, parentId: string | null) {
148
93
  const leaves = countLeaves(node)
149
94
  const slotW = leaves * (NODE_W + H_GAP) - H_GAP
95
+
150
96
  flat.push({
151
97
  data: node,
152
98
  parentId,
153
99
  x: Math.round(slotLeft + slotW / 2),
154
100
  y: Math.round(pad + depth * (NODE_H + V_GAP) + NODE_H / 2),
155
101
  })
102
+
156
103
  let childLeft = slotLeft
104
+
157
105
  for (const child of node.children) {
158
106
  const cl = countLeaves(child)
159
107
  place(child, depth + 1, childLeft, node.id)
@@ -162,6 +110,7 @@ const layout = computed<LayoutNode[]>(() => {
162
110
  }
163
111
 
164
112
  let left = pad
113
+
165
114
  for (const root of nodes.value) {
166
115
  const leaves = countLeaves(root)
167
116
  place(root, 0, left, null)
@@ -177,6 +126,7 @@ const canvasH = computed(() => layout.value.reduce((m, n) => Math.max(m, n.y + N
177
126
 
178
127
  const edges = computed<Edge[]>(() => {
179
128
  const byId = new Map(layout.value.map((n) => [n.data.id, n]))
129
+
180
130
  return layout.value
181
131
  .filter((n) => n.parentId !== null)
182
132
  .map((n) => {