feat(ui): make ui able to load unprocessed session files (#652)
* feat(ui): make ui able to load unprocessed session files * feat(ui): some lgos * feat(ui): connect api, rewrite old code * feat(ui): create testing ui functions * feat(ui/player): add ability to jump back in time for assist * feat(ui/player): rewrite for better readability * fix(ui/player): small refactor for better readability * fix(ui/player): fix private prop * fix(ui/player): add tooltip * feat(ui/player): create time calculating tooltip * fix(player): fix message timestamp * fix(ui/player): cleanup * fix(ui/player): handle errors for unprocessed files as well * fix(player): fix logged message * fix(player): code review fixes * fix(ui): fix circle color, fix button text * fix(tracker): code review * fix(player): small style fixes
This commit is contained in:
parent
762d0fad53
commit
4ebcff74e1
29 changed files with 671 additions and 268 deletions
|
|
@ -25,6 +25,7 @@ const siteIdRequiredPaths = [
|
|||
'/custom_metrics',
|
||||
'/dashboards',
|
||||
'/metrics',
|
||||
'/unprocessed',
|
||||
// '/custom_metrics/sessions',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function LivePlayer ({
|
|||
return (
|
||||
<PlayerProvider>
|
||||
<InitLoader className="flex-1 p-3">
|
||||
<PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/>
|
||||
<PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/>
|
||||
<div className={ styles.session } data-fullscreen={fullscreen}>
|
||||
<PlayerBlock />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ const SESSIONS_ROUTE = sessionsRoute();
|
|||
function Session({
|
||||
sessionId,
|
||||
loading,
|
||||
hasErrors,
|
||||
hasErrors,
|
||||
session,
|
||||
fetchSession,
|
||||
fetchSlackList,
|
||||
fetchSlackList,
|
||||
}) {
|
||||
usePageTitle("OpenReplay Session Player");
|
||||
const [ initializing, setInitializing ] = useState(true)
|
||||
|
|
@ -63,4 +63,4 @@ export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state, pro
|
|||
}, {
|
||||
fetchSession,
|
||||
fetchSlackList,
|
||||
})(Session));
|
||||
})(Session));
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import React, { memo, FC } from 'react';
|
||||
import styles from './timeline.module.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
preview?: boolean;
|
||||
isGreen?: boolean;
|
||||
}
|
||||
export const Circle: FC<Props> = memo(function Box({ preview }) {
|
||||
export const Circle: FC<Props> = memo(function Box({ preview, isGreen }) {
|
||||
return (
|
||||
<div
|
||||
className={ styles.positionTracker }
|
||||
className={ cn(styles.positionTracker, { [styles.greenTracker]: isGreen }) }
|
||||
role={preview ? 'BoxPreview' : 'Box'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export default Circle;
|
||||
export default Circle;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import {
|
|||
selectStorageListNow,
|
||||
} from 'Player/store';
|
||||
import LiveTag from 'Shared/LiveTag';
|
||||
import { session as sessionRoute, withSiteId } from 'App/routes';
|
||||
import {
|
||||
toggleTimetravel,
|
||||
jumpToLive,
|
||||
} from 'Player';
|
||||
|
||||
import { Icon } from 'UI';
|
||||
import { toggleInspectorMode } from 'Player';
|
||||
|
|
@ -26,9 +31,10 @@ import {
|
|||
EXCEPTIONS,
|
||||
INSPECTOR,
|
||||
} from 'Duck/components/player';
|
||||
import { ReduxTime } from './Time';
|
||||
import { ReduxTime, AssistDuration } from './Time';
|
||||
import Timeline from './Timeline';
|
||||
import ControlButton from './ControlButton';
|
||||
import PlayerControls from './components/PlayerControls'
|
||||
|
||||
import styles from './controls.module.css';
|
||||
import { Tooltip } from 'react-tippy';
|
||||
|
|
@ -79,7 +85,6 @@ function getStorageName(type) {
|
|||
fullscreenDisabled: state.messagesLoading,
|
||||
logCount: state.logListNow.length,
|
||||
logRedCount: state.logRedCountNow,
|
||||
// resourceCount: state.resourceCountNow,
|
||||
resourceRedCount: state.resourceRedCountNow,
|
||||
fetchRedCount: state.fetchRedCountNow,
|
||||
showStack: state.stackList.length > 0,
|
||||
|
|
@ -97,6 +102,7 @@ function getStorageName(type) {
|
|||
exceptionsCount: state.exceptionsListNow.length,
|
||||
showExceptions: state.exceptionsList.length > 0,
|
||||
showLongtasks: state.longtasksList.length > 0,
|
||||
liveTimeTravel: state.liveTimeTravel,
|
||||
}))
|
||||
@connect((state, props) => {
|
||||
const permissions = state.getIn([ 'user', 'account', 'permissions' ]) || [];
|
||||
|
|
@ -129,7 +135,6 @@ export default class Controls extends React.Component {
|
|||
if (
|
||||
nextProps.fullscreen !== this.props.fullscreen ||
|
||||
nextProps.bottomBlock !== this.props.bottomBlock ||
|
||||
nextProps.endTime !== this.props.endTime ||
|
||||
nextProps.live !== this.props.live ||
|
||||
nextProps.livePlay !== this.props.livePlay ||
|
||||
nextProps.playing !== this.props.playing ||
|
||||
|
|
@ -158,7 +163,8 @@ export default class Controls extends React.Component {
|
|||
nextProps.graphqlCount !== this.props.graphqlCount ||
|
||||
nextProps.showExceptions !== this.props.showExceptions ||
|
||||
nextProps.exceptionsCount !== this.props.exceptionsCount ||
|
||||
nextProps.showLongtasks !== this.props.showLongtasks
|
||||
nextProps.showLongtasks !== this.props.showLongtasks ||
|
||||
nextProps.liveTimeTravel !== this.props.liveTimeTravel
|
||||
) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -206,7 +212,7 @@ export default class Controls extends React.Component {
|
|||
goLive =() => this.props.jump(this.props.endTime)
|
||||
|
||||
renderPlayBtn = () => {
|
||||
const { completed, playing, disabled } = this.props;
|
||||
const { completed, playing } = this.props;
|
||||
let label;
|
||||
let icon;
|
||||
if (completed) {
|
||||
|
|
@ -279,6 +285,9 @@ export default class Controls extends React.Component {
|
|||
fullscreen,
|
||||
inspectorMode,
|
||||
closedLive,
|
||||
toggleSpeed,
|
||||
toggleSkip,
|
||||
liveTimeTravel,
|
||||
} = this.props;
|
||||
|
||||
const toggleBottomTools = (blockName) => {
|
||||
|
|
@ -290,75 +299,38 @@ export default class Controls extends React.Component {
|
|||
toggleBottomBlock(blockName);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
|
||||
{ !live && <Timeline jump={ this.props.jump } pause={this.props.pause} togglePlay={this.props.togglePlay} /> }
|
||||
<div className={ styles.controls }>
|
||||
{ !live || liveTimeTravel ? <Timeline jump={ this.props.jump } liveTimeTravel={liveTimeTravel} pause={this.props.pause} togglePlay={this.props.togglePlay} /> : null}
|
||||
{ !fullscreen &&
|
||||
<div className={ styles.buttons } data-is-live={ live }>
|
||||
<div className={ cn(styles.buttons, {'!px-5 !pt-0' : live}) } data-is-live={ live }>
|
||||
<div>
|
||||
{ !live && (
|
||||
<div className="flex items-center">
|
||||
{ this.renderPlayBtn() }
|
||||
{ !live && (
|
||||
<div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}>
|
||||
<ReduxTime isCustom name="time" format="mm:ss" />
|
||||
<span className="px-1">/</span>
|
||||
<ReduxTime isCustom name="endTime" format="mm:ss" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch">
|
||||
<Tooltip
|
||||
title='Rewind 10s'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
{this.controlIcon("skip-forward-fill", 18, this.backTenSeconds, true, 'hover:bg-active-blue-border color-main h-full flex items-center')}
|
||||
</Tooltip>
|
||||
<div className='p-1 border-l border-r bg-active-blue-border border-active-blue-border'>10s</div>
|
||||
<Tooltip
|
||||
title='Forward 10s'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
{this.controlIcon("skip-forward-fill", 18, this.forthTenSeconds, false, 'hover:bg-active-blue-border color-main h-full flex items-center')}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!live &&
|
||||
<div className='flex items-center mx-4'>
|
||||
<Tooltip
|
||||
title='Playback speed'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
className={ styles.speedButton }
|
||||
onClick={ this.props.toggleSpeed }
|
||||
data-disabled={ disabled }
|
||||
>
|
||||
<div>{ speed + 'x' }</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
className={ cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip, [styles.active]: skip }, 'ml-4') }
|
||||
onClick={ this.props.toggleSkip }
|
||||
data-disabled={ disabled }
|
||||
>
|
||||
{skip && <Icon name="check" size="24" className="mr-1" />}
|
||||
{ 'Skip Inactivity' }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{!live && (
|
||||
<PlayerControls
|
||||
live={live}
|
||||
skip={skip}
|
||||
speed={speed}
|
||||
disabled={disabled}
|
||||
backTenSeconds={this.backTenSeconds}
|
||||
forthTenSeconds={this.forthTenSeconds}
|
||||
toggleSpeed={toggleSpeed}
|
||||
toggleSkip={toggleSkip}
|
||||
playButton={this.renderPlayBtn()}
|
||||
controlIcon={this.controlIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ live && !closedLive && (
|
||||
<div className={ styles.buttonsLeft }>
|
||||
<LiveTag isLive={livePlay} />
|
||||
{'Elapsed'}
|
||||
<ReduxTime name="time" />
|
||||
<LiveTag isLive={livePlay} onClick={() => livePlay ? null : jumpToLive()} />
|
||||
<div className="font-semibold px-2"><AssistDuration isLivePlay={livePlay} /></div>
|
||||
|
||||
{!liveTimeTravel && (
|
||||
<div onClick={toggleTimetravel} className="p-2 ml-2 rounded hover:bg-teal-light bg-gray-lightest cursor-pointer">
|
||||
See Past Activity
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -95,4 +95,4 @@ const CustomDragLayer: FC<Props> = memo(function CustomDragLayer(props) {
|
|||
);
|
||||
})
|
||||
|
||||
export default CustomDragLayer;
|
||||
export default CustomDragLayer;
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ function getStyles(
|
|||
isDragging: boolean,
|
||||
): CSSProperties {
|
||||
// const transform = `translate3d(${(left * 1161) / 100}px, -8px, 0)`
|
||||
const leftPosition = left > 100 ? 100 : left
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: '-3px',
|
||||
left: `${left}%`,
|
||||
left: `${leftPosition}%`,
|
||||
// transform,
|
||||
// WebkitTransform: transform,
|
||||
// IE fallback: hide the real node using CSS when dragging
|
||||
|
|
@ -59,9 +61,9 @@ const DraggableCircle: FC<Props> = memo(function DraggableCircle(props) {
|
|||
style={getStyles(left, isDragging)}
|
||||
role="DraggableBox"
|
||||
>
|
||||
<Circle />
|
||||
<Circle isGreen={left > 99} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
export default DraggableCircle
|
||||
export default DraggableCircle
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { Duration } from 'luxon';
|
||||
import { connectPlayer } from 'Player';
|
||||
import styles from './time.module.css';
|
||||
import { Tooltip } from 'react-tippy';
|
||||
|
||||
const Time = ({ time, isCustom, format = 'm:ss', }) => (
|
||||
<div className={ !isCustom ? styles.time : undefined }>
|
||||
|
|
@ -11,13 +12,37 @@ const Time = ({ time, isCustom, format = 'm:ss', }) => (
|
|||
|
||||
Time.displayName = "Time";
|
||||
|
||||
|
||||
const ReduxTime = connectPlayer((state, { name, format }) => ({
|
||||
time: state[ name ],
|
||||
format,
|
||||
}))(Time);
|
||||
|
||||
const AssistDurationCont = connectPlayer(
|
||||
state => {
|
||||
const assistStart = state.assistStart;
|
||||
return {
|
||||
assistStart,
|
||||
}
|
||||
}
|
||||
)(({ assistStart }) => {
|
||||
const [assistDuration, setAssistDuration] = React.useState('00:00');
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAssistDuration(Duration.fromMillis(+new Date() - assistStart).toFormat('mm:ss'));
|
||||
}
|
||||
, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
Elapsed {assistDuration}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const AssistDuration = React.memo(AssistDurationCont);
|
||||
|
||||
ReduxTime.displayName = "ReduxTime";
|
||||
|
||||
export default Time;
|
||||
export { ReduxTime };
|
||||
export default React.memo(Time);
|
||||
export { ReduxTime, AssistDuration };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import { Duration } from 'luxon';
|
||||
import { connect } from 'react-redux';
|
||||
// @ts-ignore
|
||||
import stl from './timeline.module.css';
|
||||
|
||||
function TimeTooltip({ time, offset, isVisible, liveTimeTravel }: { time: number; offset: number; isVisible: boolean, liveTimeTravel: boolean }) {
|
||||
const duration = Duration.fromMillis(time).toFormat(`${liveTimeTravel ? '-' : ''}mm:ss`);
|
||||
return (
|
||||
<div
|
||||
className={stl.timeTooltip}
|
||||
style={{
|
||||
top: -30,
|
||||
left: offset - 20,
|
||||
display: isVisible ? 'block' : 'none' }
|
||||
}
|
||||
>
|
||||
{!time ? 'Loading' : duration}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state) => {
|
||||
const { time = 0, offset = 0, isVisible } = state.getIn(['sessions', 'timeLineTooltip']);
|
||||
return { time, offset, isVisible };
|
||||
})(TimeTooltip);
|
||||
|
|
@ -6,11 +6,12 @@ import { TimelinePointer, Icon } from 'UI';
|
|||
import TimeTracker from './TimeTracker';
|
||||
import stl from './timeline.module.css';
|
||||
import { TYPES } from 'Types/session/event';
|
||||
import { setTimelinePointer } from 'Duck/sessions';
|
||||
import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions';
|
||||
import DraggableCircle from './DraggableCircle';
|
||||
import CustomDragLayer from './CustomDragLayer';
|
||||
import { debounce } from 'App/utils';
|
||||
import { Tooltip } from 'react-tippy';
|
||||
import TooltipContainer from './components/TooltipContainer';
|
||||
|
||||
const BOUNDRY = 15
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ const getPointerIcon = (type) => {
|
|||
|
||||
|
||||
let deboucneJump = () => null;
|
||||
let debounceTooltipChange = () => null;
|
||||
@connectPlayer(state => ({
|
||||
playing: state.playing,
|
||||
time: state.time,
|
||||
|
|
@ -86,16 +88,25 @@ let deboucneJump = () => null;
|
|||
state.getIn([ 'sessions', 'current', 'clickRageTime' ]),
|
||||
returningLocationTime: state.getIn([ 'sessions', 'current', 'returningLocation' ]) &&
|
||||
state.getIn([ 'sessions', 'current', 'returningLocationTime' ]),
|
||||
}), { setTimelinePointer })
|
||||
tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible'])
|
||||
}), { setTimelinePointer, setTimelineHoverTime })
|
||||
export default class Timeline extends React.PureComponent {
|
||||
progressRef = React.createRef()
|
||||
timelineRef = React.createRef()
|
||||
wasPlaying = false
|
||||
|
||||
seekProgress = (e) => {
|
||||
const time = this.getTime(e)
|
||||
this.props.jump(time);
|
||||
this.hideTimeTooltip()
|
||||
}
|
||||
|
||||
getTime = (e) => {
|
||||
const { endTime } = this.props;
|
||||
const p = e.nativeEvent.offsetX / e.target.offsetWidth;
|
||||
const time = Math.max(Math.round(p * endTime), 0);
|
||||
this.props.jump(time);
|
||||
|
||||
return time
|
||||
}
|
||||
|
||||
createEventClickHandler = pointer => (e) => {
|
||||
|
|
@ -109,6 +120,7 @@ export default class Timeline extends React.PureComponent {
|
|||
const skipToIssue = Controls.updateSkipToIssue();
|
||||
const firstIssue = issues.get(0);
|
||||
deboucneJump = debounce(this.props.jump, 500);
|
||||
debounceTooltipChange = debounce(this.props.setTimelineHoverTime, 50);
|
||||
|
||||
if (firstIssue && skipToIssue) {
|
||||
this.props.jump(firstIssue.time);
|
||||
|
|
@ -127,12 +139,33 @@ export default class Timeline extends React.PureComponent {
|
|||
const p = (offset.x - BOUNDRY) / this.progressRef.current.offsetWidth;
|
||||
const time = Math.max(Math.round(p * endTime), 0);
|
||||
deboucneJump(time);
|
||||
this.hideTimeTooltip();
|
||||
if (this.props.playing) {
|
||||
this.wasPlaying = true;
|
||||
this.props.pause();
|
||||
}
|
||||
}
|
||||
|
||||
showTimeTooltip = (e) => {
|
||||
if (e.target !== this.progressRef.current && e.target !== this.timelineRef.current) {
|
||||
return this.props.tooltipVisible && this.hideTimeTooltip()
|
||||
}
|
||||
const time = this.getTime(e);
|
||||
const { endTime, liveTimeTravel } = this.props;
|
||||
|
||||
const timeLineTooltip = {
|
||||
time: liveTimeTravel ? endTime - time : time,
|
||||
offset: e.nativeEvent.offsetX,
|
||||
isVisible: true
|
||||
}
|
||||
debounceTooltipChange(timeLineTooltip)
|
||||
}
|
||||
|
||||
hideTimeTooltip = () => {
|
||||
const timeLineTooltip = { isVisible: false }
|
||||
debounceTooltipChange(timeLineTooltip)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
events,
|
||||
|
|
@ -140,14 +173,13 @@ export default class Timeline extends React.PureComponent {
|
|||
skipIntervals,
|
||||
disabled,
|
||||
endTime,
|
||||
live,
|
||||
logList,
|
||||
exceptionsList,
|
||||
resourceList,
|
||||
clickRageTime,
|
||||
stackList,
|
||||
fetchList,
|
||||
issues,
|
||||
liveTimeTravel,
|
||||
} = this.props;
|
||||
|
||||
const scale = 100 / endTime;
|
||||
|
|
@ -155,17 +187,23 @@ export default class Timeline extends React.PureComponent {
|
|||
return (
|
||||
<div
|
||||
className="flex items-center absolute w-full"
|
||||
style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`}}
|
||||
style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`, maxWidth: '100%' }}
|
||||
>
|
||||
<div
|
||||
className={ stl.progress }
|
||||
onClick={ disabled ? null : this.seekProgress }
|
||||
ref={ this.progressRef }
|
||||
role="button"
|
||||
onMouseMoveCapture={this.showTimeTooltip}
|
||||
onMouseEnter={ this.showTimeTooltip}
|
||||
onMouseLeave={this.hideTimeTooltip}
|
||||
>
|
||||
<TooltipContainer liveTimeTravel={liveTimeTravel} />
|
||||
{/* custo color is live */}
|
||||
<DraggableCircle left={this.props.time * scale} onDrop={this.onDragEnd} />
|
||||
<CustomDragLayer onDrag={this.onDrag} minX={BOUNDRY} maxX={this.progressRef.current && this.progressRef.current.offsetWidth + BOUNDRY} />
|
||||
<TimeTracker scale={ scale } />
|
||||
|
||||
{ skip && skipIntervals.map(interval =>
|
||||
(<div
|
||||
key={ interval.start }
|
||||
|
|
@ -176,7 +214,8 @@ export default class Timeline extends React.PureComponent {
|
|||
} }
|
||||
/>))
|
||||
}
|
||||
<div className={ stl.timeline }/>
|
||||
<div className={ stl.timeline } ref={this.timelineRef} />
|
||||
|
||||
{ events.map(e => (
|
||||
<div
|
||||
key={ e.key }
|
||||
|
|
@ -359,7 +398,7 @@ export default class Timeline extends React.PureComponent {
|
|||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import React from 'react'
|
||||
import { Tooltip } from 'react-tippy';
|
||||
import { ReduxTime } from '../Time';
|
||||
import { Icon } from 'UI';
|
||||
import cn from 'classnames';
|
||||
// @ts-ignore
|
||||
import styles from '../controls.module.css'
|
||||
|
||||
interface Props {
|
||||
live: boolean;
|
||||
skip: boolean;
|
||||
speed: number;
|
||||
disabled: boolean;
|
||||
playButton: JSX.Element;
|
||||
backTenSeconds: () => void;
|
||||
forthTenSeconds: () => void;
|
||||
toggleSpeed: () => void;
|
||||
toggleSkip: () => void;
|
||||
controlIcon: (icon: string, size: number, action: () => void, isBackwards: boolean, additionalClasses: string) => JSX.Element;
|
||||
}
|
||||
|
||||
function PlayerControls(props: Props) {
|
||||
const {
|
||||
live,
|
||||
skip,
|
||||
speed,
|
||||
disabled,
|
||||
playButton,
|
||||
backTenSeconds,
|
||||
forthTenSeconds,
|
||||
toggleSpeed,
|
||||
toggleSkip,
|
||||
controlIcon
|
||||
} = props;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{playButton}
|
||||
{!live && (
|
||||
<div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}>
|
||||
{/* @ts-ignore */}
|
||||
<ReduxTime isCustom name="time" format="mm:ss" />
|
||||
<span className="px-1">/</span>
|
||||
{/* @ts-ignore */}
|
||||
<ReduxTime isCustom name="endTime" format="mm:ss" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch">
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip
|
||||
title='Rewind 10s'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
{controlIcon(
|
||||
"skip-forward-fill",
|
||||
18,
|
||||
backTenSeconds,
|
||||
true,
|
||||
'hover:bg-active-blue-border color-main h-full flex items-center'
|
||||
)}
|
||||
</Tooltip>
|
||||
<div className='p-1 border-l border-r bg-active-blue-border border-active-blue-border'>10s</div>
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip
|
||||
title='Forward 10s'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
{controlIcon(
|
||||
"skip-forward-fill",
|
||||
18,
|
||||
forthTenSeconds,
|
||||
false,
|
||||
'hover:bg-active-blue-border color-main h-full flex items-center'
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!live &&
|
||||
<div className='flex items-center mx-4'>
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip
|
||||
title='Playback speed'
|
||||
delay={0}
|
||||
position="top"
|
||||
>
|
||||
<button
|
||||
className={styles.speedButton}
|
||||
onClick={toggleSpeed}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div>{speed + 'x'}</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<button
|
||||
className={cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip, [styles.active]: skip }, 'ml-4')}
|
||||
onClick={toggleSkip}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
{skip && <Icon name="check" size="24" className="mr-1" />}
|
||||
{'Skip Inactivity'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerControls;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react'
|
||||
import TimeTooltip from '../TimeTooltip';
|
||||
import store from 'App/store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
function TooltipContainer({ liveTimeTravel }: { liveTimeTravel: boolean }) {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<TimeTooltip liveTimeTravel={liveTimeTravel} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(TooltipContainer);
|
||||
|
|
@ -18,9 +18,6 @@
|
|||
height: 65px;
|
||||
padding-left: 30px;
|
||||
padding-right: 0;
|
||||
&[data-is-live=true] {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsLeft {
|
||||
|
|
|
|||
|
|
@ -21,14 +21,21 @@
|
|||
|
||||
}
|
||||
|
||||
.greenTracker {
|
||||
background-color: #42AE5E!important;
|
||||
box-shadow: 0 0 0 1px #42AE5E;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 10px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -163,3 +170,28 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeTooltip {
|
||||
position: absolute;
|
||||
padding: 0.25rem;
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
background: black;
|
||||
top: -35px;
|
||||
color: white;
|
||||
|
||||
&:after {
|
||||
content:'';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: solid 5px black;
|
||||
border-left: solid 5px transparent;
|
||||
border-right: solid 5px transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,12 +44,6 @@ function Overlay({
|
|||
togglePlay,
|
||||
closedLive
|
||||
}: Props) {
|
||||
|
||||
// useEffect(() =>{
|
||||
// setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000)
|
||||
// setTimeout(() => markTargets(null), 8000)
|
||||
// },[])
|
||||
|
||||
const showAutoplayTimer = !live && completed && autoplay && nextId
|
||||
const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
|
||||
const showLiveStatusText = live && liveStatusText && !loading;
|
||||
|
|
@ -60,7 +54,7 @@ function Overlay({
|
|||
{ showLiveStatusText &&
|
||||
<LiveStatusText text={liveStatusText} concetionStatus={closedLive ? ConnectionStatus.Closed : concetionStatus} />
|
||||
}
|
||||
{ messagesLoading && <Loader/> }
|
||||
{ messagesLoading && <Loader /> }
|
||||
{ showPlayIconLayer &&
|
||||
<PlayIconLayer playing={playing} togglePlay={togglePlay} />
|
||||
}
|
||||
|
|
@ -83,4 +77,4 @@ export default connectPlayer(state => ({
|
|||
concetionStatus: state.peerConnectionStatus,
|
||||
markedTargets: state.markedTargets,
|
||||
activeTargetIndex: state.activeTargetIndex,
|
||||
}))(Overlay);
|
||||
}))(Overlay);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { Button, Link } from 'UI'
|
||||
import { session as sessionRoute, withSiteId } from 'App/routes'
|
||||
import stl from './AutoplayTimer.module.css';
|
||||
import clsOv from './overlay.module.css';
|
||||
|
||||
function AutoplayTimer({ nextId, siteId, history }) {
|
||||
let timer
|
||||
interface IProps extends RouteComponentProps {
|
||||
nextId: number;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
function AutoplayTimer({ nextId, siteId, history }: IProps) {
|
||||
let timer: NodeJS.Timer
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const [counter, setCounter] = useState(5);
|
||||
|
||||
|
|
@ -32,7 +37,7 @@ function AutoplayTimer({ nextId, siteId, history }) {
|
|||
}
|
||||
|
||||
if (cancelled)
|
||||
return ''
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={ cn(clsOv.overlay, stl.overlayBg) } >
|
||||
|
|
@ -50,7 +55,6 @@ function AutoplayTimer({ nextId, siteId, history }) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
export default withRouter(connect(state => ({
|
||||
siteId: state.getIn([ 'site', 'siteId' ]),
|
||||
nextId: parseInt(state.getIn([ 'sessions', 'nextId' ])),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ function SubHeader(props) {
|
|||
const [isCopied, setCopied] = React.useState(false);
|
||||
|
||||
const isAssist = window.location.pathname.includes('/assist/');
|
||||
if (isAssist) return null;
|
||||
|
||||
const location = props.currentLocation && props.currentLocation.length > 60 ? `${props.currentLocation.slice(0, 60)}...` : props.currentLocation
|
||||
return (
|
||||
|
|
@ -39,37 +38,39 @@ function SubHeader(props) {
|
|||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}>
|
||||
<div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1">
|
||||
{!isAssist && props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />}
|
||||
{!isAssist ? (
|
||||
<div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}>
|
||||
<div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1">
|
||||
{props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />}
|
||||
</div>
|
||||
<div className="cursor-pointer">
|
||||
<SharePopup
|
||||
entity="sessions"
|
||||
id={ props.sessionId }
|
||||
showCopyLink={true}
|
||||
trigger={
|
||||
<div className="flex items-center hover:bg-gray-light-shade rounded-md p-1">
|
||||
<Icon
|
||||
className="mr-2"
|
||||
disabled={ props.disabled }
|
||||
name="share-alt"
|
||||
size="16"
|
||||
/>
|
||||
<span>Share</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-4 hover:bg-gray-light-shade rounded-md p-1">
|
||||
<Bookmark noMargin sessionId={props.sessionId} />
|
||||
</div>
|
||||
<div>
|
||||
<Autoplay />
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-pointer">
|
||||
<SharePopup
|
||||
entity="sessions"
|
||||
id={ props.sessionId }
|
||||
showCopyLink={true}
|
||||
trigger={
|
||||
<div className="flex items-center hover:bg-gray-light-shade rounded-md p-1">
|
||||
<Icon
|
||||
className="mr-2"
|
||||
disabled={ props.disabled }
|
||||
name="share-alt"
|
||||
size="16"
|
||||
/>
|
||||
<span>Share</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-4 hover:bg-gray-light-shade rounded-md p-1">
|
||||
<Bookmark noMargin sessionId={props.sessionId} />
|
||||
</div>
|
||||
<div>
|
||||
<Autoplay />
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,26 +8,26 @@
|
|||
cursor: pointer;
|
||||
user-select: none;
|
||||
height: 26px;
|
||||
width: 56px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-light;
|
||||
background-color: $main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $gray-dark;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
margin-right: 10px;
|
||||
& svg {
|
||||
fill: $gray-dark;
|
||||
fill: white;
|
||||
opacity: .5;
|
||||
}
|
||||
&[data-is-live=true] {
|
||||
background-color: #42AE5E;
|
||||
color: white;
|
||||
& svg {
|
||||
fill: white;
|
||||
animation: fade 1s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ interface Props {
|
|||
function LiveTag({ isLive, onClick }: Props) {
|
||||
return (
|
||||
<button onClick={ onClick } className={ stl.liveTag } data-is-live={ isLive }>
|
||||
<Icon name="circle" size="8" marginRight="5" color="white" />
|
||||
<div>{'Live'}</div>
|
||||
<Icon name="circle" size="8" marginRight={5} color="white" />
|
||||
<div>{isLive ? 'Live' : 'Go live'}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ const SET_AUTOPLAY_VALUES = 'sessions/SET_AUTOPLAY_VALUES';
|
|||
const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
|
||||
const SET_FUNNEL_PAGE_FLAG = 'sessions/SET_FUNNEL_PAGE_FLAG';
|
||||
const SET_TIMELINE_POINTER = 'sessions/SET_TIMELINE_POINTER';
|
||||
const SET_TIMELINE_HOVER_POINTER = 'sessions/SET_TIMELINE_HOVER_POINTER';
|
||||
|
||||
const SET_SESSION_PATH = 'sessions/SET_SESSION_PATH';
|
||||
const LAST_PLAYED_SESSION_ID = `${name}/LAST_PLAYED_SESSION_ID`;
|
||||
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
|
||||
|
|
@ -61,6 +63,7 @@ const initialState = Map({
|
|||
timelinePointer: null,
|
||||
sessionPath: {},
|
||||
lastPlayedSessionId: null,
|
||||
timeLineTooltip: { time: 0, offset: 0, isVisible: false }
|
||||
});
|
||||
|
||||
const reducer = (state = initialState, action = {}) => {
|
||||
|
|
@ -187,6 +190,8 @@ const reducer = (state = initialState, action = {}) => {
|
|||
return state.set('funnelPage', action.funnelPage ? Map(action.funnelPage) : false);
|
||||
case SET_TIMELINE_POINTER:
|
||||
return state.set('timelinePointer', action.pointer);
|
||||
case SET_TIMELINE_HOVER_POINTER:
|
||||
return state.set('timeLineTooltip', action.timeLineTooltip);
|
||||
case SET_SESSION_PATH:
|
||||
return state.set('sessionPath', action.path);
|
||||
case LAST_PLAYED_SESSION_ID:
|
||||
|
|
@ -350,6 +355,13 @@ export function setTimelinePointer(pointer) {
|
|||
};
|
||||
}
|
||||
|
||||
export function setTimelineHoverTime(timeLineTooltip) {
|
||||
return {
|
||||
type: SET_TIMELINE_HOVER_POINTER,
|
||||
timeLineTooltip
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionPath(path) {
|
||||
return {
|
||||
type: SET_SESSION_PATH,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// @ts-ignore
|
||||
import { Decoder } from "syncod";
|
||||
import logger from 'App/logger';
|
||||
|
||||
|
|
@ -5,7 +6,9 @@ import Resource, { TYPES } from 'Types/session/resource'; // MBTODO: player type
|
|||
import { TYPES as EVENT_TYPES } from 'Types/session/event';
|
||||
import Log from 'Types/session/log';
|
||||
|
||||
import { update } from '../store';
|
||||
import { update, getState } from '../store';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import {
|
||||
init as initListsDepr,
|
||||
append as listAppend,
|
||||
|
|
@ -24,7 +27,7 @@ import ActivityManager from './managers/ActivityManager';
|
|||
import AssistManager from './managers/AssistManager';
|
||||
|
||||
import MFileReader from './messages/MFileReader';
|
||||
import loadFiles from './network/loadFiles';
|
||||
import { loadFiles, checkUnprocessedMobs } from './network/loadFiles';
|
||||
|
||||
import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen/StatedScreen';
|
||||
import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager';
|
||||
|
|
@ -70,29 +73,30 @@ import type { Timed } from './messages/timed';
|
|||
|
||||
export default class MessageDistributor extends StatedScreen {
|
||||
// TODO: consistent with the other data-lists
|
||||
private readonly locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker();
|
||||
private readonly locationManager: ListWalker<SetPageLocation> = new ListWalker();
|
||||
private readonly loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
|
||||
private readonly connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
|
||||
private readonly performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
|
||||
private readonly windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
|
||||
private readonly clickManager: ListWalker<MouseClick> = new ListWalker();
|
||||
private locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker();
|
||||
private locationManager: ListWalker<SetPageLocation> = new ListWalker();
|
||||
private loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
|
||||
private connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
|
||||
private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
|
||||
private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
|
||||
private clickManager: ListWalker<MouseClick> = new ListWalker();
|
||||
|
||||
private readonly resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
|
||||
private readonly pagesManager: PagesManager;
|
||||
private readonly mouseMoveManager: MouseMoveManager;
|
||||
private readonly assistManager: AssistManager;
|
||||
private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
|
||||
private pagesManager: PagesManager;
|
||||
private mouseMoveManager: MouseMoveManager;
|
||||
private assistManager: AssistManager;
|
||||
|
||||
private readonly scrollManager: ListWalker<SetViewportScroll> = new ListWalker();
|
||||
private scrollManager: ListWalker<SetViewportScroll> = new ListWalker();
|
||||
|
||||
private readonly decoder = new Decoder();
|
||||
private readonly lists = initLists();
|
||||
|
||||
private activirtManager: ActivityManager | null = null;
|
||||
private activityManager: ActivityManager | null = null;
|
||||
|
||||
private readonly sessionStart: number;
|
||||
private sessionStart: number;
|
||||
private navigationStartOffset: number = 0;
|
||||
private lastMessageTime: number = 0;
|
||||
private lastRecordedMessageTime: number = 0;
|
||||
|
||||
constructor(private readonly session: any /*Session*/, config: any, live: boolean) {
|
||||
super();
|
||||
|
|
@ -106,7 +110,7 @@ export default class MessageDistributor extends StatedScreen {
|
|||
initListsDepr({})
|
||||
this.assistManager.connect();
|
||||
} else {
|
||||
this.activirtManager = new ActivityManager(this.session.duration.milliseconds);
|
||||
this.activityManager = new ActivityManager(this.session.duration.milliseconds);
|
||||
/* == REFACTOR_ME == */
|
||||
const eventList = this.session.events.toJSON();
|
||||
initListsDepr({
|
||||
|
|
@ -115,12 +119,13 @@ export default class MessageDistributor extends StatedScreen {
|
|||
resource: this.session.resources.toJSON(),
|
||||
});
|
||||
|
||||
eventList.forEach(e => {
|
||||
// TODO: fix types for events, remove immutable js
|
||||
eventList.forEach((e: Record<string, string>) => {
|
||||
if (e.type === EVENT_TYPES.LOCATION) { //TODO type system
|
||||
this.locationEventManager.append(e);
|
||||
}
|
||||
});
|
||||
this.session.errors.forEach(e => {
|
||||
this.session.errors.forEach((e: Record<string, string>) => {
|
||||
this.lists.exceptions.append(e);
|
||||
});
|
||||
/* === */
|
||||
|
|
@ -129,77 +134,160 @@ export default class MessageDistributor extends StatedScreen {
|
|||
}
|
||||
|
||||
private waitingForFiles: boolean = false
|
||||
private loadMessages(): void {
|
||||
|
||||
private onFileSuccessRead() {
|
||||
this.windowNodeCounter.reset()
|
||||
|
||||
if (this.activityManager) {
|
||||
this.activityManager.end()
|
||||
update({
|
||||
skipIntervals: this.activityManager.list
|
||||
})
|
||||
}
|
||||
|
||||
this.waitingForFiles = false
|
||||
this.setMessagesLoading(false)
|
||||
}
|
||||
|
||||
private readAndDistributeMessages(byteArray: Uint8Array, onReadCb?: (msg: Message) => void) {
|
||||
const msgs: Array<Message> = []
|
||||
const reader = new MFileReader(new Uint8Array(), this.sessionStart)
|
||||
|
||||
reader.append(byteArray)
|
||||
let next: ReturnType<MFileReader['next']>
|
||||
while (next = reader.next()) {
|
||||
const [msg, index] = next
|
||||
this.distributeMessage(msg, index)
|
||||
msgs.push(msg)
|
||||
onReadCb?.(msg)
|
||||
}
|
||||
|
||||
logger.info("Messages count: ", msgs.length, msgs)
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
private processStateUpdates(msgs: Message[]) {
|
||||
// @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first))
|
||||
const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id);
|
||||
this.pagesManager.sortPages((m1, m2) => {
|
||||
if (m1.time === m2.time) {
|
||||
if (m1.tp === "remove_node" && m2.tp !== "remove_node") {
|
||||
if (headChildrenIds.includes(m1.id)) {
|
||||
return -1;
|
||||
}
|
||||
} else if (m2.tp === "remove_node" && m1.tp !== "remove_node") {
|
||||
if (headChildrenIds.includes(m2.id)) {
|
||||
return 1;
|
||||
}
|
||||
} else if (m2.tp === "remove_node" && m1.tp === "remove_node") {
|
||||
const m1FromHead = headChildrenIds.includes(m1.id);
|
||||
const m2FromHead = headChildrenIds.includes(m2.id);
|
||||
if (m1FromHead && !m2FromHead) {
|
||||
return -1;
|
||||
} else if (m2FromHead && !m1FromHead) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
|
||||
const stateToUpdate: {[key:string]: any} = {
|
||||
performanceChartData: this.performanceTrackManager.chartData,
|
||||
performanceAvaliability: this.performanceTrackManager.avaliability,
|
||||
}
|
||||
LIST_NAMES.forEach(key => {
|
||||
stateToUpdate[ `${ key }List` ] = this.lists[ key ].list
|
||||
})
|
||||
update(stateToUpdate)
|
||||
this.setMessagesLoading(false)
|
||||
}
|
||||
|
||||
private loadMessages() {
|
||||
this.setMessagesLoading(true)
|
||||
this.waitingForFiles = true
|
||||
|
||||
const r = new MFileReader(new Uint8Array(), this.sessionStart)
|
||||
const msgs: Array<Message> = []
|
||||
const onData = (byteArray: Uint8Array) => {
|
||||
const msgs = this.readAndDistributeMessages(byteArray)
|
||||
this.processStateUpdates(msgs)
|
||||
}
|
||||
|
||||
loadFiles(this.session.mobsUrl,
|
||||
b => {
|
||||
r.append(b)
|
||||
let next: ReturnType<MFileReader['next']>
|
||||
while (next = r.next()) {
|
||||
const [msg, index] = next
|
||||
this.distributeMessage(msg, index)
|
||||
msgs.push(msg)
|
||||
}
|
||||
|
||||
logger.info("Messages count: ", msgs.length, msgs)
|
||||
|
||||
// @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first))
|
||||
const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id);
|
||||
this.pagesManager.sortPages((m1, m2) => {
|
||||
if (m1.time === m2.time) {
|
||||
if (m1.tp === "remove_node" && m2.tp !== "remove_node") {
|
||||
if (headChildrenIds.includes(m1.id)) {
|
||||
return -1;
|
||||
}
|
||||
} else if (m2.tp === "remove_node" && m1.tp !== "remove_node") {
|
||||
if (headChildrenIds.includes(m2.id)) {
|
||||
return 1;
|
||||
}
|
||||
} else if (m2.tp === "remove_node" && m1.tp === "remove_node") {
|
||||
const m1FromHead = headChildrenIds.includes(m1.id);
|
||||
const m2FromHead = headChildrenIds.includes(m2.id);
|
||||
if (m1FromHead && !m2FromHead) {
|
||||
return -1;
|
||||
} else if (m2FromHead && !m1FromHead) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
|
||||
const stateToUpdate: {[key:string]: any} = {
|
||||
performanceChartData: this.performanceTrackManager.chartData,
|
||||
performanceAvaliability: this.performanceTrackManager.avaliability,
|
||||
}
|
||||
LIST_NAMES.forEach(key => {
|
||||
stateToUpdate[ `${ key }List` ] = this.lists[ key ].list
|
||||
})
|
||||
update(stateToUpdate)
|
||||
this.setMessagesLoading(false)
|
||||
}
|
||||
onData
|
||||
)
|
||||
.then(() => {
|
||||
this.windowNodeCounter.reset()
|
||||
if (this.activirtManager) {
|
||||
this.activirtManager.end()
|
||||
update({
|
||||
skipIntervals: this.activirtManager.list
|
||||
.then(() => this.onFileSuccessRead())
|
||||
.catch(async () => {
|
||||
checkUnprocessedMobs(this.session.sessionId)
|
||||
.then(file => file ? onData(file) : Promise.reject('No session file'))
|
||||
.then(() => this.onFileSuccessRead())
|
||||
.catch((e) => {
|
||||
logger.error(e)
|
||||
update({ error: true })
|
||||
toast.error('Error getting a session replay file')
|
||||
})
|
||||
.finally(() => {
|
||||
this.waitingForFiles = false
|
||||
this.setMessagesLoading(false)
|
||||
})
|
||||
}
|
||||
this.waitingForFiles = false
|
||||
this.setMessagesLoading(false)
|
||||
|
||||
})
|
||||
.catch(e => {
|
||||
logger.error(e)
|
||||
this.waitingForFiles = false
|
||||
this.setMessagesLoading(false)
|
||||
}
|
||||
|
||||
public async reloadWithUnprocessedFile() {
|
||||
// assist will pause and skip messages to prevent timestamp related errors
|
||||
this.assistManager.toggleTimeTravelJump()
|
||||
this.reloadMessageManagers()
|
||||
|
||||
this.setMessagesLoading(true)
|
||||
this.waitingForFiles = true
|
||||
|
||||
const onData = (byteArray: Uint8Array) => {
|
||||
const onReadCallback = () => this.setLastRecordedMessageTime(this.lastMessageTime)
|
||||
const msgs = this.readAndDistributeMessages(byteArray, onReadCallback)
|
||||
this.sessionStart = msgs[0].time
|
||||
this.processStateUpdates(msgs)
|
||||
}
|
||||
|
||||
// unpausing assist
|
||||
const unpauseAssist = () => {
|
||||
this.assistManager.toggleTimeTravelJump()
|
||||
update({
|
||||
liveTimeTravel: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const unprocessedFile = await checkUnprocessedMobs(this.session.sessionId)
|
||||
|
||||
Promise.resolve(onData(unprocessedFile))
|
||||
.then(() => this.onFileSuccessRead())
|
||||
.then(unpauseAssist)
|
||||
} catch (unprocessedFilesError) {
|
||||
logger.error(unprocessedFilesError)
|
||||
update({ error: true })
|
||||
})
|
||||
toast.error('Error getting a session replay file')
|
||||
this.assistManager.toggleTimeTravelJump()
|
||||
} finally {
|
||||
this.waitingForFiles = false
|
||||
this.setMessagesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
private reloadMessageManagers() {
|
||||
this.locationEventManager = new ListWalker();
|
||||
this.locationManager = new ListWalker();
|
||||
this.loadedLocationManager = new ListWalker();
|
||||
this.connectionInfoManger = new ListWalker();
|
||||
this.clickManager = new ListWalker();
|
||||
this.scrollManager = new ListWalker();
|
||||
this.resizeManager = new ListWalker([]);
|
||||
|
||||
this.performanceTrackManager = new PerformanceTrackManager()
|
||||
this.windowNodeCounter = new WindowNodeCounter();
|
||||
this.pagesManager = new PagesManager(this, this.session.isMobile)
|
||||
this.mouseMoveManager = new MouseMoveManager(this);
|
||||
this.activityManager = new ActivityManager(this.session.duration.milliseconds);
|
||||
}
|
||||
|
||||
move(t: number, index?: number): void {
|
||||
|
|
@ -246,6 +334,7 @@ export default class MessageDistributor extends StatedScreen {
|
|||
LIST_NAMES.forEach(key => {
|
||||
const lastMsg = this.lists[key].moveGetLast(t, key === 'exceptions' ? undefined : index);
|
||||
if (lastMsg != null) {
|
||||
// @ts-ignore TODO: fix types
|
||||
stateToUpdate[`${key}ListNow`] = this.lists[key].listNow;
|
||||
}
|
||||
});
|
||||
|
|
@ -279,10 +368,11 @@ export default class MessageDistributor extends StatedScreen {
|
|||
}
|
||||
}
|
||||
|
||||
private decodeMessage(msg, keys: Array<string>) {
|
||||
private decodeMessage(msg: any, keys: Array<string>) {
|
||||
const decoded = {};
|
||||
try {
|
||||
keys.forEach(key => {
|
||||
// @ts-ignore TODO: types for decoder
|
||||
decoded[key] = this.decoder.decode(msg[key]);
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -294,7 +384,8 @@ export default class MessageDistributor extends StatedScreen {
|
|||
|
||||
/* Binded */
|
||||
distributeMessage(msg: Message, index: number): void {
|
||||
this.lastMessageTime = Math.max(msg.time, this.lastMessageTime)
|
||||
const lastMessageTime = Math.max(msg.time, this.lastMessageTime)
|
||||
this.lastMessageTime = lastMessageTime
|
||||
if ([
|
||||
"mouse_move",
|
||||
"mouse_click",
|
||||
|
|
@ -304,7 +395,7 @@ export default class MessageDistributor extends StatedScreen {
|
|||
"set_viewport_size",
|
||||
"set_viewport_scroll",
|
||||
].includes(msg.tp)) {
|
||||
this.activirtManager?.updateAcctivity(msg.time);
|
||||
this.activityManager?.updateAcctivity(msg.time);
|
||||
}
|
||||
//const index = i + index; //?
|
||||
let decoded;
|
||||
|
|
@ -444,4 +535,12 @@ export default class MessageDistributor extends StatedScreen {
|
|||
update(INITIAL_STATE);
|
||||
this.assistManager.clear();
|
||||
}
|
||||
|
||||
public setLastRecordedMessageTime(time: number) {
|
||||
this.lastRecordedMessageTime = time;
|
||||
}
|
||||
|
||||
public getLastRecordedMessageTime(): number {
|
||||
return this.lastRecordedMessageTime;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,10 +56,11 @@ export function getStatusText(status: ConnectionStatus): string {
|
|||
}
|
||||
|
||||
export interface State {
|
||||
calling: CallingState,
|
||||
peerConnectionStatus: ConnectionStatus,
|
||||
remoteControl: RemoteControlStatus,
|
||||
annotating: boolean,
|
||||
calling: CallingState;
|
||||
peerConnectionStatus: ConnectionStatus;
|
||||
remoteControl: RemoteControlStatus;
|
||||
annotating: boolean;
|
||||
assistStart: number;
|
||||
}
|
||||
|
||||
export const INITIAL_STATE: State = {
|
||||
|
|
@ -67,12 +68,16 @@ export const INITIAL_STATE: State = {
|
|||
peerConnectionStatus: ConnectionStatus.Connecting,
|
||||
remoteControl: RemoteControlStatus.Disabled,
|
||||
annotating: false,
|
||||
assistStart: 0,
|
||||
}
|
||||
|
||||
const MAX_RECONNECTION_COUNT = 4;
|
||||
|
||||
|
||||
export default class AssistManager {
|
||||
private timeTravelJump = false;
|
||||
private jumped = false;
|
||||
|
||||
constructor(private session: any, private md: MessageDistributor, private config: any) {}
|
||||
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
|
|
@ -121,6 +126,10 @@ export default class AssistManager {
|
|||
let waitingForMessages = true
|
||||
let showDisconnectTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let inactiveTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const now = +new Date()
|
||||
update({ assistStart: now })
|
||||
|
||||
import('socket.io-client').then(({ default: io }) => {
|
||||
if (this.cleaned) { return }
|
||||
if (this.socket) { this.socket.close() } // TODO: single socket connection
|
||||
|
|
@ -145,7 +154,7 @@ export default class AssistManager {
|
|||
update({ calling: CallingState.NoCall })
|
||||
})
|
||||
socket.on('messages', messages => {
|
||||
jmr.append(messages) // as RawMessage[]
|
||||
!this.timeTravelJump && jmr.append(messages) // as RawMessage[]
|
||||
|
||||
if (waitingForMessages) {
|
||||
waitingForMessages = false // TODO: more explicit
|
||||
|
|
@ -153,12 +162,21 @@ export default class AssistManager {
|
|||
|
||||
// Call State
|
||||
if (getState().calling === CallingState.Reconnecting) {
|
||||
this._call() // reconnecting call (todo improve code separation)
|
||||
this._callSessionPeer() // reconnecting call (todo improve code separation)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.timeTravelJump) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let msg = reader.readNext();msg !== null;msg = reader.readNext()) {
|
||||
//@ts-ignore
|
||||
if (this.jumped) {
|
||||
// @ts-ignore
|
||||
msg.time = this.md.getLastRecordedMessageTime() + msg.time
|
||||
}
|
||||
// @ts-ignore TODO: fix msg types in generator
|
||||
this.md.distributeMessage(msg, msg._index)
|
||||
}
|
||||
})
|
||||
|
|
@ -521,6 +539,11 @@ export default class AssistManager {
|
|||
|
||||
private annot: AnnotationCanvas | null = null
|
||||
|
||||
toggleTimeTravelJump() {
|
||||
this.jumped = true;
|
||||
this.timeTravelJump = !this.timeTravelJump;
|
||||
}
|
||||
|
||||
/* ==== Cleaning ==== */
|
||||
private cleaned: boolean = false
|
||||
clear() {
|
||||
|
|
|
|||
|
|
@ -118,4 +118,4 @@ export default class ListWalker<T extends Timed> {
|
|||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import RawMessageReader from './RawMessageReader';
|
|||
// which should be probably somehow incapsulated
|
||||
export default class MFileReader extends RawMessageReader {
|
||||
private pLastMessageID: number = 0
|
||||
private currentTime: number = 0
|
||||
private currentTime: number
|
||||
public error: boolean = false
|
||||
constructor(data: Uint8Array, private readonly startTime: number) {
|
||||
constructor(data: Uint8Array, private startTime?: number) {
|
||||
super(data)
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +60,9 @@ export default class MFileReader extends RawMessageReader {
|
|||
}
|
||||
|
||||
if (rMsg.tp === "timestamp") {
|
||||
if (!this.startTime) {
|
||||
this.startTime = rMsg.timestamp
|
||||
}
|
||||
this.currentTime = rMsg.timestamp - this.startTime
|
||||
return this.next()
|
||||
}
|
||||
|
|
@ -68,6 +71,7 @@ export default class MFileReader extends RawMessageReader {
|
|||
time: this.currentTime,
|
||||
_index: this.pLastMessageID,
|
||||
})
|
||||
|
||||
return [msg, this.pLastMessageID]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,4 +68,4 @@ export default class MStreamReader {
|
|||
_index: this.idx++,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export interface Timed { readonly time: number };
|
||||
export interface Timed { time: number };
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
const NO_NTH_FILE = "nnf"
|
||||
import APIClient from 'App/api_client';
|
||||
|
||||
export default function load(
|
||||
const NO_NTH_FILE = "nnf"
|
||||
const NO_UNPROCESSED_FILES = "nuf"
|
||||
|
||||
const getUnprocessedFileLink = (sessionId: string) => '/unprocessed/' + sessionId
|
||||
|
||||
type onDataCb = (data: Uint8Array) => void
|
||||
|
||||
export const loadFiles = (
|
||||
urls: string[],
|
||||
onData: (ba: Uint8Array) => void,
|
||||
): Promise<void> {
|
||||
onData: onDataCb,
|
||||
): Promise<void> => {
|
||||
const firstFileURL = urls[0]
|
||||
urls = urls.slice(1)
|
||||
if (!firstFileURL) {
|
||||
|
|
@ -11,31 +18,16 @@ export default function load(
|
|||
}
|
||||
return window.fetch(firstFileURL)
|
||||
.then(r => {
|
||||
if (r.status >= 400) {
|
||||
throw new Error(`no start file. status code ${ r.status }`)
|
||||
}
|
||||
return r.arrayBuffer()
|
||||
return processAPIStreamResponse(r, true)
|
||||
})
|
||||
.then(b => new Uint8Array(b))
|
||||
.then(onData)
|
||||
.then(() =>
|
||||
urls.reduce((p, url) =>
|
||||
p.then(() =>
|
||||
window.fetch(url)
|
||||
.then(r => {
|
||||
return new Promise<ArrayBuffer>((res, rej) => {
|
||||
if (r.status == 404) {
|
||||
rej(NO_NTH_FILE)
|
||||
return
|
||||
}
|
||||
if (r.status >= 400) {
|
||||
rej(`Bad endfile status code ${r.status}`)
|
||||
return
|
||||
}
|
||||
res(r.arrayBuffer())
|
||||
})
|
||||
return processAPIStreamResponse(r, false)
|
||||
})
|
||||
.then(b => new Uint8Array(b))
|
||||
.then(onData)
|
||||
),
|
||||
Promise.resolve(),
|
||||
|
|
@ -48,3 +40,32 @@ export default function load(
|
|||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
export const checkUnprocessedMobs = async (sessionId: string) => {
|
||||
try {
|
||||
const api = new APIClient()
|
||||
const res = await api.fetch(getUnprocessedFileLink(sessionId))
|
||||
if (res.status >= 400) {
|
||||
throw NO_UNPROCESSED_FILES
|
||||
}
|
||||
const byteArray = await processAPIStreamResponse(res, false)
|
||||
return byteArray
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const processAPIStreamResponse = (response: Response, isFirstFile: boolean) => {
|
||||
return new Promise<ArrayBuffer>((res, rej) => {
|
||||
if (response.status === 404 && !isFirstFile) {
|
||||
return rej(NO_NTH_FILE)
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
return rej(
|
||||
isFirstFile ? `no start file. status code ${ response.status }`
|
||||
: `Bad endfile status code ${response.status}`
|
||||
)
|
||||
}
|
||||
res(response.arrayBuffer())
|
||||
}).then(buffer => new Uint8Array(buffer))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const INITIAL_STATE = {
|
|||
inspectorMode: false,
|
||||
live: false,
|
||||
livePlay: false,
|
||||
liveTimeTravel: false,
|
||||
} as const;
|
||||
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ export const INITIAL_NON_RESETABLE_STATE = {
|
|||
skipToIssue: initialSkipToIssue,
|
||||
autoplay: initialAutoplay,
|
||||
speed: initialSpeed,
|
||||
showEvents: initialShowEvents
|
||||
showEvents: initialShowEvents,
|
||||
}
|
||||
|
||||
export default class Player extends MessageDistributor {
|
||||
|
|
@ -118,9 +119,12 @@ export default class Player extends MessageDistributor {
|
|||
});
|
||||
}
|
||||
|
||||
if (live && time > endTime) {
|
||||
// throttle store updates
|
||||
// TODO: make it possible to change frame rate
|
||||
if (live && time - endTime > 100) {
|
||||
update({
|
||||
endTime: time,
|
||||
livePlay: endTime - time < 900
|
||||
});
|
||||
}
|
||||
this._setTime(time);
|
||||
|
|
@ -153,20 +157,22 @@ export default class Player extends MessageDistributor {
|
|||
}
|
||||
|
||||
jump(time = getState().time, index: number) {
|
||||
const { live } = getState();
|
||||
if (live) return;
|
||||
const { live, liveTimeTravel, endTime } = getState();
|
||||
if (live && !liveTimeTravel) return;
|
||||
|
||||
if (getState().playing) {
|
||||
cancelAnimationFrame(this._animationFrameRequestId);
|
||||
// this._animationFrameRequestId = requestAnimationFrame(() => {
|
||||
this._setTime(time, index);
|
||||
this._startAnimation();
|
||||
update({ livePlay: time === getState().endTime });
|
||||
// throttilg the redux state update from each frame to nearly half a second
|
||||
// which is better for performance and component rerenders
|
||||
update({ livePlay: Math.abs(time - endTime) < 500 });
|
||||
//});
|
||||
} else {
|
||||
//this._animationFrameRequestId = requestAnimationFrame(() => {
|
||||
this._setTime(time, index);
|
||||
update({ livePlay: time === getState().endTime });
|
||||
update({ livePlay: Math.abs(time - endTime) < 500 });
|
||||
//});
|
||||
}
|
||||
}
|
||||
|
|
@ -246,6 +252,20 @@ export default class Player extends MessageDistributor {
|
|||
this._updateSpeed(Math.max(1, speed/2));
|
||||
}
|
||||
|
||||
toggleTimetravel() {
|
||||
if (!getState().liveTimeTravel) {
|
||||
this.reloadWithUnprocessedFile()
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
|
||||
jumpToLive() {
|
||||
cancelAnimationFrame(this._animationFrameRequestId);
|
||||
this._setTime(getState().endTime);
|
||||
this._startAnimation();
|
||||
update({ livePlay: true });
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.pause();
|
||||
super.clean();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export const requestReleaseRemoteControl = initCheck((...args) => instance.assis
|
|||
export const markTargets = initCheck((...args) => instance.markTargets(...args))
|
||||
export const activeTarget = initCheck((...args) => instance.activeTarget(...args))
|
||||
export const toggleAnnotation = initCheck((...args) => instance.assistManager.toggleAnnotation(...args))
|
||||
export const toggleTimetravel = initCheck((...args) => instance.toggleTimetravel(...args))
|
||||
export const jumpToLive = initCheck((...args) => instance.jumpToLive(...args))
|
||||
|
||||
export const Controls = {
|
||||
jump,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue