replayTable.tsx 10 KB

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