metricSamplesTable.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757
  1. import {Fragment, useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LocationDescriptorObject} from 'history';
  4. import debounce from 'lodash/debounce';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
  7. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  8. import GridEditable, {
  9. COL_WIDTH_UNDEFINED,
  10. type GridColumnOrder,
  11. } from 'sentry/components/gridEditable';
  12. import SortLink from 'sentry/components/gridEditable/sortLink';
  13. import {Hovercard} from 'sentry/components/hovercard';
  14. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  15. import Link from 'sentry/components/links/link';
  16. import PerformanceDuration from 'sentry/components/performanceDuration';
  17. import {Flex} from 'sentry/components/profiling/flex';
  18. import SmartSearchBar from 'sentry/components/smartSearchBar';
  19. import {Tooltip} from 'sentry/components/tooltip';
  20. import {IconProfiling} from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import type {DateString, MRI, PageFilters, ParsedMRI} from 'sentry/types';
  24. import {defined} from 'sentry/utils';
  25. import {trackAnalytics} from 'sentry/utils/analytics';
  26. import {Container, FieldDateTime, NumberContainer} from 'sentry/utils/discover/styles';
  27. import {getShortEventId} from 'sentry/utils/events';
  28. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  29. import {parseMRI} from 'sentry/utils/metrics/mri';
  30. import {
  31. type Field as SelectedField,
  32. getSummaryValueForOp,
  33. type MetricsSamplesResults,
  34. type ResultField,
  35. type Summary,
  36. useMetricsSamples,
  37. } from 'sentry/utils/metrics/useMetricsSamples';
  38. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  39. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  40. import Projects from 'sentry/utils/projects';
  41. import {decodeScalar} from 'sentry/utils/queryString';
  42. import {useLocation} from 'sentry/utils/useLocation';
  43. import useOrganization from 'sentry/utils/useOrganization';
  44. import usePageFilters from 'sentry/utils/usePageFilters';
  45. import useProjects from 'sentry/utils/useProjects';
  46. import type {SelectionRange} from 'sentry/views/metrics/chart/types';
  47. import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
  48. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  49. import ColorBar from 'sentry/views/performance/vitalDetail/colorBar';
  50. const fields: SelectedField[] = [
  51. 'project',
  52. 'id',
  53. 'span.op',
  54. 'span.description',
  55. 'span.duration',
  56. 'span.self_time',
  57. 'timestamp',
  58. 'trace',
  59. 'transaction',
  60. 'transaction.id',
  61. 'profile.id',
  62. ];
  63. export type Field = (typeof fields)[number];
  64. interface MetricsSamplesTableProps {
  65. focusArea?: SelectionRange;
  66. mri?: MRI;
  67. onRowHover?: (sampleId?: string) => void;
  68. op?: string;
  69. query?: string;
  70. setMetricsSamples?: React.Dispatch<
  71. React.SetStateAction<MetricsSamplesResults<Field>['data'] | undefined>
  72. >;
  73. sortKey?: string;
  74. }
  75. export function SearchableMetricSamplesTable({
  76. mri,
  77. query: primaryQuery,
  78. ...props
  79. }: MetricsSamplesTableProps) {
  80. const [secondaryQuery, setSecondaryQuery] = useState('');
  81. const handleSearch = useCallback(value => {
  82. setSecondaryQuery(value);
  83. }, []);
  84. const query = useMemo(() => {
  85. if (!secondaryQuery) {
  86. return primaryQuery;
  87. }
  88. return `${primaryQuery} ${secondaryQuery}`;
  89. }, [primaryQuery, secondaryQuery]);
  90. return (
  91. <Fragment>
  92. <MetricsSamplesSearchBar
  93. mri={mri}
  94. query={secondaryQuery}
  95. handleSearch={handleSearch}
  96. />
  97. <MetricSamplesTable mri={mri} query={query} {...props} />
  98. </Fragment>
  99. );
  100. }
  101. interface MetricsSamplesSearchBarProps {
  102. handleSearch: (string) => void;
  103. query: string;
  104. mri?: MRI;
  105. }
  106. export function MetricsSamplesSearchBar({
  107. handleSearch,
  108. mri,
  109. query,
  110. }: MetricsSamplesSearchBarProps) {
  111. const parsedMRI = useMemo(() => {
  112. if (!defined(mri)) {
  113. return null;
  114. }
  115. return parseMRI(mri);
  116. }, [mri]);
  117. const enabled = useMemo(() => {
  118. return parsedMRI?.useCase === 'transactions' || parsedMRI?.useCase === 'spans';
  119. }, [parsedMRI]);
  120. return (
  121. <SearchBar
  122. disabled={!enabled}
  123. query={query}
  124. onSearch={handleSearch}
  125. placeholder={
  126. enabled ? t('Filter by span tags') : t('Search not available for this metric')
  127. }
  128. />
  129. );
  130. }
  131. export function MetricSamplesTable({
  132. focusArea,
  133. mri,
  134. onRowHover,
  135. op,
  136. query,
  137. setMetricsSamples,
  138. sortKey = 'sort',
  139. }: MetricsSamplesTableProps) {
  140. const location = useLocation();
  141. const enabled = defined(mri);
  142. const parsedMRI = useMemo(() => {
  143. if (!defined(mri)) {
  144. return null;
  145. }
  146. return parseMRI(mri);
  147. }, [mri]);
  148. const datetime = useMemo(() => {
  149. if (!defined(focusArea) || !defined(focusArea.start) || !defined(focusArea.end)) {
  150. return undefined;
  151. }
  152. return {
  153. start: focusArea.start,
  154. end: focusArea.end,
  155. } as PageFilters['datetime'];
  156. }, [focusArea]);
  157. const currentSort = useMemo(() => {
  158. const value = decodeScalar(location.query[sortKey], '');
  159. if (!value) {
  160. return undefined;
  161. }
  162. const direction: 'asc' | 'desc' = value[0] === '-' ? 'desc' : 'asc';
  163. const key = direction === 'asc' ? value : value.substring(1);
  164. if (ALWAYS_SORTABLE_COLUMNS.has(key as ResultField)) {
  165. return {key, direction};
  166. }
  167. if (OPTIONALLY_SORTABLE_COLUMNS.has(key as ResultField)) {
  168. const column = getColumnForMRI(parsedMRI);
  169. if (column.key === key) {
  170. return {key, direction};
  171. }
  172. }
  173. return undefined;
  174. }, [location.query, parsedMRI, sortKey]);
  175. const sortQuery = useMemo(() => {
  176. if (!defined(currentSort)) {
  177. return undefined;
  178. }
  179. const direction = currentSort.direction === 'asc' ? '' : '-';
  180. return `${direction}${currentSort.key}`;
  181. }, [currentSort]);
  182. const result = useMetricsSamples({
  183. fields,
  184. datetime,
  185. max: focusArea?.max,
  186. min: focusArea?.min,
  187. mri,
  188. op,
  189. query,
  190. referrer: 'api.organization.metrics-samples',
  191. enabled,
  192. sort: sortQuery,
  193. limit: 20,
  194. });
  195. // propagate the metrics samples up as needed
  196. useEffect(() => {
  197. setMetricsSamples?.(result.data?.data ?? []);
  198. }, [result?.data?.data, setMetricsSamples]);
  199. const supportedMRI = useMemo(() => {
  200. const responseJSON = result.error?.responseJSON;
  201. if (typeof responseJSON?.detail !== 'string') {
  202. return true;
  203. }
  204. return !responseJSON?.detail?.startsWith('Unsupported MRI: ');
  205. }, [result]);
  206. const emptyMessage = useMemo(() => {
  207. if (!defined(mri)) {
  208. return (
  209. <EmptyStateWarning>
  210. <p>{t('Choose a metric to display samples')}</p>
  211. </EmptyStateWarning>
  212. );
  213. }
  214. return null;
  215. }, [mri]);
  216. const _renderPrependColumn = useMemo(() => {
  217. return renderPrependColumn();
  218. }, []);
  219. const _renderHeadCell = useMemo(() => {
  220. const generateSortLink = (key: string) => () => {
  221. if (!SORTABLE_COLUMNS.has(key as ResultField)) {
  222. return undefined;
  223. }
  224. let sort: string | undefined = undefined;
  225. if (defined(currentSort) && currentSort.key === key) {
  226. if (currentSort.direction === 'desc') {
  227. sort = key;
  228. }
  229. } else {
  230. sort = `-${key}`;
  231. }
  232. return {
  233. ...location,
  234. query: {
  235. ...location.query,
  236. sort,
  237. },
  238. };
  239. };
  240. return renderHeadCell(currentSort, generateSortLink);
  241. }, [currentSort, location]);
  242. const _renderBodyCell = useMemo(
  243. () => renderBodyCell(op, parsedMRI?.unit),
  244. [op, parsedMRI?.unit]
  245. );
  246. const wrapperRef = useRef<HTMLDivElement>(null);
  247. const handleMouseMove = useMemo(
  248. () =>
  249. debounce((event: React.MouseEvent) => {
  250. const wrapper = wrapperRef.current;
  251. const target = event.target;
  252. if (!wrapper || !(target instanceof Element)) {
  253. onRowHover?.(undefined);
  254. return;
  255. }
  256. const tableRow = (target as Element).closest('tbody >tr');
  257. if (!tableRow) {
  258. onRowHover?.(undefined);
  259. return;
  260. }
  261. const rows = Array.from(wrapper.querySelectorAll('tbody > tr'));
  262. const rowIndex = rows.indexOf(tableRow);
  263. const rowId = result.data?.data?.[rowIndex]?.id;
  264. if (!rowId) {
  265. onRowHover?.(undefined);
  266. return;
  267. }
  268. onRowHover?.(rowId);
  269. }, 10),
  270. [onRowHover, result.data?.data]
  271. );
  272. return (
  273. <div
  274. ref={wrapperRef}
  275. onMouseMove={handleMouseMove}
  276. onMouseLeave={() => onRowHover?.(undefined)}
  277. >
  278. <GridEditable
  279. isLoading={enabled && result.isLoading}
  280. error={enabled && result.isError && supportedMRI}
  281. data={result.data?.data ?? []}
  282. columnOrder={getColumnOrder(parsedMRI)}
  283. columnSortBy={[]}
  284. grid={{
  285. prependColumnWidths,
  286. renderPrependColumns: _renderPrependColumn,
  287. renderBodyCell: _renderBodyCell,
  288. renderHeadCell: _renderHeadCell,
  289. }}
  290. location={location}
  291. emptyMessage={emptyMessage}
  292. minimumColWidth={60}
  293. />
  294. </div>
  295. );
  296. }
  297. function getColumnForMRI(parsedMRI?: ParsedMRI | null): GridColumnOrder<ResultField> {
  298. return parsedMRI?.useCase === 'spans' && parsedMRI?.name === 'span.self_time'
  299. ? {key: 'span.self_time', width: COL_WIDTH_UNDEFINED, name: 'Self Time'}
  300. : parsedMRI?.useCase === 'transactions' && parsedMRI?.name === 'transaction.duration'
  301. ? {key: 'span.duration', width: COL_WIDTH_UNDEFINED, name: 'Duration'}
  302. : {key: 'summary', width: COL_WIDTH_UNDEFINED, name: parsedMRI?.name ?? 'Summary'};
  303. }
  304. function getColumnOrder(parsedMRI?: ParsedMRI | null): GridColumnOrder<ResultField>[] {
  305. const orders: (GridColumnOrder<ResultField> | undefined)[] = [
  306. {key: 'span.description', width: COL_WIDTH_UNDEFINED, name: 'Description'},
  307. {key: 'span.op', width: COL_WIDTH_UNDEFINED, name: 'Operation'},
  308. getColumnForMRI(parsedMRI),
  309. {key: 'timestamp', width: COL_WIDTH_UNDEFINED, name: 'Timestamp'},
  310. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  311. ];
  312. return orders.filter(
  313. (
  314. order: GridColumnOrder<ResultField> | undefined
  315. ): order is GridColumnOrder<ResultField> => !!order
  316. );
  317. }
  318. const RIGHT_ALIGNED_COLUMNS = new Set<ResultField>([
  319. 'span.duration',
  320. 'span.self_time',
  321. 'summary',
  322. ]);
  323. const ALWAYS_SORTABLE_COLUMNS = new Set<ResultField>(['timestamp']);
  324. const OPTIONALLY_SORTABLE_COLUMNS = new Set<ResultField>([
  325. 'summary',
  326. 'span.self_time',
  327. 'span.duration',
  328. ]);
  329. const SORTABLE_COLUMNS: Set<ResultField> = new Set([
  330. ...ALWAYS_SORTABLE_COLUMNS,
  331. ...OPTIONALLY_SORTABLE_COLUMNS,
  332. ]);
  333. const prependColumnWidths = ['40px'];
  334. function renderPrependColumn() {
  335. return function (
  336. isHeader: boolean,
  337. dataRow?: MetricsSamplesResults<SelectedField>['data'][number],
  338. _rowIndex?: number
  339. ) {
  340. if (isHeader) {
  341. return [null];
  342. }
  343. return [dataRow ? <ProjectRenderer projectSlug={dataRow.project} /> : null];
  344. };
  345. }
  346. function renderHeadCell(
  347. currentSort: {direction: 'asc' | 'desc'; key: string} | undefined,
  348. generateSortLink: (key) => () => LocationDescriptorObject | undefined
  349. ) {
  350. return function (col: GridColumnOrder<ResultField>) {
  351. return (
  352. <SortLink
  353. align={RIGHT_ALIGNED_COLUMNS.has(col.key) ? 'right' : 'left'}
  354. canSort={SORTABLE_COLUMNS.has(col.key)}
  355. direction={col.key === currentSort?.key ? currentSort?.direction : undefined}
  356. generateSortLink={generateSortLink(col.key)}
  357. title={col.name}
  358. />
  359. );
  360. };
  361. }
  362. function renderBodyCell(op?: string, unit?: string) {
  363. return function (
  364. col: GridColumnOrder<ResultField>,
  365. dataRow: MetricsSamplesResults<SelectedField>['data'][number]
  366. ) {
  367. if (col.key === 'span.description') {
  368. return (
  369. <SpanDescription
  370. description={dataRow['span.description']}
  371. project={dataRow.project}
  372. selfTime={dataRow['span.self_time']}
  373. duration={dataRow['span.duration']}
  374. spanId={dataRow.id}
  375. transaction={dataRow.transaction}
  376. transactionId={dataRow['transaction.id']}
  377. />
  378. );
  379. }
  380. if (col.key === 'span.self_time' || col.key === 'span.duration') {
  381. return <DurationRenderer duration={dataRow[col.key]} />;
  382. }
  383. if (col.key === 'summary') {
  384. return <SummaryRenderer summary={dataRow.summary} op={op} unit={unit} />;
  385. }
  386. if (col.key === 'timestamp') {
  387. return <TimestampRenderer timestamp={dataRow.timestamp} />;
  388. }
  389. if (col.key === 'trace') {
  390. return (
  391. <TraceId
  392. traceId={dataRow.trace}
  393. timestamp={dataRow.timestamp}
  394. eventId={dataRow.id}
  395. />
  396. );
  397. }
  398. if (col.key === 'profile.id') {
  399. return (
  400. <ProfileId projectSlug={dataRow.project} profileId={dataRow['profile.id']} />
  401. );
  402. }
  403. return <Container>{dataRow[col.key]}</Container>;
  404. };
  405. }
  406. function ProjectRenderer({projectSlug}: {projectSlug: string}) {
  407. const organization = useOrganization();
  408. return (
  409. <Container>
  410. <Projects orgId={organization.slug} slugs={[projectSlug]}>
  411. {({projects}) => {
  412. const project = projects.find(p => p.slug === projectSlug);
  413. return (
  414. <ProjectBadge
  415. project={project ? project : {slug: projectSlug}}
  416. avatarSize={16}
  417. hideName
  418. />
  419. );
  420. }}
  421. </Projects>
  422. </Container>
  423. );
  424. }
  425. function SpanDescription({
  426. description,
  427. duration,
  428. project,
  429. selfTime,
  430. spanId,
  431. transaction,
  432. transactionId,
  433. selfTimeColor = '#694D99',
  434. durationColor = 'gray100',
  435. }: {
  436. description: string;
  437. duration: number;
  438. project: string;
  439. selfTime: number;
  440. spanId: string;
  441. transaction: string;
  442. transactionId: string | null;
  443. durationColor?: string;
  444. selfTimeColor?: string;
  445. }) {
  446. const location = useLocation();
  447. const organization = useOrganization();
  448. const {projects} = useProjects({slugs: [project]});
  449. const transactionDetailsTarget = defined(transactionId)
  450. ? getTransactionDetailsUrl(
  451. organization.slug,
  452. `${project}:${transactionId}`,
  453. undefined,
  454. undefined,
  455. spanId
  456. )
  457. : undefined;
  458. const colorStops = useMemo(() => {
  459. const percentage = selfTime / duration;
  460. return [
  461. {color: selfTimeColor, percent: percentage},
  462. {color: durationColor, percent: 1 - percentage},
  463. ];
  464. }, [duration, selfTime, durationColor, selfTimeColor]);
  465. const transactionSummaryTarget = transactionSummaryRouteWithQuery({
  466. orgSlug: organization.slug,
  467. transaction,
  468. query: {
  469. ...location.query,
  470. query: undefined,
  471. },
  472. projectID: String(projects[0]?.id ?? ''),
  473. });
  474. let contents = description ? (
  475. <Fragment>{description}</Fragment>
  476. ) : (
  477. <EmptyValueContainer>{t('(no value)')}</EmptyValueContainer>
  478. );
  479. if (defined(transactionDetailsTarget)) {
  480. contents = <Link to={transactionDetailsTarget}>{contents}</Link>;
  481. }
  482. return (
  483. <Container>
  484. <StyledHovercard
  485. header={
  486. <Flex justify="space-between" align="center">
  487. {t('Span ID')}
  488. <SpanIdWrapper>
  489. {getShortEventId(spanId)}
  490. <CopyToClipboardButton borderless iconSize="xs" size="zero" text={spanId} />
  491. </SpanIdWrapper>
  492. </Flex>
  493. }
  494. body={
  495. <Flex gap={space(0.75)} column>
  496. <SectionTitle>{t('Duration')}</SectionTitle>
  497. <ColorBar colorStops={colorStops} />
  498. <Flex justify="space-between" align="center">
  499. <Flex justify="space-between" align="center" gap={space(0.5)}>
  500. <LegendDot color={selfTimeColor} />
  501. {t('Self Time: ')}
  502. <PerformanceDuration milliseconds={selfTime} abbreviation />
  503. </Flex>
  504. <Flex justify="space-between" align="center" gap={space(0.5)}>
  505. <LegendDot color={durationColor} />
  506. {t('Duration: ')}
  507. <PerformanceDuration milliseconds={duration} abbreviation />
  508. </Flex>
  509. </Flex>
  510. <SectionTitle>{t('Transaction')}</SectionTitle>
  511. <Tooltip containerDisplayMode="inline" showOnlyOnOverflow title={transaction}>
  512. <Link
  513. to={transactionSummaryTarget}
  514. onClick={() =>
  515. trackAnalytics('ddm.sample-table-interaction', {
  516. organization,
  517. target: 'description',
  518. })
  519. }
  520. >
  521. <TextOverflow>{transaction}</TextOverflow>
  522. </Link>
  523. </Tooltip>
  524. </Flex>
  525. }
  526. showUnderline
  527. >
  528. {contents}
  529. </StyledHovercard>
  530. </Container>
  531. );
  532. }
  533. function DurationRenderer({duration}: {duration: number}) {
  534. return (
  535. <NumberContainer>
  536. <PerformanceDuration milliseconds={duration} abbreviation />
  537. </NumberContainer>
  538. );
  539. }
  540. function SummaryRenderer({
  541. summary,
  542. op,
  543. unit,
  544. }: {
  545. summary: Summary;
  546. op?: string;
  547. unit?: string;
  548. }) {
  549. const value = getSummaryValueForOp(summary, op);
  550. // if the op is `count`, then the unit does not apply
  551. unit = op === 'count' ? '' : unit;
  552. return (
  553. <NumberContainer>{formatMetricUsingUnit(value ?? null, unit ?? '')}</NumberContainer>
  554. );
  555. }
  556. function TimestampRenderer({timestamp}: {timestamp: DateString}) {
  557. const location = useLocation();
  558. return (
  559. <FieldDateTime
  560. date={timestamp}
  561. year
  562. seconds
  563. timeZone
  564. utc={decodeScalar(location?.query?.utc) === 'true'}
  565. />
  566. );
  567. }
  568. function TraceId({
  569. traceId,
  570. timestamp,
  571. eventId,
  572. }: {
  573. traceId: string;
  574. eventId?: string;
  575. timestamp?: DateString;
  576. }) {
  577. const organization = useOrganization();
  578. const {selection} = usePageFilters();
  579. const stringOrNumberTimestamp =
  580. timestamp instanceof Date ? timestamp.toISOString() : timestamp ?? '';
  581. const target = getTraceDetailsUrl(
  582. organization,
  583. traceId,
  584. {
  585. start: selection.datetime.start,
  586. end: selection.datetime.end,
  587. statsPeriod: selection.datetime.period,
  588. },
  589. {},
  590. stringOrNumberTimestamp,
  591. eventId
  592. );
  593. return (
  594. <Container>
  595. <Link
  596. to={target}
  597. onClick={() =>
  598. trackAnalytics('ddm.sample-table-interaction', {
  599. organization,
  600. target: 'trace-id',
  601. })
  602. }
  603. >
  604. {getShortEventId(traceId)}
  605. </Link>
  606. </Container>
  607. );
  608. }
  609. function ProfileId({
  610. profileId,
  611. projectSlug,
  612. }: {
  613. profileId: string | null;
  614. projectSlug: string;
  615. }) {
  616. const organization = useOrganization();
  617. if (!defined(profileId)) {
  618. return (
  619. <Container>
  620. <Button href={undefined} disabled size="xs">
  621. <IconProfiling size="xs" />
  622. </Button>
  623. </Container>
  624. );
  625. }
  626. const target = generateProfileFlamechartRoute({
  627. orgSlug: organization.slug,
  628. projectSlug,
  629. profileId,
  630. });
  631. return (
  632. <Container>
  633. <LinkButton
  634. to={target}
  635. size="xs"
  636. onClick={() =>
  637. trackAnalytics('ddm.sample-table-interaction', {
  638. organization,
  639. target: 'profile',
  640. })
  641. }
  642. >
  643. <IconProfiling size="xs" />
  644. </LinkButton>
  645. </Container>
  646. );
  647. }
  648. const SearchBar = styled(SmartSearchBar)`
  649. margin-bottom: ${space(2)};
  650. `;
  651. const StyledHovercard = styled(Hovercard)`
  652. width: 350px;
  653. `;
  654. const SpanIdWrapper = styled('span')`
  655. font-weight: 400;
  656. `;
  657. const SectionTitle = styled('h6')`
  658. color: ${p => p.theme.subText};
  659. margin-bottom: 0;
  660. `;
  661. const TextOverflow = styled('span')`
  662. ${p => p.theme.overflowEllipsis};
  663. `;
  664. const LegendDot = styled('div')<{color: string}>`
  665. display: block;
  666. width: ${space(1)};
  667. height: ${space(1)};
  668. border-radius: 100%;
  669. background-color: ${p => p.theme[p.color] ?? p.color};
  670. `;
  671. const EmptyValueContainer = styled('span')`
  672. color: ${p => p.theme.gray300};
  673. `;