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 24 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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>
3 changes: 3 additions & 0 deletions src/client/logic/rpc-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@ export function createStaticRpcClient(): RpcFunctions {
resolveId: (query, id) => getModuleTransformInfo(query, id).then(r => r.resolvedId),
onModuleUpdated: async () => undefined,
getServerMetrics: async () => ({}),
getWaterfallInfo: async () => ({}),
getHmrEvents: async () => [],
list: () => null!,
}
}
3 changes: 3 additions & 0 deletions src/client/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ const isRoot = computed(() => route.path === '/')
<RouterLink text-lg icon-btn :to="{ path: '/metric', query: route.query }" title="Metrics">
<span i-carbon-meter />
</RouterLink>
<RouterLink text-lg icon-btn to="/waterfall" title="Waterfall">
<div i-carbon-chart-waterfall />
</RouterLink>
<RouterLink text-lg icon-btn :to="{ path: '/plugins', query: route.query }" title="Plugins">
<span i-carbon-microservices-1 />
</RouterLink>
Expand Down
322 changes: 322 additions & 0 deletions src/client/pages/index/waterfall.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
<script setup lang="ts">
import type { init } from 'echarts/core'
import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams, CustomSeriesRenderItemReturn, TopLevelFormatterParams } from 'echarts/types/dist/shared'
import { BarChart, CustomChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import { graphic, use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import VChart from 'vue-echarts'
import { createFilter, generatorHashColorByString } from '../../../node/utils'
import { getHot } from '../../logic/hot'
import { onModuleUpdated, rpc } from '../../logic/rpc'
import { useOptionsStore } from '../../stores/options'
import { usePayloadStore } from '../../stores/payload'

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

const options = useOptionsStore()
const payload = usePayloadStore()

const container = ref<HTMLDivElement | null>()

const dataZoomBar = 100
const zoomBarOffset = 100

const { height } = useElementSize(container)

const data = shallowRef(await rpc.getWaterfallInfo(payload.query))
const hmrEvents = shallowRef(await rpc.getHmrEvents(payload.query))
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 paused = ref(false)
const pluginFilter = ref('')
const idFilter = ref('')
const pluginFilterFn = computed(() => createFilter(pluginFilter.value))
const idFilterFn = computed(() => createFilter(idFilter.value))

async function refetch() {
if (!paused.value) {
data.value = await rpc.getWaterfallInfo(payload.query)
hmrEvents.value = await rpc.getHmrEvents(payload.query)
}
}

onModuleUpdated.on(refetch)

watch(
() => [paused.value, payload.query],
() => refetch(),
{ deep: true },
)

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

const sortedData = computed(() => {
return Object.entries(data.value)
.sort(([_, a], [__, b]) => {
const aStart = a[0]?.start ?? 0
const bStart = b[0]?.start ?? 0
return aStart - bStart
})
})

const moduleIds = computed(() => sortedData.value.filter(([k]) => idFilterFn.value(k)).reverse())

const chartDataById = computed(() => {
const result: any[] = []
for (const [index, [id, steps]] of moduleIds.value.entries()) {
for (const s of steps) {
if (pluginFilterFn.value(s.name)) {
const duration = s.end - s.start
result.push({
name: id,
value: [index, s.start, duration < 1 ? s.start + 1 : s.end, duration],
itemStyle: {
normal: {
color: generatorHashColorByString(id),
},
},
})
}
}
}
return result
})

interface WaterfallSpan {
kind: 'resolve' | 'transform' | 'group'
fade: boolean
start: number
end: number
id: string
name: string
}

const chartDataStacked = computed(() => {
const rows: WaterfallSpan[][] = []
const rowsEnd: number[] = []
for (let [id, steps] of sortedData.value) {
if (!options.view.waterfallShowResolveId) {
steps = steps.filter(i => !i.isResolveId)
}
if (steps.length === 0) {
continue
}
const start = steps[0].start
const end = steps[steps.length - 1].end
const spans: WaterfallSpan[] = steps.map(v => ({
kind: v.isResolveId ? 'resolve' : 'transform',
start: v.start,
end: v.end,
name: v.name,
id,
fade: !pluginFilterFn.value(v.name) && !idFilterFn.value(id),
}))
const row = rowsEnd.findIndex((rowEnd, i) => {
if (rowEnd <= start) {
rowsEnd[i] = end
rows[i].push(...spans)
return true
}
return false
})
if (row === -1) {
rows.push(spans)
rowsEnd.push(end)
}
}
return rows.reverse().map((spans, index) => spans.map((s) => {
const duration = s.end - s.start
return {
name: s.id,
value: [index, s.start, duration < 1 ? s.start + 1 : s.end, duration],
itemStyle: {
normal: {
color: generatorHashColorByString(s.id),
opacity: s.fade ? 0.2 : 1,
},
},
}
}))
})

function renderItem(params: CustomSeriesRenderItemParams | any, api: CustomSeriesRenderItemAPI): CustomSeriesRenderItemReturn {
const index = api.value(0)
const start = api.coord([api.value(1), index])
const end = api.coord([api.value(2), index])
const height = (api.size?.([0, 1]) as number[])[1]

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(),
}
)
}

type ChartOption = ReturnType<ReturnType<typeof init>['getOption']>
const chartOption = computed<ChartOption>(() => ({
tooltip: {
formatter(params: TopLevelFormatterParams | any) {
return `${params.marker}${params.name}: ${params.value[3] <= 1 ? '<1' : params.value[3]}ms}`
},
},
legendData: {
top: 'center',
data: ['c'],
},
title: {
text: 'Waterfall',
},
visualMap: {
type: 'piecewise',
// show: false,
orient: 'horizontal',
left: 'center',
bottom: 10,
pieces: [

],
seriesIndex: 1,
dimension: 1,
},
dataZoom: [
{
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,
scale: true,
axisLabel: {
formatter(val: number) {
return `${(val - startTime.value).toFixed(val % 1 ? 2 : 0)} ms`
},
},
},
yAxis: {
data: options.view.waterfallStacking ? [...chartDataStacked.value.keys()].reverse() : moduleIds.value.map(([id]) => id),
},
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: options.view.waterfallStacking ? chartDataStacked.value.flat() : chartDataById.value,
markLine: {
data: hmrEvents.value.map(({ type, file, timestamp }) => ({
name: `${type} ${file}`,
xAxis: timestamp,
})),
lineStyle: {
color: '#f00',
},
symbol: ['none', 'none'],
},
},
],
}))

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="pluginFilter" placeholder="Plugin Filter..." class="w-full px-4 py-2 text-xs">
<input v-model="idFilter" placeholder="ID Filter..." class="w-full px-4 py-2 text-xs">

<QuerySelector />

<button text-lg icon-btn title="Pause" @click="paused = !paused">
<span i-carbon-pause opacity-90 :class="paused ? 'text-red' : ''" />
</button>
<button text-lg icon-btn title="Show resolveId" @click="options.view.waterfallShowResolveId = !options.view.waterfallShowResolveId">
<span i-carbon-connect-source :class="options.view.waterfallShowResolveId ? 'opacity-100' : 'opacity-25'" />
</button>
<button text-lg icon-btn title="Stacked" @click="options.view.waterfallStacking = !options.view.waterfallStacking">
<span i-carbon-stacked-scrolling-1 :class="options.view.waterfallStacking ? 'opacity-100' : 'opacity-25'" />
</button>

<div flex-auto />
</NavBar>

<div ref="container" h-full p4>
<div v-if="!Object.keys(data).length" flex="~" h-40 w-full>
<div ma italic op50>
No data
</div>
</div>
<VChart v-else class="w-100%" :style="chartStyle" :option="chartOption" autoresize />
</div>
</template>
4 changes: 4 additions & 0 deletions src/client/stores/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface ViewState {
showBailout: boolean
showOneColumn: boolean
sort: 'default' | 'time-asc' | 'time-desc'
waterfallShowResolveId: boolean
waterfallStacking: boolean
}

export interface SearchState {
Expand All @@ -37,6 +39,8 @@ export const useOptionsStore = defineStore('options', () => {
showBailout: false,
showOneColumn: false,
sort: 'default',
waterfallShowResolveId: true,
waterfallStacking: true,
},
{ mergeDefaults: true },
)
Expand Down
Loading