Skip to content

Commit ac782e4

Browse files
Revert "chore(rate-limit): Remove legacy rate limit pages " (#98441)
Reverts #96354 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent cc5fcc0 commit ac782e4

File tree

7 files changed

+318
-0
lines changed

7 files changed

+318
-0
lines changed

static/app/routes.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,12 @@ function buildRoutes(): RouteObject[] {
967967
},
968968
],
969969
},
970+
{
971+
path: 'rate-limits/',
972+
name: t('Rate Limits'),
973+
component: make(() => import('sentry/views/settings/organizationRateLimits')),
974+
deprecatedRouteProps: true,
975+
},
970976
{
971977
path: 'relay/',
972978
name: t('Relay'),

static/app/views/settings/organization/navigationConfiguration.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ export function getOrganizationNavigationConfiguration({
8989
description: t('View the audit log for an organization'),
9090
id: 'audit-log',
9191
},
92+
{
93+
path: `${organizationSettingsPathPrefix}/rate-limits/`,
94+
title: t('Rate Limits'),
95+
show: ({features}) => features!.has('legacy-rate-limits'),
96+
description: t('Configure rate limits for all projects in the organization'),
97+
id: 'rate-limits',
98+
},
9299
{
93100
path: `${organizationSettingsPathPrefix}/relay/`,
94101
title: t('Relay'),

static/app/views/settings/organization/userOrgNavigationConfiguration.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] {
126126
description: t('View the audit log for an organization'),
127127
id: 'audit-log',
128128
},
129+
{
130+
path: `${organizationSettingsPathPrefix}/rate-limits/`,
131+
title: t('Rate Limits'),
132+
show: ({features}) => features?.has('legacy-rate-limits') ?? false,
133+
description: t('Configure rate limits for all projects in the organization'),
134+
id: 'rate-limits',
135+
},
129136
{
130137
path: `${organizationSettingsPathPrefix}/relay/`,
131138
title: t('Relay'),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import withOrganization from 'sentry/utils/withOrganization';
2+
import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert';
3+
4+
import OrganizationRateLimits from './organizationRateLimits';
5+
6+
function OrganizationRateLimitsContainer(
7+
props: React.ComponentProps<typeof OrganizationRateLimits>
8+
) {
9+
if (!props.organization) {
10+
return null;
11+
}
12+
13+
return props.organization.access.includes('org:write') ? (
14+
<OrganizationRateLimits {...props} />
15+
) : (
16+
<OrganizationPermissionAlert />
17+
);
18+
}
19+
20+
export default withOrganization(OrganizationRateLimitsContainer);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
3+
4+
import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
5+
6+
import type {OrganizationRateLimitProps} from 'sentry/views/settings/organizationRateLimits/organizationRateLimits';
7+
import OrganizationRateLimits from 'sentry/views/settings/organizationRateLimits/organizationRateLimits';
8+
9+
const ENDPOINT = '/organizations/org-slug/';
10+
11+
describe('Organization Rate Limits', () => {
12+
const organization = OrganizationFixture({
13+
quota: {
14+
projectLimit: 75,
15+
accountLimit: 70000,
16+
maxRate: null,
17+
maxRateInterval: null,
18+
},
19+
});
20+
21+
const renderComponent = (props?: Partial<OrganizationRateLimitProps>) =>
22+
render(
23+
<OrganizationRateLimits
24+
{...RouteComponentPropsFixture()}
25+
organization={organization}
26+
{...props}
27+
/>
28+
);
29+
30+
beforeEach(() => {
31+
MockApiClient.clearMockResponses();
32+
});
33+
34+
it('renders with initialData', () => {
35+
renderComponent();
36+
37+
// XXX: Slider input values are associated to their step value
38+
// Step 16 is 70000
39+
expect(screen.getByRole('slider', {name: 'Account Limit'})).toHaveValue('16');
40+
expect(screen.getByRole('slider', {name: 'Per-Project Limit'})).toHaveValue('75');
41+
});
42+
43+
it('renders with maxRate and maxRateInterval set', () => {
44+
const org = OrganizationFixture({
45+
...organization,
46+
quota: {
47+
maxRate: 100,
48+
maxRateInterval: 60,
49+
projectLimit: null,
50+
accountLimit: null,
51+
},
52+
});
53+
54+
renderComponent({organization: org});
55+
56+
expect(screen.getByRole('slider')).toBeInTheDocument();
57+
});
58+
59+
it('can change Account Rate Limit', async () => {
60+
const mock = MockApiClient.addMockResponse({
61+
url: ENDPOINT,
62+
method: 'PUT',
63+
statusCode: 200,
64+
});
65+
66+
renderComponent();
67+
68+
expect(mock).not.toHaveBeenCalled();
69+
70+
// Change Account Limit
71+
act(() => screen.getByRole('slider', {name: 'Account Limit'}).focus());
72+
await userEvent.keyboard('{ArrowLeft>5}');
73+
await userEvent.tab();
74+
75+
expect(mock).toHaveBeenCalledWith(
76+
ENDPOINT,
77+
expect.objectContaining({
78+
method: 'PUT',
79+
data: {
80+
accountRateLimit: 20000,
81+
},
82+
})
83+
);
84+
});
85+
86+
it('can change Project Rate Limit', async () => {
87+
const mock = MockApiClient.addMockResponse({
88+
url: ENDPOINT,
89+
method: 'PUT',
90+
statusCode: 200,
91+
});
92+
93+
renderComponent();
94+
95+
expect(mock).not.toHaveBeenCalled();
96+
97+
// Change Project Rate Limit
98+
act(() => screen.getByRole('slider', {name: 'Per-Project Limit'}).focus());
99+
await userEvent.keyboard('{ArrowRight>5}');
100+
await userEvent.tab();
101+
102+
expect(mock).toHaveBeenCalledWith(
103+
ENDPOINT,
104+
expect.objectContaining({
105+
method: 'PUT',
106+
data: {
107+
projectRateLimit: 100,
108+
},
109+
})
110+
);
111+
});
112+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {css} from '@emotion/react';
2+
3+
import FieldGroup from 'sentry/components/forms/fieldGroup';
4+
import RangeField from 'sentry/components/forms/fields/rangeField';
5+
import Form from 'sentry/components/forms/form';
6+
import Panel from 'sentry/components/panels/panel';
7+
import PanelAlert from 'sentry/components/panels/panelAlert';
8+
import PanelBody from 'sentry/components/panels/panelBody';
9+
import PanelHeader from 'sentry/components/panels/panelHeader';
10+
import {t, tct} from 'sentry/locale';
11+
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
12+
import type {Organization} from 'sentry/types/organization';
13+
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
14+
import TextBlock from 'sentry/views/settings/components/text/textBlock';
15+
16+
export type OrganizationRateLimitProps = RouteComponentProps & {
17+
organization: Organization;
18+
};
19+
20+
const getRateLimitValues = () => {
21+
const steps: number[] = [];
22+
let i = 0;
23+
while (i <= 1_000_000) {
24+
steps.push(i);
25+
if (i < 10_000) {
26+
i += 1_000;
27+
} else if (i < 100_000) {
28+
i += 10_000;
29+
} else {
30+
i += 100_000;
31+
}
32+
}
33+
return steps;
34+
};
35+
36+
// We can just generate this once
37+
const ACCOUNT_RATE_LIMIT_VALUES = getRateLimitValues();
38+
39+
function OrganizationRateLimit({organization}: OrganizationRateLimitProps) {
40+
// TODO(billy): Update organization.quota in organizationStore with new values
41+
42+
const {quota} = organization;
43+
const {maxRate, maxRateInterval, projectLimit, accountLimit} = quota;
44+
const initialData = {
45+
projectRateLimit: projectLimit || 100,
46+
accountRateLimit: accountLimit,
47+
};
48+
49+
return (
50+
<div>
51+
<SettingsPageHeader title={t('Rate Limits')} />
52+
53+
<Panel>
54+
<PanelHeader>{t('Adjust Limits')}</PanelHeader>
55+
<PanelBody>
56+
<PanelAlert type="info">
57+
{t(`Rate limits allow you to control how much data is stored for this
58+
organization. When a rate is exceeded the system will begin discarding
59+
data until the next interval.`)}
60+
</PanelAlert>
61+
62+
<Form
63+
data-test-id="rate-limit-editor"
64+
saveOnBlur
65+
allowUndo
66+
apiMethod="PUT"
67+
apiEndpoint={`/organizations/${organization.slug}/`}
68+
initialData={initialData}
69+
>
70+
{maxRate ? (
71+
<FieldGroup
72+
label={t('Account Limit')}
73+
help={t(
74+
'The maximum number of events to accept across this entire organization.'
75+
)}
76+
>
77+
<TextBlock
78+
css={css`
79+
margin-bottom: 0;
80+
`}
81+
>
82+
{tct(
83+
'Your account is limited to a maximum of [maxRate] events per [maxRateInterval] seconds.',
84+
{
85+
maxRate,
86+
maxRateInterval,
87+
}
88+
)}
89+
</TextBlock>
90+
</FieldGroup>
91+
) : (
92+
<RangeField
93+
name="accountRateLimit"
94+
label={t('Account Limit')}
95+
min={0}
96+
max={1000000}
97+
allowedValues={ACCOUNT_RATE_LIMIT_VALUES}
98+
help={t(
99+
'The maximum number of events to accept across this entire organization.'
100+
)}
101+
placeholder="e.g. 500"
102+
formatLabel={value =>
103+
value
104+
? tct('[number] per hour', {
105+
number: value.toLocaleString(),
106+
})
107+
: t('No Limit')
108+
}
109+
/>
110+
)}
111+
<RangeField
112+
name="projectRateLimit"
113+
label={t('Per-Project Limit')}
114+
help={t(
115+
'The maximum percentage of the account limit (set above) that an individual project can consume.'
116+
)}
117+
step={5}
118+
min={50}
119+
max={100}
120+
formatLabel={value =>
121+
value === 100 ? t('No Limit \u2014 100%') : `${value}%`
122+
}
123+
/>
124+
</Form>
125+
</PanelBody>
126+
</Panel>
127+
</div>
128+
);
129+
}
130+
131+
export default OrganizationRateLimit;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from unittest.mock import Mock, patch
2+
3+
from django.utils import timezone
4+
5+
from sentry.testutils.cases import AcceptanceTestCase
6+
from sentry.testutils.silo import no_silo_test
7+
8+
9+
@no_silo_test
10+
class OrganizationRateLimitsTest(AcceptanceTestCase):
11+
def setUp(self) -> None:
12+
super().setUp()
13+
self.user = self.create_user("[email protected]")
14+
self.org = self.create_organization(name="Rowdy Tiger", owner=None)
15+
self.team = self.create_team(organization=self.org, name="Mariachi Band")
16+
self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal")
17+
self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team])
18+
self.login_as(self.user)
19+
self.path = f"/organizations/{self.org.slug}/rate-limits/"
20+
21+
@patch("sentry.quotas.get_maximum_quota", Mock(return_value=(100, 60)))
22+
def test_with_rate_limits(self) -> None:
23+
self.project.update(first_event=timezone.now())
24+
self.browser.get(self.path)
25+
self.browser.wait_until_not('[data-test-id="loading-indicator"]')
26+
self.browser.wait_until_test_id("rate-limit-editor")
27+
assert self.browser.element_exists_by_test_id("rate-limit-editor")
28+
29+
@patch("sentry.quotas.get_maximum_quota", Mock(return_value=(0, 60)))
30+
def test_without_rate_limits(self) -> None:
31+
self.project.update(first_event=timezone.now())
32+
self.browser.get(self.path)
33+
self.browser.wait_until_not('[data-test-id="loading-indicator"]')
34+
self.browser.wait_until_test_id("rate-limit-editor")
35+
assert self.browser.element_exists_by_test_id("rate-limit-editor")

0 commit comments

Comments
 (0)