Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: waterfall view #118

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/client/components/Container.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script setup lang="ts">
const emit = defineEmits(['element'])
</script>

<template>
<div class="h-[calc(100vh-55px)]">
<div :ref="el => emit('element', el)" class="h-[calc(100vh-55px)]">
<slot />
</div>
</template>
1 change: 1 addition & 0 deletions src/client/logic/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const showOneColumn = useStorage('vite-inspect-one-column', false)
export const showBailout = useStorage('vite-inspect-bailout', false)
export const listMode = useStorage<'graph' | 'list' | 'detailed'>('vite-inspect-mode', 'detailed')
export const lineWrapping = useStorage('vite-inspect-line-wrapping', false)
export const waterfallShowResolveId = useStorage('vite-inspect-waterfall-show-resolve-id', true)
export const inspectSSR = useStorage('vite-inspect-ssr', false)
export const metricDisplayHook = useStorage<'transform' | 'resolveId' | 'server'>('vite-inspect-metric-display-hook', 'transform')
export const sortMode = useStorage<'default' | 'time-asc' | 'time-desc'>('vite-inspect-sort', 'default')
Expand Down
3 changes: 3 additions & 0 deletions src/client/logic/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,6 @@ function serializeForSourcemapsVisualizer(code: string, map: string) {
// Convert the binary string to a base64 string and return it
return btoa(binary)
}

export const isDev = import.meta.env.DEV
export const isBuild = import.meta.env.PROD
5 changes: 4 additions & 1 deletion src/client/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { listMode, refetch, searchResults, searchText, sortMode, sortedSearchResults, toggleMode, toggleSort } from '../logic'
import { isDev, listMode, refetch, searchResults, searchText, sortMode, sortedSearchResults, toggleMode, toggleSort } from '../logic'

const route = useRoute()
const isRoot = computed(() => route.path === '/')
Expand Down Expand Up @@ -49,6 +49,9 @@ onMounted(() => {
<RouterLink text-lg icon-btn to="/metric" title="Metrics">
<div i-carbon-meter />
</RouterLink>
<RouterLink v-if="isDev" text-lg icon-btn to="/waterfall" title="Waterfall">
<div i-carbon-chart-waterfall />
</RouterLink>
</NavBar>
<Container of-auto>
<KeepAlive>
Expand Down
285 changes: 285 additions & 0 deletions src/client/pages/index/waterfall.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { graphic, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import type { CustomSeriesOption } from 'echarts/charts'
import { BarChart, CustomChart } from 'echarts/charts'
import type {
SingleAxisComponentOption,
TooltipComponentOption,
} from 'echarts/components'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import VChart from 'vue-echarts'
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams, CustomSeriesRenderItemReturn, LegendComponentOption, TopLevelFormatterParams } from 'echarts/types/dist/shared'
import { rpc } from '../../logic/rpc'
import { getHot } from '../../logic/hot'
import { inspectSSR, onRefetch, waterfallShowResolveId } from '../../logic'

const container = ref<HTMLDivElement | null>()

const dataZoomBar = 100
const zoomBarOffset = 100

const { height } = useElementSize(container)

const data = shallowRef(await rpc.getWaterfallInfo(inspectSSR.value))
const startTime = computed(() => Math.min(...Object.values(data.value).map(i => i[0]?.start ?? Infinity)))
const endTime = computed(() => Math.max(...Object.values(data.value).map(i => i[i.length - 1]?.end ?? -Infinity)) + 1000)

// const reversed = ref(false)
const searchText = ref('')
const searchFn = computed(() => {
const text = searchText.value.trim()
if (text === '') {
return () => true
}
const regex = new RegExp(text, 'i')
return (name: string) => regex.test(name)
})

const categories = computed(() => {
return Object.keys(data.value).filter(searchFn.value)
})

// const legendData = computed(() => {
// const l = categories.value.map((id) => {
// return {
// name: id,
// icon: 'circle',
// }
// })

// console.log(l)

// return l
// })

function generatorHashColorByString(str: string) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xFF
color += (`00${value.toString(16)}`).substr(-2)
}
return color
}

const types = computed(() => {
return Object.keys(data.value).map((id) => {
return {
name: id,
color: generatorHashColorByString(id),
}
})
})

const waterfallData = computed(() => {
const result: any = []

Object.entries(data.value).forEach(([id, steps], index) => {
steps.forEach((s) => {
const typeItem = types.value.find(i => i.name === id)

const duration = s.end - s.start

if (searchFn.value(id) && searchFn.value(s.name)) {
result.push({
name: typeItem ? typeItem.name : id,
value: [index, s.start, (s.end - s.start) < 1 ? 1 : s.end, duration],
itemStyle: {
normal: {
color: typeItem ? typeItem.color : '#000',
},
},
})
}
})
})

// console.log(result)

return result
})

async function refetch() {
data.value = await rpc.getWaterfallInfo(inspectSSR.value)
}

onRefetch.on(refetch)
watch(inspectSSR, refetch)

getHot().then((hot) => {
if (hot) {
hot.on('vite-plugin-inspect:update', refetch)
}
})

use([
VisualMapComponent,
CanvasRenderer,
BarChart,
TooltipComponent,
TitleComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
CustomChart,
])

function renderItem(params: CustomSeriesRenderItemParams | any, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn {
const categoryIndex = api.value(0)
const start = api.coord([api.value(1), categoryIndex])
const end = api.coord([api.value(2), categoryIndex])
const height = (api.size?.([0, 1]) as number[])[1] * 0.6
const rectShape = graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height,
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height,
},
)

return (
rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style(),
}
)
}

const option = computed(() => ({
tooltip: {
formatter(params: TopLevelFormatterParams | any) {
return `${params.marker + params.name}: ${params.value[3] <= 1 ? '<1' : params.value[3]}ms}`
},

} satisfies TooltipComponentOption,
legendData: {
top: 'center',
data: ['c'],
} satisfies LegendComponentOption,

title: {
text: 'Waterfall',
// left: 'center',
},
visualMap: {
type: 'piecewise',
// show: false,
orient: 'horizontal',
left: 'center',
bottom: 10,
pieces: [

],
seriesIndex: 1,
dimension: 1,
},
dataZoom: [
// 最多支持放大到1ms

{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
top: height.value - dataZoomBar,
labelFormatter: '',
},
{
type: 'inside',
filterMode: 'weakFilter',
},
],
grid: {
height: height.value - dataZoomBar - zoomBarOffset,
},
xAxis: {
min: startTime.value,
max: endTime.value,
// type: 'value',

scale: true,
axisLabel: {
formatter(val: number) {
return `${(val - startTime.value).toFixed(val % 1 ? 2 : 0)} ms`
},
},
} satisfies SingleAxisComponentOption,
yAxis: {
data: categories.value,
} satisfies SingleAxisComponentOption,
series: [
Copy link

@ArthurDarkstone ArthurDarkstone Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

group by id or plugin name ?

{
type: 'custom',
name: 'c',
renderItem,
itemStyle: {
opacity: 0.8,
},
encode: {
x: [1, 2],
y: 0,
},
data: waterfallData.value,
},
] satisfies CustomSeriesOption[],

}))

const chartStyle = computed(() => {
return {
height: `${height.value}px`,
}
})
</script>

<template>
<NavBar>
<RouterLink class="my-auto icon-btn !outline-none" to="/">
<div i-carbon-arrow-left />
</RouterLink>
<div my-auto text-sm font-mono>
Waterfall
</div>
<input v-model="searchText" placeholder="Search..." class="w-full px-4 py-2 text-xs">

<button text-lg icon-btn title="Inspect SSR" @click="inspectSSR = !inspectSSR">
<div i-carbon-cloud-services :class="inspectSSR ? 'opacity-100' : 'opacity-25'" />
</button>
<button class="text-lg icon-btn" title="Show resolveId" @click="waterfallShowResolveId = !waterfallShowResolveId">
<div i-carbon-connect-source :class="waterfallShowResolveId ? 'opacity-100' : 'opacity-25'" />
</button>

<!-- <button class="text-lg icon-btn" title="Show resolveId" @click="reversed = !reversed">
<div i-carbon-arrows-vertical :class="reversed ? 'opacity-100' : 'opacity-25'" />
</button> -->
<div flex-auto />
</NavBar>

<div ref="container" h-full p4>
<div v-if="!waterfallData.length" flex="~" h-40 w-full>
<div ma italic op50>
No data
</div>
</div>
<VChart class="w-100%" :style="chartStyle" :option="option" autoresize />
</div>
</template>
48 changes: 47 additions & 1 deletion src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import sirv from 'sirv'
import { createRPCServer } from 'vite-dev-rpc'
import c from 'picocolors'
import { debounce } from 'perfect-debounce'
import type { HMRData, RPCFunctions } from '../types'
import { objectMap } from '@antfu/utils'
import type { HMRData, RPCFunctions, ResolveIdInfo, WaterfallInfo } from '../types'
import { DIR_CLIENT } from '../dir'
import type { Options } from './options'
import { ViteInspectContext } from './context'
Expand Down Expand Up @@ -125,6 +126,7 @@ export default function PluginInspect(options: Options = {}): Plugin {
const rpcFunctions: RPCFunctions = {
list: () => ctx.getList(server),
getIdInfo,
getWaterfallInfo,
getPluginMetrics: (ssr = false) => ctx.getPluginMetrics(ssr),
getServerMetrics,
resolveId: (id: string, ssr = false) => ctx.resolveId(id, ssr),
Expand Down Expand Up @@ -163,6 +165,50 @@ export default function PluginInspect(options: Options = {}): Plugin {
}
}

function getWaterfallInfo(ssr = false) {
const recorder = ctx.getRecorder(ssr)
const resolveIdByResult: Record<string, ResolveIdInfo> = {}
for (const id in recorder.resolveId) {
const info = recorder.resolveId[id][0]
resolveIdByResult[info.result] = {
...info,
result: id,
}
}
return objectMap(recorder.transform, (id, transforms) => {
const result: WaterfallInfo[string] = []
let currentId = id
while (resolveIdByResult[currentId]) {
const info = resolveIdByResult[currentId]
result.push({
name: info.name,
start: info.start,
end: info.end,
isResolveId: true,
})
if (currentId === info.result)
break
currentId = info.result
}
for (const transform of transforms) {
result.push({
name: transform.name,
start: transform.start,
end: transform.end,
isResolveId: false,
})
}
result.sort((a, b) => a.start - b.start)
const filtered = result.filter(({ start, end }, i) => i === 0 || i === result.length - 1 || start < end)
return filtered.length
? [
id,
filtered,
]
: undefined
})
}

function clearId(_id: string, ssr = false) {
const id = ctx.resolveId(_id)
if (id) {
Expand Down
Loading