feat(ui): timeline zoom (#1982)

* feat(ui): timeline zoom

* stable draggable markers

* integrate zoom into panels, ready up ai stuff for zoom

* tabs for ai, slider styles

* fixes for zoom tabs

* code style
This commit is contained in:
Delirium 2024-03-22 15:17:46 +01:00 committed by GitHub
parent d2cabcdb54
commit 80f0005362
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1035 additions and 288 deletions

View file

@ -2,5 +2,8 @@
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"singleQuote": true
"singleQuote": true,
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrder": ["^Components|^App|^UI|^Duck", "^Shared", "^[./]"]
}

View file

@ -1,36 +1,107 @@
import React from 'react';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { connect } from 'react-redux';
import { useStore } from 'App/mstore';
import { debounce } from 'App/utils';
import { IResourceRequest, IResourceTiming } from "../../../../../player";
import { WsChannel } from "../../../../../player/web/messages";
import { PlayerContext } from "../../../playerContext";
let debounceUpdate: any = () => {};
const userBehaviorRegex = /User\s+(\w+\s+)?Behavior/i;
const issuesErrorsRegex = /Issues\s+(and\s+|,?\s+)?(\w+\s+)?Errors/i;
function testLine(line: string): boolean {
function isTitleLine(line: string): boolean {
return userBehaviorRegex.test(line) || issuesErrorsRegex.test(line);
}
function SummaryBlock({ sessionId }: { sessionId: string }) {
function SummaryBlock({
sessionId,
zoomEnabled,
zoomStartTs,
zoomEndTs,
zoomTab,
duration,
}: {
sessionId: string;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
zoomTab: 'overview' | 'journey' | 'issues' | 'errors';
duration: any;
}) {
const { store } = React.useContext(PlayerContext)
const { tabStates } = store.get();
const { aiSummaryStore } = useStore();
React.useEffect(() => {
void aiSummaryStore.getSummary(sessionId);
debounceUpdate = debounce(
(
sessionId: string,
events: any[],
feat: 'journey' | 'issues' | 'errors',
startTs: number,
endTs: number
) => aiSummaryStore.getDetailedSummary(sessionId, events, feat, startTs, endTs),
500
);
}, []);
React.useEffect(() => {
if (zoomTab === 'overview') {
void aiSummaryStore.getSummary(sessionId);
} else {
const totalFetchList: IResourceRequest[] = [];
const totalResourceList: IResourceTiming[] = [];
const totalWebsocketList: WsChannel[] = [];
Object.values(tabStates).forEach(({
fetchList,
resourceList,
websocketList,
}) => {
totalFetchList.push(...fetchList);
totalResourceList.push(...resourceList);
totalWebsocketList.push(...websocketList);
})
const resultingEvents = [
...totalFetchList,
...totalResourceList,
...totalWebsocketList,
]
const range = !zoomEnabled ? [0, duration] : [zoomStartTs, zoomEndTs];
void debounceUpdate(sessionId, resultingEvents, zoomTab, range[0], range[1]);
}
}, [zoomTab]);
const formattedText = aiSummaryStore.text.split('\n').map((line) => {
if (testLine(line)) {
if (isTitleLine(line)) {
return <div className={'font-semibold mt-2'}>{line}</div>;
}
if (line.startsWith('*')) {
return <li className={'ml-1 marker:mr-1'}>{line.replace('* ', '')}</li>;
return (
<li className={'ml-1 marker:mr-1 flex items-center gap-1'}>
<CodeStringFormatter text={line.replace('* ', '')} />
</li>
);
}
return <div>{line}</div>;
return (
<div className={'flex items-center gap-1'}>
<CodeStringFormatter text={line} />
</div>
);
});
return (
<div style={summaryBlockStyle}>
<div className={'flex items-center gap-2 px-2 py-1 rounded border border-gray-light bg-white w-fit'}>
User Behavior Analysis
</div>
{/*<div*/}
{/* className={*/}
{/* 'flex items-center gap-2 px-2 py-1 rounded border border-gray-light bg-white w-fit'*/}
{/* }*/}
{/*>*/}
{/* User Behavior Analysis*/}
{/*</div>*/}
{aiSummaryStore.text ? (
<div className={'rounded p-4 bg-white whitespace-pre-wrap flex flex-col'}>
@ -66,6 +137,20 @@ function TextPlaceholder() {
);
}
const CodeStringFormatter = ({ text }: { text: string }) => {
const parts = text.split(/(`[^`]*`)/).map((part, index) =>
part.startsWith('`') && part.endsWith('`') ? (
<div key={index} className="whitespace-nowrap bg-gray-lightest font-mono mx-1 px-1 border">
{part.substring(1, part.length - 1)}
</div>
) : (
<span key={index}>{part}</span>
)
);
return <>{parts}</>;
};
const summaryBlockStyle: React.CSSProperties = {
background: 'linear-gradient(180deg, #E8EBFF -24.14%, rgba(236, 254, 255, 0.00) 100%)',
width: '100%',
@ -77,4 +162,10 @@ const summaryBlockStyle: React.CSSProperties = {
padding: '1rem',
};
export default observer(SummaryBlock);
export default connect((state: Record<string, any>) => ({
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
zoomTab: state.getIn(['components', 'player']).zoomTab,
duration: state.getIn(['sessions', 'current']).durationSeconds,
}))(observer(SummaryBlock));

View file

@ -26,6 +26,9 @@ interface IProps {
filterOutNote: (id: string) => void;
eventsIndex: number[];
uxtVideo: string;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}
function EventsBlock(props: IProps) {
@ -82,8 +85,21 @@ function EventsBlock(props: IProps) {
return mergeEventLists(
filteredLength > 0 ? filteredEvents : eventsWithMobxNotes,
tabChangeEvents
).filter((e) =>
props.zoomEnabled
? 'time' in e
? e.time >= props.zoomStartTs && e.time <= props.zoomEndTs
: false
: true
);
}, [filteredLength, notesWithEvtsLength, notesLength]);
}, [
filteredLength,
notesWithEvtsLength,
notesLength,
props.zoomEnabled,
props.zoomStartTs,
props.zoomEndTs,
]);
const write = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
props.setEventFilter({ query: value });
@ -180,8 +196,20 @@ function EventsBlock(props: IProps) {
<div className={cn(styles.header, 'p-4')}>
{uxtestingStore.isUxt() ? (
<div style={{ width: 240, height: 130 }} className={'relative'}>
<video className={'z-20 fixed'} muted autoPlay controls src={props.uxtVideo} width={240} />
<div style={{ top: '40%', left: '50%', transform: 'translate(-50%, -50%)' }} className={'absolute z-10'}>No video</div>
<video
className={'z-20 fixed'}
muted
autoPlay
controls
src={props.uxtVideo}
width={240}
/>
<div
style={{ top: '40%', left: '50%', transform: 'translate(-50%, -50%)' }}
className={'absolute z-10'}
>
No video
</div>
</div>
) : null}
<div className={cn(styles.hAndProgress, 'mt-3')}>
@ -233,6 +261,9 @@ export default connect(
filteredEvents: state.getIn(['sessions', 'filteredEvents']),
query: state.getIn(['sessions', 'eventsQuery']),
eventsIndex: state.getIn(['sessions', 'eventsIndex']),
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}),
{
setEventFilter,

View file

@ -1,27 +1,40 @@
import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock';
import { SummaryButton } from 'Components/Session_/Player/Controls/Controls';
import React, { useEffect } from 'react';
import { toggleBottomBlock } from 'Duck/components/player';
import BottomBlock from '../BottomBlock';
import EventRow from './components/EventRow';
import { connect } from 'react-redux';
import TimelineScale from './components/TimelineScale';
import FeatureSelection, { HELP_MESSAGE } from './components/FeatureSelection/FeatureSelection';
import TimelinePointer from './components/TimelinePointer';
import VerticalPointerLine from './components/VerticalPointerLine';
import cn from 'classnames';
import OverviewPanelContainer from './components/OverviewPanelContainer';
import { NoContent, Icon } from 'UI';
import { observer } from 'mobx-react-lite';
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { Segmented } from 'antd'
import { MobilePlayerContext, PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from 'App/mstore';
import SummaryBlock from 'Components/Session/Player/ReplayPlayer/SummaryBlock';
import { SummaryButton } from 'Components/Session_/Player/Controls/Controls';
import { toggleBottomBlock, setZoomTab } from 'Duck/components/player';
import { Icon, NoContent } from 'UI';
import BottomBlock from '../BottomBlock';
import EventRow from './components/EventRow';
import FeatureSelection, { HELP_MESSAGE } from './components/FeatureSelection/FeatureSelection';
import OverviewPanelContainer from './components/OverviewPanelContainer';
import TimelinePointer from './components/TimelinePointer';
import TimelineScale from './components/TimelineScale';
import VerticalPointerLine from './components/VerticalPointerLine';
function MobileOverviewPanelCont({
issuesList,
sessionId,
zoomEnabled,
zoomStartTs,
zoomEndTs,
setZoomTab,
zoomTab
}: {
issuesList: Record<string, any>[];
sessionId: string;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
setZoomTab: (tab: string) => void;
zoomTab: 'overview' | 'journey' | 'issues' | 'errors'
}) {
const { aiSummaryStore } = useStore();
const { store, player } = React.useContext(MobilePlayerContext);
@ -45,12 +58,18 @@ function MobileOverviewPanelCont({
const fetchPresented = fetchList.length > 0;
const checkInZoomRange = (list: any[]) => {
return list.filter((i) => (zoomEnabled ? i.time >= zoomStartTs && i.time <= zoomEndTs : true));
};
const resources = {
NETWORK: fetchList.filter((r: any) => r.status >= 400 || r.isRed || r.isYellow),
ERRORS: exceptionsList,
EVENTS: eventsList,
PERFORMANCE: performanceChartData,
FRUSTRATIONS: frustrationsList,
NETWORK: checkInZoomRange(
fetchList.filter((r: any) => r.status >= 400 || r.isRed || r.isYellow)
),
ERRORS: checkInZoomRange(exceptionsList),
EVENTS: checkInZoomRange(eventsList),
PERFORMANCE: checkInZoomRange(performanceChartData),
FRUSTRATIONS: checkInZoomRange(frustrationsList),
};
useEffect(() => {
@ -88,11 +107,27 @@ function MobileOverviewPanelCont({
showSummary={isSaas}
toggleSummary={() => aiSummaryStore.setToggleSummary(!aiSummaryStore.toggleSummary)}
summaryChecked={aiSummaryStore.toggleSummary}
setZoomTab={setZoomTab}
zoomTab={zoomTab}
/>
);
}
function WebOverviewPanelCont({ sessionId }: { sessionId: string }) {
function WebOverviewPanelCont({
sessionId,
zoomEnabled,
zoomStartTs,
zoomEndTs,
setZoomTab,
zoomTab,
}: {
sessionId: string;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
setZoomTab: (tab: string) => void;
zoomTab: 'overview' | 'journey' | 'issues' | 'errors'
}) {
const { aiSummaryStore } = useStore();
const { store } = React.useContext(PlayerContext);
const [selectedFeatures, setSelectedFeatures] = React.useState([
@ -121,15 +156,19 @@ function WebOverviewPanelCont({ sessionId }: { sessionId: string }) {
.concat(graphqlList.filter((i: any) => parseInt(i.status) >= 400))
.filter((i: any) => i.type === 'fetch');
const checkInZoomRange = (list: any[]) => {
return list.filter((i) => (zoomEnabled ? i.time >= zoomStartTs && i.time <= zoomEndTs : true));
};
const resources: any = React.useMemo(() => {
return {
NETWORK: resourceList,
ERRORS: exceptionsList,
EVENTS: stackEventList,
PERFORMANCE: performanceChartData,
FRUSTRATIONS: frustrationsList,
NETWORK: checkInZoomRange(resourceList),
ERRORS: checkInZoomRange(exceptionsList),
EVENTS: checkInZoomRange(stackEventList),
PERFORMANCE: checkInZoomRange(performanceChartData),
FRUSTRATIONS: checkInZoomRange(frustrationsList),
};
}, [tabStates, currentTab]);
}, [tabStates, currentTab, zoomEnabled, zoomStartTs, zoomEndTs]);
const originStr = window.env.ORIGIN || window.location.origin;
const isSaas = /app\.openreplay\.com/.test(originStr);
@ -144,6 +183,8 @@ function WebOverviewPanelCont({ sessionId }: { sessionId: string }) {
toggleSummary={() => aiSummaryStore.setToggleSummary(!aiSummaryStore.toggleSummary)}
summaryChecked={aiSummaryStore.toggleSummary}
sessionId={sessionId}
setZoomTab={setZoomTab}
zoomTab={zoomTab}
/>
);
}
@ -160,6 +201,8 @@ function PanelComponent({
toggleSummary,
summaryChecked,
sessionId,
zoomTab,
setZoomTab,
}: any) {
return (
<React.Fragment>
@ -168,7 +211,31 @@ function PanelComponent({
<div className="mr-4 flex items-center gap-2">
<span className={'font-semibold text-black'}>X-Ray</span>
{showSummary ? (
<SummaryButton withToggle onClick={toggleSummary} toggleValue={summaryChecked} />
<>
<SummaryButton withToggle onClick={toggleSummary} toggleValue={summaryChecked} />
<Segmented
value={zoomTab}
onChange={(val) => setZoomTab(val)}
options={[
{
label: 'Overview',
value: 'overview',
},
{
label: 'User Journey',
value: 'journey',
},
{
label: 'Issues',
value: 'issues',
},
{
label: 'Suggestions',
value: 'errors',
}
]}
/>
</>
) : null}
</div>
<div className="flex items-center h-20 mr-4">
@ -247,9 +314,12 @@ export const OverviewPanel = connect(
(state: Record<string, any>) => ({
issuesList: state.getIn(['sessions', 'current']).issues,
sessionId: state.getIn(['sessions', 'current']).sessionId,
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}),
{
toggleBottomBlock,
toggleBottomBlock, setZoomTab
}
)(observer(WebOverviewPanelCont));
@ -257,8 +327,12 @@ export const MobileOverviewPanel = connect(
(state: Record<string, any>) => ({
issuesList: state.getIn(['sessions', 'current']).issues,
sessionId: state.getIn(['sessions', 'current']).sessionId,
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
zoomTab: state.getIn(['components', 'player']).zoomTab
}),
{
toggleBottomBlock,
toggleBottomBlock, setZoomTab,
}
)(observer(MobileOverviewPanelCont));

View file

@ -75,8 +75,15 @@ function Controls(props: any) {
const { player, store } = React.useContext(PlayerContext);
const { uxtestingStore } = useStore();
const { playing, completed, skip, speed, messagesLoading, markedTargets, inspectorMode } =
store.get();
const {
playing,
completed,
skip,
speed,
messagesLoading,
markedTargets,
inspectorMode,
} = store.get();
const {
bottomBlock,

View file

@ -1,9 +1,8 @@
import DraggableMarkers from 'Components/Session_/Player/Controls/components/ZoomDragLayer';
import React, { useEffect, useMemo, useContext, useState, useRef } from 'react';
import { connect } from 'react-redux';
import TimeTracker from './TimeTracker';
import stl from './timeline.module.css';
import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions';
import DraggableCircle from './components/DraggableCircle';
import CustomDragLayer, { OnDragCallback } from './components/CustomDragLayer';
import { debounce } from 'App/utils';
import TooltipContainer from './components/TooltipContainer';
@ -11,37 +10,33 @@ import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { DateTime, Duration } from 'luxon';
import Issue from "Types/session/issue";
import Issue from 'Types/session/issue';
import { WebEventsList, MobEventsList } from './EventsList';
import NotesList from './NotesList';
import SkipIntervalsList from './SkipIntervalsList'
import TimelineTracker from "Components/Session_/Player/Controls/TimelineTracker";
import SkipIntervalsList from './SkipIntervalsList';
import TimelineTracker from 'Components/Session_/Player/Controls/TimelineTracker';
interface IProps {
issues: Issue[]
setTimelineHoverTime: (t: number) => void
startedAt: number
tooltipVisible: boolean
timezone?: string
isMobile?: boolean
issues: Issue[];
setTimelineHoverTime: (t: number) => void;
startedAt: number;
tooltipVisible: boolean;
timezone?: string;
isMobile?: boolean;
timelineZoomEnabled: boolean;
timelineZoomStartTs: number;
timelineZoomEndTs: number;
}
function Timeline(props: IProps) {
const { player, store } = useContext(PlayerContext)
const [wasPlaying, setWasPlaying] = useState(false)
const { player, store } = useContext(PlayerContext);
const [wasPlaying, setWasPlaying] = useState(false);
const { settingsStore } = useStore();
const {
playing,
skipToIssue,
ready,
endTime,
devtoolsLoading,
domLoading,
} = store.get()
const { issues, timezone } = props;
const { playing, skipToIssue, ready, endTime, devtoolsLoading, domLoading } = store.get();
const { issues, timezone, timelineZoomEnabled } = props;
const progressRef = useRef<HTMLDivElement>(null)
const timelineRef = useRef<HTMLDivElement>(null)
const progressRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const scale = 100 / endTime;
@ -51,10 +46,10 @@ function Timeline(props: IProps) {
if (firstIssue && skipToIssue) {
player.jump(firstIssue.time);
}
}, [])
}, []);
const debouncedJump = useMemo(() => debounce(player.jump, 500), [])
const debouncedTooltipChange = useMemo(() => debounce(props.setTimelineHoverTime, 50), [])
const debouncedJump = useMemo(() => debounce(player.jump, 500), []);
const debouncedTooltipChange = useMemo(() => debounce(props.setTimelineHoverTime, 50), []);
const onDragEnd = () => {
if (wasPlaying) {
@ -64,31 +59,37 @@ function Timeline(props: IProps) {
const onDrag: OnDragCallback = (offset) => {
// @ts-ignore react mismatch
const p = (offset.x) / progressRef.current.offsetWidth;
const p = offset.x / progressRef.current.offsetWidth;
const time = Math.max(Math.round(p * endTime), 0);
debouncedJump(time);
hideTimeTooltip();
if (playing) {
setWasPlaying(true)
setWasPlaying(true);
player.pause();
}
};
const showTimeTooltip = (e: React.MouseEvent<HTMLDivElement>) => {
if (
e.target !== progressRef.current
&& e.target !== timelineRef.current
e.target !== progressRef.current &&
e.target !== timelineRef.current &&
// @ts-ignore black magic
&& !progressRef.current.contains(e.target)
!progressRef.current.contains(e.target)
) {
return props.tooltipVisible && hideTimeTooltip();
}
const time = getTime(e);
if (!time) return;
const tz = settingsStore.sessionSettings.timezone.value
const timeStr = DateTime.fromMillis(props.startedAt + time).setZone(tz).toFormat(`hh:mm:ss a`)
const userTimeStr = timezone ? DateTime.fromMillis(props.startedAt + time).setZone(timezone).toFormat(`hh:mm:ss a`) : undefined
const tz = settingsStore.sessionSettings.timezone.value;
const timeStr = DateTime.fromMillis(props.startedAt + time)
.setZone(tz)
.toFormat(`hh:mm:ss a`);
const userTimeStr = timezone
? DateTime.fromMillis(props.startedAt + time)
.setZone(timezone)
.toFormat(`hh:mm:ss a`)
: undefined;
const timeLineTooltip = {
time: Duration.fromMillis(time).toFormat(`mm:ss`),
@ -99,7 +100,7 @@ function Timeline(props: IProps) {
};
debouncedTooltipChange(timeLineTooltip);
}
};
const hideTimeTooltip = () => {
const timeLineTooltip = { isVisible: false };
@ -134,6 +135,7 @@ function Timeline(props: IProps) {
left: '0.5rem',
}}
>
{timelineZoomEnabled ? <DraggableMarkers scale={scale} /> : null}
<div
className={stl.progress}
onClick={ready ? jumpToTime : undefined}
@ -179,6 +181,7 @@ export default connect(
startedAt: state.getIn(['sessions', 'current']).startedAt || 0,
timezone: state.getIn(['sessions', 'current']).timezone,
tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible']),
timelineZoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
}),
{ setTimelinePointer, setTimelineHoverTime }
)(observer(Timeline))
)(observer(Timeline));

View file

@ -3,6 +3,7 @@ import { Icon } from 'UI';
import { Button } from 'antd';
import PlayingTime from './PlayingTime';
import { JumpBack, IntervalSelector, JumpForward, SpeedOptions } from './ControlsComponents';
import TimelineZoomButton from 'Components/Session_/Player/Controls/components/TimelineZoomButton';
interface Props {
skip: boolean;
@ -80,15 +81,14 @@ function PlayerControls(props: Props) {
<JumpForward forthTenSeconds={forthTenSeconds} currentInterval={currentInterval} />
</div>
<div className="flex items-center">
<div className="mx-1" />
<div className="flex items-center gap-2 ml-2">
<TimelineZoomButton />
<SpeedOptions
toggleSpeed={toggleSpeed}
disabled={disabled}
toggleTooltip={toggleTooltip}
speed={speed}
/>
<div className="mx-1" />
<Button
onClick={toggleSkip}
disabled={disabled}

View file

@ -0,0 +1,43 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button } from 'antd';
import { toggleZoom } from 'Duck/components/player';
import { PlayerContext } from 'Components/Session/playerContext';
import { observer } from 'mobx-react-lite';
interface Props {
enabled: boolean;
startTs: number;
endTs: number;
toggleZoom: typeof toggleZoom;
}
function TimelineZoomButton({ enabled, toggleZoom }: Props) {
const { store } = React.useContext(PlayerContext);
const onClickHandler = () => {
// 2% of the timeline * 2 as initial zoom range
const distance = store.get().endTime / 50;
toggleZoom({
enabled: !enabled,
range: [
Math.max(store.get().time - distance, 0),
Math.min(store.get().time + distance, store.get().endTime),
],
});
};
return (
<Button onClick={onClickHandler} size={'small'} className={'flex items-center font-semibold'}>
Timeline Zoom {enabled ? 'On' : 'Off'}
</Button>
);
}
export default connect(
(state: Record<string, any>) => ({
enabled: state.getIn(['components', 'player']).timelineZoom.enabled,
startTs: state.getIn(['components', 'player']).timelineZoom.startTs,
endTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}),
{ toggleZoom }
)(observer(TimelineZoomButton));

View file

@ -0,0 +1,161 @@
import React, { useCallback, useState } from 'react';
import { connect } from 'react-redux';
import { getTimelinePosition } from 'Components/Session_/Player/Controls/getTimelinePosition';
import { toggleZoom } from 'Duck/components/player';
interface Props {
timelineZoomStartTs: number;
timelineZoomEndTs: number;
scale: number;
toggleZoom: typeof toggleZoom;
}
const DraggableMarkers = ({ timelineZoomStartTs, timelineZoomEndTs, scale, toggleZoom }: Props) => {
const [startPos, setStartPos] = useState(getTimelinePosition(timelineZoomStartTs, scale));
const [endPos, setEndPos] = useState(getTimelinePosition(timelineZoomEndTs, scale));
const [dragging, setDragging] = useState<string | null>(null);
const convertToPercentage = useCallback((clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
const x = clientX - rect.left;
return (x / rect.width) * 100;
}, []);
const startDrag = useCallback(
(marker: 'start' | 'end' | 'body') => (event: React.MouseEvent) => {
event.stopPropagation();
setDragging(marker);
},
[convertToPercentage, startPos]
);
const minDistance = 1.5;
const onDrag = useCallback(
(event: any) => {
event.stopPropagation();
if (dragging && event.clientX !== 0) {
const newPos = convertToPercentage(event.clientX, event.currentTarget);
if (dragging === 'start') {
setStartPos(newPos);
if (endPos - newPos <= minDistance) {
setEndPos(newPos + minDistance);
}
toggleZoom({ enabled: true, range: [newPos / scale, endPos / scale] });
} else if (dragging === 'end') {
setEndPos(newPos);
if (newPos - startPos <= minDistance) {
setStartPos(newPos - minDistance);
}
toggleZoom({ enabled: true, range: [startPos / scale, newPos / scale] });
} else if (dragging === 'body') {
const offset = (endPos - startPos) / 2;
let newStartPos = newPos - offset;
let newEndPos = newStartPos + (endPos - startPos);
if (newStartPos < 0) {
newStartPos = 0;
newEndPos = endPos - startPos;
} else if (newEndPos > 100) {
newEndPos = 100;
newStartPos = 100 - (endPos - startPos);
}
setStartPos(newStartPos);
setEndPos(newEndPos);
toggleZoom({ enabled: true, range: [newStartPos / scale, newEndPos / scale] });
}
}
},
[dragging, startPos, endPos, scale, toggleZoom]
);
const endDrag = useCallback(() => {
setDragging(null);
}, []);
return (
<div
onMouseMove={onDrag}
onMouseLeave={endDrag}
onMouseUp={endDrag}
style={{
position: 'absolute',
width: '100%',
height: '24px',
left: 0,
top: '-4px',
zIndex: 100,
}}
>
<div
className="marker start"
onMouseDown={startDrag("start")}
style={{
position: "absolute",
left: `${startPos}%`,
height: "100%",
background: "#FCC100",
cursor: "ew-resize",
borderTopLeftRadius: 3,
borderBottomLeftRadius: 3,
zIndex: 100,
display: "flex",
alignItems: "center",
paddingRight: 3,
paddingLeft: 6,
width: 18,
}}
>
<div className={"bg-black rounded-xl"} style={{ zIndex: 101, height: 18, width: 2, marginRight: 3, overflow: "hidden" }} />
<div className={"bg-black rounded-xl"} style={{ zIndex: 101, height: 18, width: 2, overflow: "hidden" }} />
</div>
<div
className="slider-body"
onMouseDown={startDrag("body")}
style={{
position: "absolute",
left: `calc(${startPos}% + 18px)`,
width: `calc(${endPos - startPos}% - 18px)`,
height: '100%',
background: 'rgba(252, 193, 0, 0.10)',
borderTop: '2px solid #FCC100',
borderBottom: '2px solid #FCC100',
cursor: 'grab',
zIndex: 100,
}}
/>
<div
className="marker end"
onMouseDown={startDrag('end')}
style={{
position: 'absolute',
left: `${endPos}%`,
height: '100%',
background: '#FCC100',
cursor: 'ew-resize',
borderTopRightRadius: 3,
borderBottomRightRadius: 3,
zIndex: 100,
display: 'flex',
alignItems: 'center',
paddingLeft: 3,
paddingRight: 6,
width: 18,
}}
>
<div className={'bg-black rounded-xl'} style={{ zIndex: 101, height: 18, width: 2, marginRight: 3, overflow: 'hidden' }} />
<div className={'bg-black rounded-xl'} style={{ zIndex: 101, height: 18, width: 2, overflow: 'hidden' }} />
</div>
</div>
);
};
export default connect(
(state: Record<string, any>) => ({
timelineZoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
timelineZoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}),
{ toggleZoom }
)(DraggableMarkers);

View file

@ -11,8 +11,9 @@ import { useStore } from 'App/mstore';
import ErrorDetailsModal from 'App/components/Dashboard/components/Errors/ErrorDetailsModal';
import { useModal } from 'App/components/Modal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache'
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
import { connect } from 'react-redux';
const ALL = 'ALL';
const INFO = 'INFO';
@ -25,13 +26,17 @@ const LEVEL_TAB = {
[LogLevel.WARN]: WARNINGS,
[LogLevel.ERROR]: ERRORS,
[LogLevel.EXCEPTION]: ERRORS,
} as const
} as const;
const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab }));
function renderWithNL(s: string | null = '') {
if (typeof s !== 'string') return '';
return s.split('\n').map((line, i) => <div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>{line}</div>);
return s.split('\n').map((line, i) => (
<div key={i + line.slice(0, 6)} className={cn({ 'ml-20': i !== 0 })}>
{line}
</div>
));
}
const getIconProps = (level: any) => {
@ -56,62 +61,80 @@ const getIconProps = (level: any) => {
return null;
};
const INDEX_KEY = 'console';
function ConsolePanel({ isLive }: { isLive?: boolean }) {
function ConsolePanel({
isLive,
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
isLive?: boolean;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) {
const {
sessionStore: { devTools },
} = useStore()
} = useStore();
const filter = devTools[INDEX_KEY].filter;
const activeTab = devTools[INDEX_KEY].activeTab;
// Why do we need to keep index in the store? if we could get read of it it would simplify the code
const activeIndex = devTools[INDEX_KEY].index;
const [ isDetailsModalActive, setIsDetailsModalActive ] = useState(false);
const [isDetailsModalActive, setIsDetailsModalActive] = useState(false);
const { showModal } = useModal();
const { player, store } = React.useContext(PlayerContext)
const jump = (t: number) => player.jump(t)
const { player, store } = React.useContext(PlayerContext);
const jump = (t: number) => player.jump(t);
const { currentTab, tabStates } = store.get()
const { logList = [], exceptionsList = [], logListNow = [], exceptionsListNow = [] } = tabStates[currentTab]
const { currentTab, tabStates } = store.get();
const {
logList = [],
exceptionsList = [],
logListNow = [],
exceptionsListNow = [],
} = tabStates[currentTab];
const list = isLive ?
useMemo(() => logListNow.concat(exceptionsListNow).sort((a, b) => a.time - b.time),
[logListNow.length, exceptionsListNow.length]
) as ILog[]
: useMemo(() => logList.concat(exceptionsList).sort((a, b) => a.time - b.time),
[ logList.length, exceptionsList.length ],
) as ILog[]
let filteredList = useRegExListFilterMemo(list, l => l.value, filter)
filteredList = useTabListFilterMemo(filteredList, l => LEVEL_TAB[l.level], ALL, activeTab)
const list = isLive
? (useMemo(
() => logListNow.concat(exceptionsListNow).sort((a, b) => a.time - b.time),
[logListNow.length, exceptionsListNow.length]
) as ILog[])
: (useMemo(
() => logList.concat(exceptionsList).sort((a, b) => a.time - b.time),
[logList.length, exceptionsList.length]
).filter((l) =>
zoomEnabled ? l.time >= zoomStartTs && l.time <= zoomEndTs : true
) as ILog[]);
let filteredList = useRegExListFilterMemo(list, (l) => l.value, filter);
filteredList = useTabListFilterMemo(filteredList, (l) => LEVEL_TAB[l.level], ALL, activeTab);
React.useEffect(() => {
setTimeout(() => {
cache.clearAll();
_list.current?.recomputeRowHeights();
}, 0)
}, [activeTab, filter])
const onTabClick = (activeTab: any) => devTools.update(INDEX_KEY, { activeTab })
const onFilterChange = ({ target: { value } }: any) => devTools.update(INDEX_KEY, { filter: value })
}, 0);
}, [activeTab, filter]);
const onTabClick = (activeTab: any) => devTools.update(INDEX_KEY, { activeTab });
const onFilterChange = ({ target: { value } }: any) =>
devTools.update(INDEX_KEY, { filter: value });
// AutoScroll
const [
timeoutStartAutoscroll,
stopAutoscroll,
] = useAutoscroll(
// AutoScroll
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList,
getLastItemTime(logListNow, exceptionsListNow),
activeIndex,
index => devTools.update(INDEX_KEY, { index })
)
const onMouseEnter = stopAutoscroll
(index) => devTools.update(INDEX_KEY, { index })
);
const onMouseEnter = stopAutoscroll;
const onMouseLeave = () => {
if (isDetailsModalActive) { return }
timeoutStartAutoscroll()
}
if (isDetailsModalActive) {
return;
}
timeoutStartAutoscroll();
};
const _list = useRef<List>(null); // TODO: fix react-virtualized types & encapsulate scrollToRow logic
useEffect(() => {
if (_list.current) {
@ -120,51 +143,45 @@ function ConsolePanel({ isLive }: { isLive?: boolean }) {
}
}, [activeIndex]);
const cache = useCellMeasurerCache()
const cache = useCellMeasurerCache();
const showDetails = (log: any) => {
setIsDetailsModalActive(true);
showModal(
<ErrorDetailsModal errorId={log.errorId} />,
{
right: true,
width: 1200,
onClose: () => {
setIsDetailsModalActive(false)
timeoutStartAutoscroll()
}
});
showModal(<ErrorDetailsModal errorId={log.errorId} />, {
right: true,
width: 1200,
onClose: () => {
setIsDetailsModalActive(false);
timeoutStartAutoscroll();
},
});
devTools.update(INDEX_KEY, { index: filteredList.indexOf(log) });
stopAutoscroll()
}
stopAutoscroll();
};
const _rowRenderer = ({ index, key, parent, style }: any) => {
const item = filteredList[index];
return (
// @ts-ignore
<CellMeasurer cache={cache} columnIndex={0} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div ref={registerChild} style={style}>
<ConsoleRow
log={item}
jump={jump}
iconProps={getIconProps(item.level)}
renderWithNL={renderWithNL}
onClick={() => showDetails(item)}
recalcHeight={measure}
/>
</div>
)}
</CellMeasurer>
)
}
// @ts-ignore
<CellMeasurer cache={cache} columnIndex={0} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div ref={registerChild} style={style}>
<ConsoleRow
log={item}
jump={jump}
iconProps={getIconProps(item.level)}
renderWithNL={renderWithNL}
onClick={() => showDetails(item)}
recalcHeight={measure}
/>
</div>
)}
</CellMeasurer>
);
};
return (
<BottomBlock
style={{ height: '100%' }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<BottomBlock style={{ height: '100%' }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{/* @ts-ignore */}
<BottomBlock.Header>
<div className="flex items-center">
@ -220,4 +237,8 @@ function ConsolePanel({ isLive }: { isLive?: boolean }) {
);
}
export default observer(ConsolePanel);
export default connect((state: Record<string, any>) => ({
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}))(observer(ConsolePanel));

View file

@ -18,7 +18,7 @@ import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import WSModal from './WSModal'
import WSModal from './WSModal';
const INDEX_KEY = 'network';
@ -150,7 +150,19 @@ function renderStatus({ status, cached }: { status: string; cached: boolean }) {
);
}
function NetworkPanelCont({ startedAt, panelHeight }: { startedAt: number; panelHeight: number }) {
function NetworkPanelCont({
startedAt,
panelHeight,
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
startedAt: number;
panelHeight: number;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) {
const { player, store } = React.useContext(PlayerContext);
const { domContentLoadedTime, loadTime, domBuildingTime, tabStates, currentTab } = store.get();
@ -184,9 +196,15 @@ function NetworkPanelCont({ startedAt, panelHeight }: { startedAt: number; panel
function MobileNetworkPanelCont({
startedAt,
panelHeight,
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
startedAt: number;
panelHeight: number;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) {
const { player, store } = React.useContext(MobilePlayerContext);
@ -219,6 +237,9 @@ function MobileNetworkPanelCont({
websocketList={websocketList}
// @ts-ignore
websocketListNow={websocketListNow}
zoomEnabled={zoomEnabled}
zoomStartTs={zoomStartTs}
zoomEndTs={zoomEndTs}
/>
);
}
@ -229,7 +250,7 @@ type WSMessage = Timed & {
timestamp: number;
dir: 'up' | 'down';
messageType: string;
}
};
interface Props {
domContentLoadedTime?: {
@ -250,6 +271,9 @@ interface Props {
player: WebPlayer | MobilePlayer;
startedAt: number;
isMobile?: boolean;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
panelHeight: number;
}
@ -267,6 +291,9 @@ const NetworkPanelComp = observer(
isMobile,
panelHeight,
websocketList,
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: Props) => {
const { showModal } = useModal();
const [sortBy, setSortBy] = useState('time');
@ -333,6 +360,7 @@ const NetworkPanelComp = observer(
transferredBodySize: 0,
}))
)
.filter((req) => (zoomEnabled ? req.time >= zoomStartTs && req.time <= zoomEndTs : true))
.sort((a, b) => a.time - b.time),
[resourceList.length, fetchList.length, socketList]
);
@ -407,13 +435,10 @@ const NetworkPanelComp = observer(
if (item.type === 'websocket') {
const socketMsgList = websocketList.filter((ws) => ws.channelName === item.channelName);
return showModal(
<WSModal
socketMsgList={socketMsgList}
/>, {
right: true, width: 700,
}
)
return showModal(<WSModal socketMsgList={socketMsgList} />, {
right: true,
width: 700,
});
}
setIsDetailsModalActive(true);
showModal(
@ -583,10 +608,16 @@ const NetworkPanelComp = observer(
const WebNetworkPanel = connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current']).startedAt,
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}))(observer(NetworkPanelCont));
const MobileNetworkPanel = connect((state: any) => ({
startedAt: state.getIn(['sessions', 'current']).startedAt,
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}))(observer(MobileNetworkPanelCont));
export { WebNetworkPanel, MobileNetworkPanel };

View file

@ -14,45 +14,102 @@ import StackEventModal from '../StackEventModal';
import useAutoscroll, { getLastItemTime } from '../useAutoscroll';
import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter';
import useCellMeasurerCache from 'App/hooks/useCellMeasurerCache';
import { connect } from 'react-redux';
const mapNames = (type: string) => {
if (type === 'openreplay') return 'OpenReplay';
return type
}
return type;
};
const INDEX_KEY = 'stackEvent';
const ALL = 'ALL';
const TAB_KEYS = [ALL, ...typeList] as const;
const TABS = TAB_KEYS.map((tab) => ({ text: tab, key: tab }));
type EventsList = Array<Timed & { name: string; source: string, key: string }>
type EventsList = Array<Timed & { name: string; source: string; key: string }>;
export const WebStackEventPanel = observer(() => {
const { player, store } = React.useContext(PlayerContext);
const jump = (t: number) => player.jump(t);
const { currentTab, tabStates } = store.get();
const WebStackEventPanelComp = observer(
({
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) => {
const { player, store } = React.useContext(PlayerContext);
const jump = (t: number) => player.jump(t);
const { currentTab, tabStates } = store.get();
const { stackList: list = [], stackListNow: listNow = [] } = tabStates[currentTab];
const { stackList: list = [], stackListNow: listNow = [] } = tabStates[currentTab];
return <EventsPanel list={list as EventsList} listNow={listNow as EventsList} jump={jump} />;
});
return (
<EventsPanel
list={list as EventsList}
listNow={listNow as EventsList}
jump={jump}
zoomEnabled={zoomEnabled}
zoomStartTs={zoomStartTs}
zoomEndTs={zoomEndTs}
/>
);
}
);
export const MobileStackEventPanel = observer(() => {
const { player, store } = React.useContext(MobilePlayerContext);
const jump = (t: number) => player.jump(t);
const { eventList: list = [], eventListNow: listNow = [] } = store.get();
export const WebStackEventPanel = connect((state: Record<string, any>) => ({
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}))(WebStackEventPanelComp);
return <EventsPanel list={list as EventsList} listNow={listNow as EventsList} jump={jump} />;
});
const MobileStackEventPanelComp = observer(
({
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) => {
const { player, store } = React.useContext(MobilePlayerContext);
const jump = (t: number) => player.jump(t);
const { eventList: list = [], eventListNow: listNow = [] } = store.get();
return (
<EventsPanel
list={list as EventsList}
listNow={listNow as EventsList}
jump={jump}
zoomEnabled={zoomEnabled}
zoomStartTs={zoomStartTs}
zoomEndTs={zoomEndTs}
/>
);
}
);
export const MobileStackEventPanel = connect((state: Record<string, any>) => ({
zoomEnabled: state.getIn(['components', 'player']).timelineZoom.enabled,
zoomStartTs: state.getIn(['components', 'player']).timelineZoom.startTs,
zoomEndTs: state.getIn(['components', 'player']).timelineZoom.endTs,
}))(MobileStackEventPanelComp);
function EventsPanel({
list,
listNow,
jump,
zoomEnabled,
zoomStartTs,
zoomEndTs,
}: {
list: EventsList;
listNow: EventsList;
jump: (t: number) => void;
zoomEnabled: boolean;
zoomStartTs: number;
zoomEndTs: number;
}) {
const {
sessionStore: { devTools },
@ -63,7 +120,14 @@ function EventsPanel({
const activeTab = devTools[INDEX_KEY].activeTab;
const activeIndex = devTools[INDEX_KEY].index;
let filteredList = useRegExListFilterMemo(list, (it) => it.name, filter);
const inZoomRangeList = list.filter(({ time }) =>
zoomEnabled ? zoomStartTs <= time && time <= zoomEndTs : true
);
const inZoomRangeListNow = listNow.filter(({ time }) =>
zoomEnabled ? zoomStartTs <= time && time <= zoomEndTs : true
);
let filteredList = useRegExListFilterMemo(inZoomRangeList, (it) => it.name, filter);
filteredList = useTabListFilterMemo(filteredList, (it) => it.source, ALL, activeTab);
const onTabClick = (activeTab: (typeof TAB_KEYS)[number]) =>
@ -71,13 +135,13 @@ function EventsPanel({
const onFilterChange = ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) =>
devTools.update(INDEX_KEY, { filter: value });
const tabs = useMemo(
() => TABS.filter(({ key }) => key === ALL || list.some(({ source }) => key === source)),
[list.length]
() => TABS.filter(({ key }) => key === ALL || inZoomRangeList.some(({ source }) => key === source)),
[inZoomRangeList.length]
);
const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll(
filteredList,
getLastItemTime(listNow),
getLastItemTime(inZoomRangeListNow),
activeIndex,
(index) => devTools.update(INDEX_KEY, { index })
);
@ -138,15 +202,17 @@ function EventsPanel({
};
return (
<BottomBlock
style={{ height: '100%' }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<BottomBlock style={{ height: '100%' }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<BottomBlock.Header>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Stack Events</span>
<Tabs renameTab={mapNames} tabs={tabs} active={activeTab} onClick={onTabClick} border={false} />
<Tabs
renameTab={mapNames}
tabs={tabs}
active={activeTab}
onClick={onTabClick}
border={false}
/>
</div>
<Input
className="input-small h-8"

View file

@ -1,4 +1,80 @@
import { Map } from 'immutable';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface PlayerState {
fullscreen: boolean;
bottomBlock: number;
hiddenHints: {
storage?: string;
stack?: string;
};
skipInterval: number;
timelineZoom: {
enabled: boolean;
startTs: number;
endTs: number;
}
zoomTab: 'overview' | 'journey' | 'issues' | 'errors',
}
const initialState: PlayerState = {
fullscreen: false,
bottomBlock: 0,
hiddenHints: {
storage: localStorage.getItem('storageHideHint') || undefined,
stack: localStorage.getItem('stackHideHint') || undefined,
},
skipInterval: parseInt(localStorage.getItem('CHANGE_SKIP_INTERVAL') || '10', 10),
timelineZoom: {
enabled: false,
startTs: 0,
endTs: 0,
},
zoomTab: 'overview',
};
export const playerSlice = createSlice({
name: 'player',
initialState,
reducers: {
toggleFullscreen: (state, action: PayloadAction<boolean | undefined>) => {
state.fullscreen = action.payload !== undefined ? action.payload : !state.fullscreen;
},
toggleBottomBlock: (state, action: PayloadAction<number>) => {
state.bottomBlock = state.bottomBlock !== action.payload && action.payload !== 0 ? action.payload : 0;
},
closeBottomBlock: (state) => {
state.bottomBlock = 0;
},
changeSkipInterval: (state, action: PayloadAction<number>) => {
const skipInterval = action.payload;
localStorage.setItem('CHANGE_SKIP_INTERVAL', skipInterval.toString());
state.skipInterval = skipInterval;
},
hideHint: (state, action: PayloadAction<'storage' | 'stack'>) => {
const name = action.payload;
localStorage.setItem(`${name}HideHint`, 'true');
state.hiddenHints[name] = 'true';
state.bottomBlock = 0;
},
toggleZoom: (state, action: PayloadAction<ToggleZoomPayload>) => {
const { enabled, range } = action.payload;
state.timelineZoom = {
enabled,
startTs: range?.[0] || 0,
endTs: range?.[1] || 0,
};
},
setZoomTab: (state, action: PayloadAction<'overview' | 'journey' | 'issues' | 'errors'>) => {
state.zoomTab = action.payload;
}
},
});
interface ToggleZoomPayload { enabled: boolean, range?: [number, number]}
export const { toggleFullscreen, toggleBottomBlock, changeSkipInterval, hideHint, toggleZoom, setZoomTab, closeBottomBlock } = playerSlice.actions;
export default playerSlice.reducer;
export const NONE = 0;
export const CONSOLE = 1;
@ -26,7 +102,7 @@ export const blocks = {
exceptions: EXCEPTIONS,
inspector: INSPECTOR,
overview: OVERVIEW,
} as const
} as const;
export const blockValues = [
NONE,
@ -41,84 +117,4 @@ export const blockValues = [
EXCEPTIONS,
INSPECTOR,
OVERVIEW,
] as const
const TOGGLE_FULLSCREEN = 'player/TOGGLE_FS';
const TOGGLE_BOTTOM_BLOCK = 'player/SET_BOTTOM_BLOCK';
const HIDE_HINT = 'player/HIDE_HINT';
const CHANGE_INTERVAL = 'player/CHANGE_SKIP_INTERVAL'
const initialState = Map({
fullscreen: false,
bottomBlock: NONE,
hiddenHints: Map({
storage: localStorage.getItem('storageHideHint'),
stack: localStorage.getItem('stackHideHint')
}),
skipInterval: localStorage.getItem(CHANGE_INTERVAL) || 10,
});
const reducer = (state = initialState, action: any = {}) => {
switch (action.type) {
case TOGGLE_FULLSCREEN:
const { flag } = action
return state.update('fullscreen', fs => typeof flag === 'boolean' ? flag : !fs);
case TOGGLE_BOTTOM_BLOCK:
const { bottomBlock } = action;
if (state.get('bottomBlock') !== bottomBlock && bottomBlock !== NONE) {
}
return state.update('bottomBlock', bb => bb === bottomBlock ? NONE : bottomBlock);
case CHANGE_INTERVAL:
const { skipInterval } = action;
localStorage.setItem(CHANGE_INTERVAL, skipInterval);
return state.update('skipInterval', () => skipInterval);
case HIDE_HINT:
const { name } = action;
localStorage.setItem(`${name}HideHint`, 'true');
return state
.setIn([ "hiddenHints", name ], true)
.set('bottomBlock', NONE);
}
return state;
};
export default reducer;
export function toggleFullscreen(flag: any) {
return {
type: TOGGLE_FULLSCREEN,
flag,
};
}
export function fullscreenOff() {
return toggleFullscreen(false);
}
export function fullscreenOn() {
return toggleFullscreen(true);
}
export function toggleBottomBlock(bottomBlock = NONE) {
return {
bottomBlock,
type: TOGGLE_BOTTOM_BLOCK,
};
}
export function closeBottomBlock() {
return toggleBottomBlock();
}
export function changeSkipInterval(skipInterval: number) {
return {
skipInterval,
type: CHANGE_INTERVAL,
};
}
export function hideHint(name: string) {
return {
name,
type: HIDE_HINT,
}
}
] as const;

View file

@ -1,9 +1,11 @@
import { aiService } from 'App/services';
import { makeAutoObservable } from 'mobx';
import { aiService } from 'App/services';
export default class AiSummaryStore {
text = '';
toggleSummary = false;
isLoading = false;
constructor() {
makeAutoObservable(this);
@ -17,7 +19,14 @@ export default class AiSummaryStore {
this.toggleSummary = toggleSummary;
}
setLoading(loading: boolean) {
this.isLoading = loading;
}
getSummary = async (sessionId: string) => {
if (this.isLoading) return;
this.setLoading(true);
this.setText('');
try {
const respText = await aiService.getSummary(sessionId);
@ -26,6 +35,25 @@ export default class AiSummaryStore {
this.setText(respText);
} catch (e) {
console.error(e);
} finally {
this.setLoading(false);
}
};
getDetailedSummary = async (sessionId: string, networkEvents: any[], feat: 'errors' | 'issues' | 'journey', startTs: number, endTs: number) => {
if (this.isLoading) return;
this.setLoading(true);
this.setText('');
try {
const respText = await aiService.getDetailedSummary(sessionId, networkEvents,feat, startTs, endTs);
if (!respText) return;
this.setText(respText);
} catch (e) {
console.error(e);
} finally {
this.setLoading(false);
}
}
}

View file

@ -4,19 +4,31 @@ export default class AiService extends BaseService {
/**
* @returns stream of text symbols
* */
async getSummary(sessionId: string): Promise<string | null> {
const r = await this.client.post(
`/sessions/${sessionId}/intelligent/summary`,
);
async getSummary(sessionId: string, start?: number, end?: number): Promise<string | null> {
const r = await this.client.post(`/sessions/${sessionId}/intelligent/summary`, {
frameStartTimestamp: start,
frameEndTimestamp: end,
});
return r.json()
return r.json();
}
async getDetailedSummary(sessionId: string, networkEvents: any[], feat: 'errors' | 'issues' | 'journey', start: number, end: number): Promise<string | null> {
const r = await this.client.post(`/sessions/${sessionId}/intelligent/detailed-summary`, {
event: feat,
frameStartTimestamp: start,
frameEndTimestamp: end,
devtoolsEvents: networkEvents,
});
return r.json();
}
async getSearchFilters(query: string): Promise<Record<string, any>> {
const r = await this.client.post('/intelligent/search', {
question: query
})
question: query,
});
const { data } = await r.json();
return data
return data;
}
}

View file

@ -28,6 +28,7 @@
"@babel/plugin-transform-private-methods": "^7.23.3",
"@floating-ui/react-dom-interactions": "^0.10.3",
"@medv/finder": "^3.1.0",
"@reduxjs/toolkit": "^2.2.2",
"@sentry/browser": "^5.21.1",
"@svg-maps/world": "^1.0.1",
"@svgr/webpack": "^6.2.1",
@ -112,6 +113,7 @@
"@storybook/manager-webpack5": "^6.5.12",
"@storybook/react": "^6.5.12",
"@storybook/testing-library": "^0.0.13",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/luxon": "^3.0.0",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",

View file

@ -198,6 +198,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:7.17.7":
version: 7.17.7
resolution: "@babel/generator@npm:7.17.7"
dependencies:
"@babel/types": ^7.17.0
jsesc: ^2.5.1
source-map: ^0.5.0
checksum: 8088453c4418e0ee6528506fbd5847bbdfd56327a0025ca9496a259261e162c594ffd08be0d63e74c32feced795616772f38acc5f5e493a86a45fd439fd9feb0
languageName: node
linkType: hard
"@babel/generator@npm:^7.12.11, @babel/generator@npm:^7.12.5, @babel/generator@npm:^7.23.3, @babel/generator@npm:^7.23.4, @babel/generator@npm:^7.7.2":
version: 7.23.4
resolution: "@babel/generator@npm:7.23.4"
@ -210,6 +221,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.23.0":
version: 7.24.1
resolution: "@babel/generator@npm:7.24.1"
dependencies:
"@babel/types": ^7.24.0
"@jridgewell/gen-mapping": ^0.3.5
"@jridgewell/trace-mapping": ^0.3.25
jsesc: ^2.5.1
checksum: f0eea7497657cdf68cfb4b7d181588e1498eefd1f303d73b0d8ca9b21a6db27136a6f5beb8f988b6bdcd4249870826080950450fd310951de42ecf36df274881
languageName: node
linkType: hard
"@babel/helper-annotate-as-pure@npm:^7.18.6, @babel/helper-annotate-as-pure@npm:^7.22.5":
version: 7.22.5
resolution: "@babel/helper-annotate-as-pure@npm:7.22.5"
@ -448,7 +471,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-validator-identifier@npm:^7.22.20":
"@babel/helper-validator-identifier@npm:^7.16.7, @babel/helper-validator-identifier@npm:^7.22.20":
version: 7.22.20
resolution: "@babel/helper-validator-identifier@npm:7.22.20"
checksum: dcad63db345fb110e032de46c3688384b0008a42a4845180ce7cd62b1a9c0507a1bed727c4d1060ed1a03ae57b4d918570259f81724aaac1a5b776056f37504e
@ -522,6 +545,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.20.5, @babel/parser@npm:^7.23.0":
version: 7.24.1
resolution: "@babel/parser@npm:7.24.1"
bin:
parser: ./bin/babel-parser.js
checksum: d2a8b99aa5f33182b69d5569367403a40e7c027ae3b03a1f81fd8ac9b06ceb85b31f6ee4267fb90726dc2ac99909c6bdaa9cf16c379efab73d8dfe85cee32c50
languageName: node
linkType: hard
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.23.3"
@ -1850,6 +1882,24 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:7.23.2":
version: 7.23.2
resolution: "@babel/traverse@npm:7.23.2"
dependencies:
"@babel/code-frame": ^7.22.13
"@babel/generator": ^7.23.0
"@babel/helper-environment-visitor": ^7.22.20
"@babel/helper-function-name": ^7.23.0
"@babel/helper-hoist-variables": ^7.22.5
"@babel/helper-split-export-declaration": ^7.22.6
"@babel/parser": ^7.23.0
"@babel/types": ^7.23.0
debug: ^4.1.0
globals: ^11.1.0
checksum: d096c7c4bab9262a2f658298a3c630ae4a15a10755bb257ae91d5ab3e3b2877438934859c8d34018b7727379fe6b26c4fa2efc81cf4c462a7fe00caf79fa02ff
languageName: node
linkType: hard
"@babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.12.11, @babel/traverse@npm:^7.12.9, @babel/traverse@npm:^7.13.0, @babel/traverse@npm:^7.23.3, @babel/traverse@npm:^7.23.4":
version: 7.23.4
resolution: "@babel/traverse@npm:7.23.4"
@ -1868,6 +1918,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:7.17.0":
version: 7.17.0
resolution: "@babel/types@npm:7.17.0"
dependencies:
"@babel/helper-validator-identifier": ^7.16.7
to-fast-properties: ^2.0.0
checksum: ad09224272b40fedb00b262677d12b6838f5b5df5c47d67059ba1181bd4805439993393a8de32459dae137b536d60ebfcaf39ae84d8b3873f1e81cc75f5aeae8
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.11, @babel/types@npm:^7.12.7, @babel/types@npm:^7.13.0, @babel/types@npm:^7.2.0, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.3, @babel/types@npm:^7.23.4, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
version: 7.23.4
resolution: "@babel/types@npm:7.23.4"
@ -1879,6 +1939,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.17.0, @babel/types@npm:^7.24.0":
version: 7.24.0
resolution: "@babel/types@npm:7.24.0"
dependencies:
"@babel/helper-string-parser": ^7.23.4
"@babel/helper-validator-identifier": ^7.22.20
to-fast-properties: ^2.0.0
checksum: 777a0bb5dbe038ca4c905fdafb1cdb6bdd10fe9d63ce13eca0bd91909363cbad554a53dc1f902004b78c1dcbc742056f877f2c99eeedff647333b1fadf51235d
languageName: node
linkType: hard
"@base2/pretty-print-object@npm:1.0.1":
version: 1.0.1
resolution: "@base2/pretty-print-object@npm:1.0.1"
@ -2661,6 +2732,17 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.5":
version: 0.3.5
resolution: "@jridgewell/gen-mapping@npm:0.3.5"
dependencies:
"@jridgewell/set-array": ^1.2.1
"@jridgewell/sourcemap-codec": ^1.4.10
"@jridgewell/trace-mapping": ^0.3.24
checksum: 1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb
languageName: node
linkType: hard
"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0":
version: 3.1.1
resolution: "@jridgewell/resolve-uri@npm:3.1.1"
@ -2675,6 +2757,13 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/set-array@npm:^1.2.1":
version: 1.2.1
resolution: "@jridgewell/set-array@npm:1.2.1"
checksum: 2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4
languageName: node
linkType: hard
"@jridgewell/source-map@npm:^0.3.3":
version: 0.3.5
resolution: "@jridgewell/source-map@npm:0.3.5"
@ -2712,6 +2801,16 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25":
version: 0.3.25
resolution: "@jridgewell/trace-mapping@npm:0.3.25"
dependencies:
"@jridgewell/resolve-uri": ^3.1.0
"@jridgewell/sourcemap-codec": ^1.4.14
checksum: 3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4
languageName: node
linkType: hard
"@leichtgewicht/ip-codec@npm:^2.0.1":
version: 2.0.4
resolution: "@leichtgewicht/ip-codec@npm:2.0.4"
@ -3087,6 +3186,26 @@ __metadata:
languageName: node
linkType: hard
"@reduxjs/toolkit@npm:^2.2.2":
version: 2.2.2
resolution: "@reduxjs/toolkit@npm:2.2.2"
dependencies:
immer: ^10.0.3
redux: ^5.0.1
redux-thunk: ^3.1.0
reselect: ^5.0.1
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
checksum: d749181b1bc071698517cba7ce05c42ddfe99363019249722b4dfa3afc71b3a6e4cb9885af574cf81c5d6515f68201ebfedddb5c14b262c941a45112fdc66ce3
languageName: node
linkType: hard
"@rollup/plugin-babel@npm:^5.2.0":
version: 5.3.1
resolution: "@rollup/plugin-babel@npm:5.3.1"
@ -4733,6 +4852,26 @@ __metadata:
languageName: node
linkType: hard
"@trivago/prettier-plugin-sort-imports@npm:^4.3.0":
version: 4.3.0
resolution: "@trivago/prettier-plugin-sort-imports@npm:4.3.0"
dependencies:
"@babel/generator": 7.17.7
"@babel/parser": ^7.20.5
"@babel/traverse": 7.23.2
"@babel/types": 7.17.0
javascript-natural-sort: 0.7.1
lodash: ^4.17.21
peerDependencies:
"@vue/compiler-sfc": 3.x
prettier: 2.x - 3.x
peerDependenciesMeta:
"@vue/compiler-sfc":
optional: true
checksum: 42270fb9c89e54a3f8b6ac8c43e6d0e03350e2857e902cdad4de22c78ef1864da600525595311bc7e94e51c16c7dd3882c2e048a162fdab59761ffa893756aa2
languageName: node
linkType: hard
"@trysound/sax@npm:0.2.0":
version: 0.2.0
resolution: "@trysound/sax@npm:0.2.0"
@ -13568,6 +13707,13 @@ __metadata:
languageName: node
linkType: hard
"immer@npm:^10.0.3":
version: 10.0.4
resolution: "immer@npm:10.0.4"
checksum: da9da59d6e71cf3f2875024b5cfb58874baef3eefec6425483e53163e31ed0ab24bae85cd2829cb7812acb9723075eedb5946654f8bd47ecf38663388e04d3bd
languageName: node
linkType: hard
"immutable@npm:^3.7.2":
version: 3.8.2
resolution: "immutable@npm:3.8.2"
@ -14657,6 +14803,13 @@ __metadata:
languageName: node
linkType: hard
"javascript-natural-sort@npm:0.7.1":
version: 0.7.1
resolution: "javascript-natural-sort@npm:0.7.1"
checksum: 340f8ffc5d30fb516e06dc540e8fa9e0b93c865cf49d791fed3eac3bdc5fc71f0066fc81d44ec1433edc87caecaf9f13eec4a1fce8c5beafc709a71eaedae6fe
languageName: node
linkType: hard
"jest-changed-files@npm:^29.7.0":
version: 29.7.0
resolution: "jest-changed-files@npm:29.7.0"
@ -18107,6 +18260,7 @@ __metadata:
"@jest/globals": ^29.7.0
"@medv/finder": ^3.1.0
"@openreplay/sourcemap-uploader": ^3.0.8
"@reduxjs/toolkit": ^2.2.2
"@sentry/browser": ^5.21.1
"@storybook/addon-actions": ^6.5.12
"@storybook/addon-docs": ^6.5.12
@ -18119,6 +18273,7 @@ __metadata:
"@storybook/testing-library": ^0.0.13
"@svg-maps/world": ^1.0.1
"@svgr/webpack": ^6.2.1
"@trivago/prettier-plugin-sort-imports": ^4.3.0
"@types/luxon": ^3.0.0
"@types/react": ^18.0.9
"@types/react-dom": ^18.0.4
@ -21415,6 +21570,15 @@ __metadata:
languageName: node
linkType: hard
"redux-thunk@npm:^3.1.0":
version: 3.1.0
resolution: "redux-thunk@npm:3.1.0"
peerDependencies:
redux: ^5.0.0
checksum: 21557f6a30e1b2e3e470933247e51749be7f1d5a9620069a3125778675ce4d178d84bdee3e2a0903427a5c429e3aeec6d4df57897faf93eb83455bc1ef7b66fd
languageName: node
linkType: hard
"redux@npm:^4.0.0, redux@npm:^4.0.5, redux@npm:^4.1.2, redux@npm:^4.2.0":
version: 4.2.1
resolution: "redux@npm:4.2.1"
@ -21424,6 +21588,13 @@ __metadata:
languageName: node
linkType: hard
"redux@npm:^5.0.1":
version: 5.0.1
resolution: "redux@npm:5.0.1"
checksum: b10c28357194f38e7d53b760ed5e64faa317cc63de1fb95bc5d9e127fab956392344368c357b8e7a9bedb0c35b111e7efa522210cfdc3b3c75e5074718e9069c
languageName: node
linkType: hard
"reflect.getprototypeof@npm:^1.0.4":
version: 1.0.4
resolution: "reflect.getprototypeof@npm:1.0.4"
@ -21757,6 +21928,13 @@ __metadata:
languageName: node
linkType: hard
"reselect@npm:^5.0.1":
version: 5.1.0
resolution: "reselect@npm:5.1.0"
checksum: b0ed789f4f6f10dfbd23741823726793384932969aa7ce8f584c882ad87620a02b09b5d1146cd2ea6eaa0953b3fd9f7df22f113893af73f35f28432a8a4294de
languageName: node
linkType: hard
"resize-observer-polyfill@npm:^1.5.1":
version: 1.5.1
resolution: "resize-observer-polyfill@npm:1.5.1"