Skip to content

Commit bb62009

Browse files
committed
Remove mobx from quotas page
1 parent a73e658 commit bb62009

4 files changed

Lines changed: 277 additions & 131 deletions

File tree

frontend/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,5 @@ dist
158158

159159
# Gemini CLI
160160
.gemini/settings.json
161+
162+
tests/**/playwright-report/

frontend/src/components/pages/quotas/quotas-list.tsx

Lines changed: 129 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -9,165 +9,163 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12-
import { Alert, AlertIcon, Button, DataTable, Result } from '@redpanda-data/ui';
12+
import { useQuery } from '@connectrpc/connect-query';
13+
import { Alert, AlertIcon, Button, DataTable, Result, Skeleton } from '@redpanda-data/ui';
1314
import { SkipIcon } from 'components/icons';
14-
import { computed, makeObservable } from 'mobx';
15-
import { observer } from 'mobx-react';
15+
import { useMemo } from 'react';
1616

17-
import { appGlobal } from '../../../state/app-global';
18-
import { api } from '../../../state/backend-api';
19-
import { type QuotaResponseSetting, QuotaType } from '../../../state/rest-interfaces';
20-
import { toJson } from '../../../utils/json-utils';
21-
import { DefaultSkeleton, InfoText } from '../../../utils/tsx-utils';
17+
import { Quota_ValueType } from '../../../protogen/redpanda/api/dataplane/v1/quota_pb';
18+
import { listQuotas } from '../../../protogen/redpanda/api/dataplane/v1/quota-QuotaService_connectquery';
19+
import { InfoText } from '../../../utils/tsx-utils';
2220
import { prettyBytes, prettyNumber } from '../../../utils/utils';
2321
import PageContent from '../../misc/page-content';
2422
import Section from '../../misc/section';
25-
import { PageComponent, type PageInitHelper } from '../page';
2623

27-
@observer
28-
class QuotasList extends PageComponent {
29-
constructor(p: Readonly<{ matchedPath: string }>) {
30-
super(p);
31-
makeObservable(this);
32-
}
24+
const QuotasList = () => {
25+
const { data, error, isLoading } = useQuery(listQuotas, {});
3326

34-
initPage(p: PageInitHelper): void {
35-
p.title = 'Quotas';
36-
p.addBreadcrumb('Quotas', '/quotas');
27+
const quotasData = useMemo(() => {
28+
if (!data?.quotas) return [];
3729

38-
this.refreshData(true);
39-
appGlobal.onRefresh = () => this.refreshData(true);
40-
}
30+
return data.quotas.map((entry) => {
31+
const entityType = entry.entity?.entityType;
32+
const entityName = entry.entity?.entityName;
4133

42-
refreshData(force: boolean) {
43-
if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) {
44-
return;
45-
}
46-
api.refreshQuotas(force);
47-
}
34+
// Map entity type to display string
35+
let displayType: 'client-id' | 'user' | 'ip' | 'unknown' = 'unknown';
36+
if (entityType === 1) displayType = 'client-id';
37+
else if (entityType === 3) displayType = 'user';
38+
else if (entityType === 4) displayType = 'ip';
4839

49-
render() {
50-
if (api.userData !== null && api.userData !== undefined && !api.userData.canListQuotas) {
51-
return PermissionDenied;
52-
}
53-
if (api.Quotas === undefined) {
54-
return DefaultSkeleton;
55-
}
40+
return {
41+
eqKey: `${entityType}-${entityName}`,
42+
entityType: displayType,
43+
entityName: entityName || undefined,
44+
values: entry.values,
45+
};
46+
});
47+
}, [data]);
5648

57-
const warning =
58-
api.Quotas === null ? (
59-
<Alert status="warning" style={{ marginBottom: '1em' }} variant="solid">
60-
<AlertIcon />
61-
You do not have the necessary permissions to view Quotas
62-
</Alert>
63-
) : null;
49+
const formatBytes = (values: Quota_Value[], valueType: Quota_ValueType) => {
50+
const value = values.find((v) => v.valueType === valueType)?.value;
51+
return value ? (
52+
prettyBytes(value)
53+
) : (
54+
<span style={{ opacity: 0.3 }}>
55+
<SkipIcon />
56+
</span>
57+
);
58+
};
6459

65-
const resources = this.quotasList;
66-
const formatBytes = (x: undefined | number) =>
67-
x ? (
68-
prettyBytes(x)
69-
) : (
70-
<span style={{ opacity: 0.3 }}>
71-
<SkipIcon />
72-
</span>
73-
);
74-
const formatRate = (x: undefined | number) =>
75-
x ? (
76-
prettyNumber(x)
77-
) : (
78-
<span style={{ opacity: 0.3 }}>
79-
<SkipIcon />
80-
</span>
81-
);
60+
const formatRate = (values: (typeof quotasData)[0]['values'], valueType: Quota_ValueType) => {
61+
const value = values.find((v) => v.valueType === valueType)?.value;
62+
return value ? (
63+
prettyNumber(value)
64+
) : (
65+
<span style={{ opacity: 0.3 }}>
66+
<SkipIcon />
67+
</span>
68+
);
69+
};
8270

71+
if (isLoading) {
8372
return (
8473
<PageContent>
8574
<Section>
86-
{warning}
87-
88-
<DataTable<{
89-
eqKey: string;
90-
entityType: 'client-id' | 'user' | 'ip';
91-
entityName?: string | undefined;
92-
settings: QuotaResponseSetting[];
93-
}>
94-
columns={[
95-
{
96-
size: 100, // Assuming '100px' translates to '100'
97-
header: 'Type',
98-
accessorKey: 'entityType',
99-
},
100-
{
101-
size: 100, // 'auto' width replaced with an example number
102-
header: 'Name',
103-
accessorKey: 'entityName',
104-
},
105-
{
106-
size: 100,
107-
header: () => <InfoText tooltip="Limit throughput of produce requests">Producer Rate</InfoText>,
108-
accessorKey: 'producerRate',
109-
cell: ({ row: { original } }) =>
110-
formatBytes(original.settings.first((k) => k.key === QuotaType.PRODUCER_BYTE_RATE)?.value),
111-
},
112-
{
113-
size: 100,
114-
header: () => <InfoText tooltip="Limit throughput of fetch requests">Consumer Rate</InfoText>,
115-
accessorKey: 'consumerRate',
116-
cell: ({ row: { original } }) =>
117-
formatBytes(original.settings.first((k) => k.key === QuotaType.CONSUMER_BYTE_RATE)?.value),
118-
},
119-
{
120-
size: 100,
121-
header: () => (
122-
<InfoText tooltip="Limit rate of topic mutation requests, including create, add, and delete partition, in number of partitions per second">
123-
Controller Mutation Rate
124-
</InfoText>
125-
),
126-
accessorKey: 'controllerMutationRate',
127-
cell: ({ row: { original } }) =>
128-
formatRate(original.settings.first((k) => k.key === QuotaType.CONTROLLER_MUTATION_RATE)?.value),
129-
},
130-
]}
131-
data={resources}
132-
/>
75+
<Skeleton height="400px" />
13376
</Section>
13477
</PageContent>
13578
);
13679
}
13780

138-
@computed get quotasList() {
139-
const quotaResponse = api.Quotas;
140-
if (!quotaResponse || quotaResponse.error) {
141-
return [];
81+
if (error) {
82+
const isPermissionError = error.message.includes('permission') || error.message.includes('forbidden');
83+
84+
if (isPermissionError) {
85+
return (
86+
<PageContent>
87+
<Section>
88+
<Result
89+
extra={
90+
<a href="https://docs.redpanda.com/docs/manage/console/" rel="noopener noreferrer" target="_blank">
91+
<Button variant="solid">Redpanda Console documentation for roles and permissions</Button>
92+
</a>
93+
}
94+
status={403}
95+
title="Forbidden"
96+
userMessage={
97+
<p>
98+
You are not allowed to view this page.
99+
<br />
100+
Contact the administrator if you think this is an error.
101+
</p>
102+
}
103+
/>
104+
</Section>
105+
</PageContent>
106+
);
142107
}
143108

144-
return quotaResponse.items.map((x) => ({ ...x, eqKey: toJson(x) }));
109+
return (
110+
<PageContent>
111+
<Section>
112+
<Alert status="warning" style={{ marginBottom: '1em' }} variant="solid">
113+
<AlertIcon />
114+
{error.message || 'Failed to load quotas'}
115+
</Alert>
116+
</Section>
117+
</PageContent>
118+
);
145119
}
146-
}
147120

148-
const PermissionDenied = (
149-
<>
150-
<PageContent key="quotasNoPerms">
121+
return (
122+
<PageContent>
151123
<Section>
152-
<Result
153-
extra={
154-
<a href="https://docs.redpanda.com/docs/manage/console/" rel="noopener noreferrer" target="_blank">
155-
<Button variant="solid">Redpanda Console documentation for roles and permissions</Button>
156-
</a>
157-
}
158-
status={403}
159-
title="Forbidden"
160-
userMessage={
161-
<p>
162-
You are not allowed to view this page.
163-
<br />
164-
Contact the administrator if you think this is an error.
165-
</p>
166-
}
124+
<DataTable<{
125+
eqKey: string;
126+
entityType: 'client-id' | 'user' | 'ip' | 'unknown';
127+
entityName?: string | undefined;
128+
values: Array<{ valueType: Quota_ValueType; value: number }>;
129+
}>
130+
columns={[
131+
{
132+
size: 100,
133+
header: 'Type',
134+
accessorKey: 'entityType',
135+
},
136+
{
137+
size: 100,
138+
header: 'Name',
139+
accessorKey: 'entityName',
140+
},
141+
{
142+
size: 100,
143+
header: () => <InfoText tooltip="Limit throughput of produce requests">Producer Rate</InfoText>,
144+
accessorKey: 'producerRate',
145+
cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.PRODUCER_BYTE_RATE),
146+
},
147+
{
148+
size: 100,
149+
header: () => <InfoText tooltip="Limit throughput of fetch requests">Consumer Rate</InfoText>,
150+
accessorKey: 'consumerRate',
151+
cell: ({ row: { original } }) => formatBytes(original.values, Quota_ValueType.CONSUMER_BYTE_RATE),
152+
},
153+
{
154+
size: 100,
155+
header: () => (
156+
<InfoText tooltip="Limit rate of topic mutation requests, including create, add, and delete partition, in number of partitions per second">
157+
Controller Mutation Rate
158+
</InfoText>
159+
),
160+
accessorKey: 'controllerMutationRate',
161+
cell: ({ row: { original } }) => formatRate(original.values, Quota_ValueType.CONTROLLER_MUTATION_RATE),
162+
},
163+
]}
164+
data={quotasData}
167165
/>
168166
</Section>
169167
</PageContent>
170-
</>
171-
);
168+
);
169+
};
172170

173171
export default QuotasList;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { createClientIdQuota, deleteClientIdQuota } from '../../shared/quota.utils';
4+
import { QuotaPage } from '../utils/quota-page';
5+
6+
const QUOTAS_TEST_LIMIT = 50;
7+
8+
test.describe('Quotas - Display 50 quotas', () => {
9+
test(`should create ${QUOTAS_TEST_LIMIT} quotas and verify all are visible on the page`, async ({ page }) => {
10+
const quotaPage = new QuotaPage(page);
11+
const timestamp = Date.now();
12+
const quotaIds: string[] = [];
13+
14+
await test.step(`Create ${QUOTAS_TEST_LIMIT} quotas using RPK`, async () => {
15+
for (let i = 1; i <= QUOTAS_TEST_LIMIT; i++) {
16+
const quotaClientId = `quota-test-${timestamp}-${i.toString().padStart(3, '0')}`;
17+
quotaIds.push(quotaClientId);
18+
19+
await createClientIdQuota({
20+
clientId: quotaClientId,
21+
producerByteRate: 1_048_576 * i, // 1MB * i
22+
});
23+
}
24+
});
25+
26+
await test.step('Navigate to quotas page', async () => {
27+
await quotaPage.goToQuotasList();
28+
});
29+
30+
await test.step(`Verify all ${QUOTAS_TEST_LIMIT} quotas are visible on the page`, async () => {
31+
// Count rows containing our test quota IDs
32+
const visibleQuotaCount = await page
33+
.locator('tr')
34+
.filter({ hasText: `quota-test-${timestamp}` })
35+
.count();
36+
37+
// Verify all quotas are visible
38+
expect(visibleQuotaCount).toBe(QUOTAS_TEST_LIMIT);
39+
40+
// Verify a few specific quotas by name to ensure they're actually rendered
41+
await quotaPage.verifyQuotaExists(quotaIds[0]); // First quota
42+
await quotaPage.verifyQuotaExists(quotaIds[Math.floor(QUOTAS_TEST_LIMIT / 2)]); // Middle quota
43+
await quotaPage.verifyQuotaExists(quotaIds[QUOTAS_TEST_LIMIT - 1]); // Last quota
44+
});
45+
46+
await test.step('Cleanup: Delete all test quotas', async () => {
47+
for (const quotaId of quotaIds) {
48+
await deleteClientIdQuota(quotaId);
49+
}
50+
});
51+
52+
await test.step('Verify quotas are removed from UI', async () => {
53+
await quotaPage.reloadPage();
54+
55+
// Verify no test quotas remain
56+
const remainingTestQuotas = await page
57+
.locator('tr')
58+
.filter({ hasText: `quota-test-${timestamp}` })
59+
.count();
60+
expect(remainingTestQuotas).toBe(0);
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)