nuxt-devtools-observatory 0.1.28 → 0.1.31
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 +93 -11
- package/client/.env +2 -1
- package/client/.env.example +2 -1
- package/client/dist/assets/index-BuMXDBO9.js +17 -0
- package/client/dist/assets/index-CwcspZ6w.css +1 -0
- package/client/dist/index.html +2 -2
- package/client/src/App.vue +4 -0
- package/client/src/components/Flamegraph.vue +443 -0
- package/client/src/components/SpanInspector.vue +446 -0
- package/client/src/components/TraceFilter.vue +344 -0
- package/client/src/components/WaterfallView.vue +443 -0
- package/client/src/composables/useResizablePane.ts +65 -0
- package/client/src/composables/useTraceFilter.ts +164 -0
- package/client/src/stores/observatory.ts +16 -2
- package/client/src/style.css +203 -28
- package/client/src/views/ComposableTracker.vue +324 -259
- package/client/src/views/FetchDashboard.vue +104 -133
- package/client/src/views/ProvideInjectGraph.vue +99 -109
- package/client/src/views/RenderHeatmap.vue +191 -147
- package/client/src/views/TraceViewer.vue +599 -0
- package/client/src/views/TransitionTimeline.vue +167 -137
- package/client/tsconfig.json +3 -1
- package/client/vite.config.ts +8 -0
- package/dist/module.d.mts +5 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +186 -200
- package/dist/runtime/composables/render-registry.js +66 -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/plugin.js +39 -3
- 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 +9 -6
- package/client/dist/assets/index-DXCGQOSF.js +0 -17
- package/client/dist/assets/index-htI4WwBU.css +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
trace: TraceEntry
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = defineProps<Props>()
|
|
10
|
+
|
|
11
|
+
const spansByType = computed(() => {
|
|
12
|
+
const groups: Record<string, TraceSpan[]> = {}
|
|
13
|
+
|
|
14
|
+
for (const span of props.trace.spans) {
|
|
15
|
+
if (!groups[span.type]) {
|
|
16
|
+
groups[span.type] = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
groups[span.type].push(span)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Object.entries(groups)
|
|
23
|
+
.map(([type, spans]) => ({
|
|
24
|
+
type,
|
|
25
|
+
spans: spans.sort((a, b) => a.startTime - b.startTime),
|
|
26
|
+
}))
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
const typeOrder: Record<string, number> = {
|
|
29
|
+
navigation: 0,
|
|
30
|
+
fetch: 1,
|
|
31
|
+
composable: 2,
|
|
32
|
+
component: 3,
|
|
33
|
+
render: 4,
|
|
34
|
+
transition: 5,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (typeOrder[a.type] ?? 999) - (typeOrder[b.type] ?? 999)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const timelineDuration = computed(() => {
|
|
42
|
+
if (props.trace.durationMs && props.trace.durationMs > 0) {
|
|
43
|
+
return props.trace.durationMs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (props.trace.endTime && props.trace.endTime > props.trace.startTime) {
|
|
47
|
+
return props.trace.endTime - props.trace.startTime
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let maxEndOffset = 0
|
|
51
|
+
|
|
52
|
+
for (const span of props.trace.spans) {
|
|
53
|
+
const endTime = span.endTime ?? span.startTime + (span.durationMs ?? 0)
|
|
54
|
+
const endOffset = endTime - props.trace.startTime
|
|
55
|
+
|
|
56
|
+
if (endOffset > maxEndOffset) {
|
|
57
|
+
maxEndOffset = endOffset
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return maxEndOffset || 1
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function getSpanX(span: TraceSpan): number {
|
|
65
|
+
const left = ((span.startTime - props.trace.startTime) / timelineDuration.value) * 100
|
|
66
|
+
|
|
67
|
+
return Math.min(100, Math.max(0, left))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getSpanWidth(span: TraceSpan): number {
|
|
71
|
+
const left = getSpanX(span)
|
|
72
|
+
const width = ((span.durationMs || 0) / timelineDuration.value) * 100
|
|
73
|
+
|
|
74
|
+
return Math.max(2, Math.min(100 - left, width))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isNarrowBar(span: TraceSpan): boolean {
|
|
78
|
+
const width = ((span.durationMs || 0) / timelineDuration.value) * 100
|
|
79
|
+
|
|
80
|
+
return width < 5
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatDuration(durationMs?: number) {
|
|
84
|
+
if (!durationMs) {
|
|
85
|
+
return '0ms'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (durationMs < 1) {
|
|
89
|
+
return `${(durationMs * 1000).toFixed(1)}μs`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (durationMs < 1000) {
|
|
93
|
+
return `${durationMs.toFixed(1)}ms`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return `${(durationMs / 1000).toFixed(2)}s`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getSpanColorClass(type: string) {
|
|
100
|
+
const colors: Record<string, string> = {
|
|
101
|
+
fetch: 'bg-blue-500',
|
|
102
|
+
composable: 'bg-purple-500',
|
|
103
|
+
component: 'bg-green-500',
|
|
104
|
+
navigation: 'bg-yellow-500',
|
|
105
|
+
render: 'bg-orange-500',
|
|
106
|
+
transition: 'bg-pink-500',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return colors[type] || 'bg-gray-500'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getStatusColor(status: string) {
|
|
113
|
+
const statusColors: Record<string, string> = {
|
|
114
|
+
ok: 'border-green-400',
|
|
115
|
+
error: 'border-red-400',
|
|
116
|
+
cancelled: 'border-gray-400',
|
|
117
|
+
active: 'border-yellow-400',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return statusColors[status] || 'border-gray-400'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function asString(val: unknown): string {
|
|
124
|
+
return typeof val === 'string' ? val : ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getSpanDisplayName(span: TraceSpan): string {
|
|
128
|
+
const m = span.metadata as Record<string, unknown> | undefined
|
|
129
|
+
|
|
130
|
+
if (!m) {
|
|
131
|
+
return span.name
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const componentName = asString(m.componentName)
|
|
135
|
+
const lifecycle = asString(m.lifecycle)
|
|
136
|
+
const uid = m.uid !== undefined ? String(m.uid) : ''
|
|
137
|
+
const method = asString(m.method)
|
|
138
|
+
const url = asString(m.url)
|
|
139
|
+
const route = asString(m.route) || asString(m.path)
|
|
140
|
+
|
|
141
|
+
if (componentName && lifecycle) {
|
|
142
|
+
return uid ? `${componentName}.${lifecycle} #${uid}` : `${componentName}.${lifecycle}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (componentName) {
|
|
146
|
+
return uid ? `${componentName} #${uid}` : componentName
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (method && url) {
|
|
150
|
+
return `${method} ${url}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (url) {
|
|
154
|
+
return url
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (route) {
|
|
158
|
+
return `${span.name} (${route})`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return span.name
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getSpanTooltip(span: TraceSpan): string {
|
|
165
|
+
const displayName = getSpanDisplayName(span)
|
|
166
|
+
const duration = formatDuration(span.durationMs)
|
|
167
|
+
const m = span.metadata as Record<string, unknown> | undefined
|
|
168
|
+
const route = m ? asString(m.route) || asString(m.path) : ''
|
|
169
|
+
let tooltip = `${displayName} — ${duration} (${span.status})`
|
|
170
|
+
|
|
171
|
+
if (route && !displayName.includes(route)) {
|
|
172
|
+
tooltip += ` [${route}]`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tooltip
|
|
176
|
+
}
|
|
177
|
+
</script>
|
|
178
|
+
|
|
179
|
+
<template>
|
|
180
|
+
<div class="waterfall">
|
|
181
|
+
<div class="waterfall__timeline-header">
|
|
182
|
+
<div class="waterfall__type-col">Type</div>
|
|
183
|
+
<div class="waterfall__timeline-col">
|
|
184
|
+
<div class="waterfall__time-markers">
|
|
185
|
+
<div class="waterfall__time-marker">0ms</div>
|
|
186
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 4) }}</div>
|
|
187
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration / 2) }}</div>
|
|
188
|
+
<div class="waterfall__time-marker">{{ formatDuration((timelineDuration * 3) / 4) }}</div>
|
|
189
|
+
<div class="waterfall__time-marker">{{ formatDuration(timelineDuration) }}</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="waterfall__groups">
|
|
195
|
+
<div v-for="group in spansByType" :key="group.type" class="waterfall__group">
|
|
196
|
+
<div class="waterfall__group-header">
|
|
197
|
+
<span class="waterfall__group-type">
|
|
198
|
+
<span class="waterfall__color-dot" :class="getSpanColorClass(group.type)"></span>
|
|
199
|
+
{{ group.type }}
|
|
200
|
+
</span>
|
|
201
|
+
<span class="waterfall__group-count">{{ group.spans.length }}</span>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div class="waterfall__group-spans">
|
|
205
|
+
<div v-for="span in group.spans" :key="span.id" class="waterfall__span-row">
|
|
206
|
+
<div class="waterfall__span-label">
|
|
207
|
+
<span :title="getSpanTooltip(span)">{{ getSpanDisplayName(span) }}</span>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="waterfall__span-bar-container">
|
|
210
|
+
<div
|
|
211
|
+
class="waterfall__span-bar"
|
|
212
|
+
:class="[getSpanColorClass(span.type), getStatusColor(span.status)]"
|
|
213
|
+
:style="{
|
|
214
|
+
left: `${getSpanX(span)}%`,
|
|
215
|
+
width: `${Math.max(1, getSpanWidth(span))}%`,
|
|
216
|
+
}"
|
|
217
|
+
:title="getSpanTooltip(span)"
|
|
218
|
+
>
|
|
219
|
+
<span v-if="!isNarrowBar(span)" class="waterfall__bar-duration">{{ formatDuration(span.durationMs) }}</span>
|
|
220
|
+
</div>
|
|
221
|
+
<span
|
|
222
|
+
v-if="isNarrowBar(span)"
|
|
223
|
+
class="waterfall__bar-duration-outside"
|
|
224
|
+
:style="{ left: `calc(${getSpanX(span)}% + 4px)` }"
|
|
225
|
+
>
|
|
226
|
+
{{ formatDuration(span.durationMs) }}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div v-if="spansByType.length === 0" class="waterfall__empty">No spans in this trace</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|
|
237
|
+
|
|
238
|
+
<style scoped>
|
|
239
|
+
.waterfall {
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-direction: column;
|
|
242
|
+
height: 100%;
|
|
243
|
+
overflow: auto;
|
|
244
|
+
font-family: var(--mono);
|
|
245
|
+
font-size: 11px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.waterfall__timeline-header {
|
|
249
|
+
display: flex;
|
|
250
|
+
position: sticky;
|
|
251
|
+
top: 0;
|
|
252
|
+
z-index: 20;
|
|
253
|
+
background: var(--bg2, var(--bg));
|
|
254
|
+
box-shadow: 0 1px 0 var(--border);
|
|
255
|
+
border-bottom: 1px solid var(--border);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.waterfall__type-col {
|
|
259
|
+
width: 120px;
|
|
260
|
+
min-width: 120px;
|
|
261
|
+
padding: 8px 0;
|
|
262
|
+
border-right: 1px solid var(--border);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.waterfall__timeline-col {
|
|
266
|
+
flex: 1;
|
|
267
|
+
min-width: 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.waterfall__time-markers {
|
|
271
|
+
display: flex;
|
|
272
|
+
justify-content: space-around;
|
|
273
|
+
padding: 8px 16px;
|
|
274
|
+
gap: 8px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.waterfall__time-marker {
|
|
278
|
+
font-size: 10px;
|
|
279
|
+
color: var(--text2, var(--text));
|
|
280
|
+
flex: 1;
|
|
281
|
+
text-align: center;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.waterfall__groups {
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.waterfall__group {
|
|
290
|
+
border-bottom: 1px solid var(--border);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.waterfall__group-header {
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
justify-content: space-between;
|
|
297
|
+
background: var(--bg2, var(--bg));
|
|
298
|
+
padding: 8px 16px;
|
|
299
|
+
font-weight: 600;
|
|
300
|
+
font-size: 12px;
|
|
301
|
+
color: var(--text2, var(--text));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.waterfall__group-type {
|
|
305
|
+
display: flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
gap: 6px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.waterfall__color-dot {
|
|
311
|
+
display: inline-block;
|
|
312
|
+
width: 8px;
|
|
313
|
+
height: 8px;
|
|
314
|
+
border-radius: 2px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.waterfall__group-count {
|
|
318
|
+
background: var(--bg);
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
padding: 2px 6px;
|
|
321
|
+
border-radius: 3px;
|
|
322
|
+
font-size: 10px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.waterfall__group-spans {
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.waterfall__span-row {
|
|
331
|
+
display: flex;
|
|
332
|
+
height: 28px;
|
|
333
|
+
align-items: center;
|
|
334
|
+
border-bottom: 1px solid var(--border);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.waterfall__span-label {
|
|
338
|
+
width: 120px;
|
|
339
|
+
min-width: 120px;
|
|
340
|
+
padding: 0 16px;
|
|
341
|
+
overflow: hidden;
|
|
342
|
+
text-overflow: ellipsis;
|
|
343
|
+
white-space: nowrap;
|
|
344
|
+
color: var(--text);
|
|
345
|
+
font-size: 11px;
|
|
346
|
+
border-right: 1px solid var(--border);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.waterfall__span-bar-container {
|
|
350
|
+
flex: 1;
|
|
351
|
+
height: 100%;
|
|
352
|
+
position: relative;
|
|
353
|
+
min-width: 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.waterfall__span-bar {
|
|
357
|
+
position: absolute;
|
|
358
|
+
top: 50%;
|
|
359
|
+
transform: translateY(-50%);
|
|
360
|
+
height: 16px;
|
|
361
|
+
border-radius: 2px;
|
|
362
|
+
border-left: 2px solid;
|
|
363
|
+
display: flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
justify-content: center;
|
|
366
|
+
color: white;
|
|
367
|
+
overflow: hidden;
|
|
368
|
+
cursor: pointer;
|
|
369
|
+
transition: opacity 0.2s;
|
|
370
|
+
padding: 0 4px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.waterfall__span-bar:hover {
|
|
374
|
+
opacity: 0.8;
|
|
375
|
+
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.waterfall__bar-duration {
|
|
379
|
+
font-size: 9px;
|
|
380
|
+
white-space: nowrap;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.waterfall__bar-duration-outside {
|
|
384
|
+
position: absolute;
|
|
385
|
+
top: 50%;
|
|
386
|
+
transform: translateY(-50%);
|
|
387
|
+
font-size: 9px;
|
|
388
|
+
white-space: nowrap;
|
|
389
|
+
color: var(--text);
|
|
390
|
+
pointer-events: none;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.waterfall__empty {
|
|
394
|
+
padding: 32px 16px;
|
|
395
|
+
text-align: center;
|
|
396
|
+
color: var(--text2, var(--text));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* Color utilities */
|
|
400
|
+
.bg-blue-500 {
|
|
401
|
+
background-color: #3b82f6;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.bg-purple-500 {
|
|
405
|
+
background-color: #a855f7;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.bg-green-500 {
|
|
409
|
+
background-color: #22c55e;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.bg-yellow-500 {
|
|
413
|
+
background-color: #eab308;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.bg-orange-500 {
|
|
417
|
+
background-color: #f97316;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.bg-pink-500 {
|
|
421
|
+
background-color: #ec4899;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.bg-gray-500 {
|
|
425
|
+
background-color: #6b7280;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.border-green-400 {
|
|
429
|
+
border-color: #4ade80;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.border-red-400 {
|
|
433
|
+
border-color: #f87171;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.border-gray-400 {
|
|
437
|
+
border-color: #9ca3af;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.border-yellow-400 {
|
|
441
|
+
border-color: #facc15;
|
|
442
|
+
}
|
|
443
|
+
</style>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ref, onBeforeUnmount } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds drag-to-resize behaviour for a right-side detail panel.
|
|
5
|
+
* The handle sits between the main area and the panel; dragging it
|
|
6
|
+
* left/right adjusts the panel width.
|
|
7
|
+
* @param {number} defaultWidth Initial panel width in px.
|
|
8
|
+
* @param {string} [storageKey] Optional localStorage key to persist the width.
|
|
9
|
+
* @returns {{ paneWidth: import('vue').Ref<number>, onHandleMouseDown: (e: MouseEvent) => void }} `paneWidth` ref (in px) and `onHandleMouseDown` event handler to attach to the resize handle element.
|
|
10
|
+
*/
|
|
11
|
+
export function useResizablePane(defaultWidth: number, storageKey?: string) {
|
|
12
|
+
const stored = storageKey ? Number(localStorage.getItem(storageKey)) || defaultWidth : defaultWidth
|
|
13
|
+
const paneWidth = ref(Math.max(160, Math.min(600, stored)))
|
|
14
|
+
|
|
15
|
+
let dragging = false
|
|
16
|
+
let startX = 0
|
|
17
|
+
let startWidth = 0
|
|
18
|
+
|
|
19
|
+
function onMouseMove(e: MouseEvent) {
|
|
20
|
+
if (!dragging) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle is on the LEFT edge of the panel → moving left increases width
|
|
25
|
+
const delta = startX - e.clientX
|
|
26
|
+
paneWidth.value = Math.max(160, Math.min(600, startWidth + delta))
|
|
27
|
+
|
|
28
|
+
if (storageKey) {
|
|
29
|
+
// eslint-disable-next-line
|
|
30
|
+
localStorage.setItem(storageKey, String(paneWidth.value))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function onMouseUp() {
|
|
35
|
+
if (!dragging) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
dragging = false
|
|
40
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
41
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
42
|
+
document.body.style.cursor = ''
|
|
43
|
+
document.body.style.userSelect = ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onHandleMouseDown(e: MouseEvent) {
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
dragging = true
|
|
49
|
+
startX = e.clientX
|
|
50
|
+
startWidth = paneWidth.value
|
|
51
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
52
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
53
|
+
document.body.style.cursor = 'col-resize'
|
|
54
|
+
document.body.style.userSelect = 'none'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onBeforeUnmount(() => {
|
|
58
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
59
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
60
|
+
document.body.style.cursor = ''
|
|
61
|
+
document.body.style.userSelect = ''
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return { paneWidth, onHandleMouseDown }
|
|
65
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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(
|
|
69
|
+
trace: TraceEntry,
|
|
70
|
+
min: number,
|
|
71
|
+
max: number
|
|
72
|
+
): boolean {
|
|
73
|
+
const hasExplicitDurationFilter = min > 0 || max < Infinity
|
|
74
|
+
|
|
75
|
+
if (trace.durationMs === undefined) {
|
|
76
|
+
return !hasExplicitDurationFilter
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return trace.durationMs >= min && trace.durationMs <= max
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchesRouteFilter(trace: TraceEntry, route: string): boolean {
|
|
83
|
+
if (!route) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check trace metadata for route
|
|
88
|
+
if (trace.metadata?.route && typeof trace.metadata.route === 'string') {
|
|
89
|
+
if (trace.metadata.route.toLowerCase().includes(route.toLowerCase())) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check span metadata for route or path
|
|
95
|
+
for (const span of trace.spans) {
|
|
96
|
+
if (span.metadata?.route) {
|
|
97
|
+
const routeValue = String(span.metadata.route).toLowerCase()
|
|
98
|
+
|
|
99
|
+
if (routeValue.includes(route.toLowerCase())) {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (span.metadata?.path) {
|
|
105
|
+
const pathValue = String(span.metadata.path).toLowerCase()
|
|
106
|
+
|
|
107
|
+
if (pathValue.includes(route.toLowerCase())) {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function filterTraces(traces: TraceEntry[]): TraceEntry[] {
|
|
117
|
+
return traces.filter((trace) => {
|
|
118
|
+
return (
|
|
119
|
+
matchesSearch(trace, searchQuery.value) &&
|
|
120
|
+
matchesSpanTypeFilter(trace, selectedSpanTypes.value) &&
|
|
121
|
+
matchesDurationFilter(trace, minDuration.value, maxDuration.value) &&
|
|
122
|
+
matchesRouteFilter(trace, routeFilter.value)
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toggleSpanType(type: string) {
|
|
128
|
+
if (selectedSpanTypes.value.has(type)) {
|
|
129
|
+
selectedSpanTypes.value.delete(type)
|
|
130
|
+
} else {
|
|
131
|
+
selectedSpanTypes.value.add(type)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function clearFilters() {
|
|
136
|
+
searchQuery.value = ''
|
|
137
|
+
selectedSpanTypes.value.clear()
|
|
138
|
+
minDuration.value = 0
|
|
139
|
+
maxDuration.value = Infinity
|
|
140
|
+
routeFilter.value = ''
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const hasActiveFilters = computed(() => {
|
|
144
|
+
return (
|
|
145
|
+
searchQuery.value.length > 0 ||
|
|
146
|
+
selectedSpanTypes.value.size > 0 ||
|
|
147
|
+
minDuration.value > 0 ||
|
|
148
|
+
maxDuration.value < Infinity ||
|
|
149
|
+
routeFilter.value.length > 0
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
searchQuery,
|
|
155
|
+
selectedSpanTypes,
|
|
156
|
+
minDuration,
|
|
157
|
+
maxDuration,
|
|
158
|
+
routeFilter,
|
|
159
|
+
filterTraces,
|
|
160
|
+
toggleSpanType,
|
|
161
|
+
clearFilters,
|
|
162
|
+
hasActiveFilters,
|
|
163
|
+
}
|
|
164
|
+
}
|