serverSideSampling.tsx 22 KB

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