replayTable.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import {Fragment} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import Alert from 'sentry/components/alert';
  5. import Duration from 'sentry/components/duration';
  6. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  7. import UserBadge from 'sentry/components/idBadge/userBadge';
  8. import Link from 'sentry/components/links/link';
  9. import {PanelTable} from 'sentry/components/panels';
  10. import QuestionTooltip from 'sentry/components/questionTooltip';
  11. import ReplayHighlight from 'sentry/components/replays/replayHighlight';
  12. import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {IconArrow, IconCalendar} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import type {Organization} from 'sentry/types';
  18. import {spanOperationRelativeBreakdownRenderer} from 'sentry/utils/discover/fieldRenderers';
  19. import type {Sort} from 'sentry/utils/discover/fields';
  20. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  21. import {useLocation} from 'sentry/utils/useLocation';
  22. import useMedia from 'sentry/utils/useMedia';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import useProjects from 'sentry/utils/useProjects';
  25. import {useRoutes} from 'sentry/utils/useRoutes';
  26. import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysFromTransaction';
  27. import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
  28. type Props = {
  29. isFetching: boolean;
  30. replays: undefined | ReplayListRecord[] | ReplayListRecordWithTx[];
  31. showProjectColumn: boolean;
  32. sort: Sort;
  33. fetchError?: Error;
  34. showSlowestTxColumn?: boolean;
  35. };
  36. type TableProps = {
  37. showProjectColumn: boolean;
  38. showSlowestTxColumn: boolean;
  39. };
  40. type RowProps = {
  41. minWidthIsSmall: boolean;
  42. organization: Organization;
  43. referrer: string;
  44. replay: ReplayListRecord | ReplayListRecordWithTx;
  45. showProjectColumn: boolean;
  46. showSlowestTxColumn: boolean;
  47. };
  48. function SortableHeader({
  49. fieldName,
  50. label,
  51. sort,
  52. }: {
  53. fieldName: string;
  54. label: string;
  55. sort: Sort;
  56. }) {
  57. const location = useLocation<ReplayListLocationQuery>();
  58. const arrowDirection = sort.kind === 'asc' ? 'up' : 'down';
  59. const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
  60. return (
  61. <SortLink
  62. role="columnheader"
  63. aria-sort={
  64. sort.field.endsWith(fieldName)
  65. ? sort.kind === 'asc'
  66. ? 'ascending'
  67. : 'descending'
  68. : 'none'
  69. }
  70. to={{
  71. pathname: location.pathname,
  72. query: {
  73. ...location.query,
  74. sort: sort.kind === 'desc' ? fieldName : '-' + fieldName,
  75. },
  76. }}
  77. >
  78. {label} {sort.field === fieldName && sortArrow}
  79. </SortLink>
  80. );
  81. }
  82. function ReplayTable({
  83. isFetching,
  84. replays,
  85. showProjectColumn,
  86. sort,
  87. fetchError,
  88. showSlowestTxColumn = false,
  89. }: Props) {
  90. const routes = useRoutes();
  91. const referrer = encodeURIComponent(getRouteStringFromRoutes(routes));
  92. const organization = useOrganization();
  93. const theme = useTheme();
  94. const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
  95. const tableHeaders = [
  96. t('Session'),
  97. showProjectColumn && minWidthIsSmall && (
  98. <SortableHeader
  99. key="projectId"
  100. sort={sort}
  101. fieldName="projectId"
  102. label={t('Project')}
  103. />
  104. ),
  105. showSlowestTxColumn && minWidthIsSmall && (
  106. <Header key="slowestTransaction">
  107. {t('Slowest Transaction')}
  108. <QuestionTooltip
  109. size="xs"
  110. position="top"
  111. title={t(
  112. 'The duration of the slowest transaction operation that was recorded during the replay session.'
  113. )}
  114. />
  115. </Header>
  116. ),
  117. minWidthIsSmall && (
  118. <SortableHeader
  119. key="startedAt"
  120. sort={sort}
  121. fieldName="startedAt"
  122. label={t('Start Time')}
  123. />
  124. ),
  125. <SortableHeader
  126. key="duration"
  127. sort={sort}
  128. fieldName="duration"
  129. label={t('Duration')}
  130. />,
  131. <SortableHeader
  132. key="countErrors"
  133. sort={sort}
  134. fieldName="countErrors"
  135. label={t('Errors')}
  136. />,
  137. <Header key="activity">
  138. {t('Activity')}{' '}
  139. <QuestionTooltip
  140. size="xs"
  141. position="top"
  142. title={t(
  143. 'Activity represents how much user activity happened in a replay. It is determined by the number of errors encountered, duration, and UI events.'
  144. )}
  145. />
  146. </Header>,
  147. ].filter(Boolean);
  148. if (fetchError && !isFetching) {
  149. return (
  150. <StyledPanelTable
  151. headers={tableHeaders}
  152. showProjectColumn={showProjectColumn}
  153. isLoading={false}
  154. showSlowestTxColumn={showSlowestTxColumn}
  155. >
  156. <StyledAlert type="error" showIcon>
  157. {typeof fetchError === 'string'
  158. ? fetchError
  159. : t(
  160. 'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
  161. )}
  162. </StyledAlert>
  163. </StyledPanelTable>
  164. );
  165. }
  166. return (
  167. <StyledPanelTable
  168. isLoading={isFetching}
  169. isEmpty={replays?.length === 0}
  170. showProjectColumn={showProjectColumn}
  171. showSlowestTxColumn={showSlowestTxColumn}
  172. headers={tableHeaders}
  173. >
  174. {replays?.map(replay => (
  175. <ReplayTableRow
  176. key={replay.id}
  177. minWidthIsSmall={minWidthIsSmall}
  178. organization={organization}
  179. referrer={referrer}
  180. replay={replay}
  181. showProjectColumn={showProjectColumn}
  182. showSlowestTxColumn={showSlowestTxColumn}
  183. />
  184. ))}
  185. </StyledPanelTable>
  186. );
  187. }
  188. function ReplayTableRow({
  189. minWidthIsSmall,
  190. organization,
  191. referrer,
  192. replay,
  193. showProjectColumn,
  194. showSlowestTxColumn,
  195. }: RowProps) {
  196. const location = useLocation();
  197. const {projects} = useProjects();
  198. const project = projects.find(p => p.id === replay.projectId);
  199. const hasTxEvent = 'txEvent' in replay;
  200. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  201. return (
  202. <Fragment>
  203. <UserBadge
  204. avatarSize={32}
  205. displayName={
  206. <Link
  207. to={`/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/?referrer=${referrer}`}
  208. >
  209. {replay.user.displayName || ''}
  210. </Link>
  211. }
  212. user={{
  213. username: replay.user.displayName || '',
  214. email: replay.user.email || '',
  215. id: replay.user.id || '',
  216. ip_address: replay.user.ip_address || '',
  217. name: replay.user.name || '',
  218. }}
  219. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  220. displayEmail={<StringWalker urls={replay.urls} />}
  221. />
  222. {showProjectColumn && minWidthIsSmall && (
  223. <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
  224. )}
  225. {minWidthIsSmall && showSlowestTxColumn && (
  226. <Item>
  227. {hasTxEvent ? (
  228. <SpanOperationBreakdown>
  229. {txDuration ? <TxDuration>{txDuration}ms</TxDuration> : null}
  230. {spanOperationRelativeBreakdownRenderer(replay.txEvent, {
  231. organization,
  232. location,
  233. })}
  234. </SpanOperationBreakdown>
  235. ) : null}
  236. </Item>
  237. )}
  238. {minWidthIsSmall && (
  239. <Item>
  240. <TimeSinceWrapper>
  241. {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
  242. <TimeSince date={replay.startedAt} />
  243. </TimeSinceWrapper>
  244. </Item>
  245. )}
  246. <Item>
  247. <Duration seconds={replay.duration.asSeconds()} exact abbreviation />
  248. </Item>
  249. <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
  250. <Item>
  251. <ReplayHighlight replay={replay} />
  252. </Item>
  253. </Fragment>
  254. );
  255. }
  256. function getColCount(props: TableProps) {
  257. let colCount = 4;
  258. if (props.showSlowestTxColumn) {
  259. colCount += 1;
  260. }
  261. if (props.showProjectColumn) {
  262. colCount += 1;
  263. }
  264. return colCount;
  265. }
  266. const StyledPanelTable = styled(PanelTable)<TableProps>`
  267. ${p => `grid-template-columns: minmax(0, 1fr) repeat(${getColCount(p)}, max-content);`}
  268. @media (max-width: ${p => p.theme.breakpoints.small}) {
  269. grid-template-columns: minmax(0, 1fr) repeat(3, min-content);
  270. }
  271. `;
  272. const SortLink = styled(Link)`
  273. color: inherit;
  274. :hover {
  275. color: inherit;
  276. }
  277. svg {
  278. vertical-align: top;
  279. }
  280. `;
  281. const Item = styled('div')`
  282. display: flex;
  283. align-items: center;
  284. `;
  285. const SpanOperationBreakdown = styled('div')`
  286. width: 100%;
  287. text-align: right;
  288. `;
  289. const TxDuration = styled('div')`
  290. color: ${p => p.theme.gray500};
  291. font-size: ${p => p.theme.fontSizeMedium};
  292. margin-bottom: ${space(0.5)};
  293. `;
  294. const TimeSinceWrapper = styled('div')`
  295. display: grid;
  296. grid-template-columns: repeat(2, minmax(auto, max-content));
  297. align-items: center;
  298. gap: ${space(1)};
  299. `;
  300. const StyledIconCalendarWrapper = styled(IconCalendar)`
  301. position: relative;
  302. top: -1px;
  303. `;
  304. const StyledAlert = styled(Alert)`
  305. border-radius: 0;
  306. border-width: 1px 0 0 0;
  307. grid-column: 1/-1;
  308. margin-bottom: 0;
  309. `;
  310. const Header = styled('div')`
  311. display: grid;
  312. grid-template-columns: repeat(2, max-content);
  313. gap: ${space(0.5)};
  314. align-items: center;
  315. `;
  316. export default ReplayTable;