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.
Files changed (51) hide show
  1. package/README.md +117 -30
  2. package/client/.env.example +2 -1
  3. package/client/dist/assets/index-5Wl1XYRH.js +17 -0
  4. package/client/dist/assets/index-DT_QUiIh.css +1 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +4 -0
  7. package/client/src/components/Flamegraph.vue +442 -0
  8. package/client/src/components/SpanInspector.vue +446 -0
  9. package/client/src/components/TraceFilter.vue +342 -0
  10. package/client/src/components/WaterfallView.vue +443 -0
  11. package/client/src/composables/composable-search.ts +124 -0
  12. package/client/src/composables/trace-render-aggregation.ts +254 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +160 -0
  15. package/client/src/stores/observatory.ts +13 -1
  16. package/client/src/views/ComposableTracker.vue +65 -30
  17. package/client/src/views/RenderHeatmap.vue +63 -1
  18. package/client/src/views/TraceViewer.vue +1212 -0
  19. package/client/src/views/TransitionTimeline.vue +1 -6
  20. package/dist/module.d.mts +5 -0
  21. package/dist/module.json +1 -1
  22. package/dist/module.mjs +31 -45
  23. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  24. package/dist/runtime/composables/composable-registry.js +63 -5
  25. package/dist/runtime/composables/render-registry.js +74 -110
  26. package/dist/runtime/composables/transition-registry.js +103 -28
  27. package/dist/runtime/instrumentation/asyncData.d.ts +9 -0
  28. package/dist/runtime/instrumentation/asyncData.js +49 -0
  29. package/dist/runtime/instrumentation/component.d.ts +2 -0
  30. package/dist/runtime/instrumentation/component.js +126 -0
  31. package/dist/runtime/instrumentation/fetch.d.ts +2 -0
  32. package/dist/runtime/instrumentation/fetch.js +89 -0
  33. package/dist/runtime/instrumentation/route.d.ts +6 -0
  34. package/dist/runtime/instrumentation/route.js +41 -0
  35. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  36. package/dist/runtime/nitro/fetch-capture.js +85 -7
  37. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  38. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  39. package/dist/runtime/plugin.js +82 -2
  40. package/dist/runtime/tracing/context.d.ts +9 -0
  41. package/dist/runtime/tracing/context.js +15 -0
  42. package/dist/runtime/tracing/trace.d.ts +25 -0
  43. package/dist/runtime/tracing/trace.js +0 -0
  44. package/dist/runtime/tracing/traceStore.d.ts +39 -0
  45. package/dist/runtime/tracing/traceStore.js +101 -0
  46. package/dist/runtime/tracing/tracing.d.ts +27 -0
  47. package/dist/runtime/tracing/tracing.js +48 -0
  48. package/package.json +5 -1
  49. package/client/.env +0 -16
  50. package/client/dist/assets/index-BCaKoHBH.js +0 -17
  51. package/client/dist/assets/index-BmGW_M3W.css +0 -1
@@ -0,0 +1,1212 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { useObservatoryData } from '@observatory-client/stores/observatory'
4
+ import Flamegraph from '@observatory-client/components/Flamegraph.vue'
5
+ import WaterfallView from '@observatory-client/components/WaterfallView.vue'
6
+ import SpanInspector from '@observatory-client/components/SpanInspector.vue'
7
+ import TraceFilter from '@observatory-client/components/TraceFilter.vue'
8
+ import { useTraceFilter } from '@observatory-client/composables/useTraceFilter'
9
+ import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
10
+ import {
11
+ buildRenderSummaryForTrace,
12
+ buildCrossTraceRenderSummary,
13
+ type TraceRenderStatsRow,
14
+ } from '@observatory-client/composables/trace-render-aggregation'
15
+ import type { ObservatoryExportFile } from '@observatory-client/composables/useExportImport'
16
+ import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
17
+
18
+ const { traces, connected } = useObservatoryData()
19
+
20
+ const importedTraces = ref<TraceEntry[]>([])
21
+ const isImportMode = computed(() => importedTraces.value.length > 0)
22
+
23
+ const selectedTraceId = ref<string | null>(null)
24
+ const selectedSpan = ref<TraceSpan | undefined>(undefined)
25
+ const viewMode = ref<'overview' | 'flamegraph' | 'waterfall'>('overview')
26
+ const showFilters = ref(false)
27
+ const renderSummaryOpen = ref(true)
28
+ const crossTraceSummaryOpen = ref(true)
29
+ const crossTraceSortBy = ref<'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName'>('avgRerendersPerTrace')
30
+ const crossTraceSortDir = ref<'asc' | 'desc'>('desc')
31
+ const crossTraceOnlyRegressions = ref(false)
32
+ const crossTraceOnlyComparable = ref(false)
33
+ const crossTraceSearch = ref('')
34
+ const highlightedUid = ref<string | number | undefined>(undefined)
35
+ const highlightedComponentKey = ref<string | undefined>(undefined)
36
+
37
+ const {
38
+ searchQuery,
39
+ selectedSpanTypes,
40
+ minDuration,
41
+ maxDuration,
42
+ routeFilter,
43
+ filterTraces: applyFilters,
44
+ clearFilters,
45
+ hasActiveFilters,
46
+ } = useTraceFilter()
47
+
48
+ const sortedTraces = computed(() => {
49
+ const source = isImportMode.value ? importedTraces.value : traces.value
50
+ return [...source].sort((a, b) => b.startTime - a.startTime)
51
+ })
52
+
53
+ const filteredTraces = computed(() => {
54
+ return applyFilters(sortedTraces.value)
55
+ })
56
+
57
+ const traceCountLabel = computed(() => {
58
+ const suffix = isImportMode.value ? ' (imported)' : ''
59
+
60
+ if (!hasActiveFilters.value) {
61
+ return `${sortedTraces.value.length} traces${suffix}`
62
+ }
63
+
64
+ return `Showing ${filteredTraces.value.length} of ${sortedTraces.value.length} traces${suffix}`
65
+ })
66
+
67
+ const selectedTrace = computed(() => {
68
+ if (!selectedTraceId.value) {
69
+ return filteredTraces.value[0]
70
+ }
71
+
72
+ return filteredTraces.value.find((t) => t.id === selectedTraceId.value)
73
+ })
74
+
75
+ const renderSummary = computed<TraceRenderStatsRow[]>(() => buildRenderSummaryForTrace(selectedTrace.value))
76
+
77
+ const crossTraceRenderSummary = computed(() => {
78
+ return buildCrossTraceRenderSummary(filteredTraces.value, selectedTrace.value?.id)
79
+ })
80
+
81
+ const crossTraceRows = computed(() => {
82
+ const q = crossTraceSearch.value.trim().toLowerCase()
83
+
84
+ let rows = crossTraceRenderSummary.value.filter((row) => {
85
+ if (crossTraceOnlyRegressions.value && (row.deltaVsBaseline ?? 0) <= 0) {
86
+ return false
87
+ }
88
+
89
+ if (crossTraceOnlyComparable.value && row.selectedRerenders === undefined) {
90
+ return false
91
+ }
92
+
93
+ if (q) {
94
+ const nameMatch = row.componentName.toLowerCase().includes(q)
95
+ const fileMatch = row.file.toLowerCase().includes(q)
96
+
97
+ if (!nameMatch && !fileMatch) {
98
+ return false
99
+ }
100
+ }
101
+
102
+ return true
103
+ })
104
+
105
+ rows = [...rows].sort((a, b) => {
106
+ const key = crossTraceSortBy.value
107
+ const dir = crossTraceSortDir.value === 'asc' ? 1 : -1
108
+
109
+ if (key === 'componentName') {
110
+ return a.componentName.localeCompare(b.componentName) * dir
111
+ }
112
+
113
+ if (key === 'deltaVsBaseline') {
114
+ const av = a.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
115
+ const bv = b.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
116
+
117
+ return (av - bv) * dir
118
+ }
119
+
120
+ return ((a[key] as number) - (b[key] as number)) * dir
121
+ })
122
+
123
+ return rows
124
+ })
125
+
126
+ const crossTraceRegressionsCount = computed(() => crossTraceRenderSummary.value.filter((row) => (row.deltaVsBaseline ?? 0) > 0).length)
127
+
128
+ function elapsedFromSpans(trace: TraceEntry): number | undefined {
129
+ if (!trace.spans.length) {
130
+ return undefined
131
+ }
132
+
133
+ let max = 0
134
+
135
+ for (const span of trace.spans) {
136
+ const end = span.endTime ?? span.startTime + (span.durationMs ?? 0)
137
+ const offset = end - trace.startTime
138
+
139
+ if (offset > max) {
140
+ max = offset
141
+ }
142
+ }
143
+
144
+ return max > 0 ? max : undefined
145
+ }
146
+
147
+ function formatDuration(durationMs?: number, trace?: TraceEntry): string {
148
+ if (durationMs !== undefined) {
149
+ return `${Math.round(durationMs * 10) / 10}ms`
150
+ }
151
+
152
+ if (trace) {
153
+ const elapsed = elapsedFromSpans(trace)
154
+
155
+ if (elapsed !== undefined) {
156
+ return `~${Math.round(elapsed * 10) / 10}ms`
157
+ }
158
+ }
159
+
160
+ return 'active'
161
+ }
162
+
163
+ function asString(val: unknown): string {
164
+ return typeof val === 'string' ? val : ''
165
+ }
166
+
167
+ function getSpanDisplayName(span: TraceSpan): string {
168
+ const m = span.metadata as Record<string, unknown> | undefined
169
+
170
+ if (!m) {
171
+ return span.name
172
+ }
173
+
174
+ const componentName = asString(m.componentName)
175
+ const lifecycle = asString(m.lifecycle)
176
+ const uid = m.uid !== undefined ? String(m.uid) : ''
177
+ const method = asString(m.method)
178
+ const url = asString(m.url)
179
+ const route = asString(m.route) || asString(m.path)
180
+
181
+ if (componentName && lifecycle) {
182
+ return uid ? `${componentName}.${lifecycle} #${uid}` : `${componentName}.${lifecycle}`
183
+ }
184
+
185
+ if (componentName) {
186
+ return uid ? `${componentName} #${uid}` : componentName
187
+ }
188
+
189
+ if (method && url) {
190
+ return `${method} ${url}`
191
+ }
192
+
193
+ if (url) {
194
+ return url
195
+ }
196
+
197
+ if (route) {
198
+ return `${span.name} (${route})`
199
+ }
200
+
201
+ return span.name
202
+ }
203
+
204
+ function selectTrace(trace: TraceEntry) {
205
+ selectedTraceId.value = trace.id
206
+ selectedSpan.value = undefined
207
+ highlightedUid.value = undefined
208
+ highlightedComponentKey.value = undefined
209
+ }
210
+
211
+ function handleClearFilters() {
212
+ clearFilters()
213
+ selectedSpan.value = undefined
214
+ highlightedUid.value = undefined
215
+ highlightedComponentKey.value = undefined
216
+ }
217
+
218
+ function handleSpanTypesUpdate(value: Set<string>) {
219
+ selectedSpanTypes.value = value
220
+ }
221
+
222
+ function handleExport() {
223
+ exportJson(`observatory-traces-${Date.now()}.json`, {
224
+ type: 'observatory-traces',
225
+ version: '1',
226
+ exportedAt: Date.now(),
227
+ count: traces.value.length,
228
+ data: traces.value,
229
+ })
230
+ }
231
+
232
+ async function handleImport() {
233
+ let parsed: unknown
234
+
235
+ try {
236
+ parsed = await importJson()
237
+ } catch (err) {
238
+ if (err instanceof Error && err.message !== 'cancelled') {
239
+ alert(`Import failed: ${err.message}`)
240
+ }
241
+ return
242
+ }
243
+
244
+ const file = parsed as ObservatoryExportFile<TraceEntry>
245
+
246
+ if (
247
+ file?.type !== 'observatory-traces' ||
248
+ file?.version !== '1' ||
249
+ !Array.isArray(file?.data) ||
250
+ (file.data.length > 0 && (!file.data[0]?.id || !file.data[0]?.name || !Array.isArray(file.data[0]?.spans)))
251
+ ) {
252
+ alert('Invalid observatory traces file.')
253
+ return
254
+ }
255
+
256
+ importedTraces.value = file.data
257
+ selectedTraceId.value = null
258
+ selectedSpan.value = undefined
259
+ }
260
+
261
+ function handleBackToLive() {
262
+ importedTraces.value = []
263
+ selectedTraceId.value = null
264
+ selectedSpan.value = undefined
265
+ highlightedUid.value = undefined
266
+ highlightedComponentKey.value = undefined
267
+ }
268
+
269
+ function handleRenderSummaryRowClick(uid: string | number) {
270
+ // Toggle highlight: clicking the already-highlighted row deselects it.
271
+ highlightedUid.value = highlightedUid.value === uid ? undefined : uid
272
+ highlightedComponentKey.value = undefined
273
+ selectedSpan.value = undefined
274
+ }
275
+
276
+ function formatDelta(value?: number): string {
277
+ if (value === undefined) {
278
+ return 'n/a'
279
+ }
280
+
281
+ const rounded = Math.round(value * 10) / 10
282
+
283
+ if (rounded > 0) {
284
+ return `+${rounded}`
285
+ }
286
+
287
+ return `${rounded}`
288
+ }
289
+
290
+ function deltaToneClass(value?: number): string {
291
+ if (value === undefined) {
292
+ return 'trace-viewer__delta--na'
293
+ }
294
+
295
+ if (value > 0) {
296
+ return 'trace-viewer__delta--regression'
297
+ }
298
+
299
+ if (value < 0) {
300
+ return 'trace-viewer__delta--improvement'
301
+ }
302
+
303
+ return 'trace-viewer__delta--neutral'
304
+ }
305
+
306
+ function cycleCrossTraceSort(next: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
307
+ if (crossTraceSortBy.value === next) {
308
+ crossTraceSortDir.value = crossTraceSortDir.value === 'desc' ? 'asc' : 'desc'
309
+
310
+ return
311
+ }
312
+
313
+ crossTraceSortBy.value = next
314
+ crossTraceSortDir.value = next === 'componentName' ? 'asc' : 'desc'
315
+ }
316
+
317
+ function sortIndicator(key: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
318
+ if (crossTraceSortBy.value !== key) {
319
+ return ''
320
+ }
321
+
322
+ return crossTraceSortDir.value === 'desc' ? ' ↓' : ' ↑'
323
+ }
324
+
325
+ function clearCrossTraceHighlight() {
326
+ highlightedComponentKey.value = undefined
327
+ highlightedUid.value = undefined
328
+ }
329
+
330
+ function handleCrossTraceRowClick(componentKey: string) {
331
+ const row = crossTraceRows.value.find((item) => item.componentKey === componentKey)
332
+
333
+ if (!row || row.selectedUid === undefined) {
334
+ highlightedUid.value = undefined
335
+ highlightedComponentKey.value = undefined
336
+ selectedSpan.value = undefined
337
+
338
+ return
339
+ }
340
+
341
+ if (highlightedComponentKey.value === componentKey) {
342
+ highlightedComponentKey.value = undefined
343
+ highlightedUid.value = undefined
344
+ selectedSpan.value = undefined
345
+
346
+ return
347
+ }
348
+
349
+ highlightedComponentKey.value = componentKey
350
+ highlightedUid.value = row.selectedUid
351
+ selectedSpan.value = undefined
352
+ }
353
+ </script>
354
+
355
+ <template>
356
+ <div class="trace-viewer tracker-view">
357
+ <!-- Header -->
358
+ <div class="trace-viewer__header tracker-toolbar">
359
+ <div class="trace-viewer__title">Trace Viewer</div>
360
+ <div class="trace-viewer__header-actions">
361
+ <div class="trace-viewer__count muted text-sm">{{ traceCountLabel }}</div>
362
+ <button v-if="isImportMode" class="trace-viewer__import-mode-btn" title="Return to live data" @click="handleBackToLive">
363
+ ← live
364
+ </button>
365
+ <button
366
+ class="trace-viewer__action-btn"
367
+ title="Export traces as JSON"
368
+ :disabled="traces.length === 0 && !isImportMode"
369
+ @click="handleExport"
370
+ >
371
+ ↓ export
372
+ </button>
373
+ <button class="trace-viewer__action-btn" title="Import traces from JSON file" @click="handleImport">↑ import</button>
374
+ <button
375
+ :class="{ 'trace-viewer__filter-btn--active': showFilters }"
376
+ class="trace-viewer__filter-btn"
377
+ title="Toggle filters"
378
+ @click="showFilters = !showFilters"
379
+ >
380
+ 🔍
381
+ </button>
382
+ </div>
383
+ </div>
384
+
385
+ <!-- Filters panel -->
386
+ <TraceFilter
387
+ v-if="showFilters"
388
+ :traces="sortedTraces"
389
+ :search-query="searchQuery"
390
+ :selected-span-types="selectedSpanTypes"
391
+ :min-duration="minDuration"
392
+ :max-duration="maxDuration"
393
+ :route-filter="routeFilter"
394
+ :has-active-filters="hasActiveFilters"
395
+ @update:search="searchQuery = $event"
396
+ @update:types="handleSpanTypesUpdate"
397
+ @update:min-duration="minDuration = $event"
398
+ @update:max-duration="maxDuration = $event"
399
+ @update:route="routeFilter = $event"
400
+ @clear-filters="handleClearFilters"
401
+ />
402
+
403
+ <!-- Main content -->
404
+ <div class="trace-viewer__container">
405
+ <!-- Trace list / Preview -->
406
+ <div class="trace-viewer__list">
407
+ <div class="trace-viewer__list-header">
408
+ <span class="trace-viewer__list-title">Traces</span>
409
+ </div>
410
+ <div class="trace-viewer__table-wrap tracker-table-wrap">
411
+ <table class="data-table">
412
+ <thead>
413
+ <tr>
414
+ <th>Trace Name</th>
415
+ <th>Duration</th>
416
+ <th>Spans</th>
417
+ </tr>
418
+ </thead>
419
+ <tbody>
420
+ <tr
421
+ v-for="trace in filteredTraces"
422
+ :key="trace.id"
423
+ :class="{ 'trace-viewer__trace-row--selected': selectedTrace?.id === trace.id }"
424
+ class="trace-viewer__trace-row"
425
+ @click="selectTrace(trace)"
426
+ >
427
+ <td class="mono">{{ trace.name }}</td>
428
+ <td class="mono">{{ formatDuration(trace.durationMs, trace) }}</td>
429
+ <td class="mono">{{ trace.spans.length }}</td>
430
+ </tr>
431
+ <tr v-if="!filteredTraces.length">
432
+ <td colspan="3" class="tracker-empty-cell">
433
+ {{
434
+ sortedTraces.length === 0
435
+ ? connected
436
+ ? 'No traces recorded yet.'
437
+ : 'Waiting for connection to the Nuxt app…'
438
+ : 'No traces match applied filters.'
439
+ }}
440
+ </td>
441
+ </tr>
442
+ </tbody>
443
+ </table>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- Visualization area + Inspector -->
448
+ <div v-if="selectedTrace" class="trace-viewer__detail">
449
+ <div class="trace-viewer__detail-content">
450
+ <!-- View mode tabs -->
451
+ <div class="trace-viewer__tabs">
452
+ <button
453
+ :class="{ 'trace-viewer__tab--active': viewMode === 'overview' }"
454
+ class="trace-viewer__tab"
455
+ @click="viewMode = 'overview'"
456
+ >
457
+ Overview
458
+ </button>
459
+ <button
460
+ :class="{ 'trace-viewer__tab--active': viewMode === 'flamegraph' }"
461
+ class="trace-viewer__tab"
462
+ @click="viewMode = 'flamegraph'"
463
+ >
464
+ Flamegraph
465
+ </button>
466
+ <button
467
+ :class="{ 'trace-viewer__tab--active': viewMode === 'waterfall' }"
468
+ class="trace-viewer__tab"
469
+ @click="viewMode = 'waterfall'"
470
+ >
471
+ Waterfall
472
+ </button>
473
+ </div>
474
+
475
+ <!-- Visualization -->
476
+ <div class="trace-viewer__visualization">
477
+ <!-- Overview -->
478
+ <div v-if="viewMode === 'overview'" class="trace-viewer__overview">
479
+ <div class="trace-viewer__overview-header">
480
+ <div>
481
+ <div class="trace-viewer__overview-label">Trace</div>
482
+ <div class="trace-viewer__overview-value">{{ selectedTrace.name }}</div>
483
+ </div>
484
+ <div>
485
+ <div class="trace-viewer__overview-label">Duration</div>
486
+ <div class="trace-viewer__overview-value">
487
+ {{ formatDuration(selectedTrace.durationMs, selectedTrace) }}
488
+ </div>
489
+ </div>
490
+ <div>
491
+ <div class="trace-viewer__overview-label">Spans</div>
492
+ <div class="trace-viewer__overview-value">{{ selectedTrace.spans.length }}</div>
493
+ </div>
494
+ <div>
495
+ <div class="trace-viewer__overview-label">Status</div>
496
+ <div class="trace-viewer__overview-value">{{ selectedTrace.status }}</div>
497
+ </div>
498
+ </div>
499
+
500
+ <div class="trace-viewer__span-list">
501
+ <div
502
+ v-for="span in selectedTrace.spans"
503
+ :key="span.id"
504
+ :class="{
505
+ 'trace-viewer__span-item--selected': selectedSpan?.id === span.id,
506
+ 'trace-viewer__span-item--highlighted':
507
+ highlightedUid !== undefined &&
508
+ (span.metadata as Record<string, unknown> | undefined)?.uid === highlightedUid,
509
+ }"
510
+ class="trace-viewer__span-item"
511
+ @click="selectedSpan = span"
512
+ >
513
+ <div class="trace-viewer__span-name">{{ getSpanDisplayName(span) }}</div>
514
+ <div class="trace-viewer__span-meta">
515
+ <span class="trace-viewer__span-type">{{ span.type }}</span>
516
+ <span class="trace-viewer__span-duration">{{ formatDuration(span.durationMs) }}</span>
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <!-- Render Summary panel — shown when the trace has render spans -->
522
+ <template v-if="renderSummary.length > 0">
523
+ <div class="trace-viewer__render-summary-header" @click="renderSummaryOpen = !renderSummaryOpen">
524
+ <span class="trace-viewer__render-summary-toggle">{{ renderSummaryOpen ? '▾' : '▸' }}</span>
525
+ <span class="trace-viewer__render-summary-title">Render Summary</span>
526
+ <span class="trace-viewer__render-summary-count muted">{{ renderSummary.length }} components</span>
527
+ <span
528
+ v-if="highlightedUid !== undefined"
529
+ class="trace-viewer__render-summary-clear"
530
+ @click.stop="highlightedUid = undefined"
531
+ >
532
+ ✕ clear
533
+ </span>
534
+ </div>
535
+ <div v-if="renderSummaryOpen" class="trace-viewer__render-summary-table-wrap">
536
+ <table class="data-table trace-viewer__render-summary-table">
537
+ <thead>
538
+ <tr>
539
+ <th>Component</th>
540
+ <th title="Times the component was mounted">Mounts</th>
541
+ <th title="Reactive re-renders after initial mount">Re-renders</th>
542
+ <th title="Average render duration">Avg</th>
543
+ <th title="Sum of all render durations">Total</th>
544
+ </tr>
545
+ </thead>
546
+ <tbody>
547
+ <tr
548
+ v-for="row in renderSummary"
549
+ :key="row.uid"
550
+ :class="{ 'trace-viewer__render-summary-row--active': highlightedUid === row.uid }"
551
+ class="trace-viewer__render-summary-row"
552
+ :title="row.file"
553
+ @click="handleRenderSummaryRowClick(row.uid)"
554
+ >
555
+ <td class="mono">{{ row.componentName }}</td>
556
+ <td class="mono">{{ row.mountCount }}</td>
557
+ <td class="mono" :class="{ 'trace-viewer__render-hot': row.rerenderCount > 3 }">
558
+ {{ row.rerenderCount }}
559
+ </td>
560
+ <td class="mono">{{ formatDuration(row.avgMs) }}</td>
561
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
562
+ </tr>
563
+ </tbody>
564
+ </table>
565
+ </div>
566
+ </template>
567
+
568
+ <!-- Cross-trace comparison panel -->
569
+ <template v-if="crossTraceRenderSummary.length > 0">
570
+ <div class="trace-viewer__render-summary-header" @click="crossTraceSummaryOpen = !crossTraceSummaryOpen">
571
+ <span class="trace-viewer__render-summary-toggle">{{ crossTraceSummaryOpen ? '▾' : '▸' }}</span>
572
+ <span class="trace-viewer__render-summary-title">Cross-Trace Render Comparison</span>
573
+ <span class="trace-viewer__render-summary-count muted">
574
+ {{ crossTraceRenderSummary.length }} components · {{ filteredTraces.length }} traces
575
+ </span>
576
+ <span class="trace-viewer__render-summary-count muted">
577
+ {{ crossTraceRegressionsCount }} regressions
578
+ </span>
579
+ <span class="trace-viewer__mobile-hint muted">mobile mode: condensed columns</span>
580
+ <span
581
+ v-if="highlightedComponentKey !== undefined"
582
+ class="trace-viewer__render-summary-clear"
583
+ @click.stop="clearCrossTraceHighlight"
584
+ >
585
+ ✕ clear
586
+ </span>
587
+ </div>
588
+ <div v-if="crossTraceSummaryOpen" class="trace-viewer__render-summary-table-wrap">
589
+ <div class="trace-viewer__comparison-toolbar">
590
+ <button
591
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyRegressions }"
592
+ class="trace-viewer__comparison-chip"
593
+ @click="crossTraceOnlyRegressions = !crossTraceOnlyRegressions"
594
+ >
595
+ regressions only
596
+ </button>
597
+ <button
598
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyComparable }"
599
+ class="trace-viewer__comparison-chip"
600
+ @click="crossTraceOnlyComparable = !crossTraceOnlyComparable"
601
+ >
602
+ selected trace only
603
+ </button>
604
+ <span class="trace-viewer__comparison-spacer"></span>
605
+ <input
606
+ v-model="crossTraceSearch"
607
+ class="trace-viewer__comparison-search mono"
608
+ type="search"
609
+ placeholder="filter component..."
610
+ />
611
+ </div>
612
+ <table class="data-table trace-viewer__render-summary-table">
613
+ <thead>
614
+ <tr>
615
+ <th class="trace-viewer__sortable" @click="cycleCrossTraceSort('componentName')">
616
+ Component{{ sortIndicator('componentName') }}
617
+ </th>
618
+ <th class="trace-viewer__col-desktop" title="How many traces include this component">
619
+ Traces
620
+ </th>
621
+ <th
622
+ class="trace-viewer__sortable"
623
+ title="Average re-renders per trace"
624
+ @click="cycleCrossTraceSort('avgRerendersPerTrace')"
625
+ >
626
+ Avg Re-renders{{ sortIndicator('avgRerendersPerTrace') }}
627
+ </th>
628
+ <th class="trace-viewer__col-desktop" title="Re-renders in selected trace">Selected</th>
629
+ <th
630
+ class="trace-viewer__sortable"
631
+ title="Selected trace vs other traces baseline"
632
+ @click="cycleCrossTraceSort('deltaVsBaseline')"
633
+ >
634
+ Delta{{ sortIndicator('deltaVsBaseline') }}
635
+ </th>
636
+ <th class="trace-viewer__col-desktop" title="Average render duration across all renders">
637
+ Avg ms
638
+ </th>
639
+ <th
640
+ class="trace-viewer__sortable"
641
+ title="Sum of all render duration across filtered traces"
642
+ @click="cycleCrossTraceSort('totalMs')"
643
+ >
644
+ Total ms{{ sortIndicator('totalMs') }}
645
+ </th>
646
+ </tr>
647
+ </thead>
648
+ <tbody>
649
+ <tr
650
+ v-for="row in crossTraceRows"
651
+ :key="row.componentKey"
652
+ :class="{
653
+ 'trace-viewer__render-summary-row--active':
654
+ highlightedComponentKey === row.componentKey,
655
+ }"
656
+ class="trace-viewer__render-summary-row"
657
+ :title="row.file"
658
+ @click="handleCrossTraceRowClick(row.componentKey)"
659
+ >
660
+ <td class="mono">{{ row.componentName }}</td>
661
+ <td class="mono trace-viewer__col-desktop">{{ row.tracesSeen }}</td>
662
+ <td class="mono">{{ (Math.round(row.avgRerendersPerTrace * 10) / 10).toFixed(1) }}</td>
663
+ <td class="mono trace-viewer__col-desktop">{{ row.selectedRerenders ?? 'n/a' }}</td>
664
+ <td class="mono">
665
+ <span class="trace-viewer__delta" :class="deltaToneClass(row.deltaVsBaseline)">
666
+ {{ formatDelta(row.deltaVsBaseline) }}
667
+ </span>
668
+ </td>
669
+ <td class="mono trace-viewer__col-desktop">{{ formatDuration(row.avgMsPerRender) }}</td>
670
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
671
+ </tr>
672
+ <tr v-if="!crossTraceRows.length">
673
+ <td colspan="7" class="tracker-empty-cell">No components match the comparison filters.</td>
674
+ </tr>
675
+ </tbody>
676
+ </table>
677
+ </div>
678
+ </template>
679
+ </div>
680
+
681
+ <!-- Flamegraph -->
682
+ <div v-if="viewMode === 'flamegraph'" class="trace-viewer__flamegraph">
683
+ <Flamegraph :trace="selectedTrace" :selected-span-id="selectedSpan?.id" @select-span="selectedSpan = $event" />
684
+ </div>
685
+
686
+ <!-- Waterfall -->
687
+ <div v-if="viewMode === 'waterfall'" class="trace-viewer__waterfall">
688
+ <WaterfallView :trace="selectedTrace" />
689
+ </div>
690
+ </div>
691
+ </div>
692
+
693
+ <!-- Inspector sidebar -->
694
+ <div class="trace-viewer__inspector">
695
+ <SpanInspector :trace="selectedTrace" :span="selectedSpan" @select-span="selectedSpan = $event" />
696
+ </div>
697
+ </div>
698
+ </div>
699
+ </div>
700
+ </template>
701
+
702
+ <style scoped>
703
+ .trace-viewer {
704
+ display: flex;
705
+ flex-direction: column;
706
+ height: 100%;
707
+ overflow: hidden;
708
+ }
709
+
710
+ .trace-viewer__header {
711
+ display: flex;
712
+ align-items: center;
713
+ justify-content: space-between;
714
+ flex-shrink: 0;
715
+ }
716
+
717
+ .trace-viewer__title {
718
+ font-size: 13px;
719
+ font-weight: 600;
720
+ color: var(--text);
721
+ }
722
+
723
+ .trace-viewer__header-actions {
724
+ display: flex;
725
+ align-items: center;
726
+ gap: 12px;
727
+ }
728
+
729
+ .trace-viewer__count {
730
+ font-family: var(--mono);
731
+ }
732
+
733
+ .trace-viewer__filter-btn {
734
+ padding: 4px 8px;
735
+ background: none;
736
+ border: 1px solid transparent;
737
+ color: var(--text-secondary);
738
+ cursor: pointer;
739
+ font-size: 14px;
740
+ transition: all 0.2s;
741
+ border-radius: 3px;
742
+ }
743
+
744
+ .trace-viewer__filter-btn:hover {
745
+ background: var(--bg-secondary);
746
+ border-color: var(--border);
747
+ }
748
+
749
+ .trace-viewer__filter-btn--active {
750
+ background: var(--accent-bg);
751
+ border-color: var(--accent);
752
+ color: var(--accent);
753
+ }
754
+
755
+ .trace-viewer__action-btn {
756
+ padding: 3px 8px;
757
+ background: none;
758
+ border: 1px solid var(--border);
759
+ color: var(--text-secondary);
760
+ cursor: pointer;
761
+ font-size: 11px;
762
+ border-radius: 3px;
763
+ transition: all 0.12s;
764
+ font-family: var(--mono);
765
+ }
766
+
767
+ .trace-viewer__action-btn:hover:not(:disabled) {
768
+ background: var(--bg-secondary);
769
+ color: var(--text);
770
+ }
771
+
772
+ .trace-viewer__action-btn:disabled {
773
+ opacity: 0.4;
774
+ cursor: not-allowed;
775
+ }
776
+
777
+ .trace-viewer__import-mode-btn {
778
+ padding: 3px 8px;
779
+ background: var(--accent-bg);
780
+ border: 1px solid var(--accent);
781
+ color: var(--accent);
782
+ cursor: pointer;
783
+ font-size: 11px;
784
+ border-radius: 3px;
785
+ transition: all 0.12s;
786
+ font-family: var(--mono);
787
+ }
788
+
789
+ .trace-viewer__import-mode-btn:hover {
790
+ opacity: 0.8;
791
+ }
792
+
793
+ .trace-viewer__container {
794
+ display: flex;
795
+ flex: 1;
796
+ min-height: 0;
797
+ gap: 1px;
798
+ background: var(--border);
799
+ }
800
+
801
+ .trace-viewer__list {
802
+ display: flex;
803
+ flex-direction: column;
804
+ width: 280px;
805
+ min-width: 280px;
806
+ background: var(--bg);
807
+ border-right: 1px solid var(--border);
808
+ overflow: hidden;
809
+ }
810
+
811
+ .trace-viewer__list-header {
812
+ padding: 8px 12px;
813
+ font-size: 12px;
814
+ font-weight: 600;
815
+ color: var(--text-secondary);
816
+ text-transform: uppercase;
817
+ letter-spacing: 0.5px;
818
+ border-bottom: 1px solid var(--border);
819
+ flex-shrink: 0;
820
+ }
821
+
822
+ .trace-viewer__table-wrap {
823
+ flex: 1;
824
+ overflow: auto;
825
+ }
826
+
827
+ .trace-viewer__trace-row {
828
+ cursor: pointer;
829
+ transition: background 0.2s;
830
+ }
831
+
832
+ .trace-viewer__trace-row:hover {
833
+ background: var(--bg2, var(--bg));
834
+ }
835
+
836
+ .trace-viewer__trace-row--selected {
837
+ background: var(--bg2, var(--bg));
838
+ border-left: 2px solid var(--accent);
839
+ }
840
+
841
+ .trace-viewer__detail {
842
+ flex: 1;
843
+ display: flex;
844
+ min-width: 0;
845
+ background: var(--bg);
846
+ overflow: hidden;
847
+ }
848
+
849
+ .trace-viewer__detail-content {
850
+ flex: 1;
851
+ display: flex;
852
+ flex-direction: column;
853
+ min-width: 0;
854
+ overflow: hidden;
855
+ }
856
+
857
+ .trace-viewer__tabs {
858
+ display: flex;
859
+ gap: 0;
860
+ border-bottom: 0.5px solid var(--border);
861
+ background: var(--bg3);
862
+ flex-shrink: 0;
863
+ padding: 8px 4px 0;
864
+ }
865
+
866
+ .trace-viewer__tab {
867
+ padding: 6px 12px 8px;
868
+ background: transparent;
869
+ border: none;
870
+ border-bottom: 2px solid transparent;
871
+ margin-bottom: -1px;
872
+ color: var(--text3);
873
+ cursor: pointer;
874
+ font-size: 12px;
875
+ font-weight: 500;
876
+ transition:
877
+ color 0.12s,
878
+ border-color 0.12s;
879
+ border-radius: 0;
880
+ }
881
+
882
+ .trace-viewer__tab:hover {
883
+ color: var(--text);
884
+ background: transparent;
885
+ }
886
+
887
+ .trace-viewer__tab--active {
888
+ color: var(--purple);
889
+ border-bottom-color: var(--purple);
890
+ background: transparent;
891
+ }
892
+
893
+ .trace-viewer__visualization {
894
+ flex: 1;
895
+ overflow: hidden;
896
+ min-height: 0;
897
+ }
898
+
899
+ .trace-viewer__overview {
900
+ display: flex;
901
+ flex-direction: column;
902
+ height: 100%;
903
+ overflow: hidden;
904
+ }
905
+
906
+ .trace-viewer__overview-header {
907
+ display: flex;
908
+ gap: 24px;
909
+ padding: 16px;
910
+ border-bottom: 1px solid var(--border);
911
+ background: var(--bg2, var(--bg));
912
+ flex-shrink: 0;
913
+ }
914
+
915
+ .trace-viewer__overview-label {
916
+ font-size: 10px;
917
+ font-weight: 600;
918
+ color: var(--text2, var(--text));
919
+ text-transform: uppercase;
920
+ letter-spacing: 0.5px;
921
+ margin-bottom: 4px;
922
+ }
923
+
924
+ .trace-viewer__overview-value {
925
+ font-size: 13px;
926
+ font-weight: 600;
927
+ color: var(--text);
928
+ font-family: var(--mono);
929
+ }
930
+
931
+ .trace-viewer__span-list {
932
+ flex: 1;
933
+ overflow: auto;
934
+ display: flex;
935
+ flex-direction: column;
936
+ }
937
+
938
+ .trace-viewer__span-item {
939
+ padding: 8px 16px;
940
+ border-bottom: 1px solid var(--border);
941
+ cursor: pointer;
942
+ transition: background 0.2s;
943
+ }
944
+
945
+ .trace-viewer__span-item:hover {
946
+ background: var(--bg2, var(--bg));
947
+ }
948
+
949
+ .trace-viewer__span-item--selected {
950
+ background: var(--bg2, var(--bg));
951
+ border-left: 2px solid var(--accent);
952
+ }
953
+
954
+ .trace-viewer__span-name {
955
+ font-size: 12px;
956
+ color: var(--text);
957
+ font-weight: 500;
958
+ overflow: hidden;
959
+ text-overflow: ellipsis;
960
+ white-space: nowrap;
961
+ }
962
+
963
+ .trace-viewer__span-meta {
964
+ display: flex;
965
+ gap: 8px;
966
+ margin-top: 4px;
967
+ font-size: 10px;
968
+ color: var(--text2, var(--text));
969
+ }
970
+
971
+ .trace-viewer__span-type {
972
+ padding: 2px 6px;
973
+ background: var(--bg2, var(--bg));
974
+ border: 1px solid var(--border);
975
+ border-radius: 3px;
976
+ }
977
+
978
+ .trace-viewer__span-duration {
979
+ font-family: var(--mono);
980
+ }
981
+
982
+ .trace-viewer__flamegraph {
983
+ height: 100%;
984
+ overflow: hidden;
985
+ }
986
+
987
+ .trace-viewer__waterfall {
988
+ height: 100%;
989
+ overflow: hidden;
990
+ }
991
+
992
+ .trace-viewer__inspector {
993
+ width: 300px;
994
+ min-width: 300px;
995
+ border-left: 1px solid var(--border);
996
+ background: var(--bg);
997
+ overflow: auto;
998
+ }
999
+
1000
+ .trace-viewer__span-item--highlighted {
1001
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1002
+ border-left: 2px solid var(--purple, #a855f7);
1003
+ }
1004
+
1005
+ .trace-viewer__render-summary-header {
1006
+ display: flex;
1007
+ align-items: center;
1008
+ gap: 8px;
1009
+ padding: 8px 16px;
1010
+ border-top: 1px solid var(--border);
1011
+ border-bottom: 1px solid var(--border);
1012
+ background: var(--bg3, var(--bg2, var(--bg)));
1013
+ cursor: pointer;
1014
+ user-select: none;
1015
+ flex-shrink: 0;
1016
+ }
1017
+
1018
+ .trace-viewer__render-summary-header:hover {
1019
+ background: var(--bg2, var(--bg));
1020
+ }
1021
+
1022
+ .trace-viewer__render-summary-toggle {
1023
+ font-size: 10px;
1024
+ color: var(--text3, var(--text2, var(--text)));
1025
+ width: 12px;
1026
+ flex-shrink: 0;
1027
+ }
1028
+
1029
+ .trace-viewer__render-summary-title {
1030
+ font-size: 11px;
1031
+ font-weight: 600;
1032
+ color: var(--text2, var(--text));
1033
+ text-transform: uppercase;
1034
+ letter-spacing: 0.5px;
1035
+ }
1036
+
1037
+ .trace-viewer__render-summary-count {
1038
+ font-size: 11px;
1039
+ font-family: var(--mono);
1040
+ color: var(--text3, var(--text2, var(--text)));
1041
+ }
1042
+
1043
+ .trace-viewer__render-summary-clear {
1044
+ margin-left: auto;
1045
+ font-size: 11px;
1046
+ color: var(--accent);
1047
+ cursor: pointer;
1048
+ }
1049
+
1050
+ .trace-viewer__render-summary-clear:hover {
1051
+ text-decoration: underline;
1052
+ }
1053
+
1054
+ .trace-viewer__mobile-hint {
1055
+ display: none;
1056
+ margin-left: auto;
1057
+ font-size: 11px;
1058
+ }
1059
+
1060
+ .trace-viewer__render-summary-table-wrap {
1061
+ flex-shrink: 0;
1062
+ max-height: 200px;
1063
+ overflow: auto;
1064
+ border-bottom: 1px solid var(--border);
1065
+ }
1066
+
1067
+ .trace-viewer__comparison-toolbar {
1068
+ display: flex;
1069
+ align-items: center;
1070
+ gap: 8px;
1071
+ padding: 8px 12px;
1072
+ border-bottom: 1px solid var(--border);
1073
+ background: var(--bg2, var(--bg));
1074
+ }
1075
+
1076
+ .trace-viewer__comparison-chip {
1077
+ padding: 2px 8px;
1078
+ border: 1px solid var(--border);
1079
+ border-radius: 999px;
1080
+ background: transparent;
1081
+ color: var(--text2, var(--text));
1082
+ font-size: 11px;
1083
+ font-family: var(--mono);
1084
+ cursor: pointer;
1085
+ }
1086
+
1087
+ .trace-viewer__comparison-chip--active {
1088
+ border-color: color-mix(in srgb, var(--purple, #a855f7) 55%, var(--border));
1089
+ background: color-mix(in srgb, var(--purple, #a855f7) 15%, transparent);
1090
+ color: var(--purple, #a855f7);
1091
+ }
1092
+
1093
+ .trace-viewer__comparison-spacer {
1094
+ flex: 1;
1095
+ }
1096
+
1097
+ .trace-viewer__comparison-search {
1098
+ min-width: 180px;
1099
+ max-width: 240px;
1100
+ }
1101
+
1102
+ .trace-viewer__render-summary-table {
1103
+ width: 100%;
1104
+ }
1105
+
1106
+ .trace-viewer__sortable {
1107
+ cursor: pointer;
1108
+ user-select: none;
1109
+ }
1110
+
1111
+ .trace-viewer__sortable:hover {
1112
+ color: var(--text);
1113
+ }
1114
+
1115
+ .trace-viewer__render-summary-row {
1116
+ cursor: pointer;
1117
+ transition: background 0.15s;
1118
+ }
1119
+
1120
+ .trace-viewer__render-summary-row:hover {
1121
+ background: var(--bg2, var(--bg));
1122
+ }
1123
+
1124
+ .trace-viewer__render-summary-row--active {
1125
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1126
+ border-left: 2px solid var(--purple, #a855f7);
1127
+ }
1128
+
1129
+ .trace-viewer__render-hot {
1130
+ color: var(--orange, #f97316);
1131
+ font-weight: 600;
1132
+ }
1133
+
1134
+ .trace-viewer__delta {
1135
+ display: inline-flex;
1136
+ align-items: center;
1137
+ justify-content: center;
1138
+ min-width: 46px;
1139
+ padding: 2px 6px;
1140
+ border-radius: 999px;
1141
+ border: 1px solid transparent;
1142
+ font-weight: 600;
1143
+ }
1144
+
1145
+ .trace-viewer__delta--regression {
1146
+ color: var(--orange, #f97316);
1147
+ border-color: color-mix(in srgb, var(--orange, #f97316) 40%, var(--border));
1148
+ background: color-mix(in srgb, var(--orange, #f97316) 14%, transparent);
1149
+ }
1150
+
1151
+ .trace-viewer__delta--improvement {
1152
+ color: var(--green, #22c55e);
1153
+ border-color: color-mix(in srgb, var(--green, #22c55e) 45%, var(--border));
1154
+ background: color-mix(in srgb, var(--green, #22c55e) 14%, transparent);
1155
+ }
1156
+
1157
+ .trace-viewer__delta--neutral {
1158
+ color: var(--text2, var(--text));
1159
+ border-color: var(--border);
1160
+ background: var(--bg2, var(--bg));
1161
+ }
1162
+
1163
+ .trace-viewer__delta--na {
1164
+ color: var(--text3, var(--text2, var(--text)));
1165
+ border-color: var(--border);
1166
+ background: transparent;
1167
+ }
1168
+
1169
+ @media (width <= 1024px) {
1170
+ .trace-viewer__container {
1171
+ flex-direction: column;
1172
+ gap: 0;
1173
+ }
1174
+
1175
+ .trace-viewer__list {
1176
+ width: 100%;
1177
+ min-width: auto;
1178
+ height: 200px;
1179
+ min-height: 200px;
1180
+ border-right: none;
1181
+ border-bottom: 1px solid var(--border);
1182
+ }
1183
+
1184
+ .trace-viewer__inspector {
1185
+ width: 100%;
1186
+ min-width: auto;
1187
+ height: 200px;
1188
+ min-height: 200px;
1189
+ border-left: none;
1190
+ border-top: 1px solid var(--border);
1191
+ }
1192
+
1193
+ .trace-viewer__comparison-toolbar {
1194
+ flex-wrap: wrap;
1195
+ }
1196
+
1197
+ .trace-viewer__comparison-search {
1198
+ min-width: 100%;
1199
+ max-width: 100%;
1200
+ }
1201
+ }
1202
+
1203
+ @media (width <= 768px) {
1204
+ .trace-viewer__col-desktop {
1205
+ display: none;
1206
+ }
1207
+
1208
+ .trace-viewer__mobile-hint {
1209
+ display: inline-flex;
1210
+ }
1211
+ }
1212
+ </style>