serverSideSampling.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. import {Fragment, useCallback, useEffect, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import {
  6. addErrorMessage,
  7. addLoadingMessage,
  8. addSuccessMessage,
  9. } from 'sentry/actionCreators/indicator';
  10. import {openModal} from 'sentry/actionCreators/modal';
  11. import {
  12. fetchProjectStats,
  13. fetchSamplingDistribution,
  14. fetchSamplingSdkVersions,
  15. } from 'sentry/actionCreators/serverSideSampling';
  16. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  17. import Button from 'sentry/components/button';
  18. import ButtonBar from 'sentry/components/buttonBar';
  19. import FeatureBadge from 'sentry/components/featureBadge';
  20. import ExternalLink from 'sentry/components/links/externalLink';
  21. import {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
  22. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  23. import {IconAdd} from 'sentry/icons';
  24. import {t, tct} from 'sentry/locale';
  25. import ProjectsStore from 'sentry/stores/projectsStore';
  26. import {ServerSideSamplingStore} from 'sentry/stores/serverSideSamplingStore';
  27. import space from 'sentry/styles/space';
  28. import {Project} from 'sentry/types';
  29. import {
  30. SamplingConditionOperator,
  31. SamplingRule,
  32. SamplingRuleOperator,
  33. SamplingRuleType,
  34. UniformModalsSubmit,
  35. } from 'sentry/types/sampling';
  36. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  37. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  38. import useApi from 'sentry/utils/useApi';
  39. import {useNavigate} from 'sentry/utils/useNavigate';
  40. import useOrganization from 'sentry/utils/useOrganization';
  41. import {useParams} from 'sentry/utils/useParams';
  42. import usePrevious from 'sentry/utils/usePrevious';
  43. import {useRouteContext} from 'sentry/utils/useRouteContext';
  44. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  45. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  46. import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
  47. import {SpecificConditionsModal} from './modals/specificConditionsModal';
  48. import {responsiveModal} from './modals/styles';
  49. import {UniformRateModal} from './modals/uniformRateModal';
  50. import {useProjectStats} from './utils/useProjectStats';
  51. import {useRecommendedSdkUpgrades} from './utils/useRecommendedSdkUpgrades';
  52. import {DraggableRuleList, DraggableRuleListUpdateItemsProps} from './draggableRuleList';
  53. import {
  54. ActiveColumn,
  55. Column,
  56. ConditionColumn,
  57. GrabColumn,
  58. OperatorColumn,
  59. RateColumn,
  60. Rule,
  61. } from './rule';
  62. import {SamplingBreakdown} from './samplingBreakdown';
  63. import {SamplingFeedback} from './samplingFeedback';
  64. import {SamplingFromOtherProject} from './samplingFromOtherProject';
  65. import {SamplingProjectIncompatibleAlert} from './samplingProjectIncompatibleAlert';
  66. import {SamplingPromo} from './samplingPromo';
  67. import {SamplingSDKClientRateChangeAlert} from './samplingSDKClientRateChangeAlert';
  68. import {SamplingSDKUpgradesAlert} from './samplingSDKUpgradesAlert';
  69. import {isUniformRule, SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
  70. type Props = {
  71. project: Project;
  72. };
  73. export function ServerSideSampling({project}: Props) {
  74. const organization = useOrganization();
  75. const api = useApi();
  76. const hasAccess = organization.access.includes('project:write');
  77. const canDemo = organization.features.includes('dynamic-sampling-demo');
  78. const currentRules = project.dynamicSampling?.rules;
  79. const previousRules = usePrevious(currentRules);
  80. const navigate = useNavigate();
  81. const params = useParams();
  82. const routeContext = useRouteContext();
  83. const router = routeContext.router;
  84. const samplingProjectSettingsPath = `/settings/${organization.slug}/projects/${project.slug}/dynamic-sampling/`;
  85. const [rules, setRules] = useState<SamplingRule[]>(currentRules ?? []);
  86. useEffect(() => {
  87. trackAdvancedAnalyticsEvent('sampling.settings.view', {
  88. organization,
  89. project_id: project.id,
  90. });
  91. }, [project.id, organization]);
  92. useEffect(() => {
  93. return () => {
  94. if (!router.location.pathname.startsWith(samplingProjectSettingsPath)) {
  95. ServerSideSamplingStore.reset();
  96. }
  97. };
  98. }, [router.location.pathname, samplingProjectSettingsPath]);
  99. useEffect(() => {
  100. if (!isEqual(previousRules, currentRules)) {
  101. setRules(currentRules ?? []);
  102. }
  103. }, [currentRules, previousRules]);
  104. useEffect(() => {
  105. if (!hasAccess) {
  106. return;
  107. }
  108. async function fetchData() {
  109. fetchProjectStats({
  110. orgSlug: organization.slug,
  111. api,
  112. projId: project.id,
  113. });
  114. await fetchSamplingDistribution({
  115. orgSlug: organization.slug,
  116. projSlug: project.slug,
  117. api,
  118. });
  119. await fetchSamplingSdkVersions({
  120. orgSlug: organization.slug,
  121. api,
  122. projectID: project.id,
  123. });
  124. }
  125. fetchData();
  126. }, [api, organization.slug, project.slug, project.id, hasAccess]);
  127. const handleReadDocs = useCallback(() => {
  128. trackAdvancedAnalyticsEvent('sampling.settings.view_read_docs', {
  129. organization,
  130. project_id: project.id,
  131. });
  132. }, [organization, project.id]);
  133. const {
  134. recommendedSdkUpgrades,
  135. isProjectIncompatible,
  136. loading: loadingRecommendedSdkUpgrades,
  137. } = useRecommendedSdkUpgrades({
  138. organization,
  139. projectId: project.id,
  140. });
  141. const saveUniformRule = useCallback(
  142. async ({
  143. sampleRate,
  144. uniformRateModalOrigin,
  145. onError,
  146. onSuccess,
  147. rule,
  148. }: Parameters<UniformModalsSubmit>[0]) => {
  149. if (isProjectIncompatible) {
  150. addErrorMessage(
  151. t('Your project is currently incompatible with Dynamic Sampling.')
  152. );
  153. return;
  154. }
  155. const newRule: SamplingRule = {
  156. // All new rules must have the default id set to -1, signaling to the backend that a proper id should
  157. // be assigned.
  158. id: -1,
  159. active: rule ? rule.active : false,
  160. type: SamplingRuleType.TRACE,
  161. condition: {
  162. op: SamplingConditionOperator.AND,
  163. inner: [],
  164. },
  165. sampleRate,
  166. };
  167. trackAdvancedAnalyticsEvent(
  168. uniformRateModalOrigin
  169. ? 'sampling.settings.modal.uniform.rate_done'
  170. : 'sampling.settings.modal.recommended.next.steps_done',
  171. {
  172. organization,
  173. project_id: project.id,
  174. }
  175. );
  176. trackAdvancedAnalyticsEvent(
  177. rule
  178. ? 'sampling.settings.rule.uniform_update'
  179. : 'sampling.settings.rule.uniform_create',
  180. {
  181. organization,
  182. project_id: project.id,
  183. sampling_rate: newRule.sampleRate,
  184. old_sampling_rate: rule ? rule.sampleRate : null,
  185. }
  186. );
  187. trackAdvancedAnalyticsEvent('sampling.settings.rule.uniform_save', {
  188. organization,
  189. project_id: project.id,
  190. sampling_rate: newRule.sampleRate,
  191. old_sampling_rate: rule ? rule.sampleRate : null,
  192. });
  193. const newRules = rule
  194. ? rules.map(existingRule =>
  195. existingRule.id === rule.id ? newRule : existingRule
  196. )
  197. : [...rules, newRule];
  198. try {
  199. const response = await api.requestPromise(
  200. `/projects/${organization.slug}/${project.slug}/`,
  201. {method: 'PUT', data: {dynamicSampling: {rules: newRules}}}
  202. );
  203. ProjectsStore.onUpdateSuccess(response);
  204. addSuccessMessage(
  205. rule
  206. ? t('Successfully edited sampling rule')
  207. : t('Successfully added sampling rule')
  208. );
  209. onSuccess?.(response.dynamicSampling?.rules ?? []);
  210. } catch (error) {
  211. addErrorMessage(
  212. typeof error === 'string'
  213. ? error
  214. : error.message || t('Failed to save sampling rule')
  215. );
  216. onError?.();
  217. }
  218. },
  219. [api, project.slug, project.id, organization, isProjectIncompatible, rules]
  220. );
  221. const handleOpenUniformRateModal = useCallback(
  222. (rule?: SamplingRule) => {
  223. openModal(
  224. modalProps => (
  225. <UniformRateModal
  226. {...modalProps}
  227. organization={organization}
  228. project={project}
  229. rules={rules}
  230. onSubmit={saveUniformRule}
  231. onReadDocs={handleReadDocs}
  232. uniformRule={rule}
  233. />
  234. ),
  235. {
  236. modalCss: responsiveModal,
  237. onClose: () => {
  238. navigate(samplingProjectSettingsPath);
  239. },
  240. }
  241. );
  242. },
  243. [
  244. navigate,
  245. organization,
  246. project,
  247. rules,
  248. saveUniformRule,
  249. handleReadDocs,
  250. samplingProjectSettingsPath,
  251. ]
  252. );
  253. const handleOpenSpecificConditionsModal = useCallback(
  254. (rule?: SamplingRule) => {
  255. openModal(
  256. modalProps => (
  257. <SpecificConditionsModal
  258. {...modalProps}
  259. organization={organization}
  260. project={project}
  261. rule={rule}
  262. rules={rules}
  263. />
  264. ),
  265. {
  266. modalCss: responsiveModal,
  267. onClose: () => {
  268. navigate(samplingProjectSettingsPath);
  269. },
  270. }
  271. );
  272. },
  273. [navigate, organization, project, rules, samplingProjectSettingsPath]
  274. );
  275. useEffect(() => {
  276. if (
  277. router.location.pathname !== `${samplingProjectSettingsPath}rules/${params.rule}/`
  278. ) {
  279. return;
  280. }
  281. if (router.location.pathname === `${samplingProjectSettingsPath}rules/uniform/`) {
  282. const uniformRule = rules.find(isUniformRule);
  283. handleOpenUniformRateModal(uniformRule);
  284. return;
  285. }
  286. if (router.location.pathname === `${samplingProjectSettingsPath}rules/new/`) {
  287. handleOpenSpecificConditionsModal();
  288. return;
  289. }
  290. const rule = rules.find(r => String(r.id) === params.rule);
  291. if (!rule) {
  292. addErrorMessage(t('Unable to find sampling rule'));
  293. return;
  294. }
  295. if (isUniformRule(rule)) {
  296. handleOpenUniformRateModal(rule);
  297. return;
  298. }
  299. handleOpenSpecificConditionsModal(rule);
  300. }, [
  301. params.rule,
  302. handleOpenUniformRateModal,
  303. handleOpenSpecificConditionsModal,
  304. rules,
  305. router.location.pathname,
  306. samplingProjectSettingsPath,
  307. ]);
  308. const {projectStats48h} = useProjectStats();
  309. async function handleActivateToggle(rule: SamplingRule) {
  310. if (isProjectIncompatible) {
  311. addErrorMessage(t('Your project is currently incompatible with Dynamic Sampling.'));
  312. return;
  313. }
  314. const newRules = rules.map(r => {
  315. if (r.id === rule.id) {
  316. return {
  317. ...r,
  318. id: -1,
  319. active: !r.active,
  320. };
  321. }
  322. return r;
  323. });
  324. addLoadingMessage();
  325. try {
  326. const result = await api.requestPromise(
  327. `/projects/${organization.slug}/${project.slug}/`,
  328. {
  329. method: 'PUT',
  330. data: {dynamicSampling: {rules: newRules}},
  331. }
  332. );
  333. ProjectsStore.onUpdateSuccess(result);
  334. addSuccessMessage(t('Successfully updated the sampling rule'));
  335. } catch (error) {
  336. const message = t('Unable to update the sampling rule');
  337. handleXhrErrorResponse(message)(error);
  338. addErrorMessage(message);
  339. }
  340. if (isUniformRule(rule)) {
  341. trackAdvancedAnalyticsEvent(
  342. rule.active
  343. ? 'sampling.settings.rule.uniform_deactivate'
  344. : 'sampling.settings.rule.uniform_activate',
  345. {
  346. organization,
  347. project_id: project.id,
  348. sampling_rate: rule.sampleRate,
  349. }
  350. );
  351. } else {
  352. const analyticsConditions = rule.condition.inner.map(condition => condition.name);
  353. const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
  354. trackAdvancedAnalyticsEvent(
  355. rule.active
  356. ? 'sampling.settings.rule.specific_deactivate'
  357. : 'sampling.settings.rule.specific_activate',
  358. {
  359. organization,
  360. project_id: project.id,
  361. sampling_rate: rule.sampleRate,
  362. conditions: analyticsConditions,
  363. conditions_stringified: analyticsConditionsStringified,
  364. }
  365. );
  366. }
  367. }
  368. function handleGetStarted() {
  369. trackAdvancedAnalyticsEvent('sampling.settings.view_get_started', {
  370. organization,
  371. project_id: project.id,
  372. });
  373. navigate(`${samplingProjectSettingsPath}rules/uniform/`);
  374. }
  375. async function handleSortRules({
  376. overIndex,
  377. reorderedItems: ruleIds,
  378. }: DraggableRuleListUpdateItemsProps) {
  379. if (!rules[overIndex].condition.inner.length) {
  380. addErrorMessage(t('Specific rules cannot be below uniform rules'));
  381. return;
  382. }
  383. const sortedRules = ruleIds
  384. .map(ruleId => rules.find(rule => String(rule.id) === ruleId))
  385. .filter(rule => !!rule) as SamplingRule[];
  386. setRules(sortedRules);
  387. try {
  388. const result = await api.requestPromise(
  389. `/projects/${organization.slug}/${project.slug}/`,
  390. {
  391. method: 'PUT',
  392. data: {dynamicSampling: {rules: sortedRules}},
  393. }
  394. );
  395. ProjectsStore.onUpdateSuccess(result);
  396. addSuccessMessage(t('Successfully sorted sampling rules'));
  397. } catch (error) {
  398. setRules(previousRules ?? []);
  399. const message = t('Unable to sort sampling rules');
  400. handleXhrErrorResponse(message)(error);
  401. addErrorMessage(message);
  402. }
  403. }
  404. async function handleDeleteRule(rule: SamplingRule) {
  405. const conditions = rule.condition.inner.map(({name}) => name);
  406. trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_delete', {
  407. organization,
  408. project_id: project.id,
  409. sampling_rate: rule.sampleRate,
  410. conditions,
  411. conditions_stringified: conditions.sort().join(', '),
  412. });
  413. try {
  414. const result = await api.requestPromise(
  415. `/projects/${organization.slug}/${project.slug}/`,
  416. {
  417. method: 'PUT',
  418. data: {dynamicSampling: {rules: rules.filter(({id}) => id !== rule.id)}},
  419. }
  420. );
  421. ProjectsStore.onUpdateSuccess(result);
  422. addSuccessMessage(t('Successfully deleted sampling rule'));
  423. } catch (error) {
  424. const message = t('Unable to delete sampling rule');
  425. handleXhrErrorResponse(message)(error);
  426. addErrorMessage(message);
  427. }
  428. }
  429. // Rules without a condition (Else case) always have to be 'pinned' to the bottom of the list
  430. // and cannot be sorted
  431. const items = rules.map(rule => ({
  432. ...rule,
  433. id: String(rule.id),
  434. }));
  435. const uniformRule = rules.find(isUniformRule);
  436. return (
  437. <SentryDocumentTitle title={t('Dynamic Sampling')}>
  438. <Fragment>
  439. <SettingsPageHeader
  440. title={
  441. <Fragment>
  442. {t('Dynamic Sampling')} <FeatureBadge type="beta" />
  443. </Fragment>
  444. }
  445. action={<SamplingFeedback />}
  446. />
  447. <TextBlock>
  448. {tct(
  449. 'Improve the accuracy of your [performanceMetrics: performance metrics] and [targetTransactions: target those transactions] which are most valuable for your organization. Server-side rules are applied immediately, with no need to re-deploy your app. To learn more about our beta program, [faqLink: visit our FAQ].',
  450. {
  451. performanceMetrics: (
  452. <ExternalLink href="https://docs.sentry.io/product/performance/metrics/#metrics-and-sampling" />
  453. ),
  454. targetTransactions: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
  455. faqLink: (
  456. <ExternalLink href="https://help.sentry.io/account/account-settings/dynamic-sampling/" />
  457. ),
  458. docsLink: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
  459. }
  460. )}
  461. </TextBlock>
  462. <PermissionAlert
  463. access={['project:write']}
  464. message={t(
  465. 'These settings can only be edited by users with the organization owner, manager, or admin role.'
  466. )}
  467. />
  468. <SamplingProjectIncompatibleAlert
  469. organization={organization}
  470. projectId={project.id}
  471. isProjectIncompatible={isProjectIncompatible}
  472. />
  473. {!!rules.length && (
  474. <SamplingSDKUpgradesAlert
  475. organization={organization}
  476. projectId={project.id}
  477. recommendedSdkUpgrades={recommendedSdkUpgrades}
  478. onReadDocs={handleReadDocs}
  479. />
  480. )}
  481. {!!rules.length && !recommendedSdkUpgrades.length && (
  482. <SamplingSDKClientRateChangeAlert
  483. onReadDocs={handleReadDocs}
  484. projectStats={projectStats48h.data}
  485. organization={organization}
  486. projectId={project.id}
  487. />
  488. )}
  489. <SamplingFromOtherProject
  490. orgSlug={organization.slug}
  491. projectSlug={project.slug}
  492. />
  493. {hasAccess && <SamplingBreakdown orgSlug={organization.slug} />}
  494. {!rules.length ? (
  495. <SamplingPromo
  496. onGetStarted={handleGetStarted}
  497. onReadDocs={handleReadDocs}
  498. hasAccess={hasAccess}
  499. />
  500. ) : (
  501. <RulesPanel>
  502. <RulesPanelHeader lightText>
  503. <RulesPanelLayout>
  504. <GrabColumn />
  505. <OperatorColumn>{t('Operator')}</OperatorColumn>
  506. <ConditionColumn>{t('Condition')}</ConditionColumn>
  507. <RateColumn>{t('Rate')}</RateColumn>
  508. <ActiveColumn>{t('Active')}</ActiveColumn>
  509. <Column />
  510. </RulesPanelLayout>
  511. </RulesPanelHeader>
  512. <DraggableRuleList
  513. disabled={!hasAccess}
  514. items={items}
  515. onUpdateItems={handleSortRules}
  516. wrapperStyle={({isDragging, isSorting, index}) => {
  517. if (isDragging) {
  518. return {
  519. cursor: 'grabbing',
  520. };
  521. }
  522. if (isSorting) {
  523. return {};
  524. }
  525. return {
  526. transform: 'none',
  527. transformOrigin: '0',
  528. '--box-shadow': 'none',
  529. '--box-shadow-picked-up': 'none',
  530. overflow: 'visible',
  531. position: 'relative',
  532. zIndex: rules.length - index,
  533. cursor: 'default',
  534. };
  535. }}
  536. renderItem={({value, listeners, attributes, dragging, sorting}) => {
  537. const itemsRuleIndex = items.findIndex(item => item.id === value);
  538. if (itemsRuleIndex === -1) {
  539. return null;
  540. }
  541. const itemsRule = items[itemsRuleIndex];
  542. const currentRule = {
  543. active: itemsRule.active,
  544. condition: itemsRule.condition,
  545. sampleRate: itemsRule.sampleRate,
  546. type: itemsRule.type,
  547. id: Number(itemsRule.id),
  548. };
  549. return (
  550. <RulesPanelLayout isContent>
  551. <Rule
  552. operator={
  553. itemsRule.id === items[0].id
  554. ? SamplingRuleOperator.IF
  555. : isUniformRule(currentRule)
  556. ? SamplingRuleOperator.ELSE
  557. : SamplingRuleOperator.ELSE_IF
  558. }
  559. hideGrabButton={items.length === 1}
  560. rule={currentRule}
  561. onEditRule={() => {
  562. navigate(
  563. isUniformRule(currentRule)
  564. ? `${samplingProjectSettingsPath}rules/uniform/`
  565. : `${samplingProjectSettingsPath}rules/${currentRule.id}/`
  566. );
  567. }}
  568. canDemo={canDemo}
  569. onDeleteRule={() => handleDeleteRule(currentRule)}
  570. onActivate={() => handleActivateToggle(currentRule)}
  571. noPermission={!hasAccess}
  572. upgradeSdkForProjects={recommendedSdkUpgrades.map(
  573. recommendedSdkUpgrade => recommendedSdkUpgrade.project.slug
  574. )}
  575. listeners={listeners}
  576. grabAttributes={attributes}
  577. dragging={dragging}
  578. sorting={sorting}
  579. loadingRecommendedSdkUpgrades={loadingRecommendedSdkUpgrades}
  580. />
  581. </RulesPanelLayout>
  582. );
  583. }}
  584. />
  585. <RulesPanelFooter>
  586. <ButtonBar gap={1}>
  587. <Button
  588. href={SERVER_SIDE_SAMPLING_DOC_LINK}
  589. onClick={handleReadDocs}
  590. external
  591. >
  592. {t('Read Docs')}
  593. </Button>
  594. <GuideAnchor
  595. target="add_conditional_rule"
  596. disabled={!uniformRule?.active || !hasAccess || rules.length !== 1}
  597. >
  598. <AddRuleButton
  599. disabled={!hasAccess}
  600. title={
  601. !hasAccess
  602. ? t("You don't have permission to add a rule")
  603. : undefined
  604. }
  605. priority="primary"
  606. onClick={() => navigate(`${samplingProjectSettingsPath}rules/new/`)}
  607. icon={<IconAdd isCircled />}
  608. >
  609. {t('Add Rule')}
  610. </AddRuleButton>
  611. </GuideAnchor>
  612. </ButtonBar>
  613. </RulesPanelFooter>
  614. </RulesPanel>
  615. )}
  616. </Fragment>
  617. </SentryDocumentTitle>
  618. );
  619. }
  620. const RulesPanel = styled(Panel)``;
  621. const RulesPanelHeader = styled(PanelHeader)`
  622. padding: ${space(0.5)} 0;
  623. font-size: ${p => p.theme.fontSizeSmall};
  624. `;
  625. const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
  626. width: 100%;
  627. display: grid;
  628. grid-template-columns: 1fr 0.5fr 74px;
  629. @media (min-width: ${p => p.theme.breakpoints.small}) {
  630. grid-template-columns: 48px 97px 1fr 0.5fr 77px 74px;
  631. }
  632. ${p =>
  633. p.isContent &&
  634. css`
  635. > * {
  636. /* match the height of the ellipsis button */
  637. line-height: 34px;
  638. border-bottom: 1px solid ${p.theme.border};
  639. }
  640. `}
  641. `;
  642. const RulesPanelFooter = styled(PanelFooter)`
  643. border-top: none;
  644. padding: ${space(1.5)} ${space(2)};
  645. grid-column: 1 / -1;
  646. display: flex;
  647. align-items: center;
  648. justify-content: flex-end;
  649. `;
  650. const AddRuleButton = styled(Button)`
  651. @media (max-width: ${p => p.theme.breakpoints.small}) {
  652. width: 100%;
  653. }
  654. `;