styles.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import {Fragment, type PropsWithChildren, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LocationDescriptor} from 'history';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
  6. import {
  7. DropdownMenu,
  8. type DropdownMenuProps,
  9. type MenuItemProps,
  10. } from 'sentry/components/dropdownMenu';
  11. import EventTagsDataSection from 'sentry/components/events/eventTagsAndScreenshot/tags';
  12. import {DataSection} from 'sentry/components/events/styles';
  13. import FileSize from 'sentry/components/fileSize';
  14. import KeyValueData, {
  15. CardPanel,
  16. type KeyValueDataContentProps,
  17. Subject,
  18. } from 'sentry/components/keyValueData';
  19. import {LazyRender, type LazyRenderProps} from 'sentry/components/lazyRender';
  20. import Link from 'sentry/components/links/link';
  21. import QuestionTooltip from 'sentry/components/questionTooltip';
  22. import {Tooltip} from 'sentry/components/tooltip';
  23. import {IconChevron, IconOpen} from 'sentry/icons';
  24. import {t, tct} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {Event, EventTransaction} from 'sentry/types/event';
  27. import type {KeyValueListData} from 'sentry/types/group';
  28. import type {Organization} from 'sentry/types/organization';
  29. import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10';
  30. import getDuration from 'sentry/utils/duration/getDuration';
  31. import type {ColorOrAlias} from 'sentry/utils/theme';
  32. import {useNavigate} from 'sentry/utils/useNavigate';
  33. import useOrganization from 'sentry/utils/useOrganization';
  34. import {useParams} from 'sentry/utils/useParams';
  35. import {traceAnalytics} from '../../traceAnalytics';
  36. import {useTransaction} from '../../traceApi/useTransaction';
  37. import {useDrawerContainerRef} from '../../traceDrawer/details/drawerContainerRefContext';
  38. import {makeTraceContinuousProfilingLink} from '../../traceDrawer/traceProfilingLink';
  39. import {
  40. isAutogroupedNode,
  41. isMissingInstrumentationNode,
  42. isRootNode,
  43. isSpanNode,
  44. isTraceErrorNode,
  45. isTransactionNode,
  46. } from '../../traceGuards';
  47. import type {MissingInstrumentationNode} from '../../traceModels/missingInstrumentationNode';
  48. import type {ParentAutogroupNode} from '../../traceModels/parentAutogroupNode';
  49. import type {SiblingAutogroupNode} from '../../traceModels/siblingAutogroupNode';
  50. import {TraceTree} from '../../traceModels/traceTree';
  51. import type {TraceTreeNode} from '../../traceModels/traceTreeNode';
  52. const DetailContainer = styled('div')`
  53. display: flex;
  54. flex-direction: column;
  55. gap: ${space(2)};
  56. padding: ${space(1)};
  57. ${DataSection} {
  58. padding: 0;
  59. }
  60. `;
  61. const FlexBox = styled('div')`
  62. display: flex;
  63. align-items: center;
  64. `;
  65. const Actions = styled(FlexBox)`
  66. gap: ${space(0.5)};
  67. justify-content: end;
  68. width: 100%;
  69. `;
  70. const Title = styled(FlexBox)`
  71. gap: ${space(1)};
  72. flex-grow: 1;
  73. overflow: hidden;
  74. > span {
  75. min-width: 30px;
  76. }
  77. `;
  78. const TitleText = styled('div')`
  79. ${p => p.theme.overflowEllipsis}
  80. `;
  81. function TitleWithTestId(props: PropsWithChildren<{}>) {
  82. return <Title data-test-id="trace-drawer-title">{props.children}</Title>;
  83. }
  84. function TitleOp({text}: {text: string}) {
  85. return (
  86. <Tooltip
  87. title={
  88. <Fragment>
  89. {text}
  90. <CopyToClipboardButton
  91. borderless
  92. size="zero"
  93. iconSize="xs"
  94. text={text}
  95. tooltipProps={{disabled: true}}
  96. />
  97. </Fragment>
  98. }
  99. showOnlyOnOverflow
  100. isHoverable
  101. >
  102. <TitleOpText>{text}</TitleOpText>
  103. </Tooltip>
  104. );
  105. }
  106. const Type = styled('div')`
  107. font-size: ${p => p.theme.fontSizeSmall};
  108. `;
  109. const TitleOpText = styled('div')`
  110. font-size: 15px;
  111. font-weight: ${p => p.theme.fontWeightBold};
  112. ${p => p.theme.overflowEllipsis}
  113. `;
  114. const Table = styled('table')`
  115. margin-bottom: 0 !important;
  116. td {
  117. overflow: hidden;
  118. }
  119. `;
  120. const IconTitleWrapper = styled(FlexBox)`
  121. gap: ${space(1)};
  122. min-width: 30px;
  123. `;
  124. const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>`
  125. background-color: ${p => p.backgroundColor};
  126. border-radius: ${p => p.theme.borderRadius};
  127. padding: 0;
  128. display: flex;
  129. align-items: center;
  130. justify-content: center;
  131. width: 30px;
  132. height: 30px;
  133. min-width: 30px;
  134. svg {
  135. fill: ${p => p.theme.white};
  136. width: 14px;
  137. height: 14px;
  138. }
  139. `;
  140. const HeaderContainer = styled(FlexBox)`
  141. justify-content: space-between;
  142. gap: ${space(3)};
  143. container-type: inline-size;
  144. @container (max-width: 780px) {
  145. .DropdownMenu {
  146. display: block;
  147. }
  148. .Actions {
  149. display: none;
  150. }
  151. }
  152. @container (min-width: 781px) {
  153. .DropdownMenu {
  154. display: none;
  155. }
  156. }
  157. `;
  158. const DURATION_COMPARISON_STATUS_COLORS: {
  159. equal: {light: ColorOrAlias; normal: ColorOrAlias};
  160. faster: {light: ColorOrAlias; normal: ColorOrAlias};
  161. slower: {light: ColorOrAlias; normal: ColorOrAlias};
  162. } = {
  163. faster: {
  164. light: 'green100',
  165. normal: 'green300',
  166. },
  167. slower: {
  168. light: 'red100',
  169. normal: 'red300',
  170. },
  171. equal: {
  172. light: 'gray100',
  173. normal: 'gray300',
  174. },
  175. };
  176. const MIN_PCT_DURATION_DIFFERENCE = 10;
  177. type DurationProps = {
  178. baseline: number | undefined;
  179. duration: number;
  180. node: TraceTreeNode<TraceTree.NodeValue>;
  181. baseDescription?: string;
  182. ratio?: number;
  183. };
  184. function Duration(props: DurationProps) {
  185. if (typeof props.duration !== 'number' || Number.isNaN(props.duration)) {
  186. return <DurationContainer>{t('unknown')}</DurationContainer>;
  187. }
  188. // Since transactions have ms precision, we show 2 decimal places only if the duration is greater than 1 second.
  189. const precision = isTransactionNode(props.node) ? (props.duration > 1 ? 2 : 0) : 2;
  190. if (props.baseline === undefined || props.baseline === 0) {
  191. return (
  192. <DurationContainer>
  193. {getDuration(props.duration, precision, true)}
  194. </DurationContainer>
  195. );
  196. }
  197. const delta = props.duration - props.baseline;
  198. const deltaPct = Math.round(Math.abs((delta / props.baseline) * 100));
  199. const status = delta > 0 ? 'slower' : delta < 0 ? 'faster' : 'equal';
  200. const formattedBaseDuration = (
  201. <Tooltip
  202. title={props.baseDescription}
  203. showUnderline
  204. underlineColor={DURATION_COMPARISON_STATUS_COLORS[status].normal}
  205. >
  206. {getDuration(props.baseline, 2, true)}
  207. </Tooltip>
  208. );
  209. const deltaText =
  210. status === 'equal'
  211. ? tct(`equal to the avg of [formattedBaseDuration]`, {
  212. formattedBaseDuration,
  213. })
  214. : status === 'faster'
  215. ? tct(`[deltaPct] faster than the avg of [formattedBaseDuration]`, {
  216. formattedBaseDuration,
  217. deltaPct: `${deltaPct}%`,
  218. })
  219. : tct(`[deltaPct] slower than the avg of [formattedBaseDuration]`, {
  220. formattedBaseDuration,
  221. deltaPct: `${deltaPct}%`,
  222. });
  223. return (
  224. <Fragment>
  225. <DurationContainer>
  226. {getDuration(props.duration, precision, true)}{' '}
  227. {props.ratio ? `(${(props.ratio * 100).toFixed()}%)` : null}
  228. </DurationContainer>
  229. {deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? (
  230. <Comparison status={status}>{deltaText}</Comparison>
  231. ) : null}
  232. </Fragment>
  233. );
  234. }
  235. function TableRow({
  236. title,
  237. keep,
  238. children,
  239. prefix,
  240. extra = null,
  241. toolTipText,
  242. }: {
  243. children: React.ReactNode;
  244. title: JSX.Element | string | null;
  245. extra?: React.ReactNode;
  246. keep?: boolean;
  247. prefix?: JSX.Element;
  248. toolTipText?: string;
  249. }) {
  250. if (!keep && !children) {
  251. return null;
  252. }
  253. return (
  254. <tr>
  255. <td className="key">
  256. <Flex>
  257. {prefix}
  258. {title}
  259. {toolTipText ? <StyledQuestionTooltip size="xs" title={toolTipText} /> : null}
  260. </Flex>
  261. </td>
  262. <ValueTd className="value">
  263. <TableValueRow>
  264. <StyledPre>
  265. <span className="val-string">{children}</span>
  266. </StyledPre>
  267. <TableRowButtonContainer>{extra}</TableRowButtonContainer>
  268. </TableValueRow>
  269. </ValueTd>
  270. </tr>
  271. );
  272. }
  273. function IssuesLink({
  274. node,
  275. children,
  276. }: {
  277. children: React.ReactNode;
  278. node: TraceTreeNode<TraceTree.NodeValue>;
  279. }) {
  280. const organization = useOrganization();
  281. const params = useParams<{traceSlug?: string}>();
  282. const traceSlug = params.traceSlug?.trim() ?? '';
  283. // Adding a buffer of 15mins for errors only traces, where there is no concept of
  284. // trace duration and start equals end timestamps.
  285. const buffer = node.space[1] > 0 ? 0 : 15 * 60 * 1000;
  286. return (
  287. <Link
  288. to={{
  289. pathname: `/organizations/${organization.slug}/issues/`,
  290. query: {
  291. query: `trace:${traceSlug}`,
  292. start: new Date(node.space[0] - buffer).toISOString(),
  293. end: new Date(node.space[0] + node.space[1] + buffer).toISOString(),
  294. // If we don't pass the project param, the issues page will filter by the last selected project.
  295. // Traces can have multiple projects, so we query issues by all projects and rely on our search query to filter the results.
  296. project: -1,
  297. },
  298. }}
  299. >
  300. {children}
  301. </Link>
  302. );
  303. }
  304. const LAZY_RENDER_PROPS: Partial<LazyRenderProps> = {
  305. observerOptions: {rootMargin: '50px'},
  306. };
  307. const DurationContainer = styled('span')`
  308. font-weight: ${p => p.theme.fontWeightBold};
  309. margin-right: ${space(1)};
  310. `;
  311. const Comparison = styled('span')<{status: 'faster' | 'slower' | 'equal'}>`
  312. color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]};
  313. `;
  314. const Flex = styled('div')`
  315. display: flex;
  316. align-items: center;
  317. `;
  318. const TableValueRow = styled('div')`
  319. display: grid;
  320. grid-template-columns: auto min-content;
  321. gap: ${space(1)};
  322. border-radius: 4px;
  323. background-color: ${p => p.theme.surface200};
  324. margin: 2px;
  325. `;
  326. const StyledQuestionTooltip = styled(QuestionTooltip)`
  327. margin-left: ${space(0.5)};
  328. `;
  329. const StyledPre = styled('pre')`
  330. margin: 0 !important;
  331. background-color: transparent !important;
  332. `;
  333. const TableRowButtonContainer = styled('div')`
  334. padding: 8px 10px;
  335. `;
  336. const ValueTd = styled('td')`
  337. position: relative;
  338. `;
  339. function getThreadIdFromNode(
  340. node: TraceTreeNode<TraceTree.NodeValue>,
  341. transaction: EventTransaction | undefined
  342. ): string | undefined {
  343. if (isSpanNode(node) && node.value.data?.['thread.id']) {
  344. return node.value.data['thread.id'];
  345. }
  346. if (transaction) {
  347. return transaction.contexts?.trace?.data?.['thread.id'];
  348. }
  349. return undefined;
  350. }
  351. // Renders the dropdown menu list at the root trace drawer content container level, to prevent
  352. // being stacked under other content.
  353. function DropdownMenuWithPortal(props: DropdownMenuProps) {
  354. const drawerContainerRef = useDrawerContainerRef();
  355. return (
  356. <DropdownMenu
  357. {...props}
  358. usePortal={!!drawerContainerRef}
  359. portalContainerRef={drawerContainerRef}
  360. />
  361. );
  362. }
  363. function TypeSafeBoolean<T>(value: T | null | undefined): value is NonNullable<T> {
  364. return value !== null && value !== undefined;
  365. }
  366. function NodeActions(props: {
  367. node: TraceTreeNode<any>;
  368. onTabScrollToNode: (
  369. node:
  370. | TraceTreeNode<any>
  371. | ParentAutogroupNode
  372. | SiblingAutogroupNode
  373. | MissingInstrumentationNode
  374. ) => void;
  375. organization: Organization;
  376. eventSize?: number | undefined;
  377. }) {
  378. const navigate = useNavigate();
  379. const organization = useOrganization();
  380. const params = useParams<{traceSlug?: string}>();
  381. const {data: transaction} = useTransaction({
  382. node: isTransactionNode(props.node) ? props.node : null,
  383. organization,
  384. });
  385. const profilerId = useMemo(() => {
  386. if (isTransactionNode(props.node)) {
  387. return props.node.value.profiler_id;
  388. }
  389. if (isSpanNode(props.node)) {
  390. return props.node.value.sentry_tags?.profiler_id ?? '';
  391. }
  392. return '';
  393. }, [props]);
  394. const profileLink = makeTraceContinuousProfilingLink(props.node, profilerId, {
  395. orgSlug: props.organization.slug,
  396. projectSlug: props.node.metadata.project_slug ?? '',
  397. traceId: params.traceSlug ?? '',
  398. threadId: getThreadIdFromNode(props.node, transaction),
  399. });
  400. const items = useMemo((): MenuItemProps[] => {
  401. const showInView: MenuItemProps = {
  402. key: 'show-in-view',
  403. label: t('Show in View'),
  404. onAction: () => {
  405. traceAnalytics.trackShowInView(props.organization);
  406. props.onTabScrollToNode(props.node);
  407. },
  408. };
  409. const eventId =
  410. props.node.metadata.event_id ??
  411. TraceTree.ParentTransaction(props.node)?.metadata.event_id;
  412. const projectSlug =
  413. props.node.metadata.project_slug ??
  414. TraceTree.ParentTransaction(props.node)?.metadata.project_slug;
  415. const eventSize = props.eventSize;
  416. const jsonDetails: MenuItemProps = {
  417. key: 'json-details',
  418. onAction: () => {
  419. traceAnalytics.trackViewEventJSON(props.organization);
  420. window.open(
  421. `/api/0/projects/${props.organization.slug}/${projectSlug}/events/${eventId}/json/`,
  422. '_blank'
  423. );
  424. },
  425. label:
  426. t('JSON') +
  427. (typeof eventSize === 'number' ? ` (${formatBytesBase10(eventSize, 0)})` : ''),
  428. };
  429. const continuousProfileLink: MenuItemProps | null =
  430. organization.features.includes('continuous-profiling-ui') && !!profileLink
  431. ? {
  432. key: 'continuous-profile',
  433. onAction: () => {
  434. traceAnalytics.trackViewContinuousProfile(props.organization);
  435. navigate(profileLink!);
  436. },
  437. label: t('Continuous Profile'),
  438. }
  439. : null;
  440. if (isTransactionNode(props.node)) {
  441. return [showInView, jsonDetails, continuousProfileLink].filter(TypeSafeBoolean);
  442. }
  443. if (isSpanNode(props.node)) {
  444. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  445. }
  446. if (isMissingInstrumentationNode(props.node)) {
  447. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  448. }
  449. if (isTraceErrorNode(props.node)) {
  450. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  451. }
  452. if (isRootNode(props.node)) {
  453. return [showInView];
  454. }
  455. if (isAutogroupedNode(props.node)) {
  456. return [showInView];
  457. }
  458. return [showInView];
  459. }, [props, profileLink, navigate, organization.features]);
  460. return (
  461. <ActionsContainer>
  462. <Actions className="Actions">
  463. {organization.features.includes('continuous-profiling-ui') && !!profileLink ? (
  464. <LinkButton size="xs" to={profileLink}>
  465. {t('Continuous Profile')}
  466. </LinkButton>
  467. ) : null}
  468. <Button
  469. size="xs"
  470. onClick={_e => {
  471. traceAnalytics.trackShowInView(props.organization);
  472. props.onTabScrollToNode(props.node);
  473. }}
  474. >
  475. {t('Show in view')}
  476. </Button>
  477. {isTransactionNode(props.node) ? (
  478. <LinkButton
  479. size="xs"
  480. icon={<IconOpen />}
  481. onClick={() => traceAnalytics.trackViewEventJSON(props.organization)}
  482. href={`/api/0/projects/${props.organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`}
  483. external
  484. >
  485. {t('JSON')} (<FileSize bytes={props.eventSize ?? 0} />)
  486. </LinkButton>
  487. ) : null}
  488. </Actions>
  489. <DropdownMenuWithPortal
  490. items={items}
  491. className="DropdownMenu"
  492. position="bottom-end"
  493. trigger={triggerProps => (
  494. <ActionsButtonTrigger size="xs" {...triggerProps}>
  495. {t('Actions')}
  496. <IconChevron direction="down" size="xs" />
  497. </ActionsButtonTrigger>
  498. )}
  499. />
  500. </ActionsContainer>
  501. );
  502. }
  503. const ActionsButtonTrigger = styled(Button)`
  504. svg {
  505. margin-left: ${space(0.5)};
  506. width: 10px;
  507. height: 10px;
  508. }
  509. `;
  510. const ActionsContainer = styled('div')`
  511. display: flex;
  512. justify-content: end;
  513. align-items: center;
  514. gap: ${space(1)};
  515. `;
  516. function EventTags({projectSlug, event}: {event: Event; projectSlug: string}) {
  517. return (
  518. <LazyRender {...TraceDrawerComponents.LAZY_RENDER_PROPS} containerHeight={200}>
  519. <TagsWrapper>
  520. <EventTagsDataSection event={event} projectSlug={projectSlug} />
  521. </TagsWrapper>
  522. </LazyRender>
  523. );
  524. }
  525. const TagsWrapper = styled('div')`
  526. h3 {
  527. color: ${p => p.theme.textColor};
  528. }
  529. `;
  530. export type SectionCardKeyValueList = KeyValueListData;
  531. function SectionCard({
  532. items,
  533. title,
  534. disableTruncate,
  535. sortAlphabetically = false,
  536. itemProps = {},
  537. }: {
  538. items: SectionCardKeyValueList;
  539. title: React.ReactNode;
  540. disableTruncate?: boolean;
  541. itemProps?: Partial<KeyValueDataContentProps>;
  542. sortAlphabetically?: boolean;
  543. }) {
  544. const contentItems = items.map(item => ({item, ...itemProps}));
  545. return (
  546. <CardWrapper>
  547. <KeyValueData.Card
  548. title={title}
  549. contentItems={contentItems}
  550. sortAlphabetically={sortAlphabetically}
  551. truncateLength={disableTruncate ? Infinity : 5}
  552. />
  553. </CardWrapper>
  554. );
  555. }
  556. // This is trace-view specific styling. The card is rendered in a number of different places
  557. // with tests failing otherwise, since @container queries are not supported by the version of
  558. // jsdom currently used by jest.
  559. const CardWrapper = styled('div')`
  560. ${CardPanel} {
  561. container-type: inline-size;
  562. }
  563. ${Subject} {
  564. @container (width < 350px) {
  565. max-width: 200px;
  566. }
  567. }
  568. `;
  569. function SectionCardGroup({children}: {children: React.ReactNode}) {
  570. return <KeyValueData.Container>{children}</KeyValueData.Container>;
  571. }
  572. function CopyableCardValueWithLink({
  573. value,
  574. linkTarget,
  575. linkText,
  576. onClick,
  577. }: {
  578. value: React.ReactNode;
  579. linkTarget?: LocationDescriptor;
  580. linkText?: string;
  581. onClick?: () => void;
  582. }) {
  583. return (
  584. <CardValueContainer>
  585. <CardValueText>
  586. {value}
  587. {typeof value === 'string' ? (
  588. <StyledCopyToClipboardButton
  589. borderless
  590. size="zero"
  591. iconSize="xs"
  592. text={value}
  593. />
  594. ) : null}
  595. </CardValueText>
  596. {linkTarget && linkTarget ? (
  597. <Link to={linkTarget} onClick={onClick}>
  598. {linkText}
  599. </Link>
  600. ) : null}
  601. </CardValueContainer>
  602. );
  603. }
  604. function TraceDataSection({event}: {event: EventTransaction}) {
  605. const traceData = event.contexts.trace?.data;
  606. if (!traceData) {
  607. return null;
  608. }
  609. return (
  610. <SectionCard
  611. items={Object.entries(traceData).map(([key, value]) => ({
  612. key,
  613. subject: key,
  614. value,
  615. }))}
  616. title={t('Trace Data')}
  617. />
  618. );
  619. }
  620. const StyledCopyToClipboardButton = styled(CopyToClipboardButton)`
  621. transform: translateY(2px);
  622. `;
  623. const CardValueContainer = styled(FlexBox)`
  624. justify-content: space-between;
  625. gap: ${space(1)};
  626. flex-wrap: wrap;
  627. `;
  628. const CardValueText = styled('span')`
  629. overflow-wrap: anywhere;
  630. `;
  631. export const CardContentSubject = styled('div')`
  632. grid-column: span 1;
  633. font-family: ${p => p.theme.text.familyMono};
  634. word-wrap: break-word;
  635. `;
  636. const TraceDrawerComponents = {
  637. DetailContainer,
  638. FlexBox,
  639. Title: TitleWithTestId,
  640. Type,
  641. TitleOp,
  642. HeaderContainer,
  643. Actions,
  644. NodeActions,
  645. Table,
  646. IconTitleWrapper,
  647. IconBorder,
  648. TitleText,
  649. Duration,
  650. TableRow,
  651. LAZY_RENDER_PROPS,
  652. TableRowButtonContainer,
  653. TableValueRow,
  654. IssuesLink,
  655. SectionCard,
  656. CopyableCardValueWithLink,
  657. EventTags,
  658. TraceDataSection,
  659. SectionCardGroup,
  660. DropdownMenuWithPortal,
  661. };
  662. export {TraceDrawerComponents};