index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import DateTime from 'sentry/components/dateTime';
  4. import FileSize from 'sentry/components/fileSize';
  5. import CompactSelect from 'sentry/components/forms/compactSelect';
  6. import {PanelTable, PanelTableHeader} from 'sentry/components/panels';
  7. import {useReplayContext} from 'sentry/components/replays/replayContext';
  8. import {relativeTimeInMs, showPlayerTime} from 'sentry/components/replays/utils';
  9. import SearchBar from 'sentry/components/searchBar';
  10. import Tooltip from 'sentry/components/tooltip';
  11. import {IconArrow} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {defined} from 'sentry/utils';
  15. import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
  16. import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
  17. import useNetworkFilters from 'sentry/views/replays/detail/network/useNetworkFilters';
  18. import {
  19. getResourceTypes,
  20. getStatusTypes,
  21. ISortConfig,
  22. sortNetwork,
  23. } from 'sentry/views/replays/detail/network/utils';
  24. import type {NetworkSpan, ReplayRecord} from 'sentry/views/replays/types';
  25. type Props = {
  26. networkSpans: NetworkSpan[];
  27. replayRecord: ReplayRecord;
  28. };
  29. type SortDirection = 'asc' | 'desc';
  30. function NetworkList({replayRecord, networkSpans}: Props) {
  31. const startTimestampMs = replayRecord.startedAt.getTime();
  32. const {setCurrentHoverTime, setCurrentTime, currentTime} = useReplayContext();
  33. const [sortConfig, setSortConfig] = useState<ISortConfig>({
  34. by: 'startTimestamp',
  35. asc: true,
  36. getValue: row => row[sortConfig.by],
  37. });
  38. const {
  39. items,
  40. status: selectedStatus,
  41. type: selectedType,
  42. searchTerm,
  43. setStatus,
  44. setType,
  45. setSearchTerm,
  46. } = useNetworkFilters({networkSpans});
  47. const networkData = useMemo(() => sortNetwork(items, sortConfig), [items, sortConfig]);
  48. const currentNetworkSpan = getPrevReplayEvent({
  49. items: networkData,
  50. targetTimestampMs: startTimestampMs + currentTime,
  51. allowEqual: true,
  52. allowExact: true,
  53. });
  54. const handleMouseEnter = useCallback(
  55. (timestamp: number) => {
  56. if (startTimestampMs) {
  57. setCurrentHoverTime(relativeTimeInMs(timestamp, startTimestampMs));
  58. }
  59. },
  60. [setCurrentHoverTime, startTimestampMs]
  61. );
  62. const handleMouseLeave = useCallback(() => {
  63. setCurrentHoverTime(undefined);
  64. }, [setCurrentHoverTime]);
  65. const handleClick = useCallback(
  66. (timestamp: number) => {
  67. setCurrentTime(relativeTimeInMs(timestamp, startTimestampMs));
  68. },
  69. [setCurrentTime, startTimestampMs]
  70. );
  71. const getColumnHandlers = useCallback(
  72. (startTime: number) => ({
  73. onMouseEnter: () => handleMouseEnter(startTime),
  74. onMouseLeave: handleMouseLeave,
  75. }),
  76. [handleMouseEnter, handleMouseLeave]
  77. );
  78. function handleSort(fieldName: keyof NetworkSpan): void;
  79. function handleSort(key: string, getValue: (row: NetworkSpan) => any): void;
  80. function handleSort(
  81. fieldName: string | keyof NetworkSpan,
  82. getValue?: (row: NetworkSpan) => any
  83. ) {
  84. const getValueFunction = getValue ? getValue : (row: NetworkSpan) => row[fieldName];
  85. setSortConfig(prevSort => {
  86. if (prevSort.by === fieldName) {
  87. return {by: fieldName, asc: !prevSort.asc, getValue: getValueFunction};
  88. }
  89. return {by: fieldName, asc: true, getValue: getValueFunction};
  90. });
  91. }
  92. const sortArrow = (sortedBy: string) => {
  93. return sortConfig.by === sortedBy ? (
  94. <IconArrow
  95. color="gray300"
  96. size="xs"
  97. direction={sortConfig.by === sortedBy && !sortConfig.asc ? 'down' : 'up'}
  98. />
  99. ) : null;
  100. };
  101. const columns = [
  102. <SortItem key="status">
  103. <UnstyledHeaderButton
  104. onClick={() => handleSort('status', row => row.data.statusCode)}
  105. >
  106. {t('Status')} {sortArrow('status')}
  107. </UnstyledHeaderButton>
  108. </SortItem>,
  109. <SortItem key="path">
  110. <UnstyledHeaderButton onClick={() => handleSort('description')}>
  111. {t('Path')} {sortArrow('description')}
  112. </UnstyledHeaderButton>
  113. </SortItem>,
  114. <SortItem key="type">
  115. <UnstyledHeaderButton onClick={() => handleSort('op')}>
  116. {t('Type')} {sortArrow('op')}
  117. </UnstyledHeaderButton>
  118. </SortItem>,
  119. <SortItem key="size">
  120. <UnstyledHeaderButton onClick={() => handleSort('size', row => row.data.size)}>
  121. {t('Size')} {sortArrow('size')}
  122. </UnstyledHeaderButton>
  123. </SortItem>,
  124. <SortItem key="duration">
  125. <UnstyledHeaderButton
  126. onClick={() =>
  127. handleSort('duration', row => {
  128. return row.endTimestamp - row.startTimestamp;
  129. })
  130. }
  131. >
  132. {t('Duration')} {sortArrow('duration')}
  133. </UnstyledHeaderButton>
  134. </SortItem>,
  135. <SortItem key="timestamp">
  136. <UnstyledHeaderButton onClick={() => handleSort('startTimestamp')}>
  137. {t('Timestamp')} {sortArrow('startTimestamp')}
  138. </UnstyledHeaderButton>
  139. </SortItem>,
  140. ];
  141. const renderTableRow = (network: NetworkSpan) => {
  142. const networkStartTimestamp = network.startTimestamp * 1000;
  143. const networkEndTimestamp = network.endTimestamp * 1000;
  144. const statusCode = network.data.statusCode;
  145. const columnHandlers = getColumnHandlers(networkStartTimestamp);
  146. const columnProps = {
  147. isStatusError: typeof statusCode === 'number' && statusCode >= 400,
  148. isCurrent: currentNetworkSpan?.id === network.id,
  149. hasOccurred:
  150. currentTime >= relativeTimeInMs(networkStartTimestamp, startTimestampMs),
  151. timestampSortDir:
  152. sortConfig.by === 'startTimestamp'
  153. ? ((sortConfig.asc ? 'asc' : 'desc') as SortDirection)
  154. : undefined,
  155. };
  156. return (
  157. <Fragment key={network.id}>
  158. <Item {...columnHandlers} {...columnProps} isStatusCode>
  159. {statusCode ? statusCode : <EmptyText>---</EmptyText>}
  160. </Item>
  161. <Item {...columnHandlers} {...columnProps}>
  162. {network.description ? (
  163. <Tooltip
  164. title={network.description}
  165. isHoverable
  166. overlayStyle={{
  167. maxWidth: '500px !important',
  168. }}
  169. showOnlyOnOverflow
  170. >
  171. <Text>{network.description}</Text>
  172. </Tooltip>
  173. ) : (
  174. <EmptyText>({t('Missing')})</EmptyText>
  175. )}
  176. </Item>
  177. <Item {...columnHandlers} {...columnProps}>
  178. <Text>{network.op.replace('resource.', '')}</Text>
  179. </Item>
  180. <Item {...columnHandlers} {...columnProps} numeric>
  181. {defined(network.data.size) ? (
  182. <FileSize bytes={network.data.size} />
  183. ) : (
  184. <EmptyText>({t('Missing')})</EmptyText>
  185. )}
  186. </Item>
  187. <Item {...columnHandlers} {...columnProps} numeric>
  188. {`${(networkEndTimestamp - networkStartTimestamp).toFixed(2)}ms`}
  189. </Item>
  190. <Item {...columnHandlers} {...columnProps} numeric>
  191. <Tooltip title={<DateTime date={networkStartTimestamp} seconds />}>
  192. <UnstyledButton onClick={() => handleClick(networkStartTimestamp)}>
  193. {showPlayerTime(networkStartTimestamp, startTimestampMs, true)}
  194. </UnstyledButton>
  195. </Tooltip>
  196. </Item>
  197. </Fragment>
  198. );
  199. };
  200. return (
  201. <NetworkContainer>
  202. <NetworkFilters>
  203. <CompactSelect
  204. triggerProps={{prefix: t('Status')}}
  205. triggerLabel={selectedStatus.length === 0 ? t('Any') : null}
  206. multiple
  207. options={getStatusTypes(networkSpans).map(value => ({value, label: value}))}
  208. size="sm"
  209. onChange={selected => setStatus(selected.map(_ => _.value))}
  210. value={selectedStatus}
  211. />
  212. <CompactSelect
  213. triggerProps={{prefix: t('Type')}}
  214. triggerLabel={selectedType.length === 0 ? t('Any') : null}
  215. multiple
  216. options={getResourceTypes(networkSpans).map(value => ({value, label: value}))}
  217. size="sm"
  218. onChange={selected => setType(selected.map(_ => _.value))}
  219. value={selectedType}
  220. />
  221. <SearchBar
  222. size="sm"
  223. onChange={setSearchTerm}
  224. placeholder={t('Search Network...')}
  225. query={searchTerm}
  226. />
  227. </NetworkFilters>
  228. <StyledPanelTable
  229. columns={columns.length}
  230. isEmpty={networkData.length === 0}
  231. emptyMessage={t('No related network requests found.')}
  232. headers={columns}
  233. disablePadding
  234. stickyHeaders
  235. >
  236. {networkData.map(renderTableRow)}
  237. </StyledPanelTable>
  238. </NetworkContainer>
  239. );
  240. }
  241. const NetworkContainer = styled(FluidHeight)`
  242. height: 100%;
  243. `;
  244. const NetworkFilters = styled('div')`
  245. display: grid;
  246. gap: ${space(1)};
  247. grid-template-columns: max-content max-content 1fr;
  248. margin-bottom: ${space(1)};
  249. @media (max-width: ${p => p.theme.breakpoints.small}) {
  250. margin-top: ${space(1)};
  251. }
  252. `;
  253. const Text = styled('p')`
  254. padding: 0;
  255. margin: 0;
  256. text-overflow: ellipsis;
  257. white-space: nowrap;
  258. overflow: hidden;
  259. `;
  260. const EmptyText = styled(Text)`
  261. font-style: italic;
  262. color: ${p => p.theme.subText};
  263. `;
  264. const fontColor = p => {
  265. if (p.isStatusError) {
  266. return p.hasOccurred || !p.timestampSortDir ? p.theme.red400 : p.theme.red200;
  267. }
  268. return p.hasOccurred || !p.timestampSortDir ? p.theme.gray400 : p.theme.gray300;
  269. };
  270. const Item = styled('div')<{
  271. hasOccurred: boolean;
  272. isCurrent: boolean;
  273. isStatusError: boolean;
  274. timestampSortDir: SortDirection | undefined;
  275. center?: boolean;
  276. isStatusCode?: boolean;
  277. numeric?: boolean;
  278. }>`
  279. display: flex;
  280. align-items: center;
  281. ${p => p.center && 'justify-content: center;'}
  282. max-height: 28px;
  283. color: ${fontColor};
  284. padding: ${space(0.75)} ${space(1.5)};
  285. background-color: ${p => p.theme.background};
  286. border-bottom: ${p => {
  287. if (p.isCurrent && p.timestampSortDir === 'asc') {
  288. return `1px solid ${p.theme.purple300} !important`;
  289. }
  290. return p.isStatusError
  291. ? `1px solid ${p.theme.red100}`
  292. : `1px solid ${p.theme.innerBorder}`;
  293. }};
  294. border-top: ${p => {
  295. return p.isCurrent && p.timestampSortDir === 'desc'
  296. ? `1px solid ${p.theme.purple300} !important`
  297. : 0;
  298. }};
  299. ${p => p.numeric && 'font-variant-numeric: tabular-nums;'};
  300. ${EmptyText} {
  301. color: ${fontColor};
  302. }
  303. `;
  304. const UnstyledButton = styled('button')`
  305. border: 0;
  306. background: none;
  307. padding: 0;
  308. text-transform: inherit;
  309. width: 100%;
  310. text-align: unset;
  311. `;
  312. const UnstyledHeaderButton = styled(UnstyledButton)`
  313. display: flex;
  314. justify-content: space-between;
  315. align-items: center;
  316. `;
  317. const StyledPanelTable = styled(PanelTable)<{columns: number}>`
  318. grid-template-columns: max-content minmax(200px, 1fr) repeat(4, max-content);
  319. grid-template-rows: 24px repeat(auto-fit, 28px);
  320. font-size: ${p => p.theme.fontSizeSmall};
  321. margin-bottom: 0;
  322. height: 100%;
  323. overflow: auto;
  324. > * {
  325. border-right: 1px solid ${p => p.theme.innerBorder};
  326. border-bottom: 1px solid ${p => p.theme.innerBorder};
  327. /* Last column */
  328. &:nth-child(${p => p.columns}n) {
  329. border-right: 0;
  330. text-align: right;
  331. justify-content: end;
  332. }
  333. /* 3rd and 2nd last column */
  334. &:nth-child(${p => p.columns}n - 1),
  335. &:nth-child(${p => p.columns}n - 2) {
  336. text-align: right;
  337. justify-content: end;
  338. }
  339. }
  340. ${PanelTableHeader} {
  341. min-height: 24px;
  342. border-radius: 0;
  343. color: ${p => p.theme.subText};
  344. line-height: 16px;
  345. text-transform: none;
  346. /* Last, 2nd and 3rd last header columns. As these are flex direction columns we have to treat them separately */
  347. &:nth-child(${p => p.columns}n),
  348. &:nth-child(${p => p.columns}n - 1),
  349. &:nth-child(${p => p.columns}n - 2) {
  350. justify-content: center;
  351. align-items: flex-start;
  352. text-align: start;
  353. }
  354. }
  355. `;
  356. const SortItem = styled('span')`
  357. padding: ${space(0.5)} ${space(1.5)};
  358. width: 100%;
  359. svg {
  360. margin-left: ${space(0.25)};
  361. }
  362. `;
  363. export default NetworkList;