diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx index 8228c0495..d7312fdc0 100644 --- a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -319,10 +319,11 @@ function PanelComponent({ isGraph={feature === 'PERFORMANCE'} title={feature} list={resources[feature]} - renderElement={(pointer: any) => ( + renderElement={(pointer: any[], isGrouped: boolean) => ( )} diff --git a/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx index e063daddb..784d53d3f 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/EventRow/EventRow.tsx @@ -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 ? ( ) : _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 (
- {props.renderElement ? props.renderElement(item) : null} + {props.renderElement ? props.renderElement(item.items, item.isGrouped) : null}
); }) diff --git a/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx index 8b802953d..2a02bcfa5 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/OverviewPanelContainer/OverviewPanelContainer.tsx @@ -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) { diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/Dots.tsx b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/Dots.tsx new file mode 100644 index 000000000..51db59065 --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/Dots.tsx @@ -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 ( + + {item.success ? 'Slow resource: ' : '4xx/5xx Error:'} +
+ {shortenResourceName(name)} + + } + > +
+
+ ! +
+
+
+ ); +} + +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 ( + + {elData.name} + + } + > +
+ +
+
+ ); +} + +export function StackEventElement({ item, createEventClickHandler }: CommonProps) { + return ( + + {item.name || 'Stack Event'} + + } + > +
+ {/* */} +
+
+ ); +} + +export function PerformanceElement({ item, createEventClickHandler }: CommonProps) { + return ( + + {item.type} + + } + > +
+ {/* */} +
+
+ ); +} + +export function ExceptionElement({ item, createEventClickHandler }: CommonProps) { + return ( + + {'Exception'} +
+ {item.message} + + } + > +
+
+ ! +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx index 028f54922..cc7f13f63 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx @@ -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(, { right: true, width: 1200 }); + showModal(, { + right: true, + width: 1200, + }); } if (type === 'EVENT') { - showModal(, { right: true, width: 450 }); + showModal(, { + right: true, + width: 450, + }); } - if (type === NETWORK) { + if (type === 'NETWORK') { if (pointer.tp === 'graph_ql') { - showModal(, { right: true, width: 500 }); + showModal(, { + right: true, + width: 500, + }); } else { - showModal(, { right: true, width: 500 }); + showModal( + , + { right: true, width: 500 } + ); } } - // props.toggleBottomBlock(type); }; - const renderNetworkElement = (item: any) => { - const name = item.name || ''; + if (isGrouped) { + const onClick = createEventClickHandler(item[0], type); + return ; + } + + if (type === 'NETWORK') { return ( - - {item.success ? 'Slow resource: ' : '4xx/5xx Error:'} -
- {name.length > 200 - ? name.slice(0, 100) + ' ... ' + name.slice(-50) - : name.length > 200 - ? item.name.slice(0, 100) + ' ... ' + item.name.slice(-50) - : item.name} - - } - > -
-
- ! -
-
-
+ ); - }; - - 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 ( - - {elData.name} - - } - > -
- -
-
+ ); - }; - - const renderStackEventElement = (item: any) => { + } + if (type === 'ERRORS') { return ( - - {item.name || 'Stack Event'} - - } - > -
- {/* */} -
-
+ ); - }; - - const renderPerformanceElement = (item: any) => { + } + if (type === 'EVENTS') { return ( - - {item.type} - - } - > -
- {/* */} -
-
+ ); - }; + } - const renderExceptionElement = (item: any) => { + if (type === 'PERFORMANCE') { return ( - - {'Exception'} -
- {item.message} - - } - > -
-
- ! -
-
-
+ ); - }; + } - 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
{render()}
; + return
unknown type
; }); +function GroupedIssue({ + type, + items, + onClick, + createEventClickHandler, +}: { + type: string; + items: Record[]; + onClick: () => void; + createEventClickHandler: any; +}) { + const subStr = { + NETWORK: 'Network Issues', + ERRORS: 'Errors', + EVENTS: 'Events', + FRUSTRATIONS: 'Frustrations', + }; + const title = `${items.length} ${subStr[type]} Observed`; + + return ( + + {items.map((pointer) => ( +
+
@{shortDurationFromMs(pointer.time)}
+ +
+ ))} + + } + > +
+ {items.length} +
+
+ ); +} + +function RenderLineData({ item, type }: any) { + if (type === 'FRUSTRATIONS') { + const elData = getFrustration(item); + return <> +
+
{elData.name}
+ + } + if (type === 'NETWORK') { + const name = item.success ? 'Slow resource' : '4xx/5xx Error'; + return <> +
{name}
+
{shortenResourceName(item.name)}
+ + } + if (type === 'EVENTS') { + return
{item.name || 'Stack Event'}
+ } + if (type === 'PERFORMANCE') { + return
{item.type}
+ } + if (type === 'ERRORS') { + return
{item.message}
+ } + return
{JSON.stringify(item)}
+} + export default TimelinePointer; diff --git a/frontend/app/components/Session_/OverviewPanel/fakeData.ts b/frontend/app/components/Session_/OverviewPanel/fakeData.ts new file mode 100644 index 000000000..0f3e42c3c --- /dev/null +++ b/frontend/app/components/Session_/OverviewPanel/fakeData.ts @@ -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, +};