nuxt-devtools-observatory 0.1.31 → 0.1.33

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 (44) hide show
  1. package/README.md +79 -46
  2. package/client/.env.example +1 -0
  3. package/client/dist/assets/index-BqKYgjVB.js +20 -0
  4. package/client/dist/assets/index-bs1JBJ2u.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 +7 -8
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/components/TraceFilter.vue +0 -2
  10. package/client/src/components/WaterfallView.vue +1 -1
  11. package/client/src/composables/composable-search.ts +127 -0
  12. package/client/src/composables/trace-render-aggregation.ts +263 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +1 -5
  15. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  16. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  17. package/client/src/stores/observatory.ts +9 -1
  18. package/client/src/views/ComposableTracker.vue +273 -97
  19. package/client/src/views/FetchDashboard.vue +181 -16
  20. package/client/src/views/ProvideInjectGraph.vue +41 -18
  21. package/client/src/views/RenderHeatmap.vue +392 -76
  22. package/client/src/views/TraceViewer.vue +797 -14
  23. package/client/src/views/TransitionTimeline.vue +112 -19
  24. package/dist/module.d.mts +5 -0
  25. package/dist/module.json +1 -1
  26. package/dist/module.mjs +12 -23
  27. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  28. package/dist/runtime/composables/composable-registry.js +63 -5
  29. package/dist/runtime/composables/render-registry.js +23 -13
  30. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  31. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  32. package/dist/runtime/instrumentation/fetch.js +22 -1
  33. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  34. package/dist/runtime/nitro/fetch-capture.js +85 -7
  35. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  36. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  37. package/dist/runtime/plugin.js +48 -1
  38. package/dist/runtime/test-bridge.d.ts +18 -0
  39. package/dist/runtime/test-bridge.js +86 -0
  40. package/dist/runtime/tracing/trace.d.ts +1 -1
  41. package/package.json +18 -3
  42. package/client/.env +0 -17
  43. package/client/dist/assets/index-BuMXDBO9.js +0 -17
  44. package/client/dist/assets/index-CwcspZ6w.css +0 -1
@@ -1,19 +1,53 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
5
+ import { useVirtualizationFlags } from '@observatory-client/composables/useVirtualizationFlags'
3
6
  import { useObservatoryData } from '@observatory-client/stores/observatory'
4
7
  import Flamegraph from '@observatory-client/components/Flamegraph.vue'
5
8
  import WaterfallView from '@observatory-client/components/WaterfallView.vue'
6
9
  import SpanInspector from '@observatory-client/components/SpanInspector.vue'
7
10
  import TraceFilter from '@observatory-client/components/TraceFilter.vue'
8
11
  import { useTraceFilter } from '@observatory-client/composables/useTraceFilter'
12
+ import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
13
+ import {
14
+ buildRenderSummaryForTrace,
15
+ buildCrossTraceRenderSummary,
16
+ type CrossTraceRenderSummaryRow,
17
+ type TraceRenderStatsRow,
18
+ } from '@observatory-client/composables/trace-render-aggregation'
19
+ import type { ObservatoryExportFile } from '@observatory-client/composables/useExportImport'
9
20
  import type { TraceEntry, TraceSpan } from '@observatory/types/snapshot'
10
21
 
22
+ type TraceSpanRow = {
23
+ span: TraceSpan
24
+ displayName: string
25
+ uid: string | number | undefined
26
+ }
27
+
11
28
  const { traces, connected } = useObservatoryData()
12
29
 
30
+ const importedTraces = ref<TraceEntry[]>([])
31
+ const isImportMode = computed(() => importedTraces.value.length > 0)
32
+
13
33
  const selectedTraceId = ref<string | null>(null)
14
34
  const selectedSpan = ref<TraceSpan | undefined>(undefined)
15
35
  const viewMode = ref<'overview' | 'flamegraph' | 'waterfall'>('overview')
16
36
  const showFilters = ref(false)
37
+ const renderSummaryOpen = ref(true)
38
+ const crossTraceSummaryOpen = ref(true)
39
+ const crossTraceSortBy = ref<'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName'>('avgRerendersPerTrace')
40
+ const crossTraceSortDir = ref<'asc' | 'desc'>('desc')
41
+ const crossTraceOnlyRegressions = ref(false)
42
+ const crossTraceOnlyComparable = ref(false)
43
+ const crossTraceSearch = ref('')
44
+ const highlightedUid = ref<string | number | undefined>(undefined)
45
+ const highlightedComponentKey = ref<string | undefined>(undefined)
46
+ const traceListScrollRef = ref<HTMLElement | null>(null)
47
+ const crossTraceScrollRef = ref<HTMLElement | null>(null)
48
+
49
+ const { effective: virtualizationFlags } = useVirtualizationFlags()
50
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 34, overscan: 6 })
17
51
 
18
52
  const {
19
53
  searchQuery,
@@ -27,19 +61,26 @@ const {
27
61
  } = useTraceFilter()
28
62
 
29
63
  const sortedTraces = computed(() => {
30
- return [...traces.value].sort((a, b) => b.startTime - a.startTime)
64
+ const source = isImportMode.value ? importedTraces.value : traces.value
65
+ return [...source].sort((a, b) => b.startTime - a.startTime)
31
66
  })
32
67
 
33
68
  const filteredTraces = computed(() => {
34
69
  return applyFilters(sortedTraces.value)
35
70
  })
36
71
 
72
+ const filteredTraceById = computed(() => {
73
+ return new Map(filteredTraces.value.map((trace) => [trace.id, trace] as const))
74
+ })
75
+
37
76
  const traceCountLabel = computed(() => {
77
+ const suffix = isImportMode.value ? ' (imported)' : ''
78
+
38
79
  if (!hasActiveFilters.value) {
39
- return `${sortedTraces.value.length} traces`
80
+ return `${sortedTraces.value.length} traces${suffix}`
40
81
  }
41
82
 
42
- return `Showing ${filteredTraces.value.length} of ${sortedTraces.value.length} traces`
83
+ return `Showing ${filteredTraces.value.length} of ${sortedTraces.value.length} traces${suffix}`
43
84
  })
44
85
 
45
86
  const selectedTrace = computed(() => {
@@ -47,7 +88,154 @@ const selectedTrace = computed(() => {
47
88
  return filteredTraces.value[0]
48
89
  }
49
90
 
50
- return filteredTraces.value.find((t) => t.id === selectedTraceId.value)
91
+ return filteredTraceById.value.get(selectedTraceId.value)
92
+ })
93
+
94
+ const renderSummary = computed<TraceRenderStatsRow[]>(() => buildRenderSummaryForTrace(selectedTrace.value))
95
+
96
+ const crossTraceRenderSummary = computed(() => {
97
+ return buildCrossTraceRenderSummary(filteredTraces.value, selectedTrace.value?.id)
98
+ })
99
+
100
+ const crossTraceRows = computed(() => {
101
+ const q = crossTraceSearch.value.trim().toLowerCase()
102
+
103
+ let rows = crossTraceRenderSummary.value.filter((row) => {
104
+ if (crossTraceOnlyRegressions.value && (row.deltaVsBaseline ?? 0) <= 0) {
105
+ return false
106
+ }
107
+
108
+ if (crossTraceOnlyComparable.value && row.selectedRerenders === undefined) {
109
+ return false
110
+ }
111
+
112
+ if (q) {
113
+ const nameMatch = row.componentName.toLowerCase().includes(q)
114
+ const fileMatch = row.file.toLowerCase().includes(q)
115
+
116
+ if (!nameMatch && !fileMatch) {
117
+ return false
118
+ }
119
+ }
120
+
121
+ return true
122
+ })
123
+
124
+ rows = [...rows].sort((a, b) => {
125
+ const key = crossTraceSortBy.value
126
+ const dir = crossTraceSortDir.value === 'asc' ? 1 : -1
127
+
128
+ if (key === 'componentName') {
129
+ return a.componentName.localeCompare(b.componentName) * dir
130
+ }
131
+
132
+ if (key === 'deltaVsBaseline') {
133
+ const av = a.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
134
+ const bv = b.deltaVsBaseline ?? Number.NEGATIVE_INFINITY
135
+
136
+ return (av - bv) * dir
137
+ }
138
+
139
+ return ((a[key] as number) - (b[key] as number)) * dir
140
+ })
141
+
142
+ return rows
143
+ })
144
+
145
+ const crossTraceRegressionsCount = computed(() => crossTraceRenderSummary.value.filter((row) => (row.deltaVsBaseline ?? 0) > 0).length)
146
+
147
+ const virtualizedTraceRowsEnabled = computed(() => virtualizationFlags.value.traces)
148
+
149
+ const traceListVirtualizerOptions = computed(() => ({
150
+ count: filteredTraces.value.length,
151
+ getScrollElement: () => traceListScrollRef.value,
152
+ estimateSize: () => virtualizationPreset.value.rowHeight,
153
+ overscan: virtualizationPreset.value.overscan,
154
+ }))
155
+
156
+ const traceListVirtualizer = useVirtualizer(traceListVirtualizerOptions)
157
+
158
+ const traceListVirtualItems = computed(() => {
159
+ if (!virtualizedTraceRowsEnabled.value) {
160
+ return []
161
+ }
162
+
163
+ return traceListVirtualizer.value.getVirtualItems()
164
+ })
165
+
166
+ const traceListTopPadding = computed(() => {
167
+ if (!virtualizedTraceRowsEnabled.value || traceListVirtualItems.value.length === 0) {
168
+ return 0
169
+ }
170
+
171
+ return traceListVirtualItems.value[0].start
172
+ })
173
+
174
+ const traceListBottomPadding = computed(() => {
175
+ if (!virtualizedTraceRowsEnabled.value || traceListVirtualItems.value.length === 0) {
176
+ return 0
177
+ }
178
+
179
+ const total = traceListVirtualizer.value.getTotalSize()
180
+ const last = traceListVirtualItems.value[traceListVirtualItems.value.length - 1]
181
+
182
+ return Math.max(0, total - last.end)
183
+ })
184
+
185
+ const visibleTraceRows = computed(() => {
186
+ if (!virtualizedTraceRowsEnabled.value) {
187
+ return filteredTraces.value
188
+ }
189
+
190
+ return traceListVirtualItems.value
191
+ .map((item) => filteredTraces.value[item.index])
192
+ .filter((trace): trace is TraceEntry => Boolean(trace))
193
+ })
194
+
195
+ const crossTraceVirtualizerOptions = computed(() => ({
196
+ count: crossTraceRows.value.length,
197
+ getScrollElement: () => crossTraceScrollRef.value,
198
+ estimateSize: () => virtualizationPreset.value.rowHeight,
199
+ overscan: virtualizationPreset.value.overscan,
200
+ }))
201
+
202
+ const crossTraceVirtualizer = useVirtualizer(crossTraceVirtualizerOptions)
203
+
204
+ const crossTraceVirtualItems = computed(() => {
205
+ if (!virtualizedTraceRowsEnabled.value) {
206
+ return []
207
+ }
208
+
209
+ return crossTraceVirtualizer.value.getVirtualItems()
210
+ })
211
+
212
+ const crossTraceTopPadding = computed(() => {
213
+ if (!virtualizedTraceRowsEnabled.value || crossTraceVirtualItems.value.length === 0) {
214
+ return 0
215
+ }
216
+
217
+ return crossTraceVirtualItems.value[0].start
218
+ })
219
+
220
+ const crossTraceBottomPadding = computed(() => {
221
+ if (!virtualizedTraceRowsEnabled.value || crossTraceVirtualItems.value.length === 0) {
222
+ return 0
223
+ }
224
+
225
+ const total = crossTraceVirtualizer.value.getTotalSize()
226
+ const last = crossTraceVirtualItems.value[crossTraceVirtualItems.value.length - 1]
227
+
228
+ return Math.max(0, total - last.end)
229
+ })
230
+
231
+ const visibleCrossTraceRows = computed(() => {
232
+ if (!virtualizedTraceRowsEnabled.value) {
233
+ return crossTraceRows.value
234
+ }
235
+
236
+ return crossTraceVirtualItems.value
237
+ .map((item) => crossTraceRows.value[item.index])
238
+ .filter((row): row is CrossTraceRenderSummaryRow => Boolean(row))
51
239
  })
52
240
 
53
241
  function elapsedFromSpans(trace: TraceEntry): number | undefined {
@@ -126,19 +314,179 @@ function getSpanDisplayName(span: TraceSpan): string {
126
314
  return span.name
127
315
  }
128
316
 
317
+ function getSpanUid(span: TraceSpan): string | number | undefined {
318
+ const metadata = span.metadata as Record<string, unknown> | undefined
319
+
320
+ return metadata?.uid as string | number | undefined
321
+ }
322
+
323
+ const selectedTraceSpanRows = computed<TraceSpanRow[]>(() => {
324
+ const trace = selectedTrace.value
325
+
326
+ if (!trace) {
327
+ return []
328
+ }
329
+
330
+ return trace.spans.map((span) => ({
331
+ span,
332
+ displayName: getSpanDisplayName(span),
333
+ uid: getSpanUid(span),
334
+ }))
335
+ })
336
+
129
337
  function selectTrace(trace: TraceEntry) {
130
338
  selectedTraceId.value = trace.id
131
339
  selectedSpan.value = undefined
340
+ highlightedUid.value = undefined
341
+ highlightedComponentKey.value = undefined
132
342
  }
133
343
 
134
344
  function handleClearFilters() {
135
345
  clearFilters()
136
346
  selectedSpan.value = undefined
347
+ highlightedUid.value = undefined
348
+ highlightedComponentKey.value = undefined
137
349
  }
138
350
 
139
351
  function handleSpanTypesUpdate(value: Set<string>) {
140
352
  selectedSpanTypes.value = value
141
353
  }
354
+
355
+ function handleExport() {
356
+ exportJson(`observatory-traces-${Date.now()}.json`, {
357
+ type: 'observatory-traces',
358
+ version: '1',
359
+ exportedAt: Date.now(),
360
+ count: traces.value.length,
361
+ data: traces.value,
362
+ })
363
+ }
364
+
365
+ async function handleImport() {
366
+ let parsed: unknown
367
+
368
+ try {
369
+ parsed = await importJson()
370
+ } catch (err) {
371
+ if (err instanceof Error && err.message !== 'cancelled') {
372
+ alert(`Import failed: ${err.message}`)
373
+ }
374
+ return
375
+ }
376
+
377
+ const file = parsed as ObservatoryExportFile<TraceEntry>
378
+
379
+ if (
380
+ file?.type !== 'observatory-traces' ||
381
+ file?.version !== '1' ||
382
+ !Array.isArray(file?.data) ||
383
+ (file.data.length > 0 && (!file.data[0]?.id || !file.data[0]?.name || !Array.isArray(file.data[0]?.spans)))
384
+ ) {
385
+ alert('Invalid observatory traces file.')
386
+ return
387
+ }
388
+
389
+ importedTraces.value = file.data
390
+ selectedTraceId.value = null
391
+ selectedSpan.value = undefined
392
+ }
393
+
394
+ function handleBackToLive() {
395
+ importedTraces.value = []
396
+ selectedTraceId.value = null
397
+ selectedSpan.value = undefined
398
+ highlightedUid.value = undefined
399
+ highlightedComponentKey.value = undefined
400
+ }
401
+
402
+ function handleRenderSummaryRowClick(uid: string | number) {
403
+ // Toggle highlight: clicking the already-highlighted row deselects it.
404
+ highlightedUid.value = highlightedUid.value === uid ? undefined : uid
405
+ highlightedComponentKey.value = undefined
406
+ selectedSpan.value = undefined
407
+ }
408
+
409
+ function formatDelta(value?: number): string {
410
+ if (value === undefined) {
411
+ return 'n/a'
412
+ }
413
+
414
+ const rounded = Math.round(value * 10) / 10
415
+
416
+ if (rounded > 0) {
417
+ return `+${rounded}`
418
+ }
419
+
420
+ return `${rounded}`
421
+ }
422
+
423
+ function deltaToneClass(value?: number): string {
424
+ if (value === undefined) {
425
+ return 'trace-viewer__delta--na'
426
+ }
427
+
428
+ if (value > 0) {
429
+ return 'trace-viewer__delta--regression'
430
+ }
431
+
432
+ if (value < 0) {
433
+ return 'trace-viewer__delta--improvement'
434
+ }
435
+
436
+ return 'trace-viewer__delta--neutral'
437
+ }
438
+
439
+ function cycleCrossTraceSort(next: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
440
+ if (crossTraceSortBy.value === next) {
441
+ crossTraceSortDir.value = crossTraceSortDir.value === 'desc' ? 'asc' : 'desc'
442
+
443
+ return
444
+ }
445
+
446
+ crossTraceSortBy.value = next
447
+ crossTraceSortDir.value = next === 'componentName' ? 'asc' : 'desc'
448
+ }
449
+
450
+ function sortIndicator(key: 'avgRerendersPerTrace' | 'deltaVsBaseline' | 'totalMs' | 'componentName') {
451
+ if (crossTraceSortBy.value !== key) {
452
+ return ''
453
+ }
454
+
455
+ return crossTraceSortDir.value === 'desc' ? ' ↓' : ' ↑'
456
+ }
457
+
458
+ const crossTraceRowByKey = computed(() => {
459
+ return new Map(crossTraceRows.value.map((row) => [row.componentKey, row] as const))
460
+ })
461
+
462
+ function clearCrossTraceHighlight() {
463
+ highlightedComponentKey.value = undefined
464
+ highlightedUid.value = undefined
465
+ }
466
+
467
+ function handleCrossTraceRowClick(componentKey: string) {
468
+ const row = crossTraceRowByKey.value.get(componentKey)
469
+
470
+ if (!row || row.selectedUid === undefined) {
471
+ highlightedUid.value = undefined
472
+ highlightedComponentKey.value = undefined
473
+ selectedSpan.value = undefined
474
+
475
+ return
476
+ }
477
+
478
+ if (highlightedComponentKey.value === componentKey) {
479
+ highlightedComponentKey.value = undefined
480
+ highlightedUid.value = undefined
481
+ selectedSpan.value = undefined
482
+
483
+ return
484
+ }
485
+
486
+ highlightedComponentKey.value = componentKey
487
+ highlightedUid.value = row.selectedUid
488
+ selectedSpan.value = undefined
489
+ }
142
490
  </script>
143
491
 
144
492
  <template>
@@ -148,6 +496,18 @@ function handleSpanTypesUpdate(value: Set<string>) {
148
496
  <div class="trace-viewer__title">Trace Viewer</div>
149
497
  <div class="trace-viewer__header-actions">
150
498
  <div class="trace-viewer__count muted text-sm">{{ traceCountLabel }}</div>
499
+ <button v-if="isImportMode" class="trace-viewer__import-mode-btn" title="Return to live data" @click="handleBackToLive">
500
+ ← live
501
+ </button>
502
+ <button
503
+ class="trace-viewer__action-btn"
504
+ title="Export traces as JSON"
505
+ :disabled="traces.length === 0 && !isImportMode"
506
+ @click="handleExport"
507
+ >
508
+ ↓ export
509
+ </button>
510
+ <button class="trace-viewer__action-btn" title="Import traces from JSON file" @click="handleImport">↑ import</button>
151
511
  <button
152
512
  :class="{ 'trace-viewer__filter-btn--active': showFilters }"
153
513
  class="trace-viewer__filter-btn"
@@ -184,7 +544,7 @@ function handleSpanTypesUpdate(value: Set<string>) {
184
544
  <div class="trace-viewer__list-header">
185
545
  <span class="trace-viewer__list-title">Traces</span>
186
546
  </div>
187
- <div class="trace-viewer__table-wrap tracker-table-wrap">
547
+ <div ref="traceListScrollRef" class="trace-viewer__table-wrap tracker-table-wrap">
188
548
  <table class="data-table">
189
549
  <thead>
190
550
  <tr>
@@ -195,7 +555,14 @@ function handleSpanTypesUpdate(value: Set<string>) {
195
555
  </thead>
196
556
  <tbody>
197
557
  <tr
198
- v-for="trace in filteredTraces"
558
+ v-if="virtualizedTraceRowsEnabled && traceListTopPadding > 0"
559
+ class="trace-viewer__virtual-spacer-row"
560
+ aria-hidden="true"
561
+ >
562
+ <td colspan="3" :style="{ height: `${traceListTopPadding}px` }"></td>
563
+ </tr>
564
+ <tr
565
+ v-for="trace in visibleTraceRows"
199
566
  :key="trace.id"
200
567
  :class="{ 'trace-viewer__trace-row--selected': selectedTrace?.id === trace.id }"
201
568
  class="trace-viewer__trace-row"
@@ -205,6 +572,13 @@ function handleSpanTypesUpdate(value: Set<string>) {
205
572
  <td class="mono">{{ formatDuration(trace.durationMs, trace) }}</td>
206
573
  <td class="mono">{{ trace.spans.length }}</td>
207
574
  </tr>
575
+ <tr
576
+ v-if="virtualizedTraceRowsEnabled && traceListBottomPadding > 0"
577
+ class="trace-viewer__virtual-spacer-row"
578
+ aria-hidden="true"
579
+ >
580
+ <td colspan="3" :style="{ height: `${traceListBottomPadding}px` }"></td>
581
+ </tr>
208
582
  <tr v-if="!filteredTraces.length">
209
583
  <td colspan="3" class="tracker-empty-cell">
210
584
  {{
@@ -276,19 +650,196 @@ function handleSpanTypesUpdate(value: Set<string>) {
276
650
 
277
651
  <div class="trace-viewer__span-list">
278
652
  <div
279
- v-for="span in selectedTrace.spans"
280
- :key="span.id"
281
- :class="{ 'trace-viewer__span-item--selected': selectedSpan?.id === span.id }"
653
+ v-for="spanRow in selectedTraceSpanRows"
654
+ :key="spanRow.span.id"
655
+ :class="{
656
+ 'trace-viewer__span-item--selected': selectedSpan?.id === spanRow.span.id,
657
+ 'trace-viewer__span-item--highlighted':
658
+ highlightedUid !== undefined && spanRow.uid === highlightedUid,
659
+ }"
282
660
  class="trace-viewer__span-item"
283
- @click="selectedSpan = span"
661
+ @click="selectedSpan = spanRow.span"
284
662
  >
285
- <div class="trace-viewer__span-name">{{ getSpanDisplayName(span) }}</div>
663
+ <div class="trace-viewer__span-name">{{ spanRow.displayName }}</div>
286
664
  <div class="trace-viewer__span-meta">
287
- <span class="trace-viewer__span-type">{{ span.type }}</span>
288
- <span class="trace-viewer__span-duration">{{ formatDuration(span.durationMs) }}</span>
665
+ <span class="trace-viewer__span-type">{{ spanRow.span.type }}</span>
666
+ <span class="trace-viewer__span-duration">{{ formatDuration(spanRow.span.durationMs) }}</span>
289
667
  </div>
290
668
  </div>
291
669
  </div>
670
+
671
+ <!-- Render Summary panel — shown when the trace has render spans -->
672
+ <template v-if="renderSummary.length > 0">
673
+ <div class="trace-viewer__render-summary-header" @click="renderSummaryOpen = !renderSummaryOpen">
674
+ <span class="trace-viewer__render-summary-toggle">{{ renderSummaryOpen ? '▾' : '▸' }}</span>
675
+ <span class="trace-viewer__render-summary-title">Render Summary</span>
676
+ <span class="trace-viewer__render-summary-count muted">{{ renderSummary.length }} components</span>
677
+ <span
678
+ v-if="highlightedUid !== undefined"
679
+ class="trace-viewer__render-summary-clear"
680
+ @click.stop="highlightedUid = undefined"
681
+ >
682
+ ✕ clear
683
+ </span>
684
+ </div>
685
+ <div v-if="renderSummaryOpen" class="trace-viewer__render-summary-table-wrap">
686
+ <table class="data-table trace-viewer__render-summary-table">
687
+ <thead>
688
+ <tr>
689
+ <th>Component</th>
690
+ <th title="Times the component was mounted">Mounts</th>
691
+ <th title="Reactive re-renders after initial mount">Re-renders</th>
692
+ <th title="Average render duration">Avg</th>
693
+ <th title="Sum of all render durations">Total</th>
694
+ </tr>
695
+ </thead>
696
+ <tbody>
697
+ <tr
698
+ v-for="row in renderSummary"
699
+ :key="row.uid"
700
+ :class="{ 'trace-viewer__render-summary-row--active': highlightedUid === row.uid }"
701
+ class="trace-viewer__render-summary-row"
702
+ :title="row.file"
703
+ @click="handleRenderSummaryRowClick(row.uid)"
704
+ >
705
+ <td class="mono">{{ row.componentName }}</td>
706
+ <td class="mono">{{ row.mountCount }}</td>
707
+ <td class="mono" :class="{ 'trace-viewer__render-hot': row.rerenderCount > 3 }">
708
+ {{ row.rerenderCount }}
709
+ </td>
710
+ <td class="mono">{{ formatDuration(row.avgMs) }}</td>
711
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
712
+ </tr>
713
+ </tbody>
714
+ </table>
715
+ </div>
716
+ </template>
717
+
718
+ <!-- Cross-trace comparison panel -->
719
+ <template v-if="crossTraceRenderSummary.length > 0">
720
+ <div class="trace-viewer__render-summary-header" @click="crossTraceSummaryOpen = !crossTraceSummaryOpen">
721
+ <span class="trace-viewer__render-summary-toggle">{{ crossTraceSummaryOpen ? '▾' : '▸' }}</span>
722
+ <span class="trace-viewer__render-summary-title">Cross-Trace Render Comparison</span>
723
+ <span class="trace-viewer__render-summary-count muted">
724
+ {{ crossTraceRenderSummary.length }} components · {{ filteredTraces.length }} traces
725
+ </span>
726
+ <span class="trace-viewer__render-summary-count muted">
727
+ {{ crossTraceRegressionsCount }} regressions
728
+ </span>
729
+ <span class="trace-viewer__mobile-hint muted">mobile mode: condensed columns</span>
730
+ <span
731
+ v-if="highlightedComponentKey !== undefined"
732
+ class="trace-viewer__render-summary-clear"
733
+ @click.stop="clearCrossTraceHighlight"
734
+ >
735
+ ✕ clear
736
+ </span>
737
+ </div>
738
+ <div v-if="crossTraceSummaryOpen" ref="crossTraceScrollRef" class="trace-viewer__render-summary-table-wrap">
739
+ <div class="trace-viewer__comparison-toolbar">
740
+ <button
741
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyRegressions }"
742
+ class="trace-viewer__comparison-chip"
743
+ @click="crossTraceOnlyRegressions = !crossTraceOnlyRegressions"
744
+ >
745
+ regressions only
746
+ </button>
747
+ <button
748
+ :class="{ 'trace-viewer__comparison-chip--active': crossTraceOnlyComparable }"
749
+ class="trace-viewer__comparison-chip"
750
+ @click="crossTraceOnlyComparable = !crossTraceOnlyComparable"
751
+ >
752
+ selected trace only
753
+ </button>
754
+ <span class="trace-viewer__comparison-spacer"></span>
755
+ <input
756
+ v-model="crossTraceSearch"
757
+ class="trace-viewer__comparison-search mono"
758
+ type="search"
759
+ placeholder="filter component..."
760
+ />
761
+ </div>
762
+ <table class="data-table trace-viewer__render-summary-table">
763
+ <thead>
764
+ <tr>
765
+ <th class="trace-viewer__sortable" @click="cycleCrossTraceSort('componentName')">
766
+ Component{{ sortIndicator('componentName') }}
767
+ </th>
768
+ <th class="trace-viewer__col-desktop" title="How many traces include this component">
769
+ Traces
770
+ </th>
771
+ <th
772
+ class="trace-viewer__sortable"
773
+ title="Average re-renders per trace"
774
+ @click="cycleCrossTraceSort('avgRerendersPerTrace')"
775
+ >
776
+ Avg Re-renders{{ sortIndicator('avgRerendersPerTrace') }}
777
+ </th>
778
+ <th class="trace-viewer__col-desktop" title="Re-renders in selected trace">Selected</th>
779
+ <th
780
+ class="trace-viewer__sortable"
781
+ title="Selected trace vs other traces baseline"
782
+ @click="cycleCrossTraceSort('deltaVsBaseline')"
783
+ >
784
+ Delta{{ sortIndicator('deltaVsBaseline') }}
785
+ </th>
786
+ <th class="trace-viewer__col-desktop" title="Average render duration across all renders">
787
+ Avg ms
788
+ </th>
789
+ <th
790
+ class="trace-viewer__sortable"
791
+ title="Sum of all render duration across filtered traces"
792
+ @click="cycleCrossTraceSort('totalMs')"
793
+ >
794
+ Total ms{{ sortIndicator('totalMs') }}
795
+ </th>
796
+ </tr>
797
+ </thead>
798
+ <tbody>
799
+ <tr
800
+ v-if="virtualizedTraceRowsEnabled && crossTraceTopPadding > 0"
801
+ class="trace-viewer__virtual-spacer-row"
802
+ aria-hidden="true"
803
+ >
804
+ <td colspan="7" :style="{ height: `${crossTraceTopPadding}px` }"></td>
805
+ </tr>
806
+ <tr
807
+ v-for="row in visibleCrossTraceRows"
808
+ :key="row.componentKey"
809
+ :class="{
810
+ 'trace-viewer__render-summary-row--active':
811
+ highlightedComponentKey === row.componentKey,
812
+ }"
813
+ class="trace-viewer__render-summary-row"
814
+ :title="row.file"
815
+ @click="handleCrossTraceRowClick(row.componentKey)"
816
+ >
817
+ <td class="mono">{{ row.componentName }}</td>
818
+ <td class="mono trace-viewer__col-desktop">{{ row.tracesSeen }}</td>
819
+ <td class="mono">{{ (Math.round(row.avgRerendersPerTrace * 10) / 10).toFixed(1) }}</td>
820
+ <td class="mono trace-viewer__col-desktop">{{ row.selectedRerenders ?? 'n/a' }}</td>
821
+ <td class="mono">
822
+ <span class="trace-viewer__delta" :class="deltaToneClass(row.deltaVsBaseline)">
823
+ {{ formatDelta(row.deltaVsBaseline) }}
824
+ </span>
825
+ </td>
826
+ <td class="mono trace-viewer__col-desktop">{{ formatDuration(row.avgMsPerRender) }}</td>
827
+ <td class="mono">{{ formatDuration(row.totalMs) }}</td>
828
+ </tr>
829
+ <tr
830
+ v-if="virtualizedTraceRowsEnabled && crossTraceBottomPadding > 0"
831
+ class="trace-viewer__virtual-spacer-row"
832
+ aria-hidden="true"
833
+ >
834
+ <td colspan="7" :style="{ height: `${crossTraceBottomPadding}px` }"></td>
835
+ </tr>
836
+ <tr v-if="!crossTraceRows.length">
837
+ <td colspan="7" class="tracker-empty-cell">No components match the comparison filters.</td>
838
+ </tr>
839
+ </tbody>
840
+ </table>
841
+ </div>
842
+ </template>
292
843
  </div>
293
844
 
294
845
  <!-- Flamegraph -->
@@ -365,6 +916,44 @@ function handleSpanTypesUpdate(value: Set<string>) {
365
916
  color: var(--accent);
366
917
  }
367
918
 
919
+ .trace-viewer__action-btn {
920
+ padding: 3px 8px;
921
+ background: none;
922
+ border: 1px solid var(--border);
923
+ color: var(--text-secondary);
924
+ cursor: pointer;
925
+ font-size: 11px;
926
+ border-radius: 3px;
927
+ transition: all 0.12s;
928
+ font-family: var(--mono);
929
+ }
930
+
931
+ .trace-viewer__action-btn:disabled {
932
+ opacity: 0.4;
933
+ cursor: not-allowed;
934
+ }
935
+
936
+ .trace-viewer__action-btn:hover:not(:disabled) {
937
+ background: var(--bg-secondary);
938
+ color: var(--text);
939
+ }
940
+
941
+ .trace-viewer__import-mode-btn {
942
+ padding: 3px 8px;
943
+ background: var(--accent-bg);
944
+ border: 1px solid var(--accent);
945
+ color: var(--accent);
946
+ cursor: pointer;
947
+ font-size: 11px;
948
+ border-radius: 3px;
949
+ transition: all 0.12s;
950
+ font-family: var(--mono);
951
+ }
952
+
953
+ .trace-viewer__import-mode-btn:hover {
954
+ opacity: 0.8;
955
+ }
956
+
368
957
  .trace-viewer__container {
369
958
  display: flex;
370
959
  flex: 1;
@@ -413,6 +1002,12 @@ function handleSpanTypesUpdate(value: Set<string>) {
413
1002
  border-left: 2px solid var(--accent);
414
1003
  }
415
1004
 
1005
+ .trace-viewer__virtual-spacer-row td {
1006
+ padding: 0;
1007
+ border-bottom: 0;
1008
+ background: transparent;
1009
+ }
1010
+
416
1011
  .trace-viewer__detail {
417
1012
  flex: 1;
418
1013
  display: flex;
@@ -572,7 +1167,176 @@ function handleSpanTypesUpdate(value: Set<string>) {
572
1167
  overflow: auto;
573
1168
  }
574
1169
 
575
- @media (max-width: 1024px) {
1170
+ .trace-viewer__span-item--highlighted {
1171
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1172
+ border-left: 2px solid var(--purple, #a855f7);
1173
+ }
1174
+
1175
+ .trace-viewer__render-summary-header {
1176
+ display: flex;
1177
+ align-items: center;
1178
+ gap: 8px;
1179
+ padding: 8px 16px;
1180
+ border-top: 1px solid var(--border);
1181
+ border-bottom: 1px solid var(--border);
1182
+ background: var(--bg3, var(--bg2, var(--bg)));
1183
+ cursor: pointer;
1184
+ user-select: none;
1185
+ flex-shrink: 0;
1186
+ }
1187
+
1188
+ .trace-viewer__render-summary-header:hover {
1189
+ background: var(--bg2, var(--bg));
1190
+ }
1191
+
1192
+ .trace-viewer__render-summary-toggle {
1193
+ font-size: 10px;
1194
+ color: var(--text3, var(--text2, var(--text)));
1195
+ width: 12px;
1196
+ flex-shrink: 0;
1197
+ }
1198
+
1199
+ .trace-viewer__render-summary-title {
1200
+ font-size: 11px;
1201
+ font-weight: 600;
1202
+ color: var(--text2, var(--text));
1203
+ text-transform: uppercase;
1204
+ letter-spacing: 0.5px;
1205
+ }
1206
+
1207
+ .trace-viewer__render-summary-count {
1208
+ font-size: 11px;
1209
+ font-family: var(--mono);
1210
+ color: var(--text3, var(--text2, var(--text)));
1211
+ }
1212
+
1213
+ .trace-viewer__render-summary-clear {
1214
+ margin-left: auto;
1215
+ font-size: 11px;
1216
+ color: var(--accent);
1217
+ cursor: pointer;
1218
+ }
1219
+
1220
+ .trace-viewer__render-summary-clear:hover {
1221
+ text-decoration: underline;
1222
+ }
1223
+
1224
+ .trace-viewer__mobile-hint {
1225
+ display: none;
1226
+ margin-left: auto;
1227
+ font-size: 11px;
1228
+ }
1229
+
1230
+ .trace-viewer__render-summary-table-wrap {
1231
+ flex-shrink: 0;
1232
+ max-height: 200px;
1233
+ overflow: auto;
1234
+ border-bottom: 1px solid var(--border);
1235
+ }
1236
+
1237
+ .trace-viewer__comparison-toolbar {
1238
+ display: flex;
1239
+ align-items: center;
1240
+ gap: 8px;
1241
+ padding: 8px 12px;
1242
+ border-bottom: 1px solid var(--border);
1243
+ background: var(--bg2, var(--bg));
1244
+ }
1245
+
1246
+ .trace-viewer__comparison-chip {
1247
+ padding: 2px 8px;
1248
+ border: 1px solid var(--border);
1249
+ border-radius: 999px;
1250
+ background: transparent;
1251
+ color: var(--text2, var(--text));
1252
+ font-size: 11px;
1253
+ font-family: var(--mono);
1254
+ cursor: pointer;
1255
+ }
1256
+
1257
+ .trace-viewer__comparison-chip--active {
1258
+ border-color: color-mix(in srgb, var(--purple, #a855f7) 55%, var(--border));
1259
+ background: color-mix(in srgb, var(--purple, #a855f7) 15%, transparent);
1260
+ color: var(--purple, #a855f7);
1261
+ }
1262
+
1263
+ .trace-viewer__comparison-spacer {
1264
+ flex: 1;
1265
+ }
1266
+
1267
+ .trace-viewer__comparison-search {
1268
+ min-width: 180px;
1269
+ max-width: 240px;
1270
+ }
1271
+
1272
+ .trace-viewer__render-summary-table {
1273
+ width: 100%;
1274
+ }
1275
+
1276
+ .trace-viewer__sortable {
1277
+ cursor: pointer;
1278
+ user-select: none;
1279
+ }
1280
+
1281
+ .trace-viewer__sortable:hover {
1282
+ color: var(--text);
1283
+ }
1284
+
1285
+ .trace-viewer__render-summary-row {
1286
+ cursor: pointer;
1287
+ transition: background 0.15s;
1288
+ }
1289
+
1290
+ .trace-viewer__render-summary-row:hover {
1291
+ background: var(--bg2, var(--bg));
1292
+ }
1293
+
1294
+ .trace-viewer__render-summary-row--active {
1295
+ background: color-mix(in srgb, var(--purple, #a855f7) 8%, transparent);
1296
+ border-left: 2px solid var(--purple, #a855f7);
1297
+ }
1298
+
1299
+ .trace-viewer__render-hot {
1300
+ color: var(--orange, #f97316);
1301
+ font-weight: 600;
1302
+ }
1303
+
1304
+ .trace-viewer__delta {
1305
+ display: inline-flex;
1306
+ align-items: center;
1307
+ justify-content: center;
1308
+ min-width: 46px;
1309
+ padding: 2px 6px;
1310
+ border-radius: 999px;
1311
+ border: 1px solid transparent;
1312
+ font-weight: 600;
1313
+ }
1314
+
1315
+ .trace-viewer__delta--regression {
1316
+ color: var(--orange, #f97316);
1317
+ border-color: color-mix(in srgb, var(--orange, #f97316) 40%, var(--border));
1318
+ background: color-mix(in srgb, var(--orange, #f97316) 14%, transparent);
1319
+ }
1320
+
1321
+ .trace-viewer__delta--improvement {
1322
+ color: var(--green, #22c55e);
1323
+ border-color: color-mix(in srgb, var(--green, #22c55e) 45%, var(--border));
1324
+ background: color-mix(in srgb, var(--green, #22c55e) 14%, transparent);
1325
+ }
1326
+
1327
+ .trace-viewer__delta--neutral {
1328
+ color: var(--text2, var(--text));
1329
+ border-color: var(--border);
1330
+ background: var(--bg2, var(--bg));
1331
+ }
1332
+
1333
+ .trace-viewer__delta--na {
1334
+ color: var(--text3, var(--text2, var(--text)));
1335
+ border-color: var(--border);
1336
+ background: transparent;
1337
+ }
1338
+
1339
+ @media (width <= 1024px) {
576
1340
  .trace-viewer__container {
577
1341
  flex-direction: column;
578
1342
  gap: 0;
@@ -595,5 +1359,24 @@ function handleSpanTypesUpdate(value: Set<string>) {
595
1359
  border-left: none;
596
1360
  border-top: 1px solid var(--border);
597
1361
  }
1362
+
1363
+ .trace-viewer__comparison-toolbar {
1364
+ flex-wrap: wrap;
1365
+ }
1366
+
1367
+ .trace-viewer__comparison-search {
1368
+ min-width: 100%;
1369
+ max-width: 100%;
1370
+ }
1371
+ }
1372
+
1373
+ @media (width <= 768px) {
1374
+ .trace-viewer__col-desktop {
1375
+ display: none;
1376
+ }
1377
+
1378
+ .trace-viewer__mobile-hint {
1379
+ display: inline-flex;
1380
+ }
598
1381
  }
599
1382
  </style>