nuxt-devtools-observatory 0.1.32 → 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 (31) hide show
  1. package/README.md +5 -0
  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 +4 -4
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/composables/composable-search.ts +3 -0
  10. package/client/src/composables/trace-render-aggregation.ts +11 -2
  11. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  12. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  13. package/client/src/views/ComposableTracker.vue +212 -71
  14. package/client/src/views/FetchDashboard.vue +181 -16
  15. package/client/src/views/ProvideInjectGraph.vue +41 -18
  16. package/client/src/views/RenderHeatmap.vue +329 -75
  17. package/client/src/views/TraceViewer.vue +190 -20
  18. package/client/src/views/TransitionTimeline.vue +112 -19
  19. package/dist/module.d.mts +5 -0
  20. package/dist/module.json +1 -1
  21. package/dist/module.mjs +11 -22
  22. package/dist/runtime/composables/render-registry.js +6 -4
  23. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  24. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  25. package/dist/runtime/instrumentation/fetch.js +22 -1
  26. package/dist/runtime/plugin.js +4 -1
  27. package/dist/runtime/test-bridge.d.ts +18 -0
  28. package/dist/runtime/test-bridge.js +86 -0
  29. package/package.json +14 -3
  30. package/client/dist/assets/index-5Wl1XYRH.js +0 -17
  31. package/client/dist/assets/index-DT_QUiIh.css +0 -1
@@ -1,5 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, defineComponent, h, ref, watch, type VNode } 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 { useResizablePane } from '@observatory-client/composables/useResizablePane'
4
7
  import { useObservatoryData, openInEditor as openInEditorFromStore } from '@observatory-client/stores/observatory'
5
8
  import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
@@ -26,6 +29,37 @@ interface ComponentNode {
26
29
  route: string
27
30
  }
28
31
 
32
+ interface VisibleTreeRow {
33
+ node: ComponentNode
34
+ primaryBadge: string | null
35
+ metricValue: string
36
+ metricLabel: string
37
+ hot: boolean
38
+ }
39
+
40
+ function nodeBadges(node: ComponentNode): string[] {
41
+ const badges: string[] = []
42
+ const normalizedElement = node.element?.toLowerCase()
43
+
44
+ if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
45
+ badges.push(node.element)
46
+ }
47
+
48
+ if (node.file !== 'unknown') {
49
+ const fileBadge =
50
+ node.file
51
+ .split('/')
52
+ .pop()
53
+ ?.replace(/\.vue$/i, '') ?? node.file
54
+
55
+ if (fileBadge !== node.label && !badges.includes(fileBadge)) {
56
+ badges.push(fileBadge)
57
+ }
58
+ }
59
+
60
+ return badges
61
+ }
62
+
29
63
  const TreeNode = defineComponent({
30
64
  name: 'TreeNode',
31
65
  props: {
@@ -58,24 +92,7 @@ const TreeNode = defineComponent({
58
92
  const canExpand = node.children.length > 0
59
93
  const metric = props.mode === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`
60
94
  const metricLabel = props.mode === 'count' ? 'renders' : 'avg'
61
- const badges = []
62
- const normalizedElement = node.element?.toLowerCase()
63
-
64
- if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
65
- badges.push(node.element)
66
- }
67
-
68
- if (node.file !== 'unknown') {
69
- const fileBadge =
70
- node.file
71
- .split('/')
72
- .pop()
73
- ?.replace(/\.vue$/i, '') ?? node.file
74
-
75
- if (fileBadge !== node.label && !badges.includes(fileBadge)) {
76
- badges.push(fileBadge)
77
- }
78
- }
95
+ const badges = nodeBadges(node)
79
96
 
80
97
  return h('div', { class: 'tree-node' }, [
81
98
  h(
@@ -206,6 +223,10 @@ const activeRootId = ref<string | null>(null)
206
223
  const expandedIds = ref<Set<string>>(new Set())
207
224
  const frozenSnapshot = ref<RenderEntry[]>([])
208
225
  const expansionReady = ref(false)
226
+ const treeFrameRef = ref<HTMLElement | null>(null)
227
+
228
+ const { effective: virtualizationFlags } = useVirtualizationFlags()
229
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 34, overscan: 6 })
209
230
 
210
231
  function displayLabel(entry: RenderEntry) {
211
232
  if (entry.name && entry.name !== 'unknown' && !/^Component#\d+$/.test(entry.name)) {
@@ -303,33 +324,55 @@ function flatten(nodes: ComponentNode[]) {
303
324
  return flat
304
325
  }
305
326
 
306
- function countSubtree(node: ComponentNode): number {
307
- return 1 + node.children.reduce((sum, child) => sum + countSubtree(child), 0)
308
- }
327
+ function buildSubtreeSizeMap(roots: ComponentNode[]) {
328
+ const sizes = new Map<string, number>()
309
329
 
310
- function collectIds(node: ComponentNode, target = new Set<string>()) {
311
- target.add(node.id)
312
- node.children.forEach((child) => collectIds(child, target))
330
+ for (const root of roots) {
331
+ const stack: Array<{ node: ComponentNode; visited: boolean }> = [{ node: root, visited: false }]
313
332
 
314
- return target
315
- }
333
+ while (stack.length) {
334
+ const current = stack.pop()!
335
+
336
+ if (!current.visited) {
337
+ stack.push({ node: current.node, visited: true })
338
+
339
+ for (let i = current.node.children.length - 1; i >= 0; i--) {
340
+ stack.push({ node: current.node.children[i], visited: false })
341
+ }
342
+
343
+ continue
344
+ }
316
345
 
317
- function pathToNode(node: ComponentNode, targetId: string, trail: string[] = []): string[] | null {
318
- const nextTrail = [...trail, node.id]
346
+ let size = 1
319
347
 
320
- if (node.id === targetId) {
321
- return nextTrail
348
+ for (const child of current.node.children) {
349
+ size += sizes.get(child.id) ?? 1
350
+ }
351
+
352
+ sizes.set(current.node.id, size)
353
+ }
322
354
  }
323
355
 
324
- for (const child of node.children) {
325
- const childTrail = pathToNode(child, targetId, nextTrail)
356
+ return sizes
357
+ }
358
+
359
+ function buildRootLookup(roots: ComponentNode[]) {
360
+ const lookup = new Map<string, string>()
361
+
362
+ for (const root of roots) {
363
+ const stack: ComponentNode[] = [root]
364
+
365
+ while (stack.length) {
366
+ const node = stack.pop()!
367
+ lookup.set(node.id, root.id)
326
368
 
327
- if (childTrail) {
328
- return childTrail
369
+ for (let i = node.children.length - 1; i >= 0; i--) {
370
+ stack.push(node.children[i])
371
+ }
329
372
  }
330
373
  }
331
374
 
332
- return null
375
+ return lookup
333
376
  }
334
377
 
335
378
  function findFirstHotNode(node: ComponentNode): ComponentNode | null {
@@ -476,6 +519,37 @@ const displayEntries = computed(() => (frozen.value ? frozenSnapshot.value : ren
476
519
  const rootNodes = computed(() => buildNodes(displayEntries.value))
477
520
  const rootMap = computed(() => new Map(rootNodes.value.map((node) => [node.id, node])))
478
521
  const allComponents = computed(() => flatten(rootNodes.value))
522
+ const allComponentsById = computed(() => new Map(allComponents.value.map((node) => [node.id, node])))
523
+ const allComponentIds = computed(() => new Set(allComponents.value.map((node) => node.id)))
524
+ const subtreeSizeById = computed(() => buildSubtreeSizeMap(rootNodes.value))
525
+ const rootIdByNodeId = computed(() => buildRootLookup(rootNodes.value))
526
+
527
+ function pathToNodeWithinRoot(targetId: string, rootId?: string | null): string[] {
528
+ const path: string[] = []
529
+ let current = allComponentsById.value.get(targetId)
530
+
531
+ while (current) {
532
+ path.push(current.id)
533
+
534
+ if (rootId && current.id === rootId) {
535
+ break
536
+ }
537
+
538
+ if (!current.parentId) {
539
+ break
540
+ }
541
+
542
+ current = allComponentsById.value.get(current.parentId)
543
+ }
544
+
545
+ path.reverse()
546
+
547
+ if (rootId && path[0] !== rootId) {
548
+ return []
549
+ }
550
+
551
+ return path
552
+ }
479
553
 
480
554
  const filteredRoots = computed(() => {
481
555
  const term = search.value.trim()
@@ -483,9 +557,11 @@ const filteredRoots = computed(() => {
483
557
  return rootNodes.value.filter((root) => isVisibleRoot(root, term))
484
558
  })
485
559
 
560
+ const filteredRootById = computed(() => new Map(filteredRoots.value.map((node) => [node.id, node])))
561
+
486
562
  const activeRoot = computed(() => {
487
563
  if (activeRootId.value) {
488
- return filteredRoots.value.find((node) => node.id === activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
564
+ return filteredRootById.value.get(activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
489
565
  }
490
566
 
491
567
  return filteredRoots.value[0] ?? rootNodes.value[0] ?? null
@@ -505,11 +581,99 @@ const visibleTreeRoots = computed(() => {
505
581
  return [visibleActiveRoot.value]
506
582
  })
507
583
 
584
+ const virtualizedTreeEnabled = computed(() => virtualizationFlags.value.heatmap)
585
+
586
+ function flattenVisibleTree(root: ComponentNode | null, expanded: Set<string>) {
587
+ if (!root) {
588
+ return [] as ComponentNode[]
589
+ }
590
+
591
+ const rows: ComponentNode[] = []
592
+
593
+ function walk(node: ComponentNode) {
594
+ rows.push(node)
595
+
596
+ if (!expanded.has(node.id)) {
597
+ return
598
+ }
599
+
600
+ node.children.forEach(walk)
601
+ }
602
+
603
+ walk(root)
604
+
605
+ return rows
606
+ }
607
+
608
+ const expandedVisibleNodes = computed(() => flattenVisibleTree(visibleActiveRoot.value, expandedIds.value))
609
+
610
+ const treeVirtualizerOptions = computed(() => ({
611
+ count: expandedVisibleNodes.value.length,
612
+ getScrollElement: () => treeFrameRef.value,
613
+ estimateSize: () => virtualizationPreset.value.rowHeight,
614
+ overscan: virtualizationPreset.value.overscan,
615
+ }))
616
+
617
+ const treeVirtualizer = useVirtualizer(treeVirtualizerOptions)
618
+
619
+ const treeVirtualItems = computed(() => {
620
+ if (!virtualizedTreeEnabled.value) {
621
+ return []
622
+ }
623
+
624
+ return treeVirtualizer.value.getVirtualItems()
625
+ })
626
+
627
+ const topTreePadding = computed(() => {
628
+ if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
629
+ return 0
630
+ }
631
+
632
+ return treeVirtualItems.value[0].start
633
+ })
634
+
635
+ const bottomTreePadding = computed(() => {
636
+ if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
637
+ return 0
638
+ }
639
+
640
+ const total = treeVirtualizer.value.getTotalSize()
641
+ const last = treeVirtualItems.value[treeVirtualItems.value.length - 1]
642
+
643
+ return Math.max(0, total - last.end)
644
+ })
645
+
646
+ const visibleTreeRows = computed(() => {
647
+ if (!virtualizedTreeEnabled.value) {
648
+ return expandedVisibleNodes.value
649
+ }
650
+
651
+ return treeVirtualItems.value
652
+ .map((item) => expandedVisibleNodes.value[item.index])
653
+ .filter((node): node is ComponentNode => Boolean(node))
654
+ })
655
+
656
+ const visibleTreeRowItems = computed<VisibleTreeRow[]>(() => {
657
+ const metricLabel = activeMode.value === 'count' ? 'renders' : 'avg'
658
+
659
+ return visibleTreeRows.value.map((node) => {
660
+ const badges = nodeBadges(node)
661
+
662
+ return {
663
+ node,
664
+ primaryBadge: badges[0] ?? null,
665
+ metricValue: activeMode.value === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`,
666
+ metricLabel,
667
+ hot: isHot(node),
668
+ }
669
+ })
670
+ })
671
+
508
672
  const appEntries = computed(() =>
509
673
  filteredRoots.value.map((root, index) => ({
510
674
  id: root.id,
511
675
  label: `App ${index + 1}`,
512
- meta: `${countSubtree(root)} nodes`,
676
+ meta: `${subtreeSizeById.value.get(root.id) ?? 1} nodes`,
513
677
  root,
514
678
  }))
515
679
  )
@@ -529,7 +693,31 @@ const knownRoutes = computed(() => {
529
693
  return [...routes].sort()
530
694
  })
531
695
 
532
- const activeSelected = computed(() => allComponents.value.find((node) => node.id === activeSelectedId.value) ?? null)
696
+ const activeSelected = computed(() => {
697
+ if (!activeSelectedId.value) {
698
+ return null
699
+ }
700
+
701
+ return allComponentsById.value.get(activeSelectedId.value) ?? null
702
+ })
703
+
704
+ const activeSelectedTimelineRecent = computed(() => {
705
+ const timeline = activeSelected.value?.timeline ?? []
706
+
707
+ if (!timeline.length) {
708
+ return [] as Array<{ key: string; event: RenderEvent }>
709
+ }
710
+
711
+ const recent: Array<{ key: string; event: RenderEvent }> = []
712
+ const end = Math.max(timeline.length - 30, 0)
713
+
714
+ for (let i = timeline.length - 1; i >= end; i--) {
715
+ const event = timeline[i]
716
+ recent.push({ key: `${event.kind}-${event.t}-${i}`, event })
717
+ }
718
+
719
+ return recent
720
+ })
533
721
  const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.rerenders + node.mountCount, 0))
534
722
  const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
535
723
  const avgTime = computed(() => {
@@ -560,12 +748,11 @@ watch(
560
748
  activeRootId.value = roots[0].id
561
749
  }
562
750
 
563
- if (activeSelectedId.value && !allComponents.value.some((node) => node.id === activeSelectedId.value)) {
751
+ if (activeSelectedId.value && !allComponentIds.value.has(activeSelectedId.value)) {
564
752
  activeSelectedId.value = null
565
753
  }
566
754
 
567
- const validIds = new Set(allComponents.value.map((node) => node.id))
568
- const preserved = new Set([...expandedIds.value].filter((id) => validIds.has(id)))
755
+ const preserved = new Set([...expandedIds.value].filter((id) => allComponentIds.value.has(id)))
569
756
 
570
757
  if (!expansionReady.value) {
571
758
  expandedIds.value = defaultExpandedIds(activeRoot.value)
@@ -575,7 +762,7 @@ watch(
575
762
  }
576
763
 
577
764
  if (!search.value.trim() && activeSelectedId.value && activeRoot.value) {
578
- const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value) ?? []
765
+ const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
579
766
  selectedPath.forEach((id) => preserved.add(id))
580
767
  }
581
768
 
@@ -598,8 +785,8 @@ watch(search, (term) => {
598
785
  }
599
786
 
600
787
  if (activeSelectedId.value) {
601
- const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value)
602
- expandedIds.value = selectedPath ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
788
+ const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
789
+ expandedIds.value = selectedPath.length ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
603
790
 
604
791
  return
605
792
  }
@@ -634,17 +821,18 @@ watch([activeHotOnly, activeThreshold, activeMode, filteredRoots], () => {
634
821
  activeSelectedId.value = firstHot.id
635
822
  }
636
823
 
637
- expandedIds.value = new Set(pathToNode(topLevelRoot, firstHot.id) ?? [topLevelRoot.id])
824
+ expandedIds.value = new Set(pathToNodeWithinRoot(firstHot.id, topLevelRoot.id))
638
825
  })
639
826
 
640
827
  function selectNode(node: ComponentNode) {
641
828
  activeSelectedId.value = node.id
642
829
 
643
- const topLevelRoot = rootNodes.value.find((root) => collectIds(root).has(node.id))
830
+ const rootId = rootIdByNodeId.value.get(node.id)
831
+ const topLevelRoot = rootId ? rootMap.value.get(rootId) : null
644
832
 
645
833
  if (topLevelRoot) {
646
834
  activeRootId.value = topLevelRoot.id
647
- expandedIds.value = new Set(pathToNode(topLevelRoot, node.id) ?? [topLevelRoot.id])
835
+ expandedIds.value = new Set(pathToNodeWithinRoot(node.id, topLevelRoot.id))
648
836
  }
649
837
  }
650
838
 
@@ -829,21 +1017,89 @@ function formatTimestamp(t: number): string {
829
1017
  />
830
1018
  </div>
831
1019
 
832
- <div class="render-heatmap__tree-frame">
1020
+ <div ref="treeFrameRef" class="render-heatmap__tree-frame">
833
1021
  <div class="render-heatmap__tree-canvas tree-canvas">
834
- <TreeNode
835
- v-for="root in visibleTreeRoots"
836
- :key="root.id"
837
- :node="root"
838
- :mode="activeMode"
839
- :threshold="activeThreshold"
840
- :selected="activeSelected?.id"
841
- :expanded-ids="expandedIds"
842
- @select="selectNode"
843
- @toggle="toggleNode"
844
- />
1022
+ <template v-if="virtualizedTreeEnabled">
1023
+ <div
1024
+ v-if="topTreePadding > 0"
1025
+ class="render-heatmap__tree-spacer"
1026
+ :style="{ height: `${topTreePadding}px` }"
1027
+ aria-hidden="true"
1028
+ />
1029
+ <div
1030
+ v-for="row in visibleTreeRowItems"
1031
+ :key="row.node.id"
1032
+ class="tree-row"
1033
+ :class="{ selected: activeSelected?.id === row.node.id, hot: row.hot }"
1034
+ :style="{ '--tree-depth': String(row.node.depth) }"
1035
+ @click="selectNode(row.node)"
1036
+ >
1037
+ <span class="tree-rail" aria-hidden="true" />
1038
+ <button
1039
+ class="tree-toggle"
1040
+ :class="{ empty: !row.node.children.length }"
1041
+ :disabled="!row.node.children.length"
1042
+ @click.stop="row.node.children.length ? toggleNode(row.node.id) : undefined"
1043
+ >
1044
+ {{ row.node.children.length ? (expandedIds.has(row.node.id) ? '⌄' : '›') : '' }}
1045
+ </button>
1046
+ <div class="tree-copy">
1047
+ <span class="tree-name mono" :title="row.node.label">{{ row.node.label }}</span>
1048
+ <div v-if="row.primaryBadge" class="tree-badges">
1049
+ <span class="tree-badge mono" :title="row.primaryBadge">{{ row.primaryBadge }}</span>
1050
+ </div>
1051
+ </div>
1052
+ <div class="tree-metrics mono">
1053
+ <span
1054
+ v-if="row.node.isPersistent"
1055
+ class="tree-persistent-pill"
1056
+ title="Layout / persistent component — survives navigation"
1057
+ >
1058
+ persistent
1059
+ </span>
1060
+ <span
1061
+ v-if="row.node.isHydrationMount"
1062
+ class="tree-hydration-pill"
1063
+ title="First mount was SSR hydration — not a user-triggered render"
1064
+ >
1065
+ hydrated
1066
+ </span>
1067
+ <span class="tree-metric-pill">
1068
+ {{ row.metricValue }}
1069
+ {{ row.metricLabel }}
1070
+ </span>
1071
+ <button
1072
+ v-if="row.node.file && row.node.file !== 'unknown'"
1073
+ class="tree-jump-btn"
1074
+ :title="`Open ${row.node.file} in editor`"
1075
+ @click.stop="openInEditor(row.node.file)"
1076
+ >
1077
+
1078
+ </button>
1079
+ </div>
1080
+ </div>
1081
+ <div
1082
+ v-if="bottomTreePadding > 0"
1083
+ class="render-heatmap__tree-spacer"
1084
+ :style="{ height: `${bottomTreePadding}px` }"
1085
+ aria-hidden="true"
1086
+ />
1087
+ </template>
1088
+ <template v-else>
1089
+ <TreeNode
1090
+ v-for="root in visibleTreeRoots"
1091
+ :key="root.id"
1092
+ :node="root"
1093
+ :mode="activeMode"
1094
+ :threshold="activeThreshold"
1095
+ :selected="activeSelected?.id"
1096
+ :expanded-ids="expandedIds"
1097
+ @select="selectNode"
1098
+ @toggle="toggleNode"
1099
+ />
1100
+ </template>
845
1101
  </div>
846
- <div v-if="!visibleTreeRoots.length" class="render-heatmap__detail-empty">
1102
+ <div v-if="!expandedVisibleNodes.length" class="render-heatmap__detail-empty">
847
1103
  {{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
848
1104
  </div>
849
1105
  </div>
@@ -951,16 +1207,14 @@ function formatTimestamp(t: number): string {
951
1207
  </div>
952
1208
  <div v-if="!activeSelected.timeline.length" class="muted text-sm">no timeline events yet</div>
953
1209
  <div v-else class="render-heatmap__timeline-list">
954
- <div
955
- v-for="(event, idx) in [...activeSelected.timeline].reverse().slice(0, 30)"
956
- :key="idx"
957
- class="render-heatmap__timeline-row"
958
- >
959
- <span class="render-heatmap__timeline-kind mono" :class="event.kind">{{ event.kind }}</span>
960
- <span class="render-heatmap__timeline-time mono muted">{{ formatTimestamp(event.t) }}</span>
961
- <span class="render-heatmap__timeline-dur mono">{{ formatMs(event.durationMs) }}</span>
962
- <span v-if="event.triggerKey" class="render-heatmap__timeline-trigger mono muted">{{ event.triggerKey }}</span>
963
- <span class="render-heatmap__timeline-route mono muted">{{ event.route }}</span>
1210
+ <div v-for="row in activeSelectedTimelineRecent" :key="row.key" class="render-heatmap__timeline-row">
1211
+ <span class="render-heatmap__timeline-kind mono" :class="row.event.kind">{{ row.event.kind }}</span>
1212
+ <span class="render-heatmap__timeline-time mono muted">{{ formatTimestamp(row.event.t) }}</span>
1213
+ <span class="render-heatmap__timeline-dur mono">{{ formatMs(row.event.durationMs) }}</span>
1214
+ <span v-if="row.event.triggerKey" class="render-heatmap__timeline-trigger mono muted">
1215
+ {{ row.event.triggerKey }}
1216
+ </span>
1217
+ <span class="render-heatmap__timeline-route mono muted">{{ row.event.route }}</span>
964
1218
  </div>
965
1219
  <div v-if="activeSelected.timeline.length > 30" class="render-heatmap__compact-muted muted text-sm">
966
1220
  … {{ activeSelected.timeline.length - 30 }} earlier events
@@ -1027,11 +1281,6 @@ function formatTimestamp(t: number): string {
1027
1281
  min-height: 0;
1028
1282
  }
1029
1283
 
1030
- .render-heatmap__roots-panel,
1031
- .render-heatmap__detail-panel {
1032
- flex-shrink: 0;
1033
- }
1034
-
1035
1284
  .render-heatmap__roots-panel {
1036
1285
  width: 240px;
1037
1286
  margin-right: 12px;
@@ -1048,6 +1297,7 @@ function formatTimestamp(t: number): string {
1048
1297
 
1049
1298
  .render-heatmap__roots-panel,
1050
1299
  .render-heatmap__detail-panel {
1300
+ flex-shrink: 0;
1051
1301
  display: flex;
1052
1302
  flex-direction: column;
1053
1303
  overflow: auto;
@@ -1134,6 +1384,10 @@ function formatTimestamp(t: number): string {
1134
1384
  width: max-content;
1135
1385
  }
1136
1386
 
1387
+ .render-heatmap__tree-spacer {
1388
+ width: 100%;
1389
+ }
1390
+
1137
1391
  :deep(.tree-node) {
1138
1392
  margin-bottom: 4px;
1139
1393
  }