diff --git a/frontend/app/Router.tsx b/frontend/app/Router.tsx index 33e5499a4..604731397 100644 --- a/frontend/app/Router.tsx +++ b/frontend/app/Router.tsx @@ -30,7 +30,8 @@ const components = { FunnelDetailsPure: lazy(() => import('Components/Funnels/FunnelDetails')), FunnelIssueDetails: lazy(() => import('Components/Funnels/FunnelIssueDetails')), FunnelPagePure: lazy(() => import('Components/Funnels/FunnelPage')), - MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')) + MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')), + AssistStatsPure: lazy(() => import('Components/AssistStats')), }; @@ -45,7 +46,8 @@ const enhancedComponents = { FunnelPage: withSiteIdUpdater(components.FunnelPagePure), FunnelsDetails: withSiteIdUpdater(components.FunnelDetailsPure), FunnelIssue: withSiteIdUpdater(components.FunnelIssueDetails), - Multiview: withSiteIdUpdater(components.MultiviewPure) + Multiview: withSiteIdUpdater(components.MultiviewPure), + AssistStats: withSiteIdUpdater(components.AssistStatsPure) }; const withSiteId = routes.withSiteId; @@ -70,19 +72,20 @@ const FFLAG_CREATE_PATH = routes.newFFlag(); const FFLAG_READ_PATH = routes.fflagRead(); const NOTES_PATH = routes.notes(); const BOOKMARKS_PATH = routes.bookmarks(); -const ASSIST_PATH = routes.assist(); const RECORDINGS_PATH = routes.recordings(); const FUNNEL_PATH = routes.funnels(); const FUNNEL_CREATE_PATH = routes.funnelsCreate(); const FUNNEL_ISSUE_PATH = routes.funnelIssue(); const SESSION_PATH = routes.session(); -const LIVE_SESSION_PATH = routes.liveSession(); const CLIENT_PATH = routes.client(); const ONBOARDING_PATH = routes.onboarding(); const ONBOARDING_REDIRECT_PATH = routes.onboarding(OB_DEFAULT_TAB); + +const ASSIST_PATH = routes.assist(); +const LIVE_SESSION_PATH = routes.liveSession(); const MULTIVIEW_PATH = routes.multiview(); const MULTIVIEW_INDEX_PATH = routes.multiviewIndex(); - +const ASSIST_STATS_PATH = routes.assistStats(); interface RouterProps extends RouteComponentProps, ConnectedProps { isLoggedIn: boolean; @@ -266,6 +269,8 @@ const Router: React.FC = (props) => { + (null); + const [period, setPeriod] = React.useState(Period({ rangeName: LAST_24_HOURS })); + const [membersSort, setMembersSort] = React.useState('sessionsAssisted'); + const [tableSort, setTableSort] = React.useState('timestamp'); + const [topMembers, setTopMembers] = React.useState<{ list: Member[]; total: number }>({ + list: [], + total: 0, + }); + const [graphs, setGraphs] = React.useState(defaultGraphs); + const [sessions, setSessions] = React.useState({ + list: [], + total: 0, + page: 1, + }); + const [isLoading, setIsLoading] = React.useState(false); + const [page, setPage] = React.useState(1); + + React.useEffect(() => { + void updateData(); + }, []); + + const onChangePeriod = async (period: any) => { + setPeriod(period); + void updateData(period); + }; + + const updateData = async (customPeriod?: any) => { + const usedP = customPeriod || period; + setIsLoading(true); + const topMembersPr = assistStatsService.getTopMembers({ + startTimestamp: usedP.start, + endTimestamp: usedP.end, + sort: membersSort, + order: 'desc', + }); + + const graphsPr = assistStatsService.getGraphs(usedP); + const sessionsPr = assistStatsService.getSessions({ + startTimestamp: usedP.start, + endTimestamp: usedP.end, + sort: tableSort, + order: 'desc', + userId: selectedUser ? selectedUser : undefined, + page: 1, + limit: 10, + }); + Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then( + ([topMembers, graphs, sessions]) => { + topMembers.status === 'fulfilled' && setTopMembers(topMembers.value); + graphs.status === 'fulfilled' && setGraphs(graphs.value); + sessions.status === 'fulfilled' && setSessions(sessions.value); + } + ); + setIsLoading(false); + }; + + const onPageChange = (page: number) => { + setPage(page); + assistStatsService + .getSessions({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: tableSort, + order: 'desc', + page, + limit: 10, + }) + .then((sessions) => { + setSessions(sessions); + }); + }; + + const onMembersSort = (sortBy: string) => { + setMembersSort(sortBy); + assistStatsService + .getTopMembers({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: sortBy, + order: 'desc', + }) + .then((topMembers) => { + setTopMembers(topMembers); + }); + }; + + const onTableSort = (sortBy: string) => { + setTableSort(sortBy); + assistStatsService + .getSessions({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: sortBy, + order: 'desc', + page: 1, + limit: 10, + }) + .then((sessions) => { + setSessions(sessions); + }); + }; + + const exportCSV = () => { + assistStatsService + .getSessions({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: tableSort, + order: 'desc', + page: 1, + limit: 10000, + }).then((sessions) => { + const data = sessions.list.map((s) => ({ + ...s, + members: `"${s.teamMembers.map((m) => m.name).join(', ')}"`, + dateStr: `"${formatTimeOrDate(s.timestamp, undefined, true)}"`, + assistDuration: `"${durationFromMsFormatted(s.assistDuration)}"`, + callDuration: `"${durationFromMsFormatted(s.callDuration)}"`, + controlDuration: `"${durationFromMsFormatted(s.controlDuration)}"`, + })); + const headers = [ + { label: 'Date', key: 'dateStr' }, + { label: 'Team Members', key: 'members' }, + { label: 'Live Duration', key: 'assistDuration' }, + { label: 'Call Duration', key: 'callDuration' }, + { label: 'Remote Duration', key: 'controlDuration' }, + { label: 'Session ID', key: 'sessionId' } + ]; + + exportCSVFile(headers, data, `Assist_Stats_${new Date().toLocaleDateString()}`) + + }) + }; + + const onUserSelect = (id: any) => { + setSelectedUser(id); + setIsLoading(true); + const topMembersPr = assistStatsService.getTopMembers({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: membersSort, + userId: id, + order: 'desc', + }); + + const graphsPr = assistStatsService.getGraphs(period, id); + const sessionsPr = assistStatsService.getSessions({ + startTimestamp: period.start, + endTimestamp: period.end, + sort: tableSort, + userId: id, + order: 'desc', + page: 1, + limit: 10, + }) + + Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then( + ([topMembers, graphs, sessions]) => { + topMembers.status === 'fulfilled' && setTopMembers(topMembers.value); + graphs.status === 'fulfilled' && setGraphs(graphs.value); + sessions.status === 'fulfilled' && setSessions(sessions.value); + } + ); + setIsLoading(false); + + }; + + return ( + <> +
+
+ + Assist Stats + +
+ + + + +
+
+
+
+ {Object.keys(graphs.currentPeriod).map((i: PeriodKeys) => ( +
+
+ + {chartNames[i]} + +
+ + {graphs.currentPeriod[i] + ? durationFromMsFormatted(graphs.currentPeriod[i]) + : 0} + + {graphs.previousPeriod[i] ? ( +
graphs.previousPeriod[i] + ? 'flex items-center gap-1 text-green' + : 'flex items-center gap-2 text-red' + } + > + graphs.previousPeriod[i] ? 0 : 180} + /> + {`${Math.round( + calculatePercentageDelta( + graphs.currentPeriod[i], + graphs.previousPeriod[i] + ) + )}%`} +
+ ) : null} +
+
+ + + +
+ ))} +
+
+ +
+
+
+ +
+
+
+ + ); +} + +export default withPageTitle('Assist Stats - Openreplay')(AssistStats); diff --git a/frontend/app/components/AssistStats/components/Charts.tsx b/frontend/app/components/AssistStats/components/Charts.tsx new file mode 100644 index 000000000..2c88dc918 --- /dev/null +++ b/frontend/app/components/AssistStats/components/Charts.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { NoContent } from 'UI'; +import { Styles } from 'Components/Dashboard/Widgets/common'; +import { + AreaChart, + Area, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; + +interface Props { + data: any; + label: string; +} + +function Chart(props: Props) { + const { data, label } = props; + const gradientDef = Styles.gradientDef(); + + return ( + + + + {gradientDef} + + Styles.tickFormatter(val)} + label={{ ...Styles.axisLabelLeft, value: label }} + /> + + + + + ); +} + +export default Chart; diff --git a/frontend/app/components/AssistStats/components/Table.tsx b/frontend/app/components/AssistStats/components/Table.tsx new file mode 100644 index 000000000..4e9e7fddb --- /dev/null +++ b/frontend/app/components/AssistStats/components/Table.tsx @@ -0,0 +1,165 @@ +import { DownOutlined } from '@ant-design/icons'; +import { AssistStatsSession, SessionsResponse } from 'App/services/AssistStatsService'; +import { numberWithCommas } from 'App/utils'; +import React from 'react'; +import { Button, Dropdown, Space, Typography, Tooltip } from 'antd'; +import { CloudDownloadOutlined, TableOutlined } from '@ant-design/icons'; +import { Loader, Pagination } from 'UI'; +import PlayLink from 'Shared/SessionItem/PlayLink'; +import { recordingsService } from 'App/services'; +import { checkForRecent, durationFromMsFormatted, getDateFromMill } from 'App/date'; + +interface Props { + onSort: (v: string) => void; + isLoading: boolean; + onPageChange: (page: number) => void; + page: number; + sessions: SessionsResponse; + exportCSV: () => void; +} + +const PER_PAGE = 10; +const sortItems = [ + { + key: 'timestamp', + label: 'Newest First', + }, + { + key: 'assist_duration', + label: 'Live Duration', + }, + { + key: 'call_duration', + label: 'Call Duration', + }, + { + key: 'control_duration', + label: 'Remote Duration', + }, + // { + // key: '5', + // label: 'Team Member', + // }, +]; + +function StatsTable({ onSort, isLoading, onPageChange, page, sessions, exportCSV }: Props) { + const [sortValue, setSort] = React.useState(sortItems[0].label); + const updateRange = ({ key }: { key: string }) => { + const item = sortItems.find((item) => item.key === key); + setSort(item?.label || sortItems[0].label); + item?.key && onSort(item.key); + }; + + return ( +
+
+ + Assisted Sessions + +
+ + + + +
+
+ Date + Team Members + Live Duration + Call Duration + Remote Duration + + {/* SPACER */} + {/* BUTTONS */} +
+
+ + {sessions.list.map((session) => ( + + ))} + +
+
+ {sessions.total > 0 ? ( +
+ Showing {(page - 1) * PER_PAGE + 1} to{' '} + {(page - 1) * PER_PAGE + sessions.list.length} of{' '} + {numberWithCommas(sessions.total)} sessions. +
+ ) : ( +
+ Showing 0 to 0{' '} + of 0 sessions. +
+ )} + 0 ? page : 0} + totalPages={Math.ceil(sessions.total / PER_PAGE)} + onPageChange={onPageChange} + limit={10} + debounceRequest={200} + /> +
+
+ ); +} + +function Row({ session }: { session: AssistStatsSession }) { + return ( +
+ {checkForRecent(getDateFromMill(session.timestamp)!, 'LLL dd, hh:mm a')} + +
+ {session.teamMembers.map((member) => ( +
{member.name}
+ ))} +
+
+ {durationFromMsFormatted(session.assistDuration)} + {durationFromMsFormatted(session.callDuration)} + {durationFromMsFormatted(session.controlDuration)} + + +
+ {session.recordings?.length > 0 ? ( + session.recordings?.length > 1 ? ( + ({ + key: recording.recordId, + label: recording.name.slice(0, 20), + })), + onClick: (item) => + recordingsService.fetchRecording(item.key as unknown as number), + }} + > + + + ) : ( +
recordingsService.fetchRecording(session.recordings[0].recordId)} + > + +
+ ) + ) : null} + +
+
+
+ ); +} + +function Cell({ size, children }: { size: number; children?: React.ReactNode }) { + return
{children}
; +} + +export default StatsTable; diff --git a/frontend/app/components/AssistStats/components/TeamMembers.tsx b/frontend/app/components/AssistStats/components/TeamMembers.tsx new file mode 100644 index 000000000..dddee4fe1 --- /dev/null +++ b/frontend/app/components/AssistStats/components/TeamMembers.tsx @@ -0,0 +1,117 @@ +import { DownOutlined, TableOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Space, Typography, Tooltip } from 'antd'; +import { durationFromMsFormatted } from 'App/date'; +import { Member } from 'App/services/AssistStatsService'; +import { getInitials } from 'App/utils'; +import React from 'react'; +import { Loader } from 'UI'; +import { exportCSVFile } from 'App/utils'; + +const items = [ + { + label: 'Sessions Assisted', + key: 'sessionsAssisted', + }, + { + label: 'Live Duration', + key: 'assistDuration', + }, + { + label: 'Call Duration', + key: 'callDuration', + }, + { + label: 'Remote Duration', + key: 'controlDuration', + }, +]; + +function TeamMembers({ + isLoading, + topMembers, + onMembersSort, + membersSort, +}: { + isLoading: boolean; + topMembers: { list: Member[]; total: number }; + onMembersSort: (v: string) => void; + membersSort: string; +}) { + const [dateRange, setDateRange] = React.useState(items[0].label); + const updateRange = ({ key }: { key: string }) => { + const item = items.find((item) => item.key === key); + setDateRange(item?.label || items[0].label); + onMembersSort(item?.key || items[0].key); + }; + + const onExport = () => { + const headers = [ + { label: 'Team Member', key: 'name' }, + { label: 'Sessions Assisted', key: 'sessionsAssisted' }, + { label: 'Live Duration', key: 'assistDuration' }, + { label: 'Call Duration', key: 'callDuration' }, + { label: 'Remote Duration', key: 'controlDuration' }, + ]; + + const data = topMembers.list.map((member) => ({ + name: `"${member.name}"`, + sessionsAssisted: `"${member.assistCount}"`, + assistDuration: `"${durationFromMsFormatted(member.assistDuration)}"`, + callDuration: `"${durationFromMsFormatted(member.callDuration)}"`, + controlDuration: `"${durationFromMsFormatted(member.controlDuration)}"`, + })); + + exportCSVFile(headers, data, `Team_Members_${new Date().toLocaleDateString()}`); + }; + + return ( +
+
+ + Team Members + +
+ + + + +
+
+ + {topMembers.list.map((member) => ( +
+
+
+
{getInitials(member.name)}
+
+
{member.name}
+
+ {membersSort === 'sessionsAssisted' + ? member.count + : durationFromMsFormatted(member.count)} +
+
+ ))} + +
+ {isLoading || topMembers.list.length === 0 + ? '' + : `Showing 1 to ${topMembers.total} of the total`} +
+
+ ); +} + +export default TeamMembers; diff --git a/frontend/app/components/AssistStats/components/UserSearch.tsx b/frontend/app/components/AssistStats/components/UserSearch.tsx new file mode 100644 index 000000000..3e9baac1a --- /dev/null +++ b/frontend/app/components/AssistStats/components/UserSearch.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { AutoComplete, Input } from 'antd'; +import type { SelectProps } from 'antd/es/select'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; + +const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => { + const [selectedValue, setSelectedValue] = useState(undefined); + const { userStore } = useStore(); + const allUsers = userStore.list.map((user) => ({ + value: user.userId, + label: user.name, + })); + const [options, setOptions] = useState['options']>([]); + + React.useEffect(() => { + if (userStore.list.length === 0) { + userStore.fetchUsers().then((r) => { + setOptions( + r.map((user: any) => ({ + value: user.userId, + label: user.name, + })) + ); + }); + } + }, []); + + const handleSearch = (value: string) => { + setOptions( + value ? allUsers.filter((u) => u.label.toLowerCase().includes(value.toLocaleLowerCase())) : [] + ); + }; + + const onSelect = (value?: string) => { + onUserSelect(value) + setSelectedValue(allUsers.find((u) => u.value === value)?.label || ''); + }; + + return ( + { + setSelectedValue(e) + if (!e) onUserSelect(undefined) + }} + onClear={() => onSelect(undefined)} + onDeselect={() => onSelect(undefined)} + size="small" + > + + + ); +}; + +export default observer(UserSearch); diff --git a/frontend/app/components/AssistStats/index.ts b/frontend/app/components/AssistStats/index.ts new file mode 100644 index 000000000..96108d5f2 --- /dev/null +++ b/frontend/app/components/AssistStats/index.ts @@ -0,0 +1 @@ +export { default } from './AssistStats' diff --git a/frontend/app/components/AssistStats/pdfGenerator.ts b/frontend/app/components/AssistStats/pdfGenerator.ts new file mode 100644 index 000000000..787a32114 --- /dev/null +++ b/frontend/app/components/AssistStats/pdfGenerator.ts @@ -0,0 +1,74 @@ +import { fileNameFormat } from 'App/utils'; + +export const getPdf2 = async () => { + // @ts-ignore + import('html2canvas').then(({ default: html2canvas }) => { + // @ts-ignore + window.html2canvas = html2canvas; + + // @ts-ignore + import('jspdf').then(({ jsPDF }) => { + const doc = new jsPDF('l', 'mm', 'a4'); + const now = new Date().toISOString(); + + doc.addMetadata('Author', 'OpenReplay'); + doc.addMetadata('Title', 'OpenReplay Assist Stats'); + doc.addMetadata('Subject', 'OpenReplay Assist Stats'); + doc.addMetadata('Keywords', 'OpenReplay Assist Stats'); + doc.addMetadata('Creator', 'OpenReplay'); + doc.addMetadata('Producer', 'OpenReplay'); + doc.addMetadata('CreationDate', now); + + const el = document.getElementById('pdf-anchor') as HTMLElement; + + function buildPng() { + html2canvas(el, { + scale: 2, + ignoreElements: (e) => e.id.includes('pdf-ignore'), + }).then((canvas) => { + const imgData = canvas.toDataURL('img/png'); + + let imgWidth = 290; + let pageHeight = 200; + let imgHeight = (canvas.height * imgWidth) / canvas.width; + let heightLeft = imgHeight - pageHeight; + let position = 0; + const A4Height = 295; + const headerW = 40; + const logoWidth = 55; + doc.addImage(imgData, 'PNG', 3, 10, imgWidth, imgHeight); + + doc.addImage('/assets/img/cobrowising-report-head.png', 'png', A4Height / 2 - headerW / 2, 2, 45, 5); + if (position === 0 && heightLeft === 0) + doc.addImage( + '/assets/img/report-head.png', + 'png', + imgWidth / 2 - headerW / 2, + pageHeight - 5, + logoWidth, + 5 + ); + + while (heightLeft >= 0) { + position = heightLeft - imgHeight; + doc.addPage(); + doc.addImage(imgData, 'PNG', 5, position, imgWidth, imgHeight); + doc.addImage( + '/assets/img/report-head.png', + 'png', + A4Height / 2 - headerW / 2, + pageHeight - 5, + logoWidth, + 5 + ); + heightLeft -= pageHeight; + } + + doc.save(fileNameFormat('Assist_Stats_' + Date.now(), '.pdf')); + }); + } + + buildPng(); + }); + }); +}; diff --git a/frontend/app/components/Session/LivePlayer.tsx b/frontend/app/components/Session/LivePlayer.tsx index 9977c49fe..d470cb9d1 100644 --- a/frontend/app/components/Session/LivePlayer.tsx +++ b/frontend/app/components/Session/LivePlayer.tsx @@ -26,6 +26,7 @@ interface Props { query?: Record any>; request: () => void; userId: number; + siteId: number; } let playerInst: ILivePlayerContext['player'] | undefined; @@ -39,6 +40,7 @@ function LivePlayer({ query, isEnterprise, userId, + siteId, }: Props) { // @ts-ignore const [contextValue, setContextValue] = useState(defaultContextValue); @@ -67,6 +69,7 @@ function LivePlayer({ sessionWithAgentData, data, userId, + siteId, (state) => makeAutoObservable(state), toast ); @@ -78,6 +81,7 @@ function LivePlayer({ sessionWithAgentData, null, userId, + siteId, (state) => makeAutoObservable(state), toast ); @@ -140,6 +144,7 @@ export default withPermissions( )( connect((state: any) => { return { + siteId: state.getIn([ 'site', 'siteId' ]), session: state.getIn(['sessions', 'current']), showAssist: state.getIn(['sessions', 'showChatWindow']), isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee', diff --git a/frontend/app/components/shared/SelectDateRange/SelectDateRange.tsx b/frontend/app/components/shared/SelectDateRange/SelectDateRange.tsx index f24e0c7af..d7754fdce 100644 --- a/frontend/app/components/shared/SelectDateRange/SelectDateRange.tsx +++ b/frontend/app/components/shared/SelectDateRange/SelectDateRange.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Button, Dropdown, Space, Typography, Input } from 'antd'; +import { FilePdfOutlined, DownOutlined, TableOutlined } from '@ant-design/icons'; import { DATE_RANGE_OPTIONS, CUSTOM_RANGE } from 'App/dateRange'; import Select from 'Shared/Select'; import Period from 'Types/app/period'; @@ -9,81 +11,149 @@ import cn from 'classnames'; import { observer } from 'mobx-react-lite'; interface Props { - period: any; - onChange: (data: any) => void; - disableCustom?: boolean; - right?: boolean; - timezone?: string; - [x: string]: any; + period: any; + onChange: (data: any) => void; + disableCustom?: boolean; + right?: boolean; + timezone?: string; + isAnt?: boolean; + + [x: string]: any; } + function SelectDateRange(props: Props) { - const [isCustom, setIsCustom] = React.useState(false); - const { right = false, period, disableCustom = false, timezone, ...rest } = props; - let selectedValue = DATE_RANGE_OPTIONS.find((obj: any) => obj.value === period.rangeName); - const options = DATE_RANGE_OPTIONS.filter((obj: any) => (disableCustom ? obj.value !== CUSTOM_RANGE : true)); + const [isCustom, setIsCustom] = React.useState(false); + const { right = false, period, disableCustom = false, timezone, ...rest } = props; + let selectedValue = DATE_RANGE_OPTIONS.find((obj: any) => obj.value === period.rangeName); + const options = DATE_RANGE_OPTIONS.filter((obj: any) => + disableCustom ? obj.value !== CUSTOM_RANGE : true + ); - const onChange = (value: any) => { - if (value === CUSTOM_RANGE) { - setIsCustom(true); - } else { - // @ts-ignore - props.onChange(new Period({ rangeName: value })); - } + const onChange = (value: any) => { + if (value === CUSTOM_RANGE) { + setIsCustom(true); + } else { + // @ts-ignore + props.onChange(new Period({ rangeName: value })); + } + }; + + const onApplyDateRange = (value: any) => { + // @ts-ignore + const range = new Period({ rangeName: CUSTOM_RANGE, start: value.start, end: value.end }); + props.onChange(range); + setIsCustom(false); + }; + + const isCustomRange = period.rangeName === CUSTOM_RANGE; + const customRange = isCustomRange ? period.rangeFormatted() : ''; + + if (props.isAnt) { + const onAntUpdate = ({ key }: { key: string }) => { + onChange(key); }; - - const onApplyDateRange = (value: any) => { - // @ts-ignore - const range = new Period({ rangeName: CUSTOM_RANGE, start: value.start, end: value.end }) - props.onChange(range); - setIsCustom(false); - }; - - const isCustomRange = period.rangeName === CUSTOM_RANGE; - const customRange = isCustomRange ? period.rangeFormatted() : ''; - return ( -
- onChange(value.value)} + components={{ + SingleValue: ({ children, ...props }: any) => { + return ( + + {isCustomRange ? customRange : children} + + ); + }, + }} + period={period} + right={true} + style={{ width: '100%' }} + /> + {isCustom && ( + { + if ( + e.target.parentElement.parentElement.classList.contains( + 'rc-time-picker-panel-select' + ) || + e.target.parentElement.parentElement.classList[0]?.includes('-menu') + ) { + return false; + } + setIsCustom(false); + }} + > +
+ setIsCustom(false)} + selectedDateRange={period.range} + /> +
+
+ )} +
+ ); } export default observer(SelectDateRange); diff --git a/frontend/app/components/ui/Pagination/Pagination.tsx b/frontend/app/components/ui/Pagination/Pagination.tsx index 71c249901..7ddea9b66 100644 --- a/frontend/app/components/ui/Pagination/Pagination.tsx +++ b/frontend/app/components/ui/Pagination/Pagination.tsx @@ -24,7 +24,7 @@ export default function Pagination(props: Props) { } }; - const isFirstPage = currentPage === 1; + const isFirstPage = currentPage <= 1; const isLastPage = currentPage === totalPages; return (
diff --git a/frontend/app/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index c3e3f4caf..8f778bdec 100644 --- a/frontend/app/components/ui/SVG.tsx +++ b/frontend/app/components/ui/SVG.tsx @@ -1,7 +1,7 @@ import React from 'react'; -export type IconNames = 'activity' | 'alarm-clock' | 'alarm-plus' | 'all-sessions' | 'analytics' | 'anchor' | 'arrow-alt-square-right' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down-up' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-square-left' | 'arrow-square-right' | 'arrow-up-short' | 'arrow-up' | 'arrows-angle-extend' | 'avatar/icn_avatar1' | 'avatar/icn_avatar10' | 'avatar/icn_avatar11' | 'avatar/icn_avatar12' | 'avatar/icn_avatar13' | 'avatar/icn_avatar14' | 'avatar/icn_avatar15' | 'avatar/icn_avatar16' | 'avatar/icn_avatar17' | 'avatar/icn_avatar18' | 'avatar/icn_avatar19' | 'avatar/icn_avatar2' | 'avatar/icn_avatar20' | 'avatar/icn_avatar21' | 'avatar/icn_avatar22' | 'avatar/icn_avatar23' | 'avatar/icn_avatar3' | 'avatar/icn_avatar4' | 'avatar/icn_avatar5' | 'avatar/icn_avatar6' | 'avatar/icn_avatar7' | 'avatar/icn_avatar8' | 'avatar/icn_avatar9' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'bell-fill' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book-doc' | 'book' | 'bookmark' | 'broadcast' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'buildings' | 'bullhorn' | 'business-time' | 'calendar-alt' | 'calendar-check' | 'calendar-day' | 'calendar' | 'call' | 'camera-alt' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-checklist' | 'card-list' | 'card-text' | 'caret-down-fill' | 'caret-left-fill' | 'caret-right-fill' | 'caret-up-fill' | 'chat-dots' | 'chat-left-text' | 'chat-right-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-double-left' | 'chevron-double-right' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'click-hesitation' | 'click-rage' | 'clipboard-list-check' | 'clock-history' | 'clock' | 'close' | 'cloud-fog2-fill' | 'code' | 'cog' | 'cogs' | 'collection-play' | 'collection' | 'columns-gap-filled' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-front' | 'cross' | 'cubes' | 'cursor-trash' | 'dash' | 'dashboard-icn' | 'db-icons/icn-card-clickMap' | 'db-icons/icn-card-errors' | 'db-icons/icn-card-funnel' | 'db-icons/icn-card-funnels' | 'db-icons/icn-card-insights' | 'db-icons/icn-card-library' | 'db-icons/icn-card-mapchart' | 'db-icons/icn-card-pathAnalysis' | 'db-icons/icn-card-performance' | 'db-icons/icn-card-resources' | 'db-icons/icn-card-table' | 'db-icons/icn-card-timeseries' | 'db-icons/icn-card-webVitals' | 'desktop' | 'device' | 'diagram-3' | 'dice-3' | 'dizzy' | 'door-closed' | 'doublecheck' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'enter' | 'envelope-check' | 'envelope-x' | 'envelope' | 'errors-icon' | 'event/click' | 'event/click_hesitation' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/input_hesitation' | 'event/link' | 'event/location' | 'event/mouse_thrashing' | 'event/resize' | 'event/view' | 'exclamation-circle-fill' | 'exclamation-circle' | 'exclamation-triangle' | 'expand-wide' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch' | 'fflag-multi' | 'fflag-single' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filetype-js' | 'filetype-pdf' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/state-action' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel-new' | 'funnel' | 'gear-fill' | 'gear' | 'geo-alt-fill-custom' | 'github' | 'graph-up-arrow' | 'graph-up' | 'grid-1x2' | 'grid-3x3' | 'grid-check' | 'grid-horizontal' | 'grid' | 'grip-horizontal' | 'hash' | 'hdd-stack' | 'headset' | 'heart-rate' | 'high-engagement' | 'history' | 'hourglass-start' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'input-hesitation' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'integrations/zustand' | 'journal-code' | 'key' | 'layer-group' | 'layers-half' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-arrow' | 'list-ul' | 'list' | 'lock-alt' | 'magic' | 'map-marker-alt' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'people' | 'percent' | 'performance-icon' | 'person-border' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plug' | 'plus-circle' | 'plus-lg' | 'plus' | 'pointer-sessions-search' | 'prev1' | 'pulse' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quote-left' | 'quote-right' | 'quotes' | 'record-btn' | 'record-circle' | 'record2' | 'redo-back' | 'redo' | 'redux' | 'remote-control' | 'replay-10' | 'resources-icon' | 'safe-fill' | 'safe' | 'sandglass' | 'search' | 'search_notification' | 'server' | 'share-alt' | 'shield-lock' | 'side_menu_closed' | 'side_menu_open' | 'signpost-split' | 'signup' | 'skip-forward-fill' | 'skip-forward' | 'slack' | 'slash-circle' | 'sleep' | 'sliders' | 'social/slack' | 'social/trello' | 'speedometer2' | 'spinner' | 'star-solid' | 'star' | 'step-forward' | 'stickies' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table-new' | 'table' | 'tablet-android' | 'tachometer-slow' | 'tachometer-slowest' | 'tags' | 'team-funnel' | 'telephone-fill' | 'telephone' | 'terminal' | 'text-paragraph' | 'toggles' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'users' | 'vendors/graphql' | 'vendors/mobx' | 'vendors/ngrx' | 'vendors/redux' | 'vendors/vuex' | 'web-vitals' | 'wifi' | 'window-alt' | 'window-restore' | 'window-x' | 'window' | 'zoom-in'; +export type IconNames = 'activity' | 'alarm-clock' | 'alarm-plus' | 'all-sessions' | 'analytics' | 'anchor' | 'arrow-alt-square-right' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down-up' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-square-left' | 'arrow-square-right' | 'arrow-up-short' | 'arrow-up' | 'arrows-angle-extend' | 'avatar/icn_avatar1' | 'avatar/icn_avatar10' | 'avatar/icn_avatar11' | 'avatar/icn_avatar12' | 'avatar/icn_avatar13' | 'avatar/icn_avatar14' | 'avatar/icn_avatar15' | 'avatar/icn_avatar16' | 'avatar/icn_avatar17' | 'avatar/icn_avatar18' | 'avatar/icn_avatar19' | 'avatar/icn_avatar2' | 'avatar/icn_avatar20' | 'avatar/icn_avatar21' | 'avatar/icn_avatar22' | 'avatar/icn_avatar23' | 'avatar/icn_avatar3' | 'avatar/icn_avatar4' | 'avatar/icn_avatar5' | 'avatar/icn_avatar6' | 'avatar/icn_avatar7' | 'avatar/icn_avatar8' | 'avatar/icn_avatar9' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'bell-fill' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book-doc' | 'book' | 'bookmark' | 'broadcast' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'buildings' | 'bullhorn' | 'business-time' | 'calendar-alt' | 'calendar-check' | 'calendar-day' | 'calendar' | 'call' | 'camera-alt' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-checklist' | 'card-list' | 'card-text' | 'caret-down-fill' | 'caret-left-fill' | 'caret-right-fill' | 'caret-up-fill' | 'chat-dots' | 'chat-left-text' | 'chat-right-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-double-left' | 'chevron-double-right' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'click-hesitation' | 'click-rage' | 'clipboard-list-check' | 'clock-history' | 'clock' | 'close' | 'cloud-fog2-fill' | 'code' | 'cog' | 'cogs' | 'collection-play' | 'collection' | 'columns-gap-filled' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-front' | 'cross' | 'cubes' | 'cursor-trash' | 'dash' | 'dashboard-icn' | 'db-icons/icn-card-clickMap' | 'db-icons/icn-card-errors' | 'db-icons/icn-card-funnel' | 'db-icons/icn-card-funnels' | 'db-icons/icn-card-insights' | 'db-icons/icn-card-library' | 'db-icons/icn-card-mapchart' | 'db-icons/icn-card-pathAnalysis' | 'db-icons/icn-card-performance' | 'db-icons/icn-card-resources' | 'db-icons/icn-card-table' | 'db-icons/icn-card-timeseries' | 'db-icons/icn-card-webVitals' | 'desktop' | 'device' | 'diagram-3' | 'dice-3' | 'dizzy' | 'door-closed' | 'doublecheck' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'enter' | 'envelope-check' | 'envelope-x' | 'envelope' | 'errors-icon' | 'event/click' | 'event/click_hesitation' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/input_hesitation' | 'event/link' | 'event/location' | 'event/mouse_thrashing' | 'event/resize' | 'event/view' | 'exclamation-circle-fill' | 'exclamation-circle' | 'exclamation-triangle' | 'expand-wide' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch' | 'fflag-multi' | 'fflag-single' | 'file-bar-graph' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filetype-js' | 'filetype-pdf' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/state-action' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel-new' | 'funnel' | 'gear-fill' | 'gear' | 'geo-alt-fill-custom' | 'github' | 'graph-up-arrow' | 'graph-up' | 'grid-1x2' | 'grid-3x3' | 'grid-check' | 'grid-horizontal' | 'grid' | 'grip-horizontal' | 'hash' | 'hdd-stack' | 'headset' | 'heart-rate' | 'high-engagement' | 'history' | 'hourglass-start' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'input-hesitation' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'integrations/zustand' | 'journal-code' | 'key' | 'layer-group' | 'layers-half' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-arrow' | 'list-ul' | 'list' | 'lock-alt' | 'magic' | 'map-marker-alt' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'people' | 'percent' | 'performance-icon' | 'person-border' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plug' | 'plus-circle' | 'plus-lg' | 'plus' | 'pointer-sessions-search' | 'prev1' | 'pulse' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quote-left' | 'quote-right' | 'quotes' | 'record-btn' | 'record-circle' | 'record2' | 'redo-back' | 'redo' | 'redux' | 'remote-control' | 'replay-10' | 'resources-icon' | 'safe-fill' | 'safe' | 'sandglass' | 'search' | 'search_notification' | 'server' | 'share-alt' | 'shield-lock' | 'side_menu_closed' | 'side_menu_open' | 'signpost-split' | 'signup' | 'skip-forward-fill' | 'skip-forward' | 'slack' | 'slash-circle' | 'sleep' | 'sliders' | 'social/slack' | 'social/trello' | 'speedometer2' | 'spinner' | 'star-solid' | 'star' | 'step-forward' | 'stickies' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table-new' | 'table' | 'tablet-android' | 'tachometer-slow' | 'tachometer-slowest' | 'tags' | 'team-funnel' | 'telephone-fill' | 'telephone' | 'terminal' | 'text-paragraph' | 'toggles' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'users' | 'vendors/graphql' | 'vendors/mobx' | 'vendors/ngrx' | 'vendors/redux' | 'vendors/vuex' | 'web-vitals' | 'wifi' | 'window-alt' | 'window-restore' | 'window-x' | 'window' | 'zoom-in'; interface Props { name: IconNames; @@ -194,6 +194,7 @@ const SVG = (props: Props) => { case 'fetch': return ; case 'fflag-multi': return ; case 'fflag-single': return ; + case 'file-bar-graph': return ; case 'file-code': return ; case 'file-medical-alt': return ; case 'file-pdf': return ; diff --git a/frontend/app/layout/SideMenu.tsx b/frontend/app/layout/SideMenu.tsx index 41c4e1050..99621c149 100644 --- a/frontend/app/layout/SideMenu.tsx +++ b/frontend/app/layout/SideMenu.tsx @@ -47,14 +47,14 @@ function SideMenu(props: Props) { const updatedItems = category.items.map(item => { if (item.hidden) return item; - const isHidden = [ - (item.key === MENU.NOTES && modules.includes(MODULES.NOTES)), - (item.key === MENU.LIVE_SESSIONS || item.key === MENU.RECORDINGS) && modules.includes(MODULES.ASSIST), - (item.key === MENU.SESSIONS && modules.includes(MODULES.OFFLINE_RECORDINGS)), - (item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS)), - (item.isAdmin && !isAdmin), - (item.isEnterprise && !isEnterprise) - ].some(cond => cond); + const isHidden = [ + (item.key === MENU.NOTES && modules.includes(MODULES.NOTES)), + (item.key === MENU.LIVE_SESSIONS || item.key === MENU.RECORDINGS || item.key === MENU.STATS) && modules.includes(MODULES.ASSIST), + (item.key === MENU.SESSIONS && modules.includes(MODULES.OFFLINE_RECORDINGS)), + (item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS)), + (item.isAdmin && !isAdmin), + (item.isEnterprise && !isEnterprise) + ].some(cond => cond); return { ...item, hidden: isHidden }; }); @@ -86,6 +86,7 @@ function SideMenu(props: Props) { [MENU.BOOKMARKS]: () => withSiteId(routes.bookmarks(), siteId), [MENU.NOTES]: () => withSiteId(routes.notes(), siteId), [MENU.LIVE_SESSIONS]: () => withSiteId(routes.assist(), siteId), + [MENU.STATS]: () => withSiteId(routes.assistStats(), siteId), [MENU.RECORDINGS]: () => withSiteId(routes.recordings(), siteId), [MENU.DASHBOARDS]: () => withSiteId(routes.dashboard(), siteId), [MENU.CARDS]: () => withSiteId(routes.metrics(), siteId), diff --git a/frontend/app/layout/data.ts b/frontend/app/layout/data.ts index be0532e24..50f6beb91 100644 --- a/frontend/app/layout/data.ts +++ b/frontend/app/layout/data.ts @@ -44,6 +44,7 @@ export const enum MENU { NOTES = 'notes', LIVE_SESSIONS = 'live-sessions', RECORDINGS = 'recordings', + STATS = 'stats', DASHBOARDS = 'dashboards', CARDS = 'cards', FUNNELS = 'funnels', @@ -72,7 +73,8 @@ export const categories: Category[] = [ key: 'assist', items: [ { label: 'Cobrowse', key: MENU.LIVE_SESSIONS, icon: 'broadcast' }, - { label: 'Recordings', key: MENU.RECORDINGS, icon: 'record-btn', isEnterprise: true } + { label: 'Recordings', key: MENU.RECORDINGS, icon: 'record-btn', isEnterprise: true }, + { label: 'Stats', key: MENU.STATS, icon: 'file-bar-graph' }, ] }, { diff --git a/frontend/app/player/create.ts b/frontend/app/player/create.ts index a40f75e1e..0286a8017 100644 --- a/frontend/app/player/create.ts +++ b/frontend/app/player/create.ts @@ -51,6 +51,7 @@ export function createLiveWebPlayer( session: SessionFilesInfo, config: RTCIceServer[] | null, agentId: number, + projectId: number, wrapStore?: (s:IWebLivePlayerStore) => IWebLivePlayerStore, uiErrorHandler?: { error: (msg: string) => void } ): [IWebLivePlayer, IWebLivePlayerStore] { @@ -61,6 +62,6 @@ export function createLiveWebPlayer( store = wrapStore(store) } - const player = new WebLivePlayer(store, session, config, agentId, uiErrorHandler) + const player = new WebLivePlayer(store, session, config, agentId, projectId, uiErrorHandler) return [player, store] } diff --git a/frontend/app/player/web/WebLivePlayer.ts b/frontend/app/player/web/WebLivePlayer.ts index 5b6dca2d4..e7ec09d2c 100644 --- a/frontend/app/player/web/WebLivePlayer.ts +++ b/frontend/app/player/web/WebLivePlayer.ts @@ -24,6 +24,7 @@ export default class WebLivePlayer extends WebPlayer { private session: SessionFilesInfo, config: RTCIceServer[] | null, agentId: number, + projectId: number, uiErrorHandler?: { error: (msg: string) => void }, ) { super(wpState, session, true, false, uiErrorHandler) @@ -43,7 +44,7 @@ export default class WebLivePlayer extends WebPlayer { wpState, uiErrorHandler, ) - this.assistManager.connect(session.agentToken!, agentId) + this.assistManager.connect(session.agentToken!, agentId, projectId) } toggleTimetravel = async () => { diff --git a/frontend/app/player/web/assist/AssistManager.ts b/frontend/app/player/web/assist/AssistManager.ts index 0dcb883b2..b484999d2 100644 --- a/frontend/app/player/web/assist/AssistManager.ts +++ b/frontend/app/player/web/assist/AssistManager.ts @@ -143,7 +143,7 @@ export default class AssistManager { this.inactiveTimeout && clearTimeout(this.inactiveTimeout) this.inactiveTimeout = undefined } - connect(agentToken: string, agentId: number) { + connect(agentToken: string, agentId: number, projectId: number) { const jmr = new JSONRawMessageReader() const reader = new MStreamReader(jmr, this.session.startedAt) let waitingForMessages = true @@ -165,6 +165,7 @@ export default class AssistManager { }, query: { peerId: this.peerID, + projectId, identity: "agent", agentInfo: JSON.stringify({ ...this.session.agentInfo, diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index 2a2d4962b..0d6efc1d0 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -95,6 +95,7 @@ export const fflagRead = (id = ':fflagId', hash?: string | number): string => ha export const notes = (params?: Record): string => queried('/notes', params); export const bookmarks = (params?: Record): string => queried('/bookmarks', params); export const assist = (params?: Record): string => queried('/assist', params); +export const assistStats = (params?: Record): string => queried('/cobrowse-stats', params); export const recordings = (params?: Record): string => queried('/recordings', params); export const multiviewIndex = (params?: Record): string => queried('/multiview', params); export const multiview = (sessionsQuery = ':sessionsquery', hash?: string | number): string => @@ -144,10 +145,12 @@ const REQUIRED_SITE_ID_ROUTES = [ notes(), bookmarks(), fflags(), + assist(), recordings(), multiview(), multiviewIndex(), + assistStats(), metrics(), metricDetails(''), diff --git a/frontend/app/services/AssistStatsService.ts b/frontend/app/services/AssistStatsService.ts new file mode 100644 index 000000000..69850bf11 --- /dev/null +++ b/frontend/app/services/AssistStatsService.ts @@ -0,0 +1,145 @@ +import APIClient from 'App/api_client'; + +export interface Member { + name: string; + count: number; + assistDuration: number; + callDuration: number; + controlDuration: number; + assistCount: number; +} + +export interface AssistStatsSession { + callDuration: number; + assistDuration: number; + controlDuration: number; + sessionId: string; + teamMembers: { name: string; id: string }[]; + timestamp: number; + recordings: { + recordId: number; + name: string; + duration: number; + }[]; +} + +export type PeriodKeys = + | 'assistTotal' + | 'assistAvg' + | 'callTotal' + | 'callAvg' + | 'controlTotal' + | 'controlAvg'; + +export interface Graphs { + currentPeriod: { + assistTotal: number; + assistAvg: number; + callTotal: number; + callAvg: number; + controlTotal: number; + controlAvg: number; + }; + previousPeriod: { + assistTotal: number; + assistAvg: number; + callTotal: number; + callAvg: number; + controlTotal: number; + controlAvg: number; + }; + list: { + time: number; + assistAvg: number; + callAvg: number; + controlAvg: number; + assistTotal: number; + callTotal: number; + controlTotal: number; + }[]; +} + +export const generateListData = (list: any[], key: PeriodKeys) => { + return list.map((item) => { + return { + timestamp: item.timestamp, + value: item[key], + }; + }); +}; + +export const defaultGraphs = { + currentPeriod: { + assistTotal: 0, + assistAvg: 0, + callTotal: 0, + callAvg: 0, + controlTotal: 0, + controlAvg: 0, + }, + previousPeriod: { + assistTotal: 0, + assistAvg: 0, + callTotal: 0, + callAvg: 0, + controlTotal: 0, + controlAvg: 0, + }, + list: [], +}; + +export interface SessionsResponse { + total: number; + page: number; + list: AssistStatsSession[]; +} + +export default class AssistStatsService { + private client: APIClient; + + constructor(client?: APIClient) { + this.client = client ? client : new APIClient(); + } + + initClient(client?: APIClient) { + this.client = client || new APIClient(); + } + + fetch(path: string, body: Record, method: 'get' | 'post') { + return this.client[method]('/assist-stats/' + path, body).then((r) => r.json()); + } + + getGraphs(range: { start: number; end: number }, userId?: number): Promise { + return this.fetch( + 'avg', + { startTimestamp: range.start, endTimestamp: range.end, userId }, + 'get' + ); + } + + getTopMembers(filters: { + startTimestamp: number; + endTimestamp: number; + sort: string; + order: 'asc' | 'desc'; + userId?: number; + }): Promise<{ list: Member[]; total: number }> { + return this.fetch('top-members', filters, 'get'); + } + + getSessions(filters: { + startTimestamp: number; + endTimestamp: number; + sort: string; + userId?: number; + order: 'asc' | 'desc'; + page: number; + limit: number; + }): Promise { + return this.fetch('sessions', filters, 'post'); + } + + exportCSV(filters: { start: number; end: number; sort: string; order: 'asc' | 'desc' }) { + return this.fetch('export-csv', filters, 'get'); + } +} diff --git a/frontend/app/services/RecordingsService.ts b/frontend/app/services/RecordingsService.ts index c5711e136..4c4b2aab3 100644 --- a/frontend/app/services/RecordingsService.ts +++ b/frontend/app/services/RecordingsService.ts @@ -49,7 +49,7 @@ export default class RecordingsService { method: 'PUT', headers: { 'Content-Type': 'video/webm' }, body: file, - }).then((r) => { + }).then(() => { return true; }); } @@ -66,7 +66,7 @@ export default class RecordingsService { }); } - fetchRecording(id: number): Promise { + fetchRecording(id: number | string): Promise { return this.client.get(`/assist/records/${id}`).then((r) => { return r.json().then((j) => j.data); }); diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts index bc6e4bff6..b1b318ab3 100644 --- a/frontend/app/services/index.ts +++ b/frontend/app/services/index.ts @@ -12,6 +12,7 @@ import AlertsService from './AlertsService' import WebhookService from './WebhookService' import HealthService from "./HealthService"; import FFlagsService from "App/services/FFlagsService"; +import AssistStatsService from './AssistStatsService' export const dashboardService = new DashboardService(); export const metricService = new MetricService(); @@ -30,6 +31,8 @@ export const healthService = new HealthService(); export const fflagsService = new FFlagsService(); +export const assistStatsService = new AssistStatsService(); + export const services = [ dashboardService, metricService, @@ -44,5 +47,6 @@ export const services = [ alertsService, webhookService, healthService, - fflagsService + fflagsService, + assistStatsService ] \ No newline at end of file diff --git a/frontend/app/svg/icons/file-bar-graph.svg b/frontend/app/svg/icons/file-bar-graph.svg new file mode 100644 index 000000000..a92293a36 --- /dev/null +++ b/frontend/app/svg/icons/file-bar-graph.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/cypress/e2e/replayer.cy.ts b/frontend/cypress/e2e/replayer.cy.ts index e3bde6f80..4db2d8e21 100644 --- a/frontend/cypress/e2e/replayer.cy.ts +++ b/frontend/cypress/e2e/replayer.cy.ts @@ -16,6 +16,9 @@ describe( window.localStorage.setItem('notesFeatureViewed', 'true'); }, }); + Cypress.on('uncaught:exception', (err, runnable) => { + return false + }) cy.origin('http://localhost:3000/', { args: { SECOND } }, ({ SECOND }) => { cy.visit('/'); diff --git a/tracker/tracker-assist/tests/RemoteControl.test.ts b/tracker/tracker-assist/tests/RemoteControl.test.ts index f8679576d..95bd5f6c2 100644 --- a/tracker/tracker-assist/tests/RemoteControl.test.ts +++ b/tracker/tracker-assist/tests/RemoteControl.test.ts @@ -6,6 +6,7 @@ describe('RemoteControl', () => { let remoteControl let options let onGrand + let onBusy let onRelease let confirmWindowMountMock let confirmWindowRemoveMock @@ -16,6 +17,7 @@ describe('RemoteControl', () => { } onGrand = jest.fn() onRelease = jest.fn() + onBusy = jest.fn() confirmWindowMountMock = jest.fn(() => Promise.resolve(true)) confirmWindowRemoveMock = jest.fn() @@ -36,7 +38,7 @@ describe('RemoteControl', () => { .spyOn(ConfirmWindow.prototype, 'remove') .mockImplementation(confirmWindowRemoveMock) - remoteControl = new RemoteControl(options, onGrand, onRelease) + remoteControl = new RemoteControl(options, onGrand, onRelease, onBusy) }) afterEach(() => { diff --git a/tracker/tracker/src/main/app/nodes.ts b/tracker/tracker/src/main/app/nodes.ts index e41a84038..0e9eb3ed4 100644 --- a/tracker/tracker/src/main/app/nodes.ts +++ b/tracker/tracker/src/main/app/nodes.ts @@ -90,7 +90,7 @@ export default class Nodes { clear(): void { for (let id = 0; id < this.nodes.length; id++) { const node = this.nodes[id] - if (node === undefined) { + if (!node) { continue } this.unregisterNode(node) diff --git a/tracker/tracker/src/main/app/nodes.unit.test.ts b/tracker/tracker/src/main/app/nodes.unit.test.ts new file mode 100644 index 000000000..8b32f7916 --- /dev/null +++ b/tracker/tracker/src/main/app/nodes.unit.test.ts @@ -0,0 +1,92 @@ +import Nodes from './nodes' +import { describe, beforeEach, expect, it, jest } from '@jest/globals' + +describe('Nodes', () => { + let nodes: Nodes + const nodeId = 'test_id' + const mockCallback = jest.fn() + + beforeEach(() => { + nodes = new Nodes(nodeId) + mockCallback.mockClear() + }) + + it('attachNodeCallback', () => { + nodes.attachNodeCallback(mockCallback) + nodes.callNodeCallbacks(document.createElement('div'), true) + expect(mockCallback).toHaveBeenCalled() + }) + + it('attachNodeListener is listening to events', () => { + const node = document.createElement('div') + const mockListener = jest.fn() + document.body.appendChild(node) + nodes.registerNode(node) + nodes.attachNodeListener(node, 'click', mockListener, false) + node.dispatchEvent(new Event('click')) + expect(mockListener).toHaveBeenCalled() + }) + it('attachNodeListener is calling native method', () => { + const node = document.createElement('div') + const mockListener = jest.fn() + const addEventListenerSpy = jest.spyOn(node, 'addEventListener') + nodes.registerNode(node) + nodes.attachNodeListener(node, 'click', mockListener) + + expect(addEventListenerSpy).toHaveBeenCalledWith('click', mockListener, true) + }) + + it('registerNode', () => { + const node = document.createElement('div') + const [id, isNew] = nodes.registerNode(node) + expect(id).toBeDefined() + expect(isNew).toBe(true) + }) + + it('unregisterNode', () => { + const node = document.createElement('div') + const [id] = nodes.registerNode(node) + const unregisteredId = nodes.unregisterNode(node) + expect(unregisteredId).toBe(id) + }) + + it('cleanTree', () => { + const node = document.createElement('div') + nodes.registerNode(node) + nodes.cleanTree() + expect(nodes.getNodeCount()).toBe(0) + }) + + it('callNodeCallbacks', () => { + nodes.attachNodeCallback(mockCallback) + const node = document.createElement('div') + nodes.callNodeCallbacks(node, true) + expect(mockCallback).toHaveBeenCalledWith(node, true) + }) + + it('getID', () => { + const node = document.createElement('div') + const [id] = nodes.registerNode(node) + const fetchedId = nodes.getID(node) + expect(fetchedId).toBe(id) + }) + + it('getNode', () => { + const node = document.createElement('div') + const [id] = nodes.registerNode(node) + const fetchedNode = nodes.getNode(id) + expect(fetchedNode).toBe(node) + }) + + it('getNodeCount', () => { + expect(nodes.getNodeCount()).toBe(0) + nodes.registerNode(document.createElement('div')) + expect(nodes.getNodeCount()).toBe(1) + }) + + it('clear', () => { + nodes.registerNode(document.createElement('div')) + nodes.clear() + expect(nodes.getNodeCount()).toBe(0) + }) +}) diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index 550ded483..38f8765c1 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -168,7 +168,7 @@ export default function (app: App, options?: MouseHandlerOptions): void { mouseTarget = null selectorMap = {} if (checkIntervalId) { - clearInterval(checkIntervalId) + clearInterval(checkIntervalId as unknown as number) } }) diff --git a/tracker/tracker/src/tests/featureFlags.unit.test.ts b/tracker/tracker/src/tests/featureFlags.unit.test.ts index 95d204f73..49ea25d73 100644 --- a/tracker/tracker/src/tests/featureFlags.unit.test.ts +++ b/tracker/tracker/src/tests/featureFlags.unit.test.ts @@ -72,7 +72,6 @@ describe('FeatureFlags', () => { userID: sessionInfo.userID, metadata: sessionInfo.metadata, referrer: '', - featureFlags: featureFlags.flags, os: 'test', device: 'test', country: 'test',