feat(player): player file loader refactoring (#1203)
* change(ui): refactor mob loading * refactor(player): split message loader into separate file, remove toast dependency out of player lib, fix types, fix inspector and screen context * refactor(player): simplify file loading, add safe error throws * refactor(player): move loading status changers to the end of the flow
This commit is contained in:
parent
55040b2c76
commit
a24d99f75c
21 changed files with 330 additions and 219 deletions
|
|
@ -8,10 +8,11 @@ import { createLiveWebPlayer } from 'Player';
|
|||
import PlayerBlockHeader from './Player/LivePlayer/LivePlayerBlockHeader';
|
||||
import PlayerBlock from './Player/LivePlayer/LivePlayerBlock';
|
||||
import styles from '../Session_/session.module.css';
|
||||
import Session from 'App/mstore/types/session';
|
||||
import Session from 'App/types/session';
|
||||
import withLocationHandlers from 'HOCs/withLocationHandlers';
|
||||
import APIClient from 'App/api_client';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
|
|
@ -58,15 +59,21 @@ function LivePlayer({
|
|||
if (isEnterprise) {
|
||||
new APIClient().get('/config/assist/credentials').then(r => r.json())
|
||||
.then(({ data }) => {
|
||||
const [player, store] = createLiveWebPlayer(sessionWithAgentData, data, (state) =>
|
||||
makeAutoObservable(state)
|
||||
const [player, store] = createLiveWebPlayer(
|
||||
sessionWithAgentData,
|
||||
data,
|
||||
(state) => makeAutoObservable(state),
|
||||
toast
|
||||
);
|
||||
setContextValue({ player, store });
|
||||
playerInst = player;
|
||||
})
|
||||
} else {
|
||||
const [player, store] = createLiveWebPlayer(sessionWithAgentData, null, (state) =>
|
||||
makeAutoObservable(state)
|
||||
const [player, store] = createLiveWebPlayer(
|
||||
sessionWithAgentData,
|
||||
null,
|
||||
(state) => makeAutoObservable(state),
|
||||
toast
|
||||
);
|
||||
setContextValue({ player, store });
|
||||
playerInst = player;
|
||||
|
|
@ -117,19 +124,19 @@ function LivePlayer({
|
|||
}
|
||||
|
||||
export default withPermissions(
|
||||
['ASSIST_LIVE'],
|
||||
'',
|
||||
true
|
||||
)(
|
||||
connect(
|
||||
(state: any) => {
|
||||
return {
|
||||
session: state.getIn(['sessions', 'current']),
|
||||
showAssist: state.getIn(['sessions', 'showChatWindow']),
|
||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
||||
userEmail: state.getIn(['user', 'account', 'email']),
|
||||
userName: state.getIn(['user', 'account', 'name']),
|
||||
};
|
||||
}
|
||||
)(withLocationHandlers()(React.memo(LivePlayer)))
|
||||
)
|
||||
['ASSIST_LIVE'],
|
||||
'',
|
||||
true
|
||||
)(
|
||||
connect(
|
||||
(state: any) => {
|
||||
return {
|
||||
session: state.getIn(['sessions', 'current']),
|
||||
showAssist: state.getIn(['sessions', 'showChatWindow']),
|
||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
||||
userEmail: state.getIn(['user', 'account', 'email']),
|
||||
userName: state.getIn(['user', 'account', 'name']),
|
||||
};
|
||||
}
|
||||
)(withLocationHandlers()(React.memo(LivePlayer)))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import withLocationHandlers from 'HOCs/withLocationHandlers';
|
|||
import PlayerContent from './ThinPlayerContent';
|
||||
import { IPlayerContext, PlayerContext, defaultContextValue } from '../../playerContext';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
function WebPlayer(props: any) {
|
||||
const {
|
||||
|
|
@ -20,8 +20,10 @@ function WebPlayer(props: any) {
|
|||
const [contextValue, setContextValue] = useState<IPlayerContext>(defaultContextValue);
|
||||
|
||||
useEffect(() => {
|
||||
const [WebPlayerInst, PlayerStore] = createClickMapPlayer(customSession, (state) =>
|
||||
makeAutoObservable(state)
|
||||
const [WebPlayerInst, PlayerStore] = createClickMapPlayer(
|
||||
customSession,
|
||||
(state) => makeAutoObservable(state),
|
||||
toast,
|
||||
);
|
||||
setContextValue({ player: WebPlayerInst, store: PlayerStore });
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { IPlayerContext, PlayerContext, defaultContextValue } from './playerCont
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { Note } from "App/services/NotesService";
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
const TABS = {
|
||||
EVENTS: 'User Events',
|
||||
|
|
@ -44,8 +45,10 @@ function WebPlayer(props: any) {
|
|||
if (!session.sessionId || contextValue.player !== undefined) return;
|
||||
fetchList('issues');
|
||||
|
||||
const [WebPlayerInst, PlayerStore] = createWebPlayer(session, (state) =>
|
||||
makeAutoObservable(state)
|
||||
const [WebPlayerInst, PlayerStore] = createWebPlayer(
|
||||
session,
|
||||
(state) => makeAutoObservable(state),
|
||||
toast,
|
||||
);
|
||||
setContextValue({ player: WebPlayerInst, store: PlayerStore });
|
||||
playerInst = WebPlayerInst;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function Timeline(props: IProps) {
|
|||
ready,
|
||||
endTime,
|
||||
devtoolsLoading,
|
||||
domLoading,
|
||||
} = store.get()
|
||||
const { issues } = props;
|
||||
const notes = notesStore.sessionNotes
|
||||
|
|
@ -170,7 +171,7 @@ function Timeline(props: IProps) {
|
|||
/>
|
||||
)) : null}
|
||||
<div className={stl.timeline} ref={timelineRef}>
|
||||
{devtoolsLoading || !ready ? <div className={stl.stripes} /> : null}
|
||||
{devtoolsLoading || domLoading || !ready ? <div className={stl.stripes} /> : null}
|
||||
</div>
|
||||
|
||||
{events.map((e) => (
|
||||
|
|
|
|||
|
|
@ -25,3 +25,21 @@ export interface Store<G extends Object, S extends Object = G> {
|
|||
update(state: Partial<S>): void
|
||||
}
|
||||
|
||||
|
||||
export interface SessionFilesInfo {
|
||||
startedAt: number
|
||||
sessionId: string
|
||||
isMobile: boolean
|
||||
agentToken?: string
|
||||
duration: number
|
||||
domURL: string[]
|
||||
devtoolsURL: string[]
|
||||
/** deprecated */
|
||||
mobsUrl: string[]
|
||||
fileKey: string | null
|
||||
events: Record<string, any>[]
|
||||
stackEvents: Record<string, any>[]
|
||||
frustrations: Record<string, any>[]
|
||||
errors: Record<string, any>[]
|
||||
agentInfo?: { email: string, name: string }
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import SimpleStore from './common/SimpleStore'
|
||||
import type { Store } from './common/types'
|
||||
import type { Store, SessionFilesInfo } from './common/types'
|
||||
|
||||
import WebPlayer from './web/WebPlayer'
|
||||
import WebLivePlayer from './web/WebLivePlayer'
|
||||
|
|
@ -14,7 +14,11 @@ type WebLivePlayerStore = Store<WebLiveState>
|
|||
export type IWebLivePlayer = WebLivePlayer
|
||||
export type IWebLivePlayerStore = WebLivePlayerStore
|
||||
|
||||
export function createWebPlayer(session: Record<string, any>, wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore): [IWebPlayer, IWebPlayerStore] {
|
||||
export function createWebPlayer(
|
||||
session: SessionFilesInfo,
|
||||
wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore,
|
||||
uiErrorHandler?: { error: (msg: string) => void }
|
||||
): [IWebPlayer, IWebPlayerStore] {
|
||||
let store: WebPlayerStore = new SimpleStore<WebState>({
|
||||
...WebPlayer.INITIAL_STATE,
|
||||
})
|
||||
|
|
@ -22,12 +26,16 @@ export function createWebPlayer(session: Record<string, any>, wrapStore?: (s:IWe
|
|||
store = wrapStore(store)
|
||||
}
|
||||
|
||||
const player = new WebPlayer(store, session, false)
|
||||
const player = new WebPlayer(store, session, false, false, uiErrorHandler)
|
||||
return [player, store]
|
||||
}
|
||||
|
||||
|
||||
export function createClickMapPlayer(session: Record<string, any>, wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore): [IWebPlayer, IWebPlayerStore] {
|
||||
export function createClickMapPlayer(
|
||||
session: SessionFilesInfo,
|
||||
wrapStore?: (s:IWebPlayerStore) => IWebPlayerStore,
|
||||
uiErrorHandler?: { error: (msg: string) => void }
|
||||
): [IWebPlayer, IWebPlayerStore] {
|
||||
let store: WebPlayerStore = new SimpleStore<WebState>({
|
||||
...WebPlayer.INITIAL_STATE,
|
||||
})
|
||||
|
|
@ -35,11 +43,16 @@ export function createClickMapPlayer(session: Record<string, any>, wrapStore?: (
|
|||
store = wrapStore(store)
|
||||
}
|
||||
|
||||
const player = new WebPlayer(store, session, false, true)
|
||||
const player = new WebPlayer(store, session, false, true, uiErrorHandler)
|
||||
return [player, store]
|
||||
}
|
||||
|
||||
export function createLiveWebPlayer(session: Record<string, any>, config: RTCIceServer[] | null, wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore): [IWebLivePlayer, IWebLivePlayerStore] {
|
||||
export function createLiveWebPlayer(
|
||||
session: SessionFilesInfo,
|
||||
config: RTCIceServer[] | null,
|
||||
wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore,
|
||||
uiErrorHandler?: { error: (msg: string) => void }
|
||||
): [IWebLivePlayer, IWebLivePlayerStore] {
|
||||
let store: WebLivePlayerStore = new SimpleStore<WebLiveState>({
|
||||
...WebLivePlayer.INITIAL_STATE,
|
||||
})
|
||||
|
|
@ -47,6 +60,6 @@ export function createLiveWebPlayer(session: Record<string, any>, config: RTCIce
|
|||
store = wrapStore(store)
|
||||
}
|
||||
|
||||
const player = new WebLivePlayer(store, session, config)
|
||||
const player = new WebLivePlayer(store, session, config, uiErrorHandler)
|
||||
return [player, store]
|
||||
}
|
||||
|
|
|
|||
3
frontend/app/player/guards.ts
Normal file
3
frontend/app/player/guards.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isRootNode(node: Node): node is Document {
|
||||
return node.nodeType === Node.DOCUMENT_NODE || node instanceof Document
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Store, Moveable, Interval } from '../common/types';
|
||||
import type { Store, Interval } from '../common/types';
|
||||
import MessageManager from 'App/player/web/MessageManager'
|
||||
|
||||
|
||||
const fps = 60
|
||||
const performance: { now: () => number } = window.performance || { now: Date.now.bind(Date) }
|
||||
const requestAnimationFrame: typeof window.requestAnimationFrame =
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import * as typedLocalStorage from './localStorage';
|
||||
|
||||
import type { Moveable, Cleanable, Store } from '../common/types';
|
||||
import type { Store } from '../common/types';
|
||||
import Animator from './Animator';
|
||||
import type { GetState as AnimatorGetState } from './Animator';
|
||||
import MessageManager from "Player/web/MessageManager";
|
||||
export const SPEED_OPTIONS = [0.5, 1, 2, 4, 8, 16]
|
||||
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ export default class Player extends Animator {
|
|||
speed: initialSpeed,
|
||||
} as const
|
||||
|
||||
constructor(private pState: Store<State & AnimatorGetState>, private manager: Moveable & Cleanable) {
|
||||
constructor(private pState: Store<State & AnimatorGetState>, private manager: MessageManager) {
|
||||
super(pState, manager)
|
||||
|
||||
// Autoplay
|
||||
|
|
|
|||
135
frontend/app/player/web/MessageLoader.ts
Normal file
135
frontend/app/player/web/MessageLoader.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { Store, SessionFilesInfo } from 'Player';
|
||||
import { decryptSessionBytes } from './network/crypto';
|
||||
import MFileReader from './messages/MFileReader';
|
||||
import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles';
|
||||
import type {
|
||||
Message,
|
||||
} from './messages';
|
||||
import logger from 'App/logger';
|
||||
import MessageManager from "Player/web/MessageManager";
|
||||
|
||||
|
||||
interface State {
|
||||
firstFileLoading: boolean,
|
||||
domLoading: boolean,
|
||||
devtoolsLoading: boolean,
|
||||
error: boolean,
|
||||
}
|
||||
|
||||
export default class MessageLoader {
|
||||
static INITIAL_STATE: State = {
|
||||
firstFileLoading: false,
|
||||
domLoading: false,
|
||||
devtoolsLoading: false,
|
||||
error: false,
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly session: SessionFilesInfo,
|
||||
private store: Store<State>,
|
||||
private messageManager: MessageManager,
|
||||
private isClickmap: boolean,
|
||||
) {}
|
||||
|
||||
createNewParser(shouldDecrypt = true, file?: string, toggleStatus?: (isLoading: boolean) => void) {
|
||||
const decrypt = shouldDecrypt && this.session.fileKey
|
||||
? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey!)
|
||||
: (b: Uint8Array) => Promise.resolve(b)
|
||||
// Each time called - new fileReader created
|
||||
const fileReader = new MFileReader(new Uint8Array(), this.session.startedAt)
|
||||
return (b: Uint8Array) => decrypt(b).then(b => {
|
||||
toggleStatus?.(true);
|
||||
fileReader.append(b)
|
||||
fileReader.checkForIndexes()
|
||||
const msgs: Array<Message & { _index?: number }> = []
|
||||
for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) {
|
||||
msgs.push(msg)
|
||||
}
|
||||
const sorted = msgs.sort((m1, m2) => {
|
||||
return m1.time - m2.time
|
||||
})
|
||||
|
||||
sorted.forEach(msg => {
|
||||
this.messageManager.distributeMessage(msg)
|
||||
})
|
||||
logger.info("Messages count: ", msgs.length, sorted, file)
|
||||
|
||||
this.messageManager._sortMessagesHack(sorted)
|
||||
toggleStatus?.(false);
|
||||
this.messageManager.setMessagesLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) {
|
||||
if (urls.length > 0) {
|
||||
this.store.update({ domLoading: true })
|
||||
return loadFiles(urls, parser, true).then(() => this.store.update({ domLoading: false }))
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
loadDevtools() {
|
||||
if (!this.isClickmap) {
|
||||
this.store.update({ devtoolsLoading: true })
|
||||
return loadFiles(this.session.devtoolsURL, this.createNewParser(true, 'devtools'))
|
||||
// TODO: also in case of dynamic update through assist
|
||||
.then(() => {
|
||||
// @ts-ignore ?
|
||||
this.store.update({ ...this.messageManager.getListsFullState(), devtoolsLoading: false });
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
async loadFiles() {
|
||||
this.messageManager.startLoading()
|
||||
|
||||
const loadMethod = this.session.domURL && this.session.domURL.length > 0
|
||||
? { url: this.session.domURL, parser: () => this.createNewParser(true, 'dom') }
|
||||
: { url: this.session.mobsUrl, parser: () => this.createNewParser(false, 'dom') }
|
||||
|
||||
const parser = loadMethod.parser()
|
||||
/**
|
||||
* We load first dom mob file before the rest
|
||||
* to speed up time to replay
|
||||
* but as a tradeoff we have to have some copy-paste
|
||||
* for the devtools file
|
||||
* */
|
||||
try {
|
||||
await loadFiles([loadMethod.url[0]], parser)
|
||||
const restDomFilesPromise = this.loadDomFiles([...loadMethod.url.slice(1)], parser)
|
||||
const restDevtoolsFilesPromise = this.loadDevtools()
|
||||
|
||||
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise])
|
||||
this.messageManager.onFileReadSuccess()
|
||||
} catch (e) {
|
||||
try {
|
||||
this.store.update({ domLoading: true, devtoolsLoading: true })
|
||||
const efsDomFilePromise = requestEFSDom(this.session.sessionId)
|
||||
const efsDevtoolsFilePromise = requestEFSDevtools(this.session.sessionId)
|
||||
|
||||
const [domData, devtoolsData] = await Promise.allSettled([efsDomFilePromise, efsDevtoolsFilePromise])
|
||||
const domParser = this.createNewParser(false, 'domEFS')
|
||||
const devtoolsParser = this.createNewParser(false, 'devtoolsEFS')
|
||||
const parseDomPromise: Promise<any> = domData.status === 'fulfilled'
|
||||
? domParser(domData.value) : Promise.reject('No dom file in EFS')
|
||||
const parseDevtoolsPromise: Promise<any> = devtoolsData.status === 'fulfilled'
|
||||
? devtoolsParser(devtoolsData.value) : Promise.reject('No devtools file in EFS')
|
||||
|
||||
await Promise.all([parseDomPromise, parseDevtoolsPromise])
|
||||
this.messageManager.onFileReadSuccess()
|
||||
} catch (e2) {
|
||||
this.messageManager.onFileReadFailed(e)
|
||||
}
|
||||
} finally {
|
||||
this.messageManager.onFileReadFinally()
|
||||
this.store.update({ domLoading: false, devtoolsLoading: false })
|
||||
}
|
||||
}
|
||||
|
||||
clean() {
|
||||
this.store.update(MessageLoader.INITIAL_STATE);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,12 +3,14 @@ import { Decoder } from "syncod";
|
|||
import logger from 'App/logger';
|
||||
|
||||
import { TYPES as EVENT_TYPES } from 'Types/session/event';
|
||||
import { Log } from './types/log';
|
||||
import { Resource, ResourceType, getResourceFromResourceTiming, getResourceFromNetworkRequest } from './types/resource'
|
||||
import { Log } from 'Player';
|
||||
import {
|
||||
ResourceType,
|
||||
getResourceFromResourceTiming,
|
||||
getResourceFromNetworkRequest
|
||||
} from 'Player'
|
||||
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import type { Store, Timed } from '../common/types';
|
||||
import type { Store } from 'Player';
|
||||
import ListWalker from '../common/ListWalker';
|
||||
|
||||
import PagesManager from './managers/PagesManager';
|
||||
|
|
@ -18,7 +20,6 @@ import PerformanceTrackManager from './managers/PerformanceTrackManager';
|
|||
import WindowNodeCounter from './managers/WindowNodeCounter';
|
||||
import ActivityManager from './managers/ActivityManager';
|
||||
|
||||
import MFileReader from './messages/MFileReader';
|
||||
import { MouseThrashing, MType } from "./messages";
|
||||
import { isDOMType } from './messages/filters.gen';
|
||||
import type {
|
||||
|
|
@ -30,9 +31,6 @@ import type {
|
|||
MouseClick,
|
||||
} from './messages';
|
||||
|
||||
import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles';
|
||||
import { decryptSessionBytes } from './network/crypto';
|
||||
|
||||
import Lists, { INITIAL_STATE as LISTS_INITIAL_STATE, State as ListsState } from './Lists';
|
||||
|
||||
import Screen, {
|
||||
|
|
@ -44,7 +42,6 @@ import type { InitialLists } from './Lists'
|
|||
import type { PerformanceChartPoint } from './managers/PerformanceTrackManager';
|
||||
import type { SkipInterval } from './managers/ActivityManager';
|
||||
|
||||
|
||||
export interface State extends ScreenState, ListsState {
|
||||
performanceChartData: PerformanceChartPoint[],
|
||||
skipIntervals: SkipInterval[],
|
||||
|
|
@ -58,8 +55,6 @@ export interface State extends ScreenState, ListsState {
|
|||
domBuildingTime?: number,
|
||||
loadTime?: { time: number, value: number },
|
||||
error: boolean,
|
||||
devtoolsLoading: boolean,
|
||||
|
||||
messagesLoading: boolean,
|
||||
cssLoading: boolean,
|
||||
|
||||
|
|
@ -87,14 +82,12 @@ export default class MessageManager {
|
|||
performanceChartData: [],
|
||||
skipIntervals: [],
|
||||
error: false,
|
||||
devtoolsLoading: false,
|
||||
|
||||
messagesLoading: false,
|
||||
cssLoading: false,
|
||||
ready: false,
|
||||
lastMessageTime: 0,
|
||||
firstVisualEvent: 0,
|
||||
messagesProcessed: false,
|
||||
messagesLoading: false,
|
||||
}
|
||||
|
||||
private locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker();
|
||||
|
|
@ -117,7 +110,7 @@ export default class MessageManager {
|
|||
|
||||
private activityManager: ActivityManager | null = null;
|
||||
|
||||
private sessionStart: number;
|
||||
private readonly sessionStart: number;
|
||||
private navigationStartOffset: number = 0;
|
||||
private lastMessageTime: number = 0;
|
||||
private firstVisualEventSet = false;
|
||||
|
|
@ -126,7 +119,8 @@ export default class MessageManager {
|
|||
private readonly session: any /*Session*/,
|
||||
private readonly state: Store<State>,
|
||||
private readonly screen: Screen,
|
||||
initialLists?: Partial<InitialLists>
|
||||
initialLists?: Partial<InitialLists>,
|
||||
private readonly uiErrorHandler?: { error: (error: string) => void, },
|
||||
) {
|
||||
this.pagesManager = new PagesManager(screen, this.session.isMobile, this.setCSSLoading)
|
||||
this.mouseMoveManager = new MouseMoveManager(screen)
|
||||
|
|
@ -134,7 +128,7 @@ export default class MessageManager {
|
|||
this.sessionStart = this.session.startedAt
|
||||
|
||||
this.lists = new Lists(initialLists)
|
||||
initialLists?.event?.forEach((e: Record<string, string>) => { // TODO: to one of "Moveable" module
|
||||
initialLists?.event?.forEach((e: Record<string, string>) => { // TODO: to one of "Movable" module
|
||||
if (e.type === EVENT_TYPES.LOCATION) {
|
||||
this.locationEventManager.append(e);
|
||||
}
|
||||
|
|
@ -143,6 +137,10 @@ export default class MessageManager {
|
|||
this.activityManager = new ActivityManager(this.session.duration.milliseconds) // only if not-live
|
||||
}
|
||||
|
||||
public getListsFullState = () => {
|
||||
return this.lists.getFullListsState()
|
||||
}
|
||||
|
||||
public updateLists(lists: Partial<InitialLists>) {
|
||||
Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => {
|
||||
const currentList = this.lists.lists[key]
|
||||
|
|
@ -162,7 +160,7 @@ export default class MessageManager {
|
|||
this.state.update({ cssLoading, ready: !this.state.get().messagesLoading && !cssLoading })
|
||||
}
|
||||
|
||||
private _sortMessagesHack(msgs: Message[]) {
|
||||
public _sortMessagesHack = (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) => {
|
||||
|
|
@ -190,7 +188,7 @@ export default class MessageManager {
|
|||
}
|
||||
|
||||
private waitingForFiles: boolean = false
|
||||
private onFileReadSuccess = () => {
|
||||
public onFileReadSuccess = () => {
|
||||
const stateToUpdate : Partial<State>= {
|
||||
performanceChartData: this.performanceTrackManager.chartData,
|
||||
performanceAvailability: this.performanceTrackManager.availability,
|
||||
|
|
@ -202,108 +200,22 @@ export default class MessageManager {
|
|||
}
|
||||
this.state.update(stateToUpdate)
|
||||
}
|
||||
private onFileReadFailed = (e: any) => {
|
||||
|
||||
public onFileReadFailed = (e: any) => {
|
||||
logger.error(e)
|
||||
this.state.update({ error: true })
|
||||
toast.error('Error requesting a session file')
|
||||
this.uiErrorHandler?.error('Error requesting a session file')
|
||||
}
|
||||
private onFileReadFinally = () => {
|
||||
|
||||
public onFileReadFinally = () => {
|
||||
this.waitingForFiles = false
|
||||
this.state.update({ messagesProcessed: true })
|
||||
// this.setMessagesLoading(false)
|
||||
// this.state.update({ filesLoaded: true })
|
||||
}
|
||||
|
||||
async loadMessages(isClickmap: boolean = false) {
|
||||
public startLoading = () => {
|
||||
this.waitingForFiles = true
|
||||
this.state.update({ messagesProcessed: false })
|
||||
this.setMessagesLoading(true)
|
||||
// TODO: reusable decryptor instance
|
||||
const createNewParser = (shouldDecrypt = true, file?: string) => {
|
||||
const decrypt = shouldDecrypt && this.session.fileKey
|
||||
? (b: Uint8Array) => decryptSessionBytes(b, this.session.fileKey)
|
||||
: (b: Uint8Array) => Promise.resolve(b)
|
||||
// Each time called - new fileReader created
|
||||
const fileReader = new MFileReader(new Uint8Array(), this.sessionStart)
|
||||
return (b: Uint8Array) => decrypt(b).then(b => {
|
||||
fileReader.append(b)
|
||||
fileReader.checkForIndexes()
|
||||
const msgs: Array<Message> = []
|
||||
for (let msg = fileReader.readNext();msg !== null;msg = fileReader.readNext()) {
|
||||
msgs.push(msg)
|
||||
}
|
||||
const sorted = msgs.sort((m1, m2) => {
|
||||
return m1.time - m2.time
|
||||
})
|
||||
|
||||
let outOfOrderCounter = 0
|
||||
sorted.forEach(msg => {
|
||||
this.distributeMessage(msg)
|
||||
})
|
||||
|
||||
if (outOfOrderCounter > 0) console.warn("Unsorted mob file, error count: ", outOfOrderCounter)
|
||||
logger.info("Messages count: ", msgs.length, sorted, file)
|
||||
|
||||
this._sortMessagesHack(msgs)
|
||||
this.setMessagesLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
this.waitingForFiles = true
|
||||
|
||||
// TODO: refactor this stuff; split everything to async/await
|
||||
|
||||
const loadMethod = this.session.domURL && this.session.domURL.length > 0
|
||||
? { url: this.session.domURL, parser: () => createNewParser(true, 'dom') }
|
||||
: { url: this.session.mobsUrl, parser: () => createNewParser(false, 'dom')}
|
||||
|
||||
const parser = loadMethod.parser()
|
||||
|
||||
/**
|
||||
* We load first dom mobfile before the rest
|
||||
* to speed up time to replay
|
||||
* but as a tradeoff we have to have some copy-paste
|
||||
* for the devtools file
|
||||
* */
|
||||
loadFiles([loadMethod.url[0]], parser)
|
||||
.then(() => {
|
||||
const domPromise = loadMethod.url.length > 1
|
||||
? loadFiles([loadMethod.url[1]], parser, true)
|
||||
: Promise.resolve()
|
||||
const devtoolsPromise = !isClickmap
|
||||
? this.loadDevtools(createNewParser)
|
||||
: Promise.resolve()
|
||||
return Promise.all([domPromise, devtoolsPromise])
|
||||
})
|
||||
/**
|
||||
* EFS fallback for unprocessed sessions (which are live)
|
||||
* */
|
||||
.catch(() => {
|
||||
requestEFSDom(this.session.sessionId)
|
||||
.then(createNewParser(false, 'domEFS'))
|
||||
.catch(this.onFileReadFailed)
|
||||
if (!isClickmap) {
|
||||
this.loadDevtools(createNewParser)
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(this.onFileReadSuccess)
|
||||
.finally(this.onFileReadFinally);
|
||||
}
|
||||
|
||||
loadDevtools(createNewParser: (shouldDecrypt: boolean, file: string) => (b: Uint8Array) => Promise<void>) {
|
||||
this.state.update({ devtoolsLoading: true })
|
||||
return loadFiles(this.session.devtoolsURL, createNewParser(true, 'devtools'))
|
||||
// EFS fallback
|
||||
.catch(() =>
|
||||
requestEFSDevtools(this.session.sessionId)
|
||||
.then(createNewParser(false, 'devtoolsEFS'))
|
||||
)
|
||||
// TODO: also in case of dynamic update through assist
|
||||
.then(() => {
|
||||
this.state.update({ ...this.lists.getFullListsState() })
|
||||
})
|
||||
.catch(e => logger.error("Can not download the devtools file", e))
|
||||
.finally(() => this.state.update({ devtoolsLoading: false }))
|
||||
}
|
||||
|
||||
resetMessageManagers() {
|
||||
|
|
@ -395,29 +307,14 @@ export default class MessageManager {
|
|||
}
|
||||
}
|
||||
|
||||
private decodeStateMessage(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) {
|
||||
logger.error("Error on message decoding: ", e, msg);
|
||||
return null;
|
||||
}
|
||||
return { ...msg, ...decoded };
|
||||
}
|
||||
|
||||
distributeMessage(msg: Message): void {
|
||||
distributeMessage = (msg: Message): void => {
|
||||
const lastMessageTime = Math.max(msg.time, this.lastMessageTime)
|
||||
this.lastMessageTime = lastMessageTime
|
||||
this.state.update({ lastMessageTime })
|
||||
if (visualChanges.includes(msg.tp)) {
|
||||
this.activityManager?.updateAcctivity(msg.time);
|
||||
}
|
||||
let decoded;
|
||||
const time = msg.time;
|
||||
switch (msg.tp) {
|
||||
case MType.SetPageLocation:
|
||||
this.locationManager.append(msg);
|
||||
|
|
@ -464,6 +361,7 @@ export default class MessageManager {
|
|||
case MType.ResourceTiming:
|
||||
// TODO: merge `resource` and `fetch` lists into one here instead of UI
|
||||
if (msg.initiator !== ResourceType.FETCH && msg.initiator !== ResourceType.XHR) {
|
||||
// @ts-ignore TODO: typing for lists
|
||||
this.lists.lists.resource.insert(getResourceFromResourceTiming(msg, this.sessionStart))
|
||||
}
|
||||
break;
|
||||
|
|
@ -523,7 +421,7 @@ export default class MessageManager {
|
|||
}
|
||||
}
|
||||
|
||||
setMessagesLoading(messagesLoading: boolean) {
|
||||
setMessagesLoading = (messagesLoading: boolean) => {
|
||||
this.screen.display(!messagesLoading);
|
||||
this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,20 +97,6 @@ export default class Screen {
|
|||
|
||||
parentElement.appendChild(this.screen);
|
||||
this.parentElement = parentElement;
|
||||
|
||||
/* == For the Inspecting Document content == */
|
||||
this.overlay.addEventListener('contextmenu', () => {
|
||||
this.overlay.style.display = 'none'
|
||||
const doc = this.document
|
||||
if (!doc) { return }
|
||||
const returnOverlay = () => {
|
||||
this.overlay.style.display = 'block'
|
||||
doc.removeEventListener('mousemove', returnOverlay)
|
||||
doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection
|
||||
}
|
||||
doc.addEventListener('mousemove', returnOverlay)
|
||||
doc.addEventListener('mouseclick', returnOverlay)
|
||||
})
|
||||
}
|
||||
|
||||
getParentElement(): HTMLElement | null {
|
||||
|
|
@ -137,7 +123,7 @@ export default class Screen {
|
|||
private getBoundingClientRect(): DOMRect {
|
||||
if (this.boundingRect === null) {
|
||||
// TODO: use this.screen instead in order to separate overlay functionality
|
||||
return this.boundingRect = this.overlay.getBoundingClientRect() // expensive operation?
|
||||
return this.boundingRect = this.screen.getBoundingClientRect() // expensive operation?
|
||||
}
|
||||
return this.boundingRect
|
||||
}
|
||||
|
|
@ -145,7 +131,7 @@ export default class Screen {
|
|||
getInternalViewportCoordinates({ x, y }: Point): Point {
|
||||
const { x: overlayX, y: overlayY, width } = this.getBoundingClientRect();
|
||||
|
||||
const screenWidth = this.overlay.offsetWidth;
|
||||
const screenWidth = this.screen.offsetWidth;
|
||||
|
||||
const scale = screenWidth / width;
|
||||
const screenX = (x - overlayX) * scale;
|
||||
|
|
@ -246,7 +232,7 @@ export default class Screen {
|
|||
width: width + 'px',
|
||||
})
|
||||
|
||||
this.boundingRect = this.overlay.getBoundingClientRect();
|
||||
this.boundingRect = this.screen.getBoundingClientRect();
|
||||
this.onUpdateHook(width, height)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Store } from '../common/types'
|
||||
import type { Store, SessionFilesInfo } from 'Player'
|
||||
import type { Message } from './messages'
|
||||
|
||||
import WebPlayer from './WebPlayer'
|
||||
|
|
@ -7,9 +7,6 @@ import AssistManager from './assist/AssistManager'
|
|||
import MFileReader from './messages/MFileReader'
|
||||
import { requestEFSDom } from './network/loadFiles'
|
||||
|
||||
import { toast } from 'react-toastify'; // **
|
||||
|
||||
|
||||
export default class WebLivePlayer extends WebPlayer {
|
||||
static readonly INITIAL_STATE = {
|
||||
...WebPlayer.INITIAL_STATE,
|
||||
|
|
@ -21,26 +18,31 @@ export default class WebLivePlayer extends WebPlayer {
|
|||
private readonly incomingMessages: Message[] = []
|
||||
private historyFileIsLoading = false
|
||||
private lastMessageInFileTime = 0
|
||||
private lastMessageInFileIndex = 0
|
||||
|
||||
constructor(wpState: Store<typeof WebLivePlayer.INITIAL_STATE>, private session:any, config: RTCIceServer[] | null) {
|
||||
super(wpState, session, true)
|
||||
constructor(
|
||||
wpState: Store<typeof WebLivePlayer.INITIAL_STATE>,
|
||||
private session: SessionFilesInfo,
|
||||
config: RTCIceServer[] | null,
|
||||
uiErrorHandler?: { error: (msg: string) => void }
|
||||
) {
|
||||
super(wpState, session, true, false, uiErrorHandler)
|
||||
|
||||
this.assistManager = new AssistManager(
|
||||
session,
|
||||
f => this.messageManager.setMessagesLoading(f),
|
||||
(msg, idx) => {
|
||||
(msg) => {
|
||||
this.incomingMessages.push(msg)
|
||||
if (!this.historyFileIsLoading) {
|
||||
// TODO: fix index-ing after historyFile-load
|
||||
this.messageManager.distributeMessage(msg, idx)
|
||||
this.messageManager.distributeMessage(msg)
|
||||
}
|
||||
},
|
||||
this.screen,
|
||||
config,
|
||||
wpState,
|
||||
uiErrorHandler,
|
||||
)
|
||||
this.assistManager.connect(session.agentToken)
|
||||
this.assistManager.connect(session.agentToken!)
|
||||
}
|
||||
|
||||
toggleTimetravel = async () => {
|
||||
|
|
@ -64,14 +66,14 @@ export default class WebLivePlayer extends WebPlayer {
|
|||
result = true
|
||||
// here we need to update also lists state, if we gonna use them this.messageManager.onFileReadSuccess
|
||||
} catch(e) {
|
||||
toast.error('Error requesting a session file')
|
||||
this.uiErrorHandler?.error('Error requesting a session file')
|
||||
console.error("EFS file download error:", e)
|
||||
}
|
||||
|
||||
// Append previously received messages
|
||||
this.incomingMessages
|
||||
.filter(msg => msg.time >= this.lastMessageInFileTime)
|
||||
.forEach((msg, i) => this.messageManager.distributeMessage(msg, this.lastMessageInFileIndex + i))
|
||||
.forEach((msg) => this.messageManager.distributeMessage(msg))
|
||||
this.incomingMessages.length = 0
|
||||
|
||||
this.historyFileIsLoading = false
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
import { Log, LogLevel } from './types/log'
|
||||
import { Log, LogLevel, SessionFilesInfo } from 'App/player'
|
||||
|
||||
import type { Store } from 'App/player'
|
||||
import Player from '../player/Player'
|
||||
|
||||
import MessageManager from './MessageManager'
|
||||
import MessageLoader from './MessageLoader'
|
||||
import InspectorController from './addons/InspectorController'
|
||||
import TargetMarker from './addons/TargetMarker'
|
||||
import Screen, { ScaleMode } from './Screen/Screen'
|
||||
import { Message } from "Player/web/messages";
|
||||
|
||||
|
||||
// export type State = typeof WebPlayer.INITIAL_STATE
|
||||
|
||||
export default class WebPlayer extends Player {
|
||||
static readonly INITIAL_STATE = {
|
||||
...Player.INITIAL_STATE,
|
||||
...TargetMarker.INITIAL_STATE,
|
||||
...MessageManager.INITIAL_STATE,
|
||||
...MessageLoader.INITIAL_STATE,
|
||||
|
||||
inspectorMode: false,
|
||||
}
|
||||
|
|
@ -24,10 +23,17 @@ export default class WebPlayer extends Player {
|
|||
private readonly inspectorController: InspectorController
|
||||
protected screen: Screen
|
||||
protected readonly messageManager: MessageManager
|
||||
protected readonly messageLoader: MessageLoader
|
||||
|
||||
private targetMarker: TargetMarker
|
||||
|
||||
constructor(protected wpState: Store<typeof WebPlayer.INITIAL_STATE>, session: any, live: boolean, isClickMap = false) {
|
||||
constructor(
|
||||
protected wpState: Store<typeof WebPlayer.INITIAL_STATE>,
|
||||
session: SessionFilesInfo,
|
||||
live: boolean,
|
||||
isClickMap = false,
|
||||
public readonly uiErrorHandler?: { error: (msg: string) => void }
|
||||
) {
|
||||
let initialLists = live ? {} : {
|
||||
event: session.events || [],
|
||||
stack: session.stackEvents || [],
|
||||
|
|
@ -42,12 +48,19 @@ export default class WebPlayer extends Player {
|
|||
}
|
||||
|
||||
const screen = new Screen(session.isMobile, isClickMap ? ScaleMode.AdjustParentHeight : ScaleMode.Embed)
|
||||
const messageManager = new MessageManager(session, wpState, screen, initialLists)
|
||||
const messageManager = new MessageManager(session, wpState, screen, initialLists, uiErrorHandler)
|
||||
const messageLoader = new MessageLoader(
|
||||
session,
|
||||
wpState,
|
||||
messageManager,
|
||||
isClickMap
|
||||
)
|
||||
super(wpState, messageManager)
|
||||
this.screen = screen
|
||||
this.messageManager = messageManager
|
||||
this.messageLoader = messageLoader
|
||||
if (!live) { // hack. TODO: split OfflinePlayer class
|
||||
void messageManager.loadMessages(isClickMap)
|
||||
void messageLoader.loadFiles()
|
||||
}
|
||||
|
||||
this.targetMarker = new TargetMarker(this.screen, wpState)
|
||||
|
|
@ -154,6 +167,7 @@ export default class WebPlayer extends Player {
|
|||
this.screen.clean()
|
||||
// @ts-ignore
|
||||
this.screen = undefined;
|
||||
this.messageLoader.clean()
|
||||
// @ts-ignore
|
||||
this.messageManager = undefined;
|
||||
window.removeEventListener('resize', this.scale)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,20 @@ export default class InspectorController {
|
|||
private substitutor: Screen | null = null
|
||||
private inspector: Inspector | null = null
|
||||
marker: Marker | null = null
|
||||
constructor(private screen: Screen) {}
|
||||
constructor(private screen: Screen) {
|
||||
screen.overlay.addEventListener('contextmenu', () => {
|
||||
screen.overlay.style.display = 'none'
|
||||
const doc = screen.document
|
||||
if (!doc) { return }
|
||||
const returnOverlay = () => {
|
||||
screen.overlay.style.display = 'block'
|
||||
doc.removeEventListener('mousemove', returnOverlay)
|
||||
doc.removeEventListener('mouseclick', returnOverlay) // TODO: prevent default in case of input selection
|
||||
}
|
||||
doc.addEventListener('mousemove', returnOverlay)
|
||||
doc.addEventListener('mouseclick', returnOverlay)
|
||||
})
|
||||
}
|
||||
|
||||
scale(dims: Dimensions) {
|
||||
if (this.substitutor) {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default class AssistManager {
|
|||
private screen: Screen,
|
||||
private config: RTCIceServer[] | null,
|
||||
private store: Store<typeof AssistManager.INITIAL_STATE>,
|
||||
public readonly uiErrorHandler?: { error: (msg: string) => void }
|
||||
) {}
|
||||
|
||||
private get borderStyle() {
|
||||
|
|
@ -228,6 +229,7 @@ export default class AssistManager {
|
|||
socket,
|
||||
this.session.agentInfo,
|
||||
() => this.screen.setBorderStyle(this.borderStyle),
|
||||
this.uiErrorHandler
|
||||
)
|
||||
|
||||
document.addEventListener('visibilitychange', this.onVisChange)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { toast } from 'react-toastify'
|
||||
|
||||
import type { Socket } from './types'
|
||||
import type { Store } from '../../common/types'
|
||||
|
||||
|
|
@ -24,6 +22,7 @@ export default class ScreenRecording {
|
|||
private socket: Socket,
|
||||
private agentInfo: Object,
|
||||
private onToggle: (active: boolean) => void,
|
||||
public readonly uiErrorHandler: { error: (msg: string) => void } | undefined
|
||||
) {
|
||||
socket.on('recording_accepted', () => {
|
||||
this.toggleRecording(true)
|
||||
|
|
@ -38,7 +37,7 @@ export default class ScreenRecording {
|
|||
}
|
||||
|
||||
private onRecordingBusy = () => {
|
||||
toast.error("This session is already being recorded by another agent")
|
||||
this.uiErrorHandler?.error("This session is already being recorded by another agent")
|
||||
}
|
||||
|
||||
requestRecording = ({ onDeny }: { onDeny: () => void }) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { insertRule, deleteRule } from './safeCSSRules';
|
||||
|
||||
import { isRootNode } from 'App/player/guards'
|
||||
|
||||
type Callback<T> = (o: T) => void
|
||||
|
||||
|
|
@ -316,7 +316,7 @@ export class OnloadStyleSheet extends PromiseQueue<CSSStyleSheet> {
|
|||
return new OnloadStyleSheet(new Promise((resolve, reject) =>
|
||||
vRoot.onNode(node => {
|
||||
let context: typeof globalThis | null
|
||||
if (node instanceof Document || node.nodeName === '#document') {
|
||||
if (isRootNode(node)) {
|
||||
context = node.defaultView
|
||||
} else {
|
||||
context = node.ownerDocument.defaultView
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default class PagesManager extends ListWalker<DOMManager> {
|
|||
constructor(
|
||||
private screen: Screen,
|
||||
private isMobile: boolean,
|
||||
private setCssLoading: ConstructorParameters<typeof DOMManager>[3],
|
||||
private setCssLoading: ConstructorParameters<typeof DOMManager>[4],
|
||||
) { super() }
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function decryptSessionBytes(cypher: Uint8Array, keyString: string): Prom
|
|||
const data = gunzipSync(u8Array)
|
||||
console.debug(
|
||||
"Decompression time",
|
||||
performance.now() - now,
|
||||
Math.floor(performance.now() - now) + 'ms',
|
||||
'size',
|
||||
Math.floor(u8Array.byteLength/1024),
|
||||
'->',
|
||||
|
|
|
|||
|
|
@ -15,18 +15,31 @@ export async function loadFiles(
|
|||
}
|
||||
try {
|
||||
for (let url of urls) {
|
||||
const response = await window.fetch(url)
|
||||
const data = await processAPIStreamResponse(response, urls.length > 1 ? url !== urls[0] : canSkip)
|
||||
await onData(data)
|
||||
await loadFile(url, onData, urls.length > 1 ? url !== urls[0] : canSkip)
|
||||
}
|
||||
} catch(e) {
|
||||
if (e === ALLOWED_404) {
|
||||
return
|
||||
}
|
||||
throw e
|
||||
return Promise.resolve()
|
||||
} catch (e) {
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFile(
|
||||
url: string,
|
||||
onData: (data: Uint8Array) => void,
|
||||
canSkip: boolean = false,
|
||||
): Promise<void> {
|
||||
return window.fetch(url)
|
||||
.then(response => processAPIStreamResponse(response, canSkip))
|
||||
.then(data => onData(data))
|
||||
.catch(e => {
|
||||
if (e === ALLOWED_404) {
|
||||
return;
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestEFSDom(sessionId: string) {
|
||||
return await requestEFSMobFile(sessionId + "/dom.mob")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue