index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import {
  3. AutoSizer,
  4. CellMeasurer,
  5. CellMeasurerCache,
  6. GridCellProps,
  7. MultiGrid,
  8. } from 'react-virtualized';
  9. import styled from '@emotion/styled';
  10. import CompactSelect from 'sentry/components/compactSelect';
  11. import DateTime from 'sentry/components/dateTime';
  12. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  13. import FileSize from 'sentry/components/fileSize';
  14. import {useReplayContext} from 'sentry/components/replays/replayContext';
  15. import {relativeTimeInMs, showPlayerTime} from 'sentry/components/replays/utils';
  16. import SearchBar from 'sentry/components/searchBar';
  17. import Tooltip from 'sentry/components/tooltip';
  18. import {IconArrow} from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import space from 'sentry/styles/space';
  21. import {defined} from 'sentry/utils';
  22. import {getPrevReplayEvent} from 'sentry/utils/replays/getReplayEvent';
  23. import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
  24. import useNetworkFilters from 'sentry/views/replays/detail/network/useNetworkFilters';
  25. import {
  26. getResourceTypes,
  27. getStatusTypes,
  28. ISortConfig,
  29. sortNetwork,
  30. } from 'sentry/views/replays/detail/network/utils';
  31. import type {NetworkSpan, ReplayRecord} from 'sentry/views/replays/types';
  32. type Props = {
  33. networkSpans: NetworkSpan[];
  34. replayRecord: ReplayRecord;
  35. };
  36. type SortDirection = 'asc' | 'desc';
  37. const cache = new CellMeasurerCache({
  38. defaultWidth: 100,
  39. fixedHeight: true,
  40. });
  41. const headerRowHeight = 24;
  42. function NetworkList({replayRecord, networkSpans}: Props) {
  43. const startTimestampMs = replayRecord.startedAt.getTime();
  44. const {setCurrentHoverTime, setCurrentTime, currentTime} = useReplayContext();
  45. const [sortConfig, setSortConfig] = useState<ISortConfig>({
  46. by: 'startTimestamp',
  47. asc: true,
  48. getValue: row => row[sortConfig.by],
  49. });
  50. const [scrollBarWidth, setScrollBarWidth] = useState(0);
  51. const multiGridRef = useRef<MultiGrid>(null);
  52. const networkTableRef = useRef<HTMLDivElement>(null);
  53. const {
  54. items,
  55. status: selectedStatus,
  56. type: selectedType,
  57. searchTerm,
  58. setStatus,
  59. setType,
  60. setSearchTerm,
  61. } = useNetworkFilters({networkSpans});
  62. const networkData = useMemo(() => sortNetwork(items, sortConfig), [items, sortConfig]);
  63. const currentNetworkSpan = getPrevReplayEvent({
  64. items: networkData,
  65. targetTimestampMs: startTimestampMs + currentTime,
  66. allowEqual: true,
  67. allowExact: true,
  68. });
  69. const handleMouseEnter = useCallback(
  70. (timestamp: number) => {
  71. if (startTimestampMs) {
  72. setCurrentHoverTime(relativeTimeInMs(timestamp, startTimestampMs));
  73. }
  74. },
  75. [setCurrentHoverTime, startTimestampMs]
  76. );
  77. const handleMouseLeave = useCallback(() => {
  78. setCurrentHoverTime(undefined);
  79. }, [setCurrentHoverTime]);
  80. const handleClick = useCallback(
  81. (timestamp: number) => {
  82. setCurrentTime(relativeTimeInMs(timestamp, startTimestampMs));
  83. },
  84. [setCurrentTime, startTimestampMs]
  85. );
  86. const getColumnHandlers = useCallback(
  87. (startTime: number) => ({
  88. onMouseEnter: () => handleMouseEnter(startTime),
  89. onMouseLeave: handleMouseLeave,
  90. }),
  91. [handleMouseEnter, handleMouseLeave]
  92. );
  93. useEffect(() => {
  94. let observer: ResizeObserver | null;
  95. if (networkTableRef.current) {
  96. // Observe the network table for width changes
  97. observer = new ResizeObserver(() => {
  98. // Recompute the column widths
  99. multiGridRef.current?.recomputeGridSize({columnIndex: 1});
  100. });
  101. observer.observe(networkTableRef.current);
  102. }
  103. return () => {
  104. observer?.disconnect();
  105. };
  106. }, [networkTableRef, searchTerm]);
  107. function handleSort(fieldName: keyof NetworkSpan): void;
  108. function handleSort(key: string, getValue: (row: NetworkSpan) => any): void;
  109. function handleSort(
  110. fieldName: string | keyof NetworkSpan,
  111. getValue?: (row: NetworkSpan) => any
  112. ) {
  113. const getValueFunction = getValue ? getValue : (row: NetworkSpan) => row[fieldName];
  114. setSortConfig(prevSort => {
  115. if (prevSort.by === fieldName) {
  116. return {by: fieldName, asc: !prevSort.asc, getValue: getValueFunction};
  117. }
  118. return {by: fieldName, asc: true, getValue: getValueFunction};
  119. });
  120. }
  121. const sortArrow = (sortedBy: string) => {
  122. return sortConfig.by === sortedBy ? (
  123. <IconArrow
  124. color="gray300"
  125. size="xs"
  126. direction={sortConfig.by === sortedBy && !sortConfig.asc ? 'down' : 'up'}
  127. />
  128. ) : (
  129. <IconArrow size="xs" style={{visibility: 'hidden'}} />
  130. );
  131. };
  132. const columns = [
  133. <SortItem key="status">
  134. <UnstyledHeaderButton
  135. onClick={() => handleSort('status', row => row.data.statusCode)}
  136. >
  137. {t('Status')} {sortArrow('status')}
  138. </UnstyledHeaderButton>
  139. </SortItem>,
  140. <SortItem key="path">
  141. <UnstyledHeaderButton onClick={() => handleSort('description')}>
  142. {t('Path')} {sortArrow('description')}
  143. </UnstyledHeaderButton>
  144. </SortItem>,
  145. <SortItem key="type">
  146. <UnstyledHeaderButton onClick={() => handleSort('op')}>
  147. {t('Type')} {sortArrow('op')}
  148. </UnstyledHeaderButton>
  149. </SortItem>,
  150. <SortItem key="size">
  151. <UnstyledHeaderButton onClick={() => handleSort('size', row => row.data.size)}>
  152. {t('Size')} {sortArrow('size')}
  153. </UnstyledHeaderButton>
  154. </SortItem>,
  155. <SortItem key="duration">
  156. <UnstyledHeaderButton
  157. onClick={() =>
  158. handleSort('duration', row => {
  159. return row.endTimestamp - row.startTimestamp;
  160. })
  161. }
  162. >
  163. {t('Duration')} {sortArrow('duration')}
  164. </UnstyledHeaderButton>
  165. </SortItem>,
  166. <SortItem key="timestamp">
  167. <UnstyledHeaderButton onClick={() => handleSort('startTimestamp')}>
  168. {t('Timestamp')} {sortArrow('startTimestamp')}
  169. </UnstyledHeaderButton>
  170. </SortItem>,
  171. ];
  172. const getNetworkColumnValue = (network: NetworkSpan, column: number) => {
  173. const networkStartTimestamp = network.startTimestamp * 1000;
  174. const networkEndTimestamp = network.endTimestamp * 1000;
  175. const statusCode = network.data.statusCode;
  176. const columnHandlers = getColumnHandlers(networkStartTimestamp);
  177. const columnProps = {
  178. isStatusError: typeof statusCode === 'number' && statusCode >= 400,
  179. isCurrent: currentNetworkSpan?.id === network.id,
  180. hasOccurred:
  181. currentTime >= relativeTimeInMs(networkStartTimestamp, startTimestampMs),
  182. timestampSortDir:
  183. sortConfig.by === 'startTimestamp'
  184. ? ((sortConfig.asc ? 'asc' : 'desc') as SortDirection)
  185. : undefined,
  186. };
  187. const columnValues = [
  188. <Item key="statusCode" {...columnHandlers} {...columnProps}>
  189. {statusCode ? statusCode : <EmptyText>---</EmptyText>}
  190. </Item>,
  191. <Item key="description" {...columnHandlers} {...columnProps}>
  192. {network.description ? (
  193. <Tooltip
  194. title={network.description}
  195. isHoverable
  196. overlayStyle={{
  197. maxWidth: '500px !important',
  198. }}
  199. showOnlyOnOverflow
  200. >
  201. <Text>{network.description}</Text>
  202. </Tooltip>
  203. ) : (
  204. <EmptyText>({t('No value')})</EmptyText>
  205. )}
  206. </Item>,
  207. <Item key="type" {...columnHandlers} {...columnProps}>
  208. <Tooltip
  209. title={network.op.replace('resource.', '')}
  210. isHoverable
  211. overlayStyle={{
  212. maxWidth: '500px !important',
  213. }}
  214. showOnlyOnOverflow
  215. >
  216. <Text>{network.op.replace('resource.', '')}</Text>
  217. </Tooltip>
  218. </Item>,
  219. <Item key="size" {...columnHandlers} {...columnProps} numeric>
  220. {defined(network.data.size) ? (
  221. <FileSize bytes={network.data.size} />
  222. ) : (
  223. <EmptyText>({t('No value')})</EmptyText>
  224. )}
  225. </Item>,
  226. <Item key="duration" {...columnHandlers} {...columnProps} numeric>
  227. {`${(networkEndTimestamp - networkStartTimestamp).toFixed(2)}ms`}
  228. </Item>,
  229. <Item key="timestamp" {...columnHandlers} {...columnProps} numeric>
  230. <Tooltip title={<DateTime date={networkStartTimestamp} seconds />}>
  231. <UnstyledButton onClick={() => handleClick(networkStartTimestamp)}>
  232. {showPlayerTime(networkStartTimestamp, startTimestampMs, true)}
  233. </UnstyledButton>
  234. </Tooltip>
  235. </Item>,
  236. ];
  237. return columnValues[column];
  238. };
  239. const renderTableRow = ({columnIndex, rowIndex, key, style, parent}: GridCellProps) => {
  240. const network = networkData[rowIndex - 1];
  241. return (
  242. <CellMeasurer
  243. cache={cache}
  244. columnIndex={columnIndex}
  245. key={key}
  246. parent={parent}
  247. rowIndex={rowIndex}
  248. >
  249. <div key={key} style={style}>
  250. {rowIndex === 0
  251. ? columns[columnIndex]
  252. : getNetworkColumnValue(network, columnIndex)}
  253. </div>
  254. </CellMeasurer>
  255. );
  256. };
  257. return (
  258. <NetworkContainer>
  259. <NetworkFilters>
  260. <CompactSelect
  261. triggerProps={{prefix: t('Status')}}
  262. triggerLabel={selectedStatus.length === 0 ? t('Any') : null}
  263. multiple
  264. options={getStatusTypes(networkSpans).map(value => ({value, label: value}))}
  265. size="sm"
  266. onChange={selected => setStatus(selected.map(_ => _.value))}
  267. value={selectedStatus}
  268. />
  269. <CompactSelect
  270. triggerProps={{prefix: t('Type')}}
  271. triggerLabel={selectedType.length === 0 ? t('Any') : null}
  272. multiple
  273. options={getResourceTypes(networkSpans).map(value => ({value, label: value}))}
  274. size="sm"
  275. onChange={selected => setType(selected.map(_ => _.value))}
  276. value={selectedType}
  277. />
  278. <SearchBar
  279. size="sm"
  280. onChange={setSearchTerm}
  281. placeholder={t('Search Network...')}
  282. query={searchTerm}
  283. />
  284. </NetworkFilters>
  285. <NetworkTable ref={networkTableRef}>
  286. <AutoSizer>
  287. {({width, height}) => (
  288. <MultiGrid
  289. ref={multiGridRef}
  290. columnCount={columns.length}
  291. columnWidth={({index}) => {
  292. if (index === 1) {
  293. return Math.max(
  294. columns.reduce(
  295. (remaining, _, i) =>
  296. i === 1 ? remaining : remaining - cache.columnWidth({index: i}),
  297. width - scrollBarWidth
  298. ),
  299. 200
  300. );
  301. }
  302. return cache.columnWidth({index});
  303. }}
  304. deferredMeasurementCache={cache}
  305. height={height}
  306. overscanRowCount={5}
  307. cellRenderer={renderTableRow}
  308. rowCount={networkData.length + 1}
  309. rowHeight={({index}) => (index === 0 ? headerRowHeight : 28)}
  310. width={width}
  311. fixedRowCount={1}
  312. onScrollbarPresenceChange={({vertical, size}) => {
  313. if (vertical) {
  314. setScrollBarWidth(size);
  315. } else {
  316. setScrollBarWidth(0);
  317. }
  318. }}
  319. noContentRenderer={() => (
  320. <EmptyStateWarning withIcon small>
  321. {t('No related network requests found.')}
  322. </EmptyStateWarning>
  323. )}
  324. />
  325. )}
  326. </AutoSizer>
  327. </NetworkTable>
  328. </NetworkContainer>
  329. );
  330. }
  331. const NetworkContainer = styled(FluidHeight)`
  332. height: 100%;
  333. `;
  334. const NetworkFilters = styled('div')`
  335. display: grid;
  336. gap: ${space(1)};
  337. grid-template-columns: max-content max-content 1fr;
  338. margin-bottom: ${space(1)};
  339. @media (max-width: ${p => p.theme.breakpoints.small}) {
  340. margin-top: ${space(1)};
  341. }
  342. `;
  343. const Text = styled('p')`
  344. padding: 0;
  345. margin: 0;
  346. text-overflow: ellipsis;
  347. white-space: nowrap;
  348. overflow: hidden;
  349. `;
  350. const EmptyText = styled(Text)`
  351. font-style: italic;
  352. color: ${p => p.theme.subText};
  353. `;
  354. const fontColor = p => {
  355. if (p.isStatusError) {
  356. return p.hasOccurred || !p.timestampSortDir ? p.theme.red400 : p.theme.red200;
  357. }
  358. return p.hasOccurred || !p.timestampSortDir ? p.theme.gray400 : p.theme.gray300;
  359. };
  360. const Item = styled('div')<{
  361. hasOccurred: boolean;
  362. isCurrent: boolean;
  363. isStatusError: boolean;
  364. timestampSortDir: SortDirection | undefined;
  365. numeric?: boolean;
  366. }>`
  367. display: flex;
  368. align-items: center;
  369. font-size: ${p => p.theme.fontSizeSmall};
  370. max-height: 28px;
  371. color: ${fontColor};
  372. padding: ${space(0.75)} ${space(1.5)};
  373. background-color: ${p => p.theme.background};
  374. border-bottom: ${p => {
  375. if (p.isCurrent && p.timestampSortDir === 'asc') {
  376. return `1px solid ${p.theme.purple300} !important`;
  377. }
  378. return p.isStatusError
  379. ? `1px solid ${p.theme.red100}`
  380. : `1px solid ${p.theme.innerBorder}`;
  381. }};
  382. border-top: ${p => {
  383. return p.isCurrent && p.timestampSortDir === 'desc'
  384. ? `1px solid ${p.theme.purple300} !important`
  385. : 0;
  386. }};
  387. border-right: 1px solid ${p => p.theme.innerBorder};
  388. ${p => p.numeric && 'font-variant-numeric: tabular-nums; justify-content: flex-end;'};
  389. ${EmptyText} {
  390. color: ${fontColor};
  391. }
  392. `;
  393. const UnstyledButton = styled('button')`
  394. border: 0;
  395. background: none;
  396. padding: 0;
  397. text-transform: inherit;
  398. width: 100%;
  399. text-align: unset;
  400. `;
  401. const UnstyledHeaderButton = styled(UnstyledButton)`
  402. padding: ${space(0.5)} ${space(1)} ${space(0.5)} ${space(1.5)};
  403. display: flex;
  404. justify-content: space-between;
  405. align-items: center;
  406. `;
  407. const NetworkTable = styled('div')`
  408. list-style: none;
  409. position: relative;
  410. height: 100%;
  411. overflow: hidden;
  412. border: 1px solid ${p => p.theme.border};
  413. border-radius: ${p => p.theme.borderRadius};
  414. padding-left: 0;
  415. margin-bottom: 0;
  416. `;
  417. const SortItem = styled('span')`
  418. color: ${p => p.theme.subText};
  419. font-size: ${p => p.theme.fontSizeSmall};
  420. font-weight: 600;
  421. background: ${p => p.theme.backgroundSecondary};
  422. display: flex;
  423. flex-direction: column;
  424. justify-content: center;
  425. align-items: center;
  426. width: 100%;
  427. max-height: ${headerRowHeight}px;
  428. line-height: 16px;
  429. text-transform: uppercase;
  430. border-radius: 0;
  431. border-right: 1px solid ${p => p.theme.innerBorder};
  432. border-bottom: 1px solid ${p => p.theme.innerBorder};
  433. svg {
  434. margin-left: ${space(0.25)};
  435. }
  436. `;
  437. export default NetworkList;