diff --git a/frontend/app/Router.js b/frontend/app/Router.js index 6a4aea446..d1be5a569 100644 --- a/frontend/app/Router.js +++ b/frontend/app/Router.js @@ -67,6 +67,7 @@ const DASHBOARD_METRIC_DETAILS_PATH = routes.dashboardMetricDetails(); // const WIDGET_PATAH = routes.dashboardMetric(); const SESSIONS_PATH = routes.sessions(); const ASSIST_PATH = routes.assist(); +const RECORDINGS_PATH = routes.recordings(); const ERRORS_PATH = routes.errors(); const ERROR_PATH = routes.error(); const FUNNEL_PATH = routes.funnels(); @@ -213,6 +214,7 @@ class Router extends React.Component { + diff --git a/frontend/app/components/Assist/Assist.tsx b/frontend/app/components/Assist/Assist.tsx index 3ef99c573..7aa559d43 100644 --- a/frontend/app/components/Assist/Assist.tsx +++ b/frontend/app/components/Assist/Assist.tsx @@ -1,26 +1,51 @@ import React from 'react'; -import LiveSessionList from 'Shared/LiveSessionList'; -import LiveSessionSearch from 'Shared/LiveSessionSearch'; -import cn from 'classnames' +import { withRouter, RouteComponentProps } from 'react-router-dom'; import withPageTitle from 'HOCs/withPageTitle'; import withPermissions from 'HOCs/withPermissions' -// import SessionSearch from '../shared/SessionSearch'; -// import MainSearchBar from '../shared/MainSearchBar'; -import AssistSearchField from './AssistSearchField'; +import AssistRouter from './AssistRouter'; +import { SideMenuitem } from 'UI'; +import { withSiteId, assist, recordings } from 'App/routes'; -function Assist() { + +interface Props extends RouteComponentProps { + siteId: string; + history: any; + setShowAlerts: (show: boolean) => void; +} + +function Assist(props: Props) { + const { history, siteId, setShowAlerts } = props; + const isAssist = history.location.pathname.includes('assist'); + const isRecords = history.location.pathname.includes('recordings'); + + const redirect = (path: string) => { + history.push(path); + }; return (
-
- - -
- +
+ redirect(withSiteId(assist(), siteId))} + /> + redirect(withSiteId(recordings(), siteId))} + /> +
+
+
) } -export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE'])(Assist)); +export default withPageTitle("Assist - OpenReplay")(withPermissions(['ASSIST_LIVE'])(withRouter(Assist))); diff --git a/frontend/app/components/Assist/AssistRouter.tsx b/frontend/app/components/Assist/AssistRouter.tsx new file mode 100644 index 000000000..57efaef37 --- /dev/null +++ b/frontend/app/components/Assist/AssistRouter.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Switch, Route } from 'react-router'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import { + assist, + recordings, + withSiteId, +} from 'App/routes'; +import AssistView from './AssistView' +import Recordings from './RecordingsList/Recordings' + +interface Props extends RouteComponentProps { + match: any; +} + +function AssistRouter(props: Props) { + const { + match: { + params: { siteId }, + }, + } = props; + + return ( +
+ + + + + + + + + +
+ ); +} + +export default withRouter(AssistRouter); diff --git a/frontend/app/components/Assist/AssistView.tsx b/frontend/app/components/Assist/AssistView.tsx new file mode 100644 index 000000000..700820cbd --- /dev/null +++ b/frontend/app/components/Assist/AssistView.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import LiveSessionList from 'Shared/LiveSessionList'; +import LiveSessionSearch from 'Shared/LiveSessionSearch'; +import cn from 'classnames' +import AssistSearchField from './AssistSearchField'; + +function AssistView() { + return ( +
+ + +
+ +
+ ) +} + +export default AssistView; diff --git a/frontend/app/components/Assist/RecordingsList/Recordings.tsx b/frontend/app/components/Assist/RecordingsList/Recordings.tsx new file mode 100644 index 000000000..1bc29efaa --- /dev/null +++ b/frontend/app/components/Assist/RecordingsList/Recordings.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { PageTitle } from 'UI'; +import RecordingsSearch from './RecordingsSearch'; +import RecordingsList from './RecordingsList'; + +function Recordings() { + + return ( +
+
+
+ +
+
+
+ +
+
+
+ +
+ ) +} + +export default Recordings diff --git a/frontend/app/components/Assist/RecordingsList/RecordingsList.tsx b/frontend/app/components/Assist/RecordingsList/RecordingsList.tsx new file mode 100644 index 000000000..7f71d157d --- /dev/null +++ b/frontend/app/components/Assist/RecordingsList/RecordingsList.tsx @@ -0,0 +1,73 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { NoContent, Pagination, Icon } from 'UI'; +import { useStore } from 'App/mstore'; +import { filterList } from 'App/utils'; +import { sliceListPerPage } from 'App/utils'; +import RecordsListItem from './RecordsListItem'; + +function RecordingsList() { + const { recordingsStore } = useStore(); + const [shownRecordings, setRecordings] = React.useState([]); + const recordings = recordingsStore.recordings; + const recordsSearch = recordingsStore.search; + + React.useEffect(() => { + recordingsStore.fetchRecordings() + }, []) + + React.useEffect(() => { + setRecordings(filterList(recordings, recordsSearch, ['createdBy', 'name'])); + }, [recordsSearch]); + + const list = recordsSearch !== '' ? shownRecordings : recordings; + const lenth = list.length; + + return ( + + +
+ {recordsSearch !== '' + ? 'No matching results' + : "You haven't created any recordings yet"} +
+
+ } + > +
+
+
Title
+
By
+
+ + {sliceListPerPage(list, recordingsStore.page - 1, recordingsStore.pageSize).map( + (record: any) => ( + + + + ) + )} +
+ +
+
+ Showing{' '} + {Math.min(list.length, recordingsStore.pageSize)}{' '} + out of {list.length} Recording +
+ recordingsStore.updatePage(page)} + limit={recordingsStore.pageSize} + debounceRequest={100} + /> +
+ + ); +} + +export default observer(RecordingsList); diff --git a/frontend/app/components/Assist/RecordingsList/RecordingsSearch.tsx b/frontend/app/components/Assist/RecordingsList/RecordingsSearch.tsx new file mode 100644 index 000000000..298f191fb --- /dev/null +++ b/frontend/app/components/Assist/RecordingsList/RecordingsSearch.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; +import { Icon } from 'UI'; +import { debounce } from 'App/utils'; + +let debounceUpdate: any = () => {} + +function RecordingsSearch() { + const { recordingsStore } = useStore(); + const [query, setQuery] = useState(recordingsStore.search); + useEffect(() => { + debounceUpdate = debounce((value: any) => recordingsStore.updateSearch(value), 500); + }, []) + + // @ts-ignore + const write = ({ target: { value } }) => { + setQuery(value); + debounceUpdate(value); + } + + return ( +
+ + +
+ ); +} + +export default observer(RecordingsSearch); diff --git a/frontend/app/components/Assist/RecordingsList/RecordsListItem.tsx b/frontend/app/components/Assist/RecordingsList/RecordsListItem.tsx new file mode 100644 index 000000000..132282fcf --- /dev/null +++ b/frontend/app/components/Assist/RecordingsList/RecordsListItem.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Icon, ItemMenu } from 'UI'; +import { durationFromMs, checkForRecent, getDateFromMill } from 'App/date' +import { IRecord } from 'App/services/RecordingsService' +import { useStore } from 'App/mstore' +import { toast } from 'react-toastify' + +interface Props { + record: IRecord; +} + +function RecordsListItem(props: Props) { + const { record } = props; + const { recordingsStore } = useStore() + + const onRecordClick = () => { + recordingsStore.fetchRecordingUrl(record.recordId).then(url => { + window.open(url, "_blank"); + }) + } + + const onDelete = () => { + recordingsStore.deleteRecording(record.recordId).then(() => { + toast.success("Recording deleted") + }) + } + + const menuItems = [ + { icon: 'trash', text: 'Delete', onClick: onDelete }, + ] + return ( +
+
+
+
+
+ +
+
+
{record.name}
+
+ {durationFromMs(record.duration)} +
+
+
+
+
+
+
+ {record.createdBy} +
+
{checkForRecent(getDateFromMill(record.createdAt), 'LLL dd, yyyy, hh:mm a')}
+
+
+
+
+ + +
Play Video
+
+ +
+
+
+ ); +} + +export default RecordsListItem; diff --git a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx index 30d5ac08e..e7e75d979 100644 --- a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx +++ b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx @@ -44,6 +44,7 @@ function ScreenRecorder({ React.useEffect(() => { return () => stopRecorderCb?.(); }, []); + const onSave = async (saveObj: { name: string; duration: number }, blob: Blob) => { try { const url = await recordingsService.reserveUrl(siteId, saveObj); diff --git a/frontend/app/date.ts b/frontend/app/date.ts index 3403abfd6..a6162d671 100644 --- a/frontend/app/date.ts +++ b/frontend/app/date.ts @@ -1,7 +1,6 @@ // @flow import { DateTime, Duration } from 'luxon'; // TODO -import { toJS } from 'mobx'; import { Timezone } from 'MOBX/types/sessionSettings'; export const durationFormatted = (duration: Duration):string => { diff --git a/frontend/app/mstore/index.tsx b/frontend/app/mstore/index.tsx index 1d6d758a1..fe21a11ab 100644 --- a/frontend/app/mstore/index.tsx +++ b/frontend/app/mstore/index.tsx @@ -14,6 +14,7 @@ import { funnelService, errorService, notesService, + recordingsService, } from 'App/services'; import SettingsStore from './settingsStore'; import AuditStore from './auditStore'; @@ -22,6 +23,7 @@ import ErrorStore from './errorStore'; import SessionStore from './sessionStore'; import NotesStore from './notesStore'; import BugReportStore from './bugReportStore' +import RecordingsStore from './recordingsStore' export class RootStore { dashboardStore: DashboardStore; @@ -35,7 +37,8 @@ export class RootStore { notificationStore: NotificationStore; sessionStore: SessionStore; notesStore: NotesStore; - bugReportStore: BugReportStore + bugReportStore: BugReportStore; + recordingsStore: RecordingsStore; constructor() { this.dashboardStore = new DashboardStore(); @@ -50,6 +53,7 @@ export class RootStore { this.sessionStore = new SessionStore(); this.notesStore = new NotesStore(); this.bugReportStore = new BugReportStore(); + this.recordingsStore = new RecordingsStore(); } initClient() { @@ -62,6 +66,7 @@ export class RootStore { auditService.initClient(client); errorService.initClient(client); notesService.initClient(client) + recordingsService.initClient(client); } } diff --git a/frontend/app/mstore/recordingsStore.ts b/frontend/app/mstore/recordingsStore.ts new file mode 100644 index 000000000..0f581a931 --- /dev/null +++ b/frontend/app/mstore/recordingsStore.ts @@ -0,0 +1,74 @@ +import { makeAutoObservable } from "mobx" +import { recordingsService } from "App/services" +import { IRecord } from 'App/services/RecordingsService' + +export default class RecordingsStore { + recordings: IRecord[] = [] + loading: boolean + + page = 1 + pageSize = 15 + order: 'desc' | 'asc' = 'desc' + search = '' + // not later we will add search by user id + userId: number + + constructor() { + makeAutoObservable(this) + } + + updateSearch(val: string) { + this.search = val + } + updatePage(page: number) { + this.page = page + } + + async fetchRecordings() { + const filter = { + page: this.page, + limit: this.pageSize, + order: this.order, + search: this.search, + } + + this.loading = true + try { + const recordings = await recordingsService.fetchRecordings(filter) + this.recordings = recordings; + this.fetchRecordingUrl(recordings[0].recordId) + return recordings; + } catch (e) { + console.error(e) + } finally { + this.loading = false + } + } + + async fetchRecordingUrl(id: number): Promise { + this.loading = true + try { + const recording = await recordingsService.fetchRecording(id) + return recording.URL; + } catch (e) { + console.error(e) + } finally { + this.loading = false + } + } + + async deleteRecording(id: number) { + this.loading = true + try { + const recording = await recordingsService.deleteRecording(id) + console.log(recording) + return recording + } catch (e) { + console.error(e) + } finally { + this.loading = false + } + } + + +} diff --git a/frontend/app/player/web/assist/AssistManager.ts b/frontend/app/player/web/assist/AssistManager.ts index 7d65a988d..8c30d9876 100644 --- a/frontend/app/player/web/assist/AssistManager.ts +++ b/frontend/app/player/web/assist/AssistManager.ts @@ -283,7 +283,6 @@ export default class AssistManager { const recordingState = getState().recordingState if (!this.socket || recordingState === SessionRecordingStatus.Off) return; - console.log('stop rec') this.socket.emit("stop_recording") this.toggleRecording(false) } @@ -292,7 +291,6 @@ export default class AssistManager { this.md.toggleRecordingStatus(isAccepted) update({ recordingState: isAccepted ? SessionRecordingStatus.Recording : SessionRecordingStatus.Off }) - console.log('Im here', isAccepted, getState().recordingState) } /* ==== Remote Control ==== */ diff --git a/frontend/app/player/web/network/loadFiles.ts b/frontend/app/player/web/network/loadFiles.ts index 6b34c3261..79e591aac 100644 --- a/frontend/app/player/web/network/loadFiles.ts +++ b/frontend/app/player/web/network/loadFiles.ts @@ -17,8 +17,8 @@ export const loadFiles = ( return processAPIStreamResponse(r, true) }) .then(onData) - .then(() => - urls.reduce((p, url) => + .then(() => + urls.reduce((p, url) => p.then(() => window.fetch(url) .then(r => { @@ -62,7 +62,7 @@ const processAPIStreamResponse = (response: Response, isMainFile: boolean) => { } if (response.status >= 400) { return rej( - isMainFile ? `no start file. status code ${ response.status }` + isMainFile ? `no start file. status code ${ response.status }` : `Bad endfile status code ${response.status}` ) } diff --git a/frontend/app/routes.js b/frontend/app/routes.js index 90de53012..139994fc0 100644 --- a/frontend/app/routes.js +++ b/frontend/app/routes.js @@ -84,6 +84,7 @@ export const onboarding = (tab = routerOBTabString) => `/onboarding/${ tab }`; export const sessions = params => queried('/sessions', params); export const assist = params => queried('/assist', params); +export const recordings = params => queried("/recordings", params); export const session = (sessionId = ':sessionId', hash) => hashed(`/session/${ sessionId }`, hash); export const liveSession = (sessionId = ':sessionId', params, hash) => hashed(queried(`/assist/${ sessionId }`, params), hash); @@ -123,7 +124,8 @@ const REQUIRED_SITE_ID_ROUTES = [ session(''), sessions(), assist(), - + recordings(), + metrics(), metricDetails(''), metricDetailsSub(''), @@ -172,6 +174,7 @@ const SITE_CHANGE_AVALIABLE_ROUTES = [ sessions(), funnels(), assist(), + recordings(), dashboard(), dashboardSelected(), metrics(), diff --git a/frontend/app/services/RecordingsService.ts b/frontend/app/services/RecordingsService.ts index 0b50fb943..64cbe21f8 100644 --- a/frontend/app/services/RecordingsService.ts +++ b/frontend/app/services/RecordingsService.ts @@ -5,6 +5,24 @@ interface RecordingData { duration: number; } +interface FetchFilter { + page: number + limit: number + order: 'asc' | 'desc' + search: string +} + +export interface IRecord { + createdAt: number + createdBy: string + duration: number + name: string + recordId: number + sessionId: number + userId: number + URL?: string +} + export default class RecordingsService { private client: APIClient; @@ -38,4 +56,37 @@ export default class RecordingsService { }) } + fetchRecordings(filters: FetchFilter): Promise { + return this.client.post(`/assist/records`, filters) + .then(r => { + if (r.ok) { + return r.json().then(j => j.data) + } else { + throw new Error("Can't get recordings: " + r.status); + } + }) + } + + fetchRecording(id: number): Promise { + return this.client.get(`/assist/records/${id}`) + .then(r => { + if (r.ok) { + return r.json().then(j => j.data) + } else { + throw new Error("Can't get recordings: " + r.status); + } + }) + } + + deleteRecording(id: number): Promise { + return this.client.delete(`/assist/records/${id}`) + .then(r => { + if (r.ok) { + return r.json().then(j => j.data) + } else { + throw new Error("Can't get recordings: " + r.status); + } + }) + } + } diff --git a/frontend/app/utils/screenRecorder.ts b/frontend/app/utils/screenRecorder.ts index fe68df4a5..6c7985a04 100644 --- a/frontend/app/utils/screenRecorder.ts +++ b/frontend/app/utils/screenRecorder.ts @@ -95,7 +95,11 @@ export async function screenRecorder(recName: string, sessionId: string, saveCb: const stream = await recordScreen(); const mediaRecorder = createFileRecorder(stream, FILE_TYPE, recName, sessionId, saveCb); - return () => mediaRecorder.stop(); + return () => { + if (mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + } + } } catch (e) { console.log(e); }