openreplay/frontend/app/components/shared/DevTools/LongTaskPanel/LongTaskPanel.tsx
Delirium b1b21937ed
ui, tracker, backend: support long animation metrics (#3262)
* 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
2025-05-13 12:04:14 +02:00

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);