httpSamplesPanel.tsx 21 KB

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