replayTable.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 | undefined;
  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: Props['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?.field.endsWith(fieldName)
  75. ? sort?.kind === 'desc'
  76. ? fieldName
  77. : '-' + fieldName
  78. : '-' + fieldName,
  79. },
  80. }}
  81. >
  82. {label} {sort?.field === fieldName && sortArrow}
  83. </SortLink>
  84. );
  85. }
  86. function ReplayTable({
  87. isFetching,
  88. replays,
  89. showProjectColumn,
  90. sort,
  91. fetchError,
  92. showSlowestTxColumn = false,
  93. }: Props) {
  94. const routes = useRoutes();
  95. const referrer = getRouteStringFromRoutes(routes);
  96. const organization = useOrganization();
  97. const theme = useTheme();
  98. const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
  99. const tableHeaders = [
  100. t('Session'),
  101. showProjectColumn && minWidthIsSmall && (
  102. <SortableHeader
  103. key="projectId"
  104. sort={sort}
  105. fieldName="projectId"
  106. label={t('Project')}
  107. />
  108. ),
  109. showSlowestTxColumn && minWidthIsSmall && (
  110. <Header key="slowestTransaction">
  111. {t('Slowest Transaction')}
  112. <QuestionTooltip
  113. size="xs"
  114. position="top"
  115. title={t(
  116. 'Slowest single instance of this transaction captured by this session.'
  117. )}
  118. />
  119. </Header>
  120. ),
  121. minWidthIsSmall && (
  122. <SortableHeader
  123. key="startedAt"
  124. sort={sort}
  125. fieldName="startedAt"
  126. label={t('Start Time')}
  127. />
  128. ),
  129. <SortableHeader
  130. key="duration"
  131. sort={sort}
  132. fieldName="duration"
  133. label={t('Duration')}
  134. />,
  135. <SortableHeader
  136. key="countErrors"
  137. sort={sort}
  138. fieldName="countErrors"
  139. label={t('Errors')}
  140. />,
  141. <Header key="activity">
  142. {t('Activity')}{' '}
  143. <QuestionTooltip
  144. size="xs"
  145. position="top"
  146. title={t(
  147. 'Activity represents how much user activity happened in a replay. It is determined by the number of errors encountered, duration, and UI events.'
  148. )}
  149. />
  150. </Header>,
  151. ].filter(Boolean);
  152. if (fetchError && !isFetching) {
  153. return (
  154. <StyledPanelTable
  155. headers={tableHeaders}
  156. showProjectColumn={showProjectColumn}
  157. isLoading={false}
  158. showSlowestTxColumn={showSlowestTxColumn}
  159. >
  160. <StyledAlert type="error" showIcon>
  161. {typeof fetchError === 'string'
  162. ? fetchError
  163. : t(
  164. 'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
  165. )}
  166. </StyledAlert>
  167. </StyledPanelTable>
  168. );
  169. }
  170. return (
  171. <StyledPanelTable
  172. isLoading={isFetching}
  173. isEmpty={replays?.length === 0}
  174. showProjectColumn={showProjectColumn}
  175. showSlowestTxColumn={showSlowestTxColumn}
  176. headers={tableHeaders}
  177. >
  178. {replays?.map(replay => (
  179. <ReplayTableRow
  180. key={replay.id}
  181. minWidthIsSmall={minWidthIsSmall}
  182. organization={organization}
  183. referrer={referrer}
  184. replay={replay}
  185. showProjectColumn={showProjectColumn}
  186. showSlowestTxColumn={showSlowestTxColumn}
  187. />
  188. ))}
  189. </StyledPanelTable>
  190. );
  191. }
  192. function ReplayTableRow({
  193. minWidthIsSmall,
  194. organization,
  195. referrer,
  196. replay,
  197. showProjectColumn,
  198. showSlowestTxColumn,
  199. }: RowProps) {
  200. const location = useLocation();
  201. const {projects} = useProjects();
  202. const project = projects.find(p => p.id === replay.projectId);
  203. const hasTxEvent = 'txEvent' in replay;
  204. const txDuration = hasTxEvent ? replay.txEvent?.['transaction.duration'] : undefined;
  205. return (
  206. <Fragment>
  207. <UserBadge
  208. avatarSize={32}
  209. displayName={
  210. <Link
  211. to={{
  212. pathname: `/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/`,
  213. query: {
  214. referrer,
  215. },
  216. }}
  217. >
  218. {replay.user.displayName || ''}
  219. </Link>
  220. }
  221. user={{
  222. username: replay.user.displayName || '',
  223. email: replay.user.email || '',
  224. id: replay.user.id || '',
  225. ip_address: replay.user.ip_address || '',
  226. name: replay.user.name || '',
  227. }}
  228. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  229. displayEmail={<StringWalker urls={replay.urls} />}
  230. />
  231. {showProjectColumn && minWidthIsSmall && (
  232. <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
  233. )}
  234. {minWidthIsSmall && showSlowestTxColumn && (
  235. <Item>
  236. {hasTxEvent ? (
  237. <SpanOperationBreakdown>
  238. {txDuration ? <TxDuration>{txDuration}ms</TxDuration> : null}
  239. {spanOperationRelativeBreakdownRenderer(
  240. replay.txEvent,
  241. {
  242. organization,
  243. location,
  244. },
  245. {
  246. enableOnClick: false,
  247. }
  248. )}
  249. </SpanOperationBreakdown>
  250. ) : null}
  251. </Item>
  252. )}
  253. {minWidthIsSmall && (
  254. <Item>
  255. <TimeSinceWrapper>
  256. {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
  257. <TimeSince date={replay.startedAt} />
  258. </TimeSinceWrapper>
  259. </Item>
  260. )}
  261. <Item>
  262. <Duration seconds={replay.duration.asSeconds()} exact abbreviation />
  263. </Item>
  264. <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
  265. <Item>
  266. <ReplayHighlight replay={replay} />
  267. </Item>
  268. </Fragment>
  269. );
  270. }
  271. function getColCount(props: TableProps) {
  272. let colCount = 4;
  273. if (props.showSlowestTxColumn) {
  274. colCount += 1;
  275. }
  276. if (props.showProjectColumn) {
  277. colCount += 1;
  278. }
  279. return colCount;
  280. }
  281. const StyledPanelTable = styled(PanelTable)<TableProps>`
  282. ${p => `grid-template-columns: minmax(0, 1fr) repeat(${getColCount(p)}, max-content);`}
  283. @media (max-width: ${p => p.theme.breakpoints.small}) {
  284. grid-template-columns: minmax(0, 1fr) repeat(3, min-content);
  285. }
  286. `;
  287. const SortLink = styled(Link)`
  288. color: inherit;
  289. :hover {
  290. color: inherit;
  291. }
  292. svg {
  293. vertical-align: top;
  294. }
  295. `;
  296. const Item = styled('div')`
  297. display: flex;
  298. align-items: center;
  299. `;
  300. const SpanOperationBreakdown = styled('div')`
  301. width: 100%;
  302. text-align: right;
  303. `;
  304. const TxDuration = styled('div')`
  305. color: ${p => p.theme.gray500};
  306. font-size: ${p => p.theme.fontSizeMedium};
  307. margin-bottom: ${space(0.5)};
  308. `;
  309. const TimeSinceWrapper = styled('div')`
  310. display: grid;
  311. grid-template-columns: repeat(2, minmax(auto, max-content));
  312. align-items: center;
  313. gap: ${space(1)};
  314. `;
  315. const StyledIconCalendarWrapper = styled(IconCalendar)`
  316. position: relative;
  317. top: -1px;
  318. `;
  319. const StyledAlert = styled(Alert)`
  320. border-radius: 0;
  321. border-width: 1px 0 0 0;
  322. grid-column: 1/-1;
  323. margin-bottom: 0;
  324. `;
  325. const Header = styled('div')`
  326. display: grid;
  327. grid-template-columns: repeat(2, max-content);
  328. gap: ${space(0.5)};
  329. align-items: center;
  330. `;
  331. export default ReplayTable;