uniformRateModal.tsx 22 KB

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