replayTable.tsx 10 KB

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