httpSamplesPanel.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. import {Fragment, useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import Feature from 'sentry/components/acl/feature';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import {Button} from 'sentry/components/button';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import Link from 'sentry/components/links/link';
  10. import {SegmentedControl} from 'sentry/components/segmentedControl';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {trackAnalytics} from 'sentry/utils/analytics';
  14. import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields';
  15. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  16. import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  17. import {decodeScalar} from 'sentry/utils/queryString';
  18. import {
  19. EMPTY_OPTION_VALUE,
  20. escapeFilterValue,
  21. MutableSearch,
  22. } from 'sentry/utils/tokenizeSearch';
  23. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  24. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import usePageFilters from 'sentry/utils/usePageFilters';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import useRouter from 'sentry/utils/useRouter';
  30. import {computeAxisMax} from 'sentry/views/insights/common/components/chart';
  31. import DetailPanel from 'sentry/views/insights/common/components/detailPanel';
  32. import {MetricReadout} from 'sentry/views/insights/common/components/metricReadout';
  33. import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
  34. import {getTimeSpentExplanation} from 'sentry/views/insights/common/components/tableCells/timeSpentCell';
  35. import {
  36. useSpanMetrics,
  37. useSpansIndexed,
  38. } from 'sentry/views/insights/common/queries/useDiscover';
  39. import {useSpanMetricsSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
  40. import {useSpanMetricsTopNSeries} from 'sentry/views/insights/common/queries/useSpanMetricsTopNSeries';
  41. import {AverageValueMarkLine} from 'sentry/views/insights/common/utils/averageValueMarkLine';
  42. import {findSampleFromDataPoint} from 'sentry/views/insights/common/utils/findDataPoint';
  43. import {
  44. DataTitles,
  45. getThroughputTitle,
  46. } from 'sentry/views/insights/common/views/spans/types';
  47. import {useSampleScatterPlotSeries} from 'sentry/views/insights/common/views/spanSummaryPage/sampleList/durationChart/useSampleScatterPlotSeries';
  48. import {DurationChart} from 'sentry/views/insights/http/components/charts/durationChart';
  49. import {ResponseCodeCountChart} from 'sentry/views/insights/http/components/charts/responseCodeCountChart';
  50. import {SpanSamplesTable} from 'sentry/views/insights/http/components/tables/spanSamplesTable';
  51. import {HTTP_RESPONSE_STATUS_CODES} from 'sentry/views/insights/http/data/definitions';
  52. import {useSpanSamples} from 'sentry/views/insights/http/queries/useSpanSamples';
  53. import {Referrer} from 'sentry/views/insights/http/referrers';
  54. import {BASE_FILTERS} from 'sentry/views/insights/http/settings';
  55. import decodePanel from 'sentry/views/insights/http/utils/queryParameterDecoders/panel';
  56. import decodeResponseCodeClass from 'sentry/views/insights/http/utils/queryParameterDecoders/responseCodeClass';
  57. import {useDebouncedState} from 'sentry/views/insights/http/utils/useDebouncedState';
  58. import {
  59. ModuleName,
  60. SpanFunction,
  61. SpanIndexedField,
  62. SpanMetricsField,
  63. type SpanMetricsQueryFilters,
  64. } from 'sentry/views/insights/types';
  65. import {TraceViewSources} from 'sentry/views/performance/newTraceDetails/traceMetadataHeader';
  66. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  67. export function HTTPSamplesPanel() {
  68. const router = useRouter();
  69. const location = useLocation();
  70. const query = useLocationQuery({
  71. fields: {
  72. project: decodeScalar,
  73. domain: decodeScalar,
  74. transaction: decodeScalar,
  75. transactionMethod: decodeScalar,
  76. panel: decodePanel,
  77. responseCodeClass: decodeResponseCodeClass,
  78. spanSearchQuery: decodeScalar,
  79. },
  80. });
  81. const organization = useOrganization();
  82. const {projects} = useProjects();
  83. const {selection} = usePageFilters();
  84. const supportedTags = useSpanFieldSupportedTags();
  85. const project = projects.find(p => query.project === p.id);
  86. const [highlightedSpanId, setHighlightedSpanId] = useDebouncedState<string | undefined>(
  87. undefined,
  88. [],
  89. SAMPLE_HOVER_DEBOUNCE
  90. );
  91. // `detailKey` controls whether the panel is open. If all required properties are available, concat them to make a key, otherwise set to `undefined` and hide the panel
  92. const detailKey = query.transaction
  93. ? [query.domain, query.transactionMethod, query.transaction].filter(Boolean).join(':')
  94. : undefined;
  95. const handlePanelChange = newPanelName => {
  96. trackAnalytics('performance_views.sample_spans.filter_updated', {
  97. filter: 'panel',
  98. new_state: newPanelName,
  99. organization,
  100. source: ModuleName.HTTP,
  101. });
  102. router.replace({
  103. pathname: location.pathname,
  104. query: {
  105. ...location.query,
  106. panel: newPanelName,
  107. },
  108. });
  109. };
  110. const handleResponseCodeClassChange = newResponseCodeClass => {
  111. trackAnalytics('performance_views.sample_spans.filter_updated', {
  112. filter: 'status_code',
  113. new_state: newResponseCodeClass.value,
  114. organization,
  115. source: ModuleName.HTTP,
  116. });
  117. router.replace({
  118. pathname: location.pathname,
  119. query: {
  120. ...location.query,
  121. responseCodeClass: newResponseCodeClass.value,
  122. },
  123. });
  124. };
  125. const isPanelOpen = Boolean(detailKey);
  126. // The ribbon is above the data selectors, and not affected by them. So, it has its own filters.
  127. const ribbonFilters: SpanMetricsQueryFilters = {
  128. ...BASE_FILTERS,
  129. 'span.domain':
  130. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  131. transaction: query.transaction,
  132. };
  133. // These filters are for the charts and samples tables
  134. const filters: SpanMetricsQueryFilters = {
  135. ...BASE_FILTERS,
  136. 'span.domain':
  137. query.domain === '' ? EMPTY_OPTION_VALUE : escapeFilterValue(query.domain),
  138. transaction: query.transaction,
  139. };
  140. const responseCodeInRange = query.responseCodeClass
  141. ? Object.keys(HTTP_RESPONSE_STATUS_CODES).filter(code =>
  142. code.startsWith(query.responseCodeClass)
  143. )
  144. : [];
  145. if (responseCodeInRange.length > 0) {
  146. // TODO: Allow automatic array parameter concatenation
  147. filters['span.status_code'] = `[${responseCodeInRange.join(',')}]`;
  148. }
  149. const search = MutableSearch.fromQueryObject(filters);
  150. const {
  151. data: domainTransactionMetrics,
  152. isFetching: areDomainTransactionMetricsFetching,
  153. } = useSpanMetrics(
  154. {
  155. search: MutableSearch.fromQueryObject(ribbonFilters),
  156. fields: [
  157. `${SpanFunction.SPM}()`,
  158. `avg(${SpanMetricsField.SPAN_SELF_TIME})`,
  159. `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
  160. 'http_response_rate(3)',
  161. 'http_response_rate(4)',
  162. 'http_response_rate(5)',
  163. `${SpanFunction.TIME_SPENT_PERCENTAGE}()`,
  164. ],
  165. enabled: isPanelOpen,
  166. },
  167. Referrer.SAMPLES_PANEL_METRICS_RIBBON
  168. );
  169. const {
  170. isFetching: isDurationDataFetching,
  171. data: durationData,
  172. error: durationError,
  173. } = useSpanMetricsSeries(
  174. {
  175. search,
  176. yAxis: [`avg(span.self_time)`],
  177. enabled: isPanelOpen && query.panel === 'duration',
  178. },
  179. Referrer.SAMPLES_PANEL_DURATION_CHART
  180. );
  181. const {
  182. isFetching: isResponseCodeDataLoading,
  183. data: responseCodeData,
  184. error: responseCodeError,
  185. } = useSpanMetricsTopNSeries({
  186. search,
  187. fields: ['span.status_code', 'count()'],
  188. yAxis: ['count()'],
  189. topEvents: 5,
  190. sorts: [
  191. {
  192. kind: 'desc',
  193. field: 'count()',
  194. },
  195. ],
  196. enabled: isPanelOpen && query.panel === 'status',
  197. referrer: Referrer.SAMPLES_PANEL_RESPONSE_CODE_CHART,
  198. });
  199. // NOTE: Due to some data confusion, the `domain` column in the spans table can either be `null` or `""`. Searches like `"!has:span.domain"` are turned into the ClickHouse clause `isNull(domain)`, and do not match the empty string. We need a query that matches empty strings _and_ null_ which is `(!has:domain OR domain:[""])`. This hack can be removed in August 2024, once https://github.com/getsentry/snuba/pull/5780 has been deployed for 90 days and all `""` domains have fallen out of the data retention window. Also, `null` domains will become more rare as people upgrade the JS SDK to versions that populate the `server.address` span attribute
  200. const sampleSpansSearch = MutableSearch.fromQueryObject({
  201. ...filters,
  202. 'span.domain': undefined,
  203. });
  204. // filter by key-value filters specified in the search bar query
  205. sampleSpansSearch.addStringMultiFilter(query.spanSearchQuery);
  206. if (query.domain === '') {
  207. sampleSpansSearch.addOp('(');
  208. sampleSpansSearch.addFilterValue('!has', 'span.domain');
  209. sampleSpansSearch.addOp('OR');
  210. // HACK: Use `addOp` to add the condition `'span.domain:[""]'` and avoid escaping the double quotes. Ideally there'd be a way to specify this explicitly, but this whole thing is a hack anyway. Once a plain `!has:span.domain` condition works, this is not necessary
  211. sampleSpansSearch.addOp('span.domain:[""]');
  212. sampleSpansSearch.addOp(')');
  213. } else {
  214. sampleSpansSearch.addFilterValue('span.domain', query.domain);
  215. }
  216. const durationAxisMax = computeAxisMax([durationData?.[`avg(span.self_time)`]]);
  217. const {
  218. data: durationSamplesData,
  219. isFetching: isDurationSamplesDataFetching,
  220. error: durationSamplesDataError,
  221. refetch: refetchDurationSpanSamples,
  222. } = useSpanSamples({
  223. search: sampleSpansSearch,
  224. fields: [
  225. SpanIndexedField.TRACE,
  226. SpanIndexedField.TRANSACTION_ID,
  227. SpanIndexedField.SPAN_DESCRIPTION,
  228. SpanIndexedField.RESPONSE_CODE,
  229. ],
  230. min: 0,
  231. max: durationAxisMax,
  232. enabled: isPanelOpen && query.panel === 'duration' && durationAxisMax > 0,
  233. referrer: Referrer.SAMPLES_PANEL_DURATION_SAMPLES,
  234. });
  235. const {
  236. data: responseCodeSamplesData,
  237. isFetching: isResponseCodeSamplesDataFetching,
  238. error: responseCodeSamplesDataError,
  239. refetch: refetchResponseCodeSpanSamples,
  240. } = useSpansIndexed(
  241. {
  242. search: sampleSpansSearch,
  243. fields: [
  244. SpanIndexedField.PROJECT,
  245. SpanIndexedField.TRACE,
  246. SpanIndexedField.TRANSACTION_ID,
  247. SpanIndexedField.ID,
  248. SpanIndexedField.TIMESTAMP,
  249. SpanIndexedField.SPAN_DESCRIPTION,
  250. SpanIndexedField.RESPONSE_CODE,
  251. ],
  252. sorts: [SPAN_SAMPLES_SORT],
  253. limit: SPAN_SAMPLE_LIMIT,
  254. enabled: isPanelOpen && query.panel === 'status',
  255. },
  256. Referrer.SAMPLES_PANEL_RESPONSE_CODE_SAMPLES
  257. );
  258. const sampledSpanDataSeries = useSampleScatterPlotSeries(
  259. durationSamplesData,
  260. domainTransactionMetrics?.[0]?.['avg(span.self_time)'],
  261. highlightedSpanId
  262. );
  263. const handleSearch = (newSpanSearchQuery: string) => {
  264. router.replace({
  265. pathname: location.pathname,
  266. query: {
  267. ...location.query,
  268. spanSearchQuery: newSpanSearchQuery,
  269. },
  270. });
  271. };
  272. const handleClose = () => {
  273. router.replace({
  274. pathname: router.location.pathname,
  275. query: {
  276. ...router.location.query,
  277. transaction: undefined,
  278. transactionMethod: undefined,
  279. },
  280. });
  281. };
  282. const handleOpen = useCallback(() => {
  283. if (query.transaction) {
  284. trackAnalytics('performance_views.sample_spans.opened', {
  285. organization,
  286. source: ModuleName.HTTP,
  287. });
  288. }
  289. }, [organization, query.transaction]);
  290. return (
  291. <PageAlertProvider>
  292. <DetailPanel detailKey={detailKey} onClose={handleClose} onOpen={handleOpen}>
  293. <ModuleLayout.Layout>
  294. <ModuleLayout.Full>
  295. <HeaderContainer>
  296. {project && (
  297. <SpanSummaryProjectAvatar
  298. project={project}
  299. direction="left"
  300. size={40}
  301. hasTooltip
  302. tooltip={project.slug}
  303. />
  304. )}
  305. <Title>
  306. <Link
  307. to={normalizeUrl(
  308. `/organizations/${organization.slug}/performance/summary?${qs.stringify(
  309. {
  310. project: query.project,
  311. transaction: query.transaction,
  312. }
  313. )}`
  314. )}
  315. >
  316. {query.transaction &&
  317. query.transactionMethod &&
  318. !query.transaction.startsWith(query.transactionMethod)
  319. ? `${query.transactionMethod} ${query.transaction}`
  320. : query.transaction}
  321. </Link>
  322. </Title>
  323. </HeaderContainer>
  324. </ModuleLayout.Full>
  325. <ModuleLayout.Full>
  326. <MetricsRibbon>
  327. <MetricReadout
  328. align="left"
  329. title={getThroughputTitle('http')}
  330. value={domainTransactionMetrics?.[0]?.[`${SpanFunction.SPM}()`]}
  331. unit={RateUnit.PER_MINUTE}
  332. isLoading={areDomainTransactionMetricsFetching}
  333. />
  334. <MetricReadout
  335. align="left"
  336. title={DataTitles.avg}
  337. value={
  338. domainTransactionMetrics?.[0]?.[
  339. `avg(${SpanMetricsField.SPAN_SELF_TIME})`
  340. ]
  341. }
  342. unit={DurationUnit.MILLISECOND}
  343. isLoading={areDomainTransactionMetricsFetching}
  344. />
  345. <MetricReadout
  346. align="left"
  347. title={t('3XXs')}
  348. value={domainTransactionMetrics?.[0]?.[`http_response_rate(3)`]}
  349. unit="percentage"
  350. isLoading={areDomainTransactionMetricsFetching}
  351. />
  352. <MetricReadout
  353. align="left"
  354. title={t('4XXs')}
  355. value={domainTransactionMetrics?.[0]?.[`http_response_rate(4)`]}
  356. unit="percentage"
  357. isLoading={areDomainTransactionMetricsFetching}
  358. />
  359. <MetricReadout
  360. align="left"
  361. title={t('5XXs')}
  362. value={domainTransactionMetrics?.[0]?.[`http_response_rate(5)`]}
  363. unit="percentage"
  364. isLoading={areDomainTransactionMetricsFetching}
  365. />
  366. <MetricReadout
  367. align="left"
  368. title={DataTitles.timeSpent}
  369. value={domainTransactionMetrics?.[0]?.['sum(span.self_time)']}
  370. unit={DurationUnit.MILLISECOND}
  371. tooltip={getTimeSpentExplanation(
  372. domainTransactionMetrics?.[0]?.['time_spent_percentage()'],
  373. 'http.client'
  374. )}
  375. isLoading={areDomainTransactionMetricsFetching}
  376. />
  377. </MetricsRibbon>
  378. </ModuleLayout.Full>
  379. <ModuleLayout.Full>
  380. <PanelControls>
  381. <SegmentedControl
  382. value={query.panel}
  383. onChange={handlePanelChange}
  384. aria-label={t('Choose breakdown type')}
  385. >
  386. <SegmentedControl.Item key="duration">
  387. {t('By Duration')}
  388. </SegmentedControl.Item>
  389. <SegmentedControl.Item key="status">
  390. {t('By Response Code')}
  391. </SegmentedControl.Item>
  392. </SegmentedControl>
  393. <CompactSelect
  394. value={query.responseCodeClass}
  395. options={HTTP_RESPONSE_CODE_CLASS_OPTIONS}
  396. onChange={handleResponseCodeClassChange}
  397. triggerProps={{
  398. prefix: t('Response Code'),
  399. }}
  400. />
  401. </PanelControls>
  402. </ModuleLayout.Full>
  403. {query.panel === 'duration' && (
  404. <Fragment>
  405. <ModuleLayout.Full>
  406. <DurationChart
  407. series={[
  408. {
  409. ...durationData[`avg(span.self_time)`],
  410. markLine: AverageValueMarkLine(),
  411. },
  412. ]}
  413. scatterPlot={sampledSpanDataSeries}
  414. onHighlight={highlights => {
  415. const firstHighlight = highlights[0];
  416. if (!firstHighlight) {
  417. setHighlightedSpanId(undefined);
  418. return;
  419. }
  420. const sample = findSampleFromDataPoint<
  421. (typeof durationSamplesData)[0]
  422. >(
  423. firstHighlight.dataPoint,
  424. durationSamplesData,
  425. SpanIndexedField.SPAN_SELF_TIME
  426. );
  427. setHighlightedSpanId(sample?.span_id);
  428. }}
  429. isLoading={isDurationDataFetching}
  430. error={durationError}
  431. />
  432. </ModuleLayout.Full>
  433. </Fragment>
  434. )}
  435. {query.panel === 'status' && (
  436. <Fragment>
  437. <ModuleLayout.Full>
  438. <ResponseCodeCountChart
  439. series={Object.values(responseCodeData).filter(Boolean)}
  440. isLoading={isResponseCodeDataLoading}
  441. error={responseCodeError}
  442. />
  443. </ModuleLayout.Full>
  444. </Fragment>
  445. )}
  446. <Feature features="performance-sample-panel-search">
  447. <ModuleLayout.Full>
  448. <SearchBar
  449. searchSource={`${ModuleName.HTTP}-sample-panel`}
  450. query={query.spanSearchQuery}
  451. onSearch={handleSearch}
  452. placeholder={t('Search for span attributes')}
  453. organization={organization}
  454. supportedTags={supportedTags}
  455. dataset={DiscoverDatasets.SPANS_INDEXED}
  456. projectIds={selection.projects}
  457. />
  458. </ModuleLayout.Full>
  459. </Feature>
  460. {query.panel === 'duration' && (
  461. <Fragment>
  462. <ModuleLayout.Full>
  463. <SpanSamplesTable
  464. data={durationSamplesData}
  465. isLoading={isDurationDataFetching || isDurationSamplesDataFetching}
  466. highlightedSpanId={highlightedSpanId}
  467. onSampleMouseOver={sample => setHighlightedSpanId(sample.span_id)}
  468. onSampleMouseOut={() => setHighlightedSpanId(undefined)}
  469. error={durationSamplesDataError}
  470. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  471. meta={{
  472. fields: {
  473. 'span.response_code': 'number',
  474. },
  475. units: {},
  476. }}
  477. referrer={TraceViewSources.REQUESTS_MODULE}
  478. />
  479. </ModuleLayout.Full>
  480. <ModuleLayout.Full>
  481. <Button
  482. onClick={() => {
  483. trackAnalytics(
  484. 'performance_views.sample_spans.try_different_samples_clicked',
  485. {organization, source: ModuleName.HTTP}
  486. );
  487. refetchDurationSpanSamples();
  488. }}
  489. >
  490. {t('Try Different Samples')}
  491. </Button>
  492. </ModuleLayout.Full>
  493. </Fragment>
  494. )}
  495. {query.panel === 'status' && (
  496. <Fragment>
  497. <ModuleLayout.Full>
  498. <SpanSamplesTable
  499. data={responseCodeSamplesData ?? []}
  500. isLoading={isResponseCodeSamplesDataFetching}
  501. error={responseCodeSamplesDataError}
  502. // TODO: The samples endpoint doesn't provide its own meta, so we need to create it manually
  503. meta={{
  504. fields: {
  505. 'span.response_code': 'number',
  506. },
  507. units: {},
  508. }}
  509. />
  510. </ModuleLayout.Full>
  511. <ModuleLayout.Full>
  512. <Button
  513. onClick={() => {
  514. trackAnalytics(
  515. 'performance_views.sample_spans.try_different_samples_clicked',
  516. {organization, source: ModuleName.HTTP}
  517. );
  518. refetchResponseCodeSpanSamples();
  519. }}
  520. >
  521. {t('Try Different Samples')}
  522. </Button>
  523. </ModuleLayout.Full>
  524. </Fragment>
  525. )}
  526. </ModuleLayout.Layout>
  527. </DetailPanel>
  528. </PageAlertProvider>
  529. );
  530. }
  531. const SAMPLE_HOVER_DEBOUNCE = 10;
  532. const SPAN_SAMPLE_LIMIT = 10;
  533. // This is functionally a random sort, which is what we want
  534. const SPAN_SAMPLES_SORT = {
  535. field: 'span_id',
  536. kind: 'desc' as const,
  537. };
  538. const SpanSummaryProjectAvatar = styled(ProjectAvatar)`
  539. padding-right: ${space(1)};
  540. `;
  541. const HTTP_RESPONSE_CODE_CLASS_OPTIONS = [
  542. {
  543. value: '',
  544. label: t('All'),
  545. },
  546. {
  547. value: '2',
  548. label: t('2XXs'),
  549. },
  550. {
  551. value: '3',
  552. label: t('3XXs'),
  553. },
  554. {
  555. value: '4',
  556. label: t('4XXs'),
  557. },
  558. {
  559. value: '5',
  560. label: t('5XXs'),
  561. },
  562. ];
  563. // TODO - copy of static/app/views/starfish/views/spanSummaryPage/sampleList/index.tsx
  564. const HeaderContainer = styled('div')`
  565. display: grid;
  566. grid-template-rows: auto auto auto;
  567. align-items: center;
  568. @media (min-width: ${p => p.theme.breakpoints.small}) {
  569. grid-template-rows: auto;
  570. grid-template-columns: auto 1fr;
  571. }
  572. `;
  573. const Title = styled('h4')`
  574. overflow: hidden;
  575. text-overflow: ellipsis;
  576. white-space: nowrap;
  577. margin: 0;
  578. `;
  579. const MetricsRibbon = styled('div')`
  580. display: flex;
  581. flex-wrap: wrap;
  582. gap: ${space(4)};
  583. `;
  584. const PanelControls = styled('div')`
  585. display: flex;
  586. justify-content: space-between;
  587. gap: ${space(2)};
  588. `;