diff --git a/static/app/routes.tsx b/static/app/routes.tsx index 0a6c7754f905e2..c31c477cbaefc0 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -967,6 +967,12 @@ function buildRoutes(): RouteObject[] { }, ], }, + { + path: 'rate-limits/', + name: t('Rate Limits'), + component: make(() => import('sentry/views/settings/organizationRateLimits')), + deprecatedRouteProps: true, + }, { path: 'relay/', name: t('Relay'), diff --git a/static/app/views/settings/organization/navigationConfiguration.tsx b/static/app/views/settings/organization/navigationConfiguration.tsx index beb5daed2a8bbb..2a3d9caa1352ae 100644 --- a/static/app/views/settings/organization/navigationConfiguration.tsx +++ b/static/app/views/settings/organization/navigationConfiguration.tsx @@ -89,6 +89,13 @@ export function getOrganizationNavigationConfiguration({ description: t('View the audit log for an organization'), id: 'audit-log', }, + { + path: `${organizationSettingsPathPrefix}/rate-limits/`, + title: t('Rate Limits'), + show: ({features}) => features!.has('legacy-rate-limits'), + description: t('Configure rate limits for all projects in the organization'), + id: 'rate-limits', + }, { path: `${organizationSettingsPathPrefix}/relay/`, title: t('Relay'), diff --git a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx index 8e031ba43e01ed..8738ca6052a39e 100644 --- a/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx +++ b/static/app/views/settings/organization/userOrgNavigationConfiguration.tsx @@ -126,6 +126,13 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] { description: t('View the audit log for an organization'), id: 'audit-log', }, + { + path: `${organizationSettingsPathPrefix}/rate-limits/`, + title: t('Rate Limits'), + show: ({features}) => features?.has('legacy-rate-limits') ?? false, + description: t('Configure rate limits for all projects in the organization'), + id: 'rate-limits', + }, { path: `${organizationSettingsPathPrefix}/relay/`, title: t('Relay'), diff --git a/static/app/views/settings/organizationRateLimits/index.tsx b/static/app/views/settings/organizationRateLimits/index.tsx new file mode 100644 index 00000000000000..970c926338bcb7 --- /dev/null +++ b/static/app/views/settings/organizationRateLimits/index.tsx @@ -0,0 +1,20 @@ +import withOrganization from 'sentry/utils/withOrganization'; +import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert'; + +import OrganizationRateLimits from './organizationRateLimits'; + +function OrganizationRateLimitsContainer( + props: React.ComponentProps +) { + if (!props.organization) { + return null; + } + + return props.organization.access.includes('org:write') ? ( + + ) : ( + + ); +} + +export default withOrganization(OrganizationRateLimitsContainer); diff --git a/static/app/views/settings/organizationRateLimits/organizationRateLimits.spec.tsx b/static/app/views/settings/organizationRateLimits/organizationRateLimits.spec.tsx new file mode 100644 index 00000000000000..c2bc8f9497191f --- /dev/null +++ b/static/app/views/settings/organizationRateLimits/organizationRateLimits.spec.tsx @@ -0,0 +1,112 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; + +import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import type {OrganizationRateLimitProps} from 'sentry/views/settings/organizationRateLimits/organizationRateLimits'; +import OrganizationRateLimits from 'sentry/views/settings/organizationRateLimits/organizationRateLimits'; + +const ENDPOINT = '/organizations/org-slug/'; + +describe('Organization Rate Limits', () => { + const organization = OrganizationFixture({ + quota: { + projectLimit: 75, + accountLimit: 70000, + maxRate: null, + maxRateInterval: null, + }, + }); + + const renderComponent = (props?: Partial) => + render( + + ); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + }); + + it('renders with initialData', () => { + renderComponent(); + + // XXX: Slider input values are associated to their step value + // Step 16 is 70000 + expect(screen.getByRole('slider', {name: 'Account Limit'})).toHaveValue('16'); + expect(screen.getByRole('slider', {name: 'Per-Project Limit'})).toHaveValue('75'); + }); + + it('renders with maxRate and maxRateInterval set', () => { + const org = OrganizationFixture({ + ...organization, + quota: { + maxRate: 100, + maxRateInterval: 60, + projectLimit: null, + accountLimit: null, + }, + }); + + renderComponent({organization: org}); + + expect(screen.getByRole('slider')).toBeInTheDocument(); + }); + + it('can change Account Rate Limit', async () => { + const mock = MockApiClient.addMockResponse({ + url: ENDPOINT, + method: 'PUT', + statusCode: 200, + }); + + renderComponent(); + + expect(mock).not.toHaveBeenCalled(); + + // Change Account Limit + act(() => screen.getByRole('slider', {name: 'Account Limit'}).focus()); + await userEvent.keyboard('{ArrowLeft>5}'); + await userEvent.tab(); + + expect(mock).toHaveBeenCalledWith( + ENDPOINT, + expect.objectContaining({ + method: 'PUT', + data: { + accountRateLimit: 20000, + }, + }) + ); + }); + + it('can change Project Rate Limit', async () => { + const mock = MockApiClient.addMockResponse({ + url: ENDPOINT, + method: 'PUT', + statusCode: 200, + }); + + renderComponent(); + + expect(mock).not.toHaveBeenCalled(); + + // Change Project Rate Limit + act(() => screen.getByRole('slider', {name: 'Per-Project Limit'}).focus()); + await userEvent.keyboard('{ArrowRight>5}'); + await userEvent.tab(); + + expect(mock).toHaveBeenCalledWith( + ENDPOINT, + expect.objectContaining({ + method: 'PUT', + data: { + projectRateLimit: 100, + }, + }) + ); + }); +}); diff --git a/static/app/views/settings/organizationRateLimits/organizationRateLimits.tsx b/static/app/views/settings/organizationRateLimits/organizationRateLimits.tsx new file mode 100644 index 00000000000000..00f1ea9ce9472e --- /dev/null +++ b/static/app/views/settings/organizationRateLimits/organizationRateLimits.tsx @@ -0,0 +1,131 @@ +import {css} from '@emotion/react'; + +import FieldGroup from 'sentry/components/forms/fieldGroup'; +import RangeField from 'sentry/components/forms/fields/rangeField'; +import Form from 'sentry/components/forms/form'; +import Panel from 'sentry/components/panels/panel'; +import PanelAlert from 'sentry/components/panels/panelAlert'; +import PanelBody from 'sentry/components/panels/panelBody'; +import PanelHeader from 'sentry/components/panels/panelHeader'; +import {t, tct} from 'sentry/locale'; +import type {RouteComponentProps} from 'sentry/types/legacyReactRouter'; +import type {Organization} from 'sentry/types/organization'; +import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; +import TextBlock from 'sentry/views/settings/components/text/textBlock'; + +export type OrganizationRateLimitProps = RouteComponentProps & { + organization: Organization; +}; + +const getRateLimitValues = () => { + const steps: number[] = []; + let i = 0; + while (i <= 1_000_000) { + steps.push(i); + if (i < 10_000) { + i += 1_000; + } else if (i < 100_000) { + i += 10_000; + } else { + i += 100_000; + } + } + return steps; +}; + +// We can just generate this once +const ACCOUNT_RATE_LIMIT_VALUES = getRateLimitValues(); + +function OrganizationRateLimit({organization}: OrganizationRateLimitProps) { + // TODO(billy): Update organization.quota in organizationStore with new values + + const {quota} = organization; + const {maxRate, maxRateInterval, projectLimit, accountLimit} = quota; + const initialData = { + projectRateLimit: projectLimit || 100, + accountRateLimit: accountLimit, + }; + + return ( +
+ + + + {t('Adjust Limits')} + + + {t(`Rate limits allow you to control how much data is stored for this + organization. When a rate is exceeded the system will begin discarding + data until the next interval.`)} + + +
+ {maxRate ? ( + + + {tct( + 'Your account is limited to a maximum of [maxRate] events per [maxRateInterval] seconds.', + { + maxRate, + maxRateInterval, + } + )} + + + ) : ( + + value + ? tct('[number] per hour', { + number: value.toLocaleString(), + }) + : t('No Limit') + } + /> + )} + + value === 100 ? t('No Limit \u2014 100%') : `${value}%` + } + /> + +
+
+
+ ); +} + +export default OrganizationRateLimit; diff --git a/tests/acceptance/test_organization_rate_limits.py b/tests/acceptance/test_organization_rate_limits.py new file mode 100644 index 00000000000000..7eab70f55b42be --- /dev/null +++ b/tests/acceptance/test_organization_rate_limits.py @@ -0,0 +1,35 @@ +from unittest.mock import Mock, patch + +from django.utils import timezone + +from sentry.testutils.cases import AcceptanceTestCase +from sentry.testutils.silo import no_silo_test + + +@no_silo_test +class OrganizationRateLimitsTest(AcceptanceTestCase): + def setUp(self) -> None: + super().setUp() + self.user = self.create_user("foo@example.com") + self.org = self.create_organization(name="Rowdy Tiger", owner=None) + self.team = self.create_team(organization=self.org, name="Mariachi Band") + self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal") + self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team]) + self.login_as(self.user) + self.path = f"/organizations/{self.org.slug}/rate-limits/" + + @patch("sentry.quotas.get_maximum_quota", Mock(return_value=(100, 60))) + def test_with_rate_limits(self) -> None: + self.project.update(first_event=timezone.now()) + self.browser.get(self.path) + self.browser.wait_until_not('[data-test-id="loading-indicator"]') + self.browser.wait_until_test_id("rate-limit-editor") + assert self.browser.element_exists_by_test_id("rate-limit-editor") + + @patch("sentry.quotas.get_maximum_quota", Mock(return_value=(0, 60))) + def test_without_rate_limits(self) -> None: + self.project.update(first_event=timezone.now()) + self.browser.get(self.path) + self.browser.wait_until_not('[data-test-id="loading-indicator"]') + self.browser.wait_until_test_id("rate-limit-editor") + assert self.browser.element_exists_by_test_id("rate-limit-editor")