ui: group xray events together (#2654)

This commit is contained in:
Delirium 2024-10-16 10:18:16 +02:00 committed by GitHub
parent d0df66b539
commit 4a97094c0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 456 additions and 140 deletions

View file

@ -319,10 +319,11 @@ function PanelComponent({
isGraph={feature === 'PERFORMANCE'}
title={feature}
list={resources[feature]}
renderElement={(pointer: any) => (
renderElement={(pointer: any[], isGrouped: boolean) => (
<TimelinePointer
pointer={pointer}
type={feature}
isGrouped={isGrouped}
fetchPresented={fetchPresented}
/>
)}

View file

@ -9,7 +9,7 @@ interface Props {
message?: string;
className?: string;
endTime?: number;
renderElement?: (item: any) => React.ReactNode;
renderElement?: (item: any, isGrouped: boolean) => React.ReactNode;
isGraph?: boolean;
zIndex?: number;
noMargin?: boolean;
@ -18,15 +18,70 @@ const EventRow = React.memo((props: Props) => {
const { title, className, list = [], endTime = 0, isGraph = false, message = '' } = props;
const scale = 100 / endTime;
const _list =
!isGraph &&
isGraph ? [] :
React.useMemo(() => {
return list.map((item: any, _index: number) => {
const spread = item.toJS ? { ...item.toJS() } : { ...item };
return {
...spread,
left: getTimelinePosition(item.time, scale),
};
});
const tolerance = 2; // within what %s to group items
const groupedItems = [];
let currentGroup = [];
let currentLeft = 0;
for (let i = 0; i < list.length; i++) {
const item = list[i];
const spread = item.toJS ? { ...item.toJS() } : item;
const left: number = getTimelinePosition(item.time, scale);
const itemWithLeft = { ...spread, left };
if (currentGroup.length === 0) {
currentGroup.push(itemWithLeft);
currentLeft = left;
} else {
if (Math.abs(left - currentLeft) <= tolerance) {
currentGroup.push(itemWithLeft);
} else {
if (currentGroup.length > 1) {
const leftValues = currentGroup.map(item => item.left);
const minLeft = Math.min(...leftValues);
const maxLeft = Math.max(...leftValues);
const middleLeft = (minLeft + maxLeft) / 2;
groupedItems.push({
isGrouped: true,
items: currentGroup,
left: middleLeft,
});
} else {
groupedItems.push({
isGrouped: false,
items: [currentGroup[0]],
left: currentGroup[0].left,
});
}
currentGroup = [itemWithLeft];
currentLeft = left;
}
}
}
if (currentGroup.length > 1) {
const leftValues = currentGroup.map(item => item.left);
const minLeft = Math.min(...leftValues);
const maxLeft = Math.max(...leftValues);
const middleLeft = (minLeft + maxLeft) / 2;
groupedItems.push({
isGrouped: true,
items: currentGroup,
left: middleLeft,
});
} else if (currentGroup.length === 1) {
groupedItems.push({
isGrouped: false,
items: [currentGroup[0]],
left: currentGroup[0].left,
});
}
return groupedItems;
}, [list]);
return (
@ -52,17 +107,18 @@ const EventRow = React.memo((props: Props) => {
{isGraph ? (
<PerformanceGraph list={list} />
) : _list.length > 0 ? (
_list.map((item: any, index: number) => {
_list.map((item: { items: any[], left: number, isGrouped: boolean }, index: number) => {
const left = item.left
return (
<div
key={index}
className="absolute"
style={{
left: `clamp(0%, calc(${item.left}% - 7px), calc(100% - 14px))`,
left: `clamp(0%, calc(${left}% - 7px), calc(100% - 14px))`,
zIndex: props.zIndex ? props.zIndex : undefined,
}}
>
{props.renderElement ? props.renderElement(item) : null}
{props.renderElement ? props.renderElement(item.items, item.isGrouped) : null}
</div>
);
})

View file

@ -15,6 +15,9 @@ const OverviewPanelContainer = React.memo((props: Props) => {
const [mouseX, setMouseX] = React.useState(0);
const [mouseIn, setMouseIn] = React.useState(false);
const onClickTrack = (e: any) => {
if (e.target.className.includes('ant-popover')) {
return;
}
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
const time = Math.max(Math.round(p * endTime), 0);
if (time) {

View file

@ -0,0 +1,148 @@
import React from "react";
import { EXCEPTIONS, NETWORK } from "App/mstore/uiPlayerStore";
import { TYPES } from "App/types/session/event";
import { types as issueTypes } from "App/types/session/issue";
import { Icon } from "UI";
import { Tooltip } from "antd";
interface CommonProps {
item: any;
createEventClickHandler: any;
}
export function shortenResourceName(name: string) {
return name.length > 100
? name.slice(0, 100) + ' ... ' + name.slice(-50)
: name
}
export function NetworkElement({ item, createEventClickHandler }: CommonProps) {
const name = item.name || '';
return (
<Tooltip
title={
<div className="">
<b>{item.success ? 'Slow resource: ' : '4xx/5xx Error:'}</b>
<br />
{shortenResourceName(name)}
</div>
}
>
<div
onClick={createEventClickHandler(item, NETWORK)}
className="cursor-pointer"
>
<div className="h-4 w-4 rounded-full bg-red text-white font-bold flex items-center justify-center text-sm">
<span>!</span>
</div>
</div>
</Tooltip>
);
}
export function getFrustration(item: any) {
const elData = { name: '', icon: '' };
if (item.type === TYPES.CLICK)
Object.assign(elData, {
name: `User hesitated to click for ${Math.round(
item.hesitation / 1000
)}s`,
icon: 'click-hesitation',
});
if (item.type === TYPES.INPUT)
Object.assign(elData, {
name: `User hesitated to enter a value for ${Math.round(
item.hesitation / 1000
)}s`,
icon: 'input-hesitation',
});
if (item.type === TYPES.CLICKRAGE || item.type === TYPES.TAPRAGE)
Object.assign(elData, { name: 'Click Rage', icon: 'click-rage' });
if (item.type === TYPES.DEAD_LICK)
Object.assign(elData, { name: 'Dead Click', icon: 'emoji-dizzy' });
if (item.type === issueTypes.MOUSE_THRASHING)
Object.assign(elData, { name: 'Mouse Thrashing', icon: 'cursor-trash' });
if (item.type === 'ios_perf_event')
Object.assign(elData, { name: item.name, icon: item.icon });
return elData;
}
export function FrustrationElement({ item, createEventClickHandler }: CommonProps) {
const elData = getFrustration(item);
return (
<Tooltip
title={
<div className="">
<b>{elData.name}</b>
</div>
}
>
<div
onClick={createEventClickHandler(item, null)}
className="cursor-pointer"
>
<Icon name={elData.icon} color="black" size="16" />
</div>
</Tooltip>
);
}
export function StackEventElement({ item, createEventClickHandler }: CommonProps) {
return (
<Tooltip
title={
<div className="">
<b>{item.name || 'Stack Event'}</b>
</div>
}
>
<div
onClick={createEventClickHandler(item, 'EVENT')}
className="cursor-pointer w-1 h-4 bg-red"
>
{/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */}
</div>
</Tooltip>
);
}
export function PerformanceElement({ item, createEventClickHandler }: CommonProps) {
return (
<Tooltip
title={
<div className="">
<b>{item.type}</b>
</div>
}
>
<div
onClick={createEventClickHandler(item, EXCEPTIONS)}
className="cursor-pointer w-1 h-4 bg-red"
>
{/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */}
</div>
</Tooltip>
);
}
export function ExceptionElement({ item, createEventClickHandler }: CommonProps) {
return (
<Tooltip
title={
<div className="">
<b>{'Exception'}</b>
<br />
<span>{item.message}</span>
</div>
}
>
<div
onClick={createEventClickHandler(item, 'ERRORS')}
className="cursor-pointer"
>
<div className="h-4 w-4 rounded-full bg-red text-white font-bold flex items-center justify-center text-sm">
<span>!</span>
</div>
</div>
</Tooltip>
);
}

View file

@ -1,24 +1,40 @@
import React from 'react';
import { NETWORK, EXCEPTIONS } from 'App/mstore/uiPlayerStore';
import { useModal } from 'App/components/Modal';
import { Icon } from 'UI';
import { shortDurationFromMs } from "App/date";
import StackEventModal from '../StackEventModal';
import ErrorDetailsModal from 'App/components/Dashboard/components/Errors/ErrorDetailsModal';
import FetchDetails from 'Shared/FetchDetailsModal';
import GraphQLDetailsModal from 'Shared/GraphQLDetailsModal';
import { PlayerContext } from 'App/components/Session/playerContext';
import { TYPES } from 'App/types/session/event'
import { types as issueTypes } from 'App/types/session/issue'
import { Tooltip } from 'antd';
import { Popover } from 'antd';
import {
shortenResourceName,
NetworkElement,
getFrustration,
FrustrationElement,
StackEventElement,
PerformanceElement,
ExceptionElement,
} from './Dots'
interface Props {
pointer: any;
type: 'ERRORS' | 'EVENT' | 'NETWORK' | 'FRUSTRATIONS' | 'EVENTS' | 'PERFORMANCE';
type:
| 'ERRORS'
| 'EVENT'
| 'NETWORK'
| 'FRUSTRATIONS'
| 'EVENTS'
| 'PERFORMANCE'
noClick?: boolean;
fetchPresented?: boolean;
isGrouped?: boolean;
}
const TimelinePointer = React.memo((props: Props) => {
const { player } = React.useContext(PlayerContext)
const { pointer, type, isGrouped } = props;
const { player } = React.useContext(PlayerContext);
const item = isGrouped ? pointer : pointer[0]
const { showModal } = useModal();
const createEventClickHandler = (pointer: any, type: any) => (e: any) => {
@ -30,150 +46,162 @@ const TimelinePointer = React.memo((props: Props) => {
}
if (type === 'ERRORS') {
showModal(<ErrorDetailsModal errorId={pointer.errorId} />, { right: true, width: 1200 });
showModal(<ErrorDetailsModal errorId={pointer.errorId} />, {
right: true,
width: 1200,
});
}
if (type === 'EVENT') {
showModal(<StackEventModal event={pointer} />, { right: true, width: 450 });
showModal(<StackEventModal event={pointer} />, {
right: true,
width: 450,
});
}
if (type === NETWORK) {
if (type === 'NETWORK') {
if (pointer.tp === 'graph_ql') {
showModal(<GraphQLDetailsModal resource={pointer} />, { right: true, width: 500 });
showModal(<GraphQLDetailsModal resource={pointer} />, {
right: true,
width: 500,
});
} else {
showModal(<FetchDetails resource={pointer} fetchPresented={props.fetchPresented} />, { right: true, width: 500 });
showModal(
<FetchDetails
resource={pointer}
fetchPresented={props.fetchPresented}
/>,
{ right: true, width: 500 }
);
}
}
// props.toggleBottomBlock(type);
};
const renderNetworkElement = (item: any) => {
const name = item.name || '';
if (isGrouped) {
const onClick = createEventClickHandler(item[0], type);
return <GroupedIssue type={type} items={item} onClick={onClick} createEventClickHandler={createEventClickHandler} />;
}
if (type === 'NETWORK') {
return (
<Tooltip
title={
<div className="">
<b>{item.success ? 'Slow resource: ' : '4xx/5xx Error:'}</b>
<br />
{name.length > 200
? name.slice(0, 100) + ' ... ' + name.slice(-50)
: name.length > 200
? item.name.slice(0, 100) + ' ... ' + item.name.slice(-50)
: item.name}
</div>
}
>
<div onClick={createEventClickHandler(item, NETWORK)} className="cursor-pointer">
<div className="h-4 w-4 rounded-full bg-red text-white font-bold flex items-center justify-center text-sm">
<span>!</span>
</div>
</div>
</Tooltip>
<NetworkElement
item={item}
createEventClickHandler={createEventClickHandler}
/>
);
};
const renderFrustrationElement = (item: any) => {
const elData = { name: '', icon: ''}
if (item.type === TYPES.CLICK) Object.assign(elData, { name: `User hesitated to click for ${Math.round(item.hesitation/1000)}s`, icon: 'click-hesitation' })
if (item.type === TYPES.INPUT) Object.assign(elData, { name: `User hesitated to enter a value for ${Math.round(item.hesitation/1000)}s`, icon: 'input-hesitation' })
if (item.type === TYPES.CLICKRAGE || item.type === TYPES.TAPRAGE) Object.assign(elData, { name: 'Click Rage', icon: 'click-rage' })
if (item.type === TYPES.DEAD_LICK) Object.assign(elData, { name: 'Dead Click', icon: 'emoji-dizzy' })
if (item.type === issueTypes.MOUSE_THRASHING) Object.assign(elData, { name: 'Mouse Thrashing', icon: 'cursor-trash' })
if (item.type === 'ios_perf_event') Object.assign(elData, { name: item.name, icon: item.icon })
}
if (type === 'FRUSTRATIONS') {
return (
<Tooltip
title={
<div className="">
<b>{elData.name}</b>
</div>
}
>
<div onClick={createEventClickHandler(item, null)} className="cursor-pointer">
<Icon name={elData.icon} color="black" size="16" />
</div>
</Tooltip>
<FrustrationElement
item={item}
createEventClickHandler={createEventClickHandler}
/>
);
};
const renderStackEventElement = (item: any) => {
}
if (type === 'ERRORS') {
return (
<Tooltip
title={
<div className="">
<b>{item.name || 'Stack Event'}</b>
</div>
}
>
<div
onClick={createEventClickHandler(item, 'EVENT')}
className="cursor-pointer w-1 h-4 bg-red"
>
{/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */}
</div>
</Tooltip>
<ExceptionElement
item={item}
createEventClickHandler={createEventClickHandler}
/>
);
};
const renderPerformanceElement = (item: any) => {
}
if (type === 'EVENTS') {
return (
<Tooltip
title={
<div className="">
<b>{item.type}</b>
</div>
}
>
<div
onClick={createEventClickHandler(item, EXCEPTIONS)}
className="cursor-pointer w-1 h-4 bg-red"
>
{/* <Icon className="rounded-full bg-white" name="funnel/exclamation-circle-fill" color="red" size="16" /> */}
</div>
</Tooltip>
<StackEventElement
item={item}
createEventClickHandler={createEventClickHandler}
/>
);
};
}
const renderExceptionElement = (item: any) => {
if (type === 'PERFORMANCE') {
return (
<Tooltip
title={
<div className="">
<b>{'Exception'}</b>
<br />
<span>{item.message}</span>
</div>
}
>
<div onClick={createEventClickHandler(item, 'ERRORS')} className="cursor-pointer">
<div className="h-4 w-4 rounded-full bg-red text-white font-bold flex items-center justify-center text-sm">
<span>!</span>
</div>
</div>
</Tooltip>
<PerformanceElement
item={item}
createEventClickHandler={createEventClickHandler}
/>
);
};
}
const render = () => {
const { pointer, type } = props;
if (type === 'NETWORK') {
return renderNetworkElement(pointer);
}
if (type === 'FRUSTRATIONS') {
return renderFrustrationElement(pointer);
}
if (type === 'ERRORS') {
return renderExceptionElement(pointer);
}
if (type === 'EVENTS') {
return renderStackEventElement(pointer);
}
if (type === 'PERFORMANCE') {
return renderPerformanceElement(pointer);
}
};
return <div>{render()}</div>;
return <div>unknown type</div>;
});
function GroupedIssue({
type,
items,
onClick,
createEventClickHandler,
}: {
type: string;
items: Record<string, any>[];
onClick: () => void;
createEventClickHandler: any;
}) {
const subStr = {
NETWORK: 'Network Issues',
ERRORS: 'Errors',
EVENTS: 'Events',
FRUSTRATIONS: 'Frustrations',
};
const title = `${items.length} ${subStr[type]} Observed`;
return (
<Popover
placement={'right'}
title={title}
content={
<div style={{ maxHeight: 160, overflowY: 'auto' }}>
{items.map((pointer) => (
<div
key={pointer.time}
onClick={createEventClickHandler(pointer, type)}
className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'}
>
<div className={'text-disabled-text'}>@{shortDurationFromMs(pointer.time)}</div>
<RenderLineData type={type} item={pointer} />
</div>
))}
</div>
}
>
<div
onClick={onClick}
className={
'h-5 w-5 cursor-pointer rounded-full bg-red text-white font-bold flex items-center justify-center text-sm'
}
>
{items.length}
</div>
</Popover>
);
}
function RenderLineData({ item, type }: any) {
if (type === 'FRUSTRATIONS') {
const elData = getFrustration(item);
return <>
<div><Icon name={elData.icon} color="black" size="16" /></div>
<div>{elData.name}</div>
</>
}
if (type === 'NETWORK') {
const name = item.success ? 'Slow resource' : '4xx/5xx Error';
return <>
<div>{name}</div>
<div>{shortenResourceName(item.name)}</div>
</>
}
if (type === 'EVENTS') {
return <div>{item.name || 'Stack Event'}</div>
}
if (type === 'PERFORMANCE') {
return <div>{item.type}</div>
}
if (type === 'ERRORS') {
return <div>{item.message}</div>
}
return <div>{JSON.stringify(item)}</div>
}
export default TimelinePointer;

View file

@ -0,0 +1,80 @@
const zoomStartTime = 100
// Generate fake fetchList data for NETWORK
const fetchList: any[] = [];
for (let i = 0; i < 100; i++) {
const statusOptions = [200, 200, 200, 404, 500]; // Higher chance of 200
const status = statusOptions[Math.floor(Math.random() * statusOptions.length)];
const isRed = status >= 500;
const isYellow = status >= 400 && status < 500;
const resource = {
time: zoomStartTime + i * 1000 + Math.floor(Math.random() * 500), // Incremental time with randomness
name: `https://api.example.com/resource/${i}`,
status: status,
isRed: isRed,
isYellow: isYellow,
success: status < 400,
tp: Math.random() > 0.5 ? 'graph_ql' : 'fetch',
// Additional properties used by your component
method: 'GET',
duration: Math.floor(Math.random() * 3000) + 500, // Duration between 500ms to 3.5s
};
fetchList.push(resource);
}
// Generate fake exceptionsList data for ERRORS
const exceptionsList: any[] = [];
for (let i = 0; i < 50; i++) {
const exception = {
time: zoomStartTime + i * 2000 + Math.floor(Math.random() * 1000),
message: `Error message ${i}`,
errorId: `error-${i}`,
type: 'ERRORS',
// Additional properties if needed
stackTrace: `Error at function ${i} in file${i}.js`,
};
exceptionsList.push(exception);
}
// Generate fake eventsList data for EVENTS
const eventsList: any[] = [];
for (let i = 0; i < 50; i++) {
const event = {
time: zoomStartTime + i * 1500 + Math.floor(Math.random() * 500),
name: `Custom Event ${i}`,
type: 'EVENTS',
// Additional properties if needed
details: `Details about event ${i}`,
};
eventsList.push(event);
}
// Generate fake performanceChartData data for PERFORMANCE
const performanceChartData: any[] = [];
const performanceTypes = ['SLOW_PAGE_LOAD', 'HIGH_MEMORY_USAGE'];
for (let i = 0; i < 30; i++) {
const performanceEvent = {
time: zoomStartTime + i * 3000 + Math.floor(Math.random() * 1500),
type: performanceTypes[Math.floor(Math.random() * performanceTypes.length)],
// Additional properties if needed
value: Math.floor(Math.random() * 1000) + 500, // Random value
};
performanceChartData.push(performanceEvent);
}
// Generate fake frustrationsList data for FRUSTRATIONS
const frustrationsList: any[] = [];
const frustrationEventTypes = ['CLICK', 'INPUT', 'CLICKRAGE', 'DEAD_CLICK', 'MOUSE_THRASHING'];
for (let i = 0; i < 70; i++) {
const frustrationEvent = {
time: zoomStartTime + i * 1200 + Math.floor(Math.random() * 600),
type: frustrationEventTypes[Math.floor(Math.random() * frustrationEventTypes.length)],
hesitation: Math.floor(Math.random() * 5000) + 1000, // 1s to 6s
// Additional properties if needed
details: `Frustration event ${i}`,
};
frustrationsList.push(frustrationEvent);
}
export const resources = {
NETWORK: fetchList.filter((r: any) => r.status >= 400 || r.isRed || r.isYellow),
ERRORS: exceptionsList,
EVENTS: eventsList,
PERFORMANCE: performanceChartData,
FRUSTRATIONS: frustrationsList,
};