Skip to content

Commit 713033a

Browse files
CrabSAMAwebfansplz
andauthored
feat: plugin flamegraph (#79)
* feat: add plugin flamegraph * chore: extract `normalizeTimestamp` function to common utils * feat: add more information in plugin flamegraph hover content * chore: update --------- Co-authored-by: arlo <webfansplz@gmail.com>
1 parent 61d7f4f commit 713033a

File tree

6 files changed

+218
-23
lines changed

6 files changed

+218
-23
lines changed

packages/devtools-vite/src/app/components/chart/ModuleFlamegraph.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ const tree = computed(() => {
3131
const children = [
3232
n({
3333
id: '~resolves',
34-
text: 'resolve',
34+
text: 'Resolve Id',
3535
children: resolveIds,
3636
}),
3737
n({
3838
id: '~loads',
39-
text: 'load',
39+
text: 'Load',
4040
children: loads,
4141
}),
4242
n({
4343
id: '~transforms',
44-
text: 'transform',
44+
text: 'Transform',
4545
children: transforms,
4646
}),
4747
]
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<script setup lang="ts">
2+
import type { TreeNodeInput } from 'nanovis'
3+
import type { PluginBuildInfo, RolldownPluginBuildMetrics, SessionContext } from '~~/shared/types'
4+
import { Flamegraph, normalizeTreeNode } from 'nanovis'
5+
import { relative } from 'pathe'
6+
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
7+
import { parseReadablePath } from '~/utils/filepath'
8+
import { normalizeTimestamp } from '~/utils/format'
9+
import { getFileTypeFromModuleId, ModuleTypeRules } from '~/utils/icon'
10+
11+
const props = defineProps<{
12+
session: SessionContext
13+
buildMetrics: RolldownPluginBuildMetrics
14+
}>()
15+
16+
const parsedPaths = computed(() => props.session.modulesList.map((mod) => {
17+
const path = parseReadablePath(mod.id, props.session.meta.cwd)
18+
const type = getFileTypeFromModuleId(mod.id)
19+
return {
20+
mod,
21+
path,
22+
type,
23+
}
24+
}))
25+
const moduleTypes = computed(() => ModuleTypeRules.filter(rule => parsedPaths.value.some(mod => rule.match.test(mod.mod.id))))
26+
27+
const n = (node: TreeNodeInput<PluginBuildInfo>) => normalizeTreeNode(node, undefined, false)
28+
29+
function normalizeModulePath(path: string) {
30+
const normalized = path.replace(/%2F/g, '/')
31+
const cwd = props.session!.meta.cwd
32+
let relate = cwd ? relative(cwd, normalized) : normalized
33+
if (!relate.startsWith('.'))
34+
relate = `./${relate}`
35+
if (relate.startsWith('./'))
36+
return relate
37+
if (relate.match(/^(?:\.\.\/){1,3}[^.]/))
38+
return relate
39+
return normalized
40+
}
41+
42+
const tree = computed(() => {
43+
const resolveIds = moduleTypes.value.map((type, idx) => n({
44+
id: `resolveId-${type.name}-${idx}`,
45+
text: type.description,
46+
children: props.buildMetrics.resolveIdMetrics.filter((item) => {
47+
return getFileTypeFromModuleId(item.module).name === type.name
48+
}).map((id, idx) => n({
49+
id: `resolveId-${idx}`,
50+
text: normalizeModulePath(id.module),
51+
size: id.duration,
52+
})),
53+
}))
54+
const loads = moduleTypes.value.map((type, idx) => n({
55+
id: `loads-${type.name}-${idx}`,
56+
text: type.description,
57+
children: props.buildMetrics.loadMetrics.filter((item) => {
58+
return getFileTypeFromModuleId(item.module).name === type.name
59+
}).map((id, idx) => n({
60+
id: `resolveId-${idx}`,
61+
text: normalizeModulePath(id.module),
62+
size: id.duration,
63+
})),
64+
}))
65+
const transforms = moduleTypes.value.map((type, idx) => n({
66+
id: `transforms-${type.name}-${idx}`,
67+
text: type.description,
68+
children: props.buildMetrics.transformMetrics.filter((item) => {
69+
return getFileTypeFromModuleId(item.module).name === type.name
70+
}).map((id, idx) => n({
71+
id: `resolveId-${idx}`,
72+
text: normalizeModulePath(id.module),
73+
size: id.duration,
74+
})),
75+
}))
76+
77+
// resolve/load/transform -> module type -> module
78+
const children = [
79+
n({
80+
id: '~resolves',
81+
text: 'Resolve Id',
82+
children: resolveIds,
83+
}),
84+
n({
85+
id: '~loads',
86+
text: 'Load',
87+
children: loads,
88+
}),
89+
n({
90+
id: '~transforms',
91+
text: 'Transform',
92+
children: transforms,
93+
}),
94+
]
95+
96+
return n({
97+
id: '~root',
98+
text: 'Plugin Flamegraph',
99+
children,
100+
})
101+
})
102+
103+
const hoverNode = ref<{
104+
plugin_name: string
105+
duration: number
106+
meta: PluginBuildInfo | undefined
107+
} | null>(null)
108+
const hoverX = ref<number>(0)
109+
const hoverY = ref<number>(0)
110+
const el = useTemplateRef<HTMLDivElement>('el')
111+
const flamegraph = shallowRef<Flamegraph<PluginBuildInfo> | null>(null)
112+
113+
function buildFlamegraph() {
114+
flamegraph.value = new Flamegraph(tree.value, {
115+
animate: true,
116+
palette: {
117+
fg: '#888',
118+
},
119+
getSubtext: (node) => {
120+
const p = node.size / tree.value.size * 100
121+
if (p > 15 && p !== 100) {
122+
return `${p.toFixed(1)}%`
123+
}
124+
return undefined
125+
},
126+
onHover(node, e) {
127+
if (!node) {
128+
hoverNode.value = null
129+
return
130+
}
131+
if (e) {
132+
hoverX.value = e.clientX
133+
hoverY.value = e.clientY
134+
}
135+
hoverNode.value = {
136+
plugin_name: node.text!,
137+
duration: node.size,
138+
meta: node.meta,
139+
}
140+
},
141+
})
142+
el.value!.appendChild(flamegraph.value!.el)
143+
}
144+
145+
function disposeFlamegraph() {
146+
flamegraph.value?.dispose()
147+
}
148+
149+
onMounted(() => {
150+
buildFlamegraph()
151+
})
152+
153+
onUnmounted(() => {
154+
disposeFlamegraph()
155+
})
156+
157+
watch(tree, async () => {
158+
disposeFlamegraph()
159+
buildFlamegraph()
160+
}, {
161+
deep: true,
162+
})
163+
</script>
164+
165+
<template>
166+
<div relative border="t base" pb10 py1 mt4>
167+
<Teleport to="body">
168+
<div
169+
v-if="hoverNode"
170+
border="~ base" rounded shadow px2 py1 fixed
171+
z-panel-content bg-glass pointer-events-none text-sm
172+
:style="{ left: `${hoverX}px`, top: `${hoverY}px` }"
173+
>
174+
<div flex="~" font-bold font-mono>
175+
<DisplayFileIcon v-if="hoverNode.meta" :filename="hoverNode.meta.module" mr1.5 />
176+
{{ hoverNode.plugin_name }}
177+
</div>
178+
<div v-if="hoverNode.meta">
179+
<div>
180+
<label>Start Time: </label>
181+
<time :datetime="new Date(hoverNode.meta.timestamp_start).toISOString()">{{ normalizeTimestamp(hoverNode.meta.timestamp_start) }}</time>
182+
</div>
183+
<div>
184+
<label>End Time: </label>
185+
<time :datetime="new Date(hoverNode.meta.timestamp_end).toISOString()">{{ normalizeTimestamp(hoverNode.meta.timestamp_end) }}</time>
186+
</div>
187+
</div>
188+
<div flex="~ gap-1">
189+
<label>Duration: </label>
190+
<DisplayDuration :duration="hoverNode.duration" />
191+
</div>
192+
</div>
193+
</Teleport>
194+
<div ref="el" min-h-30 />
195+
</div>
196+
</template>

packages/devtools-vite/src/app/components/data/PluginDetailsLoader.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ const totalDuration = computed(() => {
153153
:session="session"
154154
:build-metrics="state"
155155
/>
156+
<ChartPluginFlamegraph
157+
v-if="settings.pluginDetailsViewType === 'charts'"
158+
:session="session"
159+
:build-metrics="state"
160+
/>
156161
</div>
157162
</div>
158163
<div v-else flex="~ items-center justify-center" w-full h-full>

packages/devtools-vite/src/app/components/data/PluginDetailsTable.vue

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Menu as VMenu } from 'floating-vue'
66
import { computed, ref } from 'vue'
77
import { settings } from '~~/app/state/settings'
88
import { parseReadablePath } from '~/utils/filepath'
9+
import { normalizeTimestamp } from '~/utils/format'
910
import { getFileTypeFromModuleId, ModuleTypeRules } from '~/utils/icon'
1011
1112
const props = defineProps<{
@@ -63,19 +64,6 @@ function toggleModuleType(rule: FilterMatchRule) {
6364
settings.value.pluginDetailsModuleTypes = filterModuleTypes.value
6465
}
6566
66-
function normalizeTimestamp(timestamp: number) {
67-
return new Date(timestamp).toLocaleString(undefined, {
68-
hour12: false,
69-
year: 'numeric',
70-
month: '2-digit',
71-
day: '2-digit',
72-
hour: '2-digit',
73-
minute: '2-digit',
74-
second: '2-digit',
75-
fractionalSecondDigits: 3,
76-
})
77-
}
78-
7967
function toggleDurationSortType() {
8068
next()
8169
settings.value.pluginDetailsDurationSortType = durationSortType.value

packages/devtools-vite/src/app/pages/session/[session]/plugins.vue

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,3 @@ debouncedWatch(
9090
</div>
9191
</div>
9292
</template>
93-
94-
<!--
95-
TODO: plugins framegraph
96-
Two different views direction:
97-
- plugins -> hooks -> modules
98-
- modules -> hooks -> plugins
99-
-->

packages/devtools-vite/src/app/utils/format.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,16 @@ export function toTree(modules: ModuleDest[], name: string) {
5757

5858
return node
5959
}
60+
61+
export function normalizeTimestamp(timestamp: number) {
62+
return new Date(timestamp).toLocaleString(undefined, {
63+
hour12: false,
64+
year: 'numeric',
65+
month: '2-digit',
66+
day: '2-digit',
67+
hour: '2-digit',
68+
minute: '2-digit',
69+
second: '2-digit',
70+
fractionalSecondDigits: 3,
71+
})
72+
}

0 commit comments

Comments
 (0)