uniformRateModal.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import {fetchProjectStats30d} from 'sentry/actionCreators/serverSideSampling';
  5. import Alert from 'sentry/components/alert';
  6. import Button from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import {NumberField} from 'sentry/components/forms';
  9. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import LoadingError from 'sentry/components/loadingError';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import {PanelTable} from 'sentry/components/panels';
  14. import Placeholder from 'sentry/components/placeholder';
  15. import QuestionTooltip from 'sentry/components/questionTooltip';
  16. import Radio from 'sentry/components/radio';
  17. import Tooltip from 'sentry/components/tooltip';
  18. import {IconRefresh, IconWarning} from 'sentry/icons';
  19. import {t, tct} from 'sentry/locale';
  20. import ModalStore from 'sentry/stores/modalStore';
  21. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  22. import space from 'sentry/styles/space';
  23. import {Project} from 'sentry/types';
  24. import {SamplingRule, UniformModalsSubmit} from 'sentry/types/sampling';
  25. import {defined} from 'sentry/utils';
  26. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  27. import {formatPercentage} from 'sentry/utils/formatters';
  28. import useApi from 'sentry/utils/useApi';
  29. import {Outcome} from 'sentry/views/organizationStats/types';
  30. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  31. import {SamplingProjectIncompatibleAlert} from '../samplingProjectIncompatibleAlert';
  32. import {
  33. getClientSampleRates,
  34. isValidSampleRate,
  35. percentageToRate,
  36. rateToPercentage,
  37. SERVER_SIDE_SAMPLING_DOC_LINK,
  38. } from '../utils';
  39. import {hasFirstBucketsEmpty} from '../utils/hasFirstBucketsEmpty';
  40. import {projectStatsToPredictedSeries} from '../utils/projectStatsToPredictedSeries';
  41. import {projectStatsToSeries} from '../utils/projectStatsToSeries';
  42. import {useProjectStats} from '../utils/useProjectStats';
  43. import {useRecommendedSdkUpgrades} from '../utils/useRecommendedSdkUpgrades';
  44. import {RecommendedStepsModal, RecommendedStepsModalProps} from './recommendedStepsModal';
  45. import {SpecifyClientRateModal} from './specifyClientRateModal';
  46. import {UniformRateChart} from './uniformRateChart';
  47. const CONSERVATIVE_SAMPLE_RATE = 0.1;
  48. enum Strategy {
  49. CURRENT = 'current',
  50. RECOMMENDED = 'recommended',
  51. }
  52. enum Step {
  53. SET_CURRENT_CLIENT_SAMPLE_RATE = 'set_current_client_sample_rate',
  54. SET_UNIFORM_SAMPLE_RATE = 'set_uniform_sample_rate',
  55. RECOMMENDED_STEPS = 'recommended_steps',
  56. }
  57. type Props = Omit<
  58. RecommendedStepsModalProps,
  59. 'onSubmit' | 'recommendedSdkUpgrades' | 'projectId' | 'recommendedSampleRate'
  60. > & {
  61. onSubmit: UniformModalsSubmit;
  62. project: Project;
  63. rules: SamplingRule[];
  64. };
  65. export function UniformRateModal({
  66. Header,
  67. Body,
  68. Footer,
  69. closeModal,
  70. organization,
  71. project,
  72. uniformRule,
  73. onSubmit,
  74. onReadDocs,
  75. ...props
  76. }: Props) {
  77. const api = useApi();
  78. const [rules, setRules] = useState(props.rules);
  79. const [specifiedClientRate, setSpecifiedClientRate] = useState<undefined | number>(
  80. undefined
  81. );
  82. const [activeStep, setActiveStep] = useState<Step | undefined>(undefined);
  83. const [selectedStrategy, setSelectedStrategy] = useState<Strategy>(Strategy.CURRENT);
  84. const modalStore = useLegacyStore(ModalStore);
  85. const {projectStats30d, projectStats48h} = useProjectStats();
  86. const {recommendedSdkUpgrades, affectedProjects, isProjectIncompatible} =
  87. useRecommendedSdkUpgrades({
  88. orgSlug: organization.slug,
  89. projectId: project.id,
  90. });
  91. const loading = projectStats30d.loading || projectStats48h.loading;
  92. useEffect(() => {
  93. if (loading || !projectStats30d.data) {
  94. return;
  95. }
  96. if (!projectStats30d.data.groups.length) {
  97. setActiveStep(Step.SET_UNIFORM_SAMPLE_RATE);
  98. return;
  99. }
  100. const clientDiscard = projectStats30d.data.groups.some(
  101. g => g.by.outcome === Outcome.CLIENT_DISCARD
  102. );
  103. setActiveStep(
  104. clientDiscard ? Step.SET_UNIFORM_SAMPLE_RATE : Step.SET_CURRENT_CLIENT_SAMPLE_RATE
  105. );
  106. }, [loading, projectStats30d.data]);
  107. const shouldUseConservativeSampleRate =
  108. hasFirstBucketsEmpty(projectStats30d.data, 27) &&
  109. hasFirstBucketsEmpty(projectStats48h.data, 3) &&
  110. !defined(specifiedClientRate);
  111. useEffect(() => {
  112. // updated or created rules will always have a new id,
  113. // therefore the isEqual will always work in this case
  114. if (modalStore.renderer === null && isEqual(rules, props.rules)) {
  115. trackAdvancedAnalyticsEvent(
  116. activeStep === Step.SET_CURRENT_CLIENT_SAMPLE_RATE
  117. ? 'sampling.settings.modal.specify.client.rate_cancel'
  118. : activeStep === Step.RECOMMENDED_STEPS
  119. ? 'sampling.settings.modal.recommended.next.steps_cancel'
  120. : 'sampling.settings.modal.uniform.rate_cancel',
  121. {
  122. organization,
  123. project_id: project.id,
  124. }
  125. );
  126. }
  127. }, [activeStep, modalStore.renderer, organization, project.id, rules, props.rules]);
  128. useEffect(() => {
  129. trackAdvancedAnalyticsEvent(
  130. selectedStrategy === Strategy.RECOMMENDED
  131. ? 'sampling.settings.modal.uniform.rate_switch_recommended'
  132. : 'sampling.settings.modal.uniform.rate_switch_current',
  133. {
  134. organization,
  135. project_id: project.id,
  136. }
  137. );
  138. }, [selectedStrategy, organization, project.id]);
  139. const uniformSampleRate = uniformRule?.sampleRate;
  140. const {recommended: recommendedClientSampling, current: currentClientSampling} =
  141. getClientSampleRates(projectStats48h.data, specifiedClientRate);
  142. const currentServerSampling =
  143. defined(uniformSampleRate) && !isNaN(uniformSampleRate)
  144. ? uniformSampleRate
  145. : undefined;
  146. const recommendedServerSampling = shouldUseConservativeSampleRate
  147. ? CONSERVATIVE_SAMPLE_RATE
  148. : Math.min(currentClientSampling ?? 1, recommendedClientSampling ?? 1);
  149. const [clientInput, setClientInput] = useState(
  150. rateToPercentage(recommendedClientSampling)
  151. );
  152. const [serverInput, setServerInput] = useState(
  153. rateToPercentage(recommendedServerSampling)
  154. );
  155. // ^^^ We use clientInput and serverInput variables just for the text fields, everywhere else we should use client and server variables vvv
  156. const client = percentageToRate(clientInput);
  157. const server = percentageToRate(serverInput);
  158. const [saving, setSaving] = useState(false);
  159. const shouldHaveNextStep =
  160. client !== currentClientSampling || recommendedSdkUpgrades.length > 0;
  161. useEffect(() => {
  162. setClientInput(rateToPercentage(recommendedClientSampling));
  163. setServerInput(rateToPercentage(recommendedServerSampling));
  164. }, [recommendedClientSampling, recommendedServerSampling]);
  165. const isEdited =
  166. client !== recommendedClientSampling || server !== recommendedServerSampling;
  167. const isServerRateHigherThanClientRate =
  168. defined(client) && defined(server) ? client < server : false;
  169. const isValid =
  170. isValidSampleRate(client) &&
  171. isValidSampleRate(server) &&
  172. !isServerRateHigherThanClientRate;
  173. function handlePrimaryButtonClick() {
  174. // this can either be "Next" or "Done"
  175. if (!isValid) {
  176. return;
  177. }
  178. if (shouldHaveNextStep) {
  179. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_next', {
  180. organization,
  181. project_id: project.id,
  182. });
  183. setActiveStep(Step.RECOMMENDED_STEPS);
  184. return;
  185. }
  186. setSaving(true);
  187. onSubmit({
  188. recommendedSampleRate: !isEdited,
  189. uniformRateModalOrigin: true,
  190. sampleRate: server!,
  191. rule: uniformRule,
  192. onSuccess: newRules => {
  193. setSaving(false);
  194. setRules(newRules);
  195. closeModal();
  196. },
  197. onError: () => {
  198. setSaving(false);
  199. },
  200. });
  201. }
  202. function handleReadDocs() {
  203. onReadDocs();
  204. if (activeStep === undefined) {
  205. return;
  206. }
  207. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_read_docs', {
  208. organization,
  209. project_id: project.id,
  210. });
  211. }
  212. async function handleRefetchProjectStats30d() {
  213. await fetchProjectStats30d({api, orgSlug: organization.slug, projId: project.id});
  214. }
  215. if (activeStep === undefined || loading || projectStats30d.error) {
  216. return (
  217. <Fragment>
  218. <Header closeButton>
  219. {projectStats30d.error ? (
  220. <h4>{t('Set a global sample rate')}</h4>
  221. ) : (
  222. <Placeholder height="22px" />
  223. )}
  224. </Header>
  225. <Body>
  226. {projectStats30d.error ? (
  227. <LoadingError onRetry={handleRefetchProjectStats30d} />
  228. ) : (
  229. <LoadingIndicator />
  230. )}
  231. </Body>
  232. <Footer>
  233. <FooterActions>
  234. <Button
  235. href={SERVER_SIDE_SAMPLING_DOC_LINK}
  236. onClick={handleReadDocs}
  237. external
  238. >
  239. {t('Read Docs')}
  240. </Button>
  241. <ButtonBar gap={1}>
  242. <Button onClick={closeModal}>{t('Cancel')}</Button>
  243. {projectStats30d.error ? (
  244. <Button
  245. priority="primary"
  246. title={t('There was an error loading data')}
  247. disabled
  248. >
  249. {t('Done')}
  250. </Button>
  251. ) : (
  252. <Placeholder height="40px" width="80px" />
  253. )}
  254. </ButtonBar>
  255. </FooterActions>
  256. </Footer>
  257. </Fragment>
  258. );
  259. }
  260. if (activeStep === Step.SET_CURRENT_CLIENT_SAMPLE_RATE) {
  261. return (
  262. <SpecifyClientRateModal
  263. {...props}
  264. Header={Header}
  265. Body={Body}
  266. Footer={Footer}
  267. closeModal={closeModal}
  268. onReadDocs={onReadDocs}
  269. organization={organization}
  270. projectId={project.id}
  271. value={specifiedClientRate}
  272. onChange={setSpecifiedClientRate}
  273. onGoNext={() => setActiveStep(Step.SET_UNIFORM_SAMPLE_RATE)}
  274. />
  275. );
  276. }
  277. if (activeStep === Step.RECOMMENDED_STEPS) {
  278. return (
  279. <RecommendedStepsModal
  280. {...props}
  281. Header={Header}
  282. Body={Body}
  283. Footer={Footer}
  284. closeModal={closeModal}
  285. organization={organization}
  286. recommendedSdkUpgrades={recommendedSdkUpgrades}
  287. onGoBack={() => setActiveStep(Step.SET_UNIFORM_SAMPLE_RATE)}
  288. onSubmit={onSubmit}
  289. onReadDocs={onReadDocs}
  290. clientSampleRate={client}
  291. serverSampleRate={server}
  292. uniformRule={uniformRule}
  293. projectId={project.id}
  294. recommendedSampleRate={!isEdited}
  295. onSetRules={setRules}
  296. />
  297. );
  298. }
  299. return (
  300. <Fragment>
  301. <Header closeButton>
  302. <h4>{t('Set a global sample rate')}</h4>
  303. </Header>
  304. <Body>
  305. <TextBlock>
  306. {tct(
  307. 'Set a server-side sample rate for all transactions using our suggestion as a starting point. To accurately monitor overall performance, we also suggest changing your client(SDK) sample rate to allow more metrics to be processed. [learnMoreLink: Learn more about quota management].',
  308. {
  309. learnMoreLink: (
  310. <ExternalLink
  311. href={`${SERVER_SIDE_SAMPLING_DOC_LINK}getting-started/#2-set-a-uniform-sampling-rate`}
  312. />
  313. ),
  314. }
  315. )}
  316. </TextBlock>
  317. <Fragment>
  318. <UniformRateChart
  319. series={
  320. selectedStrategy === Strategy.CURRENT
  321. ? projectStatsToSeries(projectStats30d.data, specifiedClientRate)
  322. : projectStatsToPredictedSeries(
  323. projectStats30d.data,
  324. client,
  325. server,
  326. specifiedClientRate
  327. )
  328. }
  329. />
  330. <StyledPanelTable
  331. headers={[
  332. <SamplingValuesColumn key="sampling-values">
  333. {t('Sampling Values')}
  334. </SamplingValuesColumn>,
  335. <ClientColumn key="client">{t('Client')}</ClientColumn>,
  336. <ClientHelpOrWarningColumn key="client-rate-help" />,
  337. <ServerColumn key="server">{t('Server')}</ServerColumn>,
  338. <ServerWarningColumn key="server-warning" />,
  339. <RefreshRatesColumn key="refresh-rates" />,
  340. ]}
  341. >
  342. <Fragment>
  343. <SamplingValuesColumn>
  344. <Label htmlFor="sampling-current">
  345. <Radio
  346. id="sampling-current"
  347. checked={selectedStrategy === Strategy.CURRENT}
  348. onChange={() => {
  349. setSelectedStrategy(Strategy.CURRENT);
  350. }}
  351. />
  352. {t('Current')}
  353. </Label>
  354. </SamplingValuesColumn>
  355. <ClientColumn>
  356. {defined(currentClientSampling)
  357. ? formatPercentage(currentClientSampling)
  358. : 'N/A'}
  359. </ClientColumn>
  360. <ClientHelpOrWarningColumn />
  361. <ServerColumn>
  362. {defined(currentServerSampling)
  363. ? formatPercentage(currentServerSampling)
  364. : 'N/A'}
  365. </ServerColumn>
  366. <ServerWarningColumn />
  367. <RefreshRatesColumn />
  368. </Fragment>
  369. <Fragment>
  370. <SamplingValuesColumn>
  371. <Label htmlFor="sampling-recommended">
  372. <Radio
  373. id="sampling-recommended"
  374. checked={selectedStrategy === Strategy.RECOMMENDED}
  375. onChange={() => {
  376. setSelectedStrategy(Strategy.RECOMMENDED);
  377. }}
  378. />
  379. {isEdited ? t('New') : t('Suggested')}
  380. {!isEdited && (
  381. <QuestionTooltip
  382. title={t(
  383. 'Optimal sample rates based on your organization’s usage and quota.'
  384. )}
  385. size="sm"
  386. />
  387. )}
  388. </Label>
  389. </SamplingValuesColumn>
  390. <ClientColumn>
  391. <StyledNumberField
  392. name="recommended-client-sampling"
  393. placeholder="%"
  394. step="10"
  395. value={clientInput ?? null}
  396. onChange={value => {
  397. setClientInput(value === '' ? undefined : value);
  398. }}
  399. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  400. stacked
  401. flexibleControlStateSize
  402. inline={false}
  403. />
  404. </ClientColumn>
  405. <ClientHelpOrWarningColumn>
  406. {isEdited && !isValidSampleRate(client) ? (
  407. <Tooltip
  408. title={t('Set a value between 0 and 100')}
  409. containerDisplayMode="inline-flex"
  410. >
  411. <IconWarning
  412. color="red300"
  413. size="sm"
  414. data-test-id="invalid-client-rate"
  415. />
  416. </Tooltip>
  417. ) : (
  418. <QuestionTooltip
  419. title={t(
  420. 'Changing the client(SDK) sample rate will require re-deployment.'
  421. )}
  422. size="sm"
  423. />
  424. )}
  425. </ClientHelpOrWarningColumn>
  426. <ServerColumn>
  427. <StyledNumberField
  428. name="recommended-server-sampling"
  429. placeholder="%"
  430. step="10"
  431. value={serverInput ?? null}
  432. onChange={value => {
  433. setServerInput(value === '' ? undefined : value);
  434. }}
  435. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  436. stacked
  437. flexibleControlStateSize
  438. inline={false}
  439. />
  440. </ServerColumn>
  441. <ServerWarningColumn>
  442. {isEdited && !isValidSampleRate(server) ? (
  443. <Tooltip
  444. title={t('Set a value between 0 and 100')}
  445. containerDisplayMode="inline-flex"
  446. >
  447. <IconWarning
  448. color="red300"
  449. size="sm"
  450. data-test-id="invalid-server-rate"
  451. />
  452. </Tooltip>
  453. ) : (
  454. isServerRateHigherThanClientRate && (
  455. <Tooltip
  456. title={t(
  457. 'Server sample rate shall not be higher than client sample rate'
  458. )}
  459. containerDisplayMode="inline-flex"
  460. >
  461. <IconWarning
  462. color="red300"
  463. size="sm"
  464. data-test-id="invalid-server-rate"
  465. />
  466. </Tooltip>
  467. )
  468. )}
  469. </ServerWarningColumn>
  470. <RefreshRatesColumn>
  471. {isEdited && (
  472. <Button
  473. title={t('Reset to suggested values')}
  474. icon={<IconRefresh size="sm" />}
  475. aria-label={t('Reset to suggested values')}
  476. onClick={() => {
  477. setClientInput(rateToPercentage(recommendedClientSampling));
  478. setServerInput(rateToPercentage(recommendedServerSampling));
  479. }}
  480. borderless
  481. size="zero"
  482. />
  483. )}
  484. </RefreshRatesColumn>
  485. </Fragment>
  486. </StyledPanelTable>
  487. <SamplingProjectIncompatibleAlert
  488. organization={organization}
  489. projectId={project.id}
  490. isProjectIncompatible={isProjectIncompatible}
  491. />
  492. {shouldUseConservativeSampleRate && (
  493. <Alert type="info" showIcon>
  494. {t(
  495. "For accurate suggestions, we need at least 48hrs to ingest transactions. Meanwhile, here's a conservative server-side sampling rate which can be changed later on."
  496. )}
  497. </Alert>
  498. )}
  499. {affectedProjects.length > 0 && (
  500. <Alert
  501. data-test-id="affected-sdk-alert"
  502. type="info"
  503. showIcon
  504. trailingItems={
  505. <Button
  506. href={`${SERVER_SIDE_SAMPLING_DOC_LINK}#traces--propagation-of-sampling-decisions`}
  507. priority="link"
  508. borderless
  509. external
  510. >
  511. {t('Learn More')}
  512. </Button>
  513. }
  514. >
  515. {t('This rate will affect the transactions for the following projects:')}
  516. <Projects>
  517. {affectedProjects.map(affectedProject => (
  518. <ProjectBadge
  519. key={affectedProject.id}
  520. project={affectedProject}
  521. avatarSize={16}
  522. />
  523. ))}
  524. </Projects>
  525. </Alert>
  526. )}
  527. </Fragment>
  528. </Body>
  529. <Footer>
  530. <FooterActions>
  531. <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} onClick={handleReadDocs} external>
  532. {t('Read Docs')}
  533. </Button>
  534. <ButtonBar gap={1}>
  535. {shouldHaveNextStep && (
  536. <Stepper>
  537. {defined(specifiedClientRate) ? t('Step 2 of 3') : t('Step 1 of 2')}
  538. </Stepper>
  539. )}
  540. {defined(specifiedClientRate) ? (
  541. <Button onClick={() => setActiveStep(Step.SET_CURRENT_CLIENT_SAMPLE_RATE)}>
  542. {t('Back')}
  543. </Button>
  544. ) : (
  545. <Button onClick={closeModal}>{t('Cancel')}</Button>
  546. )}
  547. <Button
  548. priority="primary"
  549. onClick={handlePrimaryButtonClick}
  550. disabled={
  551. saving ||
  552. !isValid ||
  553. selectedStrategy === Strategy.CURRENT ||
  554. isProjectIncompatible
  555. }
  556. title={
  557. selectedStrategy === Strategy.CURRENT
  558. ? t('Current sampling values selected')
  559. : !isValid
  560. ? t('Sample rate is not valid')
  561. : undefined
  562. }
  563. >
  564. {shouldHaveNextStep ? t('Next') : t('Done')}
  565. </Button>
  566. </ButtonBar>
  567. </FooterActions>
  568. </Footer>
  569. </Fragment>
  570. );
  571. }
  572. const StyledPanelTable = styled(PanelTable)`
  573. grid-template-columns: 1fr 115px 24px 115px 16px 46px;
  574. border-top-left-radius: 0;
  575. border-top-right-radius: 0;
  576. > * {
  577. padding: 0;
  578. }
  579. `;
  580. const Label = styled('label')`
  581. font-weight: 400;
  582. display: inline-flex;
  583. align-items: center;
  584. gap: ${space(1)};
  585. margin-bottom: 0;
  586. `;
  587. export const StyledNumberField = styled(NumberField)`
  588. width: 100%;
  589. `;
  590. export const FooterActions = styled('div')`
  591. display: flex;
  592. justify-content: space-between;
  593. align-items: center;
  594. flex: 1;
  595. gap: ${space(1)};
  596. `;
  597. export const Stepper = styled('span')`
  598. font-size: ${p => p.theme.fontSizeMedium};
  599. color: ${p => p.theme.subText};
  600. `;
  601. const SamplingValuesColumn = styled('div')`
  602. padding: ${space(2)};
  603. display: flex;
  604. `;
  605. const ClientColumn = styled('div')`
  606. padding: ${space(2)} ${space(1)} ${space(2)} ${space(2)};
  607. text-align: right;
  608. display: flex;
  609. justify-content: flex-end;
  610. `;
  611. const ClientHelpOrWarningColumn = styled('div')`
  612. padding: ${space(2)} ${space(1)} ${space(2)} 0;
  613. display: flex;
  614. align-items: center;
  615. `;
  616. const ServerColumn = styled('div')`
  617. padding: ${space(2)} ${space(1)} ${space(2)} ${space(2)};
  618. text-align: right;
  619. display: flex;
  620. justify-content: flex-end;
  621. `;
  622. const ServerWarningColumn = styled('div')`
  623. padding: ${space(2)} 0;
  624. display: flex;
  625. align-items: center;
  626. `;
  627. const RefreshRatesColumn = styled('div')`
  628. padding: ${space(2)} ${space(2)} ${space(2)} ${space(1)};
  629. display: inline-flex;
  630. `;
  631. const Projects = styled('div')`
  632. display: flex;
  633. flex-wrap: wrap;
  634. gap: ${space(1.5)};
  635. justify-content: flex-start;
  636. margin-top: ${space(1)};
  637. `;