* ui, tracker, backend: support long animation metrics * ui: fix LAT mapping * ui: change jump button display, longTask time definition * ui: border for rows * ui: refine LAT design * tracker: regenerate messages
265 lines
7.6 KiB
TypeScript
265 lines
7.6 KiB
TypeScript
import React from 'react';
|
|
import { observer } from 'mobx-react-lite';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Input } from 'antd';
|
|
import { VList, VListHandle } from 'virtua';
|
|
import { PlayerContext } from 'App/components/Session/playerContext';
|
|
import JumpButton from '../JumpButton';
|
|
import { useRegExListFilterMemo } from '../useListFilter';
|
|
import BottomBlock from '../BottomBlock';
|
|
import { NoContent, Icon } from 'UI';
|
|
import { InfoCircleOutlined } from '@ant-design/icons';
|
|
import { Segmented, Select, Tag } from 'antd';
|
|
import { LongAnimationTask } from './type';
|
|
import Script from './Script';
|
|
import TaskTimeline from './TaskTimeline';
|
|
import { Hourglass } from 'lucide-react';
|
|
|
|
interface Row extends LongAnimationTask {
|
|
time: number;
|
|
}
|
|
|
|
const TABS = {
|
|
all: 'all',
|
|
blocking: 'blocking',
|
|
};
|
|
|
|
const SORT_BY = {
|
|
timeAsc: 'timeAsc',
|
|
blocking: 'blockingDesc',
|
|
duration: 'durationDesc',
|
|
};
|
|
|
|
function LongTaskPanel() {
|
|
const { t } = useTranslation();
|
|
const [tab, setTab] = React.useState(TABS.all);
|
|
const [sortBy, setSortBy] = React.useState(SORT_BY.timeAsc);
|
|
const _list = React.useRef<VListHandle>(null);
|
|
const { player, store } = React.useContext(PlayerContext);
|
|
const [searchValue, setSearchValue] = React.useState('');
|
|
|
|
const { currentTab, tabStates } = store.get();
|
|
const longTasks = tabStates[currentTab]?.longTaskList || [];
|
|
|
|
const filteredList = useRegExListFilterMemo(
|
|
longTasks,
|
|
(task: LongAnimationTask) => [
|
|
task.name,
|
|
task.scripts.map((script) => script.name).join(','),
|
|
task.scripts.map((script) => script.sourceURL).join(','),
|
|
],
|
|
searchValue,
|
|
);
|
|
|
|
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setSearchValue(value);
|
|
};
|
|
|
|
const onRowClick = (time: number) => {
|
|
player.jump(time);
|
|
};
|
|
|
|
const rows: Row[] = React.useMemo(() => {
|
|
let rowMap = filteredList.map((task) => ({
|
|
...task,
|
|
time: task.time ?? task.startTime,
|
|
}));
|
|
if (tab === 'blocking') {
|
|
rowMap = rowMap.filter((task) => task.blockingDuration > 0);
|
|
}
|
|
switch (sortBy) {
|
|
case SORT_BY.blocking:
|
|
rowMap = rowMap.sort((a, b) => b.blockingDuration - a.blockingDuration);
|
|
break;
|
|
case SORT_BY.duration:
|
|
rowMap = rowMap.sort((a, b) => b.duration - a.duration);
|
|
break;
|
|
default:
|
|
rowMap = rowMap.sort((a, b) => a.time - b.time);
|
|
}
|
|
return rowMap;
|
|
}, [filteredList.length, tab, sortBy]);
|
|
|
|
const blockingTasks = React.useMemo(() => {
|
|
let blockingAmount = 0;
|
|
for (const task of longTasks) {
|
|
if (task.blockingDuration > 0) {
|
|
blockingAmount++;
|
|
}
|
|
}
|
|
return blockingAmount;
|
|
}, [longTasks.length]);
|
|
|
|
return (
|
|
<BottomBlock style={{ height: '100%' }}>
|
|
<BottomBlock.Header>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold color-gray-medium mr-4">
|
|
{t('Long Tasks')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Segmented
|
|
size={'small'}
|
|
value={tab}
|
|
onChange={setTab}
|
|
options={[
|
|
{ label: t('All'), value: 'all' },
|
|
{
|
|
label: (
|
|
<div>
|
|
{t('Blocking')} ({blockingTasks})
|
|
</div>
|
|
),
|
|
value: 'blocking',
|
|
},
|
|
]}
|
|
/>
|
|
<Select
|
|
size="small"
|
|
className="rounded-lg"
|
|
value={sortBy}
|
|
onChange={setSortBy}
|
|
popupMatchSelectWidth={150}
|
|
dropdownStyle={{ minWidth: '150px' }}
|
|
options={[
|
|
{ label: t('Default Order'), value: 'timeAsc' },
|
|
{ label: t('Blocking Duration'), value: 'blockingDesc' },
|
|
{ label: t('Task Duration'), value: 'durationDesc' },
|
|
]}
|
|
/>
|
|
<Input.Search
|
|
className="rounded-lg"
|
|
placeholder={t('Filter by name or source URL')}
|
|
name="filter"
|
|
onChange={onFilterChange}
|
|
value={searchValue}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
</BottomBlock.Header>
|
|
<BottomBlock.Content>
|
|
<NoContent
|
|
title={
|
|
<div className="capitalize flex items-center gap-2">
|
|
<InfoCircleOutlined size={18} />
|
|
{t('No Data')}
|
|
</div>
|
|
}
|
|
size="small"
|
|
show={filteredList.length === 0}
|
|
>
|
|
<VList ref={_list} itemSize={25}>
|
|
{rows.map((task) => (
|
|
<LongTaskRow key={task.time} task={task} onJump={onRowClick} />
|
|
))}
|
|
</VList>
|
|
</NoContent>
|
|
</BottomBlock.Content>
|
|
</BottomBlock>
|
|
);
|
|
}
|
|
|
|
function LongTaskRow({
|
|
task,
|
|
onJump,
|
|
}: {
|
|
task: Row;
|
|
onJump: (time: number) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = React.useState(false);
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
'relative border-b border-neutral-950/5 group hover:bg-active-blue py-1 px-4 pe-8'
|
|
}
|
|
>
|
|
<div className="flex flex-col w-full">
|
|
<TaskTitle expanded={expanded} entry={task} toggleExpand={() => setExpanded(!expanded)} />
|
|
{expanded ? (
|
|
<>
|
|
<TaskTimeline task={task} />
|
|
<div className={'flex items-center gap-1 mb-2'}>
|
|
<div className={'text-neutral-900 font-medium'}>
|
|
First UI event timestamp:
|
|
</div>
|
|
<div className="text-neutral-600 font-mono block">
|
|
{Math.round(task.firstUIEventTimestamp)} ms
|
|
</div>
|
|
</div>
|
|
<div className={'text-neutral-900 font-medium'}>Scripts:</div>
|
|
<div className="flex flex-col gap-1">
|
|
{task.scripts.map((script, index) => (
|
|
<Script script={script} key={index} />
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<JumpButton time={task.time} onClick={() => onJump(task.time)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskTitle({
|
|
entry,
|
|
toggleExpand,
|
|
expanded,
|
|
}: {
|
|
entry: {
|
|
name: string;
|
|
duration: number;
|
|
blockingDuration?: number;
|
|
scripts: LongAnimationTask['scripts'];
|
|
};
|
|
expanded: boolean;
|
|
toggleExpand: () => void;
|
|
}) {
|
|
const isBlocking =
|
|
entry.blockingDuration !== undefined && entry.blockingDuration > 0;
|
|
|
|
const scriptTitles = entry.scripts.map((script) =>
|
|
script.invokerType ? script.invokerType : script.name,
|
|
);
|
|
const { title, plusMore } = getFirstTwoScripts(scriptTitles);
|
|
return (
|
|
<div className={'flex items-center gap-1 text-sm cursor-pointer'} onClick={toggleExpand}>
|
|
<Icon
|
|
name={expanded ? 'caret-down-fill' : 'caret-right-fill'}
|
|
/>
|
|
<span className="font-mono font-bold">{title}</span>
|
|
<Tag color="default" bordered={false}>
|
|
{plusMore}
|
|
</Tag>
|
|
<span className={'text-neutral-600 font-mono'}>
|
|
{Math.round(entry.duration)} ms
|
|
</span>
|
|
{isBlocking ? (
|
|
<Tag
|
|
bordered={false}
|
|
color="red"
|
|
className="font-mono rounded-lg text-xs flex gap-1 items-center text-red-600"
|
|
>
|
|
<Hourglass size={11} /> {Math.round(entry.blockingDuration!)} ms
|
|
blocking
|
|
</Tag>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getFirstTwoScripts(titles: string[]) {
|
|
if (titles.length === 0) {
|
|
return { title: 'Long Animation Task', plusMore: null };
|
|
}
|
|
const additional = titles.length - 2;
|
|
const additionalStr = additional > 0 ? `+ ${additional} more` : null;
|
|
return {
|
|
title: `${titles[0]}${titles[1] ? `, ${titles[1]}` : ''}`,
|
|
plusMore: additionalStr,
|
|
};
|
|
}
|
|
|
|
export default observer(LongTaskPanel);
|