diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index df531c956..6de7eb121 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -28,7 +28,7 @@ const siteIdRequiredPaths = [ '/unprocessed', '/notes', '/feature-flags', - // '/custom_metrics/sessions', + '/check-recording-status' ]; const noStoringFetchPathStarts = [ diff --git a/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx b/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx index e209fefa5..3374236bd 100644 --- a/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx +++ b/frontend/app/components/shared/AnimatedSVG/AnimatedSVG.tsx @@ -25,6 +25,7 @@ export enum ICONS { NO_DASHBOARDS = 'ca-no-dashboards', NO_PROJECTS = 'ca-no-projects', NO_FFLAGS = 'no-fflags', + PROCESSING = 'ca-processing', } const ICONS_SVGS = { @@ -52,6 +53,7 @@ const ICONS_SVGS = { [ICONS.NO_DASHBOARDS]: require('../../../svg/ca-no-dashboards.svg').default, [ICONS.NO_PROJECTS]: require('../../../svg/ca-no-projects.svg').default, [ICONS.NO_FFLAGS]: require('../../../svg/no-fflags.svg').default, + [ICONS.PROCESSING]: require('../../../svg/ca-processing.svg').default, }; interface Props { diff --git a/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx b/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx index 8080bae32..07266a103 100644 --- a/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx +++ b/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx @@ -2,31 +2,42 @@ import React from 'react'; import SessionList from './components/SessionList'; import SessionHeader from './components/SessionHeader'; import NotesList from './components/Notes/NoteList'; -import { connect } from 'react-redux'; +import { connect, DefaultRootState } from 'react-redux'; import LatestSessionsMessage from './components/LatestSessionsMessage'; +import RecordingStatus from 'Shared/SessionsTabOverview/components/RecordingStatus'; function SessionsTabOverview({ - activeTab, - members, -}: { + activeTab, + members, + sites, + siteId + }: { activeTab: string; members: object[]; + sites: object[]; + siteId: string; }) { + const activeSite: any = sites.find((s: any) => s.id === siteId); + const hasNoRecordings = !activeSite || !activeSite.recorded; + return ( -
+
-
+
- {activeTab !== 'notes' ? : } + {activeTab !== 'notes' ? : + }
); } export default connect( - (state) => ({ + (state: any) => ({ // @ts-ignore activeTab: state.getIn(['search', 'activeTab', 'type']), // @ts-ignore members: state.getIn(['members', 'list']), - }), + siteId: state.getIn(['site', 'siteId']), + sites: state.getIn(['site', 'list']) + }) )(SessionsTabOverview); diff --git a/frontend/app/components/shared/SessionsTabOverview/components/RecordingStatus.tsx b/frontend/app/components/shared/SessionsTabOverview/components/RecordingStatus.tsx new file mode 100644 index 000000000..d522b9481 --- /dev/null +++ b/frontend/app/components/shared/SessionsTabOverview/components/RecordingStatus.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { Icon } from 'UI'; + +interface Props { + data: any; +} + +function RecordingStatus(props: Props) { + const { data } = props; + + + return ( +
+ +
+
Processing your first session.
+
+
+ +
+
Your tracker seems to be correctly setup!
+
+
+
+ +
+
There are {data.count} ongoing session(s).
Once they're complete they'll show up here within a few + minutes. +
+
+
+
+ ); +} + +export default RecordingStatus; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionsTabOverview/components/SessionList/SessionList.tsx b/frontend/app/components/shared/SessionsTabOverview/components/SessionList/SessionList.tsx index 468246608..b1f3417da 100644 --- a/frontend/app/components/shared/SessionsTabOverview/components/SessionList/SessionList.tsx +++ b/frontend/app/components/shared/SessionsTabOverview/components/SessionList/SessionList.tsx @@ -10,12 +10,15 @@ import { addFilterByKeyAndValue, updateCurrentPage, setScrollPosition, - checkForLatestSessions, + checkForLatestSessions } from 'Duck/search'; import { numberWithCommas } from 'App/utils'; import { fetchListActive as fetchMetadata } from 'Duck/customField'; import { toggleFavorite } from 'Duck/sessions'; import SessionDateRange from './SessionDateRange'; +import RecordingStatus from 'Shared/SessionsTabOverview/components/RecordingStatus'; +import { sessionService } from 'App/services'; +import { updateProjectRecordingStatus } from 'Duck/site'; enum NoContentType { Bookmarked, @@ -23,8 +26,17 @@ enum NoContentType { ToDate, } +type SessionStatus = { + status: number; + count: number; +} + const AUTOREFRESH_INTERVAL = 5 * 60 * 1000; let sessionTimeOut: any = null; +let sessionStatusTimeOut: any = null; + +const STATUS_FREQUENCY = 5000; + interface Props extends RouteComponentProps { loading: boolean; list: any; @@ -40,11 +52,15 @@ interface Props extends RouteComponentProps { setScrollPosition: (scrollPosition: number) => void; fetchSessions: (filters: any, force: boolean) => void; fetchMetadata: () => void; + updateProjectRecordingStatus: (siteId: string, status: boolean) => void; activeTab: any; isEnterprise?: boolean; checkForLatestSessions: () => void; toggleFavorite: (sessionId: string) => Promise; + sites: object[]; + siteId: string; } + function SessionList(props: Props) { const [noContentType, setNoContentType] = React.useState(NoContentType.ToDate); const { @@ -58,48 +74,91 @@ function SessionList(props: Props) { metaList, activeTab, isEnterprise = false, + sites, + siteId } = props; const _filterKeys = filters.map((i: any) => i.key); const hasUserFilter = _filterKeys.includes(FilterKey.USERID) || _filterKeys.includes(FilterKey.USERANONYMOUSID); const isBookmark = activeTab.type === 'bookmark'; const isVault = isBookmark && isEnterprise; + const activeSite: any = sites.find((s: any) => s.id === siteId); + const hasNoRecordings = !activeSite || !activeSite.recorded; + const NO_CONTENT = React.useMemo(() => { if (isBookmark && !isEnterprise) { setNoContentType(NoContentType.Bookmarked); return { icon: ICONS.NO_BOOKMARKS, - message: 'No sessions bookmarked.', + message: 'No sessions bookmarked.' }; } else if (isVault) { setNoContentType(NoContentType.Vaulted); return { icon: ICONS.NO_SESSIONS_IN_VAULT, - message: 'No sessions found in vault.', + message: 'No sessions found in vault.' }; } setNoContentType(NoContentType.ToDate); return { icon: ICONS.NO_SESSIONS, - message: , + message: }; }, [isBookmark, isVault, activeTab]); + const [statusData, setStatusData] = React.useState({ status: 0, count: 0 }); + + + const fetchStatus = async () => { + const response = await sessionService.getRecordingStatus(); + setStatusData({ + status: response.recording_status, + count: response.sessions_count + }); + }; + + + useEffect(() => { + if (!hasNoRecordings) { + return; + } + + fetchStatus(); + + sessionStatusTimeOut = setInterval(() => { + fetchStatus(); + }, STATUS_FREQUENCY); + + return () => clearInterval(sessionStatusTimeOut); + }, [hasNoRecordings]); + + + useEffect(() => { + if (!hasNoRecordings && statusData.status === 0) { + return; + } + + if (statusData.status === 2) { // recording && processed + props.updateProjectRecordingStatus(activeSite.id, true); + props.fetchSessions(null, true); + clearInterval(sessionStatusTimeOut); + } + }, [statusData]); useEffect(() => { const id = setInterval(() => { if (!document.hidden) { - props.checkForLatestSessions() + props.checkForLatestSessions(); } - }, AUTOREFRESH_INTERVAL) - return () => clearInterval(id) - }, []) + }, AUTOREFRESH_INTERVAL); + return () => clearInterval(id); + }, []); useEffect(() => { // handle scroll position const { scrollY } = props; window.scrollTo(0, scrollY); - if (total === 0 && !loading) { + if (total === 0 && !loading && !hasNoRecordings) { setTimeout(() => { props.fetchSessions(null, true); }, 300); @@ -117,7 +176,7 @@ function SessionList(props: Props) { return; } - sessionTimeOut = setTimeout(function () { + sessionTimeOut = setTimeout(function() { if (!document.hidden) { props.checkForLatestSessions(); } @@ -147,73 +206,73 @@ function SessionList(props: Props) { return ( - - -
-
- {NO_CONTENT.message} - {/* {noContentType === NoContentType.ToDate ? ( -
- + {hasNoRecordings && statusData.status >= 1 ? : ( + <> + + +
+
+ {NO_CONTENT.message}
- ) : null} */} -
-
- } - subtext={ -
- {(isVault || isBookmark) && ( -
- {isVault - ? 'Extend the retention period of any session by adding it to your vault directly from the player screen.' - : 'Effortlessly find important sessions by bookmarking them directly from the player screen.'}
- )} - -
- } - show={!loading && list.length === 0} - > - {list.map((session: any) => ( -
- -
- ))} - + } + subtext={ +
+ {(isVault || isBookmark) && ( +
+ {isVault + ? 'Extend the retention period of any session by adding it to your vault directly from the player screen.' + : 'Effortlessly find important sessions by bookmarking them directly from the player screen.'} +
+ )} + +
+ } + show={!loading && list.length === 0} + > + {list.map((session: any) => ( +
+ +
+ ))} + + + {total > 0 && ( +
+
+ Showing {(currentPage - 1) * pageSize + 1} to{' '} + {(currentPage - 1) * pageSize + list.length} of{' '} + {numberWithCommas(total)} sessions. +
+ props.updateCurrentPage(page)} + limit={pageSize} + debounceRequest={1000} + /> +
+ )} + - {total > 0 && ( -
-
- Showing {(currentPage - 1) * pageSize + 1} to{' '} - {(currentPage - 1) * pageSize + list.length} of{' '} - {numberWithCommas(total)} sessions. -
- props.updateCurrentPage(page)} - limit={pageSize} - debounceRequest={1000} - /> -
)} ); @@ -232,6 +291,8 @@ export default connect( activeTab: state.getIn(['search', 'activeTab']), pageSize: state.getIn(['search', 'pageSize']), isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', + siteId: state.getIn(['site', 'siteId']), + sites: state.getIn(['site', 'list']) }), { updateCurrentPage, @@ -241,5 +302,6 @@ export default connect( fetchMetadata, checkForLatestSessions, toggleFavorite, + updateProjectRecordingStatus } )(withRouter(SessionList)); diff --git a/frontend/app/duck/site.js b/frontend/app/duck/site.js index 348642f16..9678b94a9 100644 --- a/frontend/app/duck/site.js +++ b/frontend/app/duck/site.js @@ -1,30 +1,30 @@ import Site from 'Types/site'; import GDPR from 'Types/site/gdpr'; -import { - mergeReducers, - createItemInListUpdater, - success, - array, - createListUpdater, +import { + mergeReducers, + createItemInListUpdater, + success, + array, + createListUpdater } from './funcTools/tools'; -import { - createCRUDReducer, - getCRUDRequestTypes, - createInit, - createEdit, - createRemove, - createUpdate, - saveType, +import { + createCRUDReducer, + getCRUDRequestTypes, + createInit, + createEdit, + createRemove, + createUpdate, + saveType } from './funcTools/crud'; import { createRequestReducer } from './funcTools/request'; -import { Map, List, fromJS } from "immutable"; +import { Map, List, fromJS } from 'immutable'; import { GLOBAL_HAS_NO_RECORDINGS, SITE_ID_STORAGE_KEY } from 'App/constants/storageKeys'; const storedSiteId = localStorage.getItem(SITE_ID_STORAGE_KEY); const name = 'project'; const idKey = 'id'; -const itemInListUpdater = createItemInListUpdater(idKey) +const itemInListUpdater = createItemInListUpdater(idKey); const updateItemInList = createListUpdater(idKey); const EDIT_GDPR = 'sites/EDIT_GDPR'; @@ -37,94 +37,101 @@ const SAVE_GDPR_SUCCESS = success(SAVE_GDPR); const FETCH_LIST_SUCCESS = success(FETCH_LIST); const SAVE = saveType('sites/SAVE'); +const UPDATE_PROJECT_RECORDING_STATUS = 'sites/UPDATE_PROJECT_RECORDING_STATUS'; + const initialState = Map({ - list: List(), - instance: fromJS(), - remainingSites: undefined, - siteId: null, - active: null, + list: List(), + instance: fromJS(), + remainingSites: undefined, + siteId: null, + active: null }); const reducer = (state = initialState, action = {}) => { - switch(action.type) { - case EDIT_GDPR: - return state.mergeIn([ 'instance', 'gdpr' ], action.gdpr); - case FETCH_GDPR_SUCCESS: - return state.mergeIn([ 'instance', 'gdpr' ], action.data); - case success(SAVE): - const newSite = Site(action.data); - return updateItemInList(state, newSite) - .set('siteId', newSite.get('id')) - .set('active', newSite); - case SAVE_GDPR_SUCCESS: - const gdpr = GDPR(action.data); - return state.setIn([ 'instance', 'gdpr' ], gdpr); - case FETCH_LIST_SUCCESS: - let siteId = state.get("siteId"); - const siteIds = action.data.map(s => parseInt(s.projectId)) - const siteExists = siteIds.includes(siteId); - if (action.siteIdFromPath && siteIds.includes(parseInt(action.siteIdFromPath))) { - siteId = action.siteIdFromPath; - } else if (!siteId || !siteExists) { - siteId = siteIds.includes(parseInt(storedSiteId)) - ? storedSiteId - : action.data[0].projectId; - } - const list = List(action.data.map(Site)); - const hasRecordings = list.some(s => s.recorded); - if (!hasRecordings) { - localStorage.setItem(GLOBAL_HAS_NO_RECORDINGS, true) - } else { - localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS) - } - - return state.set('list', list) - .set('siteId', siteId) - .set('active', list.find(s => parseInt(s.id) === parseInt(siteId))); - case SET_SITE_ID: - const _siteId = action.siteId ? action.siteId : state.get('list').get(0).id; - localStorage.setItem(SITE_ID_STORAGE_KEY, _siteId) - const site = state.get('list').find(s => parseInt(s.id) == _siteId); - return state.set('siteId', _siteId).set('active', site); - } - return state; + switch (action.type) { + case EDIT_GDPR: + return state.mergeIn(['instance', 'gdpr'], action.gdpr); + case FETCH_GDPR_SUCCESS: + return state.mergeIn(['instance', 'gdpr'], action.data); + case success(SAVE): + const newSite = Site(action.data); + return updateItemInList(state, newSite) + .set('siteId', newSite.get('id')) + .set('active', newSite); + case SAVE_GDPR_SUCCESS: + const gdpr = GDPR(action.data); + return state.setIn(['instance', 'gdpr'], gdpr); + case FETCH_LIST_SUCCESS: + let siteId = state.get('siteId'); + const siteIds = action.data.map(s => parseInt(s.projectId)); + const siteExists = siteIds.includes(siteId); + if (action.siteIdFromPath && siteIds.includes(parseInt(action.siteIdFromPath))) { + siteId = action.siteIdFromPath; + } else if (!siteId || !siteExists) { + siteId = siteIds.includes(parseInt(storedSiteId)) + ? storedSiteId + : action.data[0].projectId; + } + const list = List(action.data.map(Site)); + const hasRecordings = list.some(s => s.recorded); + if (!hasRecordings) { + localStorage.setItem(GLOBAL_HAS_NO_RECORDINGS, true); + } else { + localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS); + } + + return state.set('list', list) + .set('siteId', siteId) + .set('active', list.find(s => parseInt(s.id) === parseInt(siteId))); + case SET_SITE_ID: + const _siteId = action.siteId ? action.siteId : state.get('list').get(0).id; + localStorage.setItem(SITE_ID_STORAGE_KEY, _siteId); + const site = state.get('list').find(s => parseInt(s.id) == _siteId); + return state.set('siteId', _siteId).set('active', site); + case UPDATE_PROJECT_RECORDING_STATUS: + const { siteId: _siteIdToUpdate, status } = action; + const siteToUpdate = state.get('list').find(s => parseInt(s.id) === parseInt(_siteIdToUpdate)); + const updatedSite = siteToUpdate.set('recorded', status); + return updateItemInList(state, updatedSite); + } + return state; }; export function editGDPR(gdpr) { return { type: EDIT_GDPR, - gdpr, + gdpr }; } export function fetchGDPR(siteId) { return { types: array(FETCH_GDPR), - call: client => client.get(`/${ siteId }/gdpr`), - } + call: client => client.get(`/${siteId}/gdpr`) + }; } export const saveGDPR = (siteId, gdpr) => (dispatch, getState) => { const g = getState().getIn(['site', 'instance', 'gdpr']); return dispatch({ types: array(SAVE_GDPR), - call: client => client.post(`/${ siteId }/gdpr`, g.toData()), + call: client => client.post(`/${siteId}/gdpr`, g.toData()) }); -} +}; export function fetchList(siteId) { - return { - types: array(FETCH_LIST), - call: client => client.get('/projects'), - siteIdFromPath: siteId - }; + return { + types: array(FETCH_LIST), + call: client => client.get('/projects'), + siteIdFromPath: siteId + }; } export function save(site) { - return { - types: array(SAVE), - call: client => client.post(`/projects`, site.toData()), - } + return { + types: array(SAVE), + call: client => client.post(`/projects`, site.toData()) + }; } // export const fetchList = createFetchList(name); @@ -135,18 +142,26 @@ export const update = createUpdate(name); export const remove = createRemove(name); export function setSiteId(siteId) { - return { - type: SET_SITE_ID, - siteId, - }; - } + return { + type: SET_SITE_ID, + siteId + }; +} + +export const updateProjectRecordingStatus = (siteId, status) => { + return { + type: UPDATE_PROJECT_RECORDING_STATUS, + siteId, + status + }; +}; export default mergeReducers( - reducer, - createCRUDReducer(name, Site, idKey), - createRequestReducer({ - saveGDPR: SAVE_GDPR, - ...getCRUDRequestTypes(name), - }), + reducer, + createCRUDReducer(name, Site, idKey), + createRequestReducer({ + saveGDPR: SAVE_GDPR, + ...getCRUDRequestTypes(name) + }) ); diff --git a/frontend/app/services/SessionService.ts b/frontend/app/services/SessionService.ts index aaf511873..b438c95f7 100644 --- a/frontend/app/services/SessionService.ts +++ b/frontend/app/services/SessionService.ts @@ -78,4 +78,12 @@ export default class SettingsService { .then(j => j.data || []) .catch(Promise.reject) } + + getRecordingStatus(): Promise { + return this.client + .get('/check-recording-status') + .then(r => r.json()) + .then(j => j.data || {}) + .catch(Promise.reject) + } } diff --git a/frontend/app/svg/ca-processing.svg b/frontend/app/svg/ca-processing.svg new file mode 100644 index 000000000..b5bd49272 --- /dev/null +++ b/frontend/app/svg/ca-processing.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +