styles.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. import {
  2. Children,
  3. Fragment,
  4. type PropsWithChildren,
  5. useMemo,
  6. useRef,
  7. useState,
  8. } from 'react';
  9. import styled from '@emotion/styled';
  10. import type {LocationDescriptor} from 'history';
  11. import startCase from 'lodash/startCase';
  12. import * as qs from 'query-string';
  13. import {Button} from 'sentry/components/button';
  14. import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
  15. import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
  16. import {useIssueDetailsColumnCount} from 'sentry/components/events/eventTags/util';
  17. import {DataSection} from 'sentry/components/events/styles';
  18. import FileSize from 'sentry/components/fileSize';
  19. import type {LazyRenderProps} from 'sentry/components/lazyRender';
  20. import Link from 'sentry/components/links/link';
  21. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  22. import Panel from 'sentry/components/panels/panel';
  23. import QuestionTooltip from 'sentry/components/questionTooltip';
  24. import {StructuredData} from 'sentry/components/structuredEventData';
  25. import {Tooltip} from 'sentry/components/tooltip';
  26. import {IconChevron, IconOpen} from 'sentry/icons';
  27. import {t, tct} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import type {KeyValueListDataItem} from 'sentry/types';
  30. import type {Organization} from 'sentry/types/organization';
  31. import {defined, formatBytesBase10} from 'sentry/utils';
  32. import getDuration from 'sentry/utils/duration/getDuration';
  33. import {decodeScalar} from 'sentry/utils/queryString';
  34. import type {ColorOrAlias} from 'sentry/utils/theme';
  35. import useOrganization from 'sentry/utils/useOrganization';
  36. import {useParams} from 'sentry/utils/useParams';
  37. import {
  38. isAutogroupedNode,
  39. isMissingInstrumentationNode,
  40. isNoDataNode,
  41. isRootNode,
  42. isSpanNode,
  43. isTraceErrorNode,
  44. isTransactionNode,
  45. } from 'sentry/views/performance/newTraceDetails/guards';
  46. import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
  47. import type {
  48. MissingInstrumentationNode,
  49. NoDataNode,
  50. ParentAutogroupNode,
  51. SiblingAutogroupNode,
  52. TraceTree,
  53. TraceTreeNode,
  54. } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  55. const DetailContainer = styled('div')`
  56. display: flex;
  57. flex-direction: column;
  58. gap: ${space(2)};
  59. padding: ${space(1)};
  60. ${DataSection} {
  61. padding: 0;
  62. }
  63. `;
  64. const FlexBox = styled('div')`
  65. display: flex;
  66. align-items: center;
  67. `;
  68. const Actions = styled(FlexBox)`
  69. gap: ${space(0.5)};
  70. flex-wrap: wrap;
  71. justify-content: end;
  72. width: 100%;
  73. `;
  74. const Title = styled(FlexBox)`
  75. gap: ${space(1)};
  76. width: 50%;
  77. > span {
  78. min-width: 30px;
  79. }
  80. `;
  81. const TitleText = styled('div')`
  82. ${p => p.theme.overflowEllipsis}
  83. `;
  84. function TitleWithTestId(props: PropsWithChildren<{}>) {
  85. return <Title data-test-id="trace-drawer-title">{props.children}</Title>;
  86. }
  87. const Type = styled('div')`
  88. font-size: ${p => p.theme.fontSizeSmall};
  89. `;
  90. const TitleOp = styled('div')`
  91. font-size: 15px;
  92. font-weight: bold;
  93. ${p => p.theme.overflowEllipsis}
  94. `;
  95. const Table = styled('table')`
  96. margin-bottom: 0 !important;
  97. td {
  98. overflow: hidden;
  99. }
  100. `;
  101. const IconTitleWrapper = styled(FlexBox)`
  102. gap: ${space(1)};
  103. min-width: 30px;
  104. `;
  105. const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>`
  106. background-color: ${p => p.backgroundColor};
  107. border-radius: ${p => p.theme.borderRadius};
  108. padding: 0;
  109. display: flex;
  110. align-items: center;
  111. justify-content: center;
  112. width: 30px;
  113. height: 30px;
  114. min-width: 30px;
  115. svg {
  116. fill: ${p => p.theme.white};
  117. width: 14px;
  118. height: 14px;
  119. }
  120. `;
  121. const HeaderContainer = styled(Title)`
  122. justify-content: space-between;
  123. width: 100%;
  124. z-index: 10;
  125. flex: 1 1 auto;
  126. `;
  127. const DURATION_COMPARISON_STATUS_COLORS: {
  128. equal: {light: ColorOrAlias; normal: ColorOrAlias};
  129. faster: {light: ColorOrAlias; normal: ColorOrAlias};
  130. slower: {light: ColorOrAlias; normal: ColorOrAlias};
  131. } = {
  132. faster: {
  133. light: 'green100',
  134. normal: 'green300',
  135. },
  136. slower: {
  137. light: 'red100',
  138. normal: 'red300',
  139. },
  140. equal: {
  141. light: 'gray100',
  142. normal: 'gray300',
  143. },
  144. };
  145. const MIN_PCT_DURATION_DIFFERENCE = 10;
  146. type DurationProps = {
  147. baseline: number | undefined;
  148. duration: number;
  149. baseDescription?: string;
  150. ratio?: number;
  151. };
  152. function Duration(props: DurationProps) {
  153. if (typeof props.duration !== 'number' || Number.isNaN(props.duration)) {
  154. return <DurationContainer>{t('unknown')}</DurationContainer>;
  155. }
  156. if (props.baseline === undefined || props.baseline === 0) {
  157. return <DurationContainer>{getDuration(props.duration, 2, true)}</DurationContainer>;
  158. }
  159. const delta = props.duration - props.baseline;
  160. const deltaPct = Math.round(Math.abs((delta / props.baseline) * 100));
  161. const status = delta > 0 ? 'slower' : delta < 0 ? 'faster' : 'equal';
  162. const formattedBaseDuration = (
  163. <Tooltip
  164. title={props.baseDescription}
  165. showUnderline
  166. underlineColor={DURATION_COMPARISON_STATUS_COLORS[status].normal}
  167. >
  168. {getDuration(props.baseline, 2, true)}
  169. </Tooltip>
  170. );
  171. const deltaText =
  172. status === 'equal'
  173. ? tct(`equal to the avg of [formattedBaseDuration]`, {
  174. formattedBaseDuration,
  175. })
  176. : status === 'faster'
  177. ? tct(`[deltaPct] faster than the avg of [formattedBaseDuration]`, {
  178. formattedBaseDuration,
  179. deltaPct: `${deltaPct}%`,
  180. })
  181. : tct(`[deltaPct] slower than the avg of [formattedBaseDuration]`, {
  182. formattedBaseDuration,
  183. deltaPct: `${deltaPct}%`,
  184. });
  185. return (
  186. <Fragment>
  187. <DurationContainer>
  188. {getDuration(props.duration, 2, true)}{' '}
  189. {props.ratio ? `(${(props.ratio * 100).toFixed()}%)` : null}
  190. </DurationContainer>
  191. {deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? (
  192. <Comparison status={status}>{deltaText}</Comparison>
  193. ) : null}
  194. </Fragment>
  195. );
  196. }
  197. function TableRow({
  198. title,
  199. keep,
  200. children,
  201. prefix,
  202. extra = null,
  203. toolTipText,
  204. }: {
  205. children: React.ReactNode;
  206. title: JSX.Element | string | null;
  207. extra?: React.ReactNode;
  208. keep?: boolean;
  209. prefix?: JSX.Element;
  210. toolTipText?: string;
  211. }) {
  212. if (!keep && !children) {
  213. return null;
  214. }
  215. return (
  216. <tr>
  217. <td className="key">
  218. <Flex>
  219. {prefix}
  220. {title}
  221. {toolTipText ? <StyledQuestionTooltip size="xs" title={toolTipText} /> : null}
  222. </Flex>
  223. </td>
  224. <ValueTd className="value">
  225. <TableValueRow>
  226. <StyledPre>
  227. <span className="val-string">{children}</span>
  228. </StyledPre>
  229. <TableRowButtonContainer>{extra}</TableRowButtonContainer>
  230. </TableValueRow>
  231. </ValueTd>
  232. </tr>
  233. );
  234. }
  235. function getSearchParamFromNode(node: TraceTreeNode<TraceTree.NodeValue>) {
  236. if (isTransactionNode(node) || isTraceErrorNode(node)) {
  237. return `id:${node.value.event_id}`;
  238. }
  239. // Issues associated to a span or autogrouped node are not queryable, so we query by
  240. // the parent transaction's id
  241. const parentTransaction = node.parent_transaction;
  242. if ((isSpanNode(node) || isAutogroupedNode(node)) && parentTransaction) {
  243. return `id:${parentTransaction.value.event_id}`;
  244. }
  245. if (isMissingInstrumentationNode(node)) {
  246. throw new Error('Missing instrumentation nodes do not have associated issues');
  247. }
  248. return '';
  249. }
  250. function IssuesLink({
  251. node,
  252. children,
  253. }: {
  254. children: React.ReactNode;
  255. node?: TraceTreeNode<TraceTree.NodeValue>;
  256. }) {
  257. const organization = useOrganization();
  258. const params = useParams<{traceSlug?: string}>();
  259. const traceSlug = params.traceSlug?.trim() ?? '';
  260. const dateSelection = useMemo(() => {
  261. const normalizedParams = normalizeDateTimeParams(qs.parse(window.location.search), {
  262. allowAbsolutePageDatetime: true,
  263. });
  264. const start = decodeScalar(normalizedParams.start);
  265. const end = decodeScalar(normalizedParams.end);
  266. const statsPeriod = decodeScalar(normalizedParams.statsPeriod);
  267. return {start, end, statsPeriod};
  268. }, []);
  269. return (
  270. <Link
  271. to={{
  272. pathname: `/organizations/${organization.slug}/issues/`,
  273. query: {
  274. query: `trace:${traceSlug} ${node ? getSearchParamFromNode(node) : ''}`,
  275. start: dateSelection.start,
  276. end: dateSelection.end,
  277. statsPeriod: dateSelection.statsPeriod,
  278. // If we don't pass the project param, the issues page will filter by the last selected project.
  279. // Traces can have multiple projects, so we query issues by all projects and rely on our search query to filter the results.
  280. project: -1,
  281. },
  282. }}
  283. >
  284. {children}
  285. </Link>
  286. );
  287. }
  288. const LAZY_RENDER_PROPS: Partial<LazyRenderProps> = {
  289. observerOptions: {rootMargin: '50px'},
  290. };
  291. const DurationContainer = styled('span')`
  292. font-weight: bold;
  293. margin-right: ${space(1)};
  294. `;
  295. const Comparison = styled('span')<{status: 'faster' | 'slower' | 'equal'}>`
  296. color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]};
  297. `;
  298. const Flex = styled('div')`
  299. display: flex;
  300. align-items: center;
  301. `;
  302. const TableValueRow = styled('div')`
  303. display: grid;
  304. grid-template-columns: auto min-content;
  305. gap: ${space(1)};
  306. border-radius: 4px;
  307. background-color: ${p => p.theme.surface200};
  308. margin: 2px;
  309. `;
  310. const StyledQuestionTooltip = styled(QuestionTooltip)`
  311. margin-left: ${space(0.5)};
  312. `;
  313. const StyledPre = styled('pre')`
  314. margin: 0 !important;
  315. background-color: transparent !important;
  316. `;
  317. const TableRowButtonContainer = styled('div')`
  318. padding: 8px 10px;
  319. `;
  320. const ValueTd = styled('td')`
  321. position: relative;
  322. `;
  323. function NodeActions(props: {
  324. node: TraceTreeNode<any>;
  325. onTabScrollToNode: (
  326. node:
  327. | TraceTreeNode<any>
  328. | ParentAutogroupNode
  329. | SiblingAutogroupNode
  330. | NoDataNode
  331. | MissingInstrumentationNode
  332. ) => void;
  333. organization: Organization;
  334. eventSize?: number | undefined;
  335. }) {
  336. const items = useMemo(() => {
  337. const showInView: MenuItemProps = {
  338. key: 'show-in-view',
  339. label: t('Show in View'),
  340. onAction: () => {
  341. traceAnalytics.trackShowInView(props.organization);
  342. props.onTabScrollToNode(props.node);
  343. },
  344. };
  345. const eventId =
  346. props.node.metadata.event_id ?? props.node.parent_transaction?.metadata.event_id;
  347. const projectSlug =
  348. props.node.metadata.project_slug ??
  349. props.node.parent_transaction?.metadata.project_slug;
  350. const eventSize = props.eventSize;
  351. const jsonDetails: MenuItemProps = {
  352. key: 'json-details',
  353. onAction: () => {
  354. traceAnalytics.trackViewEventJSON(props.organization);
  355. window.open(
  356. `/api/0/projects/${props.organization.slug}/${projectSlug}/events/${eventId}/json/`,
  357. '_blank'
  358. );
  359. },
  360. label:
  361. t('JSON') +
  362. (typeof eventSize === 'number' ? ` (${formatBytesBase10(eventSize, 0)})` : ''),
  363. };
  364. if (isTransactionNode(props.node)) {
  365. return [showInView, jsonDetails];
  366. }
  367. if (isSpanNode(props.node)) {
  368. return [showInView];
  369. }
  370. if (isMissingInstrumentationNode(props.node)) {
  371. return [showInView];
  372. }
  373. if (isTraceErrorNode(props.node)) {
  374. return [showInView];
  375. }
  376. if (isRootNode(props.node)) {
  377. return [showInView];
  378. }
  379. if (isAutogroupedNode(props.node)) {
  380. return [showInView];
  381. }
  382. if (isNoDataNode(props.node)) {
  383. return [showInView];
  384. }
  385. return [showInView];
  386. }, [props]);
  387. return (
  388. <ActionsContainer>
  389. <Actions className="Actions">
  390. <Button
  391. size="xs"
  392. onClick={_e => {
  393. traceAnalytics.trackShowInView(props.organization);
  394. props.onTabScrollToNode(props.node);
  395. }}
  396. >
  397. {t('Show in view')}
  398. </Button>
  399. {isTransactionNode(props.node) ? (
  400. <Button
  401. size="xs"
  402. icon={<IconOpen />}
  403. onClick={() => traceAnalytics.trackViewEventJSON(props.organization)}
  404. href={`/api/0/projects/${props.organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`}
  405. external
  406. >
  407. {t('JSON')} (<FileSize bytes={props.eventSize ?? 0} />)
  408. </Button>
  409. ) : null}
  410. </Actions>
  411. <DropdownMenu
  412. items={items}
  413. className="DropdownMenu"
  414. position="bottom-end"
  415. trigger={triggerProps => (
  416. <ActionsButtonTrigger size="xs" {...triggerProps}>
  417. {t('Actions')}
  418. <IconChevron direction="down" size="xs" />
  419. </ActionsButtonTrigger>
  420. )}
  421. />
  422. </ActionsContainer>
  423. );
  424. }
  425. const ActionsButtonTrigger = styled(Button)`
  426. svg {
  427. margin-left: ${space(0.5)};
  428. width: 10px;
  429. height: 10px;
  430. }
  431. `;
  432. const ActionsContainer = styled('div')`
  433. display: flex;
  434. justify-content: end;
  435. align-items: center;
  436. gap: ${space(1)};
  437. container-type: inline-size;
  438. min-width: 24px;
  439. width: 100%;
  440. @container (max-width: 380px) {
  441. .DropdownMenu {
  442. display: block;
  443. }
  444. .Actions {
  445. display: none;
  446. }
  447. }
  448. @container (min-width: 381px) {
  449. .DropdownMenu {
  450. display: none;
  451. }
  452. }
  453. `;
  454. interface SectionCardContentConfig {
  455. disableErrors?: boolean;
  456. includeAliasInSubject?: boolean;
  457. }
  458. type SectionCardKeyValue = Omit<KeyValueListDataItem, 'subject'> & {
  459. subject: React.ReactNode;
  460. };
  461. export type SectionCardKeyValueList = SectionCardKeyValue[];
  462. interface SectionCardContentProps {
  463. item: SectionCardKeyValue;
  464. meta: Record<string, any>;
  465. alias?: string;
  466. config?: SectionCardContentConfig;
  467. }
  468. function SectionCardContent({
  469. item,
  470. alias,
  471. meta,
  472. config,
  473. ...props
  474. }: SectionCardContentProps) {
  475. const {key, subject, value, action = {}} = item;
  476. if (key === 'type') {
  477. return null;
  478. }
  479. const dataComponent = (
  480. <StructuredData
  481. value={value}
  482. depth={0}
  483. maxDefaultDepth={0}
  484. meta={meta?.[key]}
  485. withAnnotatedText
  486. withOnlyFormattedText
  487. />
  488. );
  489. const contextSubject = subject
  490. ? config?.includeAliasInSubject && alias
  491. ? `${startCase(alias)}: ${subject}`
  492. : subject
  493. : null;
  494. return (
  495. <ContentContainer {...props}>
  496. {contextSubject ? <CardContentSubject>{contextSubject}</CardContentSubject> : null}
  497. <CardContentValueWrapper hasSubject={!!contextSubject} className="ctx-row-value">
  498. {defined(action?.link) ? (
  499. <Link to={action.link}>{dataComponent}</Link>
  500. ) : (
  501. dataComponent
  502. )}
  503. </CardContentValueWrapper>
  504. </ContentContainer>
  505. );
  506. }
  507. function SectionCard({
  508. items,
  509. title,
  510. disableTruncate,
  511. }: {
  512. items: SectionCardKeyValueList;
  513. title: React.ReactNode;
  514. disableTruncate?: boolean;
  515. }) {
  516. const [showingAll, setShowingAll] = useState(disableTruncate ?? false);
  517. const renderText = showingAll ? t('Show less') : t('Show more') + '...';
  518. if (items.length === 0) {
  519. return null;
  520. }
  521. return (
  522. <Card>
  523. <CardContentTitle>{title}</CardContentTitle>
  524. {items.slice(0, showingAll ? items.length : 5).map(item => (
  525. <SectionCardContent key={`context-card-${item.key}`} meta={{}} item={item} />
  526. ))}
  527. {items.length > 5 && !disableTruncate ? (
  528. <TruncateActionWrapper>
  529. <a onClick={() => setShowingAll(prev => !prev)}>{renderText}</a>
  530. </TruncateActionWrapper>
  531. ) : null}
  532. </Card>
  533. );
  534. }
  535. function SectionCardGroup({children}: {children: React.ReactNode}) {
  536. const containerRef = useRef<HTMLDivElement>(null);
  537. const columnCount = useIssueDetailsColumnCount(containerRef);
  538. const columns: React.ReactNode[] = [];
  539. const cards = Children.toArray(children);
  540. // Evenly distributing the cards into columns.
  541. const columnSize = Math.ceil(cards.length / columnCount);
  542. for (let i = 0; i < cards.length; i += columnSize) {
  543. columns.push(<CardsColumn key={i}>{cards.slice(i, i + columnSize)}</CardsColumn>);
  544. }
  545. return (
  546. <CardsWrapper columnCount={columnCount} ref={containerRef}>
  547. {columns}
  548. </CardsWrapper>
  549. );
  550. }
  551. const CardsWrapper = styled('div')<{columnCount: number}>`
  552. display: grid;
  553. align-items: start;
  554. grid-template-columns: repeat(${p => p.columnCount}, 1fr);
  555. gap: 10px;
  556. `;
  557. const CardsColumn = styled('div')`
  558. grid-column: span 1;
  559. `;
  560. function Description({
  561. value,
  562. linkTarget,
  563. linkText,
  564. }: {
  565. value: string;
  566. linkTarget?: LocationDescriptor;
  567. linkText?: string;
  568. }) {
  569. return (
  570. <DescriptionContainer>
  571. <DescriptionText>
  572. {value}
  573. <StyledCopuToClipboardButton borderless size="zero" iconSize="xs" text={value} />
  574. </DescriptionText>
  575. {linkTarget && linkTarget ? <Link to={linkTarget}>{linkText}</Link> : null}
  576. </DescriptionContainer>
  577. );
  578. }
  579. const StyledCopuToClipboardButton = styled(CopyToClipboardButton)`
  580. transform: translateY(2px);
  581. `;
  582. const DescriptionContainer = styled(FlexBox)`
  583. justify-content: space-between;
  584. gap: ${space(1)};
  585. flex-wrap: wrap;
  586. `;
  587. const DescriptionText = styled('span')`
  588. overflow-wrap: anywhere;
  589. `;
  590. const Card = styled(Panel)`
  591. margin-bottom: 10px;
  592. padding: ${space(0.75)};
  593. font-size: ${p => p.theme.fontSizeSmall};
  594. `;
  595. const CardContentTitle = styled('p')`
  596. grid-column: 1 / -1;
  597. padding: ${space(0.25)} ${space(0.75)};
  598. margin: 0;
  599. color: ${p => p.theme.headingColor};
  600. font-weight: bold;
  601. `;
  602. const ContentContainer = styled('div')`
  603. display: grid;
  604. column-gap: ${space(1.5)};
  605. grid-template-columns: minmax(100px, 150px) 1fr 30px;
  606. padding: ${space(0.25)} ${space(0.75)};
  607. border-radius: 4px;
  608. color: ${p => p.theme.subText};
  609. border: 1px solid 'transparent';
  610. background-color: ${p => p.theme.background};
  611. &:nth-child(odd) {
  612. background-color: ${p => p.theme.backgroundSecondary};
  613. }
  614. `;
  615. export const CardContentSubject = styled('div')`
  616. grid-column: span 1;
  617. font-family: ${p => p.theme.text.familyMono};
  618. word-wrap: break-word;
  619. `;
  620. const CardContentValueWrapper = styled(CardContentSubject)<{hasSubject: boolean}>`
  621. color: ${p => p.theme.textColor};
  622. grid-column: ${p => (p.hasSubject ? 'span 2' : '1 / -1')};
  623. `;
  624. const TruncateActionWrapper = styled('div')`
  625. grid-column: 1 / -1;
  626. margin: ${space(0.5)} 0;
  627. display: flex;
  628. justify-content: center;
  629. `;
  630. const TraceDrawerComponents = {
  631. DetailContainer,
  632. FlexBox,
  633. Title: TitleWithTestId,
  634. Type,
  635. TitleOp,
  636. HeaderContainer,
  637. Actions,
  638. NodeActions,
  639. Table,
  640. IconTitleWrapper,
  641. IconBorder,
  642. TitleText,
  643. Duration,
  644. TableRow,
  645. LAZY_RENDER_PROPS,
  646. TableRowButtonContainer,
  647. TableValueRow,
  648. IssuesLink,
  649. SectionCard,
  650. Description,
  651. SectionCardGroup,
  652. };
  653. export {TraceDrawerComponents};