nuxt-devtools-observatory 0.1.30 → 0.1.32
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 +117 -30
- package/client/.env.example +2 -1
- package/client/dist/assets/index-5Wl1XYRH.js +17 -0
- package/client/dist/assets/index-DT_QUiIh.css +1 -0
- package/client/dist/index.html +2 -2
- package/client/src/App.vue +4 -0
- package/client/src/components/Flamegraph.vue +442 -0
- package/client/src/components/SpanInspector.vue +446 -0
- package/client/src/components/TraceFilter.vue +342 -0
- package/client/src/components/WaterfallView.vue +443 -0
- package/client/src/composables/composable-search.ts +124 -0
- package/client/src/composables/trace-render-aggregation.ts +254 -0
- package/client/src/composables/useExportImport.ts +63 -0
- package/client/src/composables/useTraceFilter.ts +160 -0
- package/client/src/stores/observatory.ts +13 -1
- package/client/src/views/ComposableTracker.vue +65 -30
- package/client/src/views/RenderHeatmap.vue +63 -1
- package/client/src/views/TraceViewer.vue +1212 -0
- package/client/src/views/TransitionTimeline.vue +1 -6
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +31 -45
- 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 +74 -110
- package/dist/runtime/composables/transition-registry.js +103 -28
- package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
- package/dist/runtime/instrumentation/asyncData.js +49 -0
- package/dist/runtime/instrumentation/component.d.ts +2 -0
- package/dist/runtime/instrumentation/component.js +126 -0
- package/dist/runtime/instrumentation/fetch.d.ts +2 -0
- package/dist/runtime/instrumentation/fetch.js +89 -0
- package/dist/runtime/instrumentation/route.d.ts +6 -0
- package/dist/runtime/instrumentation/route.js +41 -0
- 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 +82 -2
- package/dist/runtime/tracing/context.d.ts +9 -0
- package/dist/runtime/tracing/context.js +15 -0
- package/dist/runtime/tracing/trace.d.ts +25 -0
- package/dist/runtime/tracing/trace.js +0 -0
- package/dist/runtime/tracing/traceStore.d.ts +39 -0
- package/dist/runtime/tracing/traceStore.js +101 -0
- package/dist/runtime/tracing/tracing.d.ts +27 -0
- package/dist/runtime/tracing/tracing.js +48 -0
- package/package.json +5 -1
- package/client/.env +0 -16
- package/client/dist/assets/index-BCaKoHBH.js +0 -17
- package/client/dist/assets/index-BmGW_M3W.css +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
|
|
2
|
+
|
|
3
|
+
interface SpanMetadata {
|
|
4
|
+
uid?: string | number
|
|
5
|
+
componentName?: string
|
|
6
|
+
file?: string
|
|
7
|
+
lifecycle?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TraceRenderStatsRow {
|
|
11
|
+
componentKey: string
|
|
12
|
+
componentName: string
|
|
13
|
+
file: string
|
|
14
|
+
uid: string | number
|
|
15
|
+
mountCount: number
|
|
16
|
+
rerenderCount: number
|
|
17
|
+
totalMs: number
|
|
18
|
+
avgMs: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CrossTraceRenderSummaryRow {
|
|
22
|
+
componentKey: string
|
|
23
|
+
componentName: string
|
|
24
|
+
file: string
|
|
25
|
+
tracesSeen: number
|
|
26
|
+
avgRerendersPerTrace: number
|
|
27
|
+
totalRerenders: number
|
|
28
|
+
totalMs: number
|
|
29
|
+
avgMsPerRender: number
|
|
30
|
+
selectedUid?: string | number
|
|
31
|
+
selectedRerenders?: number
|
|
32
|
+
baselineRerenders?: number
|
|
33
|
+
deltaVsBaseline?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function asMetadata(span: TraceSpan): SpanMetadata {
|
|
37
|
+
return ((span.metadata as Record<string, unknown> | undefined) ?? {}) as SpanMetadata
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeMs(value?: number): number {
|
|
41
|
+
return Number.isFinite(value) ? (value as number) : 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getComponentIdentity(metadata: SpanMetadata, fallbackId: string): { key: string; name: string; file: string } {
|
|
45
|
+
const name = String(metadata.componentName ?? '').trim()
|
|
46
|
+
const file = String(metadata.file ?? '').trim()
|
|
47
|
+
|
|
48
|
+
if (name && file) {
|
|
49
|
+
return {
|
|
50
|
+
key: `${file}::${name}`,
|
|
51
|
+
name,
|
|
52
|
+
file,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (name) {
|
|
57
|
+
return {
|
|
58
|
+
key: `name::${name}`,
|
|
59
|
+
name,
|
|
60
|
+
file,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
key: `unknown::${fallbackId}`,
|
|
66
|
+
name: fallbackId,
|
|
67
|
+
file,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build per-trace render stats grouped by component UID. */
|
|
72
|
+
export function buildRenderSummaryForTrace(trace?: TraceEntry): TraceRenderStatsRow[] {
|
|
73
|
+
if (!trace) {
|
|
74
|
+
return []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const byUid = new Map<string | number, { key: string; name: string; file: string; spans: TraceSpan[] }>()
|
|
78
|
+
|
|
79
|
+
for (const span of trace.spans) {
|
|
80
|
+
if (span.type !== 'render') {
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const metadata = asMetadata(span)
|
|
85
|
+
const uid = (metadata.uid as string | number | undefined) ?? span.id
|
|
86
|
+
const identity = getComponentIdentity(metadata, String(uid))
|
|
87
|
+
|
|
88
|
+
if (!byUid.has(uid)) {
|
|
89
|
+
byUid.set(uid, {
|
|
90
|
+
key: identity.key,
|
|
91
|
+
name: identity.name,
|
|
92
|
+
file: identity.file,
|
|
93
|
+
spans: [],
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
byUid.get(uid)!.spans.push(span)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rows: TraceRenderStatsRow[] = []
|
|
101
|
+
|
|
102
|
+
for (const [uid, { key, name, file, spans }] of byUid) {
|
|
103
|
+
let mountCount = 0
|
|
104
|
+
let rerenderCount = 0
|
|
105
|
+
let totalMs = 0
|
|
106
|
+
|
|
107
|
+
for (const span of spans) {
|
|
108
|
+
const lifecycle = String(asMetadata(span).lifecycle ?? '')
|
|
109
|
+
const ms = normalizeMs(span.durationMs)
|
|
110
|
+
|
|
111
|
+
if (lifecycle === 'render:mount') {
|
|
112
|
+
mountCount++
|
|
113
|
+
} else {
|
|
114
|
+
rerenderCount++
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
totalMs += ms
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalRenders = mountCount + rerenderCount
|
|
121
|
+
rows.push({
|
|
122
|
+
componentKey: key,
|
|
123
|
+
componentName: name || String(uid),
|
|
124
|
+
file,
|
|
125
|
+
uid,
|
|
126
|
+
mountCount,
|
|
127
|
+
rerenderCount,
|
|
128
|
+
totalMs,
|
|
129
|
+
avgMs: totalRenders > 0 ? totalMs / totalRenders : 0,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
rows.sort((a, b) => b.rerenderCount - a.rerenderCount || b.totalMs - a.totalMs)
|
|
134
|
+
|
|
135
|
+
return rows
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build cross-trace render aggregation and comparison against a selected trace. */
|
|
139
|
+
export function buildCrossTraceRenderSummary(traces: TraceEntry[], selectedTraceId?: string): CrossTraceRenderSummaryRow[] {
|
|
140
|
+
if (traces.length === 0) {
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const aggregate = new Map<
|
|
145
|
+
string,
|
|
146
|
+
{
|
|
147
|
+
componentName: string
|
|
148
|
+
file: string
|
|
149
|
+
tracesSeen: number
|
|
150
|
+
totalRerenders: number
|
|
151
|
+
totalRenders: number
|
|
152
|
+
totalMs: number
|
|
153
|
+
selectedUid?: string | number
|
|
154
|
+
selectedRerenders?: number
|
|
155
|
+
}
|
|
156
|
+
>()
|
|
157
|
+
|
|
158
|
+
for (const trace of traces) {
|
|
159
|
+
const rows = buildRenderSummaryForTrace(trace)
|
|
160
|
+
|
|
161
|
+
// Collapse multiple UIDs for the same component identity within one trace.
|
|
162
|
+
const perTrace = new Map<
|
|
163
|
+
string,
|
|
164
|
+
{ name: string; file: string; rerenders: number; renders: number; ms: number; uid: string | number; peakUidRerenders: number }
|
|
165
|
+
>()
|
|
166
|
+
|
|
167
|
+
for (const row of rows) {
|
|
168
|
+
const existing = perTrace.get(row.componentKey)
|
|
169
|
+
|
|
170
|
+
if (!existing) {
|
|
171
|
+
perTrace.set(row.componentKey, {
|
|
172
|
+
name: row.componentName,
|
|
173
|
+
file: row.file,
|
|
174
|
+
rerenders: row.rerenderCount,
|
|
175
|
+
renders: row.mountCount + row.rerenderCount,
|
|
176
|
+
ms: row.totalMs,
|
|
177
|
+
uid: row.uid,
|
|
178
|
+
peakUidRerenders: row.rerenderCount,
|
|
179
|
+
})
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
existing.rerenders += row.rerenderCount
|
|
184
|
+
existing.renders += row.mountCount + row.rerenderCount
|
|
185
|
+
existing.ms += row.totalMs
|
|
186
|
+
|
|
187
|
+
// Keep the UID with higher re-render count for potential highlight mapping.
|
|
188
|
+
if (row.rerenderCount > existing.peakUidRerenders) {
|
|
189
|
+
existing.uid = row.uid
|
|
190
|
+
existing.peakUidRerenders = row.rerenderCount
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [componentKey, value] of perTrace) {
|
|
195
|
+
if (!aggregate.has(componentKey)) {
|
|
196
|
+
aggregate.set(componentKey, {
|
|
197
|
+
componentName: value.name,
|
|
198
|
+
file: value.file,
|
|
199
|
+
tracesSeen: 0,
|
|
200
|
+
totalRerenders: 0,
|
|
201
|
+
totalRenders: 0,
|
|
202
|
+
totalMs: 0,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const target = aggregate.get(componentKey)!
|
|
207
|
+
target.tracesSeen += 1
|
|
208
|
+
target.totalRerenders += value.rerenders
|
|
209
|
+
target.totalRenders += value.renders
|
|
210
|
+
target.totalMs += value.ms
|
|
211
|
+
|
|
212
|
+
if (selectedTraceId && trace.id === selectedTraceId) {
|
|
213
|
+
target.selectedUid = value.uid
|
|
214
|
+
target.selectedRerenders = value.rerenders
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rows: CrossTraceRenderSummaryRow[] = []
|
|
220
|
+
|
|
221
|
+
for (const [componentKey, value] of aggregate) {
|
|
222
|
+
const avgRerendersPerTrace = value.tracesSeen > 0 ? value.totalRerenders / value.tracesSeen : 0
|
|
223
|
+
const avgMsPerRender = value.totalRenders > 0 ? value.totalMs / value.totalRenders : 0
|
|
224
|
+
|
|
225
|
+
let baselineRerenders: number | undefined
|
|
226
|
+
let deltaVsBaseline: number | undefined
|
|
227
|
+
|
|
228
|
+
if (value.selectedRerenders !== undefined && value.tracesSeen > 1) {
|
|
229
|
+
const others = value.tracesSeen - 1
|
|
230
|
+
const othersTotal = value.totalRerenders - value.selectedRerenders
|
|
231
|
+
baselineRerenders = others > 0 ? othersTotal / others : 0
|
|
232
|
+
deltaVsBaseline = value.selectedRerenders - baselineRerenders
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
rows.push({
|
|
236
|
+
componentKey,
|
|
237
|
+
componentName: value.componentName,
|
|
238
|
+
file: value.file,
|
|
239
|
+
tracesSeen: value.tracesSeen,
|
|
240
|
+
avgRerendersPerTrace,
|
|
241
|
+
totalRerenders: value.totalRerenders,
|
|
242
|
+
totalMs: value.totalMs,
|
|
243
|
+
avgMsPerRender,
|
|
244
|
+
selectedUid: value.selectedUid,
|
|
245
|
+
selectedRerenders: value.selectedRerenders,
|
|
246
|
+
baselineRerenders,
|
|
247
|
+
deltaVsBaseline,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
rows.sort((a, b) => b.avgRerendersPerTrace - a.avgRerendersPerTrace || b.totalMs - a.totalMs)
|
|
252
|
+
|
|
253
|
+
return rows
|
|
254
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface ObservatoryExportFile<T> {
|
|
2
|
+
type: 'observatory-traces' | 'observatory-renders'
|
|
3
|
+
version: '1'
|
|
4
|
+
exportedAt: number
|
|
5
|
+
count: number
|
|
6
|
+
data: T[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function exportJson(filename: string, envelope: ObservatoryExportFile<unknown>): void {
|
|
10
|
+
const json = JSON.stringify(envelope, null, 2)
|
|
11
|
+
const blob = new Blob([json], { type: 'application/json' })
|
|
12
|
+
const url = URL.createObjectURL(blob)
|
|
13
|
+
const anchor = document.createElement('a')
|
|
14
|
+
anchor.href = url
|
|
15
|
+
anchor.download = filename
|
|
16
|
+
anchor.click()
|
|
17
|
+
URL.revokeObjectURL(url)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function importJson(): Promise<unknown> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const input = document.createElement('input')
|
|
23
|
+
input.type = 'file'
|
|
24
|
+
input.accept = '.json,application/json'
|
|
25
|
+
|
|
26
|
+
input.addEventListener('change', () => {
|
|
27
|
+
const file = input.files?.[0]
|
|
28
|
+
|
|
29
|
+
if (!file) {
|
|
30
|
+
reject(new Error('No file selected'))
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const reader = new FileReader()
|
|
35
|
+
|
|
36
|
+
reader.addEventListener('load', () => {
|
|
37
|
+
try {
|
|
38
|
+
resolve(JSON.parse(reader.result as string))
|
|
39
|
+
} catch {
|
|
40
|
+
reject(new Error('Invalid JSON file'))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
reader.addEventListener('error', () => reject(new Error('Failed to read file')))
|
|
45
|
+
reader.readAsText(file)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Reject if the dialog is cancelled (focus returns without a change event)
|
|
49
|
+
window.addEventListener(
|
|
50
|
+
'focus',
|
|
51
|
+
() => {
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
if (!input.files?.length) {
|
|
54
|
+
reject(new Error('cancelled'))
|
|
55
|
+
}
|
|
56
|
+
}, 300)
|
|
57
|
+
},
|
|
58
|
+
{ once: true }
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
input.click()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import type { TraceEntry } from '@observatory/types/snapshot'
|
|
3
|
+
|
|
4
|
+
export function getSpanTypesFromTraces(traces: TraceEntry[]): string[] {
|
|
5
|
+
const types = new Set<string>()
|
|
6
|
+
|
|
7
|
+
for (const trace of traces) {
|
|
8
|
+
for (const span of trace.spans) {
|
|
9
|
+
types.add(span.type)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return Array.from(types).sort()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useTraceFilter() {
|
|
17
|
+
const searchQuery = ref<string>('')
|
|
18
|
+
const selectedSpanTypes = ref<Set<string>>(new Set())
|
|
19
|
+
const minDuration = ref<number>(0)
|
|
20
|
+
const maxDuration = ref<number>(Infinity)
|
|
21
|
+
const routeFilter = ref<string>('')
|
|
22
|
+
|
|
23
|
+
function matchesSearch(trace: TraceEntry, query: string): boolean {
|
|
24
|
+
if (!query) {
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lowerQuery = query.toLowerCase()
|
|
29
|
+
|
|
30
|
+
// Search in trace name
|
|
31
|
+
if (trace.name.toLowerCase().includes(lowerQuery)) {
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Search in span names
|
|
36
|
+
for (const span of trace.spans) {
|
|
37
|
+
if (span.name.toLowerCase().includes(lowerQuery)) {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Search in metadata (e.g., route, URL endpoint)
|
|
43
|
+
for (const span of trace.spans) {
|
|
44
|
+
if (span.metadata) {
|
|
45
|
+
for (const value of Object.values(span.metadata)) {
|
|
46
|
+
if (typeof value === 'string' && value.toLowerCase().includes(lowerQuery)) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matchesSpanTypeFilter(trace: TraceEntry, types: Set<string>): boolean {
|
|
57
|
+
if (types.size === 0) return true
|
|
58
|
+
|
|
59
|
+
for (const span of trace.spans) {
|
|
60
|
+
if (types.has(span.type)) {
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function matchesDurationFilter(trace: TraceEntry, min: number, max: number): boolean {
|
|
69
|
+
const hasExplicitDurationFilter = min > 0 || max < Infinity
|
|
70
|
+
|
|
71
|
+
if (trace.durationMs === undefined) {
|
|
72
|
+
return !hasExplicitDurationFilter
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return trace.durationMs >= min && trace.durationMs <= max
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function matchesRouteFilter(trace: TraceEntry, route: string): boolean {
|
|
79
|
+
if (!route) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check trace metadata for route
|
|
84
|
+
if (trace.metadata?.route && typeof trace.metadata.route === 'string') {
|
|
85
|
+
if (trace.metadata.route.toLowerCase().includes(route.toLowerCase())) {
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check span metadata for route or path
|
|
91
|
+
for (const span of trace.spans) {
|
|
92
|
+
if (span.metadata?.route) {
|
|
93
|
+
const routeValue = String(span.metadata.route).toLowerCase()
|
|
94
|
+
|
|
95
|
+
if (routeValue.includes(route.toLowerCase())) {
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (span.metadata?.path) {
|
|
101
|
+
const pathValue = String(span.metadata.path).toLowerCase()
|
|
102
|
+
|
|
103
|
+
if (pathValue.includes(route.toLowerCase())) {
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function filterTraces(traces: TraceEntry[]): TraceEntry[] {
|
|
113
|
+
return traces.filter((trace) => {
|
|
114
|
+
return (
|
|
115
|
+
matchesSearch(trace, searchQuery.value) &&
|
|
116
|
+
matchesSpanTypeFilter(trace, selectedSpanTypes.value) &&
|
|
117
|
+
matchesDurationFilter(trace, minDuration.value, maxDuration.value) &&
|
|
118
|
+
matchesRouteFilter(trace, routeFilter.value)
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toggleSpanType(type: string) {
|
|
124
|
+
if (selectedSpanTypes.value.has(type)) {
|
|
125
|
+
selectedSpanTypes.value.delete(type)
|
|
126
|
+
} else {
|
|
127
|
+
selectedSpanTypes.value.add(type)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clearFilters() {
|
|
132
|
+
searchQuery.value = ''
|
|
133
|
+
selectedSpanTypes.value.clear()
|
|
134
|
+
minDuration.value = 0
|
|
135
|
+
maxDuration.value = Infinity
|
|
136
|
+
routeFilter.value = ''
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const hasActiveFilters = computed(() => {
|
|
140
|
+
return (
|
|
141
|
+
searchQuery.value.length > 0 ||
|
|
142
|
+
selectedSpanTypes.value.size > 0 ||
|
|
143
|
+
minDuration.value > 0 ||
|
|
144
|
+
maxDuration.value < Infinity ||
|
|
145
|
+
routeFilter.value.length > 0
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
searchQuery,
|
|
151
|
+
selectedSpanTypes,
|
|
152
|
+
minDuration,
|
|
153
|
+
maxDuration,
|
|
154
|
+
routeFilter,
|
|
155
|
+
filterTraces,
|
|
156
|
+
toggleSpanType,
|
|
157
|
+
clearFilters,
|
|
158
|
+
hasActiveFilters,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import { useDevtoolsClient, onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
|
|
3
3
|
import type { ObservatorySnapshot, ObservatoryServerFunctions, ObservatoryClientFunctions } from '@observatory/types/rpc'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
FetchEntry,
|
|
6
|
+
ProvideEntry,
|
|
7
|
+
InjectEntry,
|
|
8
|
+
ComposableEntry,
|
|
9
|
+
RenderEntry,
|
|
10
|
+
TransitionEntry,
|
|
11
|
+
TraceEntry,
|
|
12
|
+
} from '@observatory/types/snapshot'
|
|
5
13
|
|
|
6
14
|
type ProvideInjectSnapshot = { provides: ProvideEntry[]; injects: InjectEntry[] }
|
|
7
15
|
|
|
@@ -10,6 +18,7 @@ const provideInject = ref<ProvideInjectSnapshot>({ provides: [], injects: [] })
|
|
|
10
18
|
const composables = ref<ComposableEntry[]>([])
|
|
11
19
|
const renders = ref<RenderEntry[]>([])
|
|
12
20
|
const transitions = ref<TransitionEntry[]>([])
|
|
21
|
+
const traces = ref<TraceEntry[]>([])
|
|
13
22
|
const connected = ref(false)
|
|
14
23
|
const features = ref<ObservatorySnapshot['features']>({})
|
|
15
24
|
const debugRpc = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugRpc')
|
|
@@ -50,6 +59,7 @@ function applySnapshot(data: ObservatorySnapshot) {
|
|
|
50
59
|
composables.value = cloneArray(data.composables as ComposableEntry[] | undefined)
|
|
51
60
|
renders.value = normalizeRenderEntries(data.renders as RenderEntry[] | undefined)
|
|
52
61
|
transitions.value = cloneArray(data.transitions as TransitionEntry[] | undefined)
|
|
62
|
+
traces.value = cloneArray(data.traces as TraceEntry[] | undefined)
|
|
53
63
|
features.value = data.features || {}
|
|
54
64
|
|
|
55
65
|
// If the server snapshot disagrees with the user's requested mode,
|
|
@@ -79,6 +89,7 @@ function applySnapshot(data: ObservatorySnapshot) {
|
|
|
79
89
|
composables: composables.value.length,
|
|
80
90
|
renders: renders.value.length,
|
|
81
91
|
transitions: transitions.value.length,
|
|
92
|
+
traces: traces.value.length,
|
|
82
93
|
})
|
|
83
94
|
}
|
|
84
95
|
}
|
|
@@ -224,6 +235,7 @@ export function useObservatoryData() {
|
|
|
224
235
|
composables,
|
|
225
236
|
renders,
|
|
226
237
|
transitions,
|
|
238
|
+
traces,
|
|
227
239
|
features,
|
|
228
240
|
connected,
|
|
229
241
|
refresh,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
editComposableValue,
|
|
7
7
|
openInEditor as openInEditorFromStore,
|
|
8
8
|
} from '@observatory-client/stores/observatory'
|
|
9
|
+
import { matchesComposableEntryQuery } from '@observatory-client/composables/composable-search'
|
|
9
10
|
import type { ComposableEntry as RuntimeComposableEntry } from '@observatory/types/snapshot'
|
|
10
11
|
|
|
11
12
|
const { composables: rawEntries, connected, features, clearComposables } = useObservatoryData()
|
|
@@ -118,23 +119,8 @@ const filtered = computed(() => {
|
|
|
118
119
|
return false
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (q) {
|
|
124
|
-
const matchesName = entry.name.toLowerCase().includes(q)
|
|
125
|
-
const matchesFile = entry.componentFile.toLowerCase().includes(q)
|
|
126
|
-
const matchesRef = Object.keys(entry.refs).some((k) => k.toLowerCase().includes(q))
|
|
127
|
-
const matchesVal = Object.values(entry.refs).some((v) => {
|
|
128
|
-
try {
|
|
129
|
-
return String(JSON.stringify(v.value)).toLowerCase().includes(q)
|
|
130
|
-
} catch {
|
|
131
|
-
return false
|
|
132
|
-
}
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
if (!matchesName && !matchesFile && !matchesRef && !matchesVal) {
|
|
136
|
-
return false
|
|
137
|
-
}
|
|
122
|
+
if (search.value.trim() && !matchesComposableEntryQuery(entry, search.value)) {
|
|
123
|
+
return false
|
|
138
124
|
}
|
|
139
125
|
|
|
140
126
|
return true
|
|
@@ -234,22 +220,67 @@ function toggleRefExpand(entryId: string, refKey: string) {
|
|
|
234
220
|
}
|
|
235
221
|
|
|
236
222
|
// ── Reverse lookup ────────────────────────────────────────────────────────
|
|
237
|
-
// Clicking a ref key
|
|
223
|
+
// Clicking a ref key prefers identity-based lookup for shared/global refs.
|
|
224
|
+
// For non-shared keys, fallback to legacy key-name lookup.
|
|
238
225
|
|
|
239
|
-
|
|
226
|
+
interface LookupTarget {
|
|
227
|
+
key: string
|
|
228
|
+
composableName: string
|
|
229
|
+
identityGroup?: string
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const lookupTarget = ref<LookupTarget | null>(null)
|
|
240
233
|
|
|
241
234
|
const lookupResults = computed(() => {
|
|
242
|
-
if (!
|
|
235
|
+
if (!lookupTarget.value) {
|
|
243
236
|
return []
|
|
244
237
|
}
|
|
245
238
|
|
|
246
|
-
const
|
|
239
|
+
const target = lookupTarget.value
|
|
240
|
+
|
|
241
|
+
if (target.identityGroup) {
|
|
242
|
+
return entries.value.filter(
|
|
243
|
+
(entry) =>
|
|
244
|
+
entry.name === target.composableName &&
|
|
245
|
+
entry.sharedKeyGroups?.[target.key] === target.identityGroup &&
|
|
246
|
+
target.key in entry.refs
|
|
247
|
+
)
|
|
248
|
+
}
|
|
247
249
|
|
|
248
|
-
return entries.value.filter((
|
|
250
|
+
return entries.value.filter((entry) => target.key in entry.refs)
|
|
249
251
|
})
|
|
250
252
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
+
const lookupTitle = computed(() => {
|
|
254
|
+
if (!lookupTarget.value) {
|
|
255
|
+
return ''
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (lookupTarget.value.identityGroup) {
|
|
259
|
+
return `${lookupTarget.value.key} (shared identity)`
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return lookupTarget.value.key
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
function openLookup(entry: RuntimeComposableEntry, key: string) {
|
|
266
|
+
const identityGroup = entry.sharedKeyGroups?.[key]
|
|
267
|
+
const next: LookupTarget = {
|
|
268
|
+
key,
|
|
269
|
+
composableName: entry.name,
|
|
270
|
+
identityGroup,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
lookupTarget.value?.key === next.key &&
|
|
275
|
+
lookupTarget.value?.composableName === next.composableName &&
|
|
276
|
+
lookupTarget.value?.identityGroup === next.identityGroup
|
|
277
|
+
) {
|
|
278
|
+
lookupTarget.value = null
|
|
279
|
+
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
lookupTarget.value = next
|
|
253
284
|
}
|
|
254
285
|
|
|
255
286
|
// ── Inline editing ────────────────────────────────────────────────────────
|
|
@@ -408,8 +439,12 @@ function applyEdit() {
|
|
|
408
439
|
<div v-for="[k, v] in Object.entries(entry.refs)" :key="k" class="composable-tracker__ref-row">
|
|
409
440
|
<span
|
|
410
441
|
class="composable-tracker__ref-key composable-tracker__ref-key--clickable mono text-sm"
|
|
411
|
-
:title="
|
|
412
|
-
|
|
442
|
+
:title="
|
|
443
|
+
entry.sharedKeyGroups?.[k]
|
|
444
|
+
? `click to see instances sharing this exact '${k}' state`
|
|
445
|
+
: `click to see all instances exposing '${k}'`
|
|
446
|
+
"
|
|
447
|
+
@click.stop="openLookup(entry, k)"
|
|
413
448
|
>
|
|
414
449
|
{{ k }}
|
|
415
450
|
</span>
|
|
@@ -530,14 +565,14 @@ function applyEdit() {
|
|
|
530
565
|
|
|
531
566
|
<!-- ── Reverse lookup panel ──────────────────────────────────────── -->
|
|
532
567
|
<Transition name="slide">
|
|
533
|
-
<div v-if="
|
|
568
|
+
<div v-if="lookupTarget" class="composable-tracker__lookup-panel">
|
|
534
569
|
<div class="composable-tracker__lookup-header">
|
|
535
|
-
<span class="mono text-sm">{{
|
|
570
|
+
<span class="mono text-sm">{{ lookupTitle }}</span>
|
|
536
571
|
<span class="muted text-sm">— {{ lookupResults.length }} instance{{ lookupResults.length !== 1 ? 's' : '' }}</span>
|
|
537
|
-
<button class="composable-tracker__clear-btn composable-tracker__lookup-close" @click="
|
|
572
|
+
<button class="composable-tracker__clear-btn composable-tracker__lookup-close" @click="lookupTarget = null">✕</button>
|
|
538
573
|
</div>
|
|
539
574
|
<div v-if="!lookupResults.length" class="composable-tracker__lookup-empty muted text-sm">
|
|
540
|
-
No
|
|
575
|
+
No instances matched this lookup.
|
|
541
576
|
</div>
|
|
542
577
|
<div v-for="r in lookupResults" :key="r.id" class="composable-tracker__lookup-row">
|
|
543
578
|
<span class="mono text-sm">{{ r.name }}</span>
|